Next.js proxy를 거친 대용량 업로드가 413 대신 400으로 실패한 이유

파일 업로드 API에 20MiB 제한을 걸어 두고, 한도를 넘는 요청에는 413을 돌려주도록 만들었다. 그런데 실제 서버에서 한도를 1바이트 넘긴 파일을 올리자 413이 아니라 400이 돌아왔다. Next.js에서 proxy(예전 이름 middleware)를 쓰면서 큰 파일 업로드를 받는 서비스라면 같은 함정에 빠지기 쉬워 원인과 해결 과정을 정리한다.
상황
자체 호스팅한 Next.js 블로그 서비스에 이미지·첨부 파일 업로드 기능을 붙였다. 업로드는 App Router의 Route Handler가 multipart/form-data로 받고, 파일 크기가 20MiB를 넘으면 413 Payload Too Large로 거절하도록 직접 검사했다. 앱 앞단에는 인증과 경로 처리를 위한 proxy 파일이 있다.
검증은 로컬 단위 테스트가 아니라 실제 배포 서버에서 했다. 작은 파일, 정확히 20MiB 파일, 20MiB에 1바이트를 더한 파일 세 가지를 올려 각각 저장 성공, 저장 성공, 413 거절이 나오는지 보는 시나리오였다.
증상
- 작은 파일은 정상적으로 저장됐다.
- 20MiB를 넘는 파일은 기대한 413이 아니라 400 Bad Request로 끝났다.
- 라우트 코드에는 400을 돌려주는 분기가 "본문을 multipart로 해석하지 못했을 때"뿐이었다. 즉 크기 검사까지 가기도 전에 본문 파싱이 깨지고 있었다.
처음에는 업로드 라우트의 크기 검사 순서가 틀렸다고 의심했다. 하지만 같은 코드가 작은 파일에서는 문제없이 돌았고, 크기만 키웠을 때 파싱 자체가 실패한다는 점에서 본문이 라우트에 온전히 도착하지 않는다고 판단했다. 실제 컨테이너 로그를 열어 보니 요청 본문이 10MB를 넘었다는 경고가 남아 있었다.
원인
Next.js 공식 문서에 따르면 proxy를 쓰는 앱에서는 요청 본문을 proxy와 라우트가 모두 읽을 수 있도록 Next.js가 본문을 복제해 메모리에 버퍼링한다. 메모리 과다 사용을 막기 위해 이 버퍼에는 상한이 있고, 기본값은 10MB다.
중요한 점은 상한을 넘었을 때의 동작이다. 문서에 적힌 동작은 다음과 같다.
1. 상한까지만 버퍼링한다.
2. 어느 경로가 상한을 넘었는지 경고 로그를 남긴다.
3. 요청은 그대로 진행되고, 라우트는 잘린 본문만 받는다.
4. 클라이언트에 오류를 돌려주지 않는다.
그래서 20MiB를 넘는 multipart 본문은 10MB 지점에서 잘려 라우트에 전달됐다. 끝 경계(boundary)가 없는 multipart 본문이니 formData 해석이 실패했고, 라우트는 "잘못된 요청"으로 판단해 400을 돌려준 것이다. 앱이 정한 20MiB 제한보다 프레임워크의 숨은 10MB 제한이 먼저 걸려 있었던 셈이다.
해결
next.config의 experimental.proxyClientMaxBodySize로 버퍼 상한을 늘렸다. 앱의 파일 제한(20MiB)보다 조금 크게, multipart 경계와 폼 필드가 들어갈 여유를 두고 21MiB로 맞췄다.
```js
// next.config.js
module.exports = {
experimental: {
// 앱 업로드 제한(20MiB) + multipart 오버헤드 여유
proxyClientMaxBodySize: 21 * 1024 * 1024,
},
}
```
API 쪽 20MiB 검사는 그대로 유지했다. 버퍼 상한은 "라우트가 본문을 온전히 받을 수 있는 크기"이고, 실제 허용 정책은 라우트가 정한다는 역할 분리다. 설정을 바꾼 뒤 같은 시나리오를 다시 돌려 20MiB는 저장되고 20MiB+1바이트는 413으로 거절되는 것을 실제 서버에서 확인했다.
운영하면서 정리한 판단 기준
- 이 버퍼 상한은 proxy 파일이 있을 때만 적용된다. proxy를 나중에 추가하면 멀쩡하던 대용량 업로드가 갑자기 깨질 수 있다.
- 상한을 넘어도 요청이 실패하지 않는다는 점이 가장 위험하다. 오류가 아니라 "잘린 본문"으로 나타나므로 증상이 파싱 실패, JSON 오류, 400 같은 엉뚱한 형태로 보인다.
- 상한을 무작정 크게 잡으면 요청마다 그만큼 메모리를 쓸 수 있다. 문서도 상한은 요청 단위로 적용된다고 설명한다. 업로드 경로가 proxy를 거칠 필요가 없다면 matcher에서 제외하는 쪽이 메모리 면에서 낫다.
- 업로드 제한 숫자는 한 곳에서 관리하고, 프레임워크 상한은 거기에 여유분을 더해 계산하도록 묶어 두면 한쪽만 바꾸는 실수를 줄일 수 있다.
다시 겪지 않으려면
- 업로드 기능 검증은 경계값 세 개(작은 파일, 정확히 한도, 한도+1바이트)를 실제 배포 환경에서 확인한다.
- "한도 초과인데 413이 아니다"라는 증상이 보이면 앱 코드보다 앞단(프레임워크 proxy, 리버스 프록시, 로드밸런서)의 본문 상한부터 확인한다.
- 서버 로그에서 본문 상한 초과 경고를 찾아본다. 클라이언트 응답에는 단서가 남지 않는다.
- proxy를 새로 추가하거나 matcher를 바꿀 때 업로드 경로가 영향을 받는지 점검한다.
- 앱의 업로드 제한과 proxyClientMaxBodySize를 같은 변경 단위로 관리한다.
참고 자료
- proxyClientMaxBodySize — Next.js Docs
- proxy.js File Convention — Next.js Docs