본문 바로가기
NestJS

[NestJS] GraphQL 적용하기

by Programmer.Junny 2025. 5. 26.

1. GraphQL 이란?

GraphQL은 하나의 API 호출로 여러 소스에서 데이터를 가져올 수 있는 API용 오픈 소스 쿼리 언어이다. 또한 기존 데이터를 사용하여 쿼리를 수행하는 서버 측 런타임 역할도 한다.

1.1. 기존 REST API의 한계

1.1.1. 고정 구조 데이터 교환

REST API는 클라이언트 요청이 고정된 구조를 따라야 리소스를 수신할 수 있다. 이 엄격한 구조는 사용하기 쉽지만 필요한 데이터를 정확히 교환하기에 항상 가장 효율적인 수단인 것은 아니다.

1.1.2. 오버페칭 및 언더페칭

REST API는 항상 전체 데이터 세트를 반환한다. 예를 들어 REST API의 person 객체로부터는 그 사람의 이름, 생년월일, 주소 및 전화번호를 받게 된다. 전화번호만 있으면 이 모든 데이터를 얻을 수 있다.

마찬가지로, 개인의 전화번호와 마지막 구매 내역을 알려면 여러 개의 REST API 요청이 필요하게 된다. /person이라는 URL은 전화번호를 반환하고 /purchase라는 URL은 구매 내역을 반환한다.

이렇듯 불필요한 리소스를 가져오게 되므로 성능에 영향을 끼치게 된다.

1.2. GraphQL 은 어떻게 작동되는지?

GraphQL은 클라이언트에서 필요한 개체들을 쿼리로 서버에 요청하면 해당 쿼리는 검증된 후 스키마 기반으로 실행된다. 그리고 해당되는 리졸버 함수가 값을 생성하게 된다.

정리하자면, 클라이언트에서는 백엔드와 협의된 스키마를 기반으로 쿼리를 구성하고 요청한다.

 

위의 전화번호와 구매 내역을 예로 들자면,

query {
	getPerson(1) {
            name
            phoneNumber
            purchase {
                id
            }
    }
}

getPerson의 id가 1에 해당하는 유저를 가져오지만, 전화번호나 구매내역을 따로 구성하지 않고도 스키마가 정의되어 있다면 가져올 수 있게 된다.

기존의 REST API처럼 person, phoneNumber, purchase 세 번의 api를 호출하지 않아도 되게 된다.

1.3. GraphQL 핵심 용어

  • 스키마
    • API 개발자가 클라이언트가 어떤 데이터를 쿼리할 수 있는지 보여주기 위해 만든 스키마는 클라이언트가 요청할 수 있는 내용을 정의하는 개체 유형과 개체의 특성을 나타내는 필드로 구성됩니다.
  • 쿼리
    • 쿼리는 검증된 후 스키마를 기반으로 실행됩니다. GraphQL은 객체 유형을 정의하지 않고는 쿼리를 실행할 수 없습니다.
  • Resolver
    • 각 스키마 필드에 연결된 Resolver 함수는 API 실행에서 값을 생성하기 위해 호출됩니다. 리졸버는 GraphQL의 중요한 아키텍처 구성 요소입니다.

1.4. Rest와 GraphQL의 차이점 요약

  REST GraphQL
정의 REST는 클라이언트와 서버 간의 정형 데이터 교환을 정의하는 일련의 규칙입니다. GraphQL은 API를 생성하고 조작하기 위한 쿼리 언어, 아키텍처 스타일 및 도구 세트입니다.
적합한 용도 REST는 리소스가 잘 정의된 간단한 데이터 소스에 적합합니다. GraphQL은 크고 복잡하며 서로 연관된 데이터 소스에 적합합니다.
데이터 액세스 REST에는 리소스를 정의하는 URL 형태의 여러 엔드포인트가 있습니다. GraphQL에는 단일 URL 엔드포인트가 있습니다.
반환된 데이터 REST는 서버가 정의한 고정된 구조로 데이터를 반환합니다. GraphQL은 클라이언트가 정의한 유연한 구조로 데이터를 반환합니다.
데이터 구조 및 정의 방법 REST 데이터는 형식이 약하게 지정됩니다. 따라서 클라이언트는 형식이 지정된 데이터를 반환할 때 해석하는 방법을 결정해야 합니다. GraphQL 데이터는 엄격하게 형식이 지정됩니다. 따라서 클라이언트는 미리 결정되고 상호 이해되는 형식으로 데이터를 수신합니다.
오류 검사 REST를 사용할 경우 반환된 데이터가 유효한지 클라이언트가 확인해야 합니다. GraphQL을 사용하면 잘못된 요청이 일반적으로 스키마 구조에 의해 거부됩니다. 그 결과 오류 메시지가 자동으로 생성됩니다.

