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 등을 통해
눈에 보이지 않는 쿼리를 보이게 만드는 것이 핵심이다.