안녕하세요. 인프랩 프런트엔드 개발자 루카스입니다.

아마 프런트엔드 개발을 하다 보면 code generator라는 단어를 들어보셨을 거로 생각합니다. Code generator는 서버에서 선언한 약속한 데이터 타입을 이용해 클라이언트에서 사용할 수 있는 코드를 자동으로 만들어 주는 프로그램을 의미합니다. 특히 프런트엔드 개발자의 입장에서 살펴보면 API의 변경 사항을 추적하고, type-safe 한 개발을 하고자 할 때 이런 자동화 도구는 상당한 이점을 제공합니다.

인프랩도 본격적으로 레거시 시스템을 개선하면서부터 API의 타입을 다뤄야 할 필요성이 생기기 시작했습니다. 하나의 서버에서 동작하던 인프런이 API 서버와 클라이언트로 나누어지기 시작하면서 API를 통해 기능을 구현해야 하는 일이 많아졌기 때문입니다.

이번 포스트에서는 code generator를 직접 개발하게 된 이유에 대해 말씀드리려고 합니다. 다음 포스트에서는 구현 방법과 도입 후 어떤 점이 바뀌었는지 공유하고자 합니다.

1. RESTful API를 마주하다.

제품을 만들다 보면 많은 고민을 마주하게 되지만, 그중에서도 API를 요청하는 부분을 관리하는 것은 언제나 깊은 고민이 수반됩니다.

일단 어떤 라이브러리를 사용할 것인가부터, 코드를 어디에 어떻게 위치시켜야 API의 변화에 쉽고 유연하게 대응할 수 있을지 같은 정답이 없는 선택이 필요합니다. 덧붙여, 시간이 지날수록 늘어나는 API endpoint의 관리도 걱정거리 중 하나로 다가오곤 합니다.

코드 베이스에서의 고민

특히 이제는 업계 표준이라고 말할 수 있는 TypeScript를 사용하게 되면 한가지 고민을 추가로 해야 하는데, 바로 API의 반환 타입을 어떻게 관리할 것에 대한 고민입니다.

TypeScript의 특성상 API의 반환 값을 지정하지 않고 사용할 수 없기 때문에 반드시 선언해 줘야 합니다. 이를 위해 프런트엔드 개발자는 보통 두 가지 방법 중 하나를 선택하게 되는데, 하나는 모든 요청에 대한 반환 타입을 any로 선언하는 것이고 다른 하나는 문서에 기술된 반환 값을 보고 타입을 수동으로 관리하는 것입니다.

경험상 타입을 any로 관리하지 않고 직접 수동으로 관리하게 되면 문제가 연이어 발생하곤 했습니다.

  • 프로젝트 초기에는 타입을 꾸준히 업데이트
  • 프로젝트가 막바지에 이를수록 여러 가지 이유(바쁘거나, 급한 버그 수정 등)로 타입을 적당히 컴파일이 실패하지 않는 선에서 관리
  • 제품 배포 후 유지보수가 진행될수록 새로운 타입이 양산되거나 index signature, union, intersection 등이 남발되며 runtime 안정성은 점점 떨어짐
  • 결국 타입 선언이 하나의 레거시가 되어 아무도 신경을 쓰지 않게 됨

물론 모든 프로젝트에서 발생하는 일은 아니지만 대체로 위와 같이 흘러갔을 경우, API와 클라이언트 간 인터페이스 역할을 맡은 타입의 의미가 유명무실하게 변합니다.

이 뜻은 곧 API와 클라이언트 간 역할, 책임, 협력을 신뢰할 수 없게 된다는 의미로 귀결됩니다. 제품을 위해 가장 긴밀하게 협력해야 하는 두 객체가 서로를 신뢰하지 못하게 되는 것입니다.

프런트엔드 개발자들의 불필요한 논의

