raw
backend

GraphQL N+1: 쿼리 비용과 올바른 설계 방향

2026.04.04·14분

GraphQL API를 짜다 보면 어느 순간 슬로우 쿼리 로그에서 이런 걸 발견해요.

text
1[Query] 12ms SELECT * FROM post
2[Query] 3ms SELECT * FROM comment WHERE post_id = 1
3[Query] 3ms SELECT * FROM comment WHERE post_id = 2
4[Query] 3ms SELECT * FROM comment WHERE post_id = 3
5...

posts 20개를 조회했는데 쿼리가 21번 찍혀 있다.
이게 N+1 문제다.


N+1이 정확히 뭔가

N+1 문제란, 목록 1번 조회 후 각 항목마다 추가 쿼리가 N번 발생하는 현상이다.

text
11번: SELECT * FROM post → post 20개 반환
22번: SELECT * FROM comment WHERE post_id = 1
33번: SELECT * FROM comment WHERE post_id = 2
4...
521번: SELECT * FROM comment WHERE post_id = 20
6
7총 쿼리: 1 + 20 = 21번

여기서 N은 comment 개수가 아니라 post 개수다.
각 post의 comment가 10개든 100개든 상관없다. post가 20개면 쿼리 21번, 100개면 101번.


왜 발생하는가 — GraphQL resolver 실행 메커니즘

N+1이 왜 생기는지 이해하려면 GraphQL이 resolver를 어떻게 실행하는지 알아야 한다.

프론트에서 이런 쿼리를 보낸다고 하자.

graphql
1query {
2 posts {
3 content
4 comments {
5 content
6 }
7 }
8}

GraphQL 엔진은 이 쿼리를 필드 단위로 트리를 순회하며 각 필드에 맞는 resolver를 찾아서 실행한다.

NestJS에서 @ResolveField를 이렇게 선언하면:

ts
1@Resolver(() => Post) // "이 클래스는 Post 타입의 필드 resolver야"
2export class PostResolver {
3 @Query(() => [Post])
4 async posts() {
5 return this.postService.findAll();
6 // SELECT * FROM post
7 }
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번 실행한다.

실행 흐름:

text
11. posts() 실행
2 → SELECT * FROM post
3 → [post1, post2, post3] 반환
4
52. GraphQL: "post1의 comments 필드 필요"
6 → comments(parent: post1) 실행
7 → SELECT * FROM comment WHERE post_id = 1
8
93. GraphQL: "post2의 comments 필드 필요"
10 → comments(parent: post2) 실행
11 → SELECT * FROM comment WHERE post_id = 2
12
134. GraphQL: "post3의 comments 필드 필요"
14 → comments(parent: post3) 실행
15 → SELECT * FROM comment WHERE post_id = 3
16
17총 쿼리: 1 + 3 = 4번

GraphQL 엔진이 각 post마다 자동으로 resolver를 호출하기 때문에 N+1이 생긴다.
posts resolver는 이 사실을 모른다. GraphQL이 알아서 처리하는 것.


N+1의 실제 비용

N+1이 얼마나 심각한지는 규모와 중첩 깊이에 따라 달라진다.

페이지네이션이 있을 때:

페이지당 20건이면 쿼리 21번.
인덱스가 걸린 WHERE post_id = ?는 하나당 1-3ms 수준이라 체감이 어렵다.

페이지네이션 없이 all 조회할 때:

text
1posts 1000개: 쿼리 1001번 → 약 1-3초
2posts 5000개: 쿼리 5001번 → 타임아웃

중첩이 깊어질 때:

graphql
1query {
2 posts {
3 # 20개
4 comments {
5 # 각 post마다 10개 → 200개
6 author {
7 # 각 comment마다 → 쿼리 201번 추가
8 name
9 }
10 }
11 }
12}
text
1posts 조회: 1번
2comments 조회: 20번 (post 개수만큼)
3author 조회: 200번 (comment 개수만큼)
4
5총 쿼리: 221번

단일 depth에서는 페이지네이션으로 버틸 수 있지만,
중첩이 생기면 페이지네이션이 있어도 무시할 수 없는 수준이 된다.

[!warning] DB 커넥션 비용도 있다 쿼리가 빠르더라도 커넥션을 열고 닫는 오버헤드가 쿼리마다 발생한다.
쿼리 100번 = 커넥션 100번. 커넥션 풀이 소진되면 다른 요청이 대기한다.


세 가지 구현 방법

이런 GraphQL 쿼리를 구현한다고 가정해요.

graphql
1query {
2 posts {
3 content
4 comments {
5 content
6 }
7 }
8}

데이터 상황:

  • posts: 3개
  • 각 post마다 comments: 10개
  • 총 comment: 30개

구현 방법은 세 가지다. 각 방법이 DB에 쿼리를 몇 번 날리는지 살펴보자.


방법 1. relations JOIN

@ResolveField 없이 posts resolver에서 처음부터 JOIN해서 가져온다.

ts
1@Query(() => [Post])
2async posts() {
3 return this.postService.findAll({
4 relations: ['comments']
5 });
6}

실행되는 SQL:

sql
1SELECT post.id, post.content,
2 comment.id, comment.content, comment.post_id
3FROM post
4LEFT JOIN comment ON comment.post_id = post.id

쿼리 횟수: 1번 / 반환 row: 30개

쿼리 1번으로 깔끔하게 끝난다. 단순하고 빠르다.

그런데 이게 GraphQL 철학에 맞는가?

GraphQL의 핵심은 "프론트가 필요한 것만 요청한다" 는 것이다.
만약 프론트에서 이렇게 요청한다면?

graphql
1query {
2 posts {
3 content # comments 요청 안 함
4 }
5}

프론트는 comments를 요청하지 않았다.
그런데 relations JOIN을 쓰면 프론트의 요청과 무관하게 항상 JOIN이 실행된다.

sql
1-- 프론트가 comments 안 요청해도
2SELECT post.*, comment.* -- 항상 이렇게 실행됨
3FROM post LEFT JOIN comment ON ...

relations가 늘어날수록 이 문제는 커진다.

ts
1return this.postService.findAll({
2 relations: ["comments", "tags", "author", "attachments"],
3});
4// 프론트가 뭘 요청하든 항상 4개 테이블 JOIN

GraphQL을 쓰는 이유 자체가 사라진다.

relations JOIN은 단순하지만, GraphQL이 주는 "필요한 것만 가져간다"는 이점을 포기하는 방식이다.


방법 2. @ResolveField — GraphQL 철학에 맞는 방향

posts resolver는 post만 가져오고, comments는 @ResolveField로 분리한다.

ts
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:

sql
1-- 1번: posts 조회
2SELECT * FROM post
3
4-- 2번: post 1의 comments
5SELECT * FROM comment WHERE post_id = 1
6
7-- 3번: post 2의 comments
8SELECT * FROM comment WHERE post_id = 2
9
10-- 4번: post 3의 comments
11SELECT * FROM comment WHERE post_id = 3

쿼리 횟수: 4번 (1 + N)
N은 comment 개수가 아니라 post 개수다. post가 100개면 쿼리 101번.


페이지네이션이 N+1의 숨통을 터준다

N+1이 실제로 문제가 되는 건 규모가 클 때다.

text
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 쿼리로 배치 처리한다.

ts
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}
ts
1@ResolveField(() => [Comment])
2async comments(@Parent() post: Post) {
3 return this.commentLoader.load(post.id);
4 // 개별 호출처럼 보이지만 DataLoader가 모아서 처리
5}

실행되는 SQL:

sql
1-- 1번: posts 조회
2SELECT * FROM post
3
4-- 2번: 모든 post의 comments 한 번에
5SELECT * FROM comment WHERE post_id IN (1, 2, 3)

쿼리 횟수: 2번
post가 20개여도 1000개여도 항상 2번이다.

@ResolveField를 그대로 유지하면서, 프론트가 요청하지 않으면 실행되지 않는 GraphQL 철학도 지킨다.


세 방법 비교

relations JOIN@ResolveFieldDataLoader
쿼리 횟수 (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가 번거롭다면 이런 방법도 있다.

ts
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도 필요 없다.

근데 이게 맞는 방향인가?

처음엔 괜찮아 보인다. 그런데 요구사항이 늘면 이렇게 된다.

ts
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를 도입하는 게 맞습니다.