안녕하세요 😊 오늘은 Swagger 문서와 같은 OAS(OpenAPI Specification) 문서로부터 원하는 형태로 타입을 자동 생성하는 Generator를 직접 만든 경험을 공유하고자 합니다.
문제 정의
타입스크립트가 보편화되면서 우리는 더 안전하게 코드를 작성할 수 있는 환경을 가지게 되었습니다. 하지만 그만큼 작성해야 하는 코드도 많아졌죠.
특히 API 통신 시 전달받은 API 스펙에 맞춰 타입을 수동으로 정의하는 과정은 번거롭고, 실수도 발생하기 쉬운 부분입니다. 직접 입력하다 보면 타입을 잘못 정의하거나 백엔드 개발자가 수정한 내용을 반영하지 못하는 경우가 생길 수 있죠.
저는 이전에 graphql-codegen이라는 라이브러리를 사용한 경험이 있어서, REST API에서도 자동으로 타입을 생성할 수 있는 도구가 필요하다는 갈증이 있었습니다.
그러던 중, FEconf 2024에서 토스의 양의현님이 직접 기능을 구현하셨다는 발표를 보고 도전해 보기로 결심했습니다.
라이브러리 vs 직접 구현
사이드 프로젝트로 만들어보는 것이었지만 현업에선 어떻게 했을까를 고민해보았습니다. 과연 직접 구현할만한 시간을 낼 수 있을까? 차라리 기존의 라이브러리를 익혀서 사용하는 것이 효율적이지 않을까 하고 말이죠.
여러 글과 영상을 보며 비교를 해보았고 직접 구현하겠다! 고 결정하게 되었습니다. 그 이유는 기존 라이브러리의 한계 때문이었습니다.
기존 라이브러리의 한계
OpenAPI 기반 타입 생성기 중 가장 많이 쓰이는 것은 openapi-generator입니다. 하지만 현업에서 사용하기에는 몇가지 문제가 있었습니다.
1. 수정의 어려움
openapi-generator를 사용할 때, enum 값이 제대로 변환되지 않거나 한글로 된 함수가 정상적으로 생성되지 않는 문제가 발생할 수 있습니다. 또 코드의 양이 너무 많아져서 추가적인 코드를 통해 최적화를 해줘야하기도 하죠. 라이브러리의 수정이 필요할 경우, 직접 해결할 수 없고 개발자의 업데이트를 기다려야 하는 경우도 많았습니다.
이런 문제를 해결하려면 별도의 스크립트를 추가해야 했고, 새롭게 합류하는 팀원에게 설명해야 할 부분이 늘어난다는 단점이 있었습니다.
2. mustache 템플릿의 한계
openapi-generator의 타입생성기는 Mustache라는 템플릿 언어를 지원하는데요. Mustache는 조건문을 지원하지 않습니다. 예를 들어
“GET 요청에 대한 쿼리 타입만 생성하고 싶다!”
라고해도 method === 'get' 같은 조건을 걸 수 있는 기능이 없습니다. 이러한 한계는 분명 불편한 상황을 초래할 가능성이 컸습니다.
3. 불친절한 문서
openapi-generator 사용 후기를 살펴보니, 문서에 설명이 부족해서 직접 코드 내부를 파악해야 하는 경우가 많다고 했습니다.
현업에서는 급한 수정이 필요한 경우도 많기 때문에, 문서가 친절하지 않아 빠르게 대응하지 못하는 상황이 발생할 수 있다면, 이는 치명적인 단점이 될 수 있습니다.
구현 목표
1. zod schema 생성
저는 가장 먼저 zod schema를 생성하고 싶었습니다. 혹시 zod를 모르시는 분들을 위해 설명드리겠습니다. zod는 타입스크립트 기반 유효성 검증 라이브러리입니다.
말이 어려울 수 있는데 쉬운 예시를 들어드리겠습니다. form을 만들 때 아래와 같이 선언적으로 스키마를 정의하고, 입력값의 유효성을 검사할 수 있습니다.
const userSchema = z.object({
email: z.string(),
age: z.number().min(9),
})
유사한 라이브러리로 yup, joi 등이 있지만 zod의 가장 큰 차별점은 타입스크립트 지원입니다. 위처럼 스키마를 정의하면, 해당 스키마를 기반으로 타입을 자동으로 생성할 수도 있습니다.
type UserSchema = z.infer<typeof userSchema>;
즉, 단 하나의 스키마(Single Source of Truth)에서 타입과 유효성 검사를 모두 관리할 수 있기 때문에 코드 유지보수가 훨씬 쉬워집니다.
이런 장점 덕분에 현업에서도 Zod를 많이 사용하고 있으며, 그래서 가장 먼저 Schema를 만드는 작업을 1순위 목표로 삼았습니다.
2. resources 객체 생성
이 아이디어는 위에서 언급한 토스 발표에서 영감을 얻었습니다. 토스에서는 API를 관리하는 방식으로 아래와 같은 resources 객체를 활용하는 듯 했습니다.
const resources = {
"Pet: Update Update an existing pet": {
"path": "/pet",
"method": "put",
"params": { "body": Pet },
"response": Pet
},
...
}
처음에는 바로 API 함수를 만들려 했지만, 위처럼 객체를 먼저 정의하면 얻을 수 있는 장점이 많다고 생각했습니다.
첫번째는 결합도가 낮아진다는 것입니다. 만약 API 함수를 아래처럼 직접 만든다면, 특정 라이브러리(예: axios)에 종속됩니다.
const getUsers = async () => {
const { data } = axios.get('');
return data;
}
실무에서는 보통 axios 인스턴스를 Wrapping 해서 사용하지만, API 호출 방식이 바뀔 경우 이런 코드들의 수정이 필요할 수도 있습니다. 반면, resources 객체를 사용하면 특정 라이브러리에 대한 의존성이 줄어듭니다.
두번째는 활용도가 높다는 것입니다. 객체에 있는 값을 react query의 쿼리키로도 활용할 수 있고 API 함수뿐만 아니라, Mock API, 테스트, 문서화 등 다양한 용도로 재사용할 수 있을 것 같았습니다.
이러한 이유로, resources 객체를 먼저 정의하는 것이 더 나은 방향이라고 판단했습니다.
추가 설명: API first design
마무리를 짓기전에 한가지 추가로 설명드리고 싶은 게 있습니다. 이 모든 작업의 바탕에는 API First Design이라는 원칙이 깔려 있습니다.
API first design은 백엔드 개발자와 프론트엔드 개발자 간의 약속입니다. 작업을 시작하기 전에 API spec을 함께 정의하고 백엔드 API 구현 전에 미리 swagger 문서를 작성해두는 것입니다.
이를 통해 다음과 같은 이점을 얻을 수 있습니다.
1. 안정적으로 codegen할 수 있다
codegen을 하려면 스웨거 문서가 있어야겠죠. 그런데 API 명세 없이 백엔드 구현이 늦어진다면 어떻게 될까요?
프론트엔드는 API를 예측해서 임의로 Spec을 입력해야 할 수도 있고, 이런 경우 Codegen의 의미가 사라지게 됩니다.
하지만 API First Design을 따른다면, 미리 정해진 Spec을 기반으로 Codegen을 안정적으로 활용할 수 있습니다.
2. 데이터 생성에 도움을 받을 수 있다.
다시 resources 객체를 살펴보겠습니다. (토스 개발자분이 예시를 들기 위해 오픈소스로 생성한 부분이라 실제론 다를 것 같습니다)
const resources = {
"Pet: Update Update an existing pet": {
"path": "/pet",
"method": "put",
"params": { "body": Pet },
"response": Pet
},
...
}
객체의 키가 Pet: Update Update an existing pet 라는 string으로 되어있고 이는 백엔드 개발자가 정의한 API의 summary 부분입니다. 이 부분을 사용해서 API 함수를 만들자면 아래와 같이 작업해야할것입니다.
const api = resources['Pet: Update Update an existing pet']
매우 혼란스러운 상황이 될 수 있습니다. 🥹 만약 백엔드 개발자가 summary 부분을 임의로 변경한다면, 프론트엔드 코드가 깨질 가능성이 높습니다.
하지만 API First Design을 따르면, API 명세에서 summary 형식을 사전에 약속할 수 있습니다. 이를 통해 일관된 규칙을 유지하고, 불필요한 혼선을 방지할 수 있습니다.
또한, 백엔드에서 폼의 유효성 정보(예: 필드의 최대 길이)를 함께 제공한다면, 프론트엔드에서 별도로 스키마를 커스텀해야 하는 번거로움을 줄일 수 있습니다.
따라서 OpenAPI Generator를 만들 때, 팀과 미리 API First Design 원칙을 공유하고 협의한다면, 더욱 의미 있고 효율적인 결과를 얻을 수 있을 것입니다.
구현과정
그럼 하나하나 봐볼까요? 설명코드는 타입까지 전부 빼버리고 아주 간단한 버전으로 가져왔습니다.
fullcode가 궁금하신 분들은 맨 아래 Github 주소를 참고해주세요 :)
Zod 스키마 생성
zod 스키마 생성은 매우 간단했는데요. 주석을 참고하시면 이해가 잘 가실 것 같습니다.
//swagger-parser 라이브러리로 swagger 스펙을 json 형태로 변환
const api = await SwaggerParser.bundle(swaggerUrl);
const jsonSchemas = api.definitions || {};
const zodSchemas = {};
//json schema를 순회하며 zod schema로 변환하여 저장
for (const [schemaName, jsonSchema] of Object.entries(jsonSchemas)) {
zodSchemas[schemaName] = toZodSchema(jsonSchema);
}
//handlebar 템플릿을 사용하여 파일로 변환
await hbsToFile(templatePath, outputPath, {
schemas: zodSchemas,
});
이때 json 스키마를 변환해주는 toZodSchema 함수를 좀 더 자세히 살펴보겠습니다.
function removeDescriptions(
schema,
) {
...
}
export function toZodSchema(jsonSchema) {
const schema = removeDescriptions(jsonSchema);
return jsonSchemaToZod(schema);
}
스키마 변환 전에 removeDescriptions라는 함수를 거치고 있는데요. 이는 schema 안에 있는 descriptions라는 속성을 없애주기 위해 추가했습니다. 이렇게 없애주지 않으면 스키마마다 아래와 같이 describe라는 불필요한 코드가 붙기 때문입니다.
export const ApiResponse = z.object({
code: z.number().int().optional().describe('어쩌구저쩌구저쩌구어쩌구'),
});
Resources 객체 생성
resources 객체를 생성하는 코드는 아래와 같습니다. 이것도 주석으로 설명을 대신하겠습니다.
const api = await SwaggerParser.bundle(swaggerUrl);
const paths = api.paths;
//기존에 있던 스키마를 재사용하는 API가 있다면 import하기 위해 따로 저장
const existingSchemasSet = new Set();
const resources = {};
for (const [path, pathItem] of Object.entries(paths)) {
for (const [method, operation] of Object.entries(pathItem)) {
const operationObj = operation;
//swagger 문서의 operationId를 resource의 키로 사용
// Ex)getUserList
const resourceKey = operationObj.operationId || "";
// params와 response 타입을 각각 파싱
const parametersType = getParametersType(operationObj.parameters);
const responseType = getResponseType(operationObj.responses);
resources[resourceKey] = {
path,
method: method.toLowerCase(),
params: parametersType,
responseType,
};
findExistingSchemas(operationObj).forEach((schema) =>
existingSchemasSet.add(schema),
);
}
}
// resources 결과물 예시
{
uploadFile: {
path: '/pet/{petId}/uploadImage',
method: 'post',
params: { path: [Object], formData: [Object] },
responseType: 'ApiResponse',
...
},
// existingSchemasSet 결과물 예시
{ 'ApiResponse', 'Pet', 'Order', 'User' }
const templatePath = path.join(TEMPLATES_DIR, "resources.hbs");
const outputPath = path.join(OUT_DIR, "resources.ts");
// handlebars 템플릿을 통해 resources.ts 파일로 저장
await hbsToFile(templatePath, outputPath, {
resources,
imports: Array.from(existingSchemasSet),
});
full code
참고링크
기존 라이브러리의 한계 (이한님의 발표: OpenAPI Generator 실전편, 안현모님의 글: Front-end에서 OAS generator를 어떻게 쓰면 좋을까?)
API first design (김정규님의 발표: 오늘도 여러분의 API는 안녕하신가요? API first design과 codegen 활용하기)
'프로젝트 개선' 카테고리의 다른 글
거대한 컴포넌트, 복합 컴포넌트 패턴으로 깔끔하게 정리하기 (0) | 2025.03.10 |
---|---|
tailwind css에서 classname 정렬하기 (tagged template literals) (1) | 2024.10.30 |
Next/Image 커스텀로더로 서버 부하 줄이기 (2) | 2024.10.28 |
라이브러리 크기가 클 때 성능 개선하기 (코드 스플리팅, 부분 import) (2) | 2024.10.28 |