CLASS101의 GraphQL 여정기

“클래스101에서는 GraphQL을 어떻게 쓰고 있나요?”

클래스101에서는 Typegraphql을 이용한 Code first 전략으로 스키마를 구현하고 있습니다. 그리고 Apollo를 이용해서 GraphQL을 운영하고 있습니다. 클래스101의 서비스를 만들었을 당시 어떤 고민을 통해서 Code first 방식을 택했는지, 그로 인해서 힘들었던 점은 무엇인지 등등 클래스101의 GraphQL 여정을 소개해드립니다.

(해당 내용은 클래스101 운명의 Devs ‘클래스101에서 GraphQL을 쓰는 법(서버 편) — 조이’ 영상을 글로 옮긴 내용입니다. 영상보다 글을 선호하시는 분들을 위해 작성되었습니다.)

Schema First 와 Code First

GraphQL을 쓸 때 흔히 결정해야 하는 것이 Schema First 방식을 쓸 것인지, Code First 방식을 쓸 것인지 입니다. Schema를 먼저 SDL로 작성하고, 구현단에서 Resolver를 작성하는 방법을 “Schema First” 방식이라 부르고, 코드 작성과 동시에 Resolver를 작성하고 Graph를 정의하는 방법을 “Code First” 방식이라 부릅니다.

Schema First

Schema First 방식에서는 아래에서 보는 것과 같이 SDL(Schema Definition Language)로 사용할 타입과 Query, Mutation을 정의합니다.

type Author {
  id: Int!
  firstName: String
  lastName: String
  """
  the list of Posts by this author
  """
  posts: [Post]
}
type Post {
  id: Int!
  title: String
  author: Author
  votes: Int
}
# the schema allows the following query:
type Query {
  posts: [Post]
  author(id: Int!): Author
}
# this schema allows the following mutation:
type Mutation {
  upvotePost (
    postId: Int!
  ): Post
}

이 정의된 Schema를 Resolver 형태로 작성해서 (Javascript를 예로 들었습니다.) SDL에서 정의한 모양 그대로 객체를 반환하게끔 합니다.

import { find, filter } from 'lodash';
// example data
const authors = [
  { id: 1, firstName: 'Tom', lastName: 'Coleman' },
  { id: 2, firstName: 'Sashko', lastName: 'Stubailo' },
  { id: 3, firstName: 'Mikhail', lastName: 'Novikov' },
];
const posts = [
  { id: 1, authorId: 1, title: 'Introduction to GraphQL', votes: 2 },
  { id: 2, authorId: 2, title: 'Welcome to Meteor', votes: 3 },
  { id: 3, authorId: 2, title: 'Advanced GraphQL', votes: 1 },
  { id: 4, authorId: 3, title: 'Launchpad is Cool', votes: 7 },
];
const resolvers = {
  Query: {
    posts: () => posts,
    author: (_, { id }) => find(authors, { id }),
  },
  Mutation: {
    upvotePost: (_, { postId }) => {
      const post = find(posts, { id: postId });
      if (!post) {
        throw new Error(`Couldn't find post with id ${postId}`);
      }
      post.votes += 1;
      return post;
    },
  },
  Author: {
    posts: author => filter(posts, { authorId: author.id }),
  },
  Post: {
    author: post => find(authors, { id: post.authorId }),
  },
};
  1. 구현 코드 없이 먼저 SDL을 작성하게 되므로, 클라이언트 개발자가 백엔드 구현이 완료될때 까지 blocking 되지 않습니다.
  2. SDL을 제외하고는 API Document가 굳이 필요하지 않습니다. GraphQL SDL 주석으로 풍부하게 설명할 수 있고, 복잡하지 않은 간결한 문법을 통해 문서를 유지할 수 있습니다.

하지만 단점으로는

  1. SDL이 변경되고, Resolver 구현이 변경되지 않는 경우에 런타임에 오류가 발생합니다. 변경된 SDL의 타입이 Resolver의 타입과 호환되지 않기 때문입니다.
  2. SDL과 구현 코드상의 묘한(?) 반복이 발생합니다. 완전히 일치하는 반복은 아니지만, 줄일 수 있을 것 같은 반복이 발생합니다.

