배경
진행 중인 프로젝트에서 새로운 펫의 정보를 등록하는 경우 펫의 정보와 함께 프로필 이미지를 등록할 수 있도록 하는 요건이 있었습니다.
따라서 POST /pet 요청을 하는 경우 JSON 값과 이미지를 함께 담아서 보내야 했고, 이를 위해 Content-Type을 `multipart/form-data`로 지정해서 request 요청을 받도록 구현했습니다.
또 추가적으로 api 문서화를 위해 swagger에서도 해당 엔드포인트를 테스트할 수 있도록 다음과 같이 구성했습니다.
문제 상황
그런데 이상하게도 postman로 진행할 때는 문제가 없었는데, swagger를 통해 테스트를 진행하면 예외가 발생하면서 펫 등록이 이루어지지 않는 상황이 발생했습니다.
해결을 위해 예외 메시지를 확인해 보니 Content-Type이 `application/octet-stream`으로 인식되어 문제가 되었음을 알 수 있었습니다.
[nio-8080-exec-2] .w.s.m.s.DefaultHandlerExceptionResolver : Resolved [org.springframework.web.HttpMediaTypeNotSupportedException: Content-Type 'application/octet-stream' is not supported]
원인 파악
application/octet-stream은 MIME 타입 중 하나로, 알 수 없는 이진 파일을 의미합니다. 즉 전달하고자 하는 리소스의 유형이 명시되지 않아 인식할 수 없는 리소스인 경우에 해당 타입이 사용됩니다.
그런데 저는 swagger에서 분명히 `multipart/form-data`로 설정해서 보내고 있는데, 어째서 `octet-stream`으로 인식되고 있는 것인지.. 그 점부터 확인이 필요해 보였습니다.
실제로 어떤 값이 날아가는지 개발자 도구에서 확인해 보았는데요. 먼저 아래 데이터에서 두 번째 파트부터 확인해 보면, Content-Type이 `image/png`로 설정되어 있는 것을 볼 수 있습니다.
반면 이상하게 첫 번째 파트에는 Content-Type가 아예 포함되어 있지 않습니다. 어쩌면 이 부분이 문제가 되는 것이 아닐까요?
좀 더 확실하게 확인해 보기 위해, 들어오는 요청의 Content-Type를 `getContentType`메서드를 이용해 직접 확인해 보겠습니다.
Request Content-Type: multipart/form-data; boundary=----WebKitFormBoundary8EWFD6BfCAlZA4Be
data : null
image : image/png
결과를 보면 전체 Content-Type은 `multipart/form-data`이고, 각 파트의 Content-Type은 `null`과 `image/png`입니다. 이를 통해 파트 중 타입이 누락된 부분이 null로 들어가게 되면서 어떤 타입인지 알 수가 없어 타입이 `octet-stream`으로 처리되고 있었다는 것을 알 수 있었습니다.
해결 시도
그렇다면 Content-Type이 누락되지 않도록 하는 것으로 문제를 해결할 수 있을 것이라고 예상. 이를 위해 swagger 자체에서 요청을 생성할 때 첫 번째 파트에 `Content-Type: application/json`를 명시하도록 여러 가지 시도를 해보았습니다. 하지만 계속해서 요청 시 누락이 발생해 결국 해당 방법으로는 해결을 할 수 없었습니다 😢
그래서 다른 방법을 찾아보다가 다음과 같은 해결책을 발견하게 되었는데, 해당 답변에서 제시한 코드를 추가해 보니 예외가 사라지고 정상적으로 펫이 저장되었습니다!
`AbstractJackson2HttpMessageConverter`를 상속한 클래스를 만들어서 사용하라는 내용의 답변인데, 해당 클래스가 어떤 역할을 하는지 직접 코드로 확인을 해 보도록 하겠습니다.
@RequestPart with mixed multipart request, Spring MVC 3.2
I'm developing a RESTful service based on Spring 3.2. I'm facing a problem with a controller handling mixed multipart HTTP request, with a Second part with XMLor JSON formatted data and a second part
stackoverflow.com
코드 분석
먼저 요청이 들어올 때의 흐름을 따라가 보겠습니다. 리퀘스트 메시지의 바디를 POJO로 변환하기 위해 내부적으로 타입을 확인하고 변환하는 과정을 `AbstractMessageConverterMethodArgumentResolver`클래스 내부의 `readWithMessageConverters`메서드를 통해 확인할 수 있습니다.
해당 메서드에서는 먼저 리퀘스트 메시지에서 Content-Type 값을 꺼내 `MediaType` 타입의 변수로 저장하는 과정을 거칩니다. 이때 만일 Content-Type을 알 수 없는 경우에 대한 처리가 아래와 같이 이루어지게 되는데, 바로 이 과정에서 `application/octet-stream`이 세팅되는 것을 확인할 수 있습니다.
MediaType contentType;
boolean noContentType = false;
try {
contentType = inputMessage.getHeaders().getContentType(); // 헤더의 Content-Type 값을 꺼내온다.
}
catch (InvalidMediaTypeException ex) {
throw new HttpMediaTypeNotSupportedException(
ex.getMessage(), getSupportedMediaTypes(targetClass != null ? targetClass : Object.class));
}
if (contentType == null) {
noContentType = true;
contentType = MediaType.APPLICATION_OCTET_STREAM; // 만일 null 이라면 application/octet-stream 세팅
}
이다음으로는 본격적으로 리퀘스트 바디를 컨버터 객체를 이용해 변환하는 로직이 실행되고, 이 과정에서 모든 컨버터들을 순회하며 현재 요청을 변환할 수 있는 컨버터를 찾게 됩니다.
만일 바디의 Content-Type을 변환할 수 있는 컨버터가 없는 경우 결국 바디를 변환하지 못하고 `body`변수가 `NO_VALUE`로 남아있게 되는데, 그렇게 되면 아래 코드처럼 `HttpMediaTypeNotSupportedException`가 발생할 수 있는 분기로 넘어갑니다.
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);
}
즉, 처음 문제가 되었던 application/octet-stream' is not supported 예외가 이곳에서 발생하는 것임을 알 수 있습니다.
해결 방법
결론적으로 `application/octet-stream` 타입을 담당할 컨버터를 따로 추가해 주면 해당 컨버터를 가지고 정상적으로 JSON body를 변환할 수 있기 때문에 이와 같은 예외가 발생하지 않게 됩니다.
이를 위해 스택오버플로우 답변을 참고하여 커스텀 컨버터를 추가해주었습니다. 생성자에 `MediaType.APPLICATION_OCTET_STREAM` 를 전달하여, 해당 컨버터를 `application/octet-stream`를 처리하는 컨버터로 만들었습니다.
@Component
public class MultipartJackson2HttpMessageConverter extends AbstractJackson2HttpMessageConverter {
public MultipartJackson2HttpMessageConverter(ObjectMapper objectMapper) {
super(objectMapper, MediaType.APPLICATION_OCTET_STREAM);
}
@Override
public boolean canWrite(Class<?> clazz, MediaType mediaType) {
return false;
}
@Override
public boolean canWrite(Type type, Class<?> clazz, MediaType mediaType) {
return false;
}
@Override
protected boolean canWrite(MediaType mediaType) {
return false;
}
}
(쓰기와 관련된 메서드들을 false로 설정해 둔 이유는 해당 컨버터가 읽기 과정에서만 작동하도록 하기 위해서입니다.)
이제 `readWithMessageConverters` 메서드에서 `application/octet-stream`를 위한 컨버터를 찾을 때 커스텀 컨버터가 선택되고, 우리의 커스텀 컨버터는 `AbstractJackson2HttpMessageConverter`를 상속받았기 때문에 내부의 `read`메서드를 통해 바디를 변환할 수 있습니다.
(사실 여전히 미디어 타입은 `application/octet-stream` (== null)로 어떤 의미를 전달하지 못하고 있는 값입니다만, 해당 요청을 read하는 과정 내부에서 변환을 수행할 `ObjectMapper`로 `defaultObjectMapper`이 선택되고, 전달받은 스트림의 값이 올바른 JSON 형식이기 때문에 해당 매퍼를 통해 정상적으로 매핑이 진행됩니다. 이 선택과정은 `selectObjectMapper` 메서드를 통해 확인할 수 있습니다.)
정리
HttpMediaTypeNotSupportedException: Content-Type 'application/octet-stream' is not supported 예외가 발생할 수 있는 이유 중 하나는 http request 요청에서 Content-Type 헤더 정보가 누락되어 강제적으로 `application/octet-stream` 타입으로 설정되고, 해당 타입을 처리할 `Converter` 객체가 없기 때문입니다.
해결 방법은
- request 메시지에 Content-Type을 포함하여 전송한다.
- `application/octet-stream`을 처리할 수 있는 커스텀 컨버터를 만든다.
혹은 필터에 로직을 등록하여 Content-Type이 null로 잡히는 경우 다른 타입으로 변경하도록 할 수도 있을 것 같다는 생각도 듭니다.
다만 상황에 따라 커스텀 컨버터나 로직이 문제가 되는 상황이 생길 수 있으므로, 꼭 코드의 실행 맥락을 숙지한 다음 대처할 수 있도록 하는 것이 중요할 것 같습니다.