본문으로 건너뛰기
Kreath Archive
TechProjectsBooksAbout
TechProjectsBooksAbout

내비게이션

  • Tech
  • Projects
  • Books
  • About
  • Tags

카테고리

  • AI / ML
  • 웹 개발
  • 프로그래밍
  • 개발 도구

연결

  • GitHub
  • Email
  • RSS
© 2026 Kreath Archive. All rights reserved.Built with Next.js + MDX
홈TechProjectsBooksAbout
//
  1. 홈
  2. 테크
  3. 4장: N+1 문제와 DataLoader 최적화
2026년 2월 12일·웹 개발·

4장: N+1 문제와 DataLoader 최적화

GraphQL에서 가장 흔한 성능 문제인 N+1 문제의 원인을 분석하고, DataLoader를 통한 배칭과 캐싱 최적화를 구현합니다. 쿼리 복잡도 분석과 깊이 제한 전략도 다룹니다.

13분584자8개 섹션
graphqlapi-designfrontend
공유
graphql-architecture4 / 11
1234567891011
이전3장: Resolver 구현과 데이터 로딩다음5장: Federation과 Supergraph

학습 목표

  • N+1 문제가 발생하는 구조적 원인을 이해한다
  • DataLoader의 배칭과 캐싱 원리를 파악한다
  • 일대다, 다대다 관계에서 DataLoader를 구현할 수 있다
  • 쿼리 복잡도 분석과 깊이 제한으로 API를 보호한다

N+1 문제의 원인 분석

3장에서 잠깐 언급한 N+1 문제를 구체적으로 살펴보겠습니다. 다음 쿼리를 실행한다고 가정합니다.

n1-query.graphql
graphql
query {
  posts(first: 10) {
    edges {
      node {
        title
        author {
          name
        }
      }
    }
  }
}

리졸버 실행 과정에서 발생하는 SQL 쿼리를 추적해 보면 다음과 같습니다.

n1-queries.sql
sql
-- 1번째 쿼리: 포스트 10개 조회
SELECT * FROM posts ORDER BY created_at DESC LIMIT 10;
 
-- 2번째 쿼리: 포스트 1의 작성자
SELECT * FROM users WHERE id = 'user-1';
 
-- 3번째 쿼리: 포스트 2의 작성자
SELECT * FROM users WHERE id = 'user-2';
 
-- 4번째 쿼리: 포스트 3의 작성자
SELECT * FROM users WHERE id = 'user-1';  -- 중복!
 
-- ... 총 11번의 쿼리 (1 + 10)

GraphQL은 각 필드의 리졸버를 독립적으로 실행합니다. Post.author 리졸버는 10개의 포스트 각각에 대해 개별 호출되므로, 같은 작성자를 여러 번 조회하게 됩니다. 이것이 N+1 문제입니다.

중첩이 깊어지면 상황은 더 악화됩니다.

deep-query.graphql
graphql
query {
  posts(first: 10) {         # 1 쿼리
    edges {
      node {
        author {              # 10 쿼리
          name
          recentPosts {       # 10 쿼리
            title
            comments(first: 3) { # 100 쿼리
              edges {
                node {
                  author {    # 300 쿼리
                    name
                  }
                }
              }
            }
          }
        }
      }
    }
  }
}
# 최악의 경우: 1 + 10 + 100 + 300 = 411 쿼리

DataLoader의 원리

DataLoader는 Facebook이 개발한 범용 배칭/캐싱 유틸리티입니다. 두 가지 핵심 메커니즘으로 N+1 문제를 해결합니다.

배칭(Batching)

DataLoader는 단일 이벤트 루프 틱(tick) 내에서 발생한 모든 개별 요청을 하나의 배치 요청으로 결합합니다.

캐싱(Caching)

같은 요청 내에서 동일한 키로 load()를 여러 번 호출하면, 첫 번째 호출의 결과를 캐싱하여 재사용합니다. 이 캐시는 요청 단위이며, 요청이 끝나면 자동으로 폐기됩니다.


