단순한 해결법 나열이 아니라, 왜 에러가 발생했는지(HTTP 프로토콜 관점), 프론트엔드와 백엔드가 각각 어떻게 데이터를 주고받아야 하는지 원리를 포함하여 기록하려 합니당. (해당 트러블 슈팅은 wecam 프로젝트에서 발생함..)

프론트엔드(Next.js)와 백엔드(Spring Boot)를 연동하다 보면 가장 까다로운 구간 중 하나가 바로 "파일 업로드와 일반 데이터를 동시에 보낼 때"입니다.
저는 두가지 방법을 고민했습니다.
- 두 번 요청: JSON 데이터를 먼저 보내서 ID를 받고, 그 ID로 파일을 별도 업로드한다. (Transaction 관리가 귀찮음)
- 한 번 요청:
multipart/form-data로 묶어서 한 방에 보낸다. (깔끔하지만 구현이 까다로움)
당연히 2번이 UX나 아키텍처 관점에서 깔끔합니다. 하지만 호기롭게 코드를 짜다 보면 십중팔구 이 에러를 마주하게 됩니다.
Resolved [org.springframework.web.HttpMediaTypeNotSupportedException:
Content-Type 'multipart/form-data;boundary=...' is not supported]
오늘은 이 에러가 발생하는 근본적인 원인과, Spring Boot(@RequestPart)와 Next.js(FormData + Blob)에서의 정석 구현 패턴을 아주 상세하게 정리해 봅니다.
1. 문제 상황 - JSON을 @RequestBody로 받아서...
회의록 생성 기능이 있다고 가정해 봅시다. 회의 제목, 내용 같은 JSON 데이터와 첨부 파일들을 한 번에 보내고 싶습니다.
실패한 백엔드 코드
@PostMapping(value = "/create", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
public BaseResponse<MeetingResponse> createMeeting(
@RequestBody MeetingRequest request, // <--- 여기가 문제!
@RequestPart("files") List<MultipartFile> files
) {
// ...
}
실패한 프론트엔드 코드
const formData = new FormData();
formData.append("request", JSON.stringify(data)); // 그냥 문자열로 넣음
formData.append("files", file);
// 헤더를 수동으로 지정함 (치명적 실수!)
axios.post("/create", formData, {
headers: { "Content-Type": "multipart/form-data" }
});
이 상태에서 요청을 보내면 Spring은 415 Unsupported Media Type을 뱉거나, request 객체를 파싱 하지 못해 400 에러를 냅니다.
2. 심층 분석: 도대체 왜 안 될까?
이 문제를 해결하려면 multipart/form-data가 어떻게 생겼는지 이해해야 합니다.
(1) @RequestBody vs @RequestPart
@RequestBody: 요청의 Body 전체를 하나의 JSON 덩어리로 봅니다. 하지만 Multipart 요청은 Body가 여러 개의 조각(Part)으로 나뉘어 있습니다. 스프링은 Body 전체를 JSON으로 파싱 하려다가, 이상한 바이너리 데이터(파일)가 섞여 있으니 에러를 냅니다.@RequestPart: Multipart 요청의 특정 조각(Part) 하나를 가져옵니다. 우리는 JSON 데이터도 하나의 'Part'로 취급해서 받아야 합니다.
(2) 프론트 - Content-Type 수동 설정 (Boundary 이슈)
multipart/form-data는 여러 개의 데이터 조각을 구분하기 위해 Boundary(경계선) 문자열을 사용합니다.
브라우저가 자동으로 헤더를 만들면 다음과 같이 됩니다.
Content-Type: multipart/form-data; boundary=----WebKitFormBoundaryAbC123...
하지만 개발자가 수동으로 { "Content-Type": "multipart/form-data" }를 설정해 버리면, boundary 정보가 사라집니다. 서버는 어디서 데이터가 끊기는지 알 수 없어서 파싱에 실패합니다.
(3) 프론트 - JSON을 그냥 문자열로 보냄
FormData.append("key", JSON.stringify(data))로 보내면, 기본적으로 Content-Type이 지정되지 않거나 text/plain으로 전송됩니다.
Spring의 MappingJackson2HttpMessageConverter는 Content-Type이 application/json인 Part만 찾아서 DTO로 변환해 줍니다. 따라서 단순 문자열은 DTO로 변환되지 않습니다.
3. 해결책: Backend (Spring Boot)
JSON 데이터도 파일처럼 하나의 Part로 취급해야 합니다. @RequestBody 대신 @RequestPart를 사용하세요.
import org.springframework.http.MediaType;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.multipart.MultipartFile;
@RestController
@RequestMapping("/api/meeting")
public class MeetingController {
@PostMapping(
value = "/create",
consumes = MediaType.MULTIPART_FORM_DATA_VALUE, // 필수!
produces = MediaType.APPLICATION_JSON_VALUE
)
public ResponseEntity<?> createMeeting(
// [1] JSON 데이터: @RequestPart 사용
@RequestPart(value = "request") MeetingRequest requestDto,
// [2] 파일 데이터: required=false로 설정하면 파일 없이도 요청 가능
@RequestPart(value = "files", required = false) List<MultipartFile> files
) {
// 서비스 로직 호출
meetingService.create(requestDto, files);
return ResponseEntity.ok("성공");
}
}
Tip:
@RequestPart를 쓰면 Spring이 알아서 해당 Part의 Content-Type을 확인하고 (application/json),HttpMessageConverter를 돌려서 DTO 객체로 변환해 줍니다.
4. 해결책: Frontend (Next.js / Axios / Fetch)
프론트엔드 작업의 핵심은 두 가지입니다.
- JSON 데이터를
Blob으로 감싸서application/json타입을 명시한다. Content-Type헤더를 절대 설정하지 않는다.
const uploadMeeting = async (meetingData, fileList) => {
const formData = new FormData();
// [핵심 1] JSON 데이터를 Blob으로 변환하여 추가
// 이렇게 해야 서버(Spring)가 이 Part를 JSON으로 인식합니다.
const jsonBlob = new Blob([JSON.stringify(meetingData)], {
type: "application/json",
});
// 백엔드의 @RequestPart("request") 이름과 맞춰야 함
formData.append("request", jsonBlob);
// [핵심 2] 파일 추가 (백엔드의 @RequestPart("files") 이름과 매칭)
if (fileList && fileList.length > 0) {
fileList.forEach((file) => {
formData.append("files", file);
});
}
// [핵심 3] 요청 전송 (Content-Type 헤더 설정 금지!)
// 브라우저가 알아서 boundary를 포함한 Content-Type을 만들어줍니다.
const response = await fetch("http://localhost:8080/api/meeting/create", {
method: "POST",
body: formData,
// headers: { "Content-Type": "multipart/form-data" } // <--- 절대 금지! ❌
});
if (!response.ok) {
throw new Error("Upload failed");
}
return response.json();
};
5. 트러블슈팅 체크리스트 (에러별 대응)
개발하다가 또 막히면..
| 에러 증상 / 로그 | 원인 | 해결 방법 |
|---|---|---|
| HttpMediaTypeNotSupportedException | 1. JSON을 @RequestBody로 받음2. 프론트에서 헤더 수동 설정 |
1. @RequestPart로 변경2. 프론트 헤더 설정 코드 삭제 |
| Required part 'request' is not present | FormData의 Key 이름과 @RequestPart("이름")이 다름 |
프론트 append("request", ...)와 백엔드 어노테이션 이름 일치시키기 |
| JSON 데이터가 null로 들어옴 | JSON Part의 Content-Type이 없음 | 프론트에서 new Blob(..., { type: 'application/json' }) 처리 필수 |
| MaxUploadSizeExceededException | 파일 크기가 너무 큼 | application.yml에서 max-file-size 설정 늘리기 |
6. 추가 설정 (application.yml)
파일 업로드 용량이 부족할 경우를 대비해 설정을 추가해 줍니다.
spring:
servlet:
multipart:
max-file-size: 20MB # 파일 하나당 최대 크기
max-request-size: 20MB # 요청 하나당 전체 크기
7. 마치며
"파일 업로드"와 "JSON 전송"은 각각 따로 구현하면 쉽지만, 합치는 순간 HTTP 프로토콜의 Boundary와 Content-Type 개념을 정확히 알아야 해결할 수 있습니다.
핵심 요약
- Backend:
@RequestBody대신@RequestPart를 써라. - Frontend: JSON은
Blob+application/json타입으로 감싸라. - Header: Content-Type 헤더는 브라우저에게 맡겨라 (손대지 마라).
이 패턴은 꼭 익혀두기..
<해당 트러블 슈팅은 프로젝트 wecam 을 진행하다 발생했습니다! 관련 코드는 깃허브를 통해 참고할 수 있습니다.>
'ServerDev > SpringBoot' 카테고리의 다른 글
| [Spring Boot 멀티모듈] domain-common 빌드 실패 원인과 해결 과정 정리 - Lombok, JPA @Entity 인식 안됨, Gradle 의존성 삽질기 (0) | 2025.12.13 |
|---|---|
| [Spring Boot] 사용자/관리자 API 분리를 위한 멀티모듈 설계 실전 가이드 (Common, User, Admin) (0) | 2025.12.13 |
| [JPA] 동시성 제어 : LockModeType.PESSIMISTIC_WRITE (0) | 2025.12.07 |
| [SpringBoot&JPA] QR 코드 기반 게스트 계정 자동 생성 및 로그인 구현 (0) | 2025.12.07 |
| [Spring Boot] 권한 검증 - 커스텀 AOP와 Redis로 해결하기 (@IsCouncil) (0) | 2025.12.06 |