junyeokk
Blog
React Ecosystem·2025. 11. 15

swagger-typescript-api

백엔드 API가 변경될 때마다 프론트엔드의 타입 정의와 API 호출 코드를 수동으로 수정하는 건 비효율적이다. API 엔드포인트가 10개일 때는 괜찮지만, 50개, 100개로 늘어나면 타입 하나 빠뜨리거나, 응답 구조가 바뀐 걸 놓치는 실수가 반복된다. 결국 런타임에서야 에러를 발견하게 된다.

이 문제를 해결하는 접근이 코드 제너레이션이다. OpenAPI(Swagger) 스펙 문서에서 TypeScript 타입과 API 클라이언트 코드를 자동으로 생성하면, 백엔드 스펙이 변경될 때 다시 생성만 하면 된다. 타입 불일치는 컴파일 타임에 잡히고, API 호출 코드도 일관된 패턴을 유지한다.

swagger-typescript-api는 이 작업을 위한 도구다. OpenAPI 2.0/3.0 스펙(JSON, YAML 모두 지원)을 입력받아 TypeScript로 된 API 클라이언트를 생성한다. Fetch API와 Axios를 모두 지원하고, 생성되는 코드의 구조를 커스터마이징할 수 있다.

왜 수동 타이핑이 문제인가

일반적으로 프론트엔드에서 API를 호출할 때 이런 식으로 작성한다:

typescript
interface User {
  id: number;
  name: string;
  email: string;
}

const getUser = async (id: number): Promise<User> => {
  const response = await fetch(`/api/users/${id}`);
  return response.json();
};

이 코드의 문제는 User 인터페이스가 백엔드의 실제 응답과 동기화되어 있다는 보장이 없다는 것이다. 백엔드에서 emailemailAddress로 바꾸거나, 새 필드를 추가하거나, 필드를 optional로 변경해도 프론트엔드 코드는 컴파일 에러 없이 그대로 통과한다. 실제로 서비스를 띄워서 API를 호출해봐야 문제를 발견한다.

API가 적을 때는 Swagger 문서를 보면서 수동으로 맞출 수 있지만, 엔드포인트가 수십 개가 되면 비현실적이다. 특히 여러 명이 동시에 개발하는 환경에서는 "API 스펙 변경했는데 프론트에 안 알려줬다" 같은 상황이 자주 발생한다.

OpenAPI 스펙이란

swagger-typescript-api를 이해하려면 먼저 OpenAPI 스펙이 무엇인지 알아야 한다.

OpenAPI Specification(OAS)은 REST API의 구조를 기술하는 표준 포맷이다. 엔드포인트 경로, HTTP 메서드, 요청/응답 스키마, 인증 방식 등을 JSON이나 YAML로 정의한다. 예전에는 Swagger Specification이라고 불렸고, OpenAPI 3.0부터 현재 이름으로 바뀌었다.

yaml
# OpenAPI 3.0 예시 (일부)
paths:
  /users/{id}:
    get:
      summary: 사용자 조회
      parameters:
        - name: id
          in: path
          required: true
          schema:
            type: integer
      responses:
        '200':
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/User'

components:
  schemas:
    User:
      type: object
      properties:
        id:
          type: integer
        name:
          type: string
        email:
          type: string
      required:
        - id
        - name
        - email

NestJS에서는 @nestjs/swagger 데코레이터를 사용하면 이 스펙이 자동으로 생성된다. Express + tsoa, FastAPI(Python) 등도 비슷한 기능을 제공한다. 핵심은 백엔드 코드에서 자동으로 생성된 스펙 문서가 Single Source of Truth 역할을 한다는 것이다.

설치와 기본 사용

CLI로 사용

설치 없이 npx로 바로 사용할 수 있다:

bash
npx swagger-typescript-api -p ./swagger.json -o ./src/api -n Api.ts

주요 옵션:

  • -p, --path: OpenAPI 스펙 파일 경로 또는 URL
  • -o, --output: 생성된 파일의 출력 경로
  • -n, --name: 출력 파일 이름 (기본값: Api.ts)

URL을 직접 지정할 수도 있어서, 백엔드 서버가 실행 중이면 Swagger JSON 엔드포인트를 바로 가리킬 수 있다:

bash
npx swagger-typescript-api -p http://localhost:3000/api-json -o ./src/api -n Api.ts

로컬 프로젝트에 설치해서 사용하려면:

bash
npm install --save-dev swagger-typescript-api

