TypeORM N+1 - Eager Loading

1. N+1 문제란?

N+1 문제란 1번의 쿼리를 사용해서 N개의 데이터를 가져올 때, 해당 데이터와 관련된 모든 데이터를 가져오기 위해 추가적으로 N번의 쿼리가 실행되는 문제를 말합니다. 이를 이해하기 위해서는 먼저 로딩 방식에 대해서 알고 있어야 하므로 간단한 예시를 통해 로딩 방식을 정리한 후 N+1 문제에 대해서 정리해보겠다.

2. 예시 코드

게시판 관련 API를 개발한다고 가정했을 때, 사용자와 게시판의 관계는 1:N이다.

NestJS에서 TypeORM을 사용하였을 때, 다음과 같이 나타낼 수 있다.

@Entity('User')
export class User extends Timestamp {
  @PrimaryGeneratedColumn('increment')
  user_id: number;

  @Column({ type: 'varchar' })
  email: string;

  @Column({ type: 'varchar' })
  nickname: string;

  @Column({ type: 'varchar' })
  password: string;

  @CreateDateColumn()
  createdAt: Date;

  @UpdateDateColumn()
  updatedAt: Date;

  @OneToMany(() => Post, (post) => post.user, {
    onDelete: 'CASCADE',
  })
  posts: Post[];
}
@Entity('Post')
export class Post extends Timestamp {
  @PrimaryGeneratedColumn('increment')
  post_id: number;

  @Column({ type: 'text' })
  content: string;

  @Column({ type: 'varchar' })
  image_url: string;

  @CreateDateColumn() posts;

  @ManyToOne(() => User, (user) => user.posts)
  @JoinColumn([
    {
      name: 'user_id',
      referencedColumnName: 'user_id',
    },
  ])
  user: User;
}

3. Eager Loading이란?

이거(Eager) 로딩은 즉시 로딩이라고도 불리우며, 로딩 시 참조한 데이터까지 전부 가져오는 로딩 방식이다. 이를, 웹 페이지로 예를 들어보면 다음과 같다. 즉시 로딩을 적용하면 페이지 렌더링 이전에 필요한 모든 리소스들을 한 번에 가져오기 때문에 편리해보일 수 있다. 그런데, 사용자들이 모든 리소스를 전부 사용하지 않는다면 이는 곧 리소스 낭비로 볼 수 있고, 한 번에 모든 리소스를 가져오기 때문에 초기 로딩 속도가 느려질 수 있다는 단점이 있다. 마찬가지로, ORM에 즉시 로딩을 적용하면 필요한 Entity 및 관련된 모든 데이터를 가져온다.

4. TypeORM의 기본 로딩 방식

Java Spring에서 JPA hibernate ORM의 경우 OneToMany의 기본 조회 방식(default fetch type)은 지연 로딩(Lazy Loading)이고, ManyToOne의 기본 조회 방식은 즉시 로딩이므로 N+1 문제를 바로 접할 수 있다고 한다. 반면에, TypeORM은 아래와 같이 어떤 종류의 관계에서도 기본 조회 방식을 적용하지 않는 것을 확인할 수 있다.

/* node_modules/typeorm/decorator/options/RelationOptions.d.ts */

export interface RelationOptions {
  /**...*/
  lazy?: boolean;
  /**...*/
  eager?: boolean;
}

5. 초기 설정 상태에서의 테스트

간단한 테스트를 위해 다음과 같은 데이터를 테이블에 입력해주었다.

{
  "users": [
    {
      "user_id": 1,
      "email": "choewy32@gmail.com",
      "nickname": "choewy",
      "password": "password",
      "createdAt": "2022-04-24T19:48:16.156Z",
      "updatedAt": "2022-04-24T19:48:16.156Z"
    }
  ],
  "posts": [
    {
      "post_id": 1,
      "content": "TEST1",
      "image_url": null,
      "createdAt": "2022-04-24T19:54:26.675Z",
      "updatedAt": "2022-04-24T19:54:26.675Z",
      "deletedAt": "2022-04-24T19:54:26.675Z",
      "user_id": 1
    },
    {
      "post_id": 2,
      "content": "TEST2",
      "image_url": null,
      "createdAt": "2022-04-24T19:54:26.675Z",
      "updatedAt": "2022-04-24T19:54:26.675Z",
      "deletedAt": "2022-04-24T19:54:26.675Z",
      "user_id": 1
    },
    {
      "post_id": 3,
      "content": "TEST3",
      "image_url": null,
      "createdAt": "2022-04-24T19:54:26.675Z",
      "updatedAt": "2022-04-24T19:54:26.675Z",
      "deletedAt": "2022-04-24T19:54:26.675Z",
      "user_id": 1
    }
  ]
}

아래의 코드를 통해 테스트해보면 다음과 같은 결과를 얻을 수 있다.

@Injectable()
export class UsersService {
  constructor(
    @InjectRepository(User)
    private readonly userRepository: Repository<User>,
  ) {}

  async getOneUser(userId: numer): Promise<User> {
    return await this.userRepository.find();
  }
}
{
  "user_id": 1,
  "email": "choewy32@gmail.com",
  "nickname": "choewy",
  "password": "password",
  "createdAt": "2022-04-24T19:48:16.156Z",
  "updatedAt": "2022-04-24T19:48:16.156Z"
}

위의 결과를 보면 findOne을 통해 특정 사용자를 조회하는 경우 User 데이터만 조회된다는 것을 알 수 있다.

