N+1 문제(with. TypeORM)

Series: 데이터베이스

데이터베이스contains 6

들어가며

ORM을 사용하다 보면 N+1 문제라는 말을 자주 듣게 된다. 특히 TypeORM, JPA, Sequelize, Prisma 같은 ORM을 사용해서 연관 관계 데이터를 조회할 때 자주 발생하는 성능 문제이다. N+1 문제는 간단히 말하면 처음에는 1번의 조회로 끝날 것 같았던 작업이, 실제로는 조회된 데이터 개수만큼 추가 쿼리를 발생시키는 문제이다. 예를 들어 게시글 목록을 조회하면서 각 게시글의 작성자 이름도 함께 보여줘야 한다고 해보자. 게시글 목록을 가져오는 쿼리는 1번이면 충분하다.

SELECT * FROM posts;

그런데 게시글을 가져온 뒤 각 게시글의 작성자를 따로 조회한다면, 게시글 개수만큼 사용자 조회 쿼리가 추가로 실행된다.

SELECT * FROM users WHERE id = 1;
SELECT * FROM users WHERE id = 2;
SELECT * FROM users WHERE id = 3;

게시글이 3개라면 총 4번의 쿼리가 실행된다. 게시글이 100개라면 101번, 1000개라면 1001번의 쿼리가 실행될 수 있다. 이처럼 최초 쿼리 1번에 이어서 결과 개수 N만큼 추가 쿼리가 발생하는 문제를 N+1 문제라고 한다. 이번 글에서는 TypeORM을 기준으로 N+1 문제가 어떤 상황에서 발생하는지, lazy loading과 일반 relation 조회는 어떻게 다른지, 그리고 이를 어떻게 해결할 수 있는지 정리해보겠다.

1. N+1 문제란 무엇인가?

N+1 문제에서 1은 최초로 실행되는 쿼리를 의미하고, N은 최초 쿼리로 조회된 데이터 개수만큼 추가로 실행되는 쿼리를 의미한다. 게시글 목록을 조회하는 상황을 생각해보자.

SELECT * FROM posts;

이 쿼리로 게시글 5개를 가져왔다고 가정한다. 그런데 각 게시글의 작성자 정보도 필요해서, 게시글마다 작성자를 따로 조회하면 다음과 같은 쿼리가 추가된다.

SELECT * FROM users WHERE id = 1;
SELECT * FROM users WHERE id = 2;
SELECT * FROM users WHERE id = 3;
SELECT * FROM users WHERE id = 4;
SELECT * FROM users WHERE id = 5;

결과적으로 쿼리 수는 다음과 같다.

게시글 목록 조회 1번
+ 작성자 조회 5번
= 총 6번

게시글이 5개일 때는 크게 문제가 없어 보일 수 있다. 하지만 게시글이 100개라면 총 101번의 쿼리가 실행된다. 게시글이 1000개라면 1001번의 쿼리가 실행될 수 있다.

sequenceDiagram
    participant App as 애플리케이션
    participant DB as 데이터베이스

    App->>DB: 게시글 목록 조회
    DB-->>App: 게시글 5개 반환

    App->>DB: 1번 게시글 작성자 조회
    DB-->>App: 작성자 반환

    App->>DB: 2번 게시글 작성자 조회
    DB-->>App: 작성자 반환

    App->>DB: 3번 게시글 작성자 조회
    DB-->>App: 작성자 반환

    App->>DB: 4번 게시글 작성자 조회
    DB-->>App: 작성자 반환

    App->>DB: 5번 게시글 작성자 조회
    DB-->>App: 작성자 반환

N+1 문제의 핵심은 단순히 쿼리가 여러 번 나가는 것만이 아니다. 데이터가 많아질수록 쿼리 수가 선형적으로 증가한다는 점이 문제이다. 개발 환경에서는 데이터가 적어서 잘 드러나지 않지만, 운영 환경에서 데이터가 많아지고 트래픽이 증가하면 응답 시간이 급격히 나빠질 수 있다.

