본문 바로가기
빌드노트 검색

빌드노트

multipart 요청인데 @RequestPart JSON 메타데이터가 415로 막힌다

전체 요청이 multipart/form-data여도 @RequestPart DTO는 해당 파트의 Content-Type에 따라 읽힌다. 같은 JSON 바이트의 타입 누락·octet-stream·application/json과 깨진 JSON을 MockMvc로 비교하고, JSON Blob에 타입을 지정하는 수정과 boundary 확인 위치를 구분한다.

파일과 JSON 메타데이터를 함께 보냈는데 @RequestPart("metadata") Metadata metadata를 받는 Spring MVC 컨트롤러가 415를 반환할 수 있다. 전체 요청이 multipart/form-data여도 JSON 파트의 타입까지 정해지는 것은 아니다. JSON을 담은 metadata 파트에 Content-Type: application/json이 있는지 먼저 확인한다. 문자열 내용이 올바른 JSON이어도 파트 타입이 없거나 application/octet-stream이면 DTO로 읽을 변환기를 선택하지 못할 수 있다.

이 글은 메타데이터를 Metadata(String title) DTO로 받고, 파일은 @RequestPart("file") MultipartFile file로 받는 경우를 다룬다. 파일 용량이나 빈 파일 여부를 판단하는 문제와는 확인할 지점이 다르다.

요청 헤더 아래에 파트 헤더가 한 번 더 있다

실패한 요청의 원문을 볼 수 있는 클라이언트 로그나 네트워크 도구에서 두 위치를 나누어 확인한다.

  1. 전체 요청의 Content-Type은 multipart/form-data; boundary=...인가. boundary 값은 본문에서 파트를 나누는 구분자와 맞아야 한다.
  2. 본문 안에서 Content-Disposition의 name="metadata"인 파트를 찾는다. 그 파트의 Content-Type이 application/json인가. file 파트의 타입을 보고 대신 판단하지 않는다.

boundary는 여러 파트를 나누기 위한 정보이고, 파트의 미디어 타입은 그 안의 내용을 어떻게 읽을지 알려 준다. 이 구조는 RFC 7578의 boundary와 파트 헤더 규정 (새 탭에서 열림)에 설명돼 있다. 요청 헤더만 맞는다고 두 번째 조건까지 충족되는 것은 아니다.

Spring은 DTO 타입의 @RequestPart 인자를 만들 때 해당 파트의 타입을 고려해 HttpMessageConverter로 내용을 읽는다. Spring MVC multipart 문서 (새 탭에서 열림)는 JSON 메타데이터를 이런 방식으로 변환하는 예를 제공한다. @PostMapping(consumes = "multipart/form-data")는 전체 요청에 대한 조건이므로 JSON 파트 타입을 대신 지정하지 않는다.

FormData에서는 JSON을 타입 있는 Blob으로 넣는다

브라우저 코드에서 form.append("metadata", JSON.stringify(metadata))처럼 문자열만 넣었다면 JSON 파트 타입을 명시하는 쪽으로 바꾼다. 아래 코드는 <input type="file" id="file">에서 파일 하나를 선택한 뒤 실행하는 예다. /uploads와 파트 이름은 자신의 컨트롤러 계약에 맞춘다. 전역 HTTP 래퍼를 쓴다면 자동으로 붙이는 Content-Type 헤더도 확인한다.

JSON 파트의 타입은 Blob에 지정하고, 전체 요청 헤더는 브라우저가 생성하게 둔다.

const file = document.querySelector("#file").files[0];
const form = new FormData();
form.append("metadata", new Blob(
  [JSON.stringify({ title: "note" })],
  { type: "application/json" }
), "metadata.json");
form.append("file", file);

await fetch("/uploads", { method: "POST", body: form });

여기서 metadata.json은 파일명이다. 타입을 정하는 부분은 type: "application/json"이다. 전체 요청의 헤더를 application/json으로 바꾸면 multipart를 받는 컨트롤러의 계약과 어긋난다. multipart/form-data만 직접 써 넣어도 브라우저가 만든 본문에 필요한 boundary가 헤더에서 빠질 수 있다. MDN의 FormData 안내 (새 탭에서 열림)는 이 때문에 전체 Content-Type을 직접 설정하지 말라고 설명한다.

보조 확인으로 Node.js 22.23.2의 FormData와 Request에서 합성 파일을 넣고 요청 본문만 직렬화했다. 문자열로 넣은 metadata에는 파트 Content-Type이 없었고, 위와 같이 Blob으로 넣은 경우에는 application/json이 있었다. 두 경우 모두 전체 헤더에는 본문 구분자와 일치하는 boundary가 생성됐다. 네트워크로 전송하지 않은 확인이며, 실제 브라우저에서 서버로 업로드한 결과는 아니다.

