테크

2024. 12. 23

스마트한 협업, OAS Codegen 활용기

프론트엔드와 백엔드 간의 효율적인 협업툴

스마트한 협업, OAS Codegen 활용기스마트한 협업, OAS Codegen 활용기

비누팀에는 ‘서비스개발팀’과 ‘커머스개발팀’이 있습니다. 서비스개발팀은 에브리타임과 캠퍼스픽 등의 주요 서비스를 담당하고, 커머스개발팀은 Z세대 전문 커머스플랫폼인 ‘에브리유니즈’를 전담하고 있습니다. 이번 글에서는 커머스개발팀의 개발 이야기를 중심으로 풀어가 보겠습니다.


커머스개발팀은 기존에는 한 명의 개발자가 하나의 프로젝트를 맡아 프론트엔드와 백엔드 등 다양한 업무를 동시에 수행했습니다. 하지만 새로운 프로젝트와 기술 스택의 도입을 계기로, 프론트엔드와 백엔드의 역할을 명확히 분리하고, 팀원들이 각자의 전문 분야에 집중하며 전문성을 갖출 수 있도록 업무 구조를 재정비했습니다.


이 과정에서 프론트엔드 개발자와 백엔드 개발자 간의 원활한 협업을 위한 방안이 필요했습니다. 특히, 백엔드에서 제공하는 데이터를 프론트엔드가 효율적으로 활용할 수 있도록 API 명세를 문서로 정리하거나, Swagger 같은 도구를 활용해 사용법을 명확히 정의해야 했습니다. 이러한 작업은 개발자 간 오해를 줄이고, 보다 효율적으로 협업을 진행하는 데 큰 도움이 됩니다.


프론트엔드 개발자는 사전에 정의된 문서를 참고해 요청과 응답에 대한 인터페이스를 작성하고 이를 기반으로 작업을 이어가지만, 이런 방식에도 몇 가지 한계가 있습니다.



이러한 문제를 해결하기 위해 적극적으로 활용한 도구가 있습니다. 바로 OpenAPI Specification(OAS)입니다. 이를 통해 API 명세를 체계적으로 관리하고, 백엔드와의 협업을 더욱 원활하게 진행할 수 있었습니다. 이 과정에서 커머스캐발팀은 OAS를 어떻게 활용했는지, 자세히 알아보겠습니다.



1️⃣ OAS(OpenAPI Specification)란?

OAS는 코드에 직접 접근하지 않아도, 사람과 컴퓨터가 모두 이해할 수 있는 HTTP API의 인터페이스를 표준화된 방식으로 표현한 명세입니다. 쉽게 말해, RESTful API를 더 쉽고 체계적으로 관리하고 사용할 수 있도록 돕는 표준 명세 작성 방식이라고 이해하면 됩니다.


OAS의 가장 큰 장점은 API의 구조와 기능을 표준화된 형식으로 문서화할 수 있다는 점입니다. 예를 들어, Swagger에서 제공하는 샘플 문서를 보면, API가 어떤 방식으로 동작하는지, 어떤 데이터를 주고받는지 한눈에 확인할 수 있습니다. 또한, 여기를 통해 해당 OAS 파일을 직접 확인할 수도 있습니다.


프론트엔드 개발에서는 이러한 OAS를 파싱하여 코드로 변환하는 도구(code generator)를 활용하고 있습니다. 기능 명세서가 나오면 백엔드는 명세 기반으로 OAS를 먼저 제공하고, 프론트엔드는 이를 바탕으로 API 요청과 응답 인터페이스를 사전에 준비할 수 있습니다. 덕분에 프론트엔드 개발자는 백엔드의 비즈니스 로직 구현을 기다리지 않고도 작업을 진행할 수 있어 협업 효율이 크게 향상됩니다.


그렇다면 커머스개발팀에서는 OAS를 어떻게 활용하고 있을까요?



2️⃣ 커머스몰 API 예제를 통한 OAS와 Zod 활용법 알아보기

프론트엔드 개발에서 OAS를 활용할 때, 중요한 정보는 다음과 같습니다.



이러한 정보는 프론트엔드와 백엔드 간의 통신을 명확하게 정의하고, 데이터의 흐름을 예측 가능하게 만들어줍니다. 예를 들어, 커머스몰에서 상품 정보를 조회하는 API가 있다고 가정해보겠습니다.


method: GET
api: /api/items
request
  - query
      itemName: string;
      brandName: string;		 	   

response {
  id: number;
  itemName: string;
  brandName: string;
  price: number;
  imageUrl: string;
  category: string;
}[] 


