Published on

Spring Boot 3 415 Unsupported Media Type 해결법

Authors

서버가 415 Unsupported Media Type을 반환한다는 것은 “요청 바디를 읽고 싶긴 한데, 네가 보낸 미디어 타입(Content-Type)은 내가 처리할 수 없다”는 뜻입니다. Spring Boot 3에서는 Jakarta 전환, 기본 의존성 변화, 메시지 컨버터 구성 차이 때문에 이전 버전에서 “우연히 되던 요청”이 415로 바뀌는 경우가 종종 생깁니다.

이 글은 Spring Boot 3 기준으로 415를 재현 가능한 패턴으로 쪼개고, 각 케이스별로 어디를 어떻게 고쳐야 하는지(클라이언트/서버/보안/필터/프록시까지) 실전 체크리스트 형태로 정리합니다.

415가 나는 대표 시나리오 6가지

Spring MVC에서 415는 보통 아래 중 하나로 귀결됩니다.

  1. 클라이언트가 Content-Type을 안 보냈거나 잘못 보냄
  2. 컨트롤러가 @RequestBody를 기대하는데 실제 요청은 폼/쿼리스트링/빈 바디
  3. consumes가 너무 엄격하게 걸려서 매칭 실패
  4. HttpMessageConverter가 없어서 역직렬화 불가(예: Jackson 미포함)
  5. multipart/form-data 업로드인데 파라미터/어노테이션 조합이 틀림
  6. 보안/필터/프록시가 Content-Type 또는 바디를 변형하거나 막음

먼저 가장 중요한 원칙은 이것입니다.

  • JSON을 보낼 때는 Content-Type: application/json이 있어야 합니다.
  • 폼을 보낼 때는 application/x-www-form-urlencoded 또는 multipart/form-data여야 합니다.
  • 컨트롤러의 @RequestBody는 “바디 기반”이고, @RequestParam은 “쿼리/폼 기반”입니다.

빠른 재현: 가장 흔한 415 케이스

케이스 1) JSON을 보내는데 Content-Type이 없음

잘못된 요청

curl -X POST http://localhost:8080/api/users \
  -d '{"name":"kim"}'

위 요청은 -H 'Content-Type: application/json'이 없어서 서버가 text/plain 또는 application/x-www-form-urlencoded로 오해할 수 있고, 컨트롤러가 JSON 바디를 기대하면 415가 날 수 있습니다.

올바른 요청

curl -X POST http://localhost:8080/api/users \
  -H 'Content-Type: application/json' \
  -d '{"name":"kim"}'

케이스 2) 컨트롤러가 consumes를 걸어놨는데 요청이 다름

@RestController
@RequestMapping("/api/users")
class UserController {

  @PostMapping(consumes = "application/json")
  public String create(@RequestBody CreateUserRequest req) {
    return "ok";
  }
}

record CreateUserRequest(String name) {}

이때 클라이언트가 application/json이 아닌 text/plain이나 multipart/form-data로 보내면 매핑 단계에서 바로 415가 납니다. consumes는 “명시적으로 그 타입만 받겠다”는 의미라서 디버깅 시 우선 확인해야 합니다.

원인별 해결법 (체크리스트)

1) JSON 역직렬화 컨버터(Jackson) 누락

Spring Boot 3에서 spring-boot-starter-web을 쓰면 일반적으로 Jackson이 따라옵니다. 하지만 다음 상황에서는 누락될 수 있습니다.

  • spring-boot-starter-web 대신 spring-boot-starter-webflux만 넣었는데 MVC 컨트롤러를 기대함
  • 의존성 정리 과정에서 jackson-databind가 제외됨
  • 멀티모듈에서 api 모듈에 Jackson이 없고 런타임에도 포함되지 않음

확인 방법

  • 애플리케이션 시작 로그에서 MappingJackson2HttpMessageConverter가 등록되는지 확인
  • /actuator/conditions 또는 /actuator/beans로 컨버터 관련 빈 확인(Actuator 사용 시)

해결

Gradle 예시:

dependencies {
  implementation "org.springframework.boot:spring-boot-starter-web"
  // 보통 위 스타터로 충분하지만, 문제가 있으면 명시적으로 추가
  implementation "com.fasterxml.jackson.core:jackson-databind"
}

만약 WebFlux 기반이라면(reactive) MVC 방식과 혼용하지 말고, WebFlux 컨트롤러/코덱 설정으로 접근해야 합니다.

2) @RequestBody와 폼 전송을 혼동함

프론트에서 application/x-www-form-urlencoded 또는 multipart/form-data로 보내는데, 서버가 @RequestBody로 받으면 415 또는 400으로 이어집니다.

폼 전송은 @RequestParam 또는 @ModelAttribute

@PostMapping(path = "/login", consumes = "application/x-www-form-urlencoded")
public String login(
    @RequestParam String username,
    @RequestParam String password
) {
  return "ok";
}