프로그래밍 방식으로 사용

Node.js 스크립트에서 직접 호출할 수도 있다. 이 방식은 CI/CD 파이프라인이나 커스텀 빌드 스크립트에 통합할 때 유용하다:

typescript
import * as path from "node:path";
import { generateApi } from "swagger-typescript-api";

await generateApi({
  name: "Api.ts",
  output: path.resolve(process.cwd(), "./src/__generated__"),
  url: "http://localhost:3000/api-json",
  httpClientType: "axios",  // 또는 "fetch"
  singleHttpClient: true,
  extractRequestParams: true,
  extractRequestBody: true,
});

생성되는 코드의 구조

swagger-typescript-api가 생성하는 코드는 크게 세 부분으로 나뉜다:

1. Data Contracts (타입 정의)

OpenAPI 스펙의 components/schemas에 정의된 모든 스키마가 TypeScript 인터페이스로 변환된다:

typescript
// 생성된 코드
export interface User {
  /** @format int64 */
  id: number;
  name: string;
  email: string;
}

export interface CreateUserDto {
  name: string;
  email: string;
  /** @minLength 8 */
  password: string;
}

export interface PaginatedResponse {
  items: User[];
  total: number;
  page: number;
  limit: number;
}

JSDoc 주석으로 포맷, 최소/최대값 같은 메타데이터도 포함된다. 이 타입들이 API 호출의 요청과 응답에 모두 사용된다.

2. HttpClient 클래스

HTTP 통신을 담당하는 기본 클래스가 생성된다. Fetch API 또는 Axios 기반으로 만들어지며, 인터셉터 설정, 베이스 URL, 헤더 관리 등을 담당한다:

typescript
// 생성된 HttpClient 클래스 (간략화)
export class HttpClient {
  private baseUrl: string;
  private securityData: SecurityDataType | null = null;

  constructor({ baseUrl, ...config }: ApiConfig) {
    this.baseUrl = baseUrl ?? "";
  }

  public setSecurityData(data: SecurityDataType | null) {
    this.securityData = data;
  }

  public request<T>({ path, method, body, query, ...params }: RequestParams): Promise<T> {
    // Fetch 또는 Axios를 사용한 실제 요청 로직
  }
}

3. Api 클래스 (라우트 메서드)

각 API 엔드포인트가 타입이 지정된 메서드로 변환된다:

typescript
export class Api extends HttpClient {
  users = {
    /**
     * @description 사용자 목록 조회
     * @name UsersList
     * @request GET:/users
     */
    usersList: (query?: { page?: number; limit?: number }) =>
      this.request<PaginatedResponse>({
        path: `/users`,
        method: "GET",
        query,
        format: "json",
      }),

    /**
     * @description 사용자 생성
     * @name UsersCreate
     * @request POST:/users
     */
    usersCreate: (data: CreateUserDto) =>
      this.request<User>({
        path: `/users`,
        method: "POST",
        body: data,
        type: ContentType.Json,
        format: "json",
      }),

    /**
     * @description 사용자 상세 조회
     * @name UsersDetail
     * @request GET:/users/{id}
     */
    usersDetail: (id: number) =>
      this.request<User>({
        path: `/users/${id}`,
        method: "GET",
        format: "json",
      }),
  };
}

이 코드를 사용하는 쪽에서는 이렇게 호출한다:

typescript
const api = new Api({ baseUrl: "http://localhost:3000" });

// 타입 자동완성이 동작한다
const { data } = await api.users.usersList({ page: 1, limit: 10 });
// data의 타입은 PaginatedResponse

const newUser = await api.users.usersCreate({
  name: "홍길동",
  email: "hong@example.com",
  password: "12345678",
});
// newUser의 타입은 User

주요 CLI 옵션 상세

--axios

기본값은 Fetch API 기반 클라이언트다. Axios를 사용하고 싶으면 이 플래그를 추가한다:

bash
npx swagger-typescript-api -p ./swagger.json -o ./src/api --axios

프로그래밍 방식에서는 httpClientType: "axios"로 지정한다. 기존에 Axios 인터셉터를 쓰고 있다면 Axios 모드가 자연스럽다.

--modular

기본적으로 모든 코드가 하나의 파일(Api.ts)에 생성된다. --modular 옵션을 사용하면 파일이 분리된다:

text
src/api/
├── http-client.ts      # HttpClient 기본 클래스
├── data-contracts.ts   # 모든 타입 정의
├── Users.ts            # /users 관련 API 메서드
├── Products.ts         # /products 관련 API 메서드
└── Auth.ts             # /auth 관련 API 메서드

프로젝트 규모가 크면 --modular를 쓰는 게 좋다. 파일 하나가 수천 줄이 되면 IDE 성능도 떨어지고 코드 리뷰도 어려워진다.

--extract-request-params

이 옵션은 path 파라미터와 query 파라미터를 하나의 데이터 계약 객체로 추출한다:

typescript
// 옵션 없이 생성된 코드
usersDetail: (id: number, query?: { fields?: string[] }) => ...

// --extract-request-params 적용 후
export interface UsersDetailParams {
  id: number;
  fields?: string[];
}
usersDetail: (params: UsersDetailParams) => ...

파라미터가 많은 엔드포인트에서 가독성이 좋아진다.

--extract-request-body

요청 body 타입도 별도의 데이터 계약으로 추출한다. 인라인으로 정의된 body 스키마가 있을 때 유용하다.

--union-enums

TypeScript enum 대신 유니온 타입으로 생성한다:

typescript
// 기본 (TypeScript enum)
export enum UserRole {
  Admin = "admin",
  User = "user",
  Guest = "guest",
}

// --union-enums 적용
export type UserRole = "admin" | "user" | "guest";

TypeScript 커뮤니티에서는 유니온 타입이 enum보다 선호되는 추세다. enum은 런타임 코드를 생성하고, 트리쉐이킹이 잘 안 되는 단점이 있기 때문이다.

--single-http-client

Api 인스턴스를 생성할 때 하나의 HttpClient 인스턴스를 공유한다. 이렇게 하면 베이스 URL이나 인증 토큰 같은 설정을 한 곳에서 관리할 수 있다:

typescript
const httpClient = new HttpClient({
  baseUrl: "http://localhost:3000",
  securityWorker: (token) => ({
    headers: { Authorization: `Bearer ${token}` },
  }),
});

const api = new Api(httpClient);
api.setSecurityData(accessToken);

--no-client

API 클래스 없이 타입 정의만 생성한다. 이미 자체 HTTP 클라이언트가 있고, 타입만 필요한 경우에 유용하다.

--route-types

각 라우트의 요청/응답 타입을 별도로 내보낸다:

typescript
export namespace Users {
  export type UsersListRequestQuery = { page?: number; limit?: number };
  export type UsersListResponseBody = PaginatedResponse;
  export type UsersCreateRequestBody = CreateUserDto;
  export type UsersCreateResponseBody = User;
}

React Query 같은 라이브러리와 조합할 때 이 타입들을 직접 참조할 수 있어서 편리하다.

템플릿 커스터마이징

swagger-typescript-api의 강력한 기능 중 하나는 코드 생성 템플릿을 커스터마이징할 수 있다는 점이다. 생성되는 코드의 구조가 마음에 안 들면 Eta 템플릿 엔진으로 직접 변경할 수 있다.

템플릿 파일 구조:

템플릿역할
api.etaApi 클래스 모듈 전체
data-contracts.eta타입 정의 파일
http-client.etaHttpClient 클래스
procedure-call.eta개별 API 메서드
route-docs.etaJSDoc 주석
route-name.eta라우트 이름 규칙

사용 방법:

  1. 기본 템플릿을 프로젝트로 복사한다
  2. 필요한 부분을 수정한다
  3. --templates 옵션으로 경로를 지정한다
bash
npx swagger-typescript-api \
  -p ./swagger.json \
  -o ./src/api \
  --templates ./api-templates

예를 들어, 모든 API 메서드에 자동으로 에러 핸들링 래퍼를 추가하거나, React Query의 queryKeyqueryFn을 자동 생성하는 커스텀 템플릿을 만들 수 있다.

기본 템플릿의 일부를 재사용하려면 @base, @default, @modular 접두사로 참조한다:

text
<%~ includeFile("@base/data-contracts.eta", it) %>

hooks: 생성 과정 중간에 개입하기

프로그래밍 방식으로 사용할 때 hooks를 통해 코드 생성의 각 단계에 개입할 수 있다:

typescript
await generateApi({
  // ... 기본 설정
  hooks: {
    // 스키마가 파싱될 때
    onParseSchema: (originalSchema, parsedSchema) => {
      // 특정 스키마 이름 변경, 필드 추가/제거 등
      return parsedSchema;
    },
    
    // 라우트가 생성될 때
    onCreateRoute: (routeData) => {
      // 특정 엔드포인트 필터링, 이름 변경 등
      return routeData;
    },
    
    // 라우트 이름이 포맷될 때
    onFormatRouteName: (routeInfo, templateRouteName) => {
      // 기본 이름이 마음에 안 들면 변경
      return templateRouteName;
    },
    
    // 타입 이름이 포맷될 때
    onFormatTypeName: (typeName, rawTypeName) => {
      // 접두사/접미사 추가 등
      return typeName;
    },
  },
});

hooks는 템플릿보다 가벼운 커스터마이징 방법이다. 이름 규칙만 바꾸거나, 특정 엔드포인트를 필터링하는 정도는 hooks로 충분하다.

실전 프로젝트 통합 패턴

package.json 스크립트 등록

가장 일반적인 패턴은 npm 스크립트로 등록해두는 것이다:

json
{
  "scripts": {
    "generate:api": "swagger-typescript-api -p http://localhost:3000/api-json -o ./src/api/__generated__ -n Api.ts --axios --union-enums --extract-request-params --single-http-client",
    "generate:api:local": "swagger-typescript-api -p ./swagger.json -o ./src/api/__generated__ -n Api.ts --axios --union-enums"
  }
}

백엔드 서버에서 직접 스펙을 가져오는 스크립트와, 로컬 파일에서 생성하는 스크립트를 분리해두면 편리하다.

생성된 코드 래핑

생성된 Api 클래스를 직접 사용하는 것도 되지만, 프로젝트 전용 래퍼를 만드는 게 좋다:

typescript
// src/api/client.ts
import { Api } from "./__generated__/Api";

const api = new Api({
  baseUrl: import.meta.env.VITE_API_URL,
  securityWorker: (token: string | null) =>
    token ? { headers: { Authorization: `Bearer ${token}` } } : {},
});

export const setAuthToken = (token: string | null) => {
  api.setSecurityData(token);
};

export default api;

이렇게 하면 생성된 코드를 다시 생성해도 래퍼 코드는 영향받지 않는다.

React Query와 조합

swagger-typescript-api로 생성된 타입과 React Query를 조합하면 타입 안전한 데이터 페칭 레이어를 만들 수 있다:

typescript
import api from "@/api/client";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";

// 쿼리 키 팩토리
export const userKeys = {
  all: ["users"] as const,
  lists: () => [...userKeys.all, "list"] as const,
  list: (params: { page?: number }) => [...userKeys.lists(), params] as const,
  details: () => [...userKeys.all, "detail"] as const,
  detail: (id: number) => [...userKeys.details(), id] as const,
};

// 커스텀 훅
export const useUsers = (params: { page?: number; limit?: number }) =>
  useQuery({
    queryKey: userKeys.list(params),
    queryFn: () => api.users.usersList(params).then((r) => r.data),
  });

export const useUser = (id: number) =>
  useQuery({
    queryKey: userKeys.detail(id),
    queryFn: () => api.users.usersDetail(id).then((r) => r.data),
  });

export const useCreateUser = () => {
  const queryClient = useQueryClient();
  return useMutation({
    mutationFn: api.users.usersCreate,
    onSuccess: () => {
      queryClient.invalidateQueries({ queryKey: userKeys.lists() });
    },
  });
};

타입이 자동으로 전파되기 때문에 useUsers의 반환값은 PaginatedResponse 타입으로 추론되고, useCreateUsermutate 함수는 CreateUserDto를 인자로 요구한다.

.gitignore 설정

생성된 코드를 git에 포함시킬지는 팀마다 다르다:

text
# 방법 1: 생성 코드 무시 (CI에서 빌드 시 생성)
src/api/__generated__/

# 방법 2: 생성 코드 커밋 (변경 추적 가능)
# .gitignore에 추가하지 않음

생성 코드를 커밋하면 PR에서 API 변경 사항을 diff로 볼 수 있다는 장점이 있다. 무시하면 레포가 깔끔해지지만 CI에서 생성 단계가 필요하다.

--module-name-index--module-name-first-tag

API 엔드포인트를 어떤 기준으로 모듈로 분리할지 결정하는 옵션이다.

text
GET  /api/fruits/getFruits
POST /api/fruits/addFruits  
GET  /api/vegetables/getVegetable

--module-name-index 0이면 경로의 첫 번째 세그먼트(api)를 기준으로 분리하므로 하나의 모듈이 된다.