2. GraphQL 패키지 설치

npm install @nestjs/graphql @nestjs/apollo @apollo/server graphql

yarn add @nestjs/graphql @nestjs/apollo @apollo/server graphql

3. GraphQL 모듈 설정

import { GraphQLModule } from '@nestjs/graphql';
import { ApolloDriver, ApolloDriverConfig } from '@nestjs/apollo';
import { join } from 'path';

@Module({
  imports: [
    GraphQLModule.forRoot<ApolloDriverConfig>({
      driver: ApolloDriver,
      autoSchemaFile: join(process.cwd(), 'src/schema.gql'), // 스키마 파일 생성 경로
      sortSchema: true, // 스키마를 알파벳 순으로 정렬 (선택 사항)
      playground: true, // 개발 환경에서 GraphQL Playground 활성화
      debug: true, // 개발 환경에서 디버그 정보 활성화
    }),

autoSchemaFile

코드 퍼스트(Code-First) 방식을 사용할 때, TypeScript로 정의한 @ObjectType(), @Field() 등을 바탕으로 GraphQL SDL(schema definition language) 파일을 자동 생성해 준다.

 

sortSchema

생성된 SDL 파일 내의 type, input, enum 등의 정의 순서를 알파벳 순(사전식)으로 정렬해 준다.

 

playground

GraphQL 쿼리/변경 요청을 손쉽게 테스트할 수 있는 GraphQL Playground UI를 활성화한다.

debug

GraphQL 실행 중 발생하는 에러나 경고에 대한 디버그 정보를 콘솔(또는 클라이언트)로 출력한다.

4. GraphQL 스키마 설정

import { IsString } from 'class-validator';
import { BaseModel } from 'src/common/entity/base.entity';
import { ImageModel } from 'src/common/entity/image.entity';
import { stringValidationMessage } from 'src/common/validation-message/string-validation.message';
import { UsersModel } from 'src/users/entity/users.entity';
import { Column, Entity, Index, JoinColumn, ManyToOne, OneToMany, RelationId } from 'typeorm';
import { CommentsModel } from '../comments/entity/comments.entity';
import { Field, ObjectType } from '@nestjs/graphql';

@ObjectType()
@Entity()
export class PostsModel extends BaseModel {

  @Field(() => UsersModel)
  @ManyToOne(() => UsersModel, (user) => user.posts, {
    nullable: false,
  })
  @JoinColumn( {name: 'authorId'})
  author: UsersModel;

  @Field(() => Number)
  @Column()
  @Index('idx_post_author_id')
  @RelationId((post: PostsModel) => post.author)
  authorId: number;

  @Field()
  @Column()
  @IsString({
    message: stringValidationMessage,
  })
  title: string;

  @Field()
  @Column()
  @IsString({
    message: stringValidationMessage,
  })
  content: string;

  @Field(() => Number)
  @Column({
    default: 0,
  })
  likeCount: number;

  @Field(() => Number)
  @Column({
    default: 0,
  })
  commentCount: number;

  @OneToMany(() => ImageModel, (image) => image.post)
  images: ImageModel[];

  @OneToMany(() => CommentsModel, (comment) => comment.post)
  comments: CommentsModel[];
}
import { Field, ID, ObjectType } from "@nestjs/graphql";
import { CreateDateColumn, PrimaryGeneratedColumn, UpdateDateColumn } from "typeorm";

@ObjectType()
export abstract class BaseModel {
    @Field(() => ID)
    @PrimaryGeneratedColumn()
    id: number;

    @Field()
    @UpdateDateColumn()
    updatedAt: Date;

    @Field()
    @CreateDateColumn()
    createdAt: Date;
}

GraphQL 의 스키마로 사용하고 싶은 Entity와 필드에 @ObjectType(), @Field() 데코레이터를 붙여준다.

5. 리졸버 생성

import { Resolver, Query, Args, Int } from '@nestjs/graphql';
import { PostsModel } from '../entity/posts.entity';
import { PostsService } from '../posts.service';

@Resolver(() => PostsModel)
export class PostsResolver {
  constructor(private readonly postsService: PostsService) {}

  @Query(() => PostsModel, { nullable: true }) // 단일 게시물 조회
  getPost(@Args('id', { type: () => Int }) id: number): Promise<PostsModel | null> {
    // PostsService를 사용하여 ID로 게시물 조회 로직 구현
    return this.postsService.getPostById(id);
  }
}

GraphQL은 REST와 다르게 Controller라는 이름이 아닌 Resolver라는 이름의 API 호출 아키텍처를 사용한다.

기존에 만들어두었던 PostsService의 로직을 가져와서 사용할 수 있으나, 다른 점은 쿼리기반으로 메서드가 작동해야한다는 것이다.

그러기 위해 @Query@Args 를 받아 사용한다.

6. 모듈에 리졸버 등록

import { Module } from '@nestjs/common';
import { PostsService } from './posts.service';
import { PostsController } from './posts.controller';
import { TypeOrmModule } from '@nestjs/typeorm';
import { PostsModel } from './entity/posts.entity';
import { AuthModule } from 'src/auth/auth.module';
import { UsersModule } from 'src/users/users.module';
import { CommonModule } from 'src/common/common.module';
import { ImageModel } from 'src/common/entity/image.entity';
import { PostsImagesService } from './image/images.service';
import { PostsResolver } from './resolver/posts.resolver';

@Module({
  imports: [
    TypeOrmModule.forFeature([
      PostsModel,
      ImageModel,
    ]),
    AuthModule,
    UsersModule,
    CommonModule,
  ],
  controllers: [PostsController],
  providers: [PostsService, PostsImagesService, PostsResolver],
  exports: [PostsService],
})
export class PostsModule {}

PostsModule에선 리졸버를 사용하기 위해 providersPostsResolver를 등록한다.

7. 토큰 가드에 예외처리

import { CanActivate, ExecutionContext, Injectable, UnauthorizedException } from "@nestjs/common";
import { AuthService } from "../auth.service";
import { UsersService } from "src/users/users.service";
import { Reflector } from "@nestjs/core";
import { IS_PUBLIC_KEY } from "src/common/decorator/is-public.decorator";
import { IsPublicEnum } from "src/common/const/is-public.const";
import { GqlExecutionContext } from "@nestjs/graphql";

@Injectable()
export class BearerTokenGuard implements CanActivate {
    constructor(
        protected readonly authService: AuthService,
        protected readonly usersService: UsersService,
        protected readonly reflector: Reflector,
    ) {}

    async canActivate(context: ExecutionContext): Promise<boolean> {
        const isPublic = this.reflector.getAllAndOverride(IS_PUBLIC_KEY, [
            context.getHandler(),
            context.getClass(),
          ]);
      
          if (isPublic) {
            return true;
          }

        // GraphQL 요청인지 확인하고, 맞다면 GqlExecutionContext 사용
        const gqlContext = GqlExecutionContext.create(context);
        const req = gqlContext.getContext().req || context.switchToHttp().getRequest(); // GraphQL 컨텍스트에서 req 가져오기, 없으면 HTTP 컨텍스트 시도

        if (!req) {
            // 요청 객체를 가져올 수 없는 경우 (예상치 못한 상황)
            throw new UnauthorizedException('Request object not found in context.');
        }

        const rawToken = req.headers['authorization']; // 여기서 req.headers 접근

        if (!rawToken) {
            throw new UnauthorizedException('토큰이 없습니다!');
        }

        const token = this.authService.extractTokenFromHeader(rawToken, true);
        const result = await this.authService.verifyToken(token);
        const user = await this.usersService.getUserByEmail(result.email);

        // req에 user 정보 주입 (GraphQL 컨텍스트와 HTTP 컨텍스트 모두에)
        if (gqlContext.getContext().req) {
            gqlContext.getContext().req.user = user;
            gqlContext.getContext().req.token = token;
            gqlContext.getContext().req.tokenType = result.type;
        } else {
            req.user = user;
            req.token = token;
            req.tokenType = result.type;
        }

        return true;
    }
}

@Injectable()
export class AccessTokenGuard extends BearerTokenGuard {
    async canActivate(context: ExecutionContext): Promise<boolean> {
        // 1. 현재 경로가 @IsPublic으로 지정되었는지 확인 (BearerTokenGuard와 동일한 방식)
        const isPublic = this.reflector.getAllAndOverride<IsPublicEnum>(IS_PUBLIC_KEY, [
            context.getHandler(),
            context.getClass(),
        ]);

        if (isPublic === IsPublicEnum.IS_PUBLIC) {
            // 공개 경로이면 AccessTokenGuard도 즉시 통과
            return true;
        }

        // 2. 공개 경로가 아니라면, BearerTokenGuard의 로직 실행
        // (토큰 추출, 기본 검증. 실패 시 BearerTokenGuard에서 예외 발생)
        await super.canActivate(context);

        // 3. BearerTokenGuard가 성공적으로 완료되었다면, req 객체를 올바르게 가져옴
        const gqlContext = GqlExecutionContext.create(context);
        const req = gqlContext.getContext().req || context.switchToHttp().getRequest();

        if (!req) {
            // 이 경우는 super.canActivate()가 req를 설정하지 않았거나, 컨텍스트에서 req를 찾을 수 없는 예외적 상황
            throw new UnauthorizedException('Request object not found in AccessTokenGuard.');
        }

        if (!req.tokenType) {
            // BearerTokenGuard에서 tokenType을 설정해야 함
            throw new UnauthorizedException('Token type not found on request.');
        }

        if (req.tokenType !== 'access') {
            throw new UnauthorizedException('Access Token이 아닙니다.');
        }

        return true;
    }
}

@Injectable()
export class RefreshTokenGuard extends BearerTokenGuard {
    async canActivate(context: ExecutionContext): Promise<boolean> {
        const isPublic = this.reflector.getAllAndOverride<IsPublicEnum>(IS_PUBLIC_KEY, [
            context.getHandler(),
            context.getClass(),
        ]);

        // Refresh 토큰 경로는 일반적으로 공개되지 않으므로, 이 조건은 거의 해당되지 않을 수 있습니다.
        if (isPublic === IsPublicEnum.IS_PUBLIC) {
            return true;
        }

        await super.canActivate(context);

        const gqlContext = GqlExecutionContext.create(context);
        const req = gqlContext.getContext().req || context.switchToHttp().getRequest();

        if (!req) {
            throw new UnauthorizedException('Request object not found in RefreshTokenGuard.');
        }

        if (!req.tokenType) {
            throw new UnauthorizedException('Token type not found on request.');
        }

        if (req.tokenType !== 'refresh') {
            throw new UnauthorizedException('Refresh Token이 아닙니다.');
        }

        return true;
    }
}

또한 REST의 req와 GraphQL의 req는 각기 다르므로, JWT 토큰에 대한 처리도 수정해야 한다.

REST 통신에서의 req는 context.switchToHttp().getRequest() 이지만

GraphQL에서 req는 gqlContext.getContext().req 이다.

8. Log interceptor 수정

import {
    Injectable,
    NestInterceptor,
    ExecutionContext,
    CallHandler,
    Logger,
  } from '@nestjs/common';
import { GqlExecutionContext } from '@nestjs/graphql';
  import { Observable, tap } from 'rxjs';
  
  @Injectable()
  export class LogInterceptor implements NestInterceptor {
    private readonly logger = new Logger(LogInterceptor.name);
  
    intercept(context: ExecutionContext, next: CallHandler): Observable<any> {
      const now = new Date();

      // GraphQL 요청인지 확인하고, 맞다면 GqlExecutionContext 사용
      const gqlCtx = GqlExecutionContext.create(context);
      const info = gqlCtx.getInfo(); // GraphQL 요청의 경우 info 객체에서 정보 추출 가능
      const req = gqlCtx.getContext().req || context.switchToHttp().getRequest(); // 가드에서와 동일한 방식으로 req 객체 가져오기

      let requestInfo = 'Request - ';
      if (info && info.parentType && info.fieldName) { // GraphQL 요청인 경우
          requestInfo += `Type: ${info.parentType}, Field: ${info.fieldName}`;
          if (req && req.ip) { // req 객체가 있고 ip 정보가 있다면 추가
              requestInfo += `, IP: ${req.ip}`;
          }
      } else if (req) { // HTTP 요청인 경우 (기존 로직)
          const { method, originalUrl, ip } = req;
          requestInfo += `Method: ${method}, URL: ${originalUrl}, IP: ${ip}`;
      } else {
          requestInfo += 'Unknown request type';
      }

      console.log(`${requestInfo} - ${now.toLocaleString('kr')}`);

      return next.handle().pipe(
          tap(() => {
              // 응답 로깅 (필요한 경우)
              // GraphQL의 경우 응답 구조가 다를 수 있으므로, 응답 로깅 방식도 고려해야 합니다.
              // const res = gqlCtx.getContext().res || context.switchToHttp().getResponse();
              console.log(`Response - ${requestInfo} - ${new Date().toLocaleString('kr')} (Execution time: ${new Date().getMilliseconds() - now.getMilliseconds()}ms)`);
          })
      );
    }
  }

또한 LogInterceptor를 통해 로그를 확인하고 있다면, REST 통신에 맞게 호출되고 있으므로, 이 부분도 수정해야 한다.

9. 웹 UI에서 확인하기

기존의 REST API는 특정 포스트에서 원하는 데이터를 가져오지 못한다.

설정의 playground를 true로 설정했다면, UI에서도 쿼리형태로 요청 시 원하는 데이터를 가져올 수 있다.

위와 다르게 author의 내용을 제외하고도 가져올 수 있다. 스키마에서 @Field()를 설정했다면 가능하다.

import { Resolver, Query, Args, Int } from '@nestjs/graphql';
import { PostsModel } from '../entity/posts.entity';
import { PostsService } from '../posts.service';
import { IsPublic } from 'src/common/decorator/is-public.decorator';
import { IsPublicEnum } from 'src/common/const/is-public.const';

@Resolver(() => PostsModel)
export class PostsResolver {
  constructor(private readonly postsService: PostsService) {}

  @Query(() => PostsModel, { nullable: true }) // 단일 게시물 조회
  @IsPublic(IsPublicEnum.IS_PUBLIC)
  getPost(@Args('id', { type: () => Int }) id: number): Promise<PostsModel | null> {
    // PostsService를 사용하여 ID로 게시물 조회 로직 구현
    return this.postsService.getPostById(id);
  }
}

참고로 리졸버에서 구성한 getPost와 UI에서 호출할 쿼리내의 호출명이 같아야하며, 리졸버의 메서드에는 @IsPublic으로 구성되어있어야 JWT 토큰 검증을 받지않고 실행할 수 있게 된다.

10. Postman에서 확인하기

리졸버에서 @IsPublic을 하지 않는다면 토큰 검증에서 실패하여 에러가 발생한다.

프로모션에서는 토큰 검증이 필요한 경우가 있다. 이러한 경우 postman에서도 확인이 가능하다.

postman에서는 로그인 시 jwt 토큰이 유지되도록 이미 구현한바가 있다.

GraphQL은 통신이 무조건 POST로 진행되기 때문에, POST로 구성하고, Authorization을 구성한 뒤, Body에서 GraphQL로 진행하면 정상적으로 요청이 수행되는 것을 볼 수 있다.

최근댓글

최근글

skin by © 2024 ttuttak