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

이전 글에서는 code generator의 필요성과 직접 구현하기까지의 고민을 공유했습니다. 이번 포스트에서는 code generator를 비롯하여 schema validator, type guard를 어떻게 구현했는지 간단히 소개해 드리려고 합니다.

우선 말씀드리고 싶은 부분은, 아주 간단한 아이디어이며 구현하는 데 있어 복잡한 기술이 필요하지 않다는 점입니다. 이 점을 먼저 유념해 주시고 아랫글을 읽어주시면 좋을 것 같습니다.

1. 목표와 필요한 기능

먼저 지난 글에서 세웠던 목표에 대해 다시 생각해 보겠습니다. 저는 code generator를 구현하기 전 아래와 같은 목표를 세웠습니다.

  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에서 검증할 수 있어야 한다.

특히 마지막 목표는 Elixir의 doc test 개념을 차용한 것입니다. 작년에 진행했던 개인 프로젝트에서 테스트를 작성할 때의 경험이 좋았고, GraphQL이 지원하는 schema 정합성 검증을 비슷하게라도 할 수 있을 것이라는 기대가 있었습니다. 특히 저희 백엔드 파트는 테스트 작성을 매우 중시하고 있어, OpenAPI specification과 실제 API 결과를 테스트하는 편한 방법을 지원할 수 있을 것이라 보았습니다.

아랫글 부터 OpenAPI specification JSON은 OAS로 줄이겠습니다.

2. 사용한 라이브러리

먼저 구현에 사용한 라이브러리를 소개하겠습니다. 아마 대부분 알고 계실 거로 생각합니다.

1. 구현

  • inversify

    기능이 늘어날수록 코드 베이스가 필연적으로 크고 복잡해 지리라 생각했습니다. 또한 여러 옵션을 지원할 계획을 했었기 때문에, 런타임에 실행되는 구현들이 달라질 수 있을 것이라 보았습니다.

    그랬기 때문에 여러 기능 간의 의존 관계가 복잡해질 수 있으리라 생각했고, 고민 끝에 의존성 주입을 도와줄 IoC container를 찾게 되었습니다. 저는 inversify를 선택했는데, 조금 더 가벼운 느낌으로 사용하고자 한다면 typedi라는 선택지도 존재합니다.

  • json-schema-to-typescript

    사실상 code generator의 핵심 라이브러리입니다. Code generator의 거의 모든 구현이 이 라이브러리로부터 원하는 결과를 얻어내기 위한 오브젝트를 만드는 과정이라고 보아도 무방합니다. 예를 들어 아래와 같은 JSON schema가 있을 경우

    {
      "title": "Example Schema",
      "type": "object",
      "properties": {
        "firstName": {
          "type": "string"
        },
        "lastName": {
          "type": "string"
        },
        "age": {
          "description": "Age in years",
          "type": "integer",
          "minimum": 0
        },
        "hairColor": {
          "enum": ["black", "brown", "blue"],
          "type": "string"
        }
      },
      "additionalProperties": false,
      "required": ["firstName", "lastName"]
    }

    다음과 같이 타입을 만들어 줍니다.

    export interface ExampleSchema {
      firstName: string;
      lastName: string;
      /**
       * Age in years
       */
      age?: number;
      hairColor?: 'black' | 'brown' | 'blue';
    }
  • lodash

  • object-traversal

  • js-yaml

    옵션을 yaml 형태로 받을 생각에 설치했습니다. 이제와서 생각해 보니 그냥 JavaScript로 받았어도 괜찮았을 것 같습니다.

  • jsonschema

    Code generator가 허용할 수 있는 옵션을 검증하기 위해 선택했습니다. 옵션 관련 코드가 if ~ else로 점철되는 것을 막고 싶었습니다.