--module-name-index 1이면 두 번째 세그먼트(fruits, vegetables)를 기준으로 분리하므로 두 개의 모듈이 된다.

모든 엔드포인트에 /api 같은 공통 접두사가 있으면 --module-name-index 1로 설정하는 게 자연스럽다.

--module-name-first-tag는 경로 대신 Swagger 태그를 기준으로 분리한다. NestJS의 @ApiTags('users')처럼 태그를 지정해뒀다면 이 옵션이 더 직관적이다. Swagger UI에서 보이는 그룹핑과 동일하게 코드가 분리된다.

대안 도구와 비교

OpenAPI → TypeScript 코드 생성 도구는 여러 가지가 있다. 각각의 특성이 다르다:

openapi-typescript

bash
npx openapi-typescript ./swagger.json -o ./src/api/schema.d.ts

타입만 생성한다. API 클라이언트 코드는 생성하지 않는다. 직접 fetch를 작성하되 타입 안전성만 확보하고 싶을 때 적합하다. openapi-fetch와 함께 사용하면 타입 안전한 fetch 래퍼를 얻을 수 있다.

  • 장점: 생성 코드가 매우 가볍고, 런타임 의존성 없음
  • 단점: API 호출 코드는 직접 작성해야 함

orval

bash
npx orval --input ./swagger.json --output ./src/api

React Query, SWR, Angular 등의 프레임워크별 코드를 생성할 수 있다. swagger-typescript-api가 범용 클라이언트를 생성하는 반면, orval은 특정 데이터 페칭 라이브러리에 맞춘 코드를 생성한다.

  • 장점: React Query 훅을 자동 생성, MSW mock도 생성 가능
  • 단점: 설정이 더 복잡함

swagger-typescript-api의 위치

swagger-typescript-api는 범용 API 클라이언트 생성에 초점이 맞춰져 있다. 특정 프레임워크에 종속되지 않는 깔끔한 클래스 기반 클라이언트를 원한다면 좋은 선택이다. 템플릿 커스터마이징의 자유도가 높아서, 프로젝트 스타일에 맞게 생성 코드를 조정할 수 있다.

도구생성물커스터마이징프레임워크
swagger-typescript-api타입 + API 클래스템플릿 + hooks프레임워크 무관
openapi-typescript타입만제한적프레임워크 무관
orval타입 + 프레임워크별 훅설정 기반React Query, SWR 등

주의사항

생성 코드를 직접 수정하지 않기

생성된 Api.tsdata-contracts.ts를 직접 수정하면, 다음번 생성 시 변경사항이 날아간다. 커스텀 로직은 항상 래퍼나 별도 파일에 작성한다.

스펙 품질이 생성 코드 품질을 결정한다

백엔드에서 OpenAPI 스펙을 대충 작성하면 생성되는 타입도 부정확하다. any가 많이 나오거나, 응답 타입이 void로 잡히는 경우 대부분 스펙 정의가 부실한 것이다. NestJS에서는 @ApiProperty(), @ApiResponse() 데코레이터를 꼼꼼하게 붙여야 제대로 된 스펙이 나온다.

버전 관리 전략

백엔드 API 스펙이 자주 변경되는 개발 초기에는 매번 수동으로 재생성하는 게 현실적이다. 안정화된 후에는 CI/CD에서 자동으로 생성하고, 타입 불일치가 있으면 빌드가 실패하도록 설정하는 게 이상적이다.

yaml
# GitHub Actions 예시
- name: Generate API types
  run: npm run generate:api:local
  
- name: Check for uncommitted changes
  run: |
    git diff --exit-code src/api/__generated__/
    # 생성 코드가 커밋된 것과 다르면 실패

이렇게 하면 "백엔드 스펙은 바뀌었는데 프론트엔드 타입은 안 바꿨다"는 실수를 CI에서 잡을 수 있다.

정리

  • OpenAPI 스펙에서 TypeScript 타입 + API 클라이언트를 자동 생성해서, 백엔드-프론트엔드 타입 불일치를 컴파일 타임에 잡는다
  • --modular, --union-enums, --single-http-client 조합이 실전에서 가장 쓸만하고, 생성된 코드는 직접 수정하지 말고 래퍼로 감싼다
  • React Query와 조합하면 타입 안전한 데이터 페칭 레이어를 만들 수 있고, CI에서 스펙 변경 감지까지 자동화할 수 있다

관련 문서