DTO로 받고 싶다면:

record LoginForm(String username, String password) {}

@PostMapping(path = "/login", consumes = "application/x-www-form-urlencoded")
public String login(@ModelAttribute LoginForm form) {
  return "ok";
}

JSON 전송은 @RequestBody

record LoginJson(String username, String password) {}

@PostMapping(path = "/login", consumes = "application/json")
public String login(@RequestBody LoginJson body) {
  return "ok";
}

한 엔드포인트에서 폼과 JSON을 모두 받게 설계하면 클라이언트 구현이 흔들릴 때 415가 자주 납니다. 가능하면 URL을 분리하거나, 최소한 consumes를 명확히 나누는 편이 운영에서 안전합니다.

3) multipart/form-data 업로드에서 @RequestBody를 씀

파일 업로드에서 가장 흔한 실수는 @RequestBody MultipartFile file 같은 형태입니다. multipart/form-data는 파트 단위로 파싱되어야 하므로 @RequestPart 또는 @RequestParam을 써야 합니다.

올바른 예시: 파일 + JSON 메타데이터

record UploadMeta(String title) {}

@PostMapping(path = "/upload", consumes = "multipart/form-data")
public String upload(
    @RequestPart("file") org.springframework.web.multipart.MultipartFile file,
    @RequestPart("meta") UploadMeta meta
) {
  return file.getOriginalFilename() + ":" + meta.title();
}

클라이언트 예시:

curl -X POST http://localhost:8080/upload \
  -H 'Content-Type: multipart/form-data' \
  -F 'file=@./a.png' \
  -F 'meta={"title":"hello"};type=application/json'

만약 meta 파트의 타입이 text/plain으로 들어오면 JSON 컨버터가 적용되지 않아 415/400이 날 수 있습니다. 위처럼 type=application/json을 명시하는 습관이 도움이 됩니다.

4) Content-Type: application/json;charset=UTF-8 때문에 매칭 실패?

과거에는 application/json;charset=UTF-8과 관련된 미묘한 이슈를 겪는 경우가 있었고, 일부 레거시 서버/클라이언트는 이를 다르게 취급했습니다. Spring Boot 3의 최신 스택에서는 일반적으로 application/json의 파라미터로 처리되지만,

  • 컨트롤러에 consumes = "application/json"을 걸어두고
  • 프록시/게이트웨이가 헤더를 변형하거나
  • 커스텀 컨버터/필터에서 MediaType 비교를 문자열로 해버리면

문제가 생길 수 있습니다.

해결 가이드

  • MediaType 비교는 문자열 비교가 아니라 MediaType 객체 기반으로 처리
  • 게이트웨이/프록시에서 Content-Type을 임의로 덮어쓰지 않기
  • 가능하면 클라이언트에서 application/json으로 통일

5) Spring Security / 필터가 요청을 선처리하면서 바디를 소모

415 자체는 미디어 타입 문제지만, 실전에서는 “필터에서 바디를 읽어버려서 컨트롤러에서 바디가 비어 보이는” 문제와 함께 나타나기도 합니다. 특히 로깅 필터에서 request.getInputStream()을 읽고 다시 감싸지 않으면, 이후 컨버터가 읽을 바디가 없어져 예외가 꼬일 수 있습니다.

해결: ContentCachingRequestWrapper 사용

import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import org.springframework.web.filter.OncePerRequestFilter;
import org.springframework.web.util.ContentCachingRequestWrapper;

import java.io.IOException;

public class RequestBodyCachingFilter extends OncePerRequestFilter {
  @Override
  protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain)
      throws ServletException, IOException {

    ContentCachingRequestWrapper wrapped = new ContentCachingRequestWrapper(request);
    filterChain.doFilter(wrapped, response);

    // 여기서 wrapped.getContentAsByteArray()로 로깅
  }
}

보안 관련 이슈를 함께 겪고 있다면, 인증 실패/인가 실패가 401로 보이는지, 혹은 미디어 타입 단계에서 415로 막히는지 경계를 분리해서 봐야 합니다. OAuth2/JWT 환경에서의 401 디버깅은 별도로 정리한 글도 참고하세요: Spring Security OAuth2 401 - JWKS 캐시·kid 불일치 해결

6) 클라이언트(특히 fetch/axios)에서 헤더를 잘못 설정

fetch에서 JSON 전송 기본형

await fetch("/api/users", {
  method: "POST",
  headers: {
    "Content-Type": "application/json",
    "Accept": "application/json",
  },
  body: JSON.stringify({ name: "kim" }),
});

FormData 전송 시 Content-Type을 수동으로 넣지 말기