프론트엔드에서는 이러한 API 정보를 활용해 요청과 응답에 대한 타입을 추론하고, 데이터의 유효성을 검증해야 합니다. 이때 Zod라는 라이브러리를 사용하면 OAS에서 정의된 데이터를 기반으로 요청과 응답 스키마를 정의할 수 있습니다. Zod를 활용하면 컴파일 단계에서 타입 오류를 사전에 방지할 수 있을 뿐만 아니라, 런타임 단계에서도 실제 데이터가 예상한 구조를 따르는지 검증할 수 있습니다.


상품 조회 API를 Zod로 정의하면 다음과 같은 코드로 작성할 수 있습니다.


const schema = {
  findItems: {
    url: '/api/items',
    method: 'get',
    params: {
      header: z.never(),
      query: z.object({
	itemName: z.string().optional(),
	brandName: z.string().optional()
      })
      path: z.never(),
      body: z.any()
     },
     response: z.object({
        id: z.number(),
        itemName: z.string(),
        brandName: z.string().optional(),
	price: z.number(),
	imageUrl: z.string(),
	category: z.string()
     })
   }
 }


이때 각 객체의 키값으로는 OAS에서 제공하는 operationId 속성을 활용할 수 있습니다. Swagger 공식 문서에 따르면, operationId는 각 API operation을 고유하게 식별하기 위한 값으로, 툴이나 라이브러리에서 프로그래밍적으로 활용할 수 있는 고유한 이름을 따르는 값으로 정의됩니다. 이를 통해 각 API를 명확히 구분하고, 코드를 체계적으로 관리할 수 있습니다.


OAS를 기반으로 한 API 정의가 준비되었으면, 이제 이를 활용하여 실제 프론트엔드 코드에서 사용할 수 있도록 자동화된 코드를 생성하는 작업이 필요합니다. 이를 위해서는 codegen을 활용해 OAS를 파싱하고, 필요한 타입과 구조를 생성하는 과정이 필요합니다.



3️⃣ 작업 효율성을 높이기 위한 codegen 구현하기

OAS를 활용한 작업의 첫 번째 단계는 OAS를 받아오는 것입니다. 이를 위해 @apidevtools/swagger-parser라는 패키지를 사용하여 OAS를 파싱할 수 있습니다.


파싱이 완료되면, 데이터의 구조를 원활하게 처리하기 위해 데이터를 가공하는 작업이 필요합니다. OAS에서 자주 볼 수 있는 $ref 키는 외부에 정의된 스키마를 참조합니다. 이 참조를 활용하면 코드의 중복을 줄이고, 여러 곳에서 일관된 데이터를 사용할 수 있어 효율성을 높일 수 있습니다.


