PUBLISHED

12. GraphQL로 요청 처리

작성일: 2024.11.14

12. GraphQL로 요청 처리

GraphQL은 페이스북에서 개발한 데이터 쿼리 언어이자 런타임으로, 기존 REST API가 가진 구조적 한계를 보완하기 위해 등장했다. 전통적인 REST API에서는 각 엔드포인트마다 반환되는 데이터 구조가 고정되어 있고, 동일한 리소스에 대해 서로 다른 작업을 수행하기 위해 HTTP method를 조합하는 방식이 일반적이었다.

반면 GraphQL은 단 하나의 엔드포인트를 사용하고, 클라이언트가 필요한 데이터의 형태를 직접 명시해 요청한다. 서버는 그 요청에 정확히 맞는 데이터만 응답한다. 이 구조 덕분에 불필요한 데이터 전송이 줄어들고, 한 번의 요청으로 여러 자원에 대한 데이터를 동시에 가져올 수 있어 네트워크 사용 측면에서도 효율적이다.

표준적인 GraphQL 요청은 POST method만을 사용하며, application/json 형식의 바디에 쿼리를 담아 전송한다.

GraphQLModule

Nest.js에서 GraphQL을 사용하려면 @nestjs/graphql@nestjs/apollo 패키지를 설치하고, 이를 통해 동적 모듈인 GraphQLModuleAppModule에 등록해야 한다.

Apollo는 GraphQL 서버와 클라이언트를 구현하기 위한 도구 모음으로, Nest.js에서는 Apollo Server를 기반으로 한 ApolloDriver가 이미 구현되어 있다. 덕분에 복잡한 설정 없이도 GraphQL 서버를 빠르게 구성할 수 있다.

untitled
PY
@Module({
  imports: [
    GraphQLModule.forRoot<ApolloDriverConfig>({
      driver: ApolloDriver,
      autoSchemaFile: join(process.cwd(), 'src/schema.gql'),
      playground: true,
      path: '/api/graphql',
    }),
  ],
})

autoSchemaFile 옵션은 코드 기반(code-first) GraphQL 스키마 생성을 가능하게 한다. @ObjectType, @InputType, @Resolver 등에 정의된 메타데이터를 수집해, GraphQL 스키마 파일을 자동으로 생성한다. 이 파일은 절대 경로 기준으로 생성되며, import된 모든 타입을 기반으로 스키마가 구성된다.

GraphQL Playground는 REST API의 Swagger에 해당하는 도구로, 쿼리 작성과 응답 확인을 동시에 할 수 있다. app.setGlobalPrefix('api')를 설정하더라도 GraphQL은 기본적으로 /graphql 경로를 사용하지만, 나는 REST API와 경로 규칙을 일부 통일하기 위해 path 옵션을 사용했다.

Resolver

GraphQL에서 리졸버(Resolver) 는 특정 필드의 데이터를 조회하거나 변경하는 함수다. Nest.js의 REST 컨트롤러와 유사한 역할을 하지만, 리소스 중심이 아니라 응답 스키마 중심으로 구성된다는 점에서 차이가 있다.

REST 컨트롤러가 보통 “유저”, “게시글” 같은 서버 자원 단위로 나뉜다면, GraphQL 리졸버는 “어떤 타입을 어떻게 응답할 것인가”를 기준으로 구성되는 경우가 많다. 물론 이 역시 강제되는 규칙은 아니며, 여러 응답을 하나의 리졸버에서 처리한다고 해서 문제가 되는 것은 아니다.

untitled
TS
@Resolver(() => Book)
export class BookResolver {
  constructor(private bookService: BookService) {}

  @Query(() => [Book])
  async getBooks(
    @Args({ name: 'isbn13s', type: () => [String] })
    isbn13s: string[],
  ) {
    return this.bookService.getBooksByIsbnList(isbn13s);
  }

  @Mutation(() => Book)
  async patchBook(
    @Args({ name: 'input', type: () => BookInput })
    bookInput: BookInput,
  ) {
    return this.bookService.patchBook(bookInput);
  }
}

@Resolver 데코레이터의 타입 지정은 가독성을 높이기 위한 용도에 가깝지만, @Query@Mutation 데코레이터의 반환 타입은 GraphQL 스키마 자체에 영향을 미치므로 반드시 정확해야 한다.

