본문 바로가기

Backend/Spring

[multipart/form-data] - MultipartFile과 JSON 함께 받기 (HttpMediaTypeNotSupportedException가 발생하는 이유)

 

 

이슈발생

Content-Type을 'multipart/form-data'로 설정한 HTTP 요청의 응답으로 415 Unsupported Media Type (HttpMediaTypeNotSupportedException)이 발생하였다. '파일'과 '복잡한 정보'를 함께 받아야하는 상황이었어서 "복잡한 정보를 JSON 구조로 파일과 함께 보내면 되지 않을까?"란 단순한 생각으로 한 메서드 설계가 원인이었다. 

 

물론 파일과 정보파라미터들을 일반적인 form 형태로 전달해도 되겠지만 file과 JSON을 동시에 받을 수 없을까?란 의문을 해결하고자 한다. 따라서 스프링 MVC에서 Http Message를 어떻게 처리하는지 간단하게 살펴보고 과연 file과 json 데이터를 동시에 받는 작업은 정말 불가능한 일인지 살펴본다.

 

 

 

원인

 

HttpMediaTypeNotSupportedException - 'HTTP 요청 메시지'처리를 위해 필요한 적절한 컨버터를 찾을 수 없다.

스프링 MVC에서 요청메시지와 응답메시지에서 데이터를 처리하기 위해 사용하는 주요 컨버터는 다음과 같다.

 

 

- ByteArrayHttpMessageConverter : 요청과 응답에서 byte[] 타입의 데이터를 처리하며 관련 미디어타입은 application/octet-stream이다. 

- StringHttpMessageConverter : 요청과 응답에서 String 타입의 데이터를 처리하며 관련 미디어타입은 text/plain이다.

- MappingJackson2HttpMessageConverter : 요청과 응답에서 Java의 객체를 사용하며 관련 미디어타입은 application/json이다.

- FormHttpMessageConverter : Form 데이터를 MultiValueMap<String, String> 로 변환한다.

 

... (이하 생략)

 

 

당연한 이야기지만 파일을 전달하기 위해서 multipart/form-data* 미디어 타입을 사용한다. 이때, @RequestBody가 붙으면 RequestResponseBodyMethodProcessor가 호출되고, @RequestBody가 붙은 파라미터의 타입을 대상으로 content-type에 맞추어 처리할 수 있는 기본 컨버터를 찾지만 적합한 컨버터가 존재하지 않기 때문에 HttpMediaTypeNotSupportedException이 발생한다. 

 

반대로 @RequestBody가 붙지 않은 객체에 대해서는 @ModelAttribute가 생략된 것으로 보고 ModelAttributeMethodProcessor가 호출되며 ModelAttributeMethodProcessor는 단순히 HTTP 요청에 담겨 있는 데이터를 Map 자료구조 형태로 다음 프로세스에 전달한다.  하지만 json string 으로 전달되기 때문에 매핑이 되지않아 객체는 값이 초기화되지 않는다.

 

 

 

[참고]

아래 그림1은 @RequestPart와 @RequestBody가 내부적으로 유사한 로직을 통해 HTTP 메시지를 처리함을 보여준다.

(@RequestPart와 @RequestBody 어노테이션이 붙으면 HTTP 메시지의 Body에서 각 파라미터로 데이터를 매핑하기 위해 각각 RequestPartMethodArgumentResolver.class와 RequestResponseBodyMethodProcessor.class의 resolveArgument(...) 메서드를 호출한다.)  

그림1

 

AbstractMessageConverterMethodArgumentResolver.messageConverters

 

protected <T> Object readWithMessageConverters(HttpInputMessage inputMessage, MethodParameter parameter,
      Type targetType) throws IOException, HttpMediaTypeNotSupportedException, HttpMessageNotReadableException {

   ...
   
   Object body = NO_VALUE;

   EmptyBodyCheckingHttpInputMessage message = null;
   try {
      message = new EmptyBodyCheckingHttpInputMessage(inputMessage);

      // converter 목록을 순회하면서 처리할 수 있는 컨버터 확인
      for (HttpMessageConverter<?> converter : this.messageConverters) {
         Class<HttpMessageConverter<?>> converterType = (Class<HttpMessageConverter<?>>) converter.getClass();
         GenericHttpMessageConverter<?> genericConverter =
               (converter instanceof GenericHttpMessageConverter ghmc ? ghmc : null);
               
         // 컨버터가 body를 읽을 수 있으면 body 초기화
         if (genericConverter != null ? genericConverter.canRead(targetType, contextClass, contentType) :
               (targetClass != null && converter.canRead(targetClass, contentType))) {
            if (message.hasBody()) {
               HttpInputMessage msgToUse =
                     getAdvice().beforeBodyRead(message, parameter, targetType, converterType);
               body = (genericConverter != null ? genericConverter.read(targetType, contextClass, msgToUse) :
                     ((HttpMessageConverter<T>) converter).read(targetClass, msgToUse));
               body = getAdvice().afterBodyRead(body, msgToUse, parameter, targetType, converterType);
            }
            else {
               body = getAdvice().handleEmptyBody(null, message, parameter, targetType, converterType);
            }
            break;
         }
      }
   }
   
   ...

   if (body == NO_VALUE) {
      if (httpMethod == null || !SUPPORTED_METHODS.contains(httpMethod) ||
            (noContentType && !message.hasBody())) {
         return null;
      }
      throw new HttpMediaTypeNotSupportedException(contentType,
            getSupportedMediaTypes(targetClass != null ? targetClass : Object.class), httpMethod);
   }

   ...

   return body;
}

 

