아키텍처 · API
Orval 도입 — Swagger에서 API 타입 자동 생성
서비스 초반이라 하루에도 몇 번씩 기획이 바뀌고 API가 바뀌었다. 그때마다 프론트의 타입·쿼리 훅·뮤테이션을 손으로 따라 고치는 일이 반복됐다. 이 반복을 없애려고 Orval을 도입했는데, 정작 일의 본체는 도구를 까는 게 아니라 돌아가는 16개 도메인을 어떻게 안 깨고 갈아엎느냐였다. 옵션 하나하나가 결정이었고, 끝나고 보니 순서에 대한 후회도 하나 남았다.
잦은 변경을 손으로 따라가는 비용
당시 구조는 도메인마다 index.ts(API 클래스) + queries.ts + mutations.ts를 손으로 짜는 방식이었다.
16개 도메인이 전부 이 패턴이었고, DTO 타입도 shared/api/dto.ts에서 수동으로 관리했다. 백엔드 스펙이 바뀌면
사람이 그걸 찾아 타입과 훅을 따라 고쳐야 했고, 빠뜨리면 컴파일이 아니라 런타임에서야 드러났다.
서비스 초반의 잦은 변경과 만나면서 이 비용이 매일 쌓였다.
NAVER에서 본 그림이 떠올랐다. 같이 일했던 프론트 프리랜서 개발자가 팀 내부 API generator를 라이브러리로 만들어 도입했는데, API 작성 부분을 스크립트로 자동화하고 그걸 FSD 패턴과 엮자 하위 슬라이스에 휴먼 에러가 섞일 확률이 확연히 낮아졌다. 스펙이 진실의 원천이 되고, 그 아래 레이어는 생성물을 소비만 하니 "타입을 따라 고치다 한 군데를 빼먹는" 종류의 사고가 구조적으로 사라졌다. 같은 그림을 우리 프로젝트에도 그리고 싶었다. Swagger를 원천으로, entities 레이어를 통째로 생성물로.
순서에 대한 회고 — FSD를 먼저 했어야 했다
지금 돌아보면 순서가 가장 아쉽다. FSD 마이그레이션을 먼저 빠르게 끝내고, 그 위에 Orval을 얹었어야 했다.
Orval을 나중에 시간 내서 하다 보니 이미 손으로 짜둔 entities 버전이 16개나 쌓여 있었다. 그래서
마이그레이션이 "새 구조에 생성기를 끼우는 일"이 아니라 "기존 구현을 들어내고 그 자리에 생성물을 끼워
맞추는 일"이 됐다. 훅 이름이 전부 바뀌니(useGetBuyersQuery → useFindBuyers) 사용처를 도메인별로
일일이 추적해 고쳐야 했고, 쿼리 키 형식까지 바뀌어(['buyer', id] → URL 기반) invalidateQueries
호출부도 전수 점검해야 했다. 구조(FSD)를 먼저 세운 뒤 생성기(Orval)를 끼웠다면, 애초에 손으로 짤 entities가
없었으니 마이그레이션 범위가 훨씬 작았을 것이다. 도구는 순서가 비용을 결정한다 — 이게 이 작업에서
가장 비싸게 배운 교훈이다.
안전망 — e2e 통과를 완료 기준으로 박다
이미 돌아가는 코드를 들어내는 마이그레이션이라, 제일 무서운 건 "겉보기엔 똑같은데 속만 바뀐" 회귀였다. optimistic update가 한 박자 어긋난다든가, comma 직렬화가 빠져 태그 필터가 조용히 깨진다든가 하는 것들은 타입 체크로는 안 잡힌다.
그래서 순서를 뒤집었다. 마이그레이션을 시작하기 전에, 기존 코드 위에 API e2e 통과 테스트를 (AI를 활용해) 먼저 깔아두고, 마이그레이션이 끝났을 때 그 테스트가 똑같이 통과하는 것을 완료 기준으로 삼았다. "동작은 그대로, 구현만 교체"를 사람의 눈이 아니라 테스트로 못 박은 것이다.
안전망은 두 층으로 깔았다.
| 층 | 검증 대상 | 도구 |
|---|---|---|
| 인프라 | comma 직렬화(tags=a,b,c), pageable flatten, 401 리다이렉트, _silent | Vitest 단위 테스트 |
| 동작 | optimistic update + 실패 시 롤백, 순차 mutation, 캐시 invalidation | Vitest + React Testing Library |
특히 인프라 층은 마이그레이션을 시작하기도 전에 깔았다. comma 포맷 직렬화 같은 건 한 번 깨지면 사용처가 조용히 빈 결과를 받기 때문에, 갈아엎기 전에 "현재 동작"을 테스트로 먼저 고정해두고 시작했다. 새 구현이 이 테스트를 그대로 통과하면, 적어도 직렬화·인증·캐시 동작은 변하지 않았다는 증거가 된다.
테스트의 mock 경계는 한 군데로 좁혔다 — orvalInstance(HTTP를 실제로 쏘는 함수)만 mock한다. 이렇게 하면
HTTP만 차단되고 React Query 훅은 실제로 돈다. optimistic 캐시 갱신, 롤백, invalidation이 진짜 코드
경로로 검증된다. 처음부터 MSW로 네트워크 레벨까지 갈 수도 있었지만, 마이그레이션 단계의 목적은 어디까지나
"동작 불변 증명"이라 가벼운 1단계로 시작하고 MSW는 안정화 이후로 미뤘다(뒤의 MSW 절 참고).
핵심 결정 — 옵션마다 이유
도입 자체보다 각 옵션을 왜 그렇게 골랐는지가 일의 본체였다. 표로 먼저 보고, 무거운 결정 셋은 따로 푼다.
| 결정 | 선택 | 왜 |
|---|---|---|
| 설정 구조 | 단일 entry + tags-split | swagger 태그 자동 탐색 → 16개 도메인 수동 나열 불필요 |
| 태그명 정규화 | input.transformer | buyer-controller → buyer. 백엔드 변경 없이 프론트에서 처리 |
| HTTP 클라이언트 | axios 유지 | 기존 인프라 재사용. fetch 기본값은 Blob·pageable 직렬화에서 막힘 |
| mutator | orvalInstance (3줄) | 기존 axiosInstance 래핑 → comma 포맷·401·_silent 자동 상속 |
useInvalidate | false | optimistic update 패턴과 충돌 — feature hook이 직접 제어 |
clean | false + 커스텀 스크립트 | infinite-queries 등 수동 작성 파일 보존 |
| 비즈니스 mutation | features로 이동 | send/reply/forward 분기는 entities가 아님 |
① tags-split + transformer — 태그가 곧 폴더가 되게
처음엔 도메인을 config에 손으로 나열하는 방식을 떠올렸다. 그런데 그러면 새 API가 추가될 때마다 프론트 config를 또 손으로 고쳐야 한다 — 없애려던 휴먼 에러를 자리만 옮기는 꼴이다.
그래서 mode: 'tags-split'로 갔다. orval이 swagger 태그를 자동 탐색해 태그당 폴더를 만든다. 문제는 백엔드
태그가 buyer-controller처럼 -controller 접미사를 달고 온다는 것. 그대로 두면 entities/buyer-controller/가
생긴다. 백엔드에 태그명을 바꿔달라 요청할 수도 있었지만, 그건 BE 작업을 막는 결합이다. 대신 프론트에서
input.transformer로 스펙을 받자마자 접미사를 떼어냈다.
// scripts/orval-tag-transformer.cjs (요지) — buyer-controller → buyer
module.exports = (spec) => {
const strip = (name) => name.replace(/-controller$/, '')
spec.tags = spec.tags?.map(tag => ({ ...tag, name: strip(tag.name) }))
for (const pathItem of Object.values(spec.paths ?? {})) {
for (const operation of Object.values(pathItem)) {
if (operation?.tags) operation.tags = operation.tags.map(strip)
}
}
return spec
}결과적으로 buyer-controller → buyer → src/entities/buyer/로, 태그가 그대로 FSD entities
레이어와 1:1로 맞물린다. 백엔드는 컨트롤러만 추가하면 되고, 프론트는 폴더 구조를 손대지 않는다.
② axios 유지 — ky를 진지하게 검토하고 안 갔다
마침 챗봇 스트리밍 도입 얘기가 있어서, "이참에 fetch 기반 ky로 갈아탈까"를 같이 검토했다. fetch 기반이면 Next.js 캐싱 이점도 있고 번들도 가볍다는 통념이 있었기 때문이다. 그런데 따져보니 우리 상황에선 이점이 거의 다 무효였다.
| 검토 항목 | 결론 |
|---|---|
| Next.js fetch 캐싱 | 무관 — 캐싱은 TanStack Query가 하고, 모든 요청이 BFF(/api/bff) 경유 |
| 내장 retry | 무관 — TanStack Query가 이미 담당 |
| 챗봇 스트리밍 | ky 불필요 — raw fetch의 ReadableStream으로 충분 |
| 번들 크기 | ky가 gzip ~9KB 작음 (유일한 실이득) |
| comma 직렬화 | 블로커 — 서버가 tags=a,b,c를 기대, ky 기본값은 tags=a&tags=b |
결정타는 마지막 줄이었다. 서버 API 일부가 array 파라미터를 comma 포맷으로 기대하는데, axios는
paramsSerializer에 qs.stringify(..., { arrayFormat: 'comma' })를 한 번 꽂으면 끝이지만, ky 기본값은
repeated key라 모든 API 호출부에서 params를 미리 직렬화해야 한다. 16개 entity 전부 손대는 비용을
gzip 9KB와 바꿀 이유가 없었다. 스트리밍은 ky 없이 raw fetch 단독으로 처리하기로 하고, REST는 axios를
유지했다. "ky가 의미 있어지는 시점"은 따로 적어뒀다 — BFF를 걷어내고 Server Component에서 직접 fetch
캐싱을 쓰거나, 서버가 array 포맷을 repeated key로 바꾸거나, 프로젝트를 새로 시작할 때.
③ mutator는 기존 axiosInstance를 3줄로 래핑
axios를 유지하기로 했으니, orval이 호출할 mutator를 기존 axiosInstance 위에 그대로 얹었다.
httpClient: 'axios'로 두면 orval이 { url, method, data, params } 형태로 mutator를 부르는데,
이걸 axiosInstance에 그대로 넘기기만 하면 된다.
// src/shared/api/orval-instance.ts — 이게 전부다
export const orvalInstance = <T>(config: AxiosRequestConfig): Promise<T> =>
axiosInstance(config).then(res => res.data)3줄짜리지만, 이 한 줄을 통과하면서 axiosInstance가 쌓아둔 모든 정책을 공짜로 상속한다 — params comma
포맷 + pageable flatten, 401 → 세션 만료 리다이렉트, 403 토스트, _silent 플래그, 중복 요청 취소
(AbortController). orval로 entities를 갈아엎어도 인증·에러·직렬화 동작이 한 톨도 안 바뀐 핵심 이유가
여기다. 토큰 주입은 mutator가 아니라 BFF가 getToken()으로 처리하므로 클라이언트 mutator엔 auth 로직이
아예 없다.
다만 orval의 FindXxxParams가 pageable: { page, size } 중첩 구조라, Spring이 기대하는 flat
?page=1&size=20로 펴주는 처리만 paramsSerializer에 한 줄 추가했다.
serialize: params => {
const { pageable, ...rest } = (params ?? {})
return qs.stringify({ ...rest, ...pageable }, { arrayFormat: 'comma' })
}④ useInvalidate: false — optimistic update와 충돌
orval은 useInvalidate: true로 두면 생성된 뮤테이션 훅의 onSuccess에 invalidateQueries를 자동으로
끼워 넣는다. 편해 보이지만 우리 feature 훅의 패턴과 정면충돌했다.
우리는 필드 수정 같은 곳에서 optimistic update를 쓴다 — onMutate에서 캐시를 즉시 갱신하고,
onError에서 롤백하고, onSettled에서 재검증한다. 여기에 orval이 onSuccess에까지 invalidation을
넣으면 onSuccess + onSettled로 invalidation이 두 번 돌고, 롤백 타이밍과 재검증 타이밍이 꼬인다.
캐시 제어는 feature 훅이 단독으로 쥐는 게 맞다고 보고 useInvalidate: false로 껐다. 생성기가 "친절하게"
넣어주는 코드가 우리 패턴과 안 맞을 수 있다는 걸, 이중 invalidation을 한 번 겪고 나서 룰로 박았다.
⑤ clean: false — 수동 파일을 지키며 재생성
orval은 재생성마다 출력 폴더를 비우려 한다. 그런데 entities 안엔 자동생성으로 못 만드는 수동 파일이 있다 —
무한스크롤의 getNextPageParam 커스텀이 든 infinite-queries.ts, 미디어 업로드 래퍼 같은 것들. orval의
clean: true에 맡기면 이게 매번 날아간다. 그래서 clean: false로 두고, 직접 짠 정리 스크립트가
PRESERVE 목록만 빼고 삭제하도록 했다. 자동화하되, 사람이 짠 예외는 명시적으로 지킨다.
생성 파이프라인 — generate:api 한 방
orval 단독 실행만으로는 우리 컨벤션을 다 못 맞춘다(파일명 kebab-case, barrel, eslint-disable 등). 그래서
앞뒤로 스크립트를 붙여 pnpm generate:api 한 줄에 5단계가 순서대로 돌게 묶었다.
clean-generated-entities → 자동생성물 삭제 (PRESERVE 목록은 보존)
orval → swagger에서 entities/{domain}/{domain}.ts + schemas 생성
rename-schemas → dealloAPI.schemas.ts → schemas.ts + import 경로 일괄 수정
create-barrels → index.ts 없는 도메인에 barrel 자동 생성 (기존 건 안 덮어씀)
add-eslint-disable → 생성 파일 상단에 eslint-disable 주입rename-schemas는 orval이 만드는 dealloAPI.schemas.ts라는 이름이 우리 kebab-case 규칙에 안 맞아서
schemas.ts로 바꾸고, 그걸 참조하는 모든 도메인 파일의 import 경로까지 같이 고친다. create-barrels는
새 도메인이 생겼을 때 index.ts를 자동으로 깔아주되 이미 있는 barrel은 덮어쓰지 않아서, enum 타입
래퍼처럼 커스텀이 든 barrel을 안전하게 보존한다.
이렇게 묶고 나니 새 도메인 추가 흐름이 이걸로 끝난다 — 백엔드가 swagger에 컨트롤러를 추가하고,
pnpm generate:api를 돌리면, 폴더·타입·훅·MSW·barrel까지 한 번에 생긴다. 프론트 config는 손대지 않는다.
이게 NAVER에서 본, "스펙이 곧 코드"가 되는 그림이었다.
MSW까지 — 같은 생성물로 BE 없이 개발·테스트
orval은 mock: true로 두면 도메인마다 .msw.ts까지 생성한다. faker로 타입 안전한 랜덤 응답을 만들어주는
핸들러다. 같은 핸들러 코드를 두 군데서 쓴다.
- Browser MSW:
pnpm dev에서 Service Worker가 실제 fetch를 가로챈다 → 백엔드가 없거나 끊겨도 UI 작업 가능 - Test MSW:
pnpm test에서 Node 인터셉터가 가로챈다 → CI에서 실제 네트워크 없이 검증
핵심은 MSW가 핸들러를 스택으로 관리한다는 점이다. 평소엔 자동생성 faker 데이터가 응답하다가, 특정
테스트에서 server.use(...)로 핸들러를 위에 쌓으면 그 경로만 원하는 시나리오(예: AUDIO 메시지, 400 에러)로
교체되고, afterEach의 resetHandlers()가 그걸 걷어내 다음 테스트는 다시 기본으로 돌아간다. 그래서
테스트끼리 오염되지 않는다.
여기서 vi.mock과 MSW는 경쟁이 아니라 역할이 다르다는 걸 경계로 박았다.
| 검증하고 싶은 것 | 패턴 |
|---|---|
hook이 올바른 옵션(enabled, refetchInterval)으로 호출되는가 | vi.mock |
| API 응답 → UI 렌더링 데이터 플로우가 맞는가 | MSW |
| HTTP 400/500을 UI가 제대로 처리하는가 | MSW |
| 단순 반환값 mock | vi.mock (더 빠름) |
한 가지 더 — 비즈니스 로직은 entities 밖으로
마이그레이션을 하다 보니 entities에 있으면 안 되는 게 섞여 있었다. 이메일의 useEmailActionMutation은
mode에 따라 send/reply/forward로 갈리는 분기를 품고 있었는데, 이건 API 함수가 아니라 비즈니스 로직이다.
entities는 swagger가 생성한 순수 API 레이어로 두기로 하고, 이 훅은 features/email/model/로 옮겼다.
내부에서는 orval이 만든 sendEmail/replyEmail/forwardEmail을 직접 호출한다. 생성물(entities)과 사람이
짜는 조립 로직(features)의 경계를, 마이그레이션을 핑계로 다시 그은 셈이다.
DTO 경로 — 85개 파일을 안 건드리는 barrel 트릭
타입도 자동생성으로 옮겨야 했는데, 당시 @/shared/api/dto에서 타입을 import하는 파일이 100개를 훌쩍
넘었다. 전부 새 경로로 고치면 diff가 폭발하고 리뷰가 불가능해진다. 그래서 경로 자체를 barrel로 바꿔,
사용처는 그대로 두고 안쪽 출처만 갈아끼웠다.
// shared/api/dto.ts — 사용처 import는 한 줄도 안 바뀜
export * from './model' // orval 자동생성 타입
export * from './error-codes' // 런타임 값(ERROR_CODE)은 별도 파일로 분리한 가지 함정은 기존 dto.ts에 ERROR_CODE 같은 런타임 값이 섞여 있었다는 것. export * from './model'로
바꾸면 이 값은 안 따라오므로 error-codes.ts로 분리했다. 덤으로 타입과 값이 갈라지면서 tree shaking이
정확해졌다. (이후 정식 출처는 @/shared/api/schemas로 정리됐고, @/entities/schemas는 호환 shim으로 남겨
generate 파이프라인을 안 건드리고 점진 이전할 수 있게 했다.)
임팩트 · 배운 점
- Swagger가 진실의 원천이 돼, 잦은 스펙 변경이 타입과 훅으로 자동 반영된다. 사람이 따라가던 비용이 사라지고, 빠뜨려서 런타임에 터지던 사고가 컴파일 타임으로 당겨졌다.
- 생성기(Orval)와 구조(FSD)가 맞물리니 하위 슬라이스의 휴먼 에러 확률이 확연히 낮아졌다 — NAVER에서 본 바로 그 그림을, 우리 프로젝트에 옮겨 그렸다.
- 도구는 순서가 비용을 정한다. 구조(FSD)를 먼저 세우고 생성기를 얹었어야 마이그레이션이 쌌다. 순서를 거꾸로 간 대가로, 이미 쌓인 16개 도메인을 들어내고 끼워 맞추는 일을 했다.
- 큰 교체를 자신 있게 하려면 "동작 불변"을 사람 눈이 아니라 테스트로 못 박아야 한다. 갈아엎기 전에 깔아둔 e2e가 마이그레이션 후에도 똑같이 통과한다는 것 — 그게 "구현만 바꿨고 동작은 그대로"의 유일한 증거였다.