3줄 요약
- 자동 생성 덕분에 코드 일관성과 타입 안전성이 올라갔다
- 백엔드 스펙 변경이 곧바로 FE 코드에 반영되어 싱크 맞추기 쉬워졌다
- API 연동할 때 '수동 작성'보다 '스펙 기반 자동화'는 시도해볼만 하다
문제가 있습니다. 프론트엔드 개발자는 API가 변경될 때마다 타입 정의, API 함수, React Query 훅을 수동으로 업데이트해야 합니다!
예를 들어, 저는 매번 아래와 같은 코드를 일일이 작성했었습니다.
/** 내 프로필 조회 response type */
export type MyProfileResponse = {
name: string;
nickname: string;
email: string;
phoneNumber: string;
studentId: string;
gender: string;
};
/** 내 프로필 조회 axios api */
export const getMyProfile = async () => {
const { data } = await api.get<MyProfileResponse>(API_URL.USER.MY_PROFILE);
return data;
};
/** 내 프로필 조회 query */
export const useGetMyProfileQuery = (
options?: OmittedQueryOptions<MyProfileResponse>,
) => {
return useQuery({
queryKey: [API_URL.USER.MY_PROFILE],
queryFn: () => getMyProfile(),
...options,
});
};
/** 내 프로필 조회 suspense query */
export const useGetMyProfileSuspenseQuery = (
options?: OmittedSuspenseQueryOptions<MyProfileResponse>,
) => {
return useSuspenseQuery({
queryKey: [API_URL.USER.MY_PROFILE],
queryFn: () => getMyProfile(),
...options,
});
};
이런 반복적인 작업을 자동화할 수 있다면 참 편할 것 같은데요!
Swagger URL만 있으면 자동으로 위와 같은 인터페이스를 생성해주는 라이브러리가 있다고 합니다.
이번 포스팅에서는 API 스펙 기반 코드 생성기인 orval을 적용하고, before-after 코드와 장단점을 논하고자 합니다.
공식문서에 따르면 orval은 OpenAPI 스펙을 기반으로 클라이언트를 생성해주는 도구라고 해요.
OpenAPI code generator도 여러 개가 있는데, FE 중심에다가 React Query 훅, MSW(여기선 사용 안 함)까지 자동으로 만들어주는 도구라서 이걸 선택하게 됐습니다.
정리하자면 orval의 주요 기능은 아래와 같아요.
이게 어떻게 가능하냐? 하면,
저희는 이미 아래와 같은 OpenAPI 스펙 (Swagger)을 가지고 있어요.
orval을 이용해서 단순히 이것을 파싱하고 -> 코드를 생성해주면 됩니다.
앞서 소개한 코드처럼 API 엔드포인트마다 다음을 모두 작성해야 했습니다.
pnpm add -D orval
import { defineConfig } from 'orval';
export default defineConfig({
woohakdong: {
input: {
target: 'https://api.woohakdong.com/v3/api-docs', // OpenAPI JSON URL
},
output: {
mode: 'tags-split', // API 태그별로 파일 분리
target: 'packages/api/src/generated', // 생성될 위치
client: 'react-query', // 🔥 react-query 훅 자동 생성
override: {
mutator: {
path: 'packages/api/src/axios.ts', // 커스텀 axios 인스턴스 사용
name: 'customInstance', // 커스텀 axios 인스턴스 이름
},
query: {
useQuery: true, // useQuery 훅 생성
useSuspenseQuery: true, // useSuspenseQuery 훅 생성
useMutation: true, // useMutation 훅 생성
useInfinite: false, // useInfiniteQuery는 사용 안 함
signal: true, // AbortSignal 지원
},
},
fileExtension: '.ts',
tsconfig: 'packages/api/tsconfig.json',
prettier: true,
},
},
});
{
"scripts": {
"generate:api": "orval --config orval.config.ts"
}
}
모노레포 환경이라면 공통적으로 생성된 코드를 여러 곳에서 갖다쓸 수 있도록 합니다.
{
"name": "@workspace/api",
"exports": {
"./generated": "./src/generated/index.ts"
},
"peerDependencies": {
"@tanstack/react-query": "^5.0.0"
}
}
// 😫 직접 작성
export const useGetJoinedClubsQuery = (options?) => {
return useQuery({
queryKey: [API_URL.CLUB.GET_JOINED_CLUBS],
queryFn: () => getJoinedClubs(),
...options,
});
};
// ✨ orval이 자동 생성 (packages/api/src/generated/club/club.ts)
// 1️⃣ API 함수
export const getJoinedClubs = (signal?: AbortSignal) => {
return customInstance<ListWrapperClubInfoResponse>({
url: `/api/clubs`,
method: 'GET',
signal,
});
};
// 2️⃣ QueryKey 생성 함수
export const getGetJoinedClubsQueryKey = () => {
return [`/api/clubs`] as const;
};
// 3️⃣ QueryOptions 생성 함수
export const getGetJoinedClubsQueryOptions = <TData, TError>(options?) => {
const queryKey = options?.queryKey ?? getGetJoinedClubsQueryKey();
const queryFn = ({ signal }) => getJoinedClubs(signal);
return { queryKey, queryFn, ...options };
};
// 4️⃣ useQuery 훅
export function useGetJoinedClubs<TData, TError>(options?) {
const queryOptions = getGetJoinedClubsQueryOptions(options);
const query = useQuery(queryOptions);
query.queryKey = queryOptions.queryKey;
return query;
}
// 5️⃣ useSuspenseQuery 훅
export function useGetJoinedClubsSuspense<TData, TError>(options?) {
const queryOptions = getGetJoinedClubsSuspenseQueryOptions(options);
const query = useSuspenseQuery(queryOptions);
query.queryKey = queryOptions.queryKey;
return query;
}
// 6️⃣ Mutation 훅
export const useUpdateClubInfo = <TError, TContext>(options?) => {
const mutationOptions = getUpdateClubInfoMutationOptions(options);
return useMutation(mutationOptions);
};
Before:
📁 data/
📁 club/
📁 getJoinedClubs/
📄 fetch.ts # 수동 작성
📄 query.ts # 수동 작성
📄 type.ts # 수동 작성
📁 postRegisterClub/
📄 post.ts # 수동 작성
📄 mutation.ts # 수동 작성
After:
📁 packages/api/src/generated/
📁 club/
📄 club.ts # ✨ 자동 생성 (모든 훅 포함)
📄 woohakdongServerAPI.schemas.ts # ✨ 자동 생성 (타입)
스펙 변경 시 pnpm generate:api 한 번으로 동기화 가능합니다.
pnpm generate:api
Before:
// ❌ 기존 수동 작성 훅
import { useGetJoinedClubsQuery } from '@/data/club/getJoinedClubs/query';
const { data } = useGetJoinedClubsQuery();
After:
// ✅ orval 자동 생성 훅
import { useGetJoinedClubs } from '@workspace/api/generated';
const { data } = useGetJoinedClubs();
개발 생산성이 크게 향상되었어요.
API 스펙만 있으면 타입부터 훅까지 자동 생성되니 편했습니다.
OpenAPI 스펙과 코드가 항상 동기화되어서, 백엔드와 싱크 맞추기가 편해졌어요.
타입 정의, queryKey 자동 관리 등 모든 API에 대해 동일한 패턴을 적용하다보니 수동 업데이트로 인한 휴먼 에러를 방지하기에 좋았습니다.
결과적으로 코드의 일관성이 올라가고 유지보수하기 편해졌습니다.
자동 생성해주는 건 좋은데, 만들어진 코드를 커스터마이징하고 싶은 경우가 생깁니다. 이때 생성된 코드를 래핑해서 써야 해요.
특히 MSW도 자동 생성해주는 걸 쓰고 싶었는데, 실제 서비스 흐름에 맞추려면 목데이터 커스터마이징이 필요했습니다. 오버라이드하는 게 번거로워서 지금은 마이그레이션 없이 기존 MSW 코드를 유지한 상태입니다.
장단점이라고도 볼 수 있는데, 그만큼 백엔드에서 정확한 OpenAPI 스펙을 제공해야 합니다. 그러나 한 번 스펙에 대한 규칙을 정해놓으면 이후의 생산성이 올라간다는 장점이 커질 것 같습니다.
orval을 적용해보니 반복적인 API 코드 작성과 유지보수 비용이 크게 줄어들고, 스펙 변경에도 generate 한 번으로 대응할 수 있어 큰 장점으로 다가왔습니다.
다만, 생성된 코드의 커스터마이징 제약과 OpenAPI 스펙 품질 의존성은 고려해야 할 부분입니다.
그럼에도 불구하고 React Query 중심의 프론트엔드 프로젝트라면 도입 효과가 확실하고, 특히 일관된 패턴과 타입 안전성을 중요하게 생각하는 팀이라면 투자해볼만한 기술이라 느꼈습니다.
동아리 관리 SaaS, 우학동은 현재 사전등록 프로모션 진행 중!
👉 https://www.woohakdong.com
와 이런게 있었군요 ㄷ 좋은 글 감사합니다!