프론트엔드에서 API를 연동할 때 가장 피곤한 일 중 하나는 요청/응답 타입을 백엔드와 맞추는 것입니다. Swagger를 보고 interface를 손으로 옮기다 보면, 필드가 빠지거나 타입이 어긋나도 런타임에야 알게 됩니다.
그래서 OpenAPI 스펙을 TypeScript 타입으로 바꾸는 자동화를 도입했고, 그 도구로 openapi-typescript를 선택했습니다.
아래 코드의 경로·필드·타입명은 이해를 돕기 위한 가상의 예시이며, 실제 회사 API 스펙이 아닙니다.
OpenAPI를 프론트에 연결하는 방법은 크게 세 가지로 나눌 수 있습니다.
수동 작성 — Swagger를 보고 interface와 API 함수를 직접 작성
타입만 생성 — 스펙 → TypeScript 타입만 만들고, HTTP 호출은 기존 클라이언트를 사용 (예: openapi-typescript)
클라이언트 코드까지 생성 — 타입뿐 아니라 요청 함수, 클래스, 훅까지 한 번에 생성
OpenAPI Generator (openapi-generator) — Java, TypeScript 등 다국어 클라이언트 생성
Orval — OpenAPI 기반으로 타입 + React Query/SWR 훅까지 생성
swagger-typescript-api — 타입 + API 클래스/함수 생성
@hey-api/openapi-ts — 타입 + 클라이언트 SDK 생성
클라이언트 코드까지 생성은 초기 속도가 빠르지만, 이미 Axios 인스턴스·인터셉터·공통 에러 처리·응답 래퍼가 있는 프로젝트에서는 생성 코드와 기존 레이어가 겹치기 쉽습니다. 생성 함수의 시그니처·네이밍·에러 형태를 팀 컨벤션에 맞추려면 오히려 커스터마이징 비용이 커질 수 있습니다.
그래서 우리는 타입만 생성하고, 호출 코드는 직접 유지하는 쪽을 택했고, 그 역할에 openapi-typescript가 잘 맞았습니다. 선택 이유를 정리하면 다음과 같습니다.
1. 타입만 생성하고, 호출 방식은 우리가 통제한다 openapi-typescript는 Request/Response 타입만 만들어 줍니다. 기존 Axios 래퍼, 인증 헤더, 공통 에러 코드 처리를 그대로 두고, 그 위에 타입만 얹을 수 있습니다.
2. OpenAPI path 구조가 TypeScript에 자연스럽게 남는다 생성 결과가 paths, components, operations 형태로 펼쳐집니다. 덕분에 paths['/api/pets']['post']...처럼 엔드포인트·메서드·스키마를 타입으로 인덱싱할 수 있고, "이 API의 Request/Response가 뭐지?"를 코드에서 바로 추적할 수 있습니다.
3. 도입 비용이 낮다 CLI 한 줄로 원격 스펙 URL을 받아 타입 파일을 뽑을 수 있습니다. 환경별로 URL만 바꾸면 되므로, 스크립트로 올리기 좋습니다.
정리하면, "완전한 코드젠"보다 "스펙 기반 타입 + 우리가 만든 API 레이어"가 더 잘 맞았고, 그 중간 지점에 openapi-typescript가 있었습니다.
원격 OpenAPI 스펙
↓ openapi-typescript
openapi/types.ts (paths / components / operations)
↓ normalize (필요 시)
실제 응답에 맞게 타입 보정
↓
API 함수에서 paths로 Request/Response 추출
↓
앱에서 import하여 사용openapi-typescript를 돌리면 OpenAPI의 path/method/schema가 TypeScript 타입으로 펼쳐집니다. 개념적으로는 아래와 비슷합니다.
export type paths = {
'/api/pets': {
post: {
requestBody: {
content: {
'application/json': {
name: string;
species: 'DOG' | 'CAT';
};
};
};
responses: {
200: {
content: {
'application/json': {
id: string;
name: string;
species: 'DOG' | 'CAT';
};
};
};
};
};
};
'/api/pets/{petId}': {
get: {
parameters: {
path: { petId: string };
query?: { includeOwner?: boolean };
};
responses: {
200: {
content: {
'application/json': {
id: string;
name: string;
species: 'DOG' | 'CAT';
ownerName?: string;
};
};
};
};
};
};
};여기서 중요한 건, 우리가 직접 interface를 쓰는 게 아니라 스펙에서 나온 구조에 인덱싱해서 타입을 꺼낸다는 점입니다.
인덱싱 순서는 보통 이렇게 읽습니다.
구간 | 의미 |
|---|---|
| 어떤 엔드포인트 |
| 어떤 HTTP 메서드 |
| 요청 바디 JSON 스키마 |
| 200 응답 JSON 스키마 |
| path / query 파라미터 |
실제로는 이렇게 씁니다.
import type { paths } from '../openapi/types';
// POST 요청 바디
type CreatePetRequest =
paths['/api/pets']['post']['requestBody']['content']['application/json'];
// → { name: string; species: 'DOG' | 'CAT' }
// POST 성공 응답
type CreatePetResponse =
paths['/api/pets']['post']['responses']['200']['content']['application/json'];
// → { id: string; name: string; species: 'DOG' | 'CAT' }
// GET path / query
type GetPetPathParams =
paths['/api/pets/{petId}']['get']['parameters']['path'];
// → { petId: string }
type GetPetQueryParams =
paths['/api/pets/{petId}']['get']['parameters']['query'];
// → { includeOwner?: boolean } | undefined별도 DTO를 손으로 유지하지 않아도, 엔드포인트·메서드·content-type만 알면 타입이 따라옵니다.
타입을 뽑았으면, API 함수는 얇은 래퍼로 두는 편이 좋습니다. 역할은 "HTTP 호출 + 생성 타입 연결" 정도면 충분합니다.
import { apiClient } from '../client';
import type { paths } from '../openapi/types';
type CreatePetRequest =
paths['/api/pets']['post']['requestBody']['content']['application/json'];
type CreatePetResponse =
paths['/api/pets']['post']['responses']['200']['content']['application/json'];
export type { CreatePetRequest, CreatePetResponse };
export const createPet = async (
payload: CreatePetRequest,
): Promise<CreatePetResponse> => {
const { data } = await apiClient.post<CreatePetResponse>('/api/pets', payload);
return data;
};백엔드가 species에 'BIRD'를 추가하거나 name을 필수로 바꾸면, 타입을 다시 생성한 뒤 프론트 컴파일이 깨집니다. 즉, 스펙 변경이 타입 에러로 전파됩니다.
1. 타입 작성 비용이 거의 사라졌다 예전에는 Swagger를 보고 필드를 하나씩 옮겼습니다. 이제는 스펙만 최신으로 맞추면 Request/Response 타입이 따라와서, API 함수를 추가할 때 "타입부터 고민"하는 시간이 줄었습니다.
2. 스펙 변경이 컴파일 타임에 드러난다 필드 rename, optional → required 전환, enum 값 추가 같은 변경이 런타임 장애가 아니라 타입 에러로 먼저 보입니다. 특히 UI에서 폼 값이나 테이블 컬럼을 연결할 때, 잘못된 키를 쓰는 실수를 빨리 잡을 수 있었습니다.
3. API 추가 흐름이 일정해졌다 새 엔드포인트를 붙일 때 순서가 고정됩니다.
스펙 동기화
paths에서 Request/Response 추출
API 함수 작성
UI 연결
"이 응답 타입이 맞나?"를 매번 추측하지 않아도 되어, 리뷰와 구현 모두 수월해졌습니다.
4. 문서와 코드의 간극이 줄었다 Swagger는 문서, 프론트 타입은 별개로 관리되면 어느 쪽이 진실인지 애매해집니다. 자동 생성 이후에는 스펙이 곧 타입의 기준이 되어, 백엔드와 이야기할 때도 "문서상 이 필드인데 프론트 타입이 다르다" 같은 소모적인 확인이 줄었습니다.
5. 자동완성과 가독성이 좋아졌다 paths['/api/pets']['post']...로 타입을 연결해 두면, IDE에서 payload 필드가 바로 보입니다. 새로 합류한 사람도 "이 API가 뭘 받는지"를 타입만 따라가면 파악하기 쉬웠습니다.
물론 만능은 아닙니다. 생성 파일이 커지면 IDE가 무거워질 수 있습니다. 그래도 수동 동기화와 비교하면, 맞추는 비용보다 검증하는 비용이 훨씬 작아진 느낌이었습니다.
openapi-typescript는 "완벽한 API 클라이언트를 대신 만들어 주는 도구"라기보다, OpenAPI를 프론트 타입 시스템의 입력으로 바꿔 주는 도구에 가깝습니다.
OpenAPI Generator, Orval, swagger-typescript-api처럼 클라이언트까지 뽑는 선택지도 있지만, 이미 HTTP 레이어가 있는 프로젝트에서는 타입만 생성하고 호출은 직접 유지하는 편이 더 잘 맞을 수 있습니다.
타입만 생성해 기존 HTTP 레이어와 충돌하지 않고
paths 인덱싱으로 Request/Response를 안전하게 추출하며
스펙 변경을 컴파일 타임에 잡아 수동 동기화 비용을 줄입니다
한 번 파이프라인을 만들어 두면, API가 늘어날수록 "타입부터 확보 → 함수 작성 → UI 연결" 순서가 고정되어 협업도 한결 수월해집니다.