Previewlocked to source

프로젝트에서 백엔드 API가 늘어날수록 타입 정의와 API 함수를 손으로 하나씩 작성하는 게 번거로워서 아예 OpenAPI 스펙을 기반으로 자동 생성하는 파이프라인을 설계하게 됐습니다.

gen-api 스킬 개요

gen-api는 OpenAPI 스펙을 입력으로 받아 TypeScript 타입, API 함수, DTO까지 자동으로 뽑아주는 일종의 코드 생성 파이프라인입니다. 단순히 타입만 만드는 게 아니라 함수명 규칙이나 파일 구조까지 자동화해서 팀 전체가 일관된 코드 스타일을 유지할 수 있게 해줍니다.

전체 흐름을 간단히 표현하면 이렇습니다.

입력 파싱 (tag [env])
  → OpenAPI fetch
  → 타입 생성 (npm run generate:types)
  → 경로 분석
  → 파일 생성 (api.ts / dto.ts / index.ts)
  → 완료 리포트

크게 5단계 프로세스로 구성되어 있고 각 단계가 독립적으로 동작하도록 설계했습니다.


1단계: 입력 파싱 — 환경별 OpenAPI fetch

가장 먼저 해야 할 일은 어느 환경(dev / prod)의 스펙을 가져올지 결정하는 것입니다. 명령어를 실행할 때 tagenv를 인자로 넘기면 그에 맞는 OpenAPI 스펙 URL에서 JSON을 fetch해옵니다.

// 예시: gen-api auth dev
const [tag, env] = process.argv.slice(2);
const specUrl = env === 'prod' ? PROD_SPEC_URL : DEV_SPEC_URL;

const spec = await fetch(specUrl).then((res) => res.json());

dev 서버와 prod 서버의 스펙이 미묘하게 다를 때가 있기 때문에 입력 상태에 따른 환경변수 분기처리가 필요했습니다. 특히 응답 스키마가 dev에서만 먼저 추가되는 경우가 많았습니다.


2단계: 타입 생성 — generate:types 실행

스펙을 받아온 뒤에는 npm run generate:types를 통해 TypeScript 타입을 자동 생성합니다. 내부적으로는 openapi-typescript 같은 도구를 활용해서 스펙의 components/schemas를 TypeScript interface로 변환합니다.

OpenAPI Spec (JSON)
  └─ components.schemas
       └─ TypeScript interface 자동 생성
            └─ dto.ts에 저장

여기서 한 가지 고민이 생겼는데 스웨거상에서 백엔드가 응답 데이터를 지정해주지 않아 응답 스키마가 EventResult<T> 같은 제네릭 래퍼 형태라서 실제 데이터 타입을 특정할 수 없는 경우가 있었습니다. 이럴 때는 일단 ResponseDTO<Record<string, unknown>>으로 임시 처리해두고 나중에 실제 응답 shape이 확정되면 그때 구체 interface로 갈아끼우는 점진적 타입 안전화 전략을 택했습니다.

// 응답 스키마가 불명확할 때 임시 처리
export type SomeResponseDTO = ResponseDTO<Record<string, unknown>>;

// 실제 shape 확정 후 교체
export interface SomeData {
  id: number;
  name: string;
}
export type SomeResponseDTO = ResponseDTO<SomeData>;

처음엔 unknown으로 두는 게 찜찜했는데 생각해보니 타입 없이 any로 방치하는 것보다 훨씬 낫고 나중에 교체 포인트도 명확해져서 오히려 마음에 들었습니다.


3단계: 경로 분석 — feature tag 기반 분류

OpenAPI 스펙의 각 엔드포인트에는 tags 필드가 있습니다. 이 태그를 기준으로 어떤 feature 폴더에 파일을 생성할지 결정합니다.

/api/auth/login  →  tags: ["auth"]  →  src/features/auth/
/api/event/list  →  tags: ["event"] →  src/features/event/

태그 하나가 곧 하나의 도메인 폴더로 매핑되는 구조라서 파일이 어디 있는지 굳이 찾아볼 필요가 없어집니다. 백엔드에서 태그를 잘 정의해줄수록 이 단계가 깔끔하게 동작합니다.


4단계: 코드 생성 — 함수명과 파일 구조 자동화

기존 프로젝트에서 사용하던 컨벤션을 적용해 API 함수명을 사람이 임의로 짓는 게 아니라 아래 규칙에 따라 자동으로 생성됩니다.

함수명 규칙: [method][PrefixPascalCase][SegmentPascalCase]

예시:
  GET  /api/auth/login     → getAuthLogin
  POST /api/event/register → postEventRegister
  DELETE /api/user/profile → deleteUserProfile

HTTP method를 prefix로 경로 세그먼트를 PascalCase로 변환해서 이어붙이는 방식입니다. 규칙이 단순하다 보니 함수명만 봐도 어떤 API인지 바로 유추가 된다는 게 생각보다 감동적이었습니다..