2. TypeORM에서 N+1 문제가 발생하는 방식

N+1 문제는 ORM의 연관 관계 조회 방식과 관련이 깊다. ORM은 데이터베이스 테이블을 객체처럼 다룰 수 있게 해준다. 그래서 개발자는 post.user처럼 연관 객체에 접근하는 방식으로 코드를 작성할 수 있다. 하지만 TypeORM에서는 중요한 점이 있다.

TypeORM은 relation을 선언했다고 해서 항상 연관 데이터를 자동으로 가져오지 않는다.

예를 들어 다음과 같은 엔티티가 있다고 해보자.

@Entity()
export class User {
  @PrimaryGeneratedColumn()
  id: number;

  @Column()
  name: string;

  @OneToMany(() => Post, (post) => post.user)
  posts: Post[];
}

@Entity()
export class Post {
  @PrimaryGeneratedColumn()
  id: number;

  @Column()
  title: string;

  @Column()
  userId: number;

  @ManyToOne(() => User, (user) => user.posts)
  @JoinColumn({ name: 'userId' })
  user: User;
}

이 상태에서 다음 코드를 실행한다고 해보자.

const posts = await postRepository.find();

for (const post of posts) {
  console.log(post.user.name);
}

이 코드는 TypeORM 기준으로 좋은 N+1 예시가 아니다. 왜냐하면 기본 relation은 자동으로 로드되지 않기 때문이다. postRepository.find()만 실행하면 post.user는 보통 로드되어 있지 않다. 따라서 post.user.name에 접근하면 추가 쿼리가 실행되는 것이 아니라, post.userundefined라서 에러가 발생할 수 있다. 즉, TypeORM에서 다음 문장은 조심해야 한다.

post.user에 접근하면 작성자 조회 쿼리가 자동으로 실행된다.

이 설명은 TypeORM의 기본 relation에서는 맞지 않는다. TypeORM에서 이런 식으로 접근 시점에 쿼리가 실행되는 것은 lazy relation을 명시적으로 설정했을 때의 이야기이다. 따라서 TypeORM 기준으로 N+1 문제는 크게 두 가지 형태로 나누어 설명하는 것이 정확하다.

  1. 개발자가 반복문 안에서 직접 repository를 호출하는 방식
  2. lazy loading relation을 사용해서 연관 객체에 접근할 때 쿼리가 실행되는 방식

3. 반복문 안에서 직접 조회하는 N+1

가장 이해하기 쉬운 N+1 문제는 반복문 안에서 직접 조회 쿼리를 실행하는 경우이다. 게시글 목록을 먼저 조회한다.

const posts = await postRepository.find();

이후 각 게시글의 작성자 정보를 가져오기 위해 반복문 안에서 사용자 조회를 실행한다.

for (const post of posts) {
  const user = await userRepository.findOneOrFail({
    where: { id: post.userId },
  });

  console.log(user.name);
}

겉으로 보면 자연스러운 코드처럼 보인다. 게시글을 가져오고, 각 게시글의 작성자 정보를 가져오는 코드이다. 하지만 실제로는 다음과 같은 쿼리가 실행된다.

SELECT * FROM posts;
SELECT * FROM users WHERE id = 1;
SELECT * FROM users WHERE id = 2;
SELECT * FROM users WHERE id = 3;

게시글이 3개라면 사용자 조회 쿼리가 3번 추가된다. 게시글이 100개라면 사용자 조회 쿼리가 100번 추가된다.

flowchart TB
    A[posts 조회 1번] --> B[게시글 N개 반환]
    B --> C[반복문 실행]
    C --> D[각 post마다 userRepository.findOne 실행]
    D --> E[users 조회 N번]
    E --> F[총 1 + N 쿼리]