브라우저는 FormData를 보내면 boundary를 포함한 multipart/form-data를 자동으로 만들어야 합니다. 이때 아래처럼 수동으로 Content-Type을 박아버리면 boundary가 빠져 서버가 파싱을 못하고 415/400이 날 수 있습니다.

// 나쁜 예: boundary 없는 Content-Type을 강제로 지정
const fd = new FormData();
fd.append("file", file);

await fetch("/api/upload", {
  method: "POST",
  headers: {
    "Content-Type": "multipart/form-data",
  },
  body: fd,
});

올바른 예:

const fd = new FormData();
fd.append("file", file);
fd.append("meta", new Blob([JSON.stringify({ title: "hello" })], { type: "application/json" }));

await fetch("/api/upload", {
  method: "POST",
  body: fd,
});

Spring Boot 3에서 415를 더 빨리 잡는 로깅/디버깅 팁

1) 스프링 MVC 매핑/컨버터 로그를 올리기

application.yml 예시:

logging:
  level:
    org.springframework.web: DEBUG
    org.springframework.web.servlet.mvc.method.annotation: DEBUG
    org.springframework.http.converter: DEBUG

이렇게 하면 “어떤 핸들러가 매칭되려다 실패했는지”, “어떤 컨버터를 시도했는지” 힌트를 더 빨리 얻습니다.

2) 에러 응답 바디(ProblemDetail) 확인

Spring Boot 3는 기본적으로 RFC 7807 스타일의 ProblemDetail을 쓰는 경우가 많습니다. 클라이언트에서 상태 코드만 보지 말고, 응답 바디의 detail을 반드시 확인하세요. 운영 환경에서는 API 게이트웨이나 프록시가 바디를 잘라먹지 않는지도 점검해야 합니다.

프록시 뒤에서 요청/응답이 변형되는 문제는 415뿐 아니라 502/504 같은 증상으로도 나타납니다. 네트워크 경계까지 포함해 빠르게 진단하는 접근은 다음 글의 “10분 진단” 흐름이 참고가 됩니다: EKS Pod→RDS 504 타임아웃 - SG·NACL·NAT 10분 진단

실전 해결 순서(운영 체크리스트)

아래 순서로 보면 대부분의 415를 짧게 끝낼 수 있습니다.

  1. 실제 요청의 Content-Type 확인
    • 브라우저 DevTools Network 탭, 혹은 서버 접근 로그에서 헤더 확인
  2. 컨트롤러 시그니처 확인
    • @RequestBody인지 @RequestParam인지
    • consumes/produces가 걸려 있는지
  3. 클라이언트가 보내는 바디 형태 확인
    • JSON이면 JSON.stringify가 맞는지
    • FormData면 Content-Type을 수동 지정하지 않았는지
  4. 메시지 컨버터(Jackson) 존재 확인
    • 의존성/스타터/클래스패스 확인
  5. 필터/보안에서 바디를 읽어버리는지 확인
    • 로깅 필터, 서명 검증 필터, 커스텀 인증 필터 점검
  6. 프록시/게이트웨이에서 헤더를 바꾸는지 확인
    • Content-Type 덮어쓰기, 바디 압축/변환, 크기 제한 등

예외 처리로 원인 메시지 더 친절하게 만들기

415는 클라이언트 실수인 경우가 많아서, API 사용성을 위해 메시지를 명확히 주는 것이 좋습니다.

import org.springframework.http.HttpStatus;
import org.springframework.http.ProblemDetail;
import org.springframework.web.HttpMediaTypeNotSupportedException;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice;

@RestControllerAdvice
class GlobalExceptionHandler {

  @ExceptionHandler(HttpMediaTypeNotSupportedException.class)
  ProblemDetail handle415(HttpMediaTypeNotSupportedException e) {
    ProblemDetail pd = ProblemDetail.forStatus(HttpStatus.UNSUPPORTED_MEDIA_TYPE);
    pd.setTitle("Unsupported Media Type");
    pd.setDetail("요청 Content-Type이 API가 지원하는 타입과 다릅니다. JSON 요청이면 Content-Type을 application/json으로 설정하세요.");
    return pd;
  }
}

이렇게 하면 프론트/외부 연동에서 “왜 415인지” 커뮤니케이션 비용이 크게 줄어듭니다.

마무리

Spring Boot 3에서의 415는 대체로 “요청 헤더의 Content-Type과 컨트롤러가 기대하는 바디 타입의 불일치”로 정리됩니다. 다만 운영에서는 보안 필터, 로깅 필터, 프록시/게이트웨이까지 얽혀 원인이 흐려지므로, 이 글의 체크리스트 순서대로 경계를 좁히는 방식이 가장 빠릅니다.

추가로, API 성능/안정성 이슈를 함께 다루는 과정에서 JPA 레이어 문제까지 같이 정리해야 한다면 다음 글도 같이 보면 좋습니다: Spring Boot 3에서 JPA N+1 실전 제거법