home

tRPC 이관 후 FormData 업로드 깨진 사건 정리

데이터 레이어 마이그레이션 중 생긴 오류 사항 기록입니다.

문제 발생

Next.js + tRPC 환경에서 server action을 procedure로 이관하던 중, 파일 업로드 API가 깨졌다. 백엔드 응답은 다음과 같았다.

Content-Type 'application/json' is not supported

브라우저에서는 분명 FormData로 보냈는데, 백엔드는 JSON을 받았다고 한다. 서버 로그를 보니 file 필드가 {}로 직렬화돼 있었다.

{"request":"...","file":{}}

분석

요청 흐름은 이렇다.

브라우저 ──multipart──▶ Next.js (tRPC 서버) ──axios──▶ 백엔드(Spring)

브라우저에서 Next.js까지는 multipart가 잘 유지되었다 (tRPC v11의 splitLink + httpLink 조합 덕분에 FormData가 자동으로 multipart로 직렬화됨). 문제는 Next.js에서 axios로 백엔드를 호출하는 마지막 구간이었다.

axios(1.15.2) 의 transformRequest를 까보니 결정적인 코드를 발견했다.

if (isFormData) {
  return hasJSONContentType ? JSON.stringify(formDataToJSON(data)) : data;
}

axios는 FormData를 정확히 인식했지만, Content-Type이 application/json이면 의도적으로 JSON으로 변환한다. File 객체는 enumerable 속성이 없어서 JSON.stringify{}로 직렬화된다. 서버 로그의 "file":{}가 정확히 이 경로의 결과였다.

원인은 두 가지가 결합된 것:

  1. procedure에서 axios로 호출할 때 multipart 헤더를 명시하지 않음
  2. axios client의 default가 'Content-Type': 'application/json'으로 박혀 있음

해결

procedure에서 axios로 데이터를 넘길 때 Content-Type을 명시했다.

handleProcedure({
  ctx,
  signature: API_SIGNATURE.CONTRACT.UPLOAD_DOCUMENT,
  data: input,
  headers: {
    'Content-Type': 'multipart/form-data',
  },
})

application/json이 아니므로 axios의 JSON 변환 분기를 우회하고, http adapter의 formDataToStream이 boundary를 자동으로 채워 정상 multipart로 전송된다.

교훈

  • "axios가 FormData면 자동으로 multipart 처리해 주겠지"는 절반만 맞다. default headers에 Content-Type: application/json이 박혀 있으면 axios는 오히려 FormData를 JSON으로 바꾸는 경로를 탄다.
  • 공통 인스턴스의 default 설정은 곳곳에 영향을 준다. axios client의 기본 Content-Type을 박아두는 관행은 JSON API에는 편하지만, FormData 같은 예외 케이스에서 조용히 잘못된 동작을 만든다.
  • 현상이 "데이터가 비어있음"으로 나타날 때 직렬화 경로를 의심하자. File이 {}로 찍히는 건 객체 enumerability 문제의 전형적 패턴이다.
  • 추측보다 라이브러리 소스를 직접 읽는 게 빠르다. node_modules의 axios 소스 한 줄이 한 시간의 가설보다 명확했다.

댓글

아직 댓글이 없습니다.