2. 빌드, 배포

  • TypeScript

  • tsup

    esbuild 기반의 빌드 툴입니다. 빠르고 설정이 간단해 선택하였습니다.

  • Release Please

    라이브러리는 아니지만 소개해 드리는 이유는, 해당 GitHub Action으로 쉽게 배포했던 경험이 좋았기 때문입니다.

    커밋 메시지 중 conventional commit 형태로 작성된 것이 있다면 pull request를 생성부터 배포까지 자동으로 해줍니다. 특히 change log, release, git tag 등 자동으로 관리해 주는 부분이 많아서 개발자는 코드에만 집중할 수 있게 됩니다.

    참고로 인프랩 프런트엔드 파트에서는 monorepo로 구성된 패키지는 changesets 으로, 단독 레포지토리로 운영하는 패키지는 Release Please로 관리하고 있습니다.

  • commitlint

    위에서 설명해 드렸던 Release Please의 동작 방식 때문에 개발자의 실수를 방지하기 위해 도입하였습니다. 코드의 변경이 커밋되기 전에 커밋 메시지를 검사하여 만약 conventional commit 방식을 따르지 않았다면 커밋할 수 없게 막습니다.

    커밋 메시지의 일관성을 보장하고, 배포하기 위해 코드를 변경했음에도 잘못된 커밋 메시지로 인해 패키지 배포가 안 되는 문제를 방지하고자 하였습니다.

3. 테스트

  • Jest

3. 세부 구현 소개

Code generator는 크게 3단계로 나누어 실행됩니다. 첫 번째 과정은 전처리 과정입니다. 올바른 옵션을 전달받았는지 검증하고, OAS json 파일을 로드합니다. 그다음 사용자가 원하는 전처리 로직(Pre process)를 실행해 로드한 OAS JSON을 가공합니다.

두 번째 과정에서는 Task라고 불리는 작업 단위가 병렬단위로 실행됩니다. 어떤 태스크를 실행할 것인지는 해당 태스크의 옵션을 제대로 입력하였는지에 따라 결정됩니다.

ReactQueryTask에서는 전처리된 OAS JSON을 이용해 결과물에 쓰일 오브젝트를 만들고 적당한 이름과 mapping 하게 됩니다. Query, url params, response, request body 등 필요하다고 생각하는 모든 부분을 추출합니다. 추출한 부분은 타입과 hooks로 만듭니다.

TypeGuardTask에서는 ReactQueryTask에서 만들어진 인터페이스를 검증할 type guard 함수를 만듭니다.

SchemaValidatorTask는 OAS JSON에 선언된 모든 schema를 검증할 수 있는 standalone Ajv 인스턴스를 만들어 냅니다. Ajv는 아래에서 다시 자세하게 소개하도록 하겠습니다.

Task의 병렬 실행이 끝나면, 마지막 과정은 지정된 위치에 파일을 만드는 작업입니다. Flow chart로 표현해 보자면 다음과 같습니다.

1. 옵션 입력 받기

사용자로부터 원하는 옵션을 YAML 형태로 입력받게 구성했고, 그 옵션은 아래와 같습니다.

loader: fetch | file [required]
schema: string [required]
output-dir: string [required]
react-query: [optional]
  output: string [required]
  fetcher: string [required]
  version: 3 | 4 [optional] default: 3
validator: [optional]
  output: string [required]
typeguard: [optional]
  output: string [required]
preprocess: [optional]
  filterDeprecated: boolean [optional] default: false
  extractEnum: boolean [optional] default: false
  apiPaths:
    - path:
        string [required]
      exact:
        boolean [optional]
  tags:
    - string [required]

2. 전처리

1. 옵션 검증

YAML 파일이 있는지 없는지, node arguments를 제대로 입력했는지, kebab case인 옵션을 camel case로 바꾸는 등 I/O와 사이드 이펙트를 ConfigProvider에서 담당하게 했습니다.

그다음 react-query, preprocess 등 각각의 옵션에 대한 파싱, 검증, 값 설정은 ConfigLoader를 구현한 객체들이 맡습니다. 각 객체가 파싱한 옵션은 ConfigProvider로 전달됩니다. 이렇게 구현한 이유는 각 옵션의 변경을 격리하기 위해서입니다.

예를 들어 react query를 담당하는 클래스는 아래와 같이 구현되어 있습니다.

import Ajv from 'ajv';

import type ConfigLoader from './ConfigLoader';

import type { JSONSchemaType } from 'ajv';
import { isNil } from 'lodash';
import { InvalidConfigException } from '../utils/exception';

type ReactQueryConfigOptions = {
  output: string;
  fetcher: string;
  version?: number;
  removeApiEndpointPrefix?: boolean;
};

type YamlReactQueryConfig = {
  reactQuery?: ReactQueryConfigOptions;
};
/**
 * 1. react-query 옵션에 대한 json validation 로직
 */