InputType과 ObjectType

위 예제에서 getBooksisbn13s라는 Args를 받는다. 타입은 () => [String], 즉 Array<string>이다. 여기서 주의할 점은 GraphQL에서는 배열의 요소 타입을 혼합할 수 없다는 것이다.

untitled
TS
// ❌ 잘못된 정의
@Args({ type: () => [String, Number] })
isbn13s: Array<string | number>;

GraphQL 스키마에서는 이런 정의가 허용되지 않기 때문에, 필요한 경우 타입을 분리해야 한다.

untitled
TS
// ✅ 올바른 정의
@Args({ name: 'isbn13Strings', type: () => [String], nullable: true })
isbn13Strings?: string[];

@Args({ name: 'isbn13Numbers', type: () => [Number], nullable: true })
isbn13Numbers?: number[];

Query나 Mutation에서 여러 값을 하나의 객체로 받고 싶다면 InputType을 사용해야 한다. InputType은 클라이언트 → 서버 방향의 데이터 구조를 정의하며, 서버 → 클라이언트 방향의 응답 구조는 ObjectType으로 정의한다.

untitled
TS
@ObjectType()
export class Book {
  @Field()
  isbn13: string;

  @Field()
  title: string;

  @Field()
  author: string;

  @Field({ nullable: true })
  description?: string;

  @Field({ nullable: true })
  cover?: string;
}
untitled
TS
@InputType()
export class BookInput {
  @Field()
  isbn13: string;

  @Field({ nullable: true })
  title?: string;
}

이 구조는 REST에서 DTO를 정의하는 방식과 크게 다르지 않으며, @nestjs/graphql 역시 @nestjs/swagger와 마찬가지로 mapped type 유틸리티를 제공해 타입 재사용을 돕는다.

Custom AuthGuard

기존 REST API에서는 커스텀 가드에서 HTTP request 객체를 직접 가져와 쿠키에 담긴 access token과 refresh token을 검증했다. 하지만 GraphQL 요청에서는 request가 일반적인 HTTP 요청과 다른 경로로 전달된다.

GraphQL 요청의 경우 context 안에 request가 포함되며, 이를 꺼내오는 방식이 HTTP와 다르다. 따라서 나는 getType()을 사용해 요청 타입을 구분하고, 그에 따라 request를 추출하도록 로직을 수정했다.

untitled
TS
canActivate(context: ExecutionContext) {
  const type = context.getType();
  let request;

  switch (type) {
    case 'http':
      request = context.switchToHttp().getRequest();
      break;

    case 'graphql':
      const gqlContext = GqlExecutionContext.create(context);
      request = gqlContext.getContext().req;
      break;
  }

  return this.validateRequest(request);
}

이렇게 GraphQL 요청이 AuthGuard를 통과하면, 이후 리졸버에서는 @Context 데코레이터를 통해 user 정보를 바로 사용할 수 있다.

untitled
TS
@Mutation(() => Book)
@UseGuards(AuthGuard)
async patchBook(
  @Args('input') input: BookInput,
  @Context('req') { user }: { user: User },
) {
  return this.prisma.book.update({
    where: { isbn13: input.isbn13 },
    data: { title: input.title },
  });
}

마무리

여기까지 살펴보면 GraphQL이 REST API와 구조적으로 크게 다르지 않다고 느껴질 수도 있다. 컨트롤러가 리졸버로 바뀌고, DTO가 ObjectTypeInputType으로 분리된 정도로 보일 수 있기 때문이다. 실제로 서버 구현 관점에서 보면 패러다임의 전환이라기보다는 표현 방식의 차이에 가깝게 느껴질 수도 있다.

하지만 GraphQL, 보다 정확히는 Apollo 생태계의 핵심 강점은 클라이언트와 서버가 하나의 타입 시스템으로 강하게 결합된다는 점에 있다. 서버가 정의한 스키마를 기준으로 클라이언트는 요청과 응답을 자동으로 타입화할 수 있고, 그 결과 런타임 에러의 상당 부분을 컴파일 타임에 제거할 수 있다. 이 장점은 Apollo Client와 코드 생성 도구를 함께 사용할 때 비로소 체감되며, 실제 사용 흐름과 개발 경험에 대해서는 Apollo Client 포스트에서 이어서 살펴본다.