ABOUT ME

-

Today
-
Yesterday
-
Total
-
  • [TypeORM] QueryBuilder 사용시 return 타입 구체화하기
    Javascript, Typescript 2022. 2. 10. 17:28
    반응형

    QueryBuilder의 getOne(), getMany()를 이용해서 데이터를 조회할 때 리턴되는 객체의 타입은 항상 엔티티 클래스의 인스턴스이다. 그렇기 때문에 특정 컬럼들만 가져오는 경우, 잠재적인 문제가 일어날 수 있다.

    반환되는 객체가 엔티티의 인스턴스인 경우 일어날 수 있는 문제는 무엇이고 이것을 어떻게 해결할 수 있는지 알아보자.

    가능한 문제상황

    데이터베이스에 게시글(Post)이 등록되어있고, 이 게시글들의 목록을 조회하는 API를 개발해야한다. 이때, 게시글의 목록에서는 게시글의 내용(content)은 가져올 필요가 없다.

    위와같은 요구사항이 있고, Post 테이블의 스키마는 다음과 같다. 데이터는 3개만 넣어놓았다.

    다음과 같이 Entity를 정의한다.

    @Entity()
    export class Post {
      @PrimaryGeneratedColumn()
      id: number;
      
      @Column({ length: 255 })
      title: string;
      
      @Column({ type: 'text' })
      content: string;
    
      @CreateDateColumn({
        type: 'timestamp with time zone',
        name: 'created_at',
      })
      createdAt: Date;
    }

    뷰에서도, 서비스 로직에서도 사용되지 않는 content를 데이터베이스에서 가져오는 것은 필요없는 I/O인데다가, content라는 한 컬럼 자체의 용량이 크기 때문에 병목이 일어날 수 있다.

    PostRepository라는 Custom Repository를 만들어서 QueryBuilder를 이용해 content를 제외한 Post의 id, title, createdAt을 가져오는 findAllWithoutContent()라는 메서드를 구현한다. 그리고 이 메서드를 이용해서 Post들을 조회해보자.

    @EntityRepository(Post)
    export class PostRepository extends Repository<Post> {
      findAllWithoutContent() {
        return this.createQueryBuilder('post')
          .select([
            'post.id',
            'post.title',
            'post.createdAt',
          ])
          .getMany()
      }
    }
      const postRepository = connection.getCustomRepository(PostRepository);
      const posts = await postRepository.findAllWithoutContent();

    디버그 툴을 이용해서 findAllWithoutContent()의 결과 배열에 Post 클래스의 인스턴스들이 들어있고, content 필드는 제외가 되어있는 것을 확인할 수 있다.

    문제가 바로 이것이다. getMany()를 이용해 가져온 content가 없는 데이터가 Post 클래스의 인스턴스로 반환된다. TypeORM의 내부에서 반환된 결과를 Post 클래스의 인스턴스로 변환해주기 때문이다.

    타입스크립트 컴파일러도 getMany()의 결과가 Post의 배열이라고 알고있기 때문에, 내가 구현한 PostRepository의 findAllWithoutContent() 메서드를 사용하는 사람은 결과값이 온전한 Post 인스턴스의 배열인지, content가 제외된 Post 인스턴스의 배열인지 알지 못한다.

    결과 Post 인스턴스에 content 프로퍼티가 존재하지 않는다는 사실을 모르는 다른 사용자가 만약 content의 String 프로토타입 메서드를 사용하는 코드를 작성하더라도 타입스크립트 컴파일러는 문제를 알지 못한다.

      const postRepository = connection.getCustomRepository(PostRepository);
      const posts = await postRepository.findAllWithoutContent();
      if (posts[0].includes("something")) {
        // do something...
      }

    결국 위 코드는 런타임에 오류가 발생한다는 것을 알게 된다.

    return 타입 구체화하기

    getRawMany() 사용하기

    QueryBuilder의 조회 결과를 인스턴스로 변환하지 않기 위해서는 getRawMany() 메서드를 사용할 수 있다. 결과값의 alias를 직접 명시해주지 않으면 post_id, post_title같은 프로퍼티명으로 객체를 반환하기 때문에 alias를 명시해주었다.

    @EntityRepository(Post)
    export class PostRepository extends Repository<Post> {
      findAllWithoutContent() {
        return this.createQueryBuilder('post')
          .select('post.id', 'id')
          .addSelect('post.title', 'title')
          .addSelect('post.createdAt', 'createdAt')
          .getRawMany()
      }
    }

    findAllWithoutContent() 메서드를 수정한 뒤에 결과 배열에 들어있는 값을 확인하면 plain object가 들어있는 것을 확인할 수 있다.

    하지만 getRawMany()의 return 타입은 any이고, 실제로 plain object이기 때문에 타입스크립트 컴파일러가 메서드를 통해서 얻을 수 있는 데이터의 형태가 무엇인지, 어떤 클래스의 인스턴스인지를 알 수가 없게 되었다.

    이 메서드를 사용하는 상세 구현을 모르고 있는 사람은 이 메서드의 결과값이 형태(필드, 메서드)를 가진 값인지 알 수가 없다.

    데이터의 형태도 정의하고, 반환 값 또한 알 수 있게 만들기 위해 영속성 계층과 도메인 계층 사이에서 content가 없는 Post 데이터를 교환하기 위한 클래스를 정의하고, 해당 클래스의 인스턴스로 데이터를 주고 받도록 repository를 수정해보자.

    class-transformer 사용하기

    Post에서 content가 제외된 나머지 프로퍼티를 갖는 PostListItem이라는 클래스를 다음처럼 정의해보았다.

    @Exclude()
    export class PostListItem {
      @Expose()
      private id: number;
      
      @Expose()
      private title: string;
      
      @Expose()
      private createdAt: Date;
    }

    class-transformer의 데코레이터를 적극 활용해서 포함하고 싶은 프로퍼티와 제외하고 싶은 프로퍼티를 정의하면 plainToInstance()의 인자로 어떤 데이터가 들어오더라도 원하는 클래스의 데이터 형태를 얻을 수 있다.

    추가로, 프로퍼티를 private이나 readonly로 정의해서 무분별한 접근이나 수정을 막을 수 있고, 메서드를 추가해 리스트의 아이템으로써의 기능을 구현할 수도 있다.

    이제 우리의 Custom Repository에서 class-transformer의 plainToInstance() 메서드를 사용해서 조회 결과를 PostListItem클래스의 인스턴스로 변환해보자.(최근에 plainToClass() 메서드가 deprecated된거 같다)

    @EntityRepository(Post)
    export class PostRepository extends Repository<Post> {
      async findAllWithoutContent() {
        const posts = await this.createQueryBuilder('post')
          .select('post.id', 'id')
          .addSelect('post.title', 'title')
          .addSelect('post.createdAt', 'createdAt')
          .getRawMany()
        
        return plainToInstance(PostListItem, posts);
      }
    }

    이제 findAllWithoutContent() 메서드가 클래스의 인스턴스를 리턴하는 것을 확인할 수 있다. 또한 이제 타입스크립트 컴파일러가 리턴 타입을 알고 있기 때문에 프로그램을 작성하는 시점에 먼저 얘기했던 문제 상황을 방지할 수 있다.

    반응형

    댓글

Designed by Tistory.