이 방식은 TypeORM의 lazy loading과 무관하게 발생한다. 개발자가 직접 반복문 안에서 조회를 실행하기 때문에 ORM 설정과 관계없이 N+1 문제가 된다. 이런 코드는 처음에는 읽기 쉽지만, 데이터가 많아질수록 성능 문제를 만들기 쉽다. 특히 목록 API에서 자주 발생한다. 목록 API는 한 번에 여러 데이터를 반환하기 때문에, 반복문 안에서 추가 조회가 들어가면 쿼리 수가 쉽게 증가한다.

4. Lazy Loading에서 발생하는 N+1

TypeORM에서 lazy loading을 사용하면 연관 데이터를 처음부터 가져오지 않고, 실제로 접근하는 시점에 조회한다. TypeORM에서 lazy relation을 사용하려면 relation 타입을 Promise<T>로 선언하고, relation 옵션에 { lazy: true }를 명시한다.

@Entity()
export class User {
  @PrimaryGeneratedColumn()
  id: number;

  @Column()
  name: string;

  @OneToMany(() => Post, (post) => post.user)
  posts: Post[];
}

@Entity()
export class Post {
  @PrimaryGeneratedColumn()
  id: number;

  @Column()
  title: string;

  @Column()
  userId: number;

  @ManyToOne(() => User, (user) => user.posts, { lazy: true })
  @JoinColumn({ name: 'userId' })
  user: Promise<User>;
}

이제 게시글 목록을 조회한다.

const posts = await postRepository.find();

이 시점에는 작성자 정보가 함께 조회되지 않는다. 게시글만 조회된다.

SELECT * FROM posts;

그런데 반복문 안에서 await post.user로 작성자에 접근하면, 그 시점에 작성자 조회 쿼리가 실행된다.

for (const post of posts) {
  const user = await post.user;
  console.log(user.name);
}

실제 쿼리는 다음과 같은 형태가 될 수 있다.

SELECT * FROM posts;
SELECT * FROM users WHERE id = 1;
SELECT * FROM users WHERE id = 2;
SELECT * FROM users WHERE id = 3;
sequenceDiagram
    participant App as 애플리케이션
    participant DB as 데이터베이스

    App->>DB: posts 조회
    DB-->>App: Post 3개 반환

    App->>DB: 첫 번째 post.user 접근 → user 조회
    DB-->>App: User 반환

    App->>DB: 두 번째 post.user 접근 → user 조회
    DB-->>App: User 반환

    App->>DB: 세 번째 post.user 접근 → user 조회
    DB-->>App: User 반환

lazy loading 자체가 항상 나쁜 것은 아니다. 실제로 필요한 순간에만 데이터를 가져올 수 있다는 장점이 있다. 하지만 목록 조회처럼 여러 개의 엔티티를 가져온 뒤 반복문 안에서 lazy relation에 접근하면 N+1 문제가 발생하기 쉽다. 따라서 TypeORM 글에서는 다음처럼 표현하는 것이 정확하다.

TypeORM에서는 기본 relation에 접근한다고 해서 자동으로 추가 쿼리가 실행되는 것은 아니다. 다만 { lazy: true }Promise<T>로 lazy relation을 설정한 경우, relation에 접근하는 시점에 쿼리가 실행될 수 있고, 이를 반복문 안에서 사용하면 N+1 문제가 발생할 수 있다.

5. Eager Loading, relations, join은 구분해야 한다

N+1 문제를 설명할 때 자주 헷갈리는 부분이 Eager Loading과 Join이다. TypeORM 기준으로는 다음을 구분해야 한다.

eager: true
→ 엔티티 relation 설정에 넣는 옵션
→ find 계열 메서드에서 항상 해당 relation을 함께 로드

relations 옵션
→ 특정 find 호출에서만 relation을 함께 로드

QueryBuilder join
→ 개발자가 쿼리에서 명시적으로 join

즉, 아래 코드는 Eager Loading이 아니다.

const posts = await postRepository.find({
  relations: { user: true },
});