const schema: JSONSchemaType<YamlReactQueryConfig> = {
  type: 'object',
  properties: {
    reactQuery: {
      type: 'object',
      nullable: true,
      properties: {
        output: { type: 'string' },
        fetcher: { type: 'string' },
        version: { type: 'number', nullable: true, default: 4, minimum: 3, maximum: 4 },
        removeApiEndpointPrefix: { type: 'boolean', nullable: true },
      },
      required: ['output', 'fetcher'],
    },
  },
  additionalProperties: true,
};

const validate = new Ajv({ useDefaults: true }).compile(schema);

export type ReactQueryConfig = ReactQueryConfigOptions | false;

export default class ReactQueryConfigLoader implements ConfigLoader<ReactQueryConfig> {
  /**
   * 2. react-query에 대한 옵션 검증
   */
  load(optionCandidate: unknown) {
    if (!validate(optionCandidate)) {
      throw new InvalidConfigException('ReactQueryConfigLoader: not a react query config');
    }

    /**
     * 3. react-query에 대한 옵션 반환
     * 반환타입은 ReactQueryConfigOptions | false
     */
    if (isNil(optionCandidate.reactQuery)) {
      return false;
    }

    return optionCandidate.reactQuery;
  }
}

ReactQueryConfigLoader에서 파싱한 옵션은 ConfigProvider에서 퍼블릭 메서드로 제공됩니다.

export class ConfigProviderImpl implements ConfigProvider {
  private loader: Loader;
  private options: Options;

  constructor() {
    /**
     * 1. yaml file 로드
     */
    const yamlCandidate = this.loadConfig();
    const reactQueryConfigLoader = new ReactQueryConfigLoader();
    /**
     * 2. react-query에 대한 옵션 파싱 요청
     */
    const reactQueryConfig = reactQueryConfigLoader.load(yamlCandidate);

    this.options = {
      reactQuery: reactQueryConfig,
    };
  }

  private loadConfig() {
    const flags = args.parse(process.argv);
    const yamlPath = resolve(process.cwd(), flags.config);

    if (typeof yamlPath !== 'string') {
      throw new InvalidPathException('ConfigProvider: failed to open yaml config file');
    }

    const yamlConfig = camelizeKeys(yaml.load(readFileSync(resolve(__dirname, yamlPath), 'utf-8')));
    return yamlConfig;
  }

  getOptions(): Options {
    return this.options;
  }

  /**
   * 3. 옵션이 필요한 객체에 옵션 제공
   */
  taskOption(): TaskOption {
    return pick(this.options, ['reactQuery']);
  }
}

위와 같은 형태로 구조를 만들었기 때문에, ConfigLoader만 구현하면 ConfigProvider에서 언제든 사용할 수 있습니다. 따라서 옵션은 언제든 유연하게 추가, 변경될 수 있습니다.

옵션 파싱 다이어그램

2. OpenAPI specification 파일 전달 받기

최초 목표 중 ‘File 혹은 network 요청을 통해 OAS JSON을 직접 파싱하여 원하는 형태의 결과를 file로 만들 수 있어야 한다.‘가 있었습니다. 이를 위해 Loader를 구현하는 FilterLoaderFetchLoader를 만들고, inversify의 factory 함수를 이용해 옵션에 따라 사용해야 할 로더를 선택하게 하였습니다.

// inversify.config.ts
const container = new Container();

/**
 * 1. IoC 컨테이너에 객체 등록
 */
container.bind<Loader>(TYPES.Loader).to(FetchLoader).whenTargetNamed('fetch');
container.bind<Loader>(TYPES.Loader).to(FileLoader).whenTargetNamed('file');
/**
 * 2. 팩토리 함수 설정
 */
const loaderFactory = container
  .bind<interfaces.Factory<Loader>>(TYPES.LoaderFactory)
  .toFactory<Loader, ['file' | 'fetch']>((context) => {
    return (named) => {
      /**
       * 3. 어떤 객체를 어떻게 로드할 것인지 명시
       */
      return context.container.getNamed<Loader>(TYPES.Loader, named);
    };
  });

// loader.ts
const selectedLoader = container.getNamed(TYPES.LoaderFactory, options.loader);

위와 같이 구현하면 기존 코드의 수정 없이 다른 Loader를 추가할 수 있다는 이점이 생깁니다. 예를 들어 database에서 OAS JSON을 로드해야 하는 경우 아래와 같이 처리할 수 있습니다.

class DatabaseLoader implements Loader {
  // ...
}

container.bind<Loader>(TYPES.Loader).to(DatabaseLoader).whenTargetNamed('db');

