[Nest.js] DTO 를 Entity 로 변환하기 (DTO to Entity, feat. class-validator)

정지현·2022년 11월 1일
5

본 포스팅은 Nest.js 를 이제 막 배워보기 시작한 스프링을 하다 온 백엔드 개발자의 개인적인 구현 방법(?)과 경험을 다룬 글로서, 참고용으로만 열람해주시기 바랍니다. 🙏

개요

Nest.js 의 도큐먼트를 통해 이것저것 공부하고 있다. 때마침 TypeORM 을 적용하고, Nest CLI 를 통해 리소스를 생성하고, Query Runner 를 활용한 Transaction 을 적용하려는 찰나! 다음과 같은 코드를 마주하게 되었다.

  async create(createExampleDto: CreateExampleDto) {
    const queryRunner = this.dataSource.createQueryRunner();

    await queryRunner.connect();
    await queryRunner.startTransaction();

    try {
      await queryRunner.manager.save(); // 바로 이 코드!
      await queryRunner.commitTransaction();

      return null;
    } catch (err) {
      console.log(err);
      await queryRunner.rollbackTransaction();
    } finally {
      await queryRunner.release();
    }
  }

상기 코드에서 await queryRunner.manager.save(); 이 부분인데, queryRunner 에서 EntityManager 를 통해 save() 를 호출할 때, 이 메소드에 들어가야하는 인자는 다름아닌 엔티티였다.

생각해보니, 기존 레포지토리 패턴을 통해 save() 메소드를 호출 했을 때는 DeepPartial 이라서 DTO 를 바로 때려 박아도 잘 동작했었는데, 이번에는 엔티티를 넣어야하는 것이었다!

물론 Nest 를 잘 이해하고 계시는 분들은 해결 방법을 알고 계시겠지만, 나는 이러한 상황에서 다음과 같은 생각을 해냈다.

  1. 트랜잭션은 그냥 무시하고 기존 레포지토리 패턴으로 사용하자. 어차피 간단히 만드는 토이플젝이잖아? (상당히 개발자스럽지 않다.)
  2. try 문 안에 await exampleRepository().manager.save() 를 쓰면 된다는데 이걸로 해볼까? (결론은 트랜잭션 적용이 안 되는 것 같다 ㅜ)
  3. 스프링에서 했던 것처럼 DTO 에서 toEntity() 메소드를 만들어서 해결해볼까..?

이렇게 해서 결론은 3번으로 진행해보기로 결정했다. 생각해보면 전혀 다른 프레임워크에 스프링에서 볼법한 패턴(?)을 적용해보려고 하는건데, 다른 사람들이 보면 "아니 이렇게 하면 되는건데 얘 뭐하냐.." 라고 생각할 수도 있겠다.

이름하야 DTO to Entity 이다. 두둥...

아무튼, 구현을 하려다가 매우 큰 문제를 겪었는데, 결론부터 말하자면 역직렬화 문제였다. 아래에서 내가 하려고 했던 방식을 설명하며 문제를 찾아보도록 하겠다.

DTO to Entity

잠시 과거 회상을 하자면, 스프링에서도 JPA 와 같은 ORM 을 활용하여 데이터베이스에 데이터를 저장한다.

역시 동일하게, 스프링에서도 Body 를 통해 데이터를 전달 받을 때는 DTO 를 통해 클라이언트로부터 데이터를 전달받는다. 이때 데이터를 저장하기 위해서는 엔티티를 생성하여 JPA 에 넘겨주어야한다. 엔티티를 생성하기 위해서 전달받은 DTO 의 프로퍼티를 통해 엔티티를 직접 생성하거나, 혹은 DTO 를 엔티티에 투과시켜서 새로운 엔티티 객체를 생성하는 것은 이 글에 따르면 캡슐화의 측면에서도 문제가 있고, 엔티티가 DTO 에 의존하게 되는 문제점도 있다고 한다.

따라서 나는 기존 스프링에서 개발할 때는 데이터베이스에 저장되어야하는 엔티티를 생성하기 위해 DTO 내에 toEntity() 라는 특별한 메소드를 구현하여 DTO 내에 존재하는 프로퍼티를 바로 엔티티로 변환하여 사용하곤 했다. 내가 스프링에서 사용한 로직은 다음과 같았다.

@Getter
@ToString
@NoArgsConstructor
public class ExampleSaveRequestDto implements Serializable {

    private static final long serialVersionUID = 1L;

    private String title;

	// 이 부분!
    public Example toEntity() {
        return Example.builder()
                .title(title)
                .build();
    }

}

이 코드는 전달받은 title 프로퍼티를 toEntity() 메소드 내에서 Example 객체를 생성하여 반환한다. 그렇다면 이런 패턴을 Nest.js 에서도 적용해보자!

이를 위해 두 가지의 스텝이 필요하다.

  1. 엔티티 객체 내에 파라미터를 전달받아 객체를 초기화 할 수 있는 생성자를 만든다.
  2. DTO 내에 엔티티 객체를 초기화하여 반환할 수 있는 toEntity() 메소드를 생성한다.

간단해보인다. 그럼 구현해본다.

example.entity.ts

@Entity('tb_example')
export class Example {
  // 생성자 초기화
  constructor(title?: string, content?: string) {
    this.title = title;
    this.content = content;
  }

  @PrimaryGeneratedColumn()
  private id: number;

  @Column({ nullable: true })
  private title: string;

  @Column({ nullable: true })
  private content: string;
}

create-example.dto.ts

export class CreateExampleDto {
  private title: string;
  private content: string;

  // toEntity 구현
  toEntity() {
    return new Example(this.title, this.content);
  }
}