출력 파일 구조도 통일되어 있습니다.

src/features/{tag}/
  ├── api.ts      # API 호출 함수
  ├── dto.ts      # 요청/응답 타입 정의
  └── index.ts    # 외부로 내보내는 barrel export

기존 프로젝트에 이미 만들어진 폴더가 있을 경우 기존 파일 패턴(예: rsMap 구조, Data 접미사 규칙 등)을 분석해서 새로 생성하는 코드도 그 패턴에 맞게 맞춰줍니다. 덕분에 "자동 생성 코드만 스타일이 다르다"는 문제가 생기지 않았습니다.


5단계: 완료 리포트

모든 파일 생성이 끝나면 어떤 파일이 생성/수정됐는지 리포트를 출력합니다.

생성 완료
  - src/features/auth/api.ts
  - src/features/auth/dto.ts
  - src/features/auth/index.ts

생성된 함수 목록
  - getAuthLogin
  - postAuthSignup
  - deleteAuthLogout

이 리포트가 있으면 PR에 첨부하기도 편하고 리뷰어 입장에서도 "자동 생성된 파일"이라는 걸 바로 알 수 있어서 리뷰 속도가 빨라지는 효과가 있었습니다.


마치며

이 파이프라인을 도입하고 나서 가장 크게 달라진 건 API 하나 추가될 때마다 "타입은 누가 만들지?", "함수명은 어떻게 짓지?"를 고민하는 시간이 거의 사라졌다는 겁니다. 물론 응답 스키마가 불명확한 경우엔 임시 타입으로 처리하고 나중에 교체하는 과정이 남아 있지만 그 흐름 자체가 명확하게 정해져 있으니 크게 부담스럽지 않았습니다.

백엔드가 OpenAPI 스펙을 성실하게 관리해줄수록 이 파이프라인의 효과가 극대화됩니다. 그런 의미에서 프론트와 백엔드가 함께 OpenAPI 스펙 품질을 높여가는 문화가 병행되면 더 좋을 것 같다는 생각도 듭니다. 앞으로는 스펙 변경이 감지될 때 자동으로 파이프라인이 돌도록 CI 연동도 붙여보고 싶습니다.

