[Nest.js V9] 정리 노트 - 7

Entity와 Entity인스턴스 이용시 내부 기능

Intro


시작


유저 도메인을 다시 한번 살펴봅시다.

  1. 이메일 패스워드를 요청 데이터를 받습니다. 이때 ValidationPipe를 통해 전달받은 Dto 인스턴스의 유효성을 검사합니다.
  2. 유효하면 UserController로 전달됩니다. 그후 비즈니스 로직을 수행하기 위해 UserService에게 요청 데이터를 인자로 보내 호출합니다.
  3. UserService는 최종적으로 UserRepository를 호출해 DTO인스턴스를 이용해 SQLite DB에 데이터를 저장합니다.
  4. 역순으로 돌아가 사용자에게 응답 메시지를 보냅니다.

데이터를 저장할 UserRepository 코드는 다음과 같습니다.

ts
@Injectable()
export class UsersService {
  constructor(@InjectRepository(User) private repo: Repository<User>) {
    // User 엔티티에 대한 Repository를 주입받아서 repo 변수에 할당하는 생성자 함수입니다.
  }

  create(email: string, password: string) {
    // user 인스턴스를 새로 생성합니다. 만일 user.entity 안에 IsString 등 유효성 메타데이터가 있다면
    // 인스턴스 생성시 유효성 검사를 수행합니다.
    const user = this.repo.create({email, password})

    // user 인스턴스를 저장합니다.
    return this.repo.save(user);

    // 인스턴스를 사용하지 않고 Dto 자체를 저장할 수도 있습니다.
    // return this.repo.save({...user});
  }
}

후크로 로그 출력하기


데이터 저장/수정/삭제시 일종의 트리거 작동으로 로그를 기록할 수 있습니다.

AfterInsert를 이용합니다.

User엔티티를 기준으로 저장/수정/삭제 시 로그가 출력되도록 entity파일을 수정합니다.

ts
// user.entity.ts
import { AfterInsert, AfterRemove, AfterUpdate, Entity, Column, PrimaryGeneratedColumn } from "typeorm";

@Entity()
export class User {
  // 사용자 속성

  // PK
  @PrimaryGeneratedColumn()
  id: number;

  @Column()
  email: string;

  @Column()
  password: string;

  /* Hook */
  // Insert 시점에 작동
  @AfterInsert()
  logInsert() {
    console.log('Inserted User with this.id', this.id)
  }

  // Update 시점에 작동
  @AfterUpdate()
  logUopdate() {
    console.log('Updated User with id', this.id)
  }

  // Delete 시점에 작동
  @AfterRemove()
  logRemove() {
    console.log('Removed User with id', this.id)
  }
} 

다만 이때 주의할점이 있는데, 데이터 저장/수정/삭제시 반드시 user 인스턴스를 생성하여 저장/수정/삭제 해주어야 한다는 점입니다. DTO 객체를 이용해 저장/수정/삭제 하려고 하면 후크는 동작하지 않습니다.

ts
@Injectable()
export class UsersService {
  constructor(@InjectRepository(User) private repo: Repository<User>) {
    // User 엔티티에 대한 Repository를 주입받아서 repo 변수에 할당하는 생성자 함수입니다.
  }

  create(email: string, password: string) {
    // user 인스턴스를 새로 생성합니다. 만일 user.entity 안에 IsString 등 유효성 메타데이터가 있다면
    // 인스턴스 생성시 유효성 검사를 수행합니다.
    const user = this.repo.create({email, password})

    // 후크가 인식해 작동한다.
    return this.repo.save(user);

    // 후크가 작동하지 않음.
    return this.repo.save({...user});
  }
}

동일하게 user 인스턴스를 가져와 일부 정보만 수정해봅시다.

ts
async update(id: number, attrs: Partial<User>) {
  // Partial<User>은 해당 클래스의 필드 무엇이든 받을 수 있는 인자이다. 해당 클래스에 존재하지 않는 필드를 인자로 넘길 경우 에러 발생

  // 유저 인스턴스를 가져옵니다.
  const user = await this.findOne(id);
  
  if(!user) {
    throw new Error('user not found');
  }

  // user 인스턴스에 변경된 부분만을 붙여넣어 재정의합니다.
  Object.assign(user, attrs);

  return this.repo.save(user);
}

이어서 삭제 메서드입니다.

ts
async remove(id: number) {
  const user = await this.findOne(id);

  if(!user) {
    throw new Error('user not found');
  }

  return this.repo.remove(user);
}

공통점을 보자면 후크를 사용할 경우 최소 데이터베이스에 2번의 접근이 필요하다는 점입니다. 만일 후크를 사용하지 않고 데이터베이스에 1번의 접근만을 수행할 것이라면 객체로 수정/삭제 해야합니다. 다만 수정/삭제 이전에 실제로 해당 정보가 존재해야 하는지 유무를 검사해야 하기 때문에 2번의 접근이 일어나는 것은 지극히 정상적이라고 볼 수 있습니다.

TypeORM 말고도 다른 ORM(MikroORM 등등..) 또한 동일한 기능이 대부분 내제되어 있습니다. MikroORM의 경우 AfterInsert대신 AfterCreate가 존재하죠. 때문에 다른 ORM을 사용할지라도 대부분의 컨셉은 비슷하니 변경점만 확인해 적용하면 됩니다.

UsersServiceupdate를 사용하기 적합한 컨트롤러와 요청 DTO를 이어서 생성합니다.

요청 DTO입니다.

ts
// update-user.dto.ts

import { IsEmail, IsOptional, IsString } from "class-validator";

export class UpdateUserDto {
  @IsEmail()
  @IsOptional()
  email: string;

  @IsString()
  @IsOptional()
  password: string;
}

컨트롤러 입니다.

ts
// users.controller.ts

@Patch('/:id')
updateUser(@Param('id') id: string, @Body() body: UpdateUserDto) {
  return this.usersService.update(parseInt(id), body);
}

예외에 따른 에러 개선하기


서비스 단에서 수정/삭제할 유저가 존재하지 않는 경우 new Error('not found user')와 같이 에러를 발생시키고 있죠. 단순히 자바스크립트 에러일뿐입니다. 현재 HTTP프로토콜을 이용하기 때문에 이는 적합한 에러 처리가 아닙니다.

다음과 같이 변경해줍니다.

ts
async update(id: number, attrs: Partial<User>) {
  // Partial<User>은 해당 클래스의 필드 무엇이든 받을 수 있는 인자이다. 해당 클래스에 존재하지 않는 필드를 인자로 넘길 경우 에러 발생

  // 유저 인스턴스를 가져옵니다.
  const user = await this.findOne(id);
  
  if(!user) {
    throw new NotFoundException();
  }

  // user 인스턴스에 변경된 부분만을 붙여넣어 재정의합니다.
  Object.assign(user, attrs);

  return this.repo.save(user);
}

async remove(id: number) {
  const user = await this.findOne(id);

  if(!user) {
    throw new NotFoundException();
  }

  return this.repo.remove(user);
}

NotFoundException의 경우 HTTP프로토콜에 부합하는 예외 케이스 입니다. nestjs/common 패키지내 존재하죠. HTTP 프로토콜 외에도 Websoket, gRPC 프로토콜이 존재할 수 있습니다. 각 프로토콜 통신마다 적합한 예외 케이스를 적용해야 하니 유의해야 합니다.