[SpringBoot & FormData] JSON + 파일 업로드: HttpMediaTypeNotSupported 에러 해결

2025. 12. 9. 04:30·ServerDev/SpringBoot

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

 

 

 

 

 


 

 

프론트엔드(Next.js)와 백엔드(Spring Boot)를 연동하다 보면 가장 까다로운 구간 중 하나가 바로 "파일 업로드와 일반 데이터를 동시에 보낼 때"입니다.

저는 두가지 방법을 고민했습니다.

 

  1. 두 번 요청: JSON 데이터를 먼저 보내서 ID를 받고, 그 ID로 파일을 별도 업로드한다. (Transaction 관리가 귀찮음)
  2. 한 번 요청: 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)

 

프론트엔드 작업의 핵심은 두 가지입니다.

  1. JSON 데이터를 Blob으로 감싸서 application/json 타입을 명시한다.
  2. 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 개념을 정확히 알아야 해결할 수 있습니다.

 

 

핵심 요약

  1. Backend: @RequestBody 대신 @RequestPart를 써라.
  2. Frontend: JSON은 Blob + application/json 타입으로 감싸라.
  3. 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
'ServerDev/SpringBoot' 카테고리의 다른 글
  • [Spring Boot 멀티모듈] domain-common 빌드 실패 원인과 해결 과정 정리 - Lombok, JPA @Entity 인식 안됨, Gradle 의존성 삽질기
  • [Spring Boot] 사용자/관리자 API 분리를 위한 멀티모듈 설계 실전 가이드 (Common, User, Admin)
  • [JPA] 동시성 제어 : LockModeType.PESSIMISTIC_WRITE
  • [SpringBoot&JPA] QR 코드 기반 게스트 계정 자동 생성 및 로그인 구현
yeseul-kim01
yeseul-kim01
  • yeseul-kim01
    슬 개발일지
    yeseul-kim01
  • 전체
    오늘
    어제
    • 분류 전체보기 (79)
      • 자격증 (1)
        • 정보보안기사 (0)
      • DevOps (17)
        • Docker (6)
        • Kubernetes (1)
        • GitHub Actions (0)
        • AWS (4)
        • Monitoring (1)
        • Nginx (1)
        • GCP (3)
      • ServerDev (34)
        • SpringBoot (13)
        • DJango (5)
        • FastAPI (14)
        • Next (0)
        • Flask (0)
        • Database (2)
      • Algorithm (2)
        • BFS (0)
        • DFS (1)
        • 다익스트라 (0)
      • CS (8)
      • Data Engineering (1)
      • AI&MLOps (2)
      • Architecture (6)
      • Software Engineering (0)
        • Library Packaging (0)
      • Project (5)
        • docx-generator (0)
        • speak-note (2)
        • ms-serving (1)
        • keyshield (2)
      • ProgrammingLanguages (3)
        • Python (1)
  • 블로그 메뉴

    • 홈
    • 태그
    • 방명록
  • 링크

  • 공지사항

  • 인기 글

  • 태그

    grpc
    실시간시스템
    하이브리드아키텍처
    docker
    백엔드
    프로젝트기록-KeyShield
    실무일기-백엔드편
    NLP부트캠프
    KServe
    KeyShield
    MLops
    프로젝트기록-speaknote
    multipartfile
    FastAPI
    Django
    SpeakNote
    아키텍처설계
    실무일기-인프라편
    di
    FastAPI - CORS 마스터
    동시성제어
    depends
    STT
    rag
    비동기처리
    멀티모듈
    SpringBoot
    트러블슈팅
    아키텍처
    Kubernetes
  • 최근 댓글

  • 최근 글

  • hELLO· Designed By정상우.v4.10.5
yeseul-kim01
[SpringBoot & FormData] JSON + 파일 업로드: HttpMediaTypeNotSupported 에러 해결
상단으로

티스토리툴바