GraphQL API를 짜다 보면 어느 순간 슬로우 쿼리 로그에서 이런 걸 발견해요.
1[Query] 12ms SELECT * FROM post2[Query] 3ms SELECT * FROM comment WHERE post_id = 13[Query] 3ms SELECT * FROM comment WHERE post_id = 24[Query] 3ms SELECT * FROM comment WHERE post_id = 35...posts 20개를 조회했는데 쿼리가 21번 찍혀 있다.
이게 N+1 문제다.
N+1이 정확히 뭔가
N+1 문제란, 목록 1번 조회 후 각 항목마다 추가 쿼리가 N번 발생하는 현상이다.
11번: SELECT * FROM post → post 20개 반환22번: SELECT * FROM comment WHERE post_id = 133번: SELECT * FROM comment WHERE post_id = 24...521번: SELECT * FROM comment WHERE post_id = 206
7총 쿼리: 1 + 20 = 21번여기서 N은 comment 개수가 아니라 post 개수다.
각 post의 comment가 10개든 100개든 상관없다. post가 20개면 쿼리 21번, 100개면 101번.
왜 발생하는가 — GraphQL resolver 실행 메커니즘
N+1이 왜 생기는지 이해하려면 GraphQL이 resolver를 어떻게 실행하는지 알아야 한다.
프론트에서 이런 쿼리를 보낸다고 하자.
1query {2 posts {3 content4 comments {5 content6 }7 }8}GraphQL 엔진은 이 쿼리를 필드 단위로 트리를 순회하며 각 필드에 맞는 resolver를 찾아서 실행한다.
NestJS에서 @ResolveField를 이렇게 선언하면:
1@Resolver(() => Post) // "이 클래스는 Post 타입의 필드 resolver야"2export class PostResolver {3 @Query(() => [Post])4 async posts() {5 return this.postService.findAll();6 // SELECT * FROM post7 }8
9 @ResolveField(() => [Comment])10 async comments(@Parent() post: Post) {11 // GraphQL이 Post.comments 필드를 만날 때마다 이 함수를 호출한다12 return this.commentService.findByPostId(post.id);13 // SELECT * FROM comment WHERE post_id = ?14 }15}@Resolver(() => Post) 선언이 핵심이다.
GraphQL 엔진은 Post 타입의 comments 필드가 요청되면 이 클래스의 @ResolveField comments()를 찾아서 실행한다.
posts가 20개면 20번 실행한다.
실행 흐름:
11. posts() 실행2 → SELECT * FROM post3 → [post1, post2, post3] 반환4
52. GraphQL: "post1의 comments 필드 필요"6 → comments(parent: post1) 실행7 → SELECT * FROM comment WHERE post_id = 18
93. GraphQL: "post2의 comments 필드 필요"10 → comments(parent: post2) 실행11 → SELECT * FROM comment WHERE post_id = 212
134. GraphQL: "post3의 comments 필드 필요"14 → comments(parent: post3) 실행15 → SELECT * FROM comment WHERE post_id = 316
17총 쿼리: 1 + 3 = 4번GraphQL 엔진이 각 post마다 자동으로 resolver를 호출하기 때문에 N+1이 생긴다.
posts resolver는 이 사실을 모른다. GraphQL이 알아서 처리하는 것.
N+1의 실제 비용
N+1이 얼마나 심각한지는 규모와 중첩 깊이에 따라 달라진다.
페이지네이션이 있을 때:
페이지당 20건이면 쿼리 21번.
인덱스가 걸린 WHERE post_id = ?는 하나당 1-3ms 수준이라 체감이 어렵다.
페이지네이션 없이 all 조회할 때:
1posts 1000개: 쿼리 1001번 → 약 1-3초2posts 5000개: 쿼리 5001번 → 타임아웃중첩이 깊어질 때:
1query {2 posts {3 # 20개4 comments {5 # 각 post마다 10개 → 200개6 author {7 # 각 comment마다 → 쿼리 201번 추가8 name9 }10 }11 }12}1posts 조회: 1번2comments 조회: 20번 (post 개수만큼)3author 조회: 200번 (comment 개수만큼)4
5총 쿼리: 221번단일 depth에서는 페이지네이션으로 버틸 수 있지만,
중첩이 생기면 페이지네이션이 있어도 무시할 수 없는 수준이 된다.
[!warning] DB 커넥션 비용도 있다 쿼리가 빠르더라도 커넥션을 열고 닫는 오버헤드가 쿼리마다 발생한다.
쿼리 100번 = 커넥션 100번. 커넥션 풀이 소진되면 다른 요청이 대기한다.
세 가지 구현 방법
이런 GraphQL 쿼리를 구현한다고 가정해요.
1query {2 posts {3 content4 comments {5 content6 }7 }8}데이터 상황:
- posts: 3개
- 각 post마다 comments: 10개
- 총 comment: 30개
구현 방법은 세 가지다. 각 방법이 DB에 쿼리를 몇 번 날리는지 살펴보자.
방법 1. relations JOIN
@ResolveField 없이 posts resolver에서 처음부터 JOIN해서 가져온다.
1@Query(() => [Post])2async posts() {3 return this.postService.findAll({4 relations: ['comments']5 });6}실행되는 SQL:
1SELECT post.id, post.content,2 comment.id, comment.content, comment.post_id3FROM post4LEFT JOIN comment ON comment.post_id = post.id쿼리 횟수: 1번 / 반환 row: 30개
쿼리 1번으로 깔끔하게 끝난다. 단순하고 빠르다.
그런데 이게 GraphQL 철학에 맞는가?
GraphQL의 핵심은 "프론트가 필요한 것만 요청한다" 는 것이다.
만약 프론트에서 이렇게 요청한다면?
1query {2 posts {3 content # comments 요청 안 함4 }5}프론트는 comments를 요청하지 않았다.
그런데 relations JOIN을 쓰면 프론트의 요청과 무관하게 항상 JOIN이 실행된다.
1-- 프론트가 comments 안 요청해도2SELECT post.*, comment.* -- 항상 이렇게 실행됨3FROM post LEFT JOIN comment ON ...relations가 늘어날수록 이 문제는 커진다.
1return this.postService.findAll({2 relations: ["comments", "tags", "author", "attachments"],3});4// 프론트가 뭘 요청하든 항상 4개 테이블 JOINGraphQL을 쓰는 이유 자체가 사라진다.
relations JOIN은 단순하지만, GraphQL이 주는 "필요한 것만 가져간다"는 이점을 포기하는 방식이다.
방법 2. @ResolveField — GraphQL 철학에 맞는 방향
posts resolver는 post만 가져오고, comments는 @ResolveField로 분리한다.
1@Resolver(() => Post)2export class PostResolver {3 @Query(() => [Post])4 async posts() {5 return this.postService.findAll();6 // SELECT * FROM post — post만, JOIN 없음7 }8
9 @ResolveField(() => [Comment])10 async comments(@Parent() post: Post) {11 return this.commentService.findByPostId(post.id);12 }13}프론트가 comments를 요청할 때만 @ResolveField가 실행된다.
요청하지 않으면 resolver 자체가 호출되지 않는다.
이게 GraphQL이 의도한 방식이다. 컴포넌트(또는 쿼리)가 필요한 필드를 직접 선언하고, 선언된 것만 resolve한다.
그런데 N+1이 발생한다
실행되는 SQL:
1-- 1번: posts 조회2SELECT * FROM post3
4-- 2번: post 1의 comments5SELECT * FROM comment WHERE post_id = 16
7-- 3번: post 2의 comments8SELECT * FROM comment WHERE post_id = 29
10-- 4번: post 3의 comments11SELECT * FROM comment WHERE post_id = 3쿼리 횟수: 4번 (1 + N)
N은 comment 개수가 아니라 post 개수다. post가 100개면 쿼리 101번.
페이지네이션이 N+1의 숨통을 터준다
N+1이 실제로 문제가 되는 건 규모가 클 때다.
1페이지네이션 없이 all: posts 1000개 → 쿼리 1001번 ← 위험2페이지당 20개: posts 20개 → 쿼리 21번 ← indexed 쿼리라 수십 ms페이지당 20건이면 21번의 indexed 쿼리는 충분히 빠르다.
슬로우 쿼리가 찍히지 않는 수준이에요.
그렇다고 이걸 무시해도 된다고 생각하면 안 돼요.
페이지네이션이 있어도 쿼리 21번은 쿼리 2번보다 비싸다.
규모가 커질수록, 중첩 relations가 늘어날수록 그 차이는 벌어진다.
@ResolveField를 GraphQL 철학대로 쓰면서 N+1도 없애는 방법이 필요하다.
방법 3. DataLoader — @ResolveField + N+1 해결
DataLoader는 각 post의 개별 요청을 이벤트 루프 한 틱 안에 모아서 하나의 IN 쿼리로 배치 처리한다.
1@Injectable()2export class CommentLoader {3 private loader = new DataLoader<number, Comment[]>(4 async (postIds: readonly number[]) => {5 const comments = await this.commentService.findByPostIds([...postIds]);6 // SELECT * FROM comment WHERE post_id IN (1, 2, 3)7
8 // 각 postId에 해당하는 comments를 매핑해서 반환9 return postIds.map((id) => comments.filter((c) => c.postId === id));10 }11 );12
13 load(postId: number) {14 return this.loader.load(postId);15 }16}1@ResolveField(() => [Comment])2async comments(@Parent() post: Post) {3 return this.commentLoader.load(post.id);4 // 개별 호출처럼 보이지만 DataLoader가 모아서 처리5}실행되는 SQL:
1-- 1번: posts 조 회2SELECT * FROM post3
4-- 2번: 모든 post의 comments 한 번에5SELECT * FROM comment WHERE post_id IN (1, 2, 3)쿼리 횟수: 2번
post가 20개여도 1000개여도 항상 2번이다.
@ResolveField를 그대로 유지하면서, 프론트가 요청하지 않으면 실행되지 않는 GraphQL 철학도 지킨다.
세 방법 비교
| relations JOIN | @ResolveField | DataLoader | |
|---|---|---|---|
| 쿼리 횟수 (post 3개) | 1번 | 4번 | 2번 |
| 쿼리 횟수 (post 20개) | 1번 | 21번 | 2번 |
| 쿼리 횟수 (post 100개) | 1번 | 101번 | 2번 |
| 중첩 3단계 (post 20개) | 1번 | 221번 | 3번 |
| comments 미요청 시 | 항상 JOIN ❌ | resolver 미실행 ✅ | resolver 미실행 ✅ |
| GraphQL 철학 | ❌ | ✅ | ✅ |
| 구현 복잡도 | 낮음 | 낮음 | 중간 |
"그냥 resolver를 두 개 만들면 되지 않나?"
DataLoader가 번거롭다면 이런 방법도 있다.
1// 가벼운 resolver — comments 없음2@Query(() => [Post])3async posts() {4 return this.postService.findAll();5}6
7// 무거운 resolver — comments JOIN 포함8@Query(() => [Post])9async postsWithComments() {10 return this.postService.findAll({ relations: ['comments'] });11}프론트가 필요에 따라 골라서 쓰는 방식이다.
단순하고, N+1도 없고, DataLoader도 필요 없다.
근데 이게 맞는 방향인가?
처음엔 괜찮아 보인다. 그런데 요구사항이 늘면 이렇게 된다.
1posts() // 기본2postsWithComments() // comments 포함3postsWithCommentsAndAuthor() // comments + author 포함4postsWithTags() // tags 포함5postsForFeedPage() // 피드 페이지용6postsForAdminPage() // 어드민 페이지용이건 REST API다.
REST API는 엔드포인트가 사용 케이스를 정의한다.
/posts/feed, /posts/admin, /posts/detail — 페이지마다 엔드포인트가 늘어나는 구조.
GraphQL을 쓰는 이유는 반대였다.
스키마는 엔티티를 정의하고, 클라이언트가 필요한 모양을 조합한다.
resolver가 사용 케이스를 알 필요가 없다.
resolver-per-use-case 방식은 처음엔 단순하지만,
결국 GraphQL을 쓰면서 REST의 단점(엔드포인트 증식)을 고스란히 가져오는 것이다.
[!warning] resolver가 늘어난다는 건 프론트의 요구사항이 백엔드 구조에 직접 영향을 준다는 뜻이다.
프론트 페이지가 바뀔 때마다 백엔드 resolver를 추가해야 한다면, GraphQL의 이점이 없다.
결론
relations JOIN은 단순하지만 GraphQL의 이점을 포기한다.
프론트가 요청하지 않은 데이터도 항상 JOIN되고, relations가 늘수록 불필요한 쿼리 비용이 쌓인다.
use-case별 resolver 분리는 단기적으로 편하지만,
요구사항이 늘수록 resolver가 증식해 REST API와 다를 게 없어진다.
@ResolveField가 GraphQL 철학에 맞는 방향이다.
요청된 필드만 resolve하고, 요청하지 않으면 실행되지 않는다.
N+1은 페이지네이션으로 규모를 줄이고, 근본적으로는 DataLoader로 해결한다.
[!tip] 실무 시작점
@ResolveField+ 페이지네이션으로 시작하세요.
단일 depth에 페이지당 20-50건이면 N+1이 체감되지 않는 수준이에요.
중첩 relations가 생기거나 슬로우 쿼리가 찍히기 시작하면 그때 DataLoader를 도입하는 게 맞습니다.