DataLoader 구현

기본 구현

user-loader.ts
typescript
import DataLoader from 'dataloader';
import type { PrismaClient, User } from '@prisma/client';
 
function createUserByIdLoader(prisma: PrismaClient) {
  return new DataLoader<string, User | null>(async (ids) => {
    // 1. 배치된 ID들로 한 번에 조회
    const users = await prisma.user.findMany({
      where: { id: { in: [...ids] } },
    });
 
    // 2. ID 순서에 맞게 매핑 (DataLoader 규약)
    const userMap = new Map(users.map((user) => [user.id, user]));
    return ids.map((id) => userMap.get(id) ?? null);
  });
}
Warning

DataLoader의 배치 함수는 반드시 입력 키 배열과 같은 길이, 같은 순서의 결과 배열을 반환해야 합니다. 존재하지 않는 키에 대해서는 null이나 Error 인스턴스를 반환해야 합니다. 이 규약을 어기면 데이터가 잘못 매핑됩니다.

일대다 관계 DataLoader

하나의 작성자가 여러 포스트를 가지는 일대다 관계입니다.

posts-by-author-loader.ts
typescript
function createPostsByAuthorIdLoader(prisma: PrismaClient) {
  return new DataLoader<string, Post[]>(async (authorIds) => {
    const posts = await prisma.post.findMany({
      where: { authorId: { in: [...authorIds] } },
      orderBy: { createdAt: 'desc' },
    });
 
    // authorId별로 그룹화
    const postsByAuthor = new Map<string, Post[]>();
    for (const post of posts) {
      const existing = postsByAuthor.get(post.authorId) ?? [];
      existing.push(post);
      postsByAuthor.set(post.authorId, existing);
    }
 
    // 입력 키 순서에 맞게 반환 (없으면 빈 배열)
    return authorIds.map((id) => postsByAuthor.get(id) ?? []);
  });
}

다대다 관계 DataLoader

포스트와 태그의 다대다 관계 예시입니다.

tags-by-post-loader.ts
typescript
function createTagsByPostIdLoader(prisma: PrismaClient) {
  return new DataLoader<string, Tag[]>(async (postIds) => {
    const postTags = await prisma.postTag.findMany({
      where: { postId: { in: [...postIds] } },
      include: { tag: true },
    });
 
    const tagsByPost = new Map<string, Tag[]>();
    for (const pt of postTags) {
      const existing = tagsByPost.get(pt.postId) ?? [];
      existing.push(pt.tag);
      tagsByPost.set(pt.postId, existing);
    }
 
    return postIds.map((id) => tagsByPost.get(id) ?? []);
  });
}

컨텍스트에 통합

create-loaders.ts
typescript
interface Loaders {
  userById: DataLoader<string, User | null>;
  postsByAuthorId: DataLoader<string, Post[]>;
  tagsByPostId: DataLoader<string, Tag[]>;
  commentsByPostId: DataLoader<string, Comment[]>;
}
 
function createLoaders(prisma: PrismaClient): Loaders {
  return {
    userById: createUserByIdLoader(prisma),
    postsByAuthorId: createPostsByAuthorIdLoader(prisma),
    tagsByPostId: createTagsByPostIdLoader(prisma),
    commentsByPostId: createCommentsByPostIdLoader(prisma),
  };
}
 
// 리졸버에서 사용
const resolvers = {
  Post: {
    author: (parent: Post, _args: unknown, context: Context) => {
      return context.loaders.userById.load(parent.authorId);
    },
    tags: (parent: Post, _args: unknown, context: Context) => {
      return context.loaders.tagsByPostId.load(parent.id);
    },
    comments: (parent: Post, _args: unknown, context: Context) => {
      return context.loaders.commentsByPostId.load(parent.id);
    },
  },
};

DataLoader 적용 전후의 쿼리 수를 비교해 보겠습니다.