const databaseLoader = container.getNamed(TYPES.LoaderFactory, 'db');

3. Preprocess pipe

Preprocess pipe는 로드한 OAS JSON을 여러 조건으로 가공하는 과정입니다. Deprecated 된 API를 제외하거나, 특정 API만을 선택하거나, enum 추출을 선택하는 등의 처리를 담당합니다. 다양한 preprocess 옵션을 선택할 수 있고, 마찬가지로 옵션에 해당하는 전처리를 하는 로직을 실행 시점에 결정하게 됩니다. PreprocessPipe 클래스가 위에 소개해 드린 factory 함수를 이용해 Preprocess를 구현한 구현체를 선택하여 배열에 넣고, OAS JSON을 순서대로 전달하며 로직을 실행하게 됩니다. 예시 코드는 아래와 같습니다.

import { flow } from 'lodash';

class PreprocessPipe {
  private readonly preprocessOptions: PreprocessConfig;
  private readonly initialPreprocess: Preprocess[];

  constructor(
    @inject(TYPES.PreprocessFactory)
    private readonly preprocessFactory: (name: string) => Preprocess,
  ) {}

  exec(swaggerSpecJSON: SwaggerSpecJSON | undefined): SwaggerSpecJSON | undefined {
    /**
     * 1. 옵션에서 선택한 preprocess 구현체만 선택하여 배열에 넣음.
     */
    const preprocess = this.scheduledPreprocess();
    const preprocessorFunctions = preprocess.map((preprocess) => preprocess.exec.bind(preprocess));
    /**
     * 2. preprocess 구현체를 차례대로 실행
     */
    const result = flow(preprocessorFunctions)(swaggerSpecJSON);

    return result;
  }

  private scheduledPreprocess() {
    if (!this.preprocessOptions) {
      return [];
    }

    /**
     * enabled = true인 옵션의 객체만 로드
     */
    const selectedPreprocess = Object.entries(this.preprocessOptions)
      .filter(([, enabled]) => enabled)
      .map(([name]) => this.preprocessFactory(name));

    return this.initialPreprocess.concat(selectedPreprocess);
  }
}

마찬가지로 옵션의 추가, 삭제를 기존 로직에 영향을 주지 않게 구현하였습니다. 옵션을 읽어와 검증하고, OAS JSON 파일을 로드한 다음 preprocess를 실행하는 전처리 단계는 사용하는 곳마다 다양하게 실행될 수 있기 때문에, 최대한 유연하게 설계하려고 노력했습니다.

preprocess 다이어그램

3. ReactQueryTask

위에서 설명해 드렸듯, json-schema-to-typescript라는 라이브러리로 원하는 결과물을 만들어 내기 위해 오브젝트를 만드는 과정입니다. 어떻게 보면 가장 중요한 기능을 담당하는 부분이라 책임을 세심하게 나누는데 주의를 기울였습니다. 기본적인 흐름은 아래와 같습니다.

Generator 역할을 맡은 객체들은 Parser 역할을 맡은 객체들에 필요한 부분에 대해 파싱을 요청합니다. Parser가 요청한 결과를 전달하면, 각 객체의 역할에 맡게 결과물을 문자열 형태로 만들어 냅니다.

Object Generating 역할을 맡은 객체는 파싱된 데이터를 이용해 적당한 형태의 오브젝트를 만듭니다. 인터페이스로 만들어질 이름을 지정해 json-schema-to-typescript에 전달합니다. 예를 들면 아래와 같습니다.

import { compile } from 'json-schema-to-typescript';

const queryString = queryStringParser.generate(oasJson);
/**
 * {
 *   "title": "V1UserListQueryString",
 *   "type": "object",
 *   "properties": {
 *      "firstName": {
 *        "type": "string"
 *      },
 *      "lastName": {
 *        "type": "string"
 *      }
 *   }
 * }
 */

const result = await compile(queryString);
/**
 * export interface V1UserListQueryString {
 *   firstName?: string;
 *   lastName?: string;
 * }
 */

react-query에 쓰일 hooks를 만드는 역할을 담당하는 객체는 마찬가지로 파싱된 데이터로 적당한 형태의 hooks 문자열을 만들게 됩니다. Hooks는 template literal을 통해 간단히 만들어질 결과물을 선언할 수 있습니다.

