yoowonyoungabout

백엔드 없이 프론트 먼저 — Mock-First 개발과 OpenAPI 타입 안전 API 레이어 구축기

openapi-fetch + MSW로 백엔드 없이 타입 안전하게

TL;DR

백엔드 API가 완성될 때까지 기다리면 프론트는 멈춘다. 그렇다고 타입 없이 모듈을 짜면, 실제 API 연동 시 타입 불일치로 모든 코드를 다시 쓴다. 이 글은 openapi-typescript로 서버 스키마에서 타입을 자동 생성하고, openapi-fetch로 타입 안전한 API 클라이언트를 만들고, MSW로 네트워크 레벨 모킹을 구성하여 백엔드 대기 없이 개발하면서도 타입 안전성을 보장한 경험을 공유한다.


1. 문제 — “백엔드 아직 안 됐어요”

전형적인 백엔드-퍼스트 개발 흐름

flowchart TD A["기획 완료"] --> B["백엔드 API 개발"] B --> C["프론트 개발 시작"] C --> D["타입 불일치 발견"] D --> E["수정 반복"]

이 모델의 문제점:

  • 프론트는 백엔드 API가 나올 때까지 대기
  • 프론트가 any로 임시 모듈을 만들면, 실제 API 연동 시 필드명 오타, 타입 불일치 발생
  • Mock 데이터와 실제 데이터 구조가 다른 경우 통합 비용 급증

핵심 질문

“백엔드 없이 개발하되, 백엔드가 나오면 코드를 다시 쓰지 않으려면 어떻게 해야 하는가?”


2. 해결 — 3개 도구로 타입 안전 Mock-First 파이프라인 구축

flowchart TD A["OpenAPI Spec
서버 스키마 정의"] --> 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 스키마의 타입 구조(pageable nested 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. 전체 아키텍처 요약

flowchart TB subgraph "단일 진실 공유원" A["OpenAPI Spec
서버 스키마"] 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 핸들러 사용

마무리 — "백엔드를 기다리지 않는다"는 태도의 문제

"백엔드 없이 개발한다"는 말은 쉽지만, 실제로는 백엔드가 나온 후에도 코드를 다시 쓰지 않는 것이 진짜 목표다. 이를 위해 필요한 것은:

  1. 단일 진실 공유원 (SSOT) — 서버 스키마에서 타입을 생성하여 프론트와 백엔드의 계약을 코드로 강제
  2. 네트워크 레벨 모킹 — 프로덕션 코드를 수정하지 않는 mock, 테스트와 개발에서 동일한 코드 사용
  3. 타입 안전 API 클라이언트 — 잘못된 경로, 누락된 필드, 타입 불일치를 컴파일 타임에 감지

이 세 가지가 갖춰지면, 백엔드 API가 변경될 때 pnpm generate:api 한 번 실행하는 것만으로 TypeScript가 변경 지점을 모두 알려준다.