이 코드는 이번 조회에서 user relation도 함께 가져오겠다는 의미이다. TypeORM은 내부적으로 relation을 로드하기 위한 쿼리를 만든다. 설정과 버전에 따라 join 기반으로 처리될 수 있고, relation load strategy에 따라 별도 쿼리 전략이 사용될 수도 있다. 중요한 것은 이 방식이 엔티티에 설정된 eager loading은 아니라는 점이다. 진짜 Eager Loading은 엔티티 relation에 { eager: true }를 설정하는 것이다.

@Entity()
export class Post {
  @PrimaryGeneratedColumn()
  id: number;

  @Column()
  title: string;

  @Column()
  userId: number;

  @ManyToOne(() => User, (user) => user.posts, { eager: true })
  @JoinColumn({ name: 'userId' })
  user: User;
}

이렇게 설정하면 다음처럼 relation을 따로 지정하지 않아도 find() 시점에 user가 함께 로드된다.

const posts = await postRepository.find();

반면 QueryBuilder에서는 명시적으로 join을 작성한다.

const posts = await postRepository.createQueryBuilder('post').leftJoinAndSelect('post.user', 'user').getMany();

이 셋을 같은 말처럼 쓰면 글이 부정확해진다. N+1 문제를 해결하는 핵심은 'Eager Loading을 쓰자'가 아니다. 더 정확히는 '필요한 연관 데이터를 어떤 방식으로 가져올지 결정하는 것'이다. 그 방법으로 relations, leftJoinAndSelect, QueryBuilder의 select, 또는 별도 IN 쿼리를 사용할 수 있다.

6. JOIN으로 해결하기

N+1 문제를 해결하는 가장 대표적인 방법은 필요한 연관 데이터를 처음부터 함께 조회하는 것이다. 게시글 목록에서 작성자 이름이 필요하다면, 게시글을 가져온 뒤 작성자를 하나씩 조회하는 대신 게시글과 작성자를 join해서 가져올 수 있다.

SELECT
  p.id,
  p.title,
  p.created_at,
  u.id AS user_id,
  u.name AS user_name
FROM posts p
LEFT JOIN users u ON p.user_id = u.id
ORDER BY p.created_at DESC
LIMIT 10;

TypeORM QueryBuilder로는 다음처럼 작성할 수 있다.

const posts = await postRepository
  .createQueryBuilder('post')
  .leftJoinAndSelect('post.user', 'user')
  .orderBy('post.createdAt', 'DESC')
  .limit(10)
  .getMany();

이 방식은 게시글 개수만큼 작성자를 따로 조회하지 않는다. 한 번의 쿼리로 게시글과 작성자 정보를 함께 가져온다.

sequenceDiagram
    participant App as 애플리케이션
    participant DB as 데이터베이스

    App->>DB: posts + users JOIN 조회
    DB-->>App: 게시글과 작성자 정보 함께 반환

특히 ManyToOne 관계에서는 join이 좋은 해결책이 되는 경우가 많다. 게시글 하나는 작성자 하나만 가지기 때문에, join을 해도 결과 row가 크게 늘어나지 않는다. 하지만 join이 항상 정답은 아니다. OneToMany 관계에서는 join 결과가 크게 늘어날 수 있다. 예를 들어 사용자와 게시글을 join하면, 사용자 한 명이 게시글 10개를 가지고 있을 때 사용자 정보가 10번 반복된다.

SELECT
  u.id,
  u.name,
  p.id,
  p.title
FROM users u
LEFT JOIN posts p ON p.user_id = u.id;

결과는 다음처럼 중복된다.

user_id | user_name | post_id | post_title
1       | Kim       | 1       | Post A
1       | Kim       | 2       | Post B
1       | Kim       | 3       | Post C
flowchart TB
    U[User 1] --> P1[Post A]
    U --> P2[Post B]
    U --> P3[Post C]

    R[JOIN 결과] --> R1[User 1 + Post A]
    R --> R2[User 1 + Post B]
    R --> R3[User 1 + Post C]

따라서 join은 ManyToOne, OneToOne 관계에서는 비교적 부담이 적지만, OneToMany, ManyToMany 관계에서는 결과 row 수가 커질 수 있다는 점을 고려해야 한다.