Code First

Code First 방식을 설명하기 위해 실제 클래스101의 ‘인박스’라고 하는 사용자에게 전달되는 알림이 저장되는 코드를 가져와봤습니다. 아래와 같이 Resolver(Object Type class)를 작성하면서, GraphQL Schema를 정의를 하게 되는 방식입니다. 구현 로직과 동시에 Graph를 정의하는 형태라고 할 수 있습니다.

export class Inbox {
  @Field(type => ID)
  id: number;

  @Field({ description: '유저 ID' })
  userId: string;

  @Field({ description: '제목' })
  title: string;

  @Field({ description: '내용 (html 형식)' })
  body: string;
}

@Resolver(of => Inbox)
@Service()
export class InboxResolver {
  constructor(private inboxService: InboxService) {}

  @Authorized()
  @Query(returns => [Inbox])
  public myInbox(
    @Ctx() context,
    @Arg('offset', type => Int, { defaultValue: 0 }) offset: number,
    @Arg('limit', type => Int, { defaultValue: 10 }) limit: number
  ): Promise<Inbox[]> {
    const userId = getSessionUserId(context);
    return this.inboxService.getInboxListByUserId(userId, { offset, limit, setNotified: true });
  }
    
  @Authorized()
  @Mutation(returns => Inbox)
  public viewInbox(
		@Ctx() context, 
		@Arg('inboxId', type => ID) inboxId: number
	): Promise<Inbox> {
    return this.inboxService.setInboxViewedById(inboxId);
  }
    
  @Authorized()
  @Query(type => Number)
  public async myInboxUnreadCount(@Ctx() context): Promise<number> {
    const userId = getSessionUserId(context);
    const { count } = await this.inboxService.getUnreadInboxCountByUserId(userId);
    return count;
  }
}

이렇게 Code First 방식을 쓰게 되면 장점으로는

(1) Schema와 Resolver 타입이 강결합하여, 런타임에 안전하고 Single Source Of Truth가 지켜집니다.

(2) 코드 중복이 적어지고, 러닝 커브가 낮습니다.

Schema First vs Code First

Code First 방식에서는 각종 Annotation이 붙어있고, 코드를 보고 무슨 타입인지, 어떤 Graph를 가지고 있는지 한눈에 파악되지 않습니다. 그래서 협업 관점에서 보면 Schema first 방식이 더 좋을 수 있습니다. 문서화를 하고, Schema를 정의하는 단계에서부터 프론트엔드 개발자와 백엔드 개발자가 긴밀하게 협업하는 방식이 개인적으로는 좋다고 생각했었습니다.

하지만 클래스101 같은 경우,

(1) Graph 정의에서부터, View를 작성할 수 있는 풀스택 엔지니어의 수가 많았고,

(2) View를 작성하는 사람이 보여줄 데이터를 가장 잘 가공할 수 있을 것이라고 생각했습니다.

여기에 더해 런타임 오류와 타입 체크가 안된다는 점은 꽤 큰 문제라고 생각했고, 개발자 간 의사소통에서 SDL이라는 추가로 문법을 익혀야 하고, 작성법을 배우는 것보단 이미 잘 쓰고 있는 Typescript를 활용하는 게 좋을 것 같다고 판단하였습니다.

그래서 현재 클래스101에서는 TypeGraphql을 이용한 Code First 전략으로 스키마를 구현하고 있습니다.

클래스101의 GraphQL 여정

러닝 커브가 크고, 각종 툴의 미흡한 지원들로 인해 클래스101은 다소 힘든 길을 걸었었습니다.

(1) 레퍼런스 문서가 많이 없어서 기존에 Web API 방식으로 백엔드를 구현하던 백엔드 개발자와, React와 퍼블리싱 위주로 개발했던 프론트 개발자들이 모두 학습에 시간을 써야 했습니다.

(2) 클래스101은 2018년 초에 서비스를 시작하면서 바로 GraphQL을 도입했었는데 그때 당시에는 개발 관련 툴이 미흡했습니다. typegen, codegen 등의 여러 툴을 거쳐 현재의 괜찮은 모습이 되기까지 프론트엔드에 많은 레거시 코드를 남겼습니다.

(3) Fragment의 용도를 뒤늦게 깨달아, 전역으로 사용하는 Fragment 등이 존재하는 등 under/over fetching 문제를 야기시킨 점 또한 아쉬운 부분입니다.

현재는 아래처럼, 각 View Component가 필요한 데이터를 Fragment 형태로 만들고, 그 Fragment를 상위 Container Component가 조합하여 쿼리하게 되어 적절한 데이터만 요청할 수 있는 형태로 개발을 진행하고 있습니다.

export const fragment = gql`
  ${CategoryAndCreatorTagFragmentDoc}
  ${HeartAndFeedbackCountLabelFragmentDoc}
  ${ProductBadgeFragmentDoc}
  ${RewardPercentTagFragmentDoc}
  ${ProductHeartButtonFragmentDoc}
  fragment ProductCard on Product {
    _id
    ...CategoryAndCreatorTag
    ...HeartAndFeedbackCountLabel
    ...ProductBadge
    ...RewardPercentTag
    ...ProductHeartButton
  }
`;

다만 이런 구조를 택할 시, 하위 컴포넌트들의 Fragment를 상위 Contrainer가 import 하게 되는 등 코드의 의존 흐름이 반대가 되어 문제는 존재합니다.

Apollo

현재 클래스101에서는 Apollo를 이용해서 GraphQL를 운영하고 있습니다.

Apollo Client의 Local cache를 이용해서 전역 상태 관리를 하고 있으며, Apollo Studio를 통해 대략적인 메트릭 관리, Operation 별 레이턴시/오류 추이를 확인하고 있습니다.

그리고 현재는 Graph는 무조건 하위호환성을 지키도록 코드 리뷰에서 강제하고 있는데, 추후에는 Schema Check 등으로 안전한 개발을 할 수 있도록 할 예정입니다.

Apollo Studio에서 볼 수 없는 CPU, Network, APM 정보들은 DataDog에 Graphql Plugin을 통해 더 자세한 메트릭을 수집하고 있습니다.

MSA 전환 과정에서의 Apollo + GraphQL

클래스101에서는 레거시 모놀리스 시스템을 MSA로 분리하는 과정에서 Apollo Server를 API Gateway로 사용하려는 노력을 하고 있습니다. 그래서 현재 구조는 아래처럼 애플리케이션 내부에서 서비스를 호출하고 데이터가 오고 가는 구조입니다.

(GraphQL Resolver → Services → Database)

MSA로 분리된 이후에는 아래처럼 아키텍처를 변경할 예정입니다.

(GraphQL Resolver)-→ (Commerce Service → Database)
\→ (User Service → Database)
-→ (etc... Service → Database)

즉, 서비스와 데이터베이스가 각 도메인별로 분리되고 유저로부터 요청을 Apollo Server가 받아서, 내부 서비스 간 호출을 하게 됩니다. 내부 서비스 간 호출은 현재는 HTTP를 사용하고 있으나, gRPC 형태로 변경될 수 있도록 할 예정입니다.

이로 인해 각 도메인별 백엔드 개발자는 각자 서비스를 책임 있게 개발하게 되고, Apollo Server는 BFF(Backend-For-Frontend) 서비스가 되며 클라이언트 개발자가 디자인되어있는 뷰를 구성할 때 더욱 밀접하게 데이터를 질의하고 조합하여 뷰 작성을 능동적으로 할 수 있는 효과를 기대하고 있습니다.

클래스101의 GraphQL 여정기는 이 정도로 마무리될 것 같습니다. 추후에 다른 기술이 나오게 되면 사용을 고려해 보겠지만, 현재로서는 위에서 말씀드린 마일스톤대로 GraphQL 을 사용해 개발해나갈 것입니다.

글쓴이: 배현승(Joy)


이 과정을 함께할 분들은 언제든 환영입니다. 함께하며 성장하는 서비스에 기여하고 싶다면! 아래 링크를 통해 지원서를 보내주세요. 채용 문의(recruit@101.inc)도 언제든 환영입니다. 😉
👉채용중인 포지션 확인하러 가기👈