query:
  SELECT
    `User`.`createdAt` AS `User_createdAt`,
    `User`.`updatedAt` AS `User_updatedAt`,
    `User`.`user_id` AS `User_user_id`,
    `User`.`email` AS `User_email`,
    `User`.`nickname` AS `User_nickname`,
    `User`.`password` AS `User_password`
  FROM `users` `User`
  WHERE `User`.`user_id` IN (?)
    -- PARAMETERS: ["1"]

6. 즉시 로딩 상태에서의 테스트

이번에는 User Entity에 eager: true 옵션을 추가한 후 다시 확인해보면 다음과 같다.

@Entity('User')
export class User extends Timestamp {
  @PrimaryGeneratedColumn('increment')
  user_id: number;

  @Column({ type: 'varchar' })
  email: string;

  @Column({ type: 'varchar' })
  nickname: string;

  @Column({ type: 'varchar' })
  password: string;

  @CreateDateColumn()
  createdAt: Date;

  @UpdateDateColumn()
  updatedAt: Date;

  @OneToMany(() => Post, (post) => post.user, {
    onDelete: 'CASCADE',
    eager: true,
  })
  posts: Post[];
}
{
  "user_id": 1,
  "email": "choewy32@gmail.com",
  "nickname": "choewy",
  "password": "password",
  "createdAt": "2022-04-24T19:48:16.156Z",
  "updatedAt": "2022-04-24T19:48:16.156Z",
  "posts": [
    {
      "post_id": 1,
      "content": "TEST1",
      "image_url": null,
      "createdAt": "2022-04-24T19:54:26.675Z",
      "updatedAt": "2022-04-24T19:54:26.675Z",
      "deletedAt": "2022-04-24T19:54:26.675Z",
      "user_id": 1
    },
    {
      "post_id": 2,
      "content": "TEST2",
      "image_url": null,
      "createdAt": "2022-04-24T19:54:26.675Z",
      "updatedAt": "2022-04-24T19:54:26.675Z",
      "deletedAt": "2022-04-24T19:54:26.675Z",
      "user_id": 1
    },
    {
      "post_id": 3,
      "content": "TEST3",
      "image_url": null,
      "createdAt": "2022-04-24T19:54:26.675Z",
      "updatedAt": "2022-04-24T19:54:26.675Z",
      "deletedAt": "2022-04-24T19:54:26.675Z",
      "user_id": 1
    }
  ]
}

위 결과를 보면 해당 사용자의 게시물 정보까지 조회하며, 이때 SQL에서는 LEFT JOIN이 사용된 것을 알 수 있다.

query:
  SELECT
    `User`.`createdAt` AS `User_createdAt`,
    `User`.`updatedAt` AS `User_updatedAt`,
    `User`.`user_id` AS `User_user_id`,
    `User`.`email` AS `User_email`,
    `User`.`nickname` AS `User_nickname`,
    `User`.`password` AS `User_password`,
    `User_posts`.`deletedAt` AS `User_posts_deletedAt`,
    `User_posts`.`createdAt` AS `User_posts_createdAt`,
    `User_posts`.`updatedAt` AS `User_posts_updatedAt`,
    `User_posts`.`post_id` AS `User_posts_post_id`,
    `User_posts`.`content` AS `User_posts_content`,
    `User_posts`.`image_url` AS `User_posts_image_url`,
    `User_posts`.`user_id` AS `User_posts_user_id`
    FROM `users` `User`
  LEFT JOIN `posts` `User_posts`
    ON `User_posts`.`user_id`=`User`.`user_id`
      WHERE `users`.`user_id` IN (?)
      -- PARAMETERS: ["1"]

7. 주의사항 및 단점

특정 데이터를 조회할 때 위와 같이 설정하여 해당 데이터와 연관된 데이터까지 한 번에 조회하도록 하는 것이 매우 편리해 보인다. 그러나, 즉시 조회의 단점을 비롯한 몇 가지 단점이 추가적으로 존재한다.

  • READ 시 참조 중인 데이터까지 한꺼번에 가져오기 때문에 초기 로딩이 상대적으로 길며, 불필요한 데이터를 로드하는 상황이 비교적 많이 발생한다.
  • Entity의 종류가 많아짐에 따라 JOIN의 횟수도 증가하며, 이는 곧 JOIN으로 인한 성능 저하로 이어질 수 있다.

특히, 위의 내용 중에서 첫 번째 항목의 경우, 프론트엔드에서 당장 사용하지 않는 불필요한 데이터를 받게 되는 오버 패칭(Over fetching) 현상이 발생할 수 있다고 한다. 또한, TypeORM에서 이를 사용할 때 아래와 같은 몇 가지 주의사항이 있다고 한다.

  • 즉시 로딩은 find 메소드를 사용할 때에만 작동한다. 즉, QueryBuilder를 사용할 때에는 작동하지 않으며, 즉시 로딩 효과를 적용하고자 하는 경우 leftJoinAndSelect를 사용하여야 한다.
  • 즉시 로딩은 관계의 한쪽에서만 사용할 수 있으며, 양쪽에서 true로 설정할 수 없다. 만약, 양쪽에서 true로 설정하면 서로를 계속 참조하게 되므로 Maximum call stack error가 발생한다.

"" 개의 결과를 찾았습니다.

    ""에 대한 결과를 찾을 수 없습니다.