빌드노트
파일 하나는 올라가는데 두 개를 보내면 Spring 업로드가 실패한다
파일별 한도보다 작은 파일도 묶어서 보내면 multipart 요청 전체 한도를 넘을 수 있다. 실제 Tomcat 서버에서 파일·요청 한도를 따로 바꾸고, 파일 합계에 포함되지 않는 boundary·파트 헤더와 내부 예외를 확인해 어느 한도를 조정할지 구분한다.
Spring Boot의 파일 업로드에서 파일 하나는 성공하는데 두 개를 묶으면 실패한다면, max-file-size와 함께 max-request-size를 확인한다. 각 파일이 제한보다 작아도 multipart 요청 전체가 한도를 넘을 수 있다. 별도 실험에서도 6KB 파일 하나는 성공했지만 두 개는 거절됐다. 파일별 한도만 올려서는 해결되지 않았다.
Servlet 기반 Spring Boot에서 spring.servlet.multipart.max-file-size는 파일 하나의 최대 크기이고, spring.servlet.multipart.max-request-size는 multipart 요청 전체의 최대 크기다. Spring의 업로드 가이드 (새 탭에서 열림)도 두 설정을 구분한다. 이 글은 Spring MVC와 임베디드 Tomcat의 크기 제한을 다룬다.
6KB 하나와 6KB 두 개를 비교했다
Windows 11, OpenJDK 17.0.2, Spring Boot 3.5.6, Spring Framework 6.2.11, Tomcat 10.1.46으로 별도 최소 서버를 실행했다. 서버는 127.0.0.1의 임의 포트에만 열었고 프록시는 거치지 않았다. 아래처럼 파일 한도는 8KB, 요청 한도는 10KB로 정했다.
spring.servlet.multipart.max-file-size=8KB
spring.servlet.multipart.max-request-size=10KB
설정 파일만 읽고 적용됐다고 판단하지 않았다. Boot가 만든 MultipartConfigElement 빈에서 읽은 값은 maxFileSize=8192, maxRequestSize=10240바이트였고, 바인딩된 MultipartProperties의 값과도 일치했다. 여기서 KB는 1,024바이트다. Boot 3.5.6의 설정 코드 (새 탭에서 열림)는 이 두 값을 multipart 설정으로 전달한다.
같은 files 필드에 합성 파일을 넣어 실제 HTTP POST를 보냈다. 경계 문자열은 lab-boundary-20261021로 고정했다. 다음은 직접 구성한 multipart 본문 길이와 응답이다.
- 6,144바이트 파일 하나: 본문 6,304바이트, HTTP 200, 업로드 메서드 실행 1회.
- 6,144바이트 파일 두 개: 본문 12,581바이트, HTTP 413, 업로드 메서드 실행 0회.
두 파일은 각각 8,192바이트보다 작다. 하지만 파일 합계만 12,288바이트이므로 요청 한도 10,240바이트를 이미 넘는다. 메서드 안에서 파일 크기를 검사하기 전에 multipart 파싱 단계에서 거절됐다.
이 실험의 예외 처리기는 MaxUploadSizeExceededException을 받아 413을 반환하도록 명시했다. 따라서 상태 코드는 이 서버의 처리 결과다. 다른 앱의 사용자 정의 예외 처리나 앞단 서버에서도 반드시 같은 응답 본문이 나온다고 보장하지는 않는다.
파일 합계가 10KB여도 요청은 더 크다
파일 크기를 각각 5,120바이트로 줄여도 요청 한도 10KB에서는 실패했다. 파일 합계는 정확히 10,240바이트였지만, 전송한 multipart 본문은 10,533바이트였다. 남은 293바이트는 각 파트의 헤더, boundary 구분선과 줄바꿈이었다. 별도 폼 필드는 넣지 않았다.
multipart/form-data 규격 (새 탭에서 열림)은 본문을 여러 파트로 나누고, boundary와 파트별 Content-Disposition 헤더를 사용한다. 파일명과 필드명도 이 헤더에 들어간다. 따라서 요청 한도를 파일 바이트의 합계와 딱 맞추면 부가 바이트를 담을 공간이 없다. 제목·설명 같은 일반 폼 필드가 함께 오면 그 내용과 포장도 계산에 들어가야 한다.
여기서 측정한 본문 길이는 Content-Length로 보낸 값이다. 바깥 HTTP 요청 헤더나 TCP 패킷 크기를 더한 수치가 아니다. 293바이트 역시 이 필드명·파일명·boundary를 사용한 결과다. 모든 클라이언트에 그대로 더할 고정값은 아니다.
같은 413에서도 바꿀 설정은 달랐다
한도를 하나씩 바꾸고 서버를 새로 실행해 비교했다.
- 파일 한도만 16KB로 올리고 요청 한도는 10KB로 유지했다. 6KB 두 개는 여전히 413이었다.
- 파일 한도는 8KB로 유지하고 요청 한도를 16KB로 올렸다. 같은 6KB 두 개가 200이 됐다. 5KB 두 개도 200이었다.
- 요청 한도 16KB에서 9KB 파일 하나를 보냈다. 본문은 9,376바이트로 요청 한도보다 작았지만, 파일 자체가 8KB를 넘어 413이었다.
두 실패는 Spring에서 모두 MaxUploadSizeExceededException으로 보였다. 내부 Tomcat 예외는 달랐다. 아래는 요청 전체가 넘친 경우의 원인 메시지다.
SizeLimitExceededException: the request was rejected because its size (12581) exceeds the configured maximum (10240)
9KB 파일 하나가 파일별 한도를 넘긴 경우에는 다음 메시지가 남았다.
FileSizeLimitExceededException: The field files exceeds its maximum permitted size of 8192 bytes.
이름이 비슷해도 첫 메시지는 요청 전체의 크기를, 두 번째는 files 파트의 파일 크기를 가리킨다. Spring 6.2.11의 파싱 코드 (새 탭에서 열림)는 크기 초과로 판단한 파싱 실패를 MaxUploadSizeExceededException으로 감싼다. 바깥 예외 이름이나 413만 보고 어느 설정을 올릴지 정하면 이 구분을 놓칠 수 있다. 서버 로그에서 원인 예외와 실제 초과 바이트를 함께 읽는다.
내 업로드에서도 하나와 묶음을 나눠 보낸다
Servlet 기반 Boot 테스트 서버에 파일 업로드 API가 이미 있다면 같은 파일로 두 요청을 비교할 수 있다. 다음 명령은 PowerShell과 curl.exe가 필요하다. /upload가 @RequestParam("files") List<MultipartFile>을 받고 별도 인증 없이 호출 가능한 로컬 테스트 구성을 가정한다. 자기 API의 경로·필드명에 맞춰 바꾼다.
새 빈 진단 폴더에서 실행한다. 먼저 $base에 실행 중인 테스트 서버 주소를 넣는다. 예를 들어 포트가 8080이면 $base = 'http://127.0.0.1:8080'이다. 실험에서는 서버가 배정받은 임의 포트를 넣었다.
[IO.File]::WriteAllBytes((Join-Path $PWD 'a.bin'), [byte[]]::new(6144))
[IO.File]::WriteAllBytes((Join-Path $PWD 'b.bin'), [byte[]]::new(6144))
curl.exe --noproxy '*' --max-time 5 -sS -o one.txt -w 'one: status=%{http_code} body=%{size_upload}\n' -F 'files=@a.bin' "$base/upload"
if ($LASTEXITCODE -ne 0) { throw "curl one transport exit $LASTEXITCODE" }
curl.exe --noproxy '*' --max-time 5 -sS -o two.txt -w 'two: status=%{http_code} body=%{size_upload}\n' -F 'files=@a.bin' -F 'files=@b.bin' "$base/upload"
if ($LASTEXITCODE -ne 0) { throw "curl two transport exit $LASTEXITCODE" }
Get-Content one.txt, two.txt
-F가 파일을 multipart로 구성하므로 Content-Type을 따로 적지 않는다. curl 문서 (새 탭에서 열림)의 size_upload는 전송한 본문 바이트 수다. 이 작은 요청을 끝까지 보낸 실험에서는 서버가 보고한 Content-Length와 같았다. 큰 요청을 서버가 일찍 거절하는 상황까지 이 값이 전체 예정 길이를 뜻한다고 해석해서는 안 된다.
curl 8.21.0으로 위 명령을 실행한 출력은 다음과 같았다. 서버 한도는 다시 파일 8KB·요청 10KB였다.
one: status=200 body=6354
two: status=413 body=12656
curl이 만든 boundary는 앞의 고정 문자열과 달라 본문 길이도 달랐다. 한 개는 성공하고 두 개는 전체 요청 한도에서 거절되는 판단은 같았다. one.txt와 two.txt에는 응답 본문이 남는다. 자기 앱은 이 실험의 진단용 예외 본문을 반환하지 않을 수 있으므로 서버 로그도 함께 확인한다. 400이나 415가 나온다면 필드명·필수 파라미터·허용 미디어 타입부터 맞춘다. 전송 오류가 나거나 상태가 000이면 연결과 서버 기동 상태를 확인한다. 그런 결과를 크기 제한의 증거로 세지 않는다.
설정을 바꾼 뒤에도 같은 한도에서 막히면, 테스트 컨텍스트에서 MultipartConfigElement를 주입받아 getMaxFileSize()와 getMaxRequestSize()를 바이트로 출력해 본다. 이 실험의 수정 후 값은 각각 8192, 16384였다. 프로퍼티 값만 달라지고 빈의 값이 그대로라면 별도 multipart 설정을 제공하는 코드도 확인한다. Boot 3.5.6 자동 설정 (새 탭에서 열림)은 기존 설정 빈이 없는 조건에서 기본 빈을 만든다.
요청 한도는 허용하려는 묶음의 최대 크기에서 정한다. 파일별 허용 크기와 최대 개수를 먼저 정하고, 그 합계에 일반 폼 필드와 multipart 부가 바이트가 들어갈 공간을 둔다. 이번의 16KB는 6KB 두 개를 통과시킨 값이다. 8KB 파일 두 개를 최대 크기까지 모두 받으려는 정책이면 요청 한도 16KB도 부족하다. 파일 합계만으로 이미 16KB이기 때문이다. 허용 개수와 필드 길이가 정해진 실제 요청의 경계값을 다시 보낸 뒤 한도를 정한다.
이번 검증은 실제 임베디드 Servlet 서버를 대상으로 했다. Spring의 MockMvc 문서 (새 탭에서 열림)는 multipart 테스트가 실제 본문 파싱을 하지 않는다고 설명한다. MockMultipartFile이 컨트롤러에 전달되는 테스트만으로 서버의 크기 제한까지 검증했다고 볼 수 없다. 이 실험에서는 Nginx·외부 게이트웨이, WebFlux, chunked 전송, 대용량·동시 업로드는 확인하지 않았다. Spring 쪽 크기 초과 기록이 없다면 요청이 앱에 도달했는지부터 확인하고, 프록시가 있는 경로의 제한은 별도로 조사해야 한다.