GraphQL에서 가장 흔한 성능 문제인 N+1 문제의 원인을 분석하고, DataLoader를 통한 배칭과 캐싱 최적화를 구현합니다. 쿼리 복잡도 분석과 깊이 제한 전략도 다룹니다.
3장에서 잠깐 언급한 N+1 문제를 구체적으로 살펴보겠습니다. 다음 쿼리를 실행한다고 가정합니다.
query {
posts(first: 10) {
edges {
node {
title
author {
name
}
}
}
}
}리졸버 실행 과정에서 발생하는 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 문제입니다.
중첩이 깊어지면 상황은 더 악화됩니다.
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는 Facebook이 개발한 범용 배칭/캐싱 유틸리티입니다. 두 가지 핵심 메커니즘으로 N+1 문제를 해결합니다.
DataLoader는 단일 이벤트 루프 틱(tick) 내에서 발생한 모든 개별 요청을 하나의 배치 요청으로 결합합니다.
같은 요청 내에서 동일한 키로 load()를 여러 번 호출하면, 첫 번째 호출의 결과를 캐싱하여 재사용합니다. 이 캐시는 요청 단위이며, 요청이 끝나면 자동으로 폐기됩니다.
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);
});
}DataLoader의 배치 함수는 반드시 입력 키 배열과 같은 길이, 같은 순서의 결과 배열을 반환해야 합니다. 존재하지 않는 키에 대해서는 null이나 Error 인스턴스를 반환해야 합니다. 이 규약을 어기면 데이터가 잘못 매핑됩니다.
하나의 작성자가 여러 포스트를 가지는 일대다 관계입니다.
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) ?? []);
});
}포스트와 태그의 다대다 관계 예시입니다.
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) ?? []);
});
}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는 반드시 매 요청마다 새 인스턴스를 생성해야 합니다. 요청 간에 공유하면 한 사용자의 데이터가 다른 사용자에게 노출될 수 있습니다.
// 올바른 사용: 매 요청마다 새 컨텍스트에서 생성
async function createContext(): Promise<Context> {
return {
loaders: createLoaders(prisma), // 새 인스턴스
};
}
// 잘못된 사용: 전역 공유
// const globalLoaders = createLoaders(prisma); // 절대 금지페이지네이션이나 필터가 있는 필드는 DataLoader로 처리하기 까다롭습니다. 키에 인자를 포함시켜야 합니다.
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) 을 통해 이를 방어해야 합니다.
import depthLimit from 'graphql-depth-limit';
const server = new ApolloServer({
typeDefs,
resolvers,
validationRules: [depthLimit(10)], // 최대 깊이 10
});각 필드에 비용을 부여하고 총 비용이 임계값을 초과하면 요청을 거부합니다.
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
}
`;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 이내)복잡도 임계값은 서비스 특성에 따라 조정해야 합니다. 처음에는 프로덕션 트래픽의 쿼리 복잡도를 모니터링한 후, p99 값의 2-3배를 임계값으로 설정하는 것이 좋습니다.
5장에서 상세히 다루겠지만, Apollo Federation 환경에서 DataLoader는 선택이 아닌 필수입니다.
Federation의 엔티티 참조 해결(Entity Resolution) 과정에서 라우터가 서브그래프에 _entities 쿼리를 보냅니다. 이때 DataLoader가 없으면 각 엔티티마다 개별 데이터베이스 쿼리가 발생하여 N+1 문제가 증폭됩니다.
// 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을 다룹니다. 서브그래프와 슈퍼그래프의 개념, 엔티티와 참조 리졸버, 주요 디렉티브, 그리고 스키마 컴포지션 과정을 상세히 살펴보겠습니다.
이 글이 도움이 되셨나요?
관련 주제 더 보기
Apollo Federation 2.0을 활용한 마이크로서비스 GraphQL 아키텍처를 다룹니다. 서브그래프와 슈퍼그래프 개념, 엔티티 참조 리졸버, 주요 디렉티브, 스키마 컴포지션 과정을 학습합니다.
GraphQL 리졸버 체인의 실행 흐름과 컨텍스트 설계를 이해합니다. 데이터 소스 추상화, 부모-자식 리졸버 관계, Apollo Server와 GraphQL Yoga에서의 실전 구현 패턴을 다룹니다.
GraphQL Subscriptions의 프로토콜과 구현을 다룹니다. WebSocket 전송, PubSub 패턴, Redis를 활용한 확장, 필터링 전략, 그리고 Federation 환경에서의 한계와 SSE 등 대안 패턴을 살펴봅니다.