예를 들어, OAS 스펙에서 다음과 같이 $ref를 통해 다른 스키마를 참조하는 구조를 볼 수 있습니다.


 "application/json": {
    "schema": {
      "$ref": "#/components/schemas/Pet"
    }
  },
  ...
 "components": {
   "schemas": {
     "Category": {
	...
      },
     "Pet": {,
        "properties": {
	 ...
          "category": {
            "$ref": "#/components/schemas/Category"
          },
          ...
          "tags": {
            "type": "array",
            "xml": {
              "wrapped": true
            },
            "items": {
              "$ref": "#/components/schemas/Tag"
            }
          },
        },
      },
      "Tag": {
        ...
      },
    },


schema$refPet을 참조하고 있으며, Pet 안의 category는 다시 Category를 참조합니다. 그러나 OAS 파싱 결과를 그대로 사용하면 다음과 같은 $ref 구조로 인해 코드 상에서 이 값이 실제로 무엇을 의미하는지 명확히 알기 어렵습니다.


이 문제를 해결하기 위해 OAS 객체를 순환하며 데이터를 가공하는 작업이 필요하고, json-refs 라이브러리를 통해 쉽게 구현할 수 있습니다. 이 라이브러리는 $ref를 자동으로 해석하고, 중첩된 스키마를 처리해 개발자가 쉽게 활용할 수 있도록 데이터를 정리해줍니다. 데이터를 가공한 결과는 다음과 같습니다.


"application/json": {
    "schema": {
      "properties": {
	id: { type: 'integer', format: 'int64', example: 10 },
	name: { type: 'string', example: 'doggie' },
	category: { type: 'object', properties: [Object], xml: [Object] },
        photoUrls: { type: 'array', xml: [Object], items: [Object] },
	tags: { type: 'array', xml: [Object], items: [Object] },
	status: {
	  type: 'string',
	  description: 'pet status in the store',
	  enum: [Array]
	}
      },
    }
  },
  


이 정보를 바탕으로 Zod 스키마를 생성할 수 있습니다. OAS 정보를 Zod 스키마로 변환하는 작업은 직접 구현할 수도 있지만, 이를 자동으로 처리해주는 라이브러리인 json-schema-to-zod를 활용하면 더 효율적입니다. 이 라이브러리를 사용하면 OAS에 있는 모든 URL과 값들에 대해 순환을 돌려 각 파라미터에 맞는 객체를 구성하고, 이를 Zod로 변화합니다.


Pet 스키마를 참고하여, updatePet이라는 operationId를 가진 API는 다음과 같이 나타낼 수 있습니다.


const schema = {
  updatePet: {
    url: '/pet',
    method: 'put',
    params: {
      header: z.never(),
      query: z.never(),
      path: z.never(),
      body: z.object({
        id: z.number().int().optional(),
        name: z.string(),
        category: z.object({ id: z.number().int().optional(), name: z.string().optional() }).optional(),
        photoUrls: z.array(z.string()),
        tags: z.array(z.object({ id: z.number().int().optional(), name: z.string().optional() })).optional(),
        status: z.enum(['available', 'pending', 'sold']).optional(),
      }),
    },
    response: z.object({
      id: z.number().int().optional(),
      name: z.string(),
      category: z.object({ id: z.number().int().optional(), name: z.string().optional() }).optional(),
      photoUrls: z.array(z.string()),
      tags: z.array(z.object({ id: z.number().int().optional(), name: z.string().optional() })).optional(),
      status: z.enum(['available', 'pending', 'sold']).describe('pet status in the store').optional(),
    }),
  }
};



4️⃣ 프론트엔드 개발에 codegen 활용하기

커머스개발팀에서는 codegen을 통해 생성된 API 스키마를 파라미터로 받아 리액트 훅스(hooks)를 생성하여 사용하고 있습니다. 먼저, 위에서 얻은 객체에 대한 interface를 ApiSchema로 정의하고, 아래와 같은 타입 구조를 갖도록 설계합니다.


import { AnyZodObject, ZodNever, ZodArray, ZodAny } from 'zod';

type ParameterZod = AnyZodObject | ZodNever;

interface ApiSchema {
  url: string;
  method: string;
  params: {
    header: ParameterZod;
    query: ParameterZod;
    path: ParameterZod;
    body: ParameterZod | ZodAny;
  };
  response: ParameterZod | ZodArray<AnyZodObject>;
}


데이터를 처리할 때, 가장 중요한 것은 요청 파라미터가 정확한 타입을 따르는지와, 응답 타입을 올바르게 추론할 수 있는지입니다.

다음으로, API 스키마에 대한 타입을 추론할 수 있도록 구현해 보겠습니다. 먼저, Zod의 infer 함수를 활용합니다.


const User = z.object({
  username: z.string(),
});

type User = z.infer<typeof User>; // { username: string }


이 함수를 사용해 타입 추론을 할 수 있습니다. 위의 예제와 Typescript를 활용해 코드를 작성하면 아래와 같습니다.


import { z } from 'zod';

import ApiSchema from '@interfaces/ApiSchema';
import axiosClient from '@utils/axios';

function useRead<T extends ApiSchema>(
  schema: T,
  request: {
    header?: z.infer<T['params']['header']>;
    query?: z.infer<T['params']['query']>;
    path?: z.infer<T['params']['path']>;
    body?: z.infer<T['params']['body']>;
  }
) {
  const data = await axiosClient<z.infer<T['response']>>({
    method,
    url,
    params: {
       ...request?.query
    }
  });
  
  return data;
}

export default useRead;


위 코드에서 useRead라는 커스텀 훅을 정의하고, 파라미터로 어떤 스키마에 대한 정보가 들어오는지와 요청으로 들어오는 값을 인자로 받습니다. 이 때, 제네릭 타입 TApiSchemaextends를 함으로써 타입의 종류를 제한합니다. 위와 같은 방법으로 제네릭을 통해 타입을 제약시킨 후, 각각의 응답 또는 요청에 대해 타입을 추론할 수 있습니다.


이제, 위에서 정의한 findItems를 우리가 만든 커스텀 훅에 넣어 사용해보겠습니다.


const {findItems} = apiSchema;

const itemQuery = {
   itemName: "Every uneez",
   brandName: ""
}
const data = useRead(
  findItems,
  {
     query: itemQuery
  }
);

// error: data에 추론된 타입으로 인한 에러
data.map(r => r.thumbnailUrl)

// 올바른 타입
data.map(r => r.price)


useRead 훅에서 axiosClientApiSchema를 통해 추론된 타입을 제네릭으로 넘겨주기 때문에, data는 다음과 같이 타입 추론이 가능합니다.


{
  id: number,
  itemName: string,
  brandName?: string,
  price: number,
  imageUrl: string,
  category: string
}


이렇게 타입이 추론되면, request.query 또한 제약된 제네릭을 사용해 타입 추론이 가능해집니다. 따라서, 개발자가 스키마에 정의되지 않은 요청을 넘겨주면 타입 에러가 발생하게 됩니다.


const {findItems} = apiSchema;

// type safe
const data1 = useRead(
  findItems,
  {
     query: {
       itemName: "Every uneez",
       brandName: ""
    }
  }
);

// type error
const data2 = useRead(
  findItems,
  {
     query: {
       itemName: 123, // itemName은 string
       brandName: ""
    }
  }
);


이렇게 타입 세이프한 API 통신을 구현하면, Swagger나 문서를 따로 확인하지 않아도, API 호출 시 올바른 타입 추론이 자동으로 이루어지기 때문에 더욱 안전하고 효율적인 개발이 가능합니다.



5️⃣ TanStack Query 응용을 통해 효율적으로 API 연동하기

현재 커머스개발팀은 서버 상태를 효율적으로 관리하기 위해 비동기 상태 관리 라이브러리 ‘TanStack Query’를 사용하며, 확장성과 사용 편의성을 고려해 이를 연동한 커스텀 훅스를 제작하고 있습니다.


개발 단계에서 넘어온 요청과 OAS Codegen을 통해 생성된 타입이 적합한지 런타임에서 검증하기 위해 아래와 같은 유틸리티 함수를 구현했습니다.



function parsePathUrl(url: string, data: Record<string, any> | undefined) {
  if (!data) return url.replace(/:[^/]+/g, '');

  return url.replace(/:([a-zA-Z0-9_]+)/g, (_, key) => {
    return key in data ? data[key] : '';
  });
}

export function isValidParameterData(parameter: AnyZodObject | ZodAny | ZodNever, data: unknown) {
  if (parameter instanceof ZodNever) return true;
  if (parameter instanceof ZodAny) return true;
  if (!data) return false;

  const { success } = parameter.strict().safeParse(data);
  return success;
}


이 커스텀 훅스에서는 TanStack Query의 유연성을 극대화하기 위해 queryOptions라는 인터페이스를 열어두고, 이를 TanStack Query가 제공하는 UseQueryOptions 타입과 연동했습니다. UseQueryOptions의 제네릭으로 API 응답 타입을 지정함으로써, 후속 API 요청에서도 재사용이 가능합니다.

추가적으로, TanStack Query의 enabled 옵션에 앞서 정의한 유틸리티 함수를 적용하여, 런타임 중 특정 조건을 만족하는 요청 데이터가 있을 때만 useQuery가 실행되도록 설정했습니다.


결과적으로 작성된 코드는 아래와 같습니다.


import { UseQueryOptions, useQuery } from '@tanstack/react-query';
import { z } from 'zod';

import ApiSchema from '@interfaces/ApiSchema';

import axiosClient from '@utils/axiosClient';
import { isValidParameterData, parsePathUrl } from '@utils/zodUtils';

function useRead<T extends ApiSchema>(
  resource: T,
  request: {
    header?: z.infer<T['params']['header']>;
    query?: z.infer<T['params']['query']>;
    path?: z.infer<T['params']['path']>;
    body?: z.infer<T['params']['body']>;
  },
  queryOptions: UseQueryOptions<z.infer<T['response']>>
) {
  const { url, method, params } = resource;
  const { header, query, path, body } = params;

  const tQuery = useQuery<z.infer<T['response']>>({
    ...queryOptions,
    queryFn: () => {
      return axiosClient({
        method,
        url: parsePathUrl(url, request?.path),
        params: {
          ...request?.query,
        },
      });
    },
    enabled:
      isValidParameterData(header, request?.header) &&
      isValidParameterData(query, request?.query) &&
      isValidParameterData(path, request?.path) &&
      isValidParameterData(body, request?.body) &&
      queryOptions?.enabled,
  });

  return tQuery;
}

export default useRead;

위와 같은 커스텀 훅스를 통해 API 명세 변경에 즉각적으로 대응할 수 있으며, 프론트엔드 개발 과정에서 높은 유연성과 생산성을 확보할 수 있게 되었습니다.


-


지금까지 커머스개발팀의 OAS 활용 사례를 통해 프론트엔드와 백엔드 간의 협업 방식을 살펴보았습니다. 이 방식은 커머스몰 개발은 물론, 다양한 프로젝트에서도 유연하게 활용할 수 있어 보다 효율적인 협업을 원할 때 고려해 보시면 좋겠습니다. 앞으로도 커머스개발팀만의 개발 노하우와 문화 등 다양한 이야기를 나눌 예정이니 많은 기대 부탁드립니다.


Written by 송민선