postsOpenAPI 스펙으로 TypeScript API 코드를 자동 생성하는 파이프라인 설계하기.md
010203040506070809101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134
> 프로젝트에서 백엔드 API가 늘어날수록 타입 정의와 API 함수를 손으로 하나씩 작성하는 게 번거로워서 아예 OpenAPI 스펙을 기반으로 자동 생성하는 파이프라인을 설계하게 됐습니다.
# gen-api 스킬 개요
`gen-api`는 OpenAPI 스펙을 입력으로 받아 TypeScript 타입, API 함수, DTO까지 자동으로 뽑아주는 일종의 코드 생성 파이프라인입니다. 단순히 타입만 만드는 게 아니라 함수명 규칙이나 파일 구조까지 자동화해서 팀 전체가 일관된 코드 스타일을 유지할 수 있게 해줍니다.
전체 흐름을 간단히 표현하면 이렇습니다.
```
입력 파싱 (tag [env])
→ OpenAPI fetch
→ 타입 생성 (npm run generate:types)
→ 경로 분석
→ 파일 생성 (api.ts / dto.ts / index.ts)
→ 완료 리포트
```
크게 5단계 프로세스로 구성되어 있고 각 단계가 독립적으로 동작하도록 설계했습니다.
---
# 1단계: 입력 파싱 — 환경별 OpenAPI fetch
가장 먼저 해야 할 일은 어느 환경(dev / prod)의 스펙을 가져올지 결정하는 것입니다. 명령어를 실행할 때 `tag`와 `env`를 인자로 넘기면 그에 맞는 OpenAPI 스펙 URL에서 JSON을 fetch해옵니다.
```ts
// 예시: gen-api auth dev
const [tag, env] = process.argv.slice(2);
const specUrl = env === 'prod' ? PROD_SPEC_URL : DEV_SPEC_URL;
const spec = await fetch(specUrl).then((res) => res.json());
```
dev 서버와 prod 서버의 스펙이 미묘하게 다를 때가 있기 때문에 입력 상태에 따른 환경변수 분기처리가 필요했습니다. 특히 응답 스키마가 dev에서만 먼저 추가되는 경우가 많았습니다.
---
# 2단계: 타입 생성 — `generate:types` 실행
스펙을 받아온 뒤에는 `npm run generate:types`를 통해 TypeScript 타입을 자동 생성합니다. 내부적으로는 [openapi-typescript](https://github.com/drwpow/openapi-typescript) 같은 도구를 활용해서 스펙의 `components/schemas`를 TypeScript interface로 변환합니다.
```
OpenAPI Spec (JSON)
└─ components.schemas
└─ TypeScript interface 자동 생성
└─ dto.ts에 저장
```
여기서 한 가지 고민이 생겼는데 스웨거상에서 백엔드가 응답 데이터를 지정해주지 않아 응답 스키마가 `EventResult<T>` 같은 제네릭 래퍼 형태라서 실제 데이터 타입을 특정할 수 없는 경우가 있었습니다. 이럴 때는 일단 `ResponseDTO<Record<string, unknown>>`으로 임시 처리해두고 나중에 실제 응답 shape이 확정되면 그때 구체 interface로 갈아끼우는 **점진적 타입 안전화 전략**을 택했습니다.
```ts
// 응답 스키마가 불명확할 때 임시 처리
export type SomeResponseDTO = ResponseDTO<Record<string, unknown>>;
// 실제 shape 확정 후 교체
export interface SomeData {
id: number;
name: string;
}
export type SomeResponseDTO = ResponseDTO<SomeData>;
```
처음엔 `unknown`으로 두는 게 찜찜했는데 생각해보니 타입 없이 `any`로 방치하는 것보다 훨씬 낫고 나중에 교체 포인트도 명확해져서 오히려 마음에 들었습니다.
---
# 3단계: 경로 분석 — feature tag 기반 분류
OpenAPI 스펙의 각 엔드포인트에는 `tags` 필드가 있습니다. 이 태그를 기준으로 어떤 feature 폴더에 파일을 생성할지 결정합니다.
```
/api/auth/login → tags: ["auth"] → src/features/auth/
/api/event/list → tags: ["event"] → src/features/event/
```
태그 하나가 곧 하나의 도메인 폴더로 매핑되는 구조라서 파일이 어디 있는지 굳이 찾아볼 필요가 없어집니다. 백엔드에서 태그를 잘 정의해줄수록 이 단계가 깔끔하게 동작합니다.
---
# 4단계: 코드 생성 — 함수명과 파일 구조 자동화
기존 프로젝트에서 사용하던 컨벤션을 적용해 API 함수명을 사람이 임의로 짓는 게 아니라 아래 규칙에 따라 **자동으로 생성**됩니다.
```
함수명 규칙: [method][PrefixPascalCase][SegmentPascalCase]
예시:
GET /api/auth/login → getAuthLogin
POST /api/event/register → postEventRegister
DELETE /api/user/profile → deleteUserProfile
```
HTTP method를 prefix로 경로 세그먼트를 PascalCase로 변환해서 이어붙이는 방식입니다. 규칙이 단순하다 보니 함수명만 봐도 어떤 API인지 바로 유추가 된다는 게 생각보다 감동적이었습니다..
출력 파일 구조도 통일되어 있습니다.
```
src/features/{tag}/
├── api.ts # API 호출 함수
├── dto.ts # 요청/응답 타입 정의
└── index.ts # 외부로 내보내는 barrel export
```
기존 프로젝트에 이미 만들어진 폴더가 있을 경우 기존 파일 패턴(예: `rsMap` 구조, `Data` 접미사 규칙 등)을 분석해서 새로 생성하는 코드도 그 패턴에 맞게 맞춰줍니다. 덕분에 "자동 생성 코드만 스타일이 다르다"는 문제가 생기지 않았습니다.
---
# 5단계: 완료 리포트
모든 파일 생성이 끝나면 어떤 파일이 생성/수정됐는지 리포트를 출력합니다.
```
생성 완료
- src/features/auth/api.ts
- src/features/auth/dto.ts
- src/features/auth/index.ts
생성된 함수 목록
- getAuthLogin
- postAuthSignup
- deleteAuthLogout
```
이 리포트가 있으면 PR에 첨부하기도 편하고 리뷰어 입장에서도 "자동 생성된 파일"이라는 걸 바로 알 수 있어서 리뷰 속도가 빨라지는 효과가 있었습니다.
---
# 마치며
이 파이프라인을 도입하고 나서 가장 크게 달라진 건 API 하나 추가될 때마다 "타입은 누가 만들지?", "함수명은 어떻게 짓지?"를 고민하는 시간이 거의 사라졌다는 겁니다. 물론 응답 스키마가 불명확한 경우엔 임시 타입으로 처리하고 나중에 교체하는 과정이 남아 있지만 그 흐름 자체가 명확하게 정해져 있으니 크게 부담스럽지 않았습니다.
백엔드가 OpenAPI 스펙을 성실하게 관리해줄수록 이 파이프라인의 효과가 극대화됩니다. 그런 의미에서 프론트와 백엔드가 함께 OpenAPI 스펙 품질을 높여가는 문화가 병행되면 더 좋을 것 같다는 생각도 듭니다. 앞으로는 스펙 변경이 감지될 때 자동으로 파이프라인이 돌도록 CI 연동도 붙여보고 싶습니다.
hov_i [main] ⚡