7. IN 쿼리로 한 번에 조회하기

연관 데이터를 join으로 가져오는 것이 부담스럽다면, IN 쿼리로 한 번에 조회하는 방법도 있다. 예를 들어 게시글 목록을 먼저 조회한다.

SELECT
  *
FROM posts
ORDER BY created_at DESC
LIMIT 10;

그 다음 게시글의 user_id를 모은다.

const userIds = [...new Set(posts.map((post) => post.userId))];

그리고 작성자 정보를 한 번에 조회한다.

SELECT
  *
FROM users
WHERE id IN (1, 2, 3, 4, 5);

TypeORM에서는 다음처럼 작성할 수 있다.

const posts = await postRepository.find({
  order: { createdAt: 'DESC' },
  take: 10,
});

const userIds = [...new Set(posts.map((post) => post.userId))];

const users = await userRepository.findBy({
  id: In(userIds),
});

const userMap = new Map(users.map((user) => [user.id, user]));

const result = posts.map((post) => ({
  id: post.id,
  title: post.title,
  authorName: userMap.get(post.userId)?.name,
}));

이 방식은 쿼리 수를 다음처럼 줄인다.

기존 방식:
posts 조회 1번 + users 조회 N번

IN 쿼리 방식:
posts 조회 1번 + users 조회 1번
sequenceDiagram
    participant App as 애플리케이션
    participant DB as 데이터베이스

    App->>DB: 게시글 목록 조회
    DB-->>App: 게시글 10개 반환

    App->>App: userId 목록 추출

    App->>DB: users WHERE id IN (...)
    DB-->>App: 작성자 목록 반환

    App->>App: userId 기준으로 매핑

이 방식은 join으로 인해 row가 중복되는 것을 피할 수 있다. 또한 필요한 데이터를 명확하게 나눠서 가져올 수 있다. 다만 ORM이 자동으로 객체 그래프를 채워주는 것이 아니라, 애플리케이션 코드에서 직접 매핑해야 한다. 그래서 코드가 조금 길어질 수 있다. 하지만 목록 API처럼 반환 형태가 명확한 경우에는 DTO를 직접 구성하는 방식이 오히려 더 안전하고 예측 가능하다.

8. 댓글 수 조회 예시

N+1 문제는 작성자 조회뿐 아니라 댓글 수, 좋아요 수 같은 집계 데이터에서도 자주 발생한다. 게시글 목록 화면에 다음 정보가 필요하다고 해보자.

게시글 제목
작성자 이름
댓글 수

가장 나쁜 방식은 게시글을 조회한 뒤, 각 게시글마다 댓글 수를 따로 조회하는 것이다.

const posts = await postRepository.find({
  order: { createdAt: 'DESC' },
  take: 10,
});

for (const post of posts) {
  const commentCount = await commentRepository.count({
    where: { postId: post.id },
  });

  console.log(post.title, commentCount);
}

실행되는 쿼리는 다음과 비슷하다.

SELECT
  *
FROM posts
ORDER BY created_at DESC
LIMIT 10;

SELECT COUNT(*) FROM comments WHERE post_id = 1;
SELECT COUNT(*) FROM comments WHERE post_id = 2;
SELECT COUNT(*) FROM comments WHERE post_id = 3;

게시글 10개면 댓글 수 조회가 10번 추가된다. 게시글 100개면 100번 추가된다. 이럴 때는 게시글 ID를 모아서 댓글 수를 한 번에 집계하는 것이 좋다.

SELECT
  post_id,
  COUNT(*) AS comment_count
FROM comments
WHERE post_id IN (1, 2, 3, 4, 5)
GROUP BY post_id;

TypeORM QueryBuilder로는 다음처럼 작성할 수 있다.

const postIds = posts.map((post) => post.id);

const commentCounts = await commentRepository
  .createQueryBuilder('comment')
  .select('comment.postId', 'postId')
  .addSelect('COUNT(*)', 'count')
  .where('comment.postId IN (:...postIds)', { postIds })
  .groupBy('comment.postId')
  .getRawMany();