자, 이제 DTO 를 활용해서 바로 엔티티로 변환할 수 있는 로직이 완성되었다! 이제 이 글의 초반에서 말한 await queryRunner.manager.save()await queryRunner.manager.save(createExampleDto.toEntity()) 와 같이 엔티티로 변환해서 넣어주면 될 것 같다!

그러나 현실은 녹록지 않았습니다.

기대에 부풀어 API 를 호출해봤는데..

TypeError: createExampleDto.toEntity is not a function

아니 내가 만든 것은 함수가 아니었단 말인가? 분명 DTO 클래스가 있고, 그 안에 메소드를 구현한건데 왜 함수가 아니라는건지 이해를 할 수가 없었다.

자료를 찾아보니, 이에 대한 해답(?)이 적인 스택오버플로우 글을 찾을 수 있었다.

The fact that the dto is declared like this dto: ClientDTO in the controller is not enough to create instances of the class. - Guerric P

Your dto was not a class-based object when coming in through your api call-- it's just a generic object. Therefore it can't have methods and so your toEntity method won't work.
- glitchbane

정리하자면 컨트롤러에서 @Body() 데코레이터를 통해 전달받는 DTO 는 그 자체로 인스턴스화되어있다고 보기 어렵다는 것이다.

아니 그러면 대체 이 DTO 는 누구란말인가? 직접 출력해보았다.

음.. 일단 object 인 걸로 봐서는 객체는 맞긴 한가보다.

공식 도큐먼트를 찾아보니, 다음 문구가 있었다.

Payloads coming in over the network are plain JavaScript objects.

즉, 컨트롤러를 통해서 들어오는 페이로드는 사실 그냥 순수 자바스크립트 객체에 불과했던 것이다!

자 다시 돌아와서, 그렇다면 내가 해야할 일은 클라이언트로부터 넘어온 요청 데이터를 역직렬화해서 이쁘게 DTO 클래스에 맞는 객체를 생성해주는 일이다.

JSON 역직렬화하기

찾아보니, JSON 직렬화와 역직렬화를 위해서는 class-transformerclass-validator 패키지가 필요하다고 한다. 설치해주도록 한다.

$ npm install class-validator class-transformer

원래 class-validator 는 Pipe 로서, 클라이언트로부터 들어오는 Request 데이터의 검증을 담당한다고 한다. 이와 관련한 공식 도큐먼트 글을 살펴보면 좋을 것 같다.

The ValidationPipe can automatically transform payloads to be objects typed according to their DTO classes. To enable auto-transformation, set transform to true.

여기에 한 가지 주목할만한 점이 있는데, ValidationPipe 는 자동으로 페이로드를 해당 타입에 맞는 DTO 클래스로 변환해준다고 한다. 이때, 이 기능을 활성화 하기 위해서 사용하는 특별한 옵션이 있다고 하는데, 그 옵션이 바로...

// 공식 도큐먼트에서 발췌한 코드이다!
@Post()
@UsePipes(new ValidationPipe({ transform: true }))
async create(@Body() createCatDto: CreateCatDto) {
  this.catsService.create(createCatDto);
}

transform: true 옵션이다. 상기 코드에서는 메소드 스코프에서 Pipe 를 적용했는데, Pipe 와 관련한 내용은 공식 도큐먼트를 확인해본다.

다시 돌아와서, 나는 모든 컨트롤러로 들어오는 페이로드를 해당 DTO 클래스에 맞게 변환하고 싶으므로, 메소드 스코프가 아닌 글로벌 스코프로 해당 Pipe 를 적용할 것이다.

main.ts

async function bootstrap() {
  const app = await NestFactory.create(AppModule);
  app.useGlobalPipes(new ValidationPipe({ transform: true })); // 글로벌 스코프 적용
  await app.listen(8080);
}

bootstrap();

자, 이제 역직렬화가 정상적으로 수행되는지 확인해보자! 맨 처음에 작성한 로직으로 돌아와서, 다시 createExampleDto 를 출력해보자.

// 비즈니스 로직 생략...
console.log(typeof createExampleDto);
console.log(createExampleDto);
// 비즈니스 로직 생략...

드디어.. DTO 객체로 변환이 완료되었다. 그럼 이제 toEntity() 메소드를 호출해보자.

// 비즈니스 로직 생략...
console.log(typeof createExampleDto);
console.log(createExampleDto);
console.log(createExampleDto.toEntity());
// 비즈니스 로직 생략...

아아.. 영롱하다... 그럼 이제 해당 API 를 호출해보자.

  async create(createExampleDto: CreateExampleDto) {
    const queryRunner = this.dataSource.createQueryRunner();

    await queryRunner.connect();
    await queryRunner.startTransaction();

    try {
      // 이제 실행할 수 있다....
      await queryRunner.manager.save(createExampleDto.toEntity());
      await queryRunner.commitTransaction();

      return null;
    } catch (err) {
      console.log(err);
      await queryRunner.rollbackTransaction();
    } finally {
      await queryRunner.release();
    }
  }

데이터가 잘 들어간다 ㅜㅜ 일부러 commitTransaction() 메소드 호출 이전에 예외로 발생시켜봤는데, 트랜잭션도 잘 먹는다...

내가 Nest.js 의 생태계를 잘 몰라서 엄청 간단하게 해결할 수 있는 방법이 있는데 먼 길을 돌아간건지는 잘 모르겠다. 아니면 이 방법이 일반적인 방법인건가..? 인건지는 잘 모르겠지만, 새로운 방법을 알기 전 까지는 스프링에서 사용했던 이 방법을 적용하면서 개발할 것 같다.

개발하다가 이 방법을 적용해서 문제가 발생하면 이 글을 지속적으로 업데이트하던가, 따로 포스팅을 진행해야할 것 같다.

끗.

profile
나를 성장시키는 좌절에 감사하고 즐기려고 노력 중

0개의 댓글