코드 한 줄이 DB를 죽인다: N+1 문제 완전 정복
1. "DB CPU가 100%를 찍었다"
N+1 문제를 처음 이해하게 된 건 아주 간단한 기능을 만들어보면서였다.
요구사항은 단순하다. "사용자 목록 페이지에 각 사용자의 '최근 작성한 댓글' 하나만 띄워주세요."
코드도 단순하게 짰다.
코드는 단순하다. ORM(객체 관계 매핑)을 쓰면 SQL을 직접 짤 필요도 없다.
/* 문제의 코드 */
// 1. 모든 사용자를 가져온다.
const users = await User.findAll();
// 2. 각 사용자의 최근 댓글을 가져와서 합친다.
const userList = await Promise.all(users.map(async (user) => {
const comment = await Comment.findOne({
where: { userId: user.id },
order: [['createdAt', 'DESC']]
});
return { ...user, lastComment: comment };
}));
로컬 개발 환경에서는 아무런 문제가 없다. 테스트용 사용자 데이터가 10명밖에 없으니 순식간에 로딩된다.
하지만 사용자가 1,000명인 환경에서 이 코드가 실행되면 어떤 일이 벌어질까?
SELECT * FROM users(최초 1번 실행, 1000명 리턴)SELECT * FROM comments WHERE userId = 1(1번째 사용자 댓글 조회)SELECT * FROM comments WHERE userId = 2(2번째 사용자 댓글 조회)- ...
SELECT * FROM comments WHERE userId = 1000(1000번째 사용자 댓글 조회)
사용자 목록 페이지를 한 번 새로고침 할 때마다, DB에는 총 1,001번의 쿼리가 0.1초 만에 폭격처럼 쏟아진다. 접속자가 10명이면 10,000번이다. DB CPU가 폭발하는 건 당연한 결과다.
이것이 바로 실무에서 가장 빈번한 성능 이슈인 N+1 문제다.
2. 왜 이런 일이 생길까? (Lazy Loading의 배신)
ORM이 문제인 것처럼 보이지만, 사실 이건 지연 로딩(Lazy Loading)이라는 설계 철학 때문이다. ORM은 기본적으로 "개발자가 명시적으로 달라고 하기 전까진 데이터를 안 가져온다"는 원칙을 가진다.
만약 User.findAll()을 했는데, 연관된 댓글(Comment), 주문 내역(Order), 프로필(Profile)까지 다 가져온다면 어떨까? 메모리가 터져나갈 거다. 그래서 ORM은 일단 User 정보만 가져오고, 개발자가 루프를 돌며 user.getComment()를 호출하는 그 순간에 부랴부랴 DB에 가서 댓글을 가져온다.
users.map을 돌리며 객체를 다루듯 편하게 코드를 짰지만, 실제로는 네트워크 요청 스팸을 보내고 있는 셈이다. 편의성의 대가가 성능 저하로 나타나는 지점이다.
3. 해결책 1 - "한 번에 다 줘!" (Eager Loading)
가장 쉬운 해결책은 ORM에게 미리 알려주는 거다. "나 사용자 가져올 때 댓글도 필요하니까, 처음부터 같이(Join) 가져와."
이걸 즉시 로딩(Eager Loading)이라고 한다. Sequelize, TypeORM, JPA 등 모든 ORM이 이 기능을 지원한다.
/* 수정된 코드 (Sequelize 예시) */
const users = await User.findAll({
include: [{
model: Comment,
limit: 1,
order: [['createdAt', 'DESC']]
}] // JOIN으로 한 번에 가져옴
});
단 한 줄(include)을 추가했을 뿐인데, 실행되는 쿼리는 기적적으로 단 1개로 줄어든다.
SELECT users.*, comments.*
FROM users
LEFT OUTER JOIN comments ON users.id = comments.userId;
결과 비교:
- 쿼리 수: 1,001개 -> 1개
- 응답 속도: 2,000ms -> 50ms
- DB 부하: CPU 사용률이 5%대로 떨어지며 정상화된다.
하지만 Eager Loading이 만능은 아니다. 연관된 테이블이 많으면 JOIN이 너무 많아져서 쿼리가 복잡해지고, 오히려 DB 성능을 깎아먹을 수도 있다. 그때 필요한 게 바로 두 번째 해결책이다.
4. 해결책 2 - "ID만 모아서 한 번에!" (Batch Loading)
Join 해야 할 데이터가 너무 많거나, NoSQL과 RDBMS를 섞어 쓰고 있어서 Join이 불가능한 경우엔 어떻게 할까? 이때는 애플리케이션 레벨에서 N+1을 해결해야 한다.
원리는 간단하다.
users를 먼저 다 가져온다. (쿼리 1번)- 가져온 사용자들의 ID만 따로 모은다.
[1, 2, 3, ..., 1000] - "이 ID들에 해당하는 댓글을 다 내놔"라고
IN절을 써서 요청한다. (쿼리 1번)SELECT * FROM comments WHERE userId IN (1, 2, 3, ..., 1000) - 애플리케이션 메모리 상에서 User와 Comment를 짝지어준다(Map핑).
이 패턴은 특히 GraphQL에서 필수적이다. GraphQL은 User 리졸버가 돌고, 그 안에서 Comment 리졸버가 따로 돌기 때문에 N+1 문제가 필연적으로 발생한다. 이때 DataLoader라는 라이브러리를 쓰면 이 "ID 모으기(Batching)" 작업을 자동으로 해준다.
// DataLoader 예시
const commentLoader = new DataLoader(async (userIds) => {
// 여기서 userIds 배열을 받아 IN 쿼리로 한 번에 조회
const comments = await Comment.findAll({ where: { userId: userIds } });
// 순서대로 매핑해서 리턴
return userIds.map(id => comments.find(c => c.userId === id));
});
// 사용
const userList = await Promise.all(users.map(async (user) => {
return commentLoader.load(user.id); // 요청을 모았다가 배치 실행
}));
5. 어떻게 예방할 수 있을까? (Monitoring)
문제를 해결하는 것보다 중요한 건 예방이다. 아래 안전장치들을 갖춰두면 N+1을 조기에 발견할 수 있다.
1) 쿼리 카운트 테스트
테스트 코드에서 API 호출 시 실행되는 쿼리 개수를 센다. "사용자가 10명일 때도 쿼리가 2개여야 하고, 100명일 때도 2개여야 한다"는 테스트를 작성한다. 쿼리 개수가 데이터 개수에 비례해서 늘어난다면(N개), 테스트가 실패하도록 만든다.
2) SQL 로깅 켜기 (개발 환경)
개발 중에는 반드시 터미널에 SQL 로그가 찍히도록 설정(logging: true)한다. API 하나를 호출했는데 터미널에 로그가 쉴 새 없이 올라간다면? 바로 N+1이다.
3) APM (Application Performance Monitoring)
Datadog이나 Pinpoint 같은 APM 도구를 사용하여 "Slow Query"나 "Frequency"를 모니터링한다. CloudWatch 알람을 "CPU 70% 이상 시 슬랙 알림"으로 설정해두는 것도 좋은 방법이다.
6. 마무리 - "쿼리를 상상하라"
ORM은 자율주행 자동차 같다. 정말 편하지만, 가끔은 직접 핸들을 잡아야 할 때가 있다. 특히 대용량 트래픽을 다룰 때는.
ORM을 잘 쓰는 개발자와 그렇지 않은 개발자의 차이는 여기서 드러난다.
- ORM에만 의존:
user.getComment()를 쓰면 댓글이 나온다는 것에 만족한다. - 쿼리를 상상: 저 메서드를 호출할 때 내부적으로 어떤 SQL이 생성되어 DB로 날아갈지 머릿속으로 그린다.
"기능이 돌아간다"는 건 반쪽짜리 성공이다. 데이터가 10배, 100배 늘어났을 때도 돌아가야 진짜 성공이다. 지금 당장 SQL 로그를 켜고 API 하나를 호출해보자. 쿼리가 쏟아진다면, 거기에 DB 비용을 반토막 낼 단서가 숨겨져 있을지도 모른다.
One Line of Code That Kills Your DB: The N+1 Problem Explained
1. "DB CPU at 100%"
The N+1 problem clicked for me while working through a simple feature.
The requirement is straightforward: "Show the latest comment for each user on the user list page."
The code looks simple too. With an ORM (Object-Relational Mapping), you don't even have to write raw SQL.
/* The Problematic Code */
// 1. Get all users
const users = await User.findAll();
// 2. Loop through users and get their latest comment
const userList = await Promise.all(users.map(async (user) => {
const comment = await Comment.findOne({
where: { userId: user.id },
order: [['createdAt', 'DESC']]
});
return { ...user, lastComment: comment };
}));
It works fine locally. Why? Because a local DB only has 10 test users. It loads instantly.
But with 1,000 users in a real environment, what happens inside the DB when this code runs?
SELECT * FROM users(1 Query, returns 1,000 users)SELECT * FROM comments WHERE userId = 1(Query for user #1)SELECT * FROM comments WHERE userId = 2(Query for user #2)- ...
SELECT * FROM comments WHERE userId = 1000(Query for user #1000)
Every time the user list loads, a total of 1,001 Queries bombard the DB in a split second. With 10 concurrent users, that's 10,000 queries. DB CPU spiking is the predictable result.
This is the N+1 Problem — a classic backend performance problem and the most common performance killer in backend development.
2. Why Does This Happen? (The Trap of Lazy Loading)
It's tempting to blame the ORM, but this isn't an error. It's a design philosophy called Lazy Loading. The principle: "Don't fetch data until the developer explicitly asks for it."
Imagine if User.findAll() fetched all related Comments, Orders, and Profiles by default. Your memory would explode. So, the ORM fetches only the User info first. It's only when you call user.getComment() inside the loop that it frantically rushes to the DB to get the comments.
To the developer, it feels like manipulating objects in memory comfortably. But in reality, you were spamming the database with Network Requests. The price of convenience was performance.
3. Solution 1: "Give Me Everything at Once!" (Eager Loading)
The easiest fix is to tell the ORM upfront: "I need comments too, so bring them together (Join) from the start."
This is called Eager Loading. All major ORMs like Sequelize, TypeORM, and JPA support this.
/* The Fixed Code (Sequelize Example) */
const users = await User.findAll({
include: [{
model: Comment,
limit: 1,
order: [['createdAt', 'DESC']]
}] // Fetch together using JOIN
});
By adding just one line (include), the number of queries miraculously drops to exactly 1.
SELECT users.*, comments.*
FROM users
LEFT OUTER JOIN comments ON users.id = comments.userId;
The Transformation:
- Query Count: 1,001 -> 1
- Response Time: 2,000ms -> 50ms
- DB Load: CPU drops back to 5%, service stabilizes.
However, Eager Loading isn't a silver bullet. If you join too many tables, the query becomes complex and heavy, which can also hurt performance. That's where the second solution comes in.
4. Solution 2: "Collect IDs and Fetch Once" (Batch Loading)
What if joining is too expensive, or you can't join because you're mixing NoSQL and SQL? In this case, you solve the N+1 problem at the Application Level.
The logic is simple:
- Fetch all
usersfirst. (1 Query) - Collect all their IDs into a list.
[1, 2, 3, ..., 1000] - Ask the DB: "Give me all comments for these IDs." Use the
INclause. (1 Query)SELECT * FROM comments WHERE userId IN (1, 2, 3, ..., 1000) - Match Users and Comments in the application memory.
This pattern is crucial in GraphQL. Since GraphQL resolvers run independently (User resolver runs, then Comment resolver runs inside), N+1 is inevitable. A library called DataLoader automates this "ID Collection (Batching)" process.
// DataLoader Example
const commentLoader = new DataLoader(async (userIds) => {
// Receive batch of IDs and query once using IN
const comments = await Comment.findAll({ where: { userId: userIds } });
// Map results back to IDs
return userIds.map(id => comments.find(c => c.userId === id));
});
// Usage
const userList = await Promise.all(users.map(async (user) => {
return commentLoader.load(user.id); // Requests are collected and batched
}));
5. How to Prevent It? (Monitoring & Testing)
Prevention is better than cure. These safeguards help catch N+1 early:
1) Query Count Tests
Write tests that count the number of queries executed. "If users increase from 10 to 100, the query count must remain 2." If the query count grows proportionally (N), fail the test.
2) Enable SQL Logging (Dev Env)
Always keep logging: true in your local development environment. If you hit an API endpoint and your terminal floods with SELECT statements, that's your N+1 duplicate alarm.
3) APM (Application Performance Monitoring)
Use tools like Datadog or New Relic to monitor "Slow Queries" or "High Frequency Queries". A CloudWatch alarm that triggers a Slack notification when DB CPU exceeds 70% is a simple and effective early warning system.