"user list 가져오니까 SQL 1 번이겠지" — 그런데 production 로그에 101 SQL query 가 박힘. 이게 N+1 query 문제. ORM 의 lazy loading 함정, eager loading, DataLoader 의 batching, GraphQL 에서의 폭증 — 이 가이드는 정리한다.
N+1 의 명확한 형태
# Django ORM 예시
users = User.objects.all() # 1 query: SELECT * FROM users
for user in users: # users = 100 명
print(user.posts.count()) # 100 queries: SELECT COUNT(*) FROM posts WHERE user_id = ?
총 query: 1 + 100 = 101 (= N+1, N=100)
logs:
SELECT * FROM users
SELECT COUNT(*) FROM posts WHERE user_id = 1
SELECT COUNT(*) FROM posts WHERE user_id = 2
...
SELECT COUNT(*) FROM posts WHERE user_id = 100
문제:
- 각 query 가 network round-trip (~1ms) = 100ms 만 latency
- DB connection 100 번 사용 → connection pool 부담
- N 이 1000 면 1 초 latency 😱ORM 의 lazy loading 함정
ORM 의 magic: user.posts 같은 속성 접근이 자동 SQL 실행.
users = User.query.all() # lazy: SELECT * FROM users 만
# posts 는 아직 안 가져옴
for u in users:
u.posts # ← 첫 접근 시 SQL 실행 (per user)
→ "magic" 의 cost. 코드가 단순해 보이지만 N+1.
이 문제가 보이지 않는 이유:
1. 작은 dev DB 에서는 빠름 (10 users → 10ms)
2. unit test 가 안 검증 (DB query 수 측정 안 함)
3. ORM 의 logging off 면 production 에서도 silent
4. monitoring 이 endpoint latency 만 → 어느 endpoint 가 어떻게 느린지해결 1 — Eager Loading (JOIN / IN)
# Django: select_related (단일 FK) + prefetch_related (역 FK / M2M)
users = User.objects.prefetch_related("posts").all()
# 2 queries:
# SELECT * FROM users
# SELECT * FROM posts WHERE user_id IN (1, 2, ..., 100)
# Python 에서 posts 를 user 별로 정렬
# Rails: includes
users = User.includes(:posts).all
# 같은 패턴
# SQLAlchemy: joinedload / selectinload
session.query(User).options(selectinload(User.posts)).all()
# Hibernate: @Fetch(FetchMode.SUBSELECT) 또는 JOIN FETCH
→ 1 + N 이 1 + 1 또는 1 (single JOIN).
→ ORM 의 "explicit 의 의지". 모든 fetch path 에 명시 필요 — 잊기 쉬움.해결 2 — DataLoader (Facebook batching)
// GraphQL resolver 에서 흔한 패턴
const postsLoader = new DataLoader(async (userIds) => {
const posts = await db.posts.where({user_id: userIds});
// posts 를 user_id 별로 grouped 으로 반환
return userIds.map(id => posts.filter(p => p.user_id === id));
});
// resolver 에서:
const user = async (id) => db.users.find(id);
const userPosts = async (user) => postsLoader.load(user.id);
원리:
1. 한 request 내에서 userPosts(user1), userPosts(user2), ... 호출
2. DataLoader 가 자동으로 같은 tick 내의 호출을 모음
3. 다음 microtask 에 batch SQL 1 회 실행 (IN clause)
4. 결과를 각 user 에 분배
→ N+1 의 자동 batching. application 코드는 individual 호출처럼 보이지만
underneath 는 1 query.GraphQL 의 N+1 폭증
query {
users { # 1 query: users 100 개
id
name
posts { # 100 queries: user 별 posts
id
title
comments { # posts 가 user 당 평균 10 → 1000 queries: post 별 comments
author { # comment 가 post 당 평균 20 → 20000 queries: comment 별 author
name
}
}
}
}
}
→ 21,101 queries 가능. GraphQL 의 유연성이 N+1 폭증의 위험.
대응:
- DataLoader 가 GraphQL 의 표준 답 (위 패턴)
- query 깊이 제한 (max depth 5 등)
- query complexity 분석 + 거부
- persisted query (whitelist 된 query 만 허용)
- 또는 SQL 직접 생성 (Hasura, PostGraphile)탐지 — production 에서 발견
도구 1 — Django Debug Toolbar / Rails Bullet / Laravel Telescope
dev 환경에서 N+1 자동 감지 + 알람
도구 2 — APM (DataDog, New Relic)
endpoint 별 query 수 표시 → "이 endpoint 가 1000 query" 즉시 보임
도구 3 — query log analysis
EXPLAIN ANALYZE 의 cumulative cost 또는 slow query log
도구 4 — testing
assertNumQueries(2) 같은 test 로 "이 endpoint 는 2 query 만" 강제
N+1 regression 자동 발견
→ N+1 의 cost 는 dev 환경에서 잘 안 보임. production-like data + APM 필수.관련 도구
- SQL 포매터 — query log 의 sql 가독성 ↑
흔한 함정
- nested loop 의 ORM 호출 — for user in users: for post in user.posts: ... → N×M+N+1 폭증.
- API endpoint serializer 의 N+1 — list 응답 serializer 가 각 item 의 related 자동 fetch → silent N+1.
- caching 으로 가리기 — N+1 의 진짜 cost 가 보임 X. cache hit 만 빠르고 first request 는 1 초+.
- eager 의 over-fetch — 모든 관계 prefetch 하면 single big query 의 cost ↑. 실제 사용하는 관계만.
- circular dep 의 cycle — user → posts → user (자기 자신) 의 eager 가 무한 또는 huge query.
마무리
N+1 은 ORM 의 가장 흔한 함정. lazy loading 의 magic 이 production 의 latency 폭탄. 항상 query 수 측정 + eager loading 명시 + N+1 탐지 도구.
실용 — Django bullet / Rails bullet / Laravel telescope 활성 + APM 의 query metric 보기 + test 에서 assertNumQueries 강제. GraphQL 은 DataLoader 표준 + depth/complexity 제한.