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