백엔드 없이 프론트 먼저 — Mock-First 개발과 OpenAPI 타입 안전 API 레이어 구축기
openapi-fetch + MSW로 백엔드 없이 타입 안전하게
TL;DR
백엔드 API가 완성될 때까지 기다리면 프론트는 멈춘다. 그렇다고 타입 없이 모듈을 짜면, 실제 API 연동 시 타입 불일치로 모든 코드를 다시 쓴다. 이 글은
openapi-typescript로 서버 스키마에서 타입을 자동 생성하고,openapi-fetch로 타입 안전한 API 클라이언트를 만들고,MSW로 네트워크 레벨 모킹을 구성하여 백엔드 대기 없이 개발하면서도 타입 안전성을 보장한 경험을 공유한다.
1. 문제 — “백엔드 아직 안 됐어요”
전형적인 백엔드-퍼스트 개발 흐름
이 모델의 문제점:
- 프론트는 백엔드 API가 나올 때까지 대기
- 프론트가
any로 임시 모듈을 만들면, 실제 API 연동 시 필드명 오타, 타입 불일치 발생 - Mock 데이터와 실제 데이터 구조가 다른 경우 통합 비용 급증
핵심 질문
“백엔드 없이 개발하되, 백엔드가 나오면 코드를 다시 쓰지 않으려면 어떻게 해야 하는가?”
2. 해결 — 3개 도구로 타입 안전 Mock-First 파이프라인 구축
서버 스키마 정의"] --> B["openapi-typescript
pnpm generate:api"] B --> C["schema.d.ts
타입 자동 생성"] C --> D["openapi-fetch
타입 안전 API 클라이언트"] C --> E["MSW handlers
네트워크 레벨 모킹"] D --> F["프로덕션 코드"] E --> F
| 도구 | 역할 | 핵심 가치 |
|---|---|---|
| openapi-typescript | OpenAPI spec → TypeScript 타입 자동 생성 | 서버와 프론트의 단일 진실 공유원(SSOT) |
| openapi-fetch | 생성된 타입 기반 API 호출 | 경로·파라미터·응답 모두 타입 검증 |
| MSW | Service Worker로 네트워크 요청 가로채기 | 실제 fetch 흐름 유지, 프로덕션 코드 수정 불필요 |
3. 레이어 1 — 타입 자동 생성
서버 스키마에서 타입 추출
# package.json
"generate:api": "openapi-typescript https://xxx.com/api/v1/v3/api-docs -o ./src/shared/api/schema.d.ts"
한 줄의 명령어로 서버의 OpenAPI spec에서 타입을 생성한다. 생성된 schema.d.ts에는 모든 엔드포인트의 경로, 파라미터, 응답 타입이 포함된다:
// 자동 생성된 schema.d.ts (발췌)
export interface paths {
"/tags/{id}": {
/** 태그 상세 조회 */
get: operations["getTag"];
/** 태그 수정 */
put: operations["updateTag"];
/** 태그 삭제 */
delete: operations["deleteTag"];
};
// ... 모든 엔드포인트
}
핵심: 프론트 개발자가 타입을 수동으로 작성하지 않는다. 서버 스키마가 변경되면
pnpm generate:api한 번으로 타입이 갱신되고, 변경된 필드는 TypeScript 컴파일러가 즉시 잡아준다.
4. 레이어 2 — 타입 안전 API 클라이언트
openapi-fetch 클라이언트 설정
// src/shared/api/client.ts
import createClient from 'openapi-fetch'
import type { paths } from './schema'
export const api = createClient<paths>({
baseUrl: getBaseUrl(),
// MSW 호환: 호출 시점의 fetch 사용
fetch: (...args) => fetch(...args),
// Spring Boot 호환: nested object를 플랫 쿼리 파라미터로 직렬화
// pageable: { page: 1, size: 20 } → page=1&size=20
querySerializer(params) { /* ... */ },
})
타입 안전한 API 호출
openapi-fetch는 schema.d.ts의 타입 정보를 기반으로 경로, 파라미터, 응답 모두 타입 검증한다:
// 컴파일 에러 — 존재하지 않는 경로
const { data } = await api.GET('/assets/{id}/wrongPath', {
params: { path: { id: 1 } }
})
// 컴파일 에러 — 누락된 필수 파라미터
const { data } = await api.GET('/assets/{id}', {
params: { path: {} } // id 누락!
})
// 타입 안전 — 경로 + 파라미터 + 응답 모두 검증
const { data, error } = await api.GET('/assets/{id}', {
params: { path: { id: 1 } }
})
// data의 타입이 자동으로 AssetDetailResponse
응답 처리 유틸
서버 응답 envelope({ success, data, pagination, error })를 통일된 함수로 처리한다:
// src/shared/api/response.ts
// 서버 응답 envelope({ success, data, pagination, error })를 통일 처리
export function extractData<T>(response: ApiResponse<T>): T { /* ... */ }
export function extractListData<T>(
response: ApiResponse<T[]>
): { data: T[]; pagination: Pagination } { /* ... */ }
export function getApiErrorMessage(error: unknown): string { /* ... */ }
5. 레이어 3 — MSW로 네트워크 레벨 모킹
왜 Application 레벨이 아닌 Network 레벨인가?
| 방식 | 동작 | 한계 |
|---|---|---|
| Application 모킹 | API 함수를 직접 모킹 | 실제 fetch 흐름 테스트 불가, 프로덕션 코드 수정 필요 |
| Network 모킹 (MSW) | Service Worker가 fetch를 가로채기 | 프로덕션 코드 그대로, 실제 HTTP 요청 흐름 유지 |
MSW 설정
// src/mocks/browser.ts
import { setupWorker } from 'msw/browser'
import { handlers } from './handlers'
export const worker = setupWorker(...handlers)
핸들러 구조 (26개 핸들러 파일)
src/mocks/handlers/
├── index.ts # 모든 핸들러 통합
├── auth.ts # 인증
├── exchanges.ts # 환전
├── assets.ts # 자산
├── admins.ts # 계정
├── exchangeRates.ts # 환율
├── tags.ts # 태그
├── closings.ts # 정산
├── dashboard.ts # 대시보드
├── refunds.ts # 환불
└── ... 외 16개
핸들러 작성 예시
핸들러는 schema.d.ts의 타입을 직접 참조하여 mock 데이터의 구조를 보장한다:
// src/mocks/handlers/exchangeRates.ts
import { http, HttpResponse } from 'msw'
import type { components } from '@/shared/api/schema'
type BaseExchangeRateResponse =
components['schemas']['BaseExchangeRateResponse']
const wrapResponse = <T>(data: T) => ({
success: true,
message: '성공',
data,
timestamp: new Date().toISOString(),
})
const mockRates: BaseExchangeRateResponse[] = [
{ currencyCode: 'USD', buyRate: 1380.5, sellRate: 1409.5, /* ... */ },
// ...
]
export const exchangeRateHandlers = [
http.get('/api/v1/base-exchange-rates', () => {
return HttpResponse.json(wrapResponse(mockRates))
}),
]
핵심 포인트: mock 데이터에
components['schemas']['BaseExchangeRateResponse']타입을 적용하면, 서버 스키마가 변경될 때 mock 데이터도 컴파일 에러로 감지된다.
6. Entity API 레이어 — 전체 흐름
openapi-fetch + TanStack Query를 조합한 실제 Entity 레이어:
// entities/asset/api/assetApi.ts
import { api } from '@/shared/api/client'
import { extractData, extractListData, getApiErrorMessage } from '@/shared/api/response'
export const assetApi = {
getAssets: async (params?: AssetListParams) => {
const { data, error } = await api.GET('/assets', {
params: { query: { ...params, pageable: { page: params?.page, size: params?.size } } },
})
if (error) throw new Error(getApiErrorMessage(error))
return extractListData<AssetListItem>(data)
},
// getAsset, updateAsset 등 동일 패턴
}
// entities/asset/api/useAssetQueries.ts
export const assetKeys = {
all: ['assets'] as const,
lists: () => [...assetKeys.all, 'list'] as const,
list: (params?: AssetListParams) =>
[...assetKeys.lists(), params] as const,
details: () => [...assetKeys.all, 'detail'] as const,
detail: (id: string) =>
[...assetKeys.details(), id] as const,
}
export const useAssets = (params?: AssetListParams) => {
const query = useQuery({
queryKey: assetKeys.list(params),
queryFn: () => assetApi.getAssets(params),
})
return {
data: query.data?.data ?? [],
pagination: query.data?.pagination,
}
}
7. 환경별 동작 전략
| 환경 | 명령어 | API 연결 | 설명 |
|---|---|---|---|
| 개발 (Mock) | pnpm dev:mock |
MSW Service Worker | 백엔드 없이 독립 개발 |
| 개발 (Stage) | pnpm dev:stage |
Vite Proxy → Stage API | CORS 우회, 실제 API 연동 |
| 테스트 | pnpm test |
MSW (Node) | 네트워크 없는 테스트 |
| 프로덕션 | pnpm build |
환경변수 URL 직접 호출 | MSW 제외, 실제 API |
Vite 프록시 설정
// vite.config.ts
server: {
proxy: {
'/api/v1': {
target: env.VITE_API_BASE_URL || 'https://api.example.com',
changeOrigin: true,
},
},
}
설계 포인트: 개발 서버에서는
/api/v1상대 경로를 사용하고 Vite가 프록시해준다. 프로덕션에서는 환경변수의 절대 URL을 사용한다. 코드는 동일하고, 환경만 달라지는 구조다.
8. 해결한 실제 문제들
문제 1: Spring Boot nested object 직렬화
Spring Boot는 pageable.page=1&pageable.size=20 형식을 기대하지만, openapi-fetch 기본값은 pageable[page]=1 형식이다.
Entity API에서 페이지네이션을 사용하는 코드를 보면, OpenAPI 스키마에 따라 pageable을 nested object로 전달한다:
// entities/asset/api/assetApi.ts
const { data, error } = await api.GET('/assets', {
params: {
query: {
searchCategory: params?.searchCategory,
keyword: params?.keyword,
type: params?.type,
pageable: { page: params?.page, size: params?.size },
},
},
})
openapi-fetch는 이 nested object를 기본적으로 pageable[page]=1&pageable[size]=20 형식으로 직렬화한다. 하지만 Spring Boot의 Pageable 파라미터 바인딩은 이 형식을 인식하지 못한다.
섹션 4의 클라이언트 설정에서 querySerializer를 커스텀하여, nested object를 플랫하게 펼치는 방식으로 해결했다. 이 serializer가 적용되면 쿼리 파라미터가 다음과 같이 변환된다:
| 단계 | 형식 | 예시 |
|---|---|---|
| 프론트 코드 | nested object | pageable: { page: 1, size: 20 } |
| openapi-fetch 기본 | bracket notation | pageable[page]=1&pageable[size]=20 [X] |
| 커스텀 serializer | flat params | page=1&size=20 [O] |
포인트: API 호출 코드는 OpenAPI 스키마의 타입 구조(
pageablenested object)를 그대로 따르면서, 직렬화 단계에서만 Spring Boot가 이해하는 형식으로 변환한다. 타입 안전성을 유지하면서 백엔드 호환성을 확보하는 구조다.
문제 2: MSW와 openapi-fetch의 fetch 참조 문제
createClient가 모듈 로드 시점에 fetch 참조를 캐시하면, MSW가 나중에 패치한 fetch를 사용하지 못한다.
// 문제 — 모듈 로드 시점의 fetch 캐시
export const api = createClient<paths>({ baseUrl })
// 해결 — 호출 시점의 globalThis.fetch 사용
export const api = createClient<paths>({
baseUrl,
fetch: (...args) => fetch(...args),
})
문제 3: 테스트 환경의 baseUrl
Vitest는 JSDOM 환경이라 window.location.origin이 없다. 상대 URL /api/v1만으로는 절대 URL을 구성할 수 없어서, 테스트 환경에서는 http://localhost/api/v1로 절대 URL을 사용한다.
9. 전체 아키텍처 요약
서버 스키마"] end subgraph "자동 생성" A -->|"pnpm generate:api"| B["schema.d.ts"] end subgraph "프론트엔드" B --> C["openapi-fetch client
타입 안전 API 호출"] B --> D["MSW handlers
같은 타입으로 mock"] C --> E["Entity API
assetApi, exchangeApi"] E --> F["TanStack Query hooks
useAssets, useExchanges"] F --> G["UI 컴포넌트"] D --> G end
| 계층 | 파일 | 역할 |
|---|---|---|
| 타입 생성 | schema.d.ts | 서버 스키마 → TS 타입 (SSOT) |
| API 클라이언트 | client.ts | 타입 안전 HTTP 클라이언트 |
| 응답 처리 | response.ts | envelope 추출 + 에러 처리 통일 |
| Entity API | *Api.ts | 도메인별 API 함수 |
| Query 훅 | use*Queries.ts | queryKeys + useQuery 훅 |
| Mutation 훅 | use*Mutations.ts | useMutationWithToast + 캐시 무효화 |
| Mock 레이어 | handlers/*.ts (26개) | 네트워크 레벨 모킹 |
성과
| 지표 | Before | After |
|---|---|---|
| 백엔드 대기 시간 | API 완성까지 프론트 대기 (주 단위) | API 완성 전부터 프론트 독립 개발 가능 (MSW 모킹) |
| API 연동 시 수정 범위 | 타입 불일치로 전체 코드 재작성 | MSW 핸들러 제거만으로 연동 완료 (프로덕션 코드 수정 0건) |
| 스키마 변경 반영 | 수동 타입 수정 + 필드 추적 | pnpm generate:api 1회 실행 → TypeScript가 변경 지점 자동 감지 |
| MSW 핸들러 수 | — | 26개 핸들러 파일 (전체 API 엔드포인트 커버) |
| 타입 안전성 | 런타임에서 필드명 오타/타입 불일치 발견 | 컴파일 타임에 경로·파라미터·응답 모두 검증 |
팀 협업 임팩트
- 백엔드/프론트 병렬 개발: API 스펙만 합의하면 양쪽이 동시 개발 시작. 백엔드 완성 전에 프론트 UI/로직이 이미 완성되어 있어 통합 테스트 시간 단축
- 신규 도메인 개발 속도 향상: MSW 핸들러 + Entity API + Query 훅의 3단계 템플릿이 확립되어, 새 도메인 API 레이어 구축 시 패턴만 따르면 됨
- QA 독립성 확보: MSW 모드로 백엔드 장애와 무관하게 프론트 QA 가능. 테스트 환경에서도 동일한 MSW 핸들러 사용
마무리 — "백엔드를 기다리지 않는다"는 태도의 문제
"백엔드 없이 개발한다"는 말은 쉽지만, 실제로는 백엔드가 나온 후에도 코드를 다시 쓰지 않는 것이 진짜 목표다. 이를 위해 필요한 것은:
- 단일 진실 공유원 (SSOT) — 서버 스키마에서 타입을 생성하여 프론트와 백엔드의 계약을 코드로 강제
- 네트워크 레벨 모킹 — 프로덕션 코드를 수정하지 않는 mock, 테스트와 개발에서 동일한 코드 사용
- 타입 안전 API 클라이언트 — 잘못된 경로, 누락된 필드, 타입 불일치를 컴파일 타임에 감지
이 세 가지가 갖춰지면, 백엔드 API가 변경될 때 pnpm generate:api 한 번 실행하는 것만으로 TypeScript가 변경 지점을 모두 알려준다.