위 코드를 살펴보면 @RequestBody를 사용했을 경우 부합하는 컨버터가 존재하지 않아서 body가 null이 되고 결과적으로 에러가 발생한다.

 

 

 

 

해결 방법

 

 

1. JSON 포기(기존방식) - 파라미터형태로 데이터 전송

 

만약 보내는 데이터가 복잡하지 않다면 JSON으로 데이터를 보내는 것을 포기할 수 있다. (일반적인 form 전송)

 

예시1 - 기본

@Data
public class Fruit {
    private String name;
    private String color;
}

 

@PostMapping("/v1")
public Fruit apiV1(@RequestPart MultipartFile multipartFile, @ModelAttribute Fruit fruit) {
    //...
    System.out.println("fruit = " + fruit);
    //...
}

 

 

[postman]

 

 

 

 

 

 

 

2. 특정 객체와 매칭되는 JSON을 위한 커스텀 컨버터 사용하기

 

하지만 위 방식을 사용할 경우 계층관계가 존재하여 복잡한 구조를 위한 데이터를 전달하기 어렵다.  

예) 다른객체가 임베디드된 경우(합성), 정보 객체의 배열

 

 

Stack Overflow와 Spring 공식 문서를 살펴보면 이에대한 해답을 얻을 수 있다.

Spring은 ~.core.convert 패키지에서 '데이터 타입 전환' 시스템에 관한 기능들을 제공하며, 특정 이름을 갖는 문자열을 사용자의 필요에 따라 변경할 수 있는 기능을 제공한다.

 

이를 활용해 객체로 받길 원하는 json 문자열을 변환하는 기능을 추가할 수 있다.

 

 

 

 

Converter 구현하기

아래 코드는 String을 RequestDto로 전환하는 기능을 제공하는 컨버터이다.

...

import org.springframework.core.convert.converter.Converter;
...

@Component
@RequiredArgsConstructor
public class StringToRequestConverterSecond implements Converter<String, RequestDto> {

    private final ObjectMapper objectMapper;

    @Override
    @SneakyThrows
    public RequestDto convert(String source) {
        return objectMapper.readValue(source, RequestDto.class);
    }    
}

 

 

 

예시2 - Converter

 

@Data
public class RequestDto {
    private String city;
    private Fruit fruit;
}

 

@PostMapping("/v2")
public RequestDto apiV2(@RequestPart MultipartFile multipartFile, @ModelAttribute RequestDto requestDto, HttpServletRequest request) {
    //...
    String parameter = request.getParameter("requestDto");
    System.out.println("parameter = " + parameter);
    System.out.println("requestDto = " + requestDto);
    //...
}

 

 

 

 

위 콘솔 로그기록을 보면 converter를 통해 requestDto이름으로 메시지에 담긴 JSON 파라미터값이 자동으로 전환처리되어 초기화됨을 확인할 수 있다.

 

 

 

 

 

 

 

 

 

관련 소스코드

https://github.com/JaewookMun/spring-exercise/tree/main/multi-media/multipartfile-json

 

 

레퍼런스

- 스프링 공식문서

https://docs.spring.io/spring-framework/reference/core/validation/convert.html

 

Spring Type Conversion :: Spring Framework

When you require a sophisticated Converter implementation, consider using the GenericConverter interface. With a more flexible but less strongly typed signature than Converter, a GenericConverter supports converting between multiple source and target types

docs.spring.io

https://docs.spring.io/spring-framework/reference/web/webmvc/mvc-controller/ann-initbinder.html#page-title

 

DataBinder :: Spring Framework

In the context of web applications, data binding involves the binding of HTTP request parameters (that is, form data or query parameters) to properties in a model object and its nested objects. Only public properties following the JavaBeans naming conventi

docs.spring.io

 

- 스택 오버플로우

https://stackoverflow.com/questions/52818107/how-to-send-the-multipart-file-and-json-data-to-spring-boot

 

 

- baeldung

https://www.baeldung.com/spring-httpmessageconverter-rest