[NestJS] GraphQL 전환 후기

ahn__jh·2022년 7월 21일
0
post-thumbnail

Contents

늦었지만 GraphQL로 전환한 후 후기를 작성해보려 합니다.

먼저 GraphQL 이란?

페이스북에서 만든 쿼리 언어입니다.

SQL과 비슷한 개념으로, 클라이언트가 서버로 부터 데이터를 효율적으로 가져오는 것이 목적입니다.

SQL로 DB에 저장된 데이터를 가져오는 것 처럼,
클라이언트가 서버 API로 부터 데이터를 가져온다고 이해하면 쉽습니다.

type PartnerBrand {
  brandId: ID!
  brandName: String!
  company: PartnerCompany!
  companyId: String!
  country: PartnerCountry!
  # ...
}

type PartnerCompany {
  companyId: String!
  companyName: String!
  # ...
}
GraphqL Schema
query Brand {
  brand {
    brandId
    company {
      companyId
      companyName
    }
  }
}
GraphQL Query(find API)

도입 배경
기존에는 REST 방식으로 API 개발하여 사용하고 있었으나, 여러가지 이슈가 발생하면서 GraphQL을 도입하기로 결정했습니다.

도입 배경 1. type 정의 문제

먼저, 서버에서 응답 데이터에 대한 정보를 원활하게 제공하고 있지 않아서 데이터의 형태나 타입이 변경될 때 마다 클라이언트에서 인지하지 못하여 수정해야하는 문제가 있었습니다.

Swagger 문서에 응답 형태(DTO)를 확실히 알려준다면, 클라이언트에서 타입 정의하는 데 드는 비용이 적습니다.

하지만 저희가 응답의 형태를 직접 타이핑하여 제공하고 있어서 클라이언트에서 많은 작업 비용이 발생했습니다.

GraphQL을 사용하면, schema.graphql 파일에 스키마(사용가능한 API(Query, Mutation, Subscription)와 타입 존재)가 정의되어 code generator를 활용해 자동으로 타입을 생성할 수 있게 됩니다.


도입 배경 2. Over/Under Fetching 해결 가능

GraphQL은 서버에서 자원에 대한 정보를 정의해두면, 클라이언트에서 원하는 데이터만을 자유롭게 조회할 수 있습니다.

그리고 여러 리소스에 접근할 때, REST와 달리 한 번의 요청을 통해 다양한 데이터를 조회할 수 있습니다.

다음으로, 현재 저희가 사용하는 NestJS 프레임워크에서 GraphQL을 적용하면서 발생한 여러가지 이슈와 이를 어떻게 해결했는지 소개하겠습니다.

  1. Error Handling

  2. N+1 Problem

Error Handling

먼저 API에서 발생하는 에러를 처리하기위해 크게 두 가지 문제를 해결해야 했습니다.

문제 1. REST와 다른 Error Response

REST API와 동일한 방식(throw new BadRequestException('유저가 이미 존재합니다.'))으로 에러처리를 할 경우, GraphQL에서는 아래와 같은 형태로 응답합니다.

{
  "errors": [
    {
      "message": "유저가 이미 존재합니다.",
      "extensions": {
        "code": "BAD_USER_INPUT",
        "response": {
          "statusCode": 400,
          "message": "유저가 이미 존재합니다.",
          "error": "Bad Request"
        }
      }
    }
  ],
  "data": {
    "userDelete": null
  }
}

이는 클라이언트 입장에서 불필요한 정보가 많이 포함되어 있고, REST에서 사용하던 에러 응답 형태와 많이 달랐습니다.

// REST에서의 에러 응답 형태
{
  "statusCode": 400,
  "message": [
    "유저가 이미 존재합니다.",
    // ...
  ],
  "data": null
}

문제 2. 새롭게 정의한 Error Code 적용

