01 · The N+1 Problem
이 문서가 답하는 질문: N+1이란 정확히 무엇이고, ORM·REST에 다 있던 문제인데 왜 GraphQL에서 유독 회자되는가? 한 줄 답: “N+1은 ‘부모 1번 + 자식 N번’ 호출 패턴이고, GraphQL은 resolver-per-field 구조 때문에 그것을 눈에 띄게 만들 뿐이다.”
Why — 왜 이 문제부터 정의하나
DataLoader를 처음 만나는 사람들의 절반 이상이 “N+1이 정확히 뭐냐”를 말로 설명하지 못한다. 그래서 DataLoader를 깔아놓고도 어느 자리에서 batch가 필요한지를 못 골라 결국 효과가 안 난다.
이 문서는 N+1을 수학적으로 깔끔하게 정의하고, GraphQL/REST/ORM 세 환경에서 같은 문제가 어떻게 다른 얼굴로 등장하는지를 본다.
핵심 주장:
- N+1은 GraphQL의 죄가 아니다. ORM의 lazy loading, REST의 waterfall에도 동일하게 있다.
- 다만 GraphQL은 resolver가 필드 단위라서, 부모 컬렉션의 각 원소마다 resolver가 한 번씩 호출된다 — 이것이 N+1을 자동으로 만든다.
- 그래서 GraphQL에서는 명시적인 batch layer가 프레임워크 수준 표준이 되었다.
How — N+1을 정의하기
정의 (수식)
데이터 트리의 한 부모 노드 P가 자식 컬렉션 [C₁, C₂, … Cₙ]을 가진다고 하자.
- 호출 수: 부모 1개 fetch + 자식 각각마다 1개 fetch = 1 + N
- 이상적인 호출 수: 부모 1개 + 자식 전체 batch 1개 = 1 + 1 = 2
N이 커질수록 (1 + N) / 2 의 비율이 선형으로 폭증한다.
| N (자식 수) | naive | batched | 차이 |
|---|---|---|---|
| 10 | 11 | 2 | 5.5× |
| 100 | 101 | 2 | 50.5× |
| 1,000 | 1,001 | 2 | 500.5× |
이것이 프로덕션 DB가 새벽 3시에 죽는 메커니즘이다.
가장 흔한 형태 (SQL)
-- N+1: 1번 (부모) + N번 (자식)
SELECT * FROM users LIMIT 100;
-- → 100개 row 반환
-- 그 다음 user.posts를 resolve하려고:
SELECT * FROM posts WHERE user_id = 1;
SELECT * FROM posts WHERE user_id = 2;
SELECT * FROM posts WHERE user_id = 3;
-- ... 100번 반복-- batched: 1번 + 1번
SELECT * FROM users LIMIT 100;
SELECT * FROM posts WHERE user_id IN (1, 2, 3, ..., 100);IN 절 하나로 N개 round-trip이 1개로 줄어든다.
Mermaid — 같은 문제, 세 얼굴
What — GraphQL에서 N+1이 생기는 정확한 자리
예시 스키마
type Query {
users(limit: Int): [User!]!
}
type User {
id: ID!
name: String!
posts: [Post!]! # 이 필드가 위험하다
}
type Post {
id: ID!
title: String!
}가장 무해해 보이는 쿼리
query {
users(limit: 100) {
id
name
posts {
id
title
}
}
}가장 무해해 보이는 resolver
const resolvers = {
Query: {
users: async (_, { limit }, { db }) => {
return db.users.findMany({ take: limit });
// → SELECT * FROM users LIMIT 100 (1번)
},
},
User: {
posts: async (user, _, { db }) => {
return db.posts.findMany({ where: { user_id: user.id } });
// → SELECT * FROM posts WHERE user_id = ? (100번!)
},
},
};User.posts resolver는 부모 user 한 명마다 한 번씩 호출된다. 100명이면 100번, 1000명이면 1000번. 이것이 GraphQL의 resolver-per-field 모델이 자동으로 만드는 N+1이다.
왜 GraphQL은 유독 드러나는가
REST에서는 백엔드가 응답 모양을 정한다 — GET /users가 posts까지 같이 줄지 말지를 백엔드가 미리 결정한다. 따라서 백엔드 개발자가 한 자리에서 JOIN을 짜거나 ORM의 include를 켠다.
GraphQL에서는 클라이언트가 응답 모양을 정한다 — users { posts }를 요청할지 클라이언트가 매번 다르게 결정한다. 그래서 백엔드는 모든 가능한 모양에 대해 효율을 보장해야 한다. resolver-per-field는 이 변동성을 다루기 위한 추상화이지만, 그 부작용으로 각 필드가 독립적으로 호출되며 — 그게 곧 N+1이다.
| 비교축 | REST | GraphQL |
|---|---|---|
| 응답 모양 결정자 | 서버 | 클라이언트 |
| fetch 단위 | endpoint | field resolver |
| N+1이 기본값 | 아니다 | 그렇다 |
| N+1이 드러나는가 | 트래픽 로그 | resolver 호출 트레이스 |
| 해결책 | JOIN/include를 한 자리에서 | DataLoader를 모든 resolver에 |
What-if — 잘못된 해석들
오해 1 — “GraphQL이 N+1을 만들었다”
아니다. ORM의 lazy loading은 훨씬 오래된 동일 문제다. Hibernate가 2003년부터 fetch strategy를 문서화하고 있다. GraphQL이 발명한 게 아니라 드러나게 했다.
오해 2 — “DataLoader만 깔면 끝이다”
DataLoader는 같은 tick에 들어온 같은 종류의 key들만 batch한다. 트리가 깊으면 각 깊이마다 별도 tick이 생기므로, posts → comments → author → likes처럼 4단 깊이가 되면 4번의 round-trip은 여전히 남는다. 이걸 더 줄이려면 lookahead나 JOIN 컴파일 접근이 필요하다 (05, 06 참고).
오해 3 — “JOIN 한 방으로 다 해결된다”
GraphQL은 선택적 selection을 다룬다. 클라이언트가 posts를 안 요청하면 JOIN은 비용 낭비다. 그래서 언제 JOIN이 옳고 언제 batch가 옳은지가 설계 결정이 된다.
오해 4 — “관계형 DB가 문제다, NoSQL이면 N+1이 없다”
문서형 DB도 users 컬렉션을 가져온 뒤 posts 컬렉션을 user별로 100번 부르면 똑같은 N+1이다. 문제는 데이터 모델이 아니라 fetch 패턴이다.
Insight — 한 단락 이야기
“N+1은 ORM이 1990년대 후반에 발견하고, GraphQL이 2015년에 민주화한 문제다”
Object-Relational Mapping이 처음 등장했을 때 (
Hibernate,Active Record), 가장 큰 비판이 “OOP의 객체 접근 의미를 따라가다 보면 매번 SQL이 나간다” 였다.for user in users: print(user.posts)가 DBA의 악몽이었고, 그 해결로 eager loading, select_related, include 같은 옵션이 생겼다. GraphQL이 한 일은 그 옵션을 디폴트로 만든 것이다 — 클라이언트가posts를 selection에 넣으면 resolver가 그 자리에서 호출된다. 그래서 ORM 시절에는 시니어 개발자가 알아채던 문제가, GraphQL 시대에는 모든 resolver의 default 위험이 됐다. 그 자리에서 DataLoader가 프레임워크 수준 표준으로 등장한 것은 발명이 아니라 추수에 가깝다.
요약 + Mermaid
| 핵심 키 | 값 |
|---|---|
| 정의 | 부모 1번 + 자식 N번 = (N+1)번 |
| GraphQL이 원인인가 | 아니다. 드러나게 했을 뿐 |
| ORM에서의 이름 | lazy loading 문제 |
| REST에서의 이름 | waterfall 문제 |
| 사실상 표준 해결책 | DataLoader (다음 문서) |
한 줄 결론 — N+1은 오래된 문제이고, GraphQL의 resolver-per-field 모델이 그것을 모든 필드의 기본값으로 만들었다. 다음 문서(02)는 그 기본값을 흡수하는 DataLoader 패턴을 본다.