// template.ts
export default function useMutationTemplate(name: string, method: string) {
  const type = `${name}Response`;
  const optionType = `UseMutationOptions<${name}Response, unknown, ${name}Variables, TContext>`;

  return `export function use${name}Mutation<TContext>({
    url,
    options,
    requestOptions,
  }: {
    url: string;
    options?: ${optionType};
    requestOptions?: RequestOptions;
  }) {
    const result = useMutation<${type}, unknown, ${name}Variables, TContext>(
      (variables) => fetcher.${method}(url, variables, requestOptions),
      options,
    )

    return { ...result, data: result.data?.data };
  }`;
}

// ReactMutationGenerator.ts
const result = useMutationTemplate('V1UserList', 'POST');

이렇게 generate layer에서 만들어진 결과는 각각 string[] 형태로 ReactQueryTask로 다시 전달됩니다. 여기까지의 설명이 장황하여 잘 와닿지 않으실 수 있지만, 아래의 다이어그램을 보시면 생각보다 간단한 구조라는 것을 확인하실 수 있으실 거라 봅니다.

react query 다이어그램

3. TypeGuardTask

1. Type guard란 무엇인가

TypeScript에서 type guard는 여러 타입을 가질 수 있는 변수에 대해 타입의 추론 범위를 좁힐 수 있게(narrowing) 도와주는 함수를 의미합니다. 예를 들면 아래와 같습니다.

// type guard
const isString = (value: unknown): value is string => typeof value === 'string';

const numOrStringDivide = (value: string | number) => {
  if (isString(value)) {
    return parseInt(value, 10) / 2; // value -> string
  }

  return value / 2; // value -> number
};

프런트엔드에서 type guard가 필요한 이유는 위처럼 primitive type을 구분해야 할 때도 있지만, 각기 다른 API response를 구분해 분기를 처리해야 할 경우가 발생하기 때문입니다. 특히 일반 함수뿐만 아니라 component 내부에서도 유용하게 사용할 수 있습니다. 이를테면 API response의 모습에 따라 component를 분기해서 렌더링해야 하는 경우입니다. 이 경우 인프랩 프런트엔드 파트는 type guard를 적극적으로 사용하고 있습니다.

const isStudent = (value: Student | Teacher): value is Student => {
  return isPlainObject(value) && 'grade' in value && typeof value.grade === 'string';
};

export function Profile() {
  // Student | Teacher
  const { data } = useProfileQuery();

  return (
    <div>{isStudent(data) ? <StudentProfile data={data} /> : <TeacherProfile data={data} />}</div>
  );
}

2. Type guard의 함정

물론 GraphQL을 사용하면 __typename이란 프로퍼티를 이용해 쉽게 구분할 수 있습니다. 하지만 그렇지 않은 경우에는 일반적으로 API response의 모습을 필요한 만큼 파싱하는 방법으로 타입을 구분하게 됩니다. 위의 경우를 예로 들자면, 특정 값이 오브젝트이고, ‘grade’라는 string 타입의 프로퍼티를 소유하고 있으면 ‘Student’ 타입으로 간주하게 됩니다.

이는 TypeScript의 구조적 타입 시스템(structural type system)에서 기인한 것으로, 잘 정리된 문서들이 많아 여기서는 더 이상 설명하지 않겠습니다.

다시 본론으로 돌아와서, 인프랩 프런트엔드 파트에서 type guard를 적극적으로 사용하고 있지만 장점만큼 단점 역시 뚜렷했습니다. 바로 type guard 자체가 잘못 선언된 경우 로직 상의 오류가 눈에 드러나지 않는다는 것입니다. 대표적인 경우가 API response가 바뀌었는데 type guard를 변경하는 것을 잊어버린 것입니다.

위의 ‘Student’ 타입을 예로 들자면 API response가 변경되어 ‘grade’라는 프로퍼티의 타입이 바뀌거나, 아예 사라져 버리는 경우라고 볼 수 있습니다. 이렇게 되면 코드 리뷰를 한다고 하더라도 발견하기 까다로울 뿐만 아니라, 컴파일 타임에 아무런 오류가 발생하지 않아 런타임에서 문제가 발견되는 경우가 많습니다.

3. 자동으로 type guard 만들기

위의 이유로, type guard도 OAS JSON을 통해 자동으로 만들어지면 어떨까? 라는 아이디어가 떠올랐습니다. 동료와 논의한 결과, jsonschema를 이용해 OAS schema를 검증할 수 있는 형태로 만들어 파일로 출력하는 기능을 구현하자는 결정을 내리게 되었습니다. 전체적인 구현의 흐름은 아래와 같습니다.

