OpenAPI로 TypeScript API 클라이언트 자동 생성: 타입 안전한 API 통신

프론트엔드

OpenAPITypeScriptAPI 클라이언트코드 생성타입 안전성

이 글은 누구를 위한 것인가

  • 백엔드 API 변경이 프론트엔드에서 즉시 타입 에러로 잡히길 바라는 팀
  • 수동으로 작성한 API 타입 파일 관리에 지쳐 있는 개발자
  • OpenAPI 스펙에서 React Query 훅을 자동 생성하고 싶은 팀

들어가며

백엔드가 API 응답 구조를 바꿨는데 프론트엔드가 모르는 채로 운영에 배포됐다. OpenAPI 스펙에서 TypeScript 타입을 자동 생성하면 이 문제를 컴파일 시점에 잡을 수 있다.

이 글은 bluefoxdev.kr의 OpenAPI TypeScript 코드 생성 가이드 를 참고하여 작성했습니다.


1. 코드 생성 도구 비교

[주요 도구 비교]

openapi-typescript:
  생성: TypeScript 타입만 (런타임 없음)
  크기: 매우 작음
  유연성: 높음 (직접 fetch 래핑)
  적합: 기존 fetch 클라이언트 + 타입만 필요한 경우

@hey-api/openapi-ts:
  생성: 타입 + API 함수 + React Query 훅
  플러그인: Zod, tanstack-query, axios 등
  적합: 완전한 클라이언트 코드 생성

orval:
  생성: 타입 + Axios/fetch 클라이언트
  React Query, SWR 훅 생성
  적합: React Query 훅 자동화

sw2dts (오래된 도구, 비권장):
  생성: TypeScript 인터페이스만
  업데이트: 활발하지 않음

[워크플로우]
  백엔드 openapi.yaml 업데이트
  → CI에서 자동으로 클라이언트 재생성
  → 타입 불일치 시 빌드 실패
  → PR 코멘트로 변경된 타입 표시

2. openapi-typescript 구현

// 설치
// pnpm add -D openapi-typescript
// pnpm add openapi-fetch

// package.json scripts
// "generate:api": "openapi-typescript openapi.yaml -o src/types/api.ts"
// "generate:api:remote": "openapi-typescript https://api.example.com/openapi.yaml -o src/types/api.ts"

// src/lib/api-client.ts
import createClient from 'openapi-fetch';
import type { paths } from '../types/api';  // 자동 생성된 타입

const client = createClient<paths>({
  baseUrl: process.env.NEXT_PUBLIC_API_URL,
  headers: {
    'Content-Type': 'application/json',
  },
});

// 인터셉터 추가
const apiClient = {
  ...client,
  async GET<T extends keyof paths>(
    url: T,
    ...args: Parameters<typeof client.GET<T>>
  ) {
    const token = getAuthToken();
    return client.GET(url, {
      ...args[0],
      headers: {
        ...args[0]?.headers,
        ...(token && { Authorization: `Bearer ${token}` }),
      },
    });
  },
};

function getAuthToken(): string | null {
  return localStorage.getItem('auth_token');
}

export { apiClient };
// 사용 예 - 완전한 타입 추론
async function fetchProducts() {
  const { data, error } = await apiClient.GET('/api/products', {
    params: {
      query: {
        category: 'electronics',  // 타입 안전: API 스펙 기반
        page: 1,
        limit: 20,
      },
    },
  });
  
  if (error) {
    // error 타입도 API 스펙에서 자동 추론
    throw new Error(error.message);
  }
  
  // data 타입: API 스펙의 응답 타입 자동 추론
  return data.products;  // Product[] 타입
}

// POST - 요청 바디 타입 검사
async function createProduct(product: {
  name: string;
  price: number;
  category: string;
}) {
  const { data, error } = await apiClient.POST('/api/products', {
    body: product,  // 스펙과 다른 필드 → 컴파일 에러
  });
  
  return data;
}
// @hey-api/openapi-ts - 더 완전한 코드 생성
// pnpm add -D @hey-api/openapi-ts

// openapi-ts.config.ts
import { defineConfig } from '@hey-api/openapi-ts';

export default defineConfig({
  client: '@hey-api/client-fetch',
  input: './openapi.yaml',
  output: {
    path: './src/generated',
    format: 'prettier',
  },
  plugins: [
    '@hey-api/schemas',         // Zod 스키마 생성
    '@hey-api/services',        // API 함수 생성
    {
      name: '@hey-api/transformers',
      dates: true,              // 날짜 문자열 → Date 객체 변환
    },
    {
      name: '@tanstack/react-query',  // React Query 훅 생성
      queryOptions: true,
    },
  ],
});

// 생성 실행
// npx @hey-api/openapi-ts

// 생성된 파일 예시:
// src/generated/
//   index.ts       → re-export
//   schemas.ts     → Zod 스키마
//   services.ts    → API 함수
//   types.ts       → TypeScript 타입
//   @tanstack/
//     react-query.ts  → useQuery/useMutation 훅

// 자동 생성된 React Query 훅 사용
import { useGetApiProducts, usePostApiProducts } from './generated/@tanstack/react-query';

function ProductsPage() {
  const { data, isLoading } = useGetApiProducts({
    query: { category: 'electronics', page: 1, limit: 20 },
  });
  
  const createProduct = usePostApiProducts();
  
  return (
    <div>
      {data?.products.map(p => (
        <div key={p.id}>{p.name}</div>
      ))}
      <button onClick={() => createProduct.mutate({
        body: { name: '신상품', price: 10000, category: 'electronics' }
      })}>
        상품 추가
      </button>
    </div>
  );
}
# .github/workflows/api-sync.yml
name: API 타입 동기화 검사

on:
  pull_request:
    paths:
      - 'backend/openapi.yaml'
      - 'backend/src/**'

jobs:
  check-types:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      - uses: pnpm/action-setup@v4

      - uses: actions/setup-node@v4
        with:
          node-version: '20'
          cache: 'pnpm'

      - run: pnpm install --frozen-lockfile

      - name: API 스펙에서 타입 재생성
        run: |
          cp backend/openapi.yaml frontend/openapi.yaml
          cd frontend && pnpm generate:api

      - name: 타입 변경 감지
        run: |
          git diff --exit-code frontend/src/types/api.ts || {
            echo "⚠️ API 타입이 변경됐습니다. 자동 생성된 타입을 확인하세요."
            git diff frontend/src/types/api.ts
            exit 1
          }

      - name: TypeScript 타입 체크
        run: cd frontend && pnpm type-check

마무리

OpenAPI → TypeScript 코드 생성은 "API 스펙이 진실의 근원(Single Source of Truth)"이라는 원칙을 실현한다. 백엔드가 응답 구조를 바꾸면 CI에서 자동으로 타입을 재생성하고 타입 체크 실패로 PR이 블록된다. @hey-api/openapi-ts는 React Query 훅까지 생성해 data fetching 코드 작성을 완전히 없앨 수 있다. 팀에 OpenAPI 스펙을 작성하는 문화가 없다면 이 도구들이 그 문화를 만드는 계기가 된다.