수동 관리를 해야 한다는 것은 사람 혹은 협력하는 팀 단위마다 관리하는 방법에 대한 생각과 방법이 상이할 수 있다는 것을 의미합니다. 이는 일관성 있는 타입 관리를 위해서 좁게는 협력 단위끼리, 넓게는 프런트엔드 팀 전체의 합의가 필요하다는 결론이 도출됩니다.

팀 전체의 합의가 이루어졌다고 하더라도 이 합의가 안전한 것은 아닙니다. 만약 누군가가 새로운 관리 방법을 제안한다면 다시 길고 지루한 논의를 처음부터 해야 할까요?

사실 이러한 기술적인 논의 자체가 나쁜 것은 아니지만, 개인적으로 이런 종류의 논의는 정답이 없고 소모적이라고 생각합니다. 이 문제를 해결해 주는 자동화 도구가 있다면 논의 자체가 불필요하기 때문입니다.

만약 이런 문제를 도구로 해결할 수 있다면 이 시간을 다른 논의에 사용할 수 있습니다. 이를테면 제품의 사용성, 코드를 더 잘 작성하는 방법, 배포 시간을 줄이는 방법 같은 “다른 논의”는 제품 자체의 생산성에 좀 더 유의미한 영향을 줄 수 있습니다.

프런트엔드 개발자와 백엔드 개발자의 진실게임

보통 RESTful API를 제공하는 서버를 구축하게 되면 대부분은 문서화 도구로 Swagger를 사용하게 됩니다. Swagger를 사용하게 되면 재미있게도 높은 확률로 프런트엔드 개발자와 백엔드 개발자 간의 A-chicken-and-egg-problem(닭이 먼저냐 달걀이 먼저냐)이 발생합니다.

이를테면, API 개발자도 사람이다 보니 당연히 구축된 API와 Swagger로 제공되는 문서상의 스펙이 다른 경우가 발생할 수 있습니다. 또한 프런트엔드 개발자들도 각자 일하는 스타일이 다르기 때문에 Swagger로 제공되는 문서를 보지 않고 개발하는 경우도 생깁니다.

이렇게 되면 ‘어차피 보지도 않는데 문서를 업데이트하면 뭐 하나’라는 생각과, ‘어차피 잘 맞지도 않는데 문서를 왜 보나’라는 생각을 서로 하게 됩니다. 나중에 ‘어쩌다 이렇게 되었을까?‘라고 고뇌를 하지만 이 문제의 명확한 원인은 끝내 알 수 없는 경우가 많습니다.

결국 프런트엔드 개발자는 API의 반환 타입을 직접 확인해 가며 타입을 작성합니다. 제일 확실한 방법일 것 같지만 몇 가지 문제가 발생합니다.

하나는 프로퍼티의 값이 null 혹은 undefined일 수도 있는 문제입니다. 예를 들어 다음과 같은 응답이 있을 경우,

{
  "user": {
    "name": "user name"
  }
}

만약 user 프로퍼티가 null일 수 있다면 아래와 같은 코드에서 문제가 발생할 수 있습니다.

user.name.split(' ');
// Uncaught TypeError: Cannot read properties of null (reading 'name')

또 다른 문제는 프로퍼티에 서로 상이한 타입의 값이 올 수도 있는 문제입니다. 마찬가지로 예를 들어 아래와 같은 응답이 있을 경우,

{
  "user": {
    "age": 20
  }
}

만약 age 프로퍼티가 number 혹은 boolean 타입이 될 수 있다면 아래와 같은 코드에서 문제가 발생할 수 있습니다.

user.age > 0;
// age가 20인 경우 true
// age가 false인 경우 false

이렇게 되면 실제 runtime에서 에러가 발생할 때까지 API의 변경을 눈치채지 못하게 될 확률이 생깁니다. 물론 TypeScript가 runtime 안정성을 완전히 보장하지는 않지만, 최소한의 안전장치가 느슨해지는 것입니다.

RESTful API는 분명 좋은 컨셉이라고 생각합니다. 다만, 늘 그렇듯 문제는 사람에게 있습니다. 그렇기 때문에 이 문제에서 사람의 손길을 최대한 걷어내 보려는 시도를 하게 되었습니다.

