본문으로 건너뛰기
yutils

N+1 query 는 어떻게 발생할까?

user list 한 번으로 101 SQL query 가 발생하는 lazy loading 함정, eager loading (JOIN, IN), DataLoader (Facebook batching 패턴), GraphQL N+1 폭증, ORM 의 default 가 숨기는 이유.

약 9분 읽기

"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 필수.

관련 도구

흔한 함정

  • 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 제한.

가이드 목록으로