type guard 플로우

먼저 OAS JSON을 JSON schema 형태로 바꾸고, JSON schema가 지원하지 않는 프로퍼티들을 제거합니다.

import { draft7 } from 'json-schema-migrate';
import { unset } from 'lodash';

function convertSwaggerSpecToJSONSchema(swaggerSpec: SwaggerSpecJSON) {
  const componentSwaggerSpecs = swaggerSpec.components.schemas;

  /**
   * 1. OAS -> json schema 형태로 변경
   */
  draft7(
    toJsonSchema(swaggerSpec.components.schemas, {
      strictMode: true,
    }),
  );
  /**
   * 2. json schema에서 지원하지 않는 프로퍼티 삭제
   */
  removeUnSupportProperty(componentSwaggerSpecs);

  return componentSwaggerSpecs;
}

function removeUnSupportProperty(componentSwaggerSpecs: Record<string, JSONSchema4>) {
  const notSupportedType = ['default', 'deprecated', 'nullable', 'tsEnumNames', 'example'];

  traverse(componentSwaggerSpecs, ({ parent, key }) => {
    key ??= '';

    if (notSupportedType.includes(key)) {
      unset(parent, key);
    }
  });
}

이렇게 만들어진 JSON schema를 template 함수를 이용해 문자열로 만들면 끝납니다.

const defaultTypeGuardTemplate = (jsonSchemas: Record<string, JSONSchema4>) => {
  return [
    importTemplate(Object.keys(jsonSchemas)).trim(),
    'const validator = new Validator();',
    Object.entries(jsonSchemas)
      .map(([key, schema]) => typeGuardTemplate(key, schema))
      .join('')
      .trim(),
  ].join('\n\n');
};

const importTemplate = (keys: string[]) => {
  return `
import { Validator } from 'jsonschema';
import type { ${keys.join(', ')} } from './schema';
  `;
};

const typeGuardTemplate = (type: string, jsonSchema: JSONSchema4) => {
  const stringifyJsonScheme = JSON.stringify(jsonSchema);
  return `
export const ${`is${type}`} = (value: unknown): value is ${type} => {
  return validator.validate(value, ${stringifyJsonScheme}).valid;
}
  `;
};

const schema = convertSwaggerSpecToJSONSchema(oasJson);
const result = defaultTypeGuardTemplate(schema);

아직 완벽하게 동작하는 기능은 아니지만 무리 없이 사용할 수 있도록 계속해서 개밥 먹기(dogfooding)를 하고 있습니다.

4. SchemaValidatorTask

위에서도 말씀드렸듯, Elixir의 doc test의 개념을 이용해 백엔드 개발자가 자신이 선언한 OAS가 실제 response와 일치하는지 integration test 단계에서 검증할 수 있는 기능을 제공하려고 했습니다. 저는 Ajv라는 라이브러리를 선택했는데, 백엔드에서 별도의 라이브러리 설치 없이 standalone 모드로 동작이 가능하기 때문입니다.

SchemaValidatorTask는 앞의 두 태스크에 비하면 아주 간단하게 동작합니다. OAS를 JSON schema로 변환하고 standalone 모드가 적용된 결과물을 반환하면 끝납니다.

import toJsonSchema from '@openapi-contrib/openapi-schema-to-json-schema';
import Ajv from 'ajv';
import standaloneCode from 'ajv/dist/standalone';

const schema = toJsonSchema(oasJson);

const ajv = new Ajv({
  schemas,
  code: {
    source: true,
    optimize: true,
  },
});

const result = standaloneCode(ajv);

5. 결과물 출력

각 태스크에서는 특정한 기준으로 전달받은 string 배열에서 중복을 제거합니다. 여기까지 이상이 없다면, 옵션에 명시된 경로에 결과를 출력합니다.

4. 결과물의 형태

다음과 같은 OAS JSON을 입력받았다고 가정했을 때 각 태스크의 실행 결과는 다음과 같습니다.

