
코드 한 줄이 DB를 죽인다: N+1 문제 완전 정복
기능 하나를 추가했을 뿐인데, DB CPU가 100%를 찍는 상황이 있다. 원인은 ORM의 '지연 로딩'이 만든 N+1 문제다. 1000번의 쿼리를 1번으로 줄인 최적화 과정과, 주니어 개발자가 흔히 저지르는 ORM 실수들을 정리해본다.

기능 하나를 추가했을 뿐인데, DB CPU가 100%를 찍는 상황이 있다. 원인은 ORM의 '지연 로딩'이 만든 N+1 문제다. 1000번의 쿼리를 1번으로 줄인 최적화 과정과, 주니어 개발자가 흔히 저지르는 ORM 실수들을 정리해본다.
로버트 C. 마틴(Uncle Bob)이 제안한 클린 아키텍처의 핵심은 무엇일까요? 양파 껍질 같은 계층 구조와 의존성 규칙(Dependency Rule)을 통해 프레임워크와 UI로부터 독립적인 소프트웨어를 만드는 방법을 정리합니다.

매번 3-Way Handshake 하느라 지쳤나요? 한 번 맺은 인연(TCP 연결)을 소중히 유지하는 법. HTTP 최적화의 기본.

DB 설계의 기초. 데이터를 쪼개고 쪼개서 이상 현상(Anomaly)을 방지하는 과정. 제1, 2, 3 정규형을 쉽게 설명합니다.

프링글스 통(Stack)과 맛집 대기 줄(Queue). 가장 기초적인 자료구조지만, 이걸 모르면 재귀 함수도 메시지 큐도 이해할 수 없습니다.

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 문제다.
ORM이 문제인 것처럼 보이지만, 사실 이건 지연 로딩(Lazy Loading)이라는 설계 철학 때문이다. ORM은 기본적으로 "개발자가 명시적으로 달라고 하기 전까진 데이터를 안 가져온다"는 원칙을 가진다.
만약 User.findAll()을 했는데, 연관된 댓글(Comment), 주문 내역(Order), 프로필(Profile)까지 다 가져온다면 어떨까? 메모리가 터져나갈 거다. 그래서 ORM은 일단 User 정보만 가져오고, 개발자가 루프를 돌며 user.getComment()를 호출하는 그 순간에 부랴부랴 DB에 가서 댓글을 가져온다.
users.map을 돌리며 객체를 다루듯 편하게 코드를 짰지만, 실제로는 네트워크 요청 스팸을 보내고 있는 셈이다. 편의성의 대가가 성능 저하로 나타나는 지점이다.
가장 쉬운 해결책은 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;
결과 비교:
하지만 Eager Loading이 만능은 아니다. 연관된 테이블이 많으면 JOIN이 너무 많아져서 쿼리가 복잡해지고, 오히려 DB 성능을 깎아먹을 수도 있다. 그때 필요한 게 바로 두 번째 해결책이다.
Join 해야 할 데이터가 너무 많거나, NoSQL과 RDBMS를 섞어 쓰고 있어서 Join이 불가능한 경우엔 어떻게 할까? 이때는 애플리케이션 레벨에서 N+1을 해결해야 한다.
원리는 간단하다.
users를 먼저 다 가져온다. (쿼리 1번)[1, 2, 3, ..., 1000]IN 절을 써서 요청한다. (쿼리 1번)
SELECT * FROM comments WHERE userId IN (1, 2, 3, ..., 1000)이 패턴은 특히 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); // 요청을 모았다가 배치 실행
}));
문제를 해결하는 것보다 중요한 건 예방이다. 아래 안전장치들을 갖춰두면 N+1을 조기에 발견할 수 있다.
테스트 코드에서 API 호출 시 실행되는 쿼리 개수를 센다. "사용자가 10명일 때도 쿼리가 2개여야 하고, 100명일 때도 2개여야 한다"는 테스트를 작성한다. 쿼리 개수가 데이터 개수에 비례해서 늘어난다면(N개), 테스트가 실패하도록 만든다.
개발 중에는 반드시 터미널에 SQL 로그가 찍히도록 설정(logging: true)한다. API 하나를 호출했는데 터미널에 로그가 쉴 새 없이 올라간다면? 바로 N+1이다.
Datadog이나 Pinpoint 같은 APM 도구를 사용하여 "Slow Query"나 "Frequency"를 모니터링한다. CloudWatch 알람을 "CPU 70% 이상 시 슬랙 알림"으로 설정해두는 것도 좋은 방법이다.
ORM은 자율주행 자동차 같다. 정말 편하지만, 가끔은 직접 핸들을 잡아야 할 때가 있다. 특히 대용량 트래픽을 다룰 때는.
ORM을 잘 쓰는 개발자와 그렇지 않은 개발자의 차이는 여기서 드러난다.
user.getComment()를 쓰면 댓글이 나온다는 것에 만족한다."기능이 돌아간다"는 건 반쪽짜리 성공이다. 데이터가 10배, 100배 늘어났을 때도 돌아가야 진짜 성공이다. 지금 당장 SQL 로그를 켜고 API 하나를 호출해보자. 쿼리가 쏟아진다면, 거기에 DB 비용을 반토막 낼 단서가 숨겨져 있을지도 모른다.
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.
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.
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:
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.
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:
users first. (1 Query)[1, 2, 3, ..., 1000]IN clause. (1 Query)
SELECT * FROM comments WHERE userId IN (1, 2, 3, ..., 1000)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
}));
Prevention is better than cure. These safeguards help catch N+1 early:
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.
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.
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.