const commentCountMap = new Map(commentCounts.map((row) => [Number(row.postId), Number(row.count)]));

const result = posts.map((post) => ({
  id: post.id,
  title: post.title,
  commentCount: commentCountMap.get(post.id) ?? 0,
}));

이 방식은 댓글 전체를 가져오지 않고 필요한 개수만 가져온다. 목록 화면에서 댓글 내용이 필요하지 않다면 댓글 엔티티를 join해서 모두 가져오는 것보다 훨씬 효율적이다.

9. 필요한 컬럼만 조회하기

N+1 문제를 해결하려고 join을 사용했더라도, SELECT *로 모든 컬럼을 가져오면 또 다른 비효율이 생길 수 있다. 게시글 목록 화면에는 작성자의 이름만 필요한데, 작성자의 이메일, 비밀번호 해시, 프로필 설정, 생성일 같은 모든 정보를 가져올 필요는 없다. 이 경우에는 필요한 컬럼만 명시적으로 조회하는 것이 좋다.

SELECT
  p.id,
  p.title,
  p.created_at,
  u.name AS author_name
FROM posts p
LEFT JOIN users u ON p.user_id = u.id
ORDER BY p.created_at DESC
LIMIT 10;

TypeORM에서도 QueryBuilder를 사용해 필요한 컬럼만 조회할 수 있다.

const rows = await postRepository
  .createQueryBuilder('post')
  .leftJoin('post.user', 'user')
  .select('post.id', 'id')
  .addSelect('post.title', 'title')
  .addSelect('post.createdAt', 'createdAt')
  .addSelect('user.name', 'authorName')
  .orderBy('post.createdAt', 'DESC')
  .limit(10)
  .getRawMany();

이 방식은 엔티티 전체를 가져오기보다 목록 화면에 필요한 DTO 형태로 바로 가져오는 방식이다. 목록 API에서는 엔티티를 그대로 반환하기보다 DTO에 필요한 데이터만 담아서 반환하는 것이 좋다. 이렇게 하면 불필요한 relation 접근을 줄일 수 있고, 어떤 쿼리가 실행되는지도 명확해진다.

10. TypeORM의 Eager Loading 주의점

TypeORM에서 Eager Loading은 relation에 { eager: true }를 설정하는 방식이다.

@ManyToOne(() => User, (user) => user.posts, { eager: true })
@JoinColumn({ name: 'userId' })
user: User;

이렇게 설정하면 find() 계열 메서드를 사용할 때 해당 relation이 자동으로 함께 로드된다.

const posts = await postRepository.find();

이때 user도 함께 로드된다. 하지만 eager loading은 신중하게 사용해야 한다. 항상 필요한 relation이라면 편리할 수 있지만, 특정 화면에서는 필요 없는 데이터까지 매번 조회할 수 있다. 예를 들어 게시글 목록에서는 작성자 이름이 필요하지만, 게시글 ID만 필요한 내부 배치 작업에서는 작성자 정보가 필요 없을 수 있다. 그런데 relation에 { eager: true }가 걸려 있으면 find()를 호출할 때마다 작성자 정보가 함께 로드된다. 또한 eager relation이 많아지면 쿼리가 복잡해지고 가져오는 데이터 양이 증가할 수 있다. 그래서 실무에서는 전역적으로 eager loading을 많이 설정하기보다, 필요한 API에서 명시적으로 relation을 가져오는 방식을 선호하는 경우가 많다. 즉, N+1 문제를 피하기 위해 무조건 eager loading을 켜는 것은 좋은 해결책이 아니다. 더 좋은 방향은 '각 API가 필요한 데이터를 명확히 정의하고, 그에 맞는 조회 쿼리를 작성하는 것'이다.

11. 해결 방법 선택 기준