OSA.json
{
  "openapi": "3.0.0",
  "paths": {
    "/api/v1/category": {
      "get": {
        "operationId": "CategoryB2cController_findAll",
        "summary": "모든 카테고리 목록",
        "description": "모든 카테고리 목록을 조회한다.",
        "parameters": [],
        "responses": {
          "200": {
            "description": "",
            "content": {
              "application/json": {
                "schema": {
                  "properties": {
                    "statusCode": {
                      "type": "string"
                    },
                    "message": {
                      "type": "string"
                    },
                    "data": {
                      "$ref": "#/components/schemas/CategoryAllResponse"
                    }
                  }
                }
              }
            }
          }
        },
        "tags": ["카테고리 enum API"]
      }
    }
  },
  "components": {
    "schemas": {
      "CodeName": {
        "type": "object",
        "properties": {
          "code": {
            "type": "string"
          },
          "name": {
            "type": "string"
          }
        },
        "required": ["code", "name"]
      },
      "JobGroupCodeName": {
        "type": "object",
        "properties": {
          "code": {
            "type": "string"
          },
          "name": {
            "type": "string"
          },
          "jobs": {
            "type": "array",
            "items": {
              "$ref": "#/components/schemas/CodeName"
            }
          }
        },
        "required": ["code", "name", "jobs"]
      },
      "CategoryAllResponse": {
        "type": "object",
        "properties": {
          "jobGroup": {
            "type": "array",
            "items": {
              "$ref": "#/components/schemas/JobGroupCodeName"
            }
          },
          "jobSeekerStatus": {
            "type": "array",
            "items": {
              "$ref": "#/components/schemas/CodeName"
            }
          }
        },
        "required": ["jobSeekerStatus"]
      }
    }
  }
}
ReactQueryTask
import { useQuery, useMutation } from '@tanstack/react-query';
import type { UseQueryOptions, UseMutationOptions, QueryKey } from '@tanstack/react-query';

import fetcher from './Fetcher';
import type { RequestOptions } from './Fetcher';

export type Maybe<T> = T | null;

export type OneOf<T> = T extends unknown[]
  ? T[number]
  : T extends readonly unknown[]
  ? T[number]
  : T;

export type Exact<T extends { [key: string]: unknown }> = {
  [K in keyof T]-?: Exclude<T[K], undefined>;
};

export type Values<T extends Record<string, unknown>> = T extends Record<string, infer U>
  ? U
  : T extends { [key: string]: infer U }
  ? U
  : never;

export const enum ResponseStatus {
  OK = 'OK',
  SERVER_ERROR = 'SERVER_ERROR',
  DUPLICATE = 'DUPLICATE',
  NOT_FOUND = 'NOT_FOUND',
  BAD_REQUEST = 'BAD_REQUEST',
  BAD_PARAMETER = 'BAD_PARAMETER',
  CONFLICT = 'CONFLICT',
  UNAUTHORIZED = 'UNAUTHORIZED',
  FORBIDDEN = 'FORBIDDEN',
  PAYLOAD_TOO_LARGE = 'PAYLOAD_TOO_LARGE',
  TOO_MANY_REQUESTS = 'TOO_MANY_REQUESTS',
}

export type Success<T = unknown> = {
  statusCode: ResponseStatus.OK;
  message: '';
  data: T;
};

export type Error = {
  statusCode: Omit<keyof typeof ResponseStatus, 'OK'>;
  message: string;
  data: Maybe<''>;
};

/**
 * @typeParam T - Response data type
 */
export type ApiResponse<T = unknown> = Success<T>;

export const API_ENDPOINT = {
  V1_CATEGORY: '/api/v1/category',
} as const;

export const fetchV1Category = async (url: string, requestOptions?: RequestOptions) =>
  fetcher.get<V1CategoryResponse>(url, requestOptions);

export function useV1CategoryQuery({
  queryKey,
  url,
  options,
  requestOptions,
}: {
  queryKey: QueryKey;
  url: string;
  options?: Omit<
    UseQueryOptions<V1CategoryResponse, unknown>,
    'queryKey' | 'queryFn' | 'initialData'
  > & {
    initialData?: () => undefined;
  };
  requestOptions?: RequestOptions;
}) {
  const result = useQuery<V1CategoryResponse, unknown>(
    queryKey,
    () => fetchV1Category(url, requestOptions),
    options,
  );

  return { ...result, data: result.data?.data };
}

/**
 * created by ResponseSchemaGenerator
 */

export interface V1Category {
  data: Maybe<CategoryAllResponse>;
}
export interface CategoryAllResponse {
  jobGroup: Maybe<JobGroupCodeName[]>;
  jobSeekerStatus: CodeName[];
}
export interface JobGroupCodeName {
  code: string;
  name: string;
  jobs: CodeName[];
}
export interface CodeName {
  code: string;
  name: string;
}
export type V1CategoryResponse = ApiResponse<V1Category['data']>;
TypeGuardTask
import { Validator } from 'jsonschema';
import type { CodeName, JobGroupCodeName, CategoryAllResponse } from './schema';

