NestJS GraphQL이란?
NestJS는 @nestjs/graphql 패키지를 통해 GraphQL API를 일급 시민으로 지원합니다. Code-First(TypeScript 데코레이터로 스키마 자동 생성)와 Schema-First(.graphql SDL 파일 기반) 두 가지 접근 방식을 제공하며, Apollo Server와 Mercurius 드라이버를 선택할 수 있습니다.
REST API 대비 GraphQL의 강점은 오버페칭/언더페칭 해결, 단일 엔드포인트, 강력한 타입 시스템입니다. 이 글에서는 Code-First 접근 방식으로 Resolver, DataLoader, Subscription, 인증/인가까지 심화 분석합니다.
설치와 기본 설정
npm install @nestjs/graphql @nestjs/apollo @apollo/server graphql
// app.module.ts
import { ApolloDriver, ApolloDriverConfig } from '@nestjs/apollo';
import { GraphQLModule } from '@nestjs/graphql';
@Module({
imports: [
GraphQLModule.forRoot<ApolloDriverConfig>({
driver: ApolloDriver,
autoSchemaFile: join(process.cwd(), 'src/schema.gql'), // Code-First
sortSchema: true,
playground: process.env.NODE_ENV !== 'production',
introspection: process.env.NODE_ENV !== 'production',
context: ({ req, res }) => ({ req, res }),
formatError: (error) => ({
message: error.message,
code: error.extensions?.code,
path: error.path,
}),
}),
UserModule,
PostModule,
],
})
export class AppModule {}
ObjectType: 스키마 정의
Code-First에서는 TypeScript 클래스와 데코레이터로 GraphQL 스키마를 정의합니다:
// user.model.ts
@ObjectType({ description: '사용자' })
export class UserType {
@Field(() => ID)
id: string;
@Field()
email: string;
@Field()
name: string;
@Field(() => UserRole)
role: UserRole;
@Field({ nullable: true })
bio?: string;
@Field(() => Int)
loginCount: number;
@Field(() => [PostType], { description: '작성한 게시글' })
posts?: PostType[];
@Field()
createdAt: Date;
}
// Enum 정의
registerEnumType(UserRole, {
name: 'UserRole',
description: '사용자 역할',
});
// Input 타입
@InputType()
export class CreateUserInput {
@Field()
@IsEmail()
email: string;
@Field()
@MinLength(2)
@MaxLength(50)
name: string;
@Field(() => UserRole, { defaultValue: UserRole.USER })
role: UserRole;
@Field({ nullable: true })
bio?: string;
}
@InputType()
export class UpdateUserInput extends PartialType(CreateUserInput) {
@Field(() => ID)
id: string;
}
Resolver: Query와 Mutation
@Resolver(() => UserType)
export class UserResolver {
constructor(
private readonly userService: UserService,
private readonly postService: PostService,
) {}
// Query: 단일 조회
@Query(() => UserType, { name: 'user', nullable: true })
async getUser(@Args('id', { type: () => ID }) id: string) {
return this.userService.findById(id);
}
// Query: 목록 조회 + 페이지네이션
@Query(() => UserConnection)
async users(
@Args() pagination: PaginationArgs,
@Args('filter', { nullable: true }) filter?: UserFilterInput,
) {
return this.userService.findAll(pagination, filter);
}
// Mutation: 생성
@Mutation(() => UserType)
async createUser(@Args('input') input: CreateUserInput) {
return this.userService.create(input);
}
// Mutation: 수정
@Mutation(() => UserType)
async updateUser(@Args('input') input: UpdateUserInput) {
return this.userService.update(input.id, input);
}
// Mutation: 삭제
@Mutation(() => Boolean)
async deleteUser(@Args('id', { type: () => ID }) id: string) {
return this.userService.delete(id);
}
// Field Resolver: User.posts 필드 요청 시에만 실행
@ResolveField(() => [PostType])
async posts(@Parent() user: UserType) {
return this.postService.findByAuthorId(user.id);
}
// Field Resolver: 계산 필드
@ResolveField(() => Int)
async postCount(@Parent() user: UserType) {
return this.postService.countByAuthorId(user.id);
}
}
Cursor 기반 페이지네이션
// pagination.ts — Relay 스타일 커넥션
@ObjectType()
export class PageInfo {
@Field({ nullable: true })
endCursor?: string;
@Field()
hasNextPage: boolean;
@Field({ nullable: true })
startCursor?: string;
@Field()
hasPreviousPage: boolean;
}
@ObjectType()
export class UserEdge {
@Field(() => UserType)
node: UserType;
@Field()
cursor: string;
}
@ObjectType()
export class UserConnection {
@Field(() => [UserEdge])
edges: UserEdge[];
@Field(() => PageInfo)
pageInfo: PageInfo;
@Field(() => Int)
totalCount: number;
}
@ArgsType()
export class PaginationArgs {
@Field(() => Int, { defaultValue: 20 })
@Min(1)
@Max(100)
first: number;
@Field({ nullable: true })
after?: string;
}
DataLoader: N+1 문제 해결
GraphQL의 가장 흔한 성능 문제인 N+1 쿼리를 DataLoader로 해결합니다:
// post.loader.ts
@Injectable({ scope: Scope.REQUEST })
export class PostLoader {
constructor(private readonly postService: PostService) {}
// 사용자 ID로 게시글 배치 로딩
readonly byAuthorId = new DataLoader<string, PostType[]>(
async (authorIds: readonly string[]) => {
const posts = await this.postService.findByAuthorIds([...authorIds]);
// authorId별로 그룹핑
const postMap = new Map<string, PostType[]>();
for (const post of posts) {
const existing = postMap.get(post.authorId) || [];
existing.push(post);
postMap.set(post.authorId, existing);
}
// 입력 순서대로 반환 (DataLoader 규약)
return authorIds.map(id => postMap.get(id) || []);
},
);
}
// Resolver에서 DataLoader 사용
@Resolver(() => UserType)
export class UserResolver {
constructor(private readonly postLoader: PostLoader) {}
@ResolveField(() => [PostType])
async posts(@Parent() user: UserType) {
// N+1 방지: 같은 요청 내 모든 user.posts를 하나의 쿼리로 배치
return this.postLoader.byAuthorId.load(user.id);
}
}
핵심: DataLoader는 Scope.REQUEST로 설정해야 합니다. 요청마다 새 인스턴스를 생성하여 캐시가 요청 간에 공유되지 않도록 합니다.
Subscription: 실시간 이벤트
// GraphQL 모듈에 Subscription 활성화
GraphQLModule.forRoot<ApolloDriverConfig>({
driver: ApolloDriver,
autoSchemaFile: true,
subscriptions: {
'graphql-ws': true, // 최신 프로토콜
'subscriptions-transport-ws': true, // 레거시 호환
},
installSubscriptionHandlers: true,
})
// pub-sub 설정
@Module({
providers: [
{
provide: 'PUB_SUB',
useValue: new PubSub(), // 프로덕션에서는 RedisPubSub 사용
},
],
exports: ['PUB_SUB'],
})
export class PubSubModule {}
// Resolver에서 Subscription 정의
@Resolver(() => PostType)
export class PostResolver {
constructor(
@Inject('PUB_SUB') private readonly pubSub: PubSub,
private readonly postService: PostService,
) {}
@Mutation(() => PostType)
async createPost(@Args('input') input: CreatePostInput) {
const post = await this.postService.create(input);
// 이벤트 발행
await this.pubSub.publish('postCreated', { postCreated: post });
return post;
}
// 실시간 구독
@Subscription(() => PostType, {
filter: (payload, variables) =>
// 특정 작성자의 글만 구독
!variables.authorId || payload.postCreated.authorId === variables.authorId,
})
postCreated(
@Args('authorId', { nullable: true }) authorId?: string,
) {
return this.pubSub.asyncIterableIterator('postCreated');
}
}
인증과 인가
// GQL Auth Guard
@Injectable()
export class GqlAuthGuard extends AuthGuard('jwt') {
getRequest(context: ExecutionContext) {
const ctx = GqlExecutionContext.create(context);
return ctx.getContext().req;
}
}
// 현재 사용자 데코레이터
export const CurrentUser = createParamDecorator(
(data: unknown, context: ExecutionContext) => {
const ctx = GqlExecutionContext.create(context);
return ctx.getContext().req.user;
},
);
// 역할 기반 Guard
@Injectable()
export class RolesGuard implements CanActivate {
constructor(private readonly reflector: Reflector) {}
canActivate(context: ExecutionContext): boolean {
const roles = this.reflector.get<string[]>('roles', context.getHandler());
if (!roles) return true;
const ctx = GqlExecutionContext.create(context);
const user = ctx.getContext().req.user;
return roles.includes(user.role);
}
}
// Resolver에서 사용
@Resolver(() => UserType)
export class UserResolver {
@Query(() => UserType)
@UseGuards(GqlAuthGuard)
async me(@CurrentUser() user: User) {
return user;
}
@Mutation(() => Boolean)
@UseGuards(GqlAuthGuard, RolesGuard)
@SetMetadata('roles', ['ADMIN'])
async deleteUser(@Args('id', { type: () => ID }) id: string) {
return this.userService.delete(id);
}
}
Complexity와 Depth 제한
GraphQL은 클라이언트가 자유롭게 쿼리를 구성하므로, 악의적인 깊은 중첩 쿼리를 방어해야 합니다:
npm install graphql-query-complexity graphql-depth-limit
// app.module.ts
GraphQLModule.forRoot<ApolloDriverConfig>({
driver: ApolloDriver,
autoSchemaFile: true,
validationRules: [
depthLimit(7), // 최대 깊이 7단계
],
plugins: [
{
async requestDidStart() {
return {
async didResolveOperation({ request, document }) {
const complexity = getComplexity({
schema,
operationName: request.operationName,
query: document,
variables: request.variables,
estimators: [
fieldExtensionsEstimator(),
simpleEstimator({ defaultComplexity: 1 }),
],
});
if (complexity > 100) {
throw new Error(`Query too complex: ${complexity}/100`);
}
},
};
},
},
],
})
관련 글: NestJS Interceptor 6가지 패턴에서 응답 변환 패턴을, NestJS DI Scope·Provider 심화에서 REQUEST 스코프 DataLoader 관리를 함께 확인하세요.
마무리
NestJS GraphQL은 Code-First 접근으로 TypeScript 타입과 GraphQL 스키마를 단일 소스로 관리합니다. DataLoader로 N+1 방지, Subscription으로 실시간 이벤트, Complexity 제한으로 보안 — 이 세 가지가 프로덕션 GraphQL API의 핵심입니다. REST와 GraphQL을 하이브리드로 제공하는 것도 NestJS에서는 같은 서비스 레이어를 공유하여 쉽게 구현할 수 있습니다.