2. 모색했던 해결책

이전에 사용했었던 기술 중에서 현재 팀에 적용 가능할 것으로 예상되는 몇 가지 해결책을 모색해 보았지만, 모두 채택하지 못했습니다.

GraphQL

개인적으로 GraphQL이 이 문제를 해결하는 가장 우아하면서도 확실한 방법이라고 생각했습니다. 이미 이전 팀에서 GraphQL로 BFF(Backend For Frontend) 서버를 구축하여 운영했던 경험이 있었고 사이드프로젝트에도 적극 사용했기 때문에 생소한 기술도 아니기도 했습니다.

CTO인 향로에게 조심스럽게 도입에 관해 이야기해 봤지만 결국 거절당했습니다. 정확히는, 내부 서비스에서 사용하는 건 문제가 없지만 외부로 공개되는 서비스에 대해서는 도입할 수 없을 것 같다는 대답이었습니다. 이에 대한 몇 가지 이유는 아래와 같았습니다.

  • GraphQL을 사용하면 DB modeling이 요청의 결과를 통해 많이 드러나게 된다.
  • GraphQL은 ORM에 지나치게 의존한다.
  • 모니터링을 어느 정도 수준까지 할 수 있는지, 현재 우리가 사용하는 모니터링 도구가 GraphQL을 잘 지원하는지 알 수 없다.
  • BFF는 서비스 레이어를 늘리기 때문에 팀의 규모를 고려해 보았을 때 관리하기에 부담이 될 수 있다.
  • GraphQL을 경험한 사람이 백엔드팀, 프런트엔드팀 각 1명씩 밖에 없다. 다소 학습 곡선이 높아 보이는데, 경험자가 적은 부분이 도입의 장애물로 작용할 여지가 있다.
  • 비즈니스 상황이 급박하게 바뀔 수도 있는데, 개발팀이 GraphQL로 전환하는 일 때문에 속도를 맞추지 못할 우려가 있다.

기술적인 이유에 대해서는 개인적으로 의아한 부분도 있었지만, 그 외적인 부분에서는 너무나 맞는 이유라고 생각했습니다. 더군다나 최근 인프랩팀이 발 빠르게 움직이고 있는 상황에서 새로운 기술 스택 도입이라는 결정이 걸림돌로 작용할 수는 없었습니다.

특히 점진적인 레거시 API를 개선해야 하는 상황에서는 GraphQL의 단일 엔드포인트가 단점으로 작용할 확률이 크다고 느꼈습니다. 엔드포인트가 URL로 구분되어 있는 상황에서는 URL 별로 API의 target group을 변경하면서 개선이 가능하지만, 엔드포인트가 하나라면 이런 전략을 취할 수 없기 때문입니다.

Client side GraphQL

Client side GraphQL 역시 고려 대상에 포함되었지만, 결국 스스로 포기했습니다. 클라이언트 코드에 포함된 GraphQL schema가 API에 대한 인터페이스 역할을 할 수는 있겠지만, resolver에서 API를 호출해야 하므로 근본적인 문제를 해결할 방법은 아니라고 판단했기 때문입니다.

gRPC

GraphQL과 비슷한 관점에서 접근했지만, 이를 이용해 API를 구현해야 하는 백엔드 개발자들에게는 GraphQL보다 더 낯선 방법이었다는 사실을 금세 깨닫게 되었습니다. 같은 셀의 백엔드 개발자 동료와 논의를 해보았을 때 GraphQL보다 긍정적인 반응을 얻지 못했기 때문입니다. 제품의 안정성도 중요하지만, 그렇다고 해서 바쁜 백엔드 팀에게 생소한 기술을 권유할 수는 없었습니다.

3. 커뮤니티에서 힌트를 얻다.

GraphQL 커뮤니티 내에는 The Guild라는 오픈소스 개발자 집단이 있습니다. The Guild에서 GraphQL과 관련한 강력한 오픈소스를 많이 관리하는데, 그중 하나가 Code generator입니다.