const validator = new Validator();

export const isCodeName = (value: unknown): value is CodeName => {
  return validator.validate(value, {
    type: 'object',
    properties: { code: { type: 'string' }, name: { type: 'string' } },
    required: ['code', 'name'],
  }).valid;
};

export const isJobGroupCodeName = (value: unknown): value is JobGroupCodeName => {
  return validator.validate(value, {
    type: 'object',
    properties: {
      code: { type: 'string' },
      name: { type: 'string' },
      jobs: {
        type: 'array',
        items: {
          type: 'object',
          properties: { code: { type: 'string' }, name: { type: 'string' } },
          required: ['code', 'name'],
        },
      },
    },
    required: ['code', 'name', 'jobs'],
  }).valid;
};

export const isCategoryAllResponse = (value: unknown): value is CategoryAllResponse => {
  return validator.validate(value, {
    type: 'object',
    properties: {
      jobGroup: {
        type: 'array',
        items: {
          type: 'object',
          properties: {
            code: { type: 'string' },
            name: { type: 'string' },
            jobs: {
              type: 'array',
              items: {
                type: 'object',
                properties: { code: { type: 'string' }, name: { type: 'string' } },
                required: ['code', 'name'],
              },
            },
          },
          required: ['code', 'name', 'jobs'],
        },
      },
      jobSeekerStatus: {
        type: 'array',
        items: {
          type: 'object',
          properties: { code: { type: 'string' }, name: { type: 'string' } },
          required: ['code', 'name'],
        },
      },
    },
    required: ['jobSeekerStatus'],
  }).valid;
};

SchemaValidatorTask의 결과는 복잡한 하나의 파일로 만들어지기 때문에 소개해 드릴 필요는 없을 것 같습니다.

이렇게 만들어진 결과물을 프런트엔드 코드에서는 아래와 같이 사용할 수 있습니다.

import { useV1CategoryQuery, API_ENDPOINT } from './schema';
import { isJobGroupCodeName } from './guard';

const retrieveJobGroupCodeNameJobs = (value: unknown) => {
  if (isJobGroupCodeName(value)) {
    return value.jobs;
  }

  return [];
};

export function A() {
  const { data } = useV1CategoryQuery({
    url: API_ENDPOINT.V1_CATEGORY,
    queryKey: [API_ENDPOINT.V1_CATEGORY],
  });

  return <div>{data.jobSeekerStatus.map((status) => status.name)}</div>;
}

백엔드 테스트 코드에서는 schema validator를 다음과 같이 사용할 수 있습니다.

import validator from './validator';

describe('categoryAllResponse', () => {
  it('categoryAllResponse의 OAS와 실제 결과는 같아야 한다.', async () => {
    const response = await controller.categoryAllResponse();
    const result = validator.categoryAllResponse(result);
    expect(result).toBe(true);
  });
});

5. 정리

처음에 말씀드렸듯 언뜻 과정은 복잡해 보이지만 전혀 그렇지 않습니다. 오히려 구현하면서 고민이 많았던 부분은 책임을 적절하게 분배하고, sonarqube의 duplicate line code smell을 피하고자 코드 중복을 제어하는 부분이었습니다. 그렇기 때문에 코드 전반적으로 아래와 같은 선택을 하게 되었습니다.

  1. 각 객체의 책임을 세심하게 분리하기 위해 노력하였습니다.
  2. 각 객체의 횡단 관심사는 Service라는 layer로 나누어 필요한 객체가 필요한 만큼 참조하게끔 구현하였습니다.
  3. 런타임에 실행되는 로직이 바뀌어야 하는 경우 전략 패턴을 적극적으로 사용하였습니다.
  4. 똑같은 로직에서 작은 부분만 바뀌어야 하는 경우 템플릿 메서드 패턴을 사용하였지만, 상속의 단계가 한 단계 이상 넘어가지 않게 구현하였습니다.

구현된 구조를 간략하게 표현하자면 아래와 같습니다.

전체 다이어그램

이렇게 구현에 대한 소개는 마치도록 하겠습니다.

이어지는 글에서는 code generator를 도입하고 나서 어떻게 일하는 방식이 바뀌었는지 소개해 드리려고 합니다.