기존에는 HTTP 상태 코드를 잘 활용하지 않고 거의 모든 에러를 400(Bad Request)으로 처리하고 있어, 클라이언트는 서버 내부 에러인지 요청이 잘못된 건지 구분이 힘들었었습니다.

이번 기회에 에러 유형마다 일정한 규칙을 적용하여 새롭게 정의한 에러코드(영역[1자리] 도메인[1자리 or 2자리] 순번[3자리])를 도입하기로 했습니다.

export enum ErrorCode {
  UNAUTHORIZED = 10000,
  FORBIDDEN = 10001,
  EXPIRED_TOKEN = 10002,

  QUEUE_EMPTY = 30000,
  QUEUE_ALREADY_ON_RUNNING = 30001,

  INTERNAL_SERVER_ERROR = 50000,

  AUTH_PASSWORD_SHORT = 20000,
  AUTH_INVALID_PASSWORD = 20001,
  AUTHORIZATION_NOT_EXIST = 20002,

  GPM_CATALOG_REJECT_LIST_EMPTY = 21000,
  GPM_CATALOG_REJECT_LIST_BE_ONE_OR_MORE = 21001,
  // ...
}

해결 방안 1. GraphQL Module의 formatError 속성 활용하기

export class ErrorDto {
  errorCode: ErrorCode;
  message: string;
}

GraphQLModule.forRoot({
      // ...
      formatError: (error: GraphQLError): ErrorDto => { // ⬅️
        // ErrorDto 형태로 반환되도록 처리
      },
}),

formatError 속성은 GraphQLError 객체를 받아서 특정한 형태로 변환된 에러를 반환하게 됩니다.

저희는 errorCode, message 필드를 갖는 ErrorDto 클래스를 정의하여 에러 처리를 했습니다.

그 결과, 아래와 같이 통일된 형태의 에러 정보를 응답하게 됩니다.

{
"errors": [
{
"errorCode":22003,
"message": "유저가 이미 존재합니다."
},
],
}

하지만 이 방식의 문제는 사용자가 직접 정의한 ErrorDto 타입에 있었습니다.

클라이언트에서 ApolloError 객체를 받아 에러 처리를 하게 되는데

아래 코드를 잠시 살펴보면,

export class ApolloError extends Error implements GraphQLError {
  public extensions: Record<string, any>;
  override readonly name!: string;
  readonly locations: ReadonlyArray<SourceLocation> | undefined;
  readonly path: ReadonlyArray<string | number> | undefined;
  readonly source: Source | undefined;
  readonly positions: ReadonlyArray<number> | undefined;
  readonly nodes: ReadonlyArray<ASTNode> | undefined;
  public originalError: Error | undefined;

  [key: string]: any;

  constructor(
    message: string,  // ⬅️
    code?: string,
    extensions?: Record<string, any>,
  ) {
    super(message);
  }
  // ...
}

ApolloError 객체에 message 필드는 존재하지만, errorCode 필드가 없기때문에 타입 에러가 발생하게 됩니다.

따라서, 서버에서 직접 정의한 타입(ErrorDto)을 사용할 수 없고 ApolloError 타입을 따라야합니다.

이에 저희가 선택한 방법은 가장 간편하게 message 필드에 [errorCode][errorMessage] 형태로 에러 정보를 전달하는 것입니다.

해결 방안 2. 에러 메세지에 에러 코드와 메세지를 포함하기

ErrorDto 클래스는 이전과 달리 message 필드만 갖게되고,

생성자를 통해 [errorCode][errorMessage] 형태의 message 값을 갖게 됩니다.

export class ErrorDto {
  message: string;

  constructor(errorCode: ErrorCode) {
    this.message = `${errorCode} ${ErrorMessageV1[errorCode]}`; // ⬅️
  }
}

export class ErrorException extends BadRequestException {
  constructor(errorCode: ErrorCode) {
    super(new ErrorDto(errorCode).message);
  }
}

이 방식을 사용한 결과는 아래와 같습니다.

