데이터 로더와 N+1 문제 해결
안녕하세요! 지난 절에서 NestJS와 GraphQL을 사용하여 쿼리, 뮤테이션, 서브스크립션을 구현하는 방법을 살펴보았습니다. 이제 GraphQL 서버의 성능을 최적화하고, 가장 흔한 성능 문제 중 하나인 N+1 문제를 데이터 로더(DataLoader) 를 활용하여 해결하는 방법에 대해 알아보겠습니다.
GraphQL의 유연성은 강력하지만, 데이터 페칭(data fetching) 방식에 주의하지 않으면 성능 병목 현상이 발생하기 쉽습니다. 특히 데이터베이스 쿼리가 반복적으로 발생하는 N+1 문제는 규모가 커질수록 서버 부하를 크게 증가시킬 수 있습니다.
N+1 문제란 무엇인가?
N+1 문제는 데이터베이스에서 관계형 데이터를 가져올 때 발생하는 일반적인 성능 문제입니다. 설명하기 위해 다음과 같은 시나리오를 가정해 봅시다.
우리가 게시물 목록을 조회하면서 각 게시물의 작성자 정보도 함께 가져와야 합니다.
- N+1 문제 발생 전 (일반적인 REST 또는 비효율적인 GraphQL)
- 1번 쿼리: 모든 게시물(N개)을 가져옵니다. (예:
SELECT * FROM posts;
) - N번 쿼리: 각 게시물에 대해 해당 게시물의 작성자(User) 정보를 개별적으로 가져옵니다. (예: 각 게시물마다
SELECT * FROM users WHERE id = [authorId];
) 총1 + N
번의 데이터베이스 쿼리가 발생합니다. 게시물 수가 늘어날수록 쿼리 수가 선형적으로 증가하여 성능 저하의 주범이 됩니다.
- 1번 쿼리: 모든 게시물(N개)을 가져옵니다. (예:
예시 쿼리
query {
posts {
id
title
author { # 이 부분이 N+1 문제를 발생시킵니다.
id
username
}
}
}
posts
리졸버가 모든 게시물을 가져온 후, 각 Post
객체의 author
필드를 해결하기 위해 author
리졸버가 각 게시물마다 별도의 데이터베이스 쿼리를 실행하는 것이 전형적인 N+1 문제입니다.
데이터 로더란?
데이터 로더(DataLoader) 는 Facebook에서 개발한 유틸리티 라이브러리로, GraphQL의 N+1 문제를 해결하는 데 특화되어 있습니다. 데이터 로더의 핵심 아이디어는 두 가지입니다.
- 배치 처리(Batching): 동일한 틱(tick) 내에서 여러 번 요청된 데이터 로드 요청을 모아서 단 한 번의 데이터베이스 또는 API 호출로 처리합니다. (예: N번의
SELECT * FROM users WHERE id = [id]
쿼리를SELECT * FROM users WHERE id IN ([id1], [id2], ...)
와 같은 한 번의 쿼리로 합칩니다.) - 캐싱(Caching): 한 번 로드된 데이터는 캐시에 저장하여 동일한 ID로 다시 요청될 경우 데이터베이스를 다시 조회하지 않고 캐시된 값을 반환합니다.
DataLoader는 각 GraphQL 요청 사이클(per-request cycle) 동안에만 요청들을 수집하고 배치합니다. 이는 하나의 HTTP 요청 내에서만 작동하며, 다른 HTTP 요청의 데이터 로더 인스턴스와는 독립적입니다.
NestJS에서 DataLoader 구현하기
NestJS에서 DataLoader를 통합하는 가장 좋은 방법은 @nestjs/graphql
과 함께 제공되는 유틸리티와 모듈 기반 아키텍처를 활용하는 것입니다.
단계 1: 필요한 패키지 설치
npm install dataloader
단계 2: User 데이터 로더 서비스 생성
사용자 정보를 배치 로드할 UsersLoader
서비스를 생성합니다. 이 서비스는 ID 배열을 받아 사용자 배열을 반환하는 배치 함수를 가집니다.
nest g s users/loaders/users.loader
// src/users/loaders/users.loader.ts
import { Injectable, Scope } from '@nestjs/common';
import * as DataLoader from 'dataloader'; // DataLoader 임포트
import { UsersService } from '../users.service'; // 사용자 데이터를 가져올 서비스
import { User } from '../models/user.model';
// 요청 스코프로 DataLoader를 생성하여 요청마다 새로운 인스턴스를 보장합니다.
// 이렇게 해야 한 요청 내에서만 배치가 유효하며, 다른 요청과 섞이지 않습니다.
@Injectable({ scope: Scope.REQUEST })
export class UsersLoader {
constructor(private usersService: UsersService) {}
// DataLoader 인스턴스를 저장할 속성
public readonly batchUsers = new DataLoader(async (userIds: number[]) => {
// 1. 배치 처리: usersService를 통해 여러 ID를 한 번에 조회합니다.
// 이 메서드는 usersService에 실제 데이터베이스 쿼리 로직을 가집니다.
// 여기서는 findByIds를 가정합니다.
console.log(`[DataLoader] Fetching users with IDs: ${userIds.join(', ')}`);
const users = await this.usersService.findByIds(userIds);
// 2. 캐싱을 위한 결과 매핑: DataLoader는 요청된 순서대로 결과를 기대합니다.
// 따라서 Map을 사용하여 ID와 User 객체를 매핑하고, 요청된 ID 순서대로 반환해야 합니다.
const userMap = new Map<number, User>();
users.forEach(user => userMap.set(user.id, user));
return userIds.map(id => userMap.get(id));
});
}
users.service.ts
업데이트 (findByIds 메서드 추가)
UsersService
에 여러 ID로 사용자를 조회하는 findByIds
메서드를 추가합니다. 실제로는 데이터베이스 쿼리를 여기에 구현합니다.
// src/users/users.service.ts
import { Injectable } from '@nestjs/common';
import { User } from './models/user.model'; // User 모델 임포트 (이전 절에서 정의)
@Injectable()
export class UsersService {
private users: User[] = [ // 임시 사용자 데이터
{ id: 101, username: 'user1', email: 'user1@example.com' },
{ id: 102, username: 'user2', email: 'user2@example.com' },
{ id: 103, username: 'user3', email: 'user3@example.com' },
{ id: 104, username: 'user4', email: 'user4@example.com' },
];
findOne(id: number): User | undefined {
return this.users.find(user => user.id === id);
}
// DataLoader가 호출할 배치 함수
async findByIds(ids: number[]): Promise<User[]> {
// 실제 DB 쿼리: SELECT * FROM users WHERE id IN (101, 102, 103);
return this.users.filter(user => ids.includes(user.id));
}
}
단계 3: UsersLoader
를 UsersModule
에 프로바이더로 등록
UsersLoader
는 UsersService
에 의존하므로, UsersModule
에서 함께 제공되어야 합니다.
// src/users/users.module.ts
import { Module } from '@nestjs/common';
import { UsersService } from './users.service';
import { UsersLoader } from './loaders/users.loader'; // UsersLoader 임포트
// import { UsersV1Controller, UsersV2Controller } from './users.controller'; // REST 컨트롤러 (이 예제와 무관)
import { User } from './models/user.model'; // User 모델도 임포트
@Module({
// controllers: [UsersV1Controller, UsersV2Controller], // REST 컨트롤러는 여기서 제거하거나 주석 처리
providers: [UsersService, UsersLoader, User], // UsersLoader와 UsersService를 프로바이더로 등록
exports: [UsersService, UsersLoader, User], // 다른 모듈에서 사용 가능하도록 export
})
export class UsersModule {}
단계 4: GraphQL Context
에 DataLoader 주입
각 GraphQL 요청마다 새로운 DataLoader 인스턴스를 생성하고, 이를 리졸버에서 접근할 수 있도록 GraphQL 컨텍스트(Context)에 주입해야 합니다.
// src/app.module.ts (업데이트: GraphQLModule 설정)
import { Module } from '@nestjs/common';
import { AppController } from './app.controller';
import { AppService } from './app.service';
import { GraphQLModule } from '@nestjs/graphql';
import { ApolloDriver, ApolloDriverConfig } from '@nestjs/apollo';
import { join } from 'path';
import { PostsModule } from './posts/posts.module';
import { UsersModule } from './users/users.module'; // UsersModule 임포트
import { UsersLoader } from './users/loaders/users.loader'; // UsersLoader 임포트
@Module({
imports: [
GraphQLModule.forRoot<ApolloDriverConfig>({
driver: ApolloDriver,
autoSchemaFile: join(process.cwd(), 'src/schema.gql'),
sortSchema: true,
playground: true,
subscriptions: {
'graphql-ws': true,
'subscriptions-transport-ws': true,
},
// context를 통해 DataLoader 인스턴스 주입
// 이 함수는 GraphQL 요청마다 실행됩니다.
context: ({ req, connection }) => {
// HTTP 요청 (쿼리/뮤테이션) 또는 WebSocket 연결 (서브스크립션)에 따라 다름
if (connection) {
// 서브스크립션 연결 시 DataLoader를 초기화
return { dataLoaders: new UsersLoader(connection.context.usersService) };
} else {
// HTTP 요청 시 DataLoader를 초기화 (req.injectedServices에 접근)
// UsersLoader는 요청 스코프이므로, NestJS의 DI 시스템을 통해 주입받아야 합니다.
// 이를 위해 ApolloDriverConfig의 buildServiceContext 옵션을 사용하거나,
// 또는 다음과 같이 Injector를 사용하여 수동으로 인스턴스를 가져올 수 있습니다.
// NestJS 9+에서는 request.injector를 사용하여 요청 스코프 인스턴스에 접근 가능합니다.
// 더 간단한 방법은 아래 buildServiceContext를 사용하는 것입니다.
}
},
// NestJS 9+에서 요청 스코프 프로바이더를 GraphQL context에 쉽게 주입하는 방법
// 이 함수는 각 GraphQL 요청에 대해 한 번만 실행됩니다.
buildServiceContext: async (context) => {
const { req, connection } = context;
if (req) {
// HTTP 요청 (쿼리, 뮤테이션)
// UsersLoader 인스턴스를 NestJS의 Request scoped Injector를 통해 가져옵니다.
const usersLoader = await req.injector.resolve(UsersLoader);
return { usersLoader };
} else if (connection) {
// WebSocket 연결 (서브스크립션)
// 서브스크립션은 별도의 LifeCycle을 가지므로, DataLoader를 직접 생성합니다.
// 이 경우 UsersService도 직접 주입해야 할 수 있습니다. (singleton으로 등록된 서비스)
// 예시: const usersService = app.get(UsersService);
// return { usersLoader: new UsersLoader(usersService) };
// 하지만 일반적으로 Subscription Context는 조금 다르게 관리될 수 있습니다.
// 간단하게는 서브스크립션에서는 DataLoader를 사용하지 않거나,
// 별도의 DataLoader 인스턴스를 생성하는 것으로 시작할 수 있습니다.
// 여기서는 간단하게 HTTP 요청에 대해서만 DataLoader를 설정합니다.
return {}; // 서브스크립션 요청에는 DataLoader가 필요 없을 수도 있습니다.
}
return {};
},
}),
PostsModule,
UsersModule,
],
controllers: [AppController],
providers: [AppService],
})
export class AppModule {}
주의: NestJS의 요청 스코프(Request scope) 프로바이더를 context
에 주입하는 것은 약간 복잡할 수 있습니다. 위 예시는 NestJS 9+에서 buildServiceContext
와 req.injector.resolve
를 사용하는 방식을 보여줍니다.
단계 5: 리졸버에서 DataLoader 사용
PostsResolver
에서 author
필드를 해결할 때 UsersLoader
를 사용하도록 수정합니다.
// src/posts/posts.resolver.ts (업데이트)
import { Resolver, Query, Mutation, Args, Int, ResolveField, Parent, Subscription } from '@nestjs/graphql';
import { Post } from './models/post.model';
import { User } from '../users/models/user.model';
import { CreatePostInput } from './dto/create-post.input';
import { UpdatePostInput } from './dto/update-post.input';
import { PubSub } from 'graphql-subscriptions';
import { UsersLoader } from '../users/loaders/users.loader'; // UsersLoader 임포트
import { GqlContext } from 'src/common/decorators/gql-context.decorator'; // Custom Decorator (아래 정의)
const pubSub = new PubSub();
// GqlContext 커스텀 데코레이터 정의
// src/common/decorators/gql-context.decorator.ts (새로 생성)
import { createParamDecorator, ExecutionContext } from '@nestjs/common';
import { GqlExecutionContext } from '@nestjs/graphql';
export const GqlContext = createParamDecorator(
(data: unknown, context: ExecutionContext) => {
const gqlContext = GqlExecutionContext.create(context).getContext();
return gqlContext;
},
);
@Resolver(() => Post)
export class PostsResolver {
private readonly posts: Post[] = [
{ id: 1, title: '첫 번째 게시물', content: 'GraphQL 학습 중입니다.', authorId: 101 }, // authorId로 변경
{ id: 2, title: '두 번째 게시물', content: 'NestJS와 GraphQL 통합!', authorId: 102 }, // authorId로 변경
{ id: 3, title: '세 번째 게시물', content: 'N+1 문제 해결!', authorId: 101 }, // 동일 저자
];
private nextPostId = 4; // nextPostId 업데이트
// (User 임시 데이터는 UsersService로 이동했으므로 여기서 제거)
constructor(private readonly usersLoader: UsersLoader) {} // UsersLoader 주입 (컨트롤러 아님 리졸버)
@Query(() => [Post], { name: 'posts', description: '모든 게시물 목록을 조회합니다.' })
getAllPosts(): Post[] {
return this.posts;
}
@Query(() => Post, { name: 'post', nullable: true, description: 'ID로 특정 게시물을 조회합니다.' })
getPost(@Args('id', { type: () => Int, description: '조회할 게시물의 ID' }) id: number): Post | undefined {
return this.posts.find(post => post.id === id);
}
// ResolveField: Post 타입의 author 필드를 해결합니다.
// DataLoader를 사용하여 N+1 문제를 해결합니다.
@ResolveField(() => User, { description: '게시물 작성자 정보를 반환합니다.' })
async author(@Parent() post: Post, @GqlContext() context: any): Promise<User> {
// context.usersLoader를 통해 DataLoader 인스턴스에 접근
// console.log(`[ResolveField] Fetching author for post ${post.id} (authorId: ${post.authorId})`);
return context.usersLoader.batchUsers.load(post.authorId); // DataLoader의 load 메서드 사용
}
// Mutation 코드 (createPost, updatePost, deletePost)는 user.model.ts 업데이트를 반영하여 수정 필요
@Mutation(() => Post, { description: '새로운 게시물을 생성합니다.' })
async createPost(@Args('createPostInput') createPostInput: CreatePostInput): Promise<Post> {
const newPost: Post = {
id: this.nextPostId++,
title: createPostInput.title,
content: createPostInput.content,
authorId: createPostInput.authorId, // authorId 직접 저장
};
this.posts.push(newPost);
await pubSub.publish('postAdded', { postAdded: newPost });
return newPost;
}
// ... (updatePost, deletePost도 authorId 필드를 직접 다루도록 수정)
// Subscription 코드 (동일)
}
src/posts/models/post.model.ts
업데이트 (authorId 필드 추가)
// src/posts/models/post.model.ts
import { Field, Int, ObjectType } from '@nestjs/graphql';
import { User } from '../../users/models/user.model';
@ObjectType()
export class Post {
@Field(() => Int, { description: '게시물의 고유 ID' })
id: number;
@Field({ description: '게시물의 제목' })
title: string;
@Field({ nullable: true, description: '게시물의 내용' })
content?: string;
@Field(() => Int, { description: '게시물 작성자의 고유 ID (내부용)' })
authorId: number; // 새로운 필드: 작성자 ID를 직접 가짐
@Field(() => User, { description: '게시물 작성자 정보' })
// author 필드는 authorId를 통해 ResolveField에서 해결됩니다.
author: User; // 이 필드는 실제 데이터가 아니라 GraphQL 스키마를 위한 가상 필드입니다.
}
src/users/models/user.model.ts
업데이트 (username, email 필드 추가)
// src/users/models/user.model.ts
import { Field, Int, ObjectType } from '@nestjs/graphql';
@ObjectType()
export class User {
@Field(() => Int)
id: number;
@Field()
username: string; // GraphQL 필드로 추가
@Field()
email: string; // GraphQL 필드로 추가
}
N+1 문제 해결 확인
-
애플리케이션을 실행합니다:
npm run start:dev
-
http://localhost:3000/graphql
로 접속하여 GraphQL Playground를 엽니다. -
다음 쿼리를 실행해 봅니다.
query GetPostsWithAuthors { posts { id title author { id username } } }
콘솔 로그를 확인해 보면, 이전에는
[ResolveField]
로그가 게시물 수만큼 여러 번 출력되었을 것이지만, DataLoader를 적용한 후에는[DataLoader]
로그가 단 한 번만 (모든 사용자 ID를 포함하여) 출력되는 것을 볼 수 있습니다.결과 예시
[DataLoader] Fetching users with IDs: 101, 102
(만약 데이터에 101, 102 외에 다른 사용자 ID가 있다면 함께 출력될 것입니다.)
이 로그는 여러 게시물의 작성자 정보를 가져오기 위해 단 한 번의 findByIds
호출 (즉, 한 번의 데이터베이스 쿼리)만 발생했음을 의미합니다. 이것이 바로 DataLoader가 N+1 문제를 해결하는 방식입니다.
DataLoader는 GraphQL 애플리케이션의 성능 최적화에 있어 매우 중요한 도구입니다. 배치 처리와 캐싱을 통해 불필요한 데이터베이스 쿼리를 줄이고, 서버 리소스를 효율적으로 사용하여 응답 시간을 단축시킬 수 있습니다. NestJS의 강력한 DI 시스템과 GraphQL 통합 덕분에 DataLoader를 손쉽게 적용할 수 있습니다.
이것으로 6장 "GraphQL 서버 구축"의 세 번째 절을 마칩니다.