시나리오DataLoader 미적용DataLoader 적용
포스트 10개의 작성자11 쿼리 (1+10)2 쿼리 (1+1)
포스트 10개의 태그11 쿼리 (1+10)2 쿼리 (1+1)
포스트 10개 + 작성자 + 태그 + 댓글31 쿼리 (1+10+10+10)4 쿼리 (1+1+1+1)

DataLoader의 주의사항

요청 단위 생성

DataLoader는 반드시 매 요청마다 새 인스턴스를 생성해야 합니다. 요청 간에 공유하면 한 사용자의 데이터가 다른 사용자에게 노출될 수 있습니다.

per-request.ts
typescript
// 올바른 사용: 매 요청마다 새 컨텍스트에서 생성
async function createContext(): Promise<Context> {
  return {
    loaders: createLoaders(prisma), // 새 인스턴스
  };
}
 
// 잘못된 사용: 전역 공유
// const globalLoaders = createLoaders(prisma); // 절대 금지

인자가 있는 필드

페이지네이션이나 필터가 있는 필드는 DataLoader로 처리하기 까다롭습니다. 키에 인자를 포함시켜야 합니다.

parameterized-loader.ts
typescript
interface CommentLoaderKey {
  postId: string;
  first: number;
  orderBy: 'newest' | 'oldest';
}
 
function createCommentsLoader(prisma: PrismaClient) {
  return new DataLoader<CommentLoaderKey, Comment[]>(
    async (keys) => {
      // 동일한 인자를 가진 키들을 그룹화하여 최적화 가능
      const results = await Promise.all(
        keys.map((key) =>
          prisma.comment.findMany({
            where: { postId: key.postId },
            take: key.first,
            orderBy: { createdAt: key.orderBy === 'newest' ? 'desc' : 'asc' },
          })
        )
      );
      return results;
    },
    {
      // 객체 키를 비교하기 위한 캐시 키 함수
      cacheKeyFn: (key) => `${key.postId}:${key.first}:${key.orderBy}`,
    }
  );
}

쿼리 복잡도 분석

DataLoader가 N+1 문제를 해결하더라도, 악의적으로 깊거나 넓은 쿼리는 서버에 과부하를 줄 수 있습니다. 쿼리 복잡도 분석(Query Complexity Analysis) 을 통해 이를 방어해야 합니다.

깊이 제한(Depth Limiting)

depth-limit.ts
typescript
import depthLimit from 'graphql-depth-limit';
 
const server = new ApolloServer({
  typeDefs,
  resolvers,
  validationRules: [depthLimit(10)], // 최대 깊이 10
});

비용 분석(Cost Analysis)

각 필드에 비용을 부여하고 총 비용이 임계값을 초과하면 요청을 거부합니다.

cost-analysis.ts
typescript
import { createComplexityRule, simpleEstimator, fieldExtensionsEstimator } from 'graphql-query-complexity';
 
const complexityRule = createComplexityRule({
  maximumComplexity: 1000,
  estimators: [
    fieldExtensionsEstimator(),
    simpleEstimator({ defaultComplexity: 1 }),
  ],
  onComplete: (complexity: number) => {
    console.log(`Query complexity: ${complexity}`);
  },
});
 
// 스키마에서 필드별 비용 지정
const typeDefs = `
  type Query {
    posts(first: Int!): PostConnection! # complexity: first * 10
    user(id: ID!): User                 # complexity: 1
  }
`;

복잡도 계산 예시

complexity-example.graphql
graphql
query {
  posts(first: 10) {        # 비용: 10 * 10 = 100
    edges {
      node {
        title              # 비용: 1
        author {           # 비용: 10 (포스트 수)
          name             # 비용: 1
          posts(first: 5) { # 비용: 10 * 5 * 10 = 500
            edges {
              node {
                title      # 비용: 1
              }
            }
          }
        }
      }
    }
  }
}
# 총 비용: 약 613 (임계값 1000 이내)
Info

복잡도 임계값은 서비스 특성에 따라 조정해야 합니다. 처음에는 프로덕션 트래픽의 쿼리 복잡도를 모니터링한 후, p99 값의 2-3배를 임계값으로 설정하는 것이 좋습니다.