JSON 바이트를 그대로 두고 파트 타입만 바꿨다

별도 MockMvc 실험에서는 Spring Framework 6.2.11, Jackson 2.18.4, OpenJDK 17.0.2를 사용했다. MappingJackson2HttpMessageConverter를 명시적으로 등록하고, metadata DTO와 file을 받는 /uploads 컨트롤러를 연결했다. 사용자 정의 예외 처리기는 넣지 않았다.

기존 프로젝트의 MockMvc 테스트에서도 JSON 파트 생성자의 세 번째 인자를 바꾸어 비교할 수 있다. 아래 입력은 spring-test와 Jackson이 준비돼 있고, mockMvc가 앞에서 설명한 /uploads 핸들러에 연결된 테스트 안에서 쓴다. MockMultipartFile, MvcResult, StandardCharsets를 가져오고 multipart는 MockMvcRequestBuilders에서 정적 가져오기를 한다.

같은 JSON 바이트와 파일을 유지하면서 파트 타입만 바꾸는 핵심 입력은 다음과 같다.

byte[] json = "{\"title\":\"note\"}".getBytes(StandardCharsets.UTF_8);
MockMultipartFile metadata = new MockMultipartFile(
    "metadata", "metadata.json", "application/json", json);
MockMultipartFile file = new MockMultipartFile(
    "file", "note.txt", "text/plain",
    "demo\n".getBytes(StandardCharsets.UTF_8));

MvcResult result = mockMvc.perform(
    multipart("/uploads").file(metadata).file(file)
).andReturn();
System.out.println(result.getResponse().getStatus());
System.out.println(result.getResolvedException());

독립 실험에서 얻은 결과는 다음과 같다. 컨트롤러 본문 첫 줄의 호출 수와 전달된 DTO도 함께 기록했다.

  • 파트 타입 null: 415, HttpMediaTypeNotSupportedException, 본문 호출 0회, 전달된 DTO 없음.
  • 파트 타입 application/octet-stream: 415, 같은 예외, 본문 호출 0회, 전달된 DTO 없음.
  • 파트 타입 application/json: 200, 변환 예외 없음, 본문 호출 1회, DTO의 title은 note.
  • 파트 타입은 application/json으로 두고 JSON을 {"title":로 끊음: 400, HttpMessageNotReadableException, 본문 호출 0회. 원인 예외는 Jackson의 JsonEOFException이었다.

앞의 세 요청은 JSON 바이트가 같았고 네 요청의 파일 파트도 같았다. 성공한 응답은 {"title":"note","filename":"note.txt","size":5}였다. 파일명도 네 경우 모두 같게 유지했으므로, metadata.json이라는 확장자가 JSON 변환을 보장하지 않는다는 점도 확인할 수 있었다.

타입 누락 사례의 예외 메시지에도 Content-Type 'application/octet-stream' is not supported가 나왔다. 클라이언트가 그 헤더를 명시해서 보냈다는 뜻으로 단정하면 안 된다. Spring 6.2.11의 메시지 변환 코드 (새 탭에서 열림)는 읽을 입력의 타입이 없으면 변환기 선택에 application/octet-stream을 사용한다. 이번 실험에서도 누락한 파트 헤더는 null인 채였고, 예외가 보고한 타입은 octet-stream이었다.

사용한 Jackson 변환기는 기본적으로 application/json과 application/*+json을 지원한다. 변환기 API 문서 (새 탭에서 열림)의 이 조건과 관찰을 함께 보면, 앞의 두 415는 JSON 문법을 검사하다 실패한 경우와 구별된다. 타입을 고친 뒤 400으로 바뀌고 파싱 예외가 보인다면 이제 JSON 내용과 DTO 구조를 확인할 차례다. 다만 400이라는 숫자만으로 모든 오류가 JSON 문법 문제라고 판단할 수는 없다.

application/json인데도 계속 415라면 실제로 보낸 파트 헤더와 서버에 등록된 메시지 변환기를 대조한다. 이번 결과는 Jackson 변환기가 등록된 구성에서 얻었다. 사용자 정의 변환기나 예외 처리기가 있는 앱은 같은 상태 코드가 나오는지 별도로 확인해야 한다.

MockMvc의 multipart()는 이미 구성한 mock multipart 요청을 사용한다. MockMvc 요청 문서 (새 탭에서 열림)도 실제 multipart 파싱을 하지 않는다고 명시한다. 따라서 이 결과가 확인한 것은 파트 헤더가 주어진 뒤의 DTO 변환과 컨트롤러 본문 진입 여부다. 실제 Servlet 컨테이너의 boundary 파싱, multipart resolver의 파일별·요청 전체 크기 제한, 프록시 제한은 검증하지 않았다.

← 전체 글 보기