N+1 쿼리 문제: 원인, 탐지, 해결
ORM 지연 로딩에서 흔히 생기는 N+1 쿼리의 원인을 짚고, 쿼리 로깅·테스트 어서션으로 탐지하고 JOIN·배치(IN)·DataLoader로 해결하는 법과 트레이드오프를 정리한다.

목록 화면이 느려질 때 가장 먼저 의심할 것
게시글 목록, 주문 내역, 댓글 리스트처럼 "여러 행을 보여주는 화면"이 갑자기 느려지는 일은 흔하다. 단일 행을 조회할 땐 빠르던 코드가 목록에서만 수백 ms~수 초가 걸린다면, 십중팔구 N+1 쿼리 문제다.
N+1은 이렇게 정의된다. 목록을 가져오는 쿼리 1개를 던지고, 그 결과로 받은 N개의 항목 각각에 대해 연관 데이터를 가져오는 쿼리를 1개씩 더 던지는 패턴이다. 합치면 1 + N개의 쿼리가 나가서 N+1이라 부른다. 항목이 10개면 11쿼리, 1000개면 1001쿼리다. 각 쿼리 자체는 인덱스를 타서 1ms로 빨라도, 네트워크 왕복(round-trip)과 쿼리 파싱 비용이 1000번 누적되면 화면은 체감 가능할 만큼 느려진다. 문제의 본질은 "느린 쿼리 하나"가 아니라 "빠른 쿼리가 너무 많이 나가는 것"이라는 점이 중요하다. 그래서 느린 쿼리 로그만 보면 안 잡힌다.
왜 생기나: ORM의 지연 로딩
N+1의 거의 모든 원인은 ORM의 **지연 로딩(lazy loading)**이다. ORM은 객체 그래프를 다루는 것처럼 보이게 해주지만, 실제로는 속성에 접근하는 순간 뒤에서 쿼리를 날린다. 다음 코드는 의도적으로 짠 게 아니라 누구나 자연스럽게 쓰는 형태다.
posts = Post.objects.all() # 쿼리 1: 게시글 목록
for post in posts:
print(post.title, post.author.name) # post.author 접근 시마다 쿼리 1개씩post.author에 처음 접근하는 순간 ORM은 "아직 author를 안 불러왔네"라고 판단하고 SELECT * FROM author WHERE id = ?를 던진다. 루프가 N번 돌면 이 쿼리도 N번 나간다. 코드만 보면 메모리 안의 객체를 읽는 평범한 반복문 같지만, 한 줄 한 줄이 DB 왕복이다. 이게 N+1이 위험한 이유다. 코드 리뷰에서 눈에 잘 안 띈다.
구체 예시: 실제로 날아가는 쿼리
게시글 5개를 목록으로 보여주면서 각 글의 작성자 이름을 함께 출력한다고 하자. ORM 관점의 코드는 다음과 같다.
const posts = await db.post.findMany({ take: 5 }); // 1쿼리
for (const post of posts) {
const author = await db.author.findUnique({ where: { id: post.authorId } }); // 행마다 1쿼리
render(post.title, author.name);
}이때 실제 DB에 날아가는 SQL 로그는 이렇게 찍힌다.
SELECT id, title, author_id FROM post LIMIT 5; -- 1번
SELECT id, name FROM author WHERE id = 1; -- 2번
SELECT id, name FROM author WHERE id = 2; -- 3번
SELECT id, name FROM author WHERE id = 3; -- 4번
SELECT id, name FROM author WHERE id = 4; -- 5번
SELECT id, name FROM author WHERE id = 5; -- 6번5개 항목에 1 + 5 = 6쿼리. 만약 같은 작성자가 여러 번 등장해도 ORM이 캐시를 안 한다면 중복 조회까지 발생한다. 항목이 늘어날수록 선형으로 쿼리가 늘어나는 게 핵심이다.
탐지: 어떻게 찾아내나
N+1은 평소엔 잘 안 보이다가 데이터가 쌓이면 터진다. 미리 잡으려면 측정 장치가 필요하다.
쿼리 로깅(SQL echo). 가장 직접적이다. 개발 환경에서 ORM의 SQL 로그를 켜고, 한 페이지를 열 때 콘솔에 같은 모양의 SELECT가 수십 줄 반복되면 그게 N+1이다. SQLAlchemy의 echo=True, Django의 django.db.backends 로거, Prisma의 log: ['query'], Hibernate의 show_sql 등이 여기 해당한다. "같은 쿼리가 파라미터만 바뀌며 반복되는가"가 시그니처다.
APM / 느린 쿼리 로그. 운영 환경에서는 Datadog, New Relic 같은 APM이 한 요청(trace) 안에서 발생한 쿼리 수를 보여준다. 한 트랜잭션에서 쿼리 100건이 잡히면 N+1을 강하게 의심한다. 다만 앞서 말했듯 개별 쿼리는 빨라서 DB의 느린 쿼리 로그(slow query log)에는 안 걸리는 경우가 많다. 느린 쿼리 로그가 깨끗한데 응답은 느리다면 오히려 N+1 신호다.
테스트에서 쿼리 수 어서션. 가장 견고한 방어선이다. 회귀를 막으려면 "이 엔드포인트는 쿼리 N개 이하"를 테스트로 못 박는다.
def test_post_list_query_count(client):
seed_posts(20) # 데이터 양과 무관하게
with assertNumQueries(2): # 쿼리는 2개로 고정이어야 한다
client.get("/posts")이렇게 해두면 누군가 무심코 루프 안에서 연관 객체를 건드려 쿼리 수가 22개로 늘어나는 순간 테스트가 빨간불을 띄운다. 데이터 양을 늘려도 쿼리 수가 고정인지 확인하는 게 포인트다.
해결책
(a) Eager loading / JOIN — 한 번에 가져오기
연관 데이터를 미리, 한 방에 조인해서 가져온다. 목록 쿼리에 작성자 테이블을 JOIN하면 왕복이 1번으로 끝난다.
SELECT p.id, p.title, a.name
FROM post p
JOIN author a ON a.id = p.author_id
LIMIT 5;쿼리 1개로 끝나니 다대일(many-to-one) 관계, 즉 "각 게시글이 작성자 하나를 가리키는" 경우엔 거의 항상 옳은 선택이다.
단, **일대다/다대다에서는 행 곱(row multiplication)**이라는 함정이 있다. 게시글 하나에 댓글이 100개 달려 있을 때 post JOIN comment를 하면, 게시글의 모든 컬럼이 댓글 수만큼 복제되어 전송된다. 게시글 50개 × 댓글 평균 100개 = 5000행이 되고, 각 행마다 게시글 제목·본문이 중복으로 실려 온다. 쿼리는 1번이지만 네트워크로 빠져나가는 데이터량과 ORM이 중복을 제거(de-dup)하며 객체로 재조립하는 비용이 커진다. "쿼리 수"는 줄었는데 "전송량"이 폭발하는 트레이드오프다.
(b) 배치 쿼리 — WHERE id IN (...)
목록을 먼저 받고, 거기서 필요한 키를 모아 단 한 번의 IN 쿼리로 연관 데이터를 가져온다. 쿼리는 정확히 2개로 고정된다.
SELECT id, title, author_id FROM post LIMIT 5; -- 1쿼리: 목록
SELECT id, name FROM author WHERE id IN (1,2,3,4,5); -- 1쿼리: 연관 일괄받아온 author들을 id -> author 맵으로 만들어 메모리에서 게시글과 이어 붙인다. 행 곱이 없어서 전송량이 깔끔하고, 일대다에서도 댓글을 WHERE post_id IN (...)로 한 번에 긁어 메모리에서 그룹핑하면 중복 없이 끝난다. 대부분의 ORM이 내부적으로 이 방식을 쓴다(Django prefetch_related, Rails preload, Prisma의 nested relation 로딩 등). 이 패턴을 일반화한 게 뒤에 나올 DataLoader다 — 키를 모았다가 한 번에 배치로 조회한다.
(c) ORM 기능으로 선언하기
직접 SQL을 짜기보다 ORM이 제공하는 eager/batch 옵션을 쓰는 게 보통 더 안전하다.
// Prisma — include 로 연관을 함께 로드 (내부적으로 배치 처리)
const posts = await db.post.findMany({
take: 5,
include: { author: true, comments: true },
});# Django — JOIN 방식(다대일)과 배치 방식(일대다)을 구분해 쓴다
Post.objects.select_related("author") # 다대일: JOIN
.prefetch_related("comments") # 일대다: 별도 IN 쿼리여기서 중요한 건 ORM이 두 전략을 구분해 노출한다는 점이다. Django의 select_related는 JOIN, prefetch_related는 배치 IN이다. SQLAlchemy도 joinedload(JOIN)와 selectinload(배치 IN)를 따로 둔다. 둘 중 무엇을 고를지는 관계의 종류에 달렸다.
JOIN vs IN(배치) 트레이드오프
핵심 판단 기준은 행 곱이 발생하는가다.
- 다대일(many-to-one): 게시글 → 작성자처럼 자식이 부모 하나를 가리키는 관계. JOIN해도 행이 늘지 않는다. JOIN(
select_related/joinedload)이 유리하다. 왕복 1번으로 끝난다. - 일대다 / 다대다(one-to-many / many-to-many): 게시글 → 댓글처럼 부모가 자식 여럿을 가지는 관계. JOIN하면 부모 행이 자식 수만큼 복제된다. 배치
IN(prefetch_related/selectinload)이 유리하다. 왕복은 2번이지만 중복 전송이 없다.
규모가 작고 자식 수가 적으면 JOIN의 왕복 1번이 이기고, 자식이 많아 행 곱이 커지면 배치 2쿼리가 이긴다. 게다가 여러 단계를 중첩(게시글 → 댓글 → 댓글작성자)하면 JOIN은 행 곱이 곱셈으로 누적되어 폭발하지만, 배치는 단계마다 쿼리 1개씩 더해지는 덧셈이라 예측 가능하게 늘어난다. 그래서 깊은 관계 그래프에서는 배치 쪽이 안정적이다.
GraphQL의 N+1과 DataLoader
GraphQL은 N+1이 구조적으로 터지기 쉽다. 클라이언트가 posts { author { name } }를 요청하면, 서버는 각 author 필드를 게시글마다 독립적으로 resolve한다. resolver는 자신이 전체 목록의 일부라는 걸 모르기 때문에, 순진하게 짜면 author 조회가 게시글 수만큼 나간다. 정확히 N+1이다.
해결책이 DataLoader다. 핵심 아이디어는 "조회를 즉시 실행하지 말고, 한 이벤트 루프 틱 동안 요청된 키들을 모았다가 한 번에 배치로 실행"하는 것이다. 더해서 같은 요청(request) 범위 안에서 같은 키는 캐시해서 중복 조회를 막는다.
// 요청 단위로 새 인스턴스를 만든다 (요청 간 캐시 누수 방지)
const authorLoader = new DataLoader(async (ids: number[]) => {
// ids = [1, 2, 3, 4, 5] — 틱 동안 모인 키
const authors = await db.author.findMany({ where: { id: { in: ids } } });
const byId = new Map(authors.map((a) => [a.id, a]));
// 반드시 입력 ids 순서대로 결과를 맞춰 돌려준다
return ids.map((id) => byId.get(id));
});
// resolver — 게시글마다 호출되지만 즉시 쿼리하지 않는다
const resolvers = {
Post: {
author: (post) => authorLoader.load(post.authorId),
},
};각 resolver는 load(post.authorId)를 호출만 하고, DataLoader가 이 키들을 모아 틱이 끝날 때 WHERE id IN (...) 한 방으로 처리한다. 결과적으로 N+1이 다시 2쿼리로 접힌다. 요청마다 인스턴스를 새로 만드는 이유는, 캐시가 요청을 넘어 살아남으면 stale 데이터가 새어 나가기 때문이다.
코드: N+1 → 해결 흐름 한눈에
// 1) N+1 — 안티패턴
const posts = await db.post.findMany({ take: 50 });
for (const post of posts) {
post.author = await db.author.findUnique({ where: { id: post.authorId } });
} // 1 + 50 쿼리
// 2) include / JOIN 으로 해결 (다대일)
const posts2 = await db.post.findMany({
take: 50,
include: { author: true },
}); // ORM 이 배치/조인으로 처리 → 쿼리 수 고정
// 3) DataLoader 배치 (특히 GraphQL resolver 안)
const post3 = resolveSomehow();
const author = await authorLoader.load(post3.authorId);
// load 호출은 키만 모음 → 틱 끝에 WHERE id IN (...) 한 번정리: 의심 → 측정 → 해결
순서를 지키는 게 중요하다.
- 의심한다. 목록 화면이 느리고, 느린 쿼리 로그는 깨끗한데 응답만 느리면 N+1을 1순위로 본다.
- 측정한다. 쿼리 로그를 켜서 같은 쿼리가 반복되는지 눈으로 확인하고, 한 요청당 쿼리 수를 센다. 추측으로 고치지 말고 쿼리 수라는 숫자로 확인한다.
- 관계에 맞게 해결한다. 다대일이면 JOIN(eager), 일대다/다대다면 배치
IN(prefetch). GraphQL이면 DataLoader로 요청 단위 배치·캐시. - 회귀를 막는다. 고친 엔드포인트에 "쿼리 수 N개 이하" 어서션 테스트를 걸어, 데이터가 늘거나 누가 무심코 루프에서 연관을 건드려도 즉시 잡히게 한다.
N+1은 ORM이 편의를 위해 숨겨둔 비용이 데이터 양과 함께 드러나는 문제다. 편의를 누리되 어떤 속성 접근이 쿼리를 유발하는지 의식하고, 관계의 종류(다대일 vs 일대다)에 맞는 로딩 전략을 고르며, 쿼리 수를 테스트로 고정해 두면 대부분 예방된다.