1. 개요
백엔드가 JSON 응답을 {"status": "success", "data": {...}} 로 감싸는 규약은 흔하다. 에러도 {"code", "message"} 로 통일된다. 프론트는 axios 인터셉터에서 data 만 꺼내 쓴다. 깔끔하다. 그런데 이 규약을 세 프로젝트에 적용하면서 두 프로젝트에서 각각 다른 방식으로 깨졌다. 둘 다 원인은 같다. 인터셉터는 한 경로만 지킨다.
2. 핵심 내용
2-1. 봉투와 언래핑
백엔드(Spring)는 ResponseBodyAdvice 로 컨트롤러가 돌려준 DTO 를 봉투에 넣는다. 제외 대상은 명시한다. XML(사이트맵), String, 파일 바이트, actuator. 이걸 빠뜨리면 사이트맵이 JSON 봉투에 싸여 검색 엔진이 못 읽는다.
프론트(axios)는 응답 인터셉터에서 봉투를 벗긴다.
api.interceptors.response.use((res) => {
const body = res.data
if (isEnvelope(body)) res.data = body.data
return res
})2-2. 판별 조건은 좁게
처음 isEnvelope 이 'status' in body 였다. 그러자 도메인 객체 중 status 필드를 가진 것(문의의 status: "pending")이 봉투로 오판돼 data 를 꺼내려다 undefined 가 됐다.
const isEnvelope = (b: unknown): b is Envelope =>
typeof b === "object" && b !== null &&
(b as any).status === "success" && "data" in bstatus === "success" 와 data 키 존재를 함께 요구한다. 도메인 필드와 겹칠 확률을 0 에 가깝게. 이 조건은 두 프로젝트가 같은 함수를 쓴다.
b is Envelope 라고 적은 반환 타입이 바로 TypeScript 의 커스텀 타입 가드다. 이 함수가 true 를 반환하는 분기에서만 b를 Envelope로 취급해도 된다고 컴파일러에게 보증하는 것인데, 그 보증은 함수 몸통이 실제로 맞게 짰을 때만 유효하다. 처음 버전('status' in body)이 오판한 건 정확히 이 보증이 느슨했기 때문이다. 이 패턴 자체는 TypeScript 기초 7 - 커스텀 타입 가드에서 더 자세히 다룬다.
2-3. 인터셉터를 안 타는 경로
여기가 본론이다. Next.js App Router 에서 백엔드를 부르는 경로가 셋이다.
| 경로 | 실행 위치 | axios 인터셉터 |
|---|---|---|
클라이언트 컴포넌트의 useQuery | 브라우저 | 탄다 |
| Server Action(로그인, 재발급) | Next 서버 | 안 탄다 (raw fetch) |
| Route Handler / 서버 컴포넌트의 fetch | Next 서버 | 안 탄다 |
Server Action 은 보안상 axios 인스턴스를 공유하지 않고 fetch 로 직접 부르는 경우가 많다. 그 경로는 봉투를 스스로 벗겨야 한다. 안 벗기면 body.token 이 아니라 body.data.token 이라 undefined.
2-4. 사고 1: 목록이 항상 빈 배열
서버 컴포넌트가 프리렌더할 slug 목록을 fetch 로 받았다. 봉투를 안 벗겼다. 배열이 아니라 객체가 오니 Array.isArray(body) 가 항상 false. try/catch 가 감싸고 있어 빈 배열 반환. 에러 없음. 목록이 비었으니 프리렌더할 게 없고, 아무 증상이 없었다.
3주 뒤 다른 원인으로 환경변수가 제대로 잡히고 봉투가 벗겨지자 목록이 채워졌고, 프리렌더가 실제로 시도되면서 다른 버그가 터졌다. 잘못된 코드가 다른 잘못된 코드에 가려져 있었다.
2-5. 사고 2: 로그인이 조용히 실패
다른 프로젝트에서 Server Action 로그인이 body.accessToken 을 읽었다. 봉투 도입 전 코드다. 백엔드에 봉투를 넣자 undefined 가 쿠키에 들어갔고, 다음 요청이 401, 재발급도 같은 이유로 실패. 로그인 화면으로 튕기는데 에러 메시지는 없었다.
axios 를 타는 경로는 전부 잘 됐다. 로그인·재발급·프리렌더 목록, 이 셋만 fetch 였고 셋만 깨졌다.
2-6. 배포 순서: 소비자 먼저 관용적으로
봉투를 도입할 때 백엔드와 프론트가 같은 시각에 배포되지 않는다. GitOps 에서 두 저장소는 따로 나가고, 롤백도 따로 된다. 어느 순서로 나가도, 어느 쪽이 롤백돼도 깨지지 않아야 한다.
sequenceDiagram participant FE as 프론트 participant BE as 백엔드 Note over FE: 1단계: 봉투가 있으면 벗기고<br/>없으면 그대로 (tolerant) FE->>BE: 요청 BE-->>FE: 옛 응답 (봉투 없음) → 그대로 사용 Note over BE: 2단계: 봉투 도입 FE->>BE: 요청 BE-->>FE: 새 응답 (봉투) → 벗겨서 사용 Note over FE,BE: 어느 쪽이 롤백돼도 동작
- 프론트를 먼저 배포한다.
isEnvelope이 true 면 벗기고 false 면 그대로 쓴다. 옛 백엔드와 호환. - 백엔드에 봉투를 넣는다. 새 프론트는 벗긴다.
- 백엔드를 롤백해도 1단계 프론트는 동작한다. 프론트를 롤백하면 봉투를 못 벗기니 백엔드도 롤백.
생산자(백엔드)를 바꾸기 전에 소비자(프론트)를 관용적으로 만든다. 이 순서를 지키면 배포 창에서 장애가 없다. 반대로 하면 백엔드가 나간 순간부터 프론트가 나갈 때까지 깨진다.
2-7. 규칙으로 남긴 것
- 봉투 판별은
status === "success" && "data" in body. 함수 하나를 공유. - raw
fetch를 쓰는 곳은 목록으로 관리한다. Server Action, Route Handler, 서버 컴포넌트. 각각unwrapEnvelope()를 명시적으로 부른다. - 새 Server Action 을 추가할 때 체크리스트에 “봉투 벗겼나”.
try/catch로 빈 배열을 돌려주는 코드는 “로드 실패” 와 “진짜 빈 목록” 을 구분하지 못한다. 실패는 throw 하거나 별도 상태로.
3. 마무리
요약
- 인터셉터는 axios 경로만 지킨다. Server Action·Route Handler·서버 컴포넌트의 raw fetch 는 각자 벗긴다.
- 봉투 판별은
status === "success"와data키를 함께. 도메인status필드 오탐 방지.- 소비자를 먼저 관용적으로, 생산자를 나중에. 어떤 배포·롤백 순서에도 안 깨진다.
try/catch→ 빈 배열은 장애를 “빈 화면” 으로 번역한다. 3주 동안 몰랐다.