ABOUT ME

-

Today
-
Yesterday
-
Total
-
  • [TypeORM] Relation 관계에서 Join을 하는 방법들
    Javascript, Typescript 2021. 12. 8. 22:27
    반응형

     

    2022.09.28
    TypeORM 2.41 버전을 기준으로 작성된 글입니다. 요새 Node.js를 안하다보니... 최근에 3.X 버전이 상당히 변경되어 나온걸 확인해서 3.X 버전에는 맞지 않을 수 있습니다.

    RDBMS를 사용할 때 테이블 간의 참조를 통한 관계는 필수적이다. Node.js 진영에서 가장 핫한 ORM 라이브러리인 TypeORM에서 관계가 맺어져 있는 테이블을 다음 4가지 Join 하는 방법들을 통해 실제 쿼리는 어떻게 구성되는지 알아보자.

    • find* + 옵션
    • Lazy Loading
    • Eager Loading
    • Query Builder

    환경설정

    예제 데이터베이스는 PostgreSQL을 사용하고 team과 member라는 테이블을 다음과 같이 준비한다.

    CREATE TABLE team(
        id serial PRIMARY KEY,
        "name" varchar(50),
        description text,
        created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP
    );
    
    CREATE TABLE member(
        id serial PRIMARY KEY,
        team_id integer REFERENCES team,
        "name" varchar(50),
        email varchar(255) UNIQUE,
        created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP
    );

    1개의 team, 3개의 member 데이터를 생성해놓는다.

    위 테이블 구조를 표현하는 Entity는 다음과 같이 구현한다.

    @Entity()
    class Team {
      @PrimaryGeneratedColumn()
      id: number;
    
      @Column()
      name: string;
    
      @Column()
      description: string;
    
      @Column({
        type: 'timestamp with time zone',
        default: () => 'CURRENT_TIMESTAMP'
      })
      createdAt: Date;
    }
    @Entity()
    class Member {
      @PrimaryGeneratedColumn()
      id: number;
    
      @Column()
      name: string;
    
      @Column({ unique: true })
      email: string;
    
      @ManyToOne(() => Team)
      @JoinColumn({ name: 'team_id' })
      team: Team;
    
      @Column({
        type: 'timestamp with time zone',
        default: () => 'CURRENT_TIMESTAMP'
      })
      createdAt: Date;
    }

    find* 메소드 + 옵션

    먼저 member 한 명을 조회해보자.

    실제 쿼리를 확인하기 위해서 connection의 옵션 중 logging을 true로 설정하였다.

    const memberRepository = connection.getRepository(Member);
    const member = await memberRepository.findOne(1);
    console.log(member);

    Member Entity에 relation이 명시되어있지만, repository의 그냥 find 메소드는 join 쿼리를 날리지 않는다.

    다음과 같이 relations 옵션에 join 할 프로퍼티를 알려주면 left join 쿼리가 만들어지는 것을 확인할 수 있다.

    const member = await memberRepository.findOne(1, {
      relations: ['team']  // <-
    });
    console.log(member);

    다음처럼 join 옵션을 줘서 alias까지 지정할 수 있게 쿼리를 날릴 수도 있다.

    const member = await memberRepository.findOne(1, {
      join: {                 // <-
        alias: 'm',
        leftJoinAndSelect: {
          team: 'm.team'
        }
      }
    });
    console.log(member);

    Eager Loading

    Entity의 relation을 정의할 때, eager라는 옵션을 줄 수 있다. 이 eager 옵션을 true로 설정하는 것은, find* 메소드로 조회할 때 이 컬럼을 join 해줘 를 알려주는 것이다.

    @Entity()
    class Member {
    ...
      @ManyToOne(() => Team, { eager: true }) // <-
      @JoinColumn({ name: 'team_id' })
      team: Team;
    ...
    }
    const member = await memberRepository.findOne(1);
    console.log(member);

    relations: ['team'], join: { ... }옵션을 준 것과 같은 쿼리를 생성하는 것을 확인할 수 있다(team 테이블의 alias가 다르긴 하다).

    find* 메소드가 아닌 Query Builder를 통한 쿼리에는 Eager loading이 적용되지 않는다.

    Lazy Loading

    Eager Loading과 다르게 Lazy Loading은 해당 엔티티를 이미 조회한 이후에, 추가적으로 relation 데이터를 가져올 때 사용된다.

    Lazy Loading을 구현하기 위해서는 relation 프로퍼티를 Promise로 만들어 주어야 한다.

    @Entity()
    class Member {
    ...
      @ManyToOne(() => Team)
      @JoinColumn({ name: 'team_id' })
      team: Promise<Team>;    // <-
    ...
    }

    Lazy Loading의 동작을 확인하기 위해 다음과 같이 코드를 실행해보았다.

    const member = await memberRepository.findOne(1);
    console.log(member);
    console.log(member.team);

    첫 번째 쿼리는 join이 없이 id로 member만 조회하는 쿼리가 실행되었다. 이때 member.team이 pending된 Promise인 것을 확인할 수 있다. 해당 promise를 resolve 하면 team을 조회할 수 있다.

    const team = await member.team;
    console.log(team);

    이 promise는 resolve될 때 team 테이블에 member를 Inner join 하는 쿼리를 실행한다. Lazy loading으로 조회를 하게 되면 쿼리를 2번에 나눠서 실행하게 된다.

    Query Builder

    위 방식들은 간단하게 join 쿼리를 날릴 수 있다는 장점이 있지만, join 할 컬럼들을 select 할 수 없다는 단점이 있다.

    대부분의 경우에 필요하지 않은 컬럼까지 가져올 필요가 없기 때문에, 조회 쿼리는 QueryBuilder를 사용하게 된다.

    먼저 QueryBuilder에서 단순하게 join만 하려면 *joinAndSelect 메소드를 사용할 수 있다.

    const member = await memberRepository.createQueryBuilder('m')
      .leftJoinAndSelect('m.team', 't')
      .where('m.id = :id', { id: 1 })
      .getOne();
    console.log(member);

    일부 컬럼들만 join 하려면 select와 *join 메소드를 사용할 수 있다.

    const member = await memberRepository.createQueryBuilder('m')
      .select(['m.id', 'm.name', 'm.team', 't.id'])
      .leftJoin('m.team', 't')
      .where('m.id = :id', { id: 1 })
      .getOne();
    console.log(member);

    member의 id와 name, team의 id와 name 만 가져온 것을 확인할 수 있다.

    번외) join 하지 않고 외래 키 값만 가져오기

    맨 처음에 했던 것처럼 그냥 find* 메소드를 이용해서 조회를 하게 되면 join을 하지 않고 조회를 하게 되는데, 이 때 team_id 컬럼 값은 엔티티에 매핑되지 않는 것을 확인할 수 있다.

    외래키로 설정한 값을 조인 없이 가져오려면 엔티티를 다음과 같이 엔티티를 수정하면 된다.

    @Entity()
    class Member {
      ...
      @Column({ name: 'team_id' })   // <-
      teamId: number;                // <-
    
      @ManyToOne(() => Team)
      @JoinColumn({ name: 'team_id' })
      team: Team;
      ...
    }

    find*메소드를 통해 teamId라는 프로퍼티로 외래 키 값을 잘 가져오는 것을 확인할 수 있다.

    const member = await memberRepository.findOne(1);
    console.log(member);

    반응형

    댓글

Designed by Tistory.