GraphQL을 사용할 때 큰 도움을 받았던 라이브러리인데, OpenAPI Specification을 대상으로 한 비슷한 라이브러리가 있을 지도 모른다는 생각을 하게 되었습니다. 아니나 다를까, openapi-generator라는 라이브러리를 쉽게 찾을 수 있었습니다.

라이브러리를 설치하고 적용해 보기까지 얼마 걸리지 않았는데, openapi-generator를 도입한 모범사례를 소개하는 글이 워낙 많았고 쉽게 사용할 수 있었기 때문입니다. 이런저런 옵션을 살펴볼수록 정말 잘 만든 라이브러리라는 생각을 갖게 되었습니다.

4. 바퀴를 다시 만들 결정을 하다.

개인적으로 바퀴를 다시 만드는 것을 상당히 지양하는 편입니다. 하지만 이번에는 조금 달랐는데, openapi-generator를 테스트하면서 아쉬운 부분들을 발견했기 때문입니다.

1. 산출물의 아쉬움

먼저 제일 아쉬웠던 부분은 실행 결과가 너무 과하고 번잡하다는 것입니다. 물론 옵션을 통해 이것저것 조절해 볼 수는 있겠지만 마음에 드는 형태가 나올 것 같다는 생각이 들지 않았습니다. 무엇보다 GraphQL code generator처럼 API를 호출하는 hooks까지 만들어지기를 바랐는데, 찾아본 결과 그런 기능은 존재하지 않는 것 같았습니다.

아래와 같은 GraphQL 스키마가 있다고 가정할 때,

schema {
  query: Query
}

type Query {
  me: User!
}

enum Role {
  USER
  ADMIN
}

type User {
  id: ID!
  username: String!
  email: String!
  role: Role!
}
query findUser {
  me {
    ...UserFields
  }
}

fragment UserFields on User {
  id
  username
  role
}

GraphQL code generator를 사용하면 보통 아래와 같이 코드가 만들어지기 때문에 정말 편하게 사용할 수 있습니다.

import { useQuery, UseQueryOptions } from '@tanstack/react-query';
export type Maybe<T> = T | null;
export type InputMaybe<T> = Maybe<T>;
export type Exact<T extends { [key: string]: unknown }> = { [K in keyof T]: T[K] };
export type MakeOptional<T, K extends keyof T> = Omit<T, K> & { [SubKey in K]?: Maybe<T[SubKey]> };
export type MakeMaybe<T, K extends keyof T> = Omit<T, K> & { [SubKey in K]: Maybe<T[SubKey]> };
export type MakeEmpty<T extends { [key: string]: unknown }, K extends keyof T> = {
  [_ in K]?: never;
};
export type Incremental<T> =
  | T
  | { [P in keyof T]?: P extends ' $fragmentName' | '__typename' ? T[P] : never };

function fetcher<TData, TVariables>(
  endpoint: string,
  requestInit: RequestInit,
  query: string,
  variables?: TVariables,
) {
  return async (): Promise<TData> => {
    const res = await fetch(endpoint, {
      method: 'POST',
      ...requestInit,
      body: JSON.stringify({ query, variables }),
    });

    const json = await res.json();

    if (json.errors) {
      const { message } = json.errors[0];

      throw new Error(message);
    }

    return json.data;
  };
}
/** All built-in and custom scalars, mapped to their actual values */
export type Scalars = {
  ID: { input: string; output: string };
  String: { input: string; output: string };
  Boolean: { input: boolean; output: boolean };
  Int: { input: number; output: number };
  Float: { input: number; output: number };
};

export type Query = {
  __typename?: 'Query';
  me: User;
};

export enum Role {
  User = 'USER',
  Admin = 'ADMIN',
}

export type User = {
  __typename?: 'User';
  id: Scalars['ID']['output'];
  username: Scalars['String']['output'];
  email: Scalars['String']['output'];
  role: Role;
};

