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 b

status === "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 / 서버 컴포넌트의 fetchNext 서버안 탄다

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: 어느 쪽이 롤백돼도 동작
  1. 프론트를 먼저 배포한다. isEnvelope 이 true 면 벗기고 false 면 그대로 쓴다. 옛 백엔드와 호환.
  2. 백엔드에 봉투를 넣는다. 새 프론트는 벗긴다.
  3. 백엔드를 롤백해도 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주 동안 몰랐다.