이전 1탄(안 보신분들은 1탄 먼저(1탄 보러가기))에서는...
open api 도입 과정에서의 의사 결정 및 간략하게 openapi-ts라는 라이브러리를 소개하며 마쳤다.
이번 2탄은 실전편으로 필자가 어떤 방식으로 openapi-ts를 사용했고, 적용했는 지 적어보겠다.
이제 드디어... 때가 되었다. openapi를 활용할 기회가.
때마침 그렇게 크지 않은 규모의 사내 신규 어드민 프로젝트를 할 기회가 주어졌다.
필자와 백엔드 개발자 1명 그리고 기획자로 구성된 TF가 구성되었고,
바로
를 시전하지는 않았고... 백엔드 개발자분께 전후 사정을 말씀 드렸고, 결국 대망의 open api 스펙 문서 주소를 공유 받을 수 있었다.
자 이제 그토록 바라던 open api ts를 활용할 수 있다!!!
가장 먼저, openapi-ts 라이브러리부터 설치해야 한다.
기본적으로 해당 라이브러리는 nodejs 버전 20.x 이상의 버전을 권장한다.
각자 패키지 매니저에 따라 설치
npm i -D openapi-typescript typescript
설치만 하면 땡이냐? 그건 아니다!
몇 가지 설정이 더 필요하다.
바로 tsconfig 설정이 필요하다.
아무래도, 우리가 결국 json 혹은 yaml 파일을 typescript 된 문서로 컨버팅 하는 과정이 필요하기에...
이걸 구성하고 있는 환경에 대한 조정이 필요하다.
tsconfig.json
{
"compilerOptions": {
"module": "ESNext", // or "NodeNext"
"moduleResolution": "Bundler", // or "NodeNext"
"noUncheckedIndexedAccess": true // 이건 높은 권장사항이다. 권장하는 이유는 이후에 살펴볼 타입 추론 방식을 참조하면 이해가 될 거라고 생각한다.
}
}
기본적으로는 명령어 기반으로 제네레이팅 한다.
# Local schema
npx openapi-typescript ./path/to/my/schema.yaml -o ./path/to/my/schema.d.ts
# 🚀 ./path/to/my/schema.yaml -> ./path/to/my/schema.d.ts [7ms]
# Remote schema
npx openapi-typescript https://myapi.dev/api/v1/openapi.yaml -o ./path/to/my/schema.d.ts
# 🚀 https://myapi.dev/api/v1/openapi.yaml -> ./path/to/my/schema.d.ts [250ms]
이런 식으로 스크립트를 실행시키면 본인 환경에 따라, 알아서 ts 파일이 제네레이팅 될 것이다.
이후에 이 부분에 대해 필자는 프로젝트에서 어떻게 사용했는 지 알려드리겠다.
이후에는 이 스키마 파일을 기반으로 필요한 곳에서 타입을 사용하면 된다.
import type { paths, components } from "./my-openapi-3-schema"; // generated by openapi-typescript
// Schema Obj
type MyType = components["schemas"]["MyType"];
// Path params
type EndpointParams = paths["/my/endpoint"]["parameters"];
// Response obj
type SuccessResponse =
paths["/my/endpoint"]["get"]["responses"][200]["content"]["application/json"]["schema"];
type ErrorResponse =
paths["/my/endpoint"]["get"]["responses"][500]["content"]["application/json"]["schema"];
위의 타입을 보고 간단히 설명하자면, 기본적으로 path 접근 방식과 schema 접근 방식이 존재한다.
두 방식 모두 활용가능하며, 사용의 판단은 사용자에게 맡긴다.
간단히 얘기하면, open api 스펙 기반의 json 혹은 yaml로 타입스크립트로 변환해서 필요, 필수, 선택적인 모든 타입을 만들어준다.
웬만하면,제네레이팅 된 ts 파일 자체의 타입들을 뜯어보길 권장한다.
타입 스크립트를 조금이라도 써 본 사람이면, 그렇게 어렵지 않을 것이다. 정 모르겠으면 gpt 선생님께 헬프~
자 이제 실전편 답게 이 제네레이팅된 스키마 파일을 활용하는 게 중요하지 않겠는가?
그래서, 문서에서도 openapi-fetch와 같은 fetch-client 라이브러리 사용을 권장한다.
openapi-ts 쪽에서 공식적으로 만든 fetch-client 라이브러리인데, 사용도 쉽고 기존 fetch의 타입 safe하지 못 한 점들을 잘 보완해서 좋다.
아래는 사용 예제다.
import createClient from "openapi-fetch";
import type { paths } from "./my-openapi-3-schema"; // generated by openapi-typescript
const client = createClient<paths>({ baseUrl: "https://myapi.dev/v1/" });
const {
data, // only present if 2XX response
error, // only present if 4XX or 5XX response
} = await client.GET("/blogposts/{post_id}", {
params: {
path: { post_id: "123" },
},
});
await client.PUT("/blogposts", {
body: {
title: "My New Post",
},
});
이런 식으로 우리가 기존에 axios나 다른 라이브러리에서만 지원하던 기본적인 기능들이 다 있다.
cerateClient로 선언하고 타입을 제네레이팅 된 d.ts의 가장 최상단 타입인, paths로 선언해 주기만 하면 설정 끝이다.
이후에는 사용하는 곳에서 저런 식으로 쓰면 호출이 된다.
그리고 이 라이브러리의 가장 강력한 점은 다음에 있다.
영상으로 확인해 본 결과,
어떠신가?
필자는 이걸 보고... 그 동안 시간을 버렸구나... 라고 생각했다.
이렇게 자동 타이핑 뿐만 아니라, 필수 값이 빠졌다고 하면, 다 알려주기 까지 한다.
실로 놀라울 따름이다.
이 외에도 post에서는 body를 추론해 주는 등, rest-api 기반으로 기존의 open api 문서에서 필요한 것들을 알아서 맵핑해서 처리해 주는 것이다.
그렇다면, 필자는 이걸 어떤 식으로 활용했는 지 말씀 드려 보겠다.
공식 문서에도 나와있지만, 일반적으로 스크립트화 해서 자동화 시키는 것이 좋다. 왜냐면, 그래야... 추후 변경될 때 마다 찔러보고, 깃허브 액션 등과 연동시켜서 pr을 올려서 변경된 스키마를 바로 파악할 수 있기 때문이다.(물론 그건 각자 회사의 사정에 맞게 하면 된다)
이런 식으로 script를 package.json으로 처리해서 넣어주면 된다.
script는 위에서 설명한 대로,
패키지 실행 명령어 openapi-typescript / input으로 들어갈 json 혹은 yaml 주소 / -o(아웃풋 저장 장소) 제네레이팅된 저장 파일 주소(
보통은 ./src/__generated__/api.ts 이렇게 __generated__라는 식으로 붙여서 이건 생성된 파일임을 명기해 준다고 함.
)
그러면, 이제 끝이다.
여기서 앞서 설명한 대로, 이 스크립트 명령 외에도, 주기적으로 업데이트 되는 서비스의 경우, 각 ci/cd 구축 툴에 따라 script yaml 파일 등으로 자동 동기화 시켜주면 더 좋지 않을까 생각한다.
다만, 필자는 현재 자주 바뀌는 서비스의 api 스키마는 아니어서, 일단 이대로 두고 작업했다.
자 이렇게 스크립트 만들고 처리하면, 이런 식으로 아래 사진처럼 파일이 생성될 것이다.
자!~
이제 예상대로 모든 준비는 끝났다. 진짜 이제 활용하기만 하면 된다.
이제는 api client를 미리 셋팅해 두면 된다.
마치, axios intercepter 처럼 기본적으로 셋팅하고 사용하는 거라고 보면 똑같다.
이런 식으로 하면 끝이다.
import type { paths } from '@src/__generated__/api';
import createFetchClient from 'openapi-fetch';
import createClient from 'openapi-react-query';
export const client = createFetchClient<paths>({
baseUrl: `${process.env.NEXT_PUBLIC_SERVER_HOST_URL}`,
});
export const $api = createClient(client);
여기서 눈치채신 분들도 계시겠지만, createClient를 하나 더 썼다.
이유는 필자는 리액트 쿼리를 쓰고 있고, 이를 openapi-ts 에서도 지원한다.
미쳤다... 진심.
궁금하다면, 공식 문서를 들어가 보길 바란다.
물론, 아직 메이저하게 완벽 지원되지는 않는다. 하지만, 기본적인 query, mutation, queryoptions 등은 지원하기에...
간단히 사용하는데는 문제가 없다.
일전에 보여줬던 모든 fetch 타입 지원에 기본적인 쿼리 옵션들도 타입화가 잘 되어 있다.
다만, 아직 useInfiniteQuery 등은 지원되지 않기에... 그건 조금 조심해야 한다.
자... 자...
오래 기다리셨다.
이제는 드디어, 본격 실전 사용이다.
필자는 기본적으로 react-query를 기반으로 client fetching을 하기에...
이걸 기반으로 설명드리겠다.
const { data: userData } = $api.useQuery('get', '/users');
자 이렇게 간단하다.
보이시는가? 이게 끝이다. 엥? 이러실 수 있다.
쿼리 키는? 쿼리 펑션은?
그런 거 필요 없다...
심지어 get이나, post 등의 각 메서드에서 api handler함수에서 필요한 body나 parmas의 타입들까지 전부 다 추론되어 있고, 그냥 사용할 때, 두 번 째 위치에만 값만 넣어주면 끝이다.
궁금하면 다시 동영상을 살펴보자.
엥? 이게 되네?
그런데 궁금하지 않은가?
쿼리 키는 어떻게 된건지?
쿼리 데브 툴스를 띄워보면 이렇게 뜬다.
이제 우리는 쿼리키며 뭐며 따로 타입이나 파일 만들어서 관리할 필요가 없다. 그냥 호출하는 api end 포인트가 곧 키다!!!
어떤가? 이게 충격의 도가니탕!
이 장점은 정말 생산성 및 유지 보수 관리 측면에서 좋다고 느꼈다.
그렇다면 여기서 하나 더...
궁금해 질 수 있다.
그렇다면? 도대체 invalid 등은 어떻게 하나요?
그것도 댕 쉽다. ㅎㅎ
await queryClient.removeQueries({
queryKey: $api.queryOptions('get', '/users').queryKey,
});
그냥 이게 끝이다.
어떤가. 쿼리 키의 구속에서 벗어난 당신의 모습이. 아름답지 않은가?
그런데... 이걸로 끝일 수는 없다.
당연히. ㅎㅎ
대표적으로 예외 케이스들이 존재한다.
만약, 당신이 구현하는 함수에서 openapi 타입 중에서 커스텀하거나 뭔가 조정이 필요한데...
그게 단순히 저 제네레이팅 된 스키마 d.ts에서만으로 해결할 수 없다면?
그래서 준비된 게 바로 이 헬퍼 라이브러리다.
물론, 이건 openapi-fetch 기반 타입 헬퍼이라서.. 다른 fetch client를 쓴다면 다소 제약은 있다.
import type { PathsWithMethod } from 'openapi-typescript-helpers';
interface IDownloadExcelFileProps {
requestUrl: PathsWithMethod<excelPaths, 'get'>;
}
await apiClient.GET(requestUrl);
이런 식으로 뭔가 함수나 훅의 인자로, url의 완벽한 지원을 받고 싶을 때, 쓰면 좋다.
물론 사용은 각자의 몫이다.
openapi-fetch의 경우, middleware도 존재한다.
즉, interceptor를 좀 더 유연하게 쓰는 개념이라고 생각하면 좋다.
보통 인증이나, 인가 아니면, 재 로그인 등의 처리가 필요할 때, 혹은 헤더를 별도로 설정해 주는 게 필요할 때, apiClient가 처리되기 전에 적용해 주는 용도로 쓰면 된다.
아래처럼 쓴다.
export const AuthProvider = ({ children, token }: IAuthProviderProps) => {
const { replace } = useRouter();
const clientAuthMiddleware: Middleware = {
onRequest: ({ request }) => {
request.headers.set('authorization', `Bearer ${token}`);
},
onResponse: ({ response }) => {
if (response.status === 401) {
alert('재로그인 해 주세요.');
replace('/login');
}
},
};
excelFetchClient.use(clientAuthMiddleware);
fetchClient.use(clientAuthMiddleware);
return <>{children}</>;
};
이번에는 모든 기술에 정답이 없듯...
이 openapi-ts 기술에도 아직 어려운 점인데..
이상하게 계속, 파일 업로드 쪽에서 post하는 body에 타입 제네레이팅이 string으로 추론되는 게 아닌가... ㅜㅜ
그래서 보니까... 이렇게 multipart/form-data 인데도 불구하고...
openapi json은 그냥 string으로 바로 추론이 되는 걸로 예상했다...
하지만, 여기서 포기하기엔 이르기에...
관련 이슈가 없나 찾아봤다.
당연히 이 고민을 한 개발자는 있었다.
관련 이슈는 직접 참조해 보시길 바란다.
메인테이너의 답변은 아래와 같았다.
요약 하자면, 현재 format을 multipart라고 단순히 File로 제약할 수는 없다. 그렇게 되면, 다른 타입 multipart type도 다 수용해 줘야 해서...
외부 의존성이 높아진다는 내용이었다.
그래서 이걸 너 자신의 입맛에 맛게 바꾸려면 우리가 제안한 nodejs api를 써서 타입을 바꾸면 된다고 했다.
그래서 살펴보니, 공식 문서에도 이제는 대놓고 알려주고 있었다.
역시... 방법은 다 있다. 다만, 조금 어려워지긴 했다.
이걸 하려면 약간은 다소 어려운 typescript 추상 노드를 분석하는 nodejs 함수를 이용해야 했다.
하지만 공식문서 잘 따라하면 못 할 것도 없었다.
공식문서에 따르면,
OpenAPI 스키마로부터 TypeScript AST 생성하면 된다.
즉, openapiTS라는 함수를 활용해서 formatting을 변환하는 옵션(여기서는 multipart/form-data라는 foramt 기반으로 추론했다)을 추가해 주면 그에 따라 정적 분석한 타입스크립트 노드가 만들어진다.
이후, 그 노드를 바탕으로, astToString으로 string화 시킨다.
이걸 바탕으로 파일을 산출하면 file 타입이 File 형식으로 추론되게 schema 제네레이팅이 된다.
아래 코드로 더 자세히 살펴보길 바란다.
import fs from 'node:fs';
import path from 'node:path';
import openapiTS, { astToString, OpenAPITSOptions } from 'openapi-typescript';
import { factory } from 'typescript';
// CLI 인자로 입력 경로와 출력 경로 받기
const [inputPath, outputPath] = process.argv.slice(2);
if (!inputPath || !outputPath) {
console.error('Usage: pnpm generate:types <inputPath> <outputPath>');
process.exit(1);
}
const options: OpenAPITSOptions = {
transform({ format, nullable }, { path }) {
if (format !== 'binary' || !path) {
return;
}
const typeName = path.includes('multipart~1form-data')
? 'File'
: path.includes('application~1octet-stream')
? 'Blob'
: null;
if (!typeName) {
return;
}
const node = factory.createTypeReferenceNode(typeName);
return nullable
? factory.createUnionTypeNode([node, factory.createTypeReferenceNode('null')])
: node;
},
};
async function generateApiTypes() {
try {
// OpenAPI 스키마로부터 TypeScript AST 생성
const ast = await openapiTS(new URL(inputPath), { ...options });
const typeDefinitions = astToString(ast);
// TypeScript 정의 파일로 저장
fs.writeFileSync(path.resolve(outputPath), typeDefinitions, 'utf8');
console.log(`Type definitions generated at: ${outputPath}`);
} catch (error) {
console.error('Failed to generate types:', error);
process.exit(1);
}
}
generateApiTypes();
결론적으로 스크립트도 재수정했다.
다음과 같이.
구조적으로 보면,
tsx / script주소 인풋 스키마 / json 백엔드 주소 /output 할 내 디렉토리 주소
이런 식으로 하면 기존 디렉토리에 재생성된 .ts 파일이 생긴다.
하지만, 여기서 tsx가 뭔지 궁금하지 않으신가?
tsx는 바로 .tsx 이런게 아니라...
nodejs 기반에서 .ts를 노드 환경에서 돌려주기 위한 하나의 툴이다.
이걸 안 하고 .ts를 걍 노드 환경에서 실행시키면 바로 에러가 터지니까 조심하자.
하지만, 한 가지 문제가 더 생겨 버렸다.
위의 업로드하는 post 메서드는 문제가 없었지만, 양식이나 엑셀 파일 자체를 다운 받는 get 메서드는 백엔드 단에서 openapi json 파일 추출할 때 계속 깨져서...
이런 식으로, 요청 end point를 제대로 설정하기 어렵다고 했다.
상황을 설명하자면, 이렇다.
지금은 스키마가 application/json 구조 형태로만 repsonse나 request를 보내는 타입으로 한정이 되어 있어요. 그래서 multitype part의 형식으로 같이 내려주기가 어려워서 제네레이팅이 계속 안 되네요... ㅜㅜ
결국, 이 기술은 openapi 스펙을 정의하는 원천 리소스가 뭔가 고장나면 문제가 생겨버리는 구조라고 다시 느꼈다.
이해는 갔다. 하지만, 과연 안 될까?
그래서 계속 찾아봤지만, java로 된 백엔드 쪽 상황인지라... 명확히 이해가 안 되기도 했고 계속 소통하면서 gpt, 구글링을 동원했지만 결론은.
현재 백엔드 코드 구조의 한계로 당장 적용이 어려웠다.
그럴 땐, 뭐다?
답은 하나다.
프론트에서 알아서 수용해서 고치면 된다.
그래서 필자는 기존 api.ts 파일에서
약간은 수고스럽지만, execeldownload 관련한 api 스키마만 쏙쏙 뽑아서 아래와 같은 파일을 만들었다.
물론 이로 인해서,
import createFetchClient from 'openapi-fetch';
import createClient from 'openapi-react-query';
import { paths } from '@src/__generated__/api';
import { excelPaths } from '@src/__generated__/exceldownloadApi';
export const fetchClient = createFetchClient<paths>({
baseUrl: '서버주소',
credentials: 'include',
});
export const excelFetchClient = createFetchClient<excelPaths>({
baseUrl: '서버 주소',
credentials: 'include',
});
export const $api = createClient(fetchClient);
export const $excelApi = createClient(excelFetchClient);
클라이언트도 별도로 만들어야 했다는 것은 안 비밀... ㅋㅋㅋㅋ
excel 전용 패치 클라이언트도 만들고 그에 따라 타입도 excelPath를 바라보게 해서 타입은 제대로 적용되게 만들어 줬다.
약간은 수고스럽지만, 그래도 이게 되려 장기적으로 볼 땐 더 나은 구조였다.
정말 오랫동안 염원했던 타입 자동화가 이뤄졌다.
물론, 이 기술도 백엔드 개발자 및 팀 내부 간의 협의가 가장 중요하다.
이 기술을 알고는 있어도 제대로 협의나 필요에 따라 사용하지 않는다면 독이든 성배나 다름이 없다.
필자는 이번 자동화를 통해 약간은 우아하게 타입 정의 없이 편하게 코딩할 수 있는 경험을 했다.
물론 이게 정답은 아니다.
그렇지만, 충분히 활용해 볼 수 있는 구조라는 것은 알고 넘어가면 좋겠다.
알고 활용하지 않는 것과 모르고 활용 못 하는 것은 천지 차이니까...
궁금하신 분들은 직접 활용하고 문제도 마주하면서 써보시길 바란다.
그래야 내것이 되니께..
무튼 긴 글 읽어주셔서 감사하고, 또 재밌는 기술로 찾아오겠다.
감사하다.