클라이언트는 message에만 접근하여 에러 코드와 상세 에러 메세지를 활용하게 됩니다.

{
  "errors": [
    {
      "message": "22003 유저가 이미 존재합니다.",
      "extensions": {
        "code": "BAD_USER_INPUT",
        "response": {
          "statusCode": 400,
          "message": "22003 유저가 이미 존재합니다.",
          "error": "Bad Request"
        }
      }
    }
  ],
  "data": null
}

추가로 ApolloError 객체 속성을 잘 활용하여 에러를 처리하는 방법도 있습니다.

해결 방안 3. ApolloError 객체 속성 활용하기

위에서 언급했었던 ApolloError 객체를 다시 살펴보면, 옵셔녈하게 사용 가능한 extension 이라는 필드가 존재합니다.

타입 또한 Record<string, any>로 자유롭기 때문에, 원하는 필드를 정의하여 사용할 수 있습니다.

export class ApolloError extends Error implements GraphQLError {
  public extensions: Record<string, any>;
  override readonly name!: string;
  readonly locations: ReadonlyArray<SourceLocation> | undefined;
  readonly path: ReadonlyArray<string | number> | undefined;
  readonly source: Source | undefined;
  readonly positions: ReadonlyArray<number> | undefined;
  readonly nodes: ReadonlyArray<ASTNode> | undefined;
  public originalError: Error | undefined;

  [key: string]: any;

