서버가 API를 바꿔도, 우리는 알 방법이 없었어요 (내부 관리자 개편 #1)
서버가 닷새 사이에 API 필드를 좁혔지만 프론트엔드는 알 방법이 없었어요. 손으로 옮겨 적던 API 코드 5,058줄을 OpenAPI 스펙에서 생성하기로 한 이야기예요.
JS
프론트엔드 · · 13분
크로스 프론트엔드 팀은 내부 관리자 도구를 만들고 운영해요. 여러 부서가 매일 쓰는 도구라 새 업무가 생기면 화면이 따라 늘어나요. 그리고 이 도구를 담당하는 프론트엔드는 3명이에요.
3명이라는 숫자가 이 글에 나오는 거의 모든 선택을 설명해요. 새 도구를 충분히 검증할 시간도, 서버가 뭘 바꿨는지 매번 눈으로 확인할 시간도 없었거든요. 이 시리즈는 그런 조건에서 어드민을 계속 확장 가능한 상태로 만들기 위해 팀이 무엇을 바꿨는지 네 편에 걸쳐 정리한 기록이에요. 첫 편은 API 코드를 손으로 쓰지 않기로 한 이야기예요.
5일 만에 드러난 것
2월에 OpenAPI 스펙에서 프론트엔드 코드를 생성하기 시작했어요. 도입하고 닷새 뒤, 스펙을 다시 받아 생성을 한 번 더 돌렸어요. 그때 나온 diff에 이런 변화가 있었어요.
- /** 알림 카테고리 */
- category: number
+ /** 알림 카테고리
+ * `1` - 시스템
+ * `101` - 정산
+ * `102` - 고객 문의
+ */
+ category: AlarmTemplateCategory
서버가 그 닷새 사이에 이 필드를 자유로운 정수에서 정해진 값 집합으로 좁혔어요. 프론트엔드는 아무 연락도 받지 않았고, 아무것도 깨지지 않았어요. 생성을 다시 돌렸기 때문에 알게 된 거예요.
같은 diff에 이런 것도 있었어요.
- return `/api/orders/detail`
+ return `/api/orders/detail/`
경로 끝에 슬래시가 붙었어요. 이건 타입 문제도 아니에요. 손으로 적은 URL 문자열은 어떤 검사도 통과하지 않아요. 운이 좋으면 리다이렉트로 넘어가고, 나쁘면 그냥 404예요.
닷새 동안 서버는 최소한 두 가지를 바꿨어요. 둘 다 프론트엔드 코드에서는 아무 신호도 만들지 않았어요. 저희가 생성 도구를 도입하며 없애고 싶었던 게 정확히 이거였어요. 서버가 바꾸면 사람이 눈으로 찾아야 하는 구조요.
프론트엔드는 서버 계약을 손으로 복제하고 있었어요
당시 API를 하나 연결하려면 세 단계를 거쳤어요.
먼저 요청 함수를 썼어요.
export const getAdminUsers = async () => {
return fetchWeb({
url: '/api/admin/users/',
})
}
응답 타입을 정의했어요.
export interface UserData {
id: number
username: string
role_name: string
permissions: string[]
is_active: boolean
name: string
}
React Query 훅과 Query Key를 만들었어요.
export const ADMIN_USERS_QUERY_KEY = ['adminUsers']
export const useAdminUsers = () =>
useQuery({
queryKey: ADMIN_USERS_QUERY_KEY,
queryFn: () => getAdminUsers(),
staleTime: 5 * 60 * 1000,
select: (response) => response.body,
})
어려운 코드는 아니에요. 오히려 읽기 쉽죠. 문제는 이게 계속 쌓였다는 거예요. 생성 도구를 도입하기 직전, 이 방식으로 관리하던 코드는 이 정도였어요.
| 항목 | 개수 |
|---|---|
| 요청 함수 | 183개 |
| 타입·인터페이스 | 177개 |
| React Query 훅 | 81개 |
| 합계 | 5,058줄 |
5천 줄이라는 양보다 중요한 건 이 코드의 성격이에요. 이건 새로 만든 정보가 아니에요. 서버가 이미 정해둔 것을 프론트엔드 언어로 옮겨 적은 복제본이에요. URL도 서버가 정했고, HTTP method도 서버가 정했고, 요청과 응답 필드도 서버가 정했어요. 프론트엔드는 그걸 읽고 다시 썼을 뿐이에요.
복제본에는 두 가지 비용이 따라와요.
하나는 반복이에요. API가 늘어날 때마다 같은 구조를 다시 써야 했어요. 그리고 손으로 쓰는 순간 판단이 흩어져요. 위 코드의 staleTime: 5 * 60 * 1000이 왜 5분인지, 다른 훅도 5분인지 아무도 몰라요. 작성자가 그때 그렇게 정했을 뿐이에요.
다른 하나가 진짜 문제였어요. 복제본은 원본과 연결이 끊겨 있어요. TypeScript는 프론트엔드가 자기가 적어둔 타입과 일관되게 코드를 쓰고 있는지만 검사해요. 그 타입이 서버와 같은지는 검사하지 않아요. 그래서 category: number라고 적어두면, 서버가 그 필드를 어떻게 바꾸든 컴파일은 계속 초록불이에요.
그리고 복제는 애초에 다 따라가지도 못하고 있었어요. 서버 스펙에는 엔드포인트가 235개 있었는데, 프론트엔드가 훅으로 감싸둔 건 81개였어요. 3분의 1이에요. 나머지는 필요할 때마다 그때그때 붙이고 있었어요.
스펙을 문서가 아니라 입력으로 쓰기로 했어요
저희가 OpenAPI 스펙을 새로 만든 건 아니에요. 서버는 이미 스펙을 제공하고 있었어요. 바꾼 건 프론트엔드가 그 스펙을 대하는 방식이었어요.
전에는 스펙이 사람이 읽는 문서였어요. 읽고, 이해하고, 프론트엔드 코드로 다시 옮겨 적었어요.
flowchart LR
A[OpenAPI 스펙] --> B[개발자가 읽음] --> C[API 함수 작성] --> D[타입 작성] --> E[Query 훅 작성] --> F[화면]
이제는 스펙이 코드 생성의 입력이에요. 중간 단계가 통째로 사라졌어요.
flowchart LR
A[OpenAPI 스펙] --> B["생성: API 함수·타입·Query 훅"] --> C[화면]
도구는 Orval을 골랐어요. 여기에 대단한 비교 과정이 있었다고 쓰면 거짓말이에요. 팀에 이미 사용 경험이 있는 도구였고, 그래서 검증 비용 없이 당장 시작할 수 있었어요. 3명이 기능 개발을 멈추지 않으면서 새 방식을 시험해야 하는 상황에서, 모르는 도구를 평가하는 건 그 자체로 비용이었어요.
다만 요구 조건은 분명했어요. 타입만 만들어주는 도구로는 부족했어요. 저희가 반복하고 있던 단위는 타입 하나가 아니라 요청 함수와 타입과 React Query 훅이 묶인 덩어리였거든요. 그리고 기존 네트워크 계층을 그대로 쓸 수 있어야 했어요. Orval은 둘 다 됐어요. 지금 다시 고른다면 Kubb 같은 후보도 함께 봤겠지만, 결과적으로 이 조건을 만족하는 선택이었어요.
첫 설정은 이 정도로 단순했어요.
export default defineConfig({
admin: {
output: {
mode: 'tags-split',
target: './src/api/schema/index.ts',
schemas: './src/api/schema/model',
client: 'react-query',
mock: true,
override: {
mutator: {
path: './src/api/mutator/custom-instance.ts',
name: 'customInstance',
},
},
},
input: {
target: 'https://admin-api.example.com/api/schema/',
},
hooks: {
afterAllFilesWrite: 'prettier --write',
},
},
})
이 설정 하나로 607개 파일이 생겼어요. 모델 483개, 엔드포인트 235개, 훅 235개예요. 손으로 쓰던 81개 훅과 비교하면 이제 스펙에 있는 모든 엔드포인트에 훅이 있는 상태가 됐어요.
기존 요청 계층 위에 얹으려 했고, 생각보다 안 맞았어요
어드민에는 이미 fetchWeb이 있었어요. 인증 헤더를 붙이고, 공통 응답 봉투를 벗기고, 인증 만료를 처리하는 함수예요. 새 도구를 넣는다고 이걸 같이 갈아엎으면 영향 범위가 감당이 안 돼요. 그래서 Orval의 custom mutator로 생성 코드가 기존 요청 함수를 쓰도록 연결했어요.
말로는 간단한데, 실제로는 안 맞는 지점이 있었어요. Orval은 요청 본문을 이미 JSON.stringify한 상태로 넘겨요. 그런데 fetchWeb은 받은 본문을 자기가 다시 stringify해요. 문자열을 한 번 더 문자열로 만드는 셈이라 서버가 받는 본문이 깨져요.
당시 저희가 택한 방법은 우회였어요. 본문이 이미 문자열이면 fetchWeb을 건너뛰고 직접 fetch를 부르는 분기를 mutator 안에 만들었어요.
export const customInstance = async <T>(url: string, options?: RequestOptions): Promise<T> => {
const { method = 'GET', body, headers, ...rest } = options || {}
// Orval이 이미 stringify한 경우 fetchWeb을 우회한다
if (typeof body === 'string' && method !== 'GET') {
const response = await fetch(url, {
method,
headers: { ...(await buildAuthHeaders()), ...headers },
body,
...rest,
})
return handleResponse<T>(response) // 인증 만료·에러 코드 처리
}
return fetchWeb({ url, method, body, headers, options: rest })
}
정직하게 말하면 이건 좋은 코드가 아니었어요. "기존 계층을 유지했다"고 했지만 실제로는 절반만 유지됐어요. 나머지 절반에서는 인증 헤더 구성과 에러 처리가 통째로 복제됐거든요. 이 파일은 124줄이었어요. 앞에서 문제라고 했던 복제를, 도구를 붙이는 자리에서 다시 만든 셈이에요.
이 부분은 나중에 정리됐어요. 지금 같은 파일은 30줄이고, 분기가 없어요. 생성 코드가 쓰는 전송 함수를 앱 시작 시점에 주입하는 방식으로 바꿨거든요.
type Transport = <T>(url: string, options?: Record<string, unknown>) => Promise<T>
let transport: Transport | null = null
export const setApiTransport = (t: Transport): void => {
transport = t
}
export const customInstance = <T>(url: string, options?: Record<string, unknown>): Promise<T> => {
if (!transport) throw new Error('transport가 등록되지 않았습니다.')
return transport<T>(url, options)
}
이제 이 파일은 인증이 어떻게 동작하는지 몰라요. 그건 전송 함수를 등록하는 쪽의 일이에요. 다만 처음부터 이렇게 만들 수 있었느냐면, 아니었어요. 기존 요청 함수의 어떤 가정이 생성 코드와 부딪히는지 먼저 겪어야 알 수 있는 것들이었어요.
화면 하나로 먼저 확인했어요
전부 바꾸지 않았어요. 관리자 목록 화면 하나만 골라서 생성된 훅으로 교체했어요.
// 기존
const { data, refetch } = useAdminUsers()
// 생성된 훅
const { data: queryData, refetch } = useAdminUsersRetrieve()
const data = queryData?.data.body
바꾸고 나서 바로 눈에 띈 게 있어요. queryData?.data.body요. 훅 하나 바꿨는데 데이터 접근이 두 겹 깊어졌어요. 손으로 쓸 때는 select로 벗겨내던 응답 봉투가, 생성 코드에서는 그대로 노출돼요. 생성물은 스펙에 적힌 응답 구조를 그대로 표현할 뿐이니까요.
작은 불편처럼 보이지만 이게 화면 수백 개로 퍼지면 얘기가 달라져요. 결론부터 말하면 저희는 이 봉투를 없애지 못했어요. 대신 훅을 쓸 때 select로 벗기는 방식으로 관례를 통일했는데, 왜 공통 래퍼를 한 겹 더 두지 않았는지는 다음 편에서 이야기할게요.
그래도 이 한 화면으로 확인하고 싶었던 건 다 확인했어요. 생성된 함수가 기존 인증·오류 처리를 그대로 통과하는지, 응답 타입이 화면에서 쓸 수 있는 형태인지, Query Key와 캐시가 기대대로 도는지, 그리고 UI 코드를 크게 건드리지 않고 바꿀 수 있는지요.
이틀 뒤에는 새로 만들던 화면에서 생성된 query와 mutation을 그대로 썼어요. 설정 파일로만 남아 있던 도구가 기능 개발의 기본 선택지가 된 시점이에요.
한 번에 전환하지 않았어요
생성 코드를 도입했다고 기존 수기 API를 바로 지우지는 않았어요. 그때도 여러 기능이 동시에 개발되고 있었고, 오래된 API 코드에는 화면별 데이터 가공과 예외 처리가 섞여 있었어요. 전부 한 번에 바꾸면 얻는 것보다 검증 비용이 커져요.
기준은 네 가지였어요.
- 새로 만드는 화면은 생성된 API와 훅을 우선 쓴다
- 기존 화면을 개편할 때 관련 API도 함께 전환한다
- 스펙만으로 표현되지 않는 부분은 원인을 확인하고 따로 보정한다
- 수기 코드와 생성 코드가 공존하는 기간을 허용한다
공존을 허용한 덕분에 진행 중인 개발을 멈추지 않고 새 방식을 시험할 수 있었어요. 그리고 생성 코드가 실제로 여러 화면에서 쓰인 뒤에야 다음 구조적 문제를 판단할 근거가 생겼어요.
달라진 건 코드의 양이 아니었어요
이 작업을 "보일러플레이트가 줄었다"로 요약하면 절반만 맞아요. 반복이 준 건 사실이에요. 하지만 더 중요한 변화는 따로 있어요.
서버 변경을 누가 발견하느냐가 바뀌었어요.
전에는 사람이었어요. 서버가 필드를 좁히거나 경로를 바꾸면, 누군가 스펙을 열어 대조하거나 화면이 깨진 뒤에 알았어요. 3명이 235개 엔드포인트를 그렇게 감시할 수는 없어요.
지금은 도구예요. 스펙을 다시 받아 생성을 돌리면 변경이 diff로 나와요. 그 변경이 프론트엔드 코드와 안 맞으면 타입 에러가 나요. 앞에서 본 카테고리 필드가 그랬어요. 닷새 사이의 서버 변경이 사람의 주의력이 아니라 명령어 하나로 드러났어요.
API 연결에 필요한 판단이 사라진 건 아니에요. 어떤 데이터를 어떻게 보여줄지, 언제 요청할지, 성공 후 무엇을 갱신할지는 여전히 프론트엔드가 정해요. 대신 서버가 이미 정한 것을 다시 적는 일은 줄었어요. 팀은 계약을 옮겨 적는 대신 제품의 동작을 설계하는 쪽에 시간을 쓸 수 있게 됐어요.
그리고 가려져 있던 문제가 보이기 시작했어요
생성으로 바꿨다고 모든 게 해결되지는 않았어요. 오히려 수기 타입 뒤에 숨어 있던 문제가 드러났어요.
스펙에 응답이 충분히 기술돼 있지 않은 엔드포인트들이 있었어요. 파일 다운로드처럼 바이너리를 돌려주는 API가 대표적이에요. 응답 형식이 명확하지 않으면 생성기는 나름의 추측을 하고, 그 추측이 틀리면 타입은 그럴듯한데 실제 값과 맞지 않는 상태가 돼요. 손으로 쓸 때는 개발자가 알아서 맞는 타입을 적고 넘어가던 자리예요. 스펙을 기준으로 삼는 순간, 그 자리에 원래 무엇이 비어 있었는지가 드러나요.
이런 것들은 결국 생성물을 그대로 쓰지 않고 한 겹 보정하는 계층을 만들게 했어요. 그런데 문제가 보인다는 것 자체가 변화였어요. 전에는 프론트엔드마다 다른 타입을 적어두고 각자 넘어갔다면, 이제는 하나의 서버 계약을 놓고 "여기가 부족하다"고 말할 수 있게 됐거든요.
한 가지 더. 첫 설정에 mock: true가 들어 있었어요. 스펙에서 응답 목 팩토리를 만드는 옵션인데, 도입 첫날부터 켜져 있었지만 반년 가까이 쓰지 않았어요. 이걸 실제로 E2E 테스트에 연결했을 때 무슨 일이 생겼는지는 이 시리즈 뒷부분에서 다룰게요.
다음 편에서는 생성기가 만들어준 코드를 실무에서 믿고 쓰기 위해 무엇을 보정해야 했는지, 그리고 생성물을 여러 앱이 함께 쓰기 시작하면서 그 코드를 어디에 두고 누가 관리할지 어떻게 정했는지 이야기할게요.
도움이 되었다면 눌러주세요
댓글 1
- 익명2026-08-21
도움이 되었습니다!