Federation에서의 DataLoader

5장에서 상세히 다루겠지만, Apollo Federation 환경에서 DataLoader는 선택이 아닌 필수입니다.

Federation의 엔티티 참조 해결(Entity Resolution) 과정에서 라우터가 서브그래프에 _entities 쿼리를 보냅니다. 이때 DataLoader가 없으면 각 엔티티마다 개별 데이터베이스 쿼리가 발생하여 N+1 문제가 증폭됩니다.

federation-reference-resolver.ts
typescript
// Federation 서브그래프의 참조 리졸버
const resolvers = {
  User: {
    __resolveReference: async (
      reference: { __typename: string; id: string },
      context: Context,
    ) => {
      // DataLoader 필수: 라우터가 여러 User 참조를 한 번에 보냄
      return context.loaders.userById.load(reference.id);
    },
  },
};

정리

이번 장에서는 GraphQL의 가장 흔한 성능 문제인 N+1 문제를 분석하고, DataLoader를 활용한 배칭과 캐싱 최적화를 구현했습니다. 일대일, 일대다, 다대다 관계에서의 DataLoader 구현 패턴을 살펴보았고, 쿼리 복잡도 분석과 깊이 제한을 통한 방어적 전략도 학습했습니다.

DataLoader는 GraphQL 서버의 성능을 극적으로 향상시키는 핵심 도구입니다. 모든 관계 필드 리졸버에서 DataLoader를 사용하는 것을 기본 원칙으로 삼아야 합니다.

다음 장 미리보기

5장에서는 마이크로서비스 환경에서 여러 서브그래프를 하나의 통합 API로 구성하는 Apollo Federation을 다룹니다. 서브그래프와 슈퍼그래프의 개념, 엔티티와 참조 리졸버, 주요 디렉티브, 그리고 스키마 컴포지션 과정을 상세히 살펴보겠습니다.

이 글이 도움이 되셨나요?

관련 주제 더 보기

#graphql#api-design#frontend

관련 글

웹 개발

5장: Federation과 Supergraph

Apollo Federation 2.0을 활용한 마이크로서비스 GraphQL 아키텍처를 다룹니다. 서브그래프와 슈퍼그래프 개념, 엔티티 참조 리졸버, 주요 디렉티브, 스키마 컴포지션 과정을 학습합니다.

2026년 2월 14일·15분
웹 개발

3장: Resolver 구현과 데이터 로딩

GraphQL 리졸버 체인의 실행 흐름과 컨텍스트 설계를 이해합니다. 데이터 소스 추상화, 부모-자식 리졸버 관계, Apollo Server와 GraphQL Yoga에서의 실전 구현 패턴을 다룹니다.

2026년 2월 10일·14분
웹 개발

6장: 실시간 구독(Subscriptions)

GraphQL Subscriptions의 프로토콜과 구현을 다룹니다. WebSocket 전송, PubSub 패턴, Redis를 활용한 확장, 필터링 전략, 그리고 Federation 환경에서의 한계와 SSE 등 대안 패턴을 살펴봅니다.

2026년 2월 16일·13분
이전 글3장: Resolver 구현과 데이터 로딩
다음 글5장: Federation과 Supergraph

댓글

목차

약 13분 남음
  • 학습 목표
  • N+1 문제의 원인 분석
  • DataLoader의 원리
    • 배칭(Batching)
    • 캐싱(Caching)
  • DataLoader 구현
    • 기본 구현
    • 일대다 관계 DataLoader
    • 다대다 관계 DataLoader
    • 컨텍스트에 통합
  • DataLoader의 주의사항
    • 요청 단위 생성
    • 인자가 있는 필드
  • 쿼리 복잡도 분석
    • 깊이 제한(Depth Limiting)
    • 비용 분석(Cost Analysis)
    • 복잡도 계산 예시
  • Federation에서의 DataLoader
  • 정리
    • 다음 장 미리보기