  constructor(
    message: string,
    code?: string,
    extensions?: Record<string, any>, // ⬅️
  ) {
    super(message);

다음은 ApolloError를 사용하는 간단한 예제 입니다.

직접 정의한 필드(customErrorCode, parameter)를 포함한 extension 객체를 활용할 수 있습니다.


throw new ApolloError('유저가 존재하지 않습니다', 'NOT_EXIST_USER', {  
  customErrorCode: 71000, // ⬅️ 
  parameter: 'id',  // ⬅️
});

결과는 아래와 같이 나오며,

extension 객체의 필드가 any타입이기에 해결 방안 1에서 발생한 타입 에러 문제도 해결할 수 있습니다.

{
  "errors": [
    {
      "message": "유저가 존재하지 않습니다",
      "locations": [
        {
          "line": 7,
          "column": 3
        }
      ],
      "path": [
        "userDelete"
      ],
      "extensions": { // ⬅️
        "customErrorCode": 71000, 
        "parameter": "id",
        "code": "NOT_EXIST_USER",
        "exception": {
          "stacktrace": [...]
        }
      }
    }
  ],
  "data": {
    "userDelete": null
  }
}

N+1 문제

N+1 문제는 GraphQL의 ResolveField를 사용하면서 발생하게 됩니다.

간단한 예제를 통해 설명하겠습니다.

아래와 같이 Order 와 User 객체가 M:N 관계를 맺고 있습니다.

type Order {
  id: Int!
  userId: Int!
  user: User!
}

type User {
  id: Int!
}

type Query orders {
  orders {
    id
    user {
      id
    }
  }
}

리졸버를 살펴보면, ResolveField를 통해 Order 객체를 조회할 때 User 정보를 알 수 있습니다.

// order.resolver.ts
@Resolver(() => Order)
class OrderResolver {
  constructor(
    private readonly orderRepo: OrderRepo,
    private readonly userRepo: UserRepo,
  )

  @Query(() => [Order])
  orders(): Promise<Order[]> {
    return this.orderRepo.findAll();
  }

  @ResolveField(() => User) // ⬅️
  user(@Parent(): order: Order): Promise<User> {
    return this.userRepo.getById(order.userId);
  }
}

orders 쿼리를 날렸을 때

주문이 5개라고 가정하면, 각 주문마다 사용자를 조회하는 쿼리가 총 5번 중복적으로 발생하게 됩니다.

이를 GraphQL 의 N+1문제라고 부릅니다.

SELECT * FROM order;

SELECT * FROM user WHERE id=1;
SELECT * FROM user WHERE id=2;
SELECT * FROM user WHERE id=3;
SELECT * FROM user WHERE id=4;
SELECT * FROM user WHERE id=5;

해결 방법으로는 크게 2가지가 있습니다.

해결 방안 1. Join 쿼리 활용하기

우선 graphql-parse-resolve-info 라이브러리를 활용해 요청 받은 쿼리에 포함된 필드를 확인할 수 있습니다.

orders 쿼리에 user정보를 함께 원할 경우, 사용자 정보를 조인하여 주문 정보를 조회하는 쿼리를 사용하고

그렇지 않은 경우, 주문 정보만 조회하는 일반 쿼리를 통해 결과를 반환하게 됩니다.

// order.resolver.ts
@Resolver(() => Order)
class OrderResolver {
  constructor(
    private readonly orderRepo: OrderRepo,
    private readonly userRepo: UserRepo,
  )

  @Query(() => [Order])
  orders(@Info() info: GraphQLResolveInfo): Promise<Order[]> {
    const parsedInfo = parseResolveInfo(info) as ResolveTree;
    const simplifiedInfo = simplifyParsedResolveInfoFragmentWithType(
      parsedInfo,
      info.returnType
    );
 
    const orders = 'user' in simplifiedInfo.fields  // ⬅️
      ? await this.orderRepo.find({
        relations: ['user'],
      })
      : await this.orderRepo.find();
 
    return orders;
  }
}

실행되는 쿼리는 아래와 같습니다.

SELECT * FROM order 
LEFT JOIN user ON order.userId = user.id; 

해결 방안 2. DataLoader 활용하기

다음으로 가장 일반적으로 많이 사용되는 DataLoader를 활용하는 방법입니다.

DataLoader는 많은 수의 쿼리를 일괄 처리 및 캐싱해주는 라이브러리입니다.

먼저, orders 쿼리에서 반환되는 데이터 만큼 user 리졸버가 호출되어 userLoader에 사용되는 인자(order.userId)를 수집합니다.

// order.resolver.ts
@Resolver(() => Order)
class OrderResolver {
  constructor(
    private readonly orderRepo: OrderRepo,
    private readonly userLoader: UserLoader,
  )

  @Query(() => [Order])
  orders(): Promise<Order[]> {
    return this.orderRepo.findAll();
  }

  @ResolveField(() => User)
  user(@Parent(): order: Order): Promise<User> {
    return this.userLoader.batchUsers.load(order.userId);  // ⬅️
  }
}

userLoader는 수집된 인자들을 모아 batchUsers 함수를 호출합니다.

수집된 userIds로 user 정보들을 한 번에 조회하여 만든 키맵을 통해 user 정보를 반환하게 됩니다.

// user.loader.ts
@Injectable()
export class UserLoader {
  batchUsers = new Dataloader<number, User>(
    async (userIds: number[]) => {
      const users = await this.usersService.getByIds(userIds);
      const usersMap = new Map(users.map(user => [user.id, user]));
      return userIds.map(userId => usersMap.get(userId));
    };
  ),
}

실행되는 쿼리는 아래와 같습니다.

SELECT * FROM order;

SELECT * FROM user WHERE id IN (1,2,3,4,5);

저희는 쿼리 정보를 분석하여 분기 처리가 필요한 해결 방안1 보다는,

DataLoader를 활용한 방식이 더 효율적이라 생각해 해결 방안2를 채택하게 되었습니다.

참고 자료

https://wanago.io/2021/02/08/api-nestjs-n-1-problem-graphql/

관계에 따른 순환 참조
1:1, 1:N, N:M 등의 관계를 가진 객체들이 서로 무한히 참조되는 문제가 생겼습니다.

Pagination과 파일 업,다운로드 이슈는 다음글에서 적어보도록 하겠습니다.

0개의 댓글