🔷 GraphQL3. N+1 & DataLoader01-the-n-plus-1-problem

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 (자식 수)naivebatched차이
101125.5×
100101250.5×
1,0001,0012500.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 /usersposts까지 같이 줄지 말지를 백엔드가 미리 결정한다. 따라서 백엔드 개발자가 한 자리에서 JOIN을 짜거나 ORM의 include를 켠다.

GraphQL에서는 클라이언트가 응답 모양을 정한다users { posts }를 요청할지 클라이언트가 매번 다르게 결정한다. 그래서 백엔드는 모든 가능한 모양에 대해 효율을 보장해야 한다. resolver-per-field는 이 변동성을 다루기 위한 추상화이지만, 그 부작용으로 각 필드가 독립적으로 호출되며 — 그게 곧 N+1이다.

비교축RESTGraphQL
응답 모양 결정자서버클라이언트
fetch 단위endpointfield 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은 여전히 남는다. 이걸 더 줄이려면 lookaheadJOIN 컴파일 접근이 필요하다 (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 패턴을 본다.