N+1 문제와 DataLoader
GraphQL에서 가장 흔한 성능 문제는 N+1 쿼리입니다. 사용자 목록을 조회하면서 각 사용자의 게시글을 가져올 때, 사용자 1회 + 게시글 N회 = 총 N+1회 DB 쿼리가 발생합니다. DataLoader는 이를 자동으로 배치 처리하여 2회(사용자 1회 + 게시글 1회)로 줄입니다.
문제 시각화
# GraphQL 쿼리
query {
users { # SELECT * FROM users → 1회
id
name
posts { # 각 user마다 SELECT * FROM posts WHERE author_id = ? → N회
title
}
}
}
# DataLoader 적용 후
# SELECT * FROM users → 1회
# SELECT * FROM posts WHERE author_id IN (1, 2, 3, ..., N) → 1회
# 총 2회!
설치 및 기본 설정
npm install @nestjs/graphql @nestjs/apollo @apollo/server graphql dataloader
// app.module.ts
import { ApolloDriver, ApolloDriverConfig } from '@nestjs/apollo';
@Module({
imports: [
GraphQLModule.forRoot<ApolloDriverConfig>({
driver: ApolloDriver,
autoSchemaFile: true,
// 요청별 DataLoader 인스턴스 생성을 위한 컨텍스트
context: ({ req }) => ({ req }),
}),
],
})
export class AppModule {}
DataLoader 구현
기본 패턴 — 요청 스코프
import * as DataLoader from 'dataloader';
import { Injectable, Scope } from '@nestjs/common';
// 요청마다 새 인스턴스 — 캐시 격리
@Injectable({ scope: Scope.REQUEST })
export class PostLoader {
constructor(private readonly postRepo: PostRepository) {}
// 작성자 ID로 게시글 배치 로드
readonly byAuthorId = new DataLoader<string, Post[]>(
async (authorIds: readonly string[]) => {
// 한 번의 IN 쿼리로 모든 게시글 조회
const posts = await this.postRepo.find({
where: { authorId: In([...authorIds]) },
order: { createdAt: 'DESC' },
});
// authorId별로 그룹화하여 순서대로 반환
const postMap = new Map<string, Post[]>();
for (const post of posts) {
const group = postMap.get(post.authorId) ?? [];
group.push(post);
postMap.set(post.authorId, group);
}
// DataLoader 규칙: 입력 순서와 동일한 순서로 반환
return authorIds.map(id => postMap.get(id) ?? []);
},
);
}
// ID로 단일 엔티티 로드
@Injectable({ scope: Scope.REQUEST })
export class UserLoader {
constructor(private readonly userRepo: UserRepository) {}
readonly byId = new DataLoader<string, User | null>(
async (ids: readonly string[]) => {
const users = await this.userRepo.findByIds([...ids]);
const userMap = new Map(users.map(u => [u.id, u]));
return ids.map(id => userMap.get(id) ?? null);
},
);
}
Resolver에서 사용
@Resolver(() => User)
export class UserResolver {
constructor(
private readonly userService: UserService,
private readonly postLoader: PostLoader,
private readonly commentLoader: CommentLoader,
) {}
@Query(() => [User])
async users(): Promise<User[]> {
return this.userService.findAll();
}
// DataLoader로 N+1 방지
@ResolveField(() => [Post])
async posts(@Parent() user: User): Promise<Post[]> {
return this.postLoader.byAuthorId.load(user.id);
}
@ResolveField(() => [Comment])
async recentComments(@Parent() user: User): Promise<Comment[]> {
return this.commentLoader.byUserId.load(user.id);
}
}
@Resolver(() => Post)
export class PostResolver {
constructor(
private readonly userLoader: UserLoader,
private readonly commentLoader: CommentLoader,
) {}
// 게시글의 작성자 — 단일 엔티티 로드
@ResolveField(() => User)
async author(@Parent() post: Post): Promise<User | null> {
return this.userLoader.byId.load(post.authorId);
}
@ResolveField(() => [Comment])
async comments(@Parent() post: Post): Promise<Comment[]> {
return this.commentLoader.byPostId.load(post.id);
}
}
고급 패턴 — 조건부 배치
// 복합 키 DataLoader — (postId, status) 조합
@Injectable({ scope: Scope.REQUEST })
export class FilteredCommentLoader {
constructor(private readonly commentRepo: CommentRepository) {}
readonly byPostAndStatus = new DataLoader<
{ postId: string; status: string },
Comment[],
string // 캐시 키 타입
>(
async (keys) => {
const postIds = [...new Set(keys.map(k => k.postId))];
const statuses = [...new Set(keys.map(k => k.status))];
const comments = await this.commentRepo.find({
where: {
postId: In(postIds),
status: In(statuses),
},
});
return keys.map(key =>
comments.filter(c => c.postId === key.postId && c.status === key.status)
);
},
{
// 복합 키를 문자열로 변환하여 캐시 키 생성
cacheKeyFn: (key) => `${key.postId}:${key.status}`,
},
);
}
// Resolver에서 사용
@ResolveField(() => [Comment])
async approvedComments(@Parent() post: Post): Promise<Comment[]> {
return this.filteredCommentLoader.byPostAndStatus.load({
postId: post.id,
status: 'approved',
});
}
NestJS Module 패턴 — DataLoader 팩토리
// dataloader.module.ts — 중앙 관리
@Module({
providers: [
UserLoader,
PostLoader,
CommentLoader,
FilteredCommentLoader,
],
exports: [
UserLoader,
PostLoader,
CommentLoader,
FilteredCommentLoader,
],
})
export class DataLoaderModule {}
// 또는 팩토리 패턴으로 동적 생성
@Injectable({ scope: Scope.REQUEST })
export class DataLoaderFactory {
constructor(
private readonly userRepo: UserRepository,
private readonly postRepo: PostRepository,
) {}
private loaders = new Map<string, DataLoader<any, any>>();
getLoader<K, V>(
name: string,
batchFn: DataLoader.BatchLoadFn<K, V>,
): DataLoader<K, V> {
if (!this.loaders.has(name)) {
this.loaders.set(name, new DataLoader(batchFn));
}
return this.loaders.get(name)!;
}
get userById() {
return this.getLoader<string, User | null>('user-by-id', async (ids) => {
const users = await this.userRepo.findByIds([...ids]);
const map = new Map(users.map(u => [u.id, u]));
return ids.map(id => map.get(id) ?? null);
});
}
}
캐싱 전략
// DataLoader는 요청 내 자동 캐싱
// 같은 요청에서 user.id = "1"을 여러 번 load해도 DB 쿼리 1회
// 캐시 무효화 — mutation 후
@Mutation(() => Post)
async updatePost(
@Args('input') input: UpdatePostInput,
): Promise<Post> {
const post = await this.postService.update(input);
// 관련 캐시 무효화
this.postLoader.byAuthorId.clear(post.authorId);
this.postLoader.byAuthorId.prime(post.authorId, /* 새 데이터 */);
return post;
}
// 캐시 비활성화 옵션
const noCacheLoader = new DataLoader(batchFn, {
cache: false, // 매번 새로 로드
});
성능 모니터링
// DataLoader에 로깅/메트릭 추가
function createMonitoredLoader<K, V>(
name: string,
batchFn: DataLoader.BatchLoadFn<K, V>,
logger: Logger,
): DataLoader<K, V> {
return new DataLoader<K, V>(async (keys) => {
const start = Date.now();
const result = await batchFn(keys);
const duration = Date.now() - start;
logger.debug(
`DataLoader[${name}]: ${keys.length} keys in ${duration}ms`,
);
if (duration > 100) {
logger.warn(
`DataLoader[${name}]: Slow batch — ${keys.length} keys, ${duration}ms`,
);
}
return result;
}, {
maxBatchSize: 100, // 배치 크기 제한
});
}
운영 팁
- 반드시 REQUEST 스코프: DataLoader 캐시가 요청 간 공유되면 데이터 오염 발생
- 반환 순서 보장: batch 함수는 입력 키와 동일한 순서로 결과를 반환해야 함 — Map으로 매핑 후 순서대로 추출
- null 처리: 존재하지 않는 키에 대해
null을 반환해야 함 (빈 배열 아님) - maxBatchSize: PostgreSQL의
IN절 제한에 맞게 설정 (기본 무제한) - Code-First 통합:
@ResolveField에서만 DataLoader 사용,@Query에서는 직접 쿼리 - Injection Scope 주의: REQUEST 스코프 DataLoader를 SINGLETON 서비스에 주입하면 에러 발생
정리
DataLoader는 GraphQL N+1 문제의 표준 해결책입니다. 요청 스코프로 캐시를 격리하고, 배치 함수로 IN 쿼리 하나로 모든 관련 데이터를 로드합니다. 복합 키, 조건부 배치, 캐시 무효화까지 마스터하면 GraphQL API의 성능을 드라마틱하게 개선할 수 있습니다.