N+1 문제를 해결할 때는 무조건 join만 사용하면 되는 것이 아니다. 어떤 관계를 조회하는지, 데이터가 얼마나 많은지, 화면에서 필요한 정보가 무엇인지에 따라 전략이 달라진다. 게시글과 작성자처럼 ManyToOne 관계에서는 join이 좋은 선택이 될 수 있다. 게시글 하나에 작성자 하나가 붙기 때문에 결과 row가 크게 늘어나지 않는다. 반면 게시글과 댓글처럼 OneToMany 관계에서는 join을 조심해야 한다. 게시글 하나에 댓글이 여러 개 붙으면 게시글 정보가 댓글 수만큼 반복된다. 이 경우 댓글 전체가 필요한 상세 화면인지, 댓글 수만 필요한 목록 화면인지에 따라 다른 쿼리를 사용해야 한다.

목록 화면에서는 보통 모든 연관 데이터를 가져오기보다 필요한 값만 요약해서 보여주는 경우가 많다. 작성자 이름, 댓글 수, 좋아요 수처럼 필요한 값만 조회하고 DTO로 조합하는 방식이 더 효율적일 수 있다. 상세 화면에서는 특정 게시글 하나와 댓글 목록 전체가 필요할 수 있다. 이 경우에는 게시글 조회와 댓글 조회를 분리해서 두 번의 쿼리로 가져와도 충분히 효율적이다. 결국 N+1 문제의 해결 기준은 단순히 쿼리 수를 줄이는 것이 아니다. 쿼리 수, 조회 데이터 양, 중복 row, 인덱스 사용 여부, 코드 복잡도를 함께 고려해야 한다.

12. 인덱스와 함께 고려하기

N+1 문제를 해결하기 위해 join이나 IN 쿼리를 사용하더라도, 관련 컬럼에 인덱스가 없다면 성능이 충분히 좋아지지 않을 수 있다. 예를 들어 게시글을 작성자 기준으로 조회하는 쿼리가 많다면 posts.user_id에 인덱스가 있는 것이 좋다.

CREATE INDEX idx_posts_user_id ON posts (user_id);

댓글 수를 게시글 ID 기준으로 집계한다면 comments.post_id에도 인덱스가 필요하다.

CREATE INDEX idx_comments_post_id ON comments (post_id);

다음 쿼리는 comments.post_id 인덱스가 있을 때 더 효율적으로 실행될 수 있다.

SELECT
  post_id,
  COUNT(*) AS comment_count
FROM comments
WHERE post_id IN (1, 2, 3, 4, 5)
GROUP BY post_id;

인덱스가 없다면 데이터베이스는 comments 테이블 전체를 훑어야 할 수 있다. 댓글 데이터가 많아질수록 집계 쿼리도 느려진다. 따라서 N+1 문제를 해결할 때는 쿼리 개수만 줄이는 것이 아니라, 줄인 쿼리가 효율적으로 실행될 수 있도록 인덱스도 함께 고려해야 한다.

13. 실행 로그와 실행 계획 확인

N+1 문제는 SQL 로그를 보면 비교적 쉽게 발견할 수 있다. 개발 환경에서 SQL 로그를 켜고 API를 호출했을 때, 비슷한 쿼리가 여러 번 반복된다면 N+1 문제를 의심할 수 있다.

SELECT * FROM users WHERE id = ?;
SELECT * FROM users WHERE id = ?;
SELECT * FROM users WHERE id = ?;

TypeORM에서는 개발 환경에서 query logging을 켜서 실제 실행되는 SQL을 확인할 수 있다.

new DataSource({
  logging: ['query', 'error'],
});

N+1 문제를 해결한 뒤에는 실행 계획도 확인하는 것이 좋다. MySQL에서는 다음처럼 확인할 수 있다.

EXPLAIN
SELECT
  p.id,
  p.title,
  u.name
FROM posts p
LEFT JOIN users u ON p.user_id = u.id
ORDER BY p.created_at DESC
LIMIT 10;

PostgreSQL에서는 다음처럼 실제 실행 시간까지 확인할 수 있다.

EXPLAIN ANALYZE
SELECT
  p.id,
  p.title,
  u.name
FROM posts p
LEFT JOIN users u ON p.user_id = u.id
ORDER BY p.created_at DESC
LIMIT 10;

실행 계획을 보면 인덱스를 사용하는지, 예상 row 수가 얼마나 되는지, join 방식은 무엇인지 확인할 수 있다. N+1 문제를 해결했다고 생각했더라도, 실제로는 join 결과가 너무 커지거나 인덱스를 사용하지 못해 성능이 나쁠 수 있다. 따라서 쿼리 수정 후에는 반드시 실제 SQL과 실행 계획을 확인하는 것이 좋다.

14. 실무에서 자주 하는 실수

실무에서 가장 흔한 실수는 ORM이 실행하는 SQL을 확인하지 않고 객체 접근만 보고 판단하는 것이다. TypeORM에서 기본 relation은 자동으로 조회되지 않는다. 그런데 이를 제대로 이해하지 못하면 post.user 접근 시 자동으로 쿼리가 나간다고 오해하거나, 반대로 relation이 항상 로드되어 있을 것이라고 착각할 수 있다. 또 다른 실수는 N+1 문제를 피하기 위해 모든 relation에 { eager: true }를 붙이는 것이다. 이 방식은 당장은 편해 보일 수 있지만, 필요하지 않은 데이터까지 항상 조회하게 만들어 다른 성능 문제를 만들 수 있다. 목록 API에서 엔티티를 그대로 반환하는 것도 조심해야 한다. 엔티티에는 여러 relation이 연결되어 있고, 직렬화 과정이나 응답 변환 과정에서 의도치 않게 relation에 접근할 수 있다. 특히 lazy relation을 사용하는 경우 이 과정에서 추가 쿼리가 발생할 수 있다. 따라서 목록 API에서는 필요한 필드만 담은 DTO를 만들어 반환하는 것이 좋다.

export class PostListItemDto {
  id: number;
  title: string;
  authorName: string;
  commentCount: number;
}

DTO를 기준으로 필요한 데이터를 정의하면, 어떤 쿼리가 필요한지도 명확해진다. 게시글 제목, 작성자 이름, 댓글 수가 필요하다면 그 데이터만 조회하면 된다. 엔티티 전체와 모든 relation을 가져올 필요가 없다.

마치며

N+1 문제는 ORM을 사용할 때 자주 발생하는 대표적인 성능 문제이다. 처음에는 단순한 목록 조회처럼 보이지만, 연관 데이터를 가져오는 과정에서 조회된 데이터 개수만큼 추가 쿼리가 발생할 수 있다. TypeORM에서는 특히 개념을 정확히 구분해야 한다. 기본 relation은 자동으로 로드되지 않는다. lazy relation을 설정한 경우에만 relation 접근 시점에 쿼리가 실행될 수 있다. relations 옵션은 특정 조회에서 relation을 함께 가져오는 방식이고, { eager: true }는 엔티티 설정 단계에서 항상 함께 로드하도록 만드는 방식이다. QueryBuilder의 join은 개발자가 명시적으로 join을 작성하는 방식이다.

N+1 문제를 해결하는 방법은 여러 가지가 있다. ManyToOne 관계에서는 join으로 함께 조회하는 방식이 효과적일 수 있다. OneToMany 관계에서는 join으로 인해 row가 크게 늘어날 수 있으므로, IN 쿼리나 별도 집계 쿼리를 사용하는 것이 더 적절할 수 있다. 댓글 수처럼 전체 데이터가 아니라 개수만 필요한 경우에는 COUNTGROUP BY를 사용하는 것이 좋다. 결국 중요한 것은 ORM이 실행하는 SQL을 의식하는 것이다. ORM은 객체 중심으로 코드를 작성하게 도와주지만, 실제 성능은 SQL과 데이터베이스에서 결정된다. 따라서 어떤 API에서 어떤 데이터가 필요한지 명확히 정하고, 그에 맞는 쿼리를 작성하는 것이 N+1 문제를 피하는 가장 좋은 방법이라고 생각한다.