Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

7장. N+1 문제와 데이터 로딩 전략

7.1 잘 작동하는 것처럼 보이지만

ORM을 사용하다 보면
이런 코드를 자주 만나게 된다.

const users = await prisma.user.findMany();

for (const user of users) {
  const orders = await prisma.order.findMany({
    where: { userId: user.id }
  });

  console.log(user.name, orders.length);
}

코드만 보면 자연스럽다.

  • 사용자를 가져온다
  • 사용자별 주문을 가져온다

👉 동작도 정상이다

하지만 실제로 실행되는 SQL을 보면 이야기가 달라진다.


7.2 N+1 문제란 무엇인가

위 코드가 실행될 때
DB에서는 다음과 같은 쿼리가 발생한다.

-- 사용자 목록 1번
SELECT * FROM users;

-- 사용자 수만큼 N번
SELECT * FROM orders WHERE user_id = 1;
SELECT * FROM orders WHERE user_id = 2;
SELECT * FROM orders WHERE user_id = 3;
...

👉 1번 + N번 = N+1번의 쿼리

사용자가 100명이면 101번의 쿼리가 실행된다.
사용자가 10,000명이면 10,001번이 실행된다.

이것이 N+1 문제다.


7.3 왜 ORM에서 자주 발생하는가

Raw Query에서는
개발자가 직접 SQL을 작성하기 때문에
이런 패턴이 자연스럽게 드러난다.

// 이렇게 짜는 사람은 거의 없다
const users = await db.query("SELECT * FROM users");
for (const user of users) {
  await db.query("SELECT * FROM orders WHERE user_id = ?", [user.id]);
}

👉 “이상해 보이는 코드”라서 바로 알아챈다

하지만 ORM에서는
👉 이상해 보이지 않는다

for (const user of users) {
  const orders = await user.orders();
}

코드는 깔끔하고 객체지향적이다.
실행되는 SQL이 코드에 드러나지 않기 때문에
👉 문제가 “보이지 않는다”


7.4 Lazy Loading vs Eager Loading

ORM이 연관된 데이터를 가져오는 방식에는
두 가지가 있다.

Lazy Loading (지연 로딩)

필요할 때마다 쿼리를 실행한다.

const users = await prisma.user.findMany();

// 이 시점에 orders 쿼리가 매번 실행됨
for (const user of users) {
  const orders = await user.orders();
}

👉 코드는 자연스럽지만 N+1 문제 발생

Eager Loading (즉시 로딩)

연관 데이터를 한 번에 같이 가져온다.

const users = await prisma.user.findMany({
  include: { orders: true }
});

// 추가 쿼리 없음
for (const user of users) {
  console.log(user.orders.length);
}

👉 쿼리 1~2번으로 끝난다


7.5 해결 패턴

N+1 문제를 해결하는 방식은 크게 세 가지다.

1) include / join 으로 한 번에 가져오기

const users = await prisma.user.findMany({
  include: { orders: true }
});

ORM이 내부적으로 JOIN 또는 IN 쿼리로 변환한다.

SELECT * FROM users;
SELECT * FROM orders WHERE user_id IN (1, 2, 3, ...);

👉 N번이 1번으로 줄어든다

2) 필요한 ID만 모아서 한 번에 조회

const users = await prisma.user.findMany();
const userIds = users.map(u => u.id);

const orders = await prisma.order.findMany({
  where: { userId: { in: userIds } }
});

👉 직접 제어하면서도 N+1을 피할 수 있다

3) Raw Query로 JOIN 처리

집계나 복잡한 조회는 Raw Query가 더 적합하다.

const result = await db.query(`
  SELECT u.id, u.name, COUNT(o.id) as order_count
  FROM users u
  LEFT JOIN orders o ON u.id = o.user_id
  GROUP BY u.id
`);

👉 5장에서 본 것처럼 상황에 맞는 선택


7.6 추상화 너머를 봐야 한다

N+1 문제의 본질은 단순한 성능 이슈가 아니다.

👉 추상화 뒤에 가려진 실제 동작을 보지 못해서 생기는 문제

ORM은 편리하지만
그 편리함이 실제 실행을 가린다.

await user.orders();

이 한 줄이

  • JOIN 한 번인지
  • 추가 쿼리 한 번인지
  • 캐시된 결과인지

코드만 보고는 알 수 없다.


7.7 핵심 정리

  • ORM을 쓰면 N+1 문제는 거의 반드시 만난다
  • 코드만 봐서는 N+1을 알아채기 어렵다
  • 해결은 “한 번에 가져오기”로 귀결된다
  • 그리고 가장 중요한 것은

👉 실제 실행되는 SQL을 확인하는 습관이다

ORM 로그, 쿼리 로깅, EXPLAIN 등을 통해
눈에 보이지 않는 쿼리를 보이게 만드는 것이 핵심이다.