export type FindUserQueryVariables = Exact<{ [key: string]: never }>;

export type FindUserQuery = {
  __typename?: 'Query';
  me: { __typename?: 'User'; id: string; username: string; role: Role };
};

export type UserFieldsFragment = { __typename?: 'User'; id: string; username: string; role: Role };

export const UserFieldsFragmentDoc = `
    fragment UserFields on User {
  id
  username
  role
}
    `;
export const FindUserDocument = `
    query findUser {
  me {
    ...UserFields
  }
}
    ${UserFieldsFragmentDoc}`;
export const useFindUserQuery = <TData = FindUserQuery, TError = unknown>(
  dataSource: { endpoint: string; fetchParams?: RequestInit },
  variables?: FindUserQueryVariables,
  options?: UseQueryOptions<FindUserQuery, TError, TData>,
) =>
  useQuery<FindUserQuery, TError, TData>(
    variables === undefined ? ['findUser'] : ['findUser', variables],
    fetcher<FindUserQuery, FindUserQueryVariables>(
      dataSource.endpoint,
      dataSource.fetchParams || {},
      FindUserDocument,
      variables,
    ),
    options,
  );
import { useFindUserQuery } from './schema.ts';

function User() {
  const { data } = useFindUserQuery();

  return <p>{data?.username}</p>;
}

자동으로 만들어진 hooks를 사용하는 편안함과 안정감을 알고 있었기 때문에, OpenAPI specification을 이용하더라도 비슷한 결과를 얻고 싶었습니다.

2. 인프랩만의 요구사항

분명히 저희 팀만의 요구사항이 발생할 것이라고 예상했습니다. 예를 들면 셀 별로 다른 에러 처리 방법이 등장할 것이라 생각했고, 만들어진 결과물을 하나의 파일로 줄인다거나 구조를 변경하고, 만들어지는 타입의 이름을 변경하는 등 까다로운 부분이 생길 것이라 보았습니다.

이는 mustache와 custom template을 통해 완화할 수 있었지만 완벽하게 이 문제를 해결할 수 있을 것 같지는 않았습니다.

꽤 오랜 기간 고민을 거듭했고, 여러 상황을 종합해 보았을 때 한번 직접 만들어 보자는 결심을 하게 되었습니다. 그렇게 code generator를 향한 여정이 시작되었습니다.

5. 목표

Code generator를 만들기 전에 세웠던 목표는 아래와 같았습니다. 물론 첫 버전에 모든 것을 다 구현할 수는 없겠지만, 최종적으로 v1은 아래의 모든 기능이 포함되었으면 좋겠다는 생각했습니다.

  1. File 혹은 network 요청을 통해 OpenAPI specification json을 직접 파싱하여 원하는 형태의 결과를 file로 만들 수 있어야 한다.
  2. 프런트엔드 개발자가 API의 schema 변화에 최대한 불편하지 않게 대응할 수 있어야 한다.
  3. 프런트엔드 개발자가 결과물로 만들어지기를 원하는 API를 선택할 수 있어야 한다.
  4. API에서 선언한 response를 JSON validator를 통해 검증할 수 있는 type guard가 자동으로 생성되어야 한다.
  5. 백엔드 개발자는 프런트엔드의 번거로움을 생각하지 않고 API를 생산할 수 있어야 한다.
  6. 백엔드 개발자가 선언한 Swagger와 실제 API response의 형태가 일치하는지 integration test에서 검증할 수 있어야 한다.

특히 마지막 기능의 경우 GraphQL 과는 달리 Swagger로 선언한 schema가 response type의 정합성을 보장해 주지 않기 때문에 꼭 필요하다고 생각했습니다. Type guard 역시 API의 요청 결과와 직접적인 관계는 없지만, 프런트엔드에서 API schema에 대해 일일이 type guard를 만드는 수고를 덜고 싶었습니다.

자세한 구현 방법과 도입 후의 이야기는 이어지는 글에서 소개하고자 합니다.