2024. 3. 10. 18:57ใBackend/Spring
ํ์ฌ Spring Boot๋ฅผ ํตํ ๋น๋์ค ์คํธ๋ฆฌ๋ฐ ์๋ฒ๋ฅผ ๊ฐ๋ฐํ๋ ๊ฐ๋จํ ํ๋ก์ ํธ๋ฅผ ์งํ ํด๋ณด๊ณ ์๋๋ฐ, ์ด๋ฅผ ๊ฐ๋ฐํ๋ฉด์ ๋ง์ฃผํ ์๋ฌ์ ๋ํด ์๊ฐํ๊ณ ์ด๋ฅผ ํด๊ฒฐํ ๋ฐฉ์, ๊ทธ๋ฆฌ๊ณ ์ด์งธ์ ํด๊ฒฐ์ด ๋๋์ง ๊น์ง๋ ๋ค๋ค๋ณด๊ณ ์ ํ๋ค.
๋ฌธ์ ์ํฉ ๋ฌ์ฌ
์๋ํฌ์ธํธ์ ๋ํด ์ง์ ์์ฒญ์ ๋ณด๋ด์ ํ ์คํธ๋ฅผ ํ ๋, ํฌ์คํธ๋งจ์ ์ฌ์ฉํ ์๋ ์๊ฒ ์ง๋ง ๊ทธ๊ฒ๋ณด๋จ ์ค์จ๊ฑฐ(OpenAPI)๋ฅผ ํ์ฉํ๋ ํธ์ด ์ดํ ํ์ ๋จ๊ณ์์ ๋ ์ข๋ค๊ณ ํ๋จํ๊ณ ์๋ฒ ๋ด Swagger EP๋ฅผ ๋ซ์ด๋ ์ํ์๋ค.
๊ทธ ํ ๊ฐ ์๋ํฌ์ธํธ์ ๋ํด ํ ์คํธ๋ฅผ ์งํํ๊ณ ์์๋๋ฐ.... ์๋ ์๋ํฌ์ธํธ๋ฅผ ํ ์คํธํ ๋ ๋ฌธ์ ๊ฐ ๋ฐ์ํ๋ค.
"Content-Type 'application/octet-stream' is not supported"๋ผ๊ณ ์์ธ๊ฐ ๋ฐ์ํ๋ค. ์ฆ, application/octet-stream ํ์์ผ๋ก ์์ฒญ์ ๊ฑด๋ค ๋ฐ์์ผ๋ฉฐ ์๋ฒ ์ธก์์๋ ์ด ํ์์ ์ง์ํ์ง ์๊ธฐ ๋๋ฌธ์ ๋ฐ์ํ๋ ์์ธ์ธ ๊ฒ์ด๋ค.
๋ฌธ์ ์์ธ ๋ถ์
์ฐ์ ์๋๋ ๋ฌธ์ ๊ฐ ๋ ์๋ํฌ์ธํธ์ ์ปจํธ๋กค๋ฌ ๋ฉ์๋์ ์ค์ ์ฝ๋์ด๋ค.
@PostMapping(path = "/videos/upload", consumes = "multipart/form-data")
public ResponseEntity uploadVideo(@Valid @RequestPart(value = "videoMetadata") UploadVideoRequest request,
@RequestPart(value = "videoFile") MultipartFile videoFile) {
var member = securityContextHelper.getMember();
uploadFacade.uploadVideoSync(UploadVideoCommand.builder()
.fileName(request.getFileName())
.extension(request.getExtension())
.description(request.getDescription())
.member(member)
.build(), videoFile);
return ResponseEntity.ok().build();
}
์ด๋ ธํ ์ด์ ์ ๋ณด๋ฉด ์ ์ ์์ง๋ง Content-Type์ด 'multipart/form-data'๋ก ๋ช ์๋์ด ์์ผ๋ฉฐ, Swagger์๋ ์ด ํ์ ์ ์ ์์ ์ผ๋ก ๋ฐ์๋์ด ์๊ธฐ ๋๋ฌธ์ ์ ์ด์ ์์ธ ๋ฉ์์ง์ 'appilication/octet-stream'์ด ์ด๋์์ ๋ฑ์ฅํ๋์ง๋ถํฐ ํ์ธ์ด ํ์ํ๋ค.
application/octet-stream?
์ด๋ฅผ ์๊ธฐ ์ํด์๋ MIME์ ๋ํ ์ฌ์ ์ง์์ด ์กฐ๊ธ ํ์ํ๋ค.
MIME์ด๋ Multipurpose Internet Mail Extensions์ ์ฝ์๋ก ์์ ์ด๋ ์ฌ์ง, ๋น๋์ค ๋ฑ์ ์ด์ง ํ์ผ์ ๋คํธ์ํฌ๋ฅผ ํตํด ์ ์กํ ๋ชฉ์ ์ผ๋ก ํ ์คํธ๋ก ๋ณํํ๊ณ , ์ด๋ฅผ ์์ ํ๋ ์ ์ฅ์์ ๋ค์ ํ์ผํํ ๋ ์ฌ์ฉํด์ผ ํ ๊ธฐ์ค์ ์ ์ํ๊ธฐ ์ํ ๋ชฉ์ ์ผ๋ก ์ฌ์ฉ๋๋ค.
์ด ์ค ์ด๋ฒ์ ์ง๋ฉดํ ๋ฌธ์ ์ ๋ฑ์ฅํ๋ ๋ ํ์ ์ ๋ํด์๋ง ์ถ๊ฐ์ ์ผ๋ก ์์๋ณด์.
๋จผ์ multipart/form-data ํ์ ์ด๋ค. ์ด๋ ์์ฒญ ๋ฐ๋์ ๋ด๊ธฐ๋ ๋ด์ฉ์ ์ข ๋ฅ๊ฐ ํ๋๊ฐ ์๋๋ผ์ ๊ฐ ๊ตฌ์ฑ์์๋ง๋ค ๋ฐ๋ก MIME ํ์ ์ ์ง์ ํด์ผํ๋ ์ํฉ์์ ์ฌ์ฉํ๋ค.
๋ค์์ application/octet-stream ํ์ ์ด๋ค. ์ด๋ ๋ชจ๋ ์ด์ง ํ์ผ๋ค์ ์ ๋ค๋ฆญํ ํ์ ์ ์๋ฏธํ๋๋ฐ, ๋ง์ด ์ข์ ์ ๋ค๋ฆญ์ด์ง ๋ฐ์ง๊ณ ๋ณด๋ฉด ์ด๋ค ๋ฐฉ๋ฒ์ ์ฌ์ฉํ์ฌ ํ ์คํธ๋ฅผ ํด์ํด์ผํ ์ง ๋ชจ๋ฅผ ๋ ์ฌ์ฉํ๋ ๋ฐฉ๋ฒ์ด๋ค. (Typescript์ any ํ์ ๋๋)
mulitpart/form-data -> application/octet-stream ๋ณํ ์์ธ
๊ทธ๋ ๋ค๋ฉด ์ ์๋ฒ ์ธก์์๋ ์ค์ ํด์ฃผ์ง๋ ์์ ํ์ ์ธ application/octet-stream์ผ๋ก ์ธ์ํ๊ณ ์์๊น? ์ด๋ฅผ ์ํด ํํฐ ์ฒด์ธ์ OncePerFilter๋ฅผ ํ๋ ์ถ๊ฐํ๊ณ , ์์ฒญ ๋ด์ ๋ชจ๋ Content-Type์ ํ์ธํ ์ ์๋๋ก ๊ฐ๋จํ ๋ก์ง์ ์์ฑํด์ฃผ์๋ค.
log.info(request.getContentType());
for (var part : request.getParts()) {
log.info(part.getName());
log.info(part.getContentType());
}
๊ทธ ํ ๋์ผํ ๋ฌธ์ ์ EP๋ฅผ ํธ์ถํด๋ณด์๋๋ฐ ๊ฒฐ๊ณผ๋ ์๋์ ๊ฐ๋ค.
๋ชจ๋ ์๋๋๋ก Content-Type์ด ๋ค์ด๊ฐ์์ง๋ง ๋จ ํ๋ videoMetadata ์์ฑ์ ํ์ ์ด null๋ก ๋์ด์์๋ค...!
null์ด๋ผ์ ๋ฌธ์ ์์ง๊ฐ ์๋ ๊ฒ์ ์๊ฒ ๋๋ฐ ์ ํํ application/octet-stream ํ์ ์ธ ๊ฒ์ผ๊น?
์ด๋ฅผ ์ํด ๋ฌธ์ ๊ฐ ๋ ๋งํ ์คํ๋ง ๋ด๋ถ ๊ตฌํ์ ์ข ์ฐพ์๋ดค๋๋ฐ, ์๋์ ๋ก์ง์ ํ์ธํ ์ ์์๋ค.
// class AbstractMessageConverterMethodArgumentResolver ๋ด๋ถ ๊ตฌํ
protected <T> Object readWithMessageConverters(HttpInputMessage inputMessage, MethodParameter parameter,
Type targetType) throws IOException, HttpMediaTypeNotSupportedException, HttpMessageNotReadableException {
Class<?> contextClass = parameter.getContainingClass();
Class<T> targetClass = (targetType instanceof Class clazz ? clazz : null);
if (targetClass == null) {
ResolvableType resolvableType = ResolvableType.forMethodParameter(parameter);
targetClass = (Class<T>) resolvableType.resolve();
}
MediaType contentType;
boolean noContentType = false;
try {
contentType = inputMessage.getHeaders().getContentType();
}
catch (InvalidMediaTypeException ex) {
throw new HttpMediaTypeNotSupportedException(
ex.getMessage(), getSupportedMediaTypes(targetClass != null ? targetClass : Object.class));
}
if (contentType == null) {
noContentType = true;
contentType = MediaType.APPLICATION_OCTET_STREAM;
}
...
}
์ ์ฝ๋๋ Http Request, Response๋ฅผ ์๋ฐ ํด๋์ค๋ก ๋ง๋ค์ด์ฃผ๋ HttpMessageConverter์ ์ถ์ ๊ตฌํ์ฒด์ธ AbstractMessageConverterMethodArgumentResolver์ readWithMessageConverter ๋ฉ์๋์ ์ผ๋ถ์ด๋ค. ํด๋น ์ฝ๋์ ์๋์ชฝ์ ๋ณด๋ฉด ํ์ธํ ์ ์๋ฏ, contentType์ ๊ฐ์ด null์ธ ๊ฒฝ์ฐ์ ๊ธฐ๋ณธ์ ์ผ๋ก application/octet-stream ํ์ ์ผ๋ก ์ง์ ํด๋ฒ๋ฆฌ๋ ๊ฒ์ ์ ์ ์๋ค. ์ด๋ก ์ธํด ์๋ฒ ์ธก ์์ธ ๋ฉ์์ง์๋ ํด๋น ํ์ ์ด ๋ฑ์ฅํ๋ ๊ฒ์ด๋ค.
์ด๋๋ฅผ ์์ ํด์ผ ํ ๊น?
๊ฒฐ๊ตญ Content-Type์ด Null์ผ ๋ ์ ์ ๋ก application/octet-stream์ผ๋ก ํ ๋น๋๋ ๊ฒ์ ์ดํด๋ฅผ ํ๋๋ฐ, ์ด ๋ฌธ์ ๋ฅผ ์ํด์๋ ์ด๋๋ฅผ ์์ ํด์ผํ ๊น? ์ด๋ฅผ ์ํด ์๋ฒ ์ธก์ ์์ธ๊ฐ ๋ฐ์ํ ์ฝ์คํ์ ์ถ์ ํ์๊ณ ์ด๋ฒ์๋ ์์์ ์๊ธฐํ AbstractMessageConverterMethodArgumentResolver.readWithMessageConverter ๋ฉ์๋๊ฐ ๋ฌธ์ ๊ฐ ๋๊ณ ์์์ ์ ์ ์์๋ค. ์๋๋ ๋ฌธ์ ๊ฐ ๋๋ ๋ก์ง์ด๋ค. (๋๋ฌด ๊ธฐ๋๊น ๊ฑด๋ ๋ฐ์๋ฉด ์์ฝ ์์)
// AbstractMessageConverterMethodArgumentResolver.readWithMessageConverter ๋ฉ์๋ ๋ด ์ผ๋ถ ๋ก์ง
EmptyBodyCheckingHttpInputMessage message = null;
try {
message = new EmptyBodyCheckingHttpInputMessage(inputMessage);
for (HttpMessageConverter<?> converter : this.messageConverters) {
Class<HttpMessageConverter<?>> converterType = (Class<HttpMessageConverter<?>>) converter.getClass();
GenericHttpMessageConverter<?> genericConverter =
(converter instanceof GenericHttpMessageConverter ghmc ? ghmc : null);
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 && noContentType && !message.hasBody()) {
body = getAdvice().handleEmptyBody(
null, message, parameter, targetType, NoContentTypeHttpMessageConverter.class);
}
}
catch (IOException ex) {
throw new HttpMessageNotReadableException("I/O error while reading input message", ex, inputMessage);
}
finally {
if (message != null && message.hasBody()) {
closeStreamIfNecessary(message.getBody());
}
}
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);
}
์ฝ๋๊ฐ ๋๋ฌด ๋ง์ผ๋ ๊ฐ๋ตํ ์์ฝํ๋ฉด ์๋์ ๊ฐ๋ค.
- this.messageConverters์์ ํน์ Content-Type์ ์ฒ๋ฆฌํ ์ ์๋ ์ปจ๋ฒํฐ๋ฅผ ์ฐพ์์ ์ฒ๋ฆฌ๋ฅผ ์์ํ๋ค.
- ๋ง์ฝ body์ ๋ด์ฉ์ด ์กด์ฌํ๋ ์ ์ ํ ์ปจ๋ฒํฐ๋ฅผ ์ฐพ์ง ๋ชปํ๋ ๊ฒฝ์ฐ, "Content-Type ~ is not supported" ์์ธ๋ฅผ ๋ฐ์์ํจ๋ค.
๋ฐ๋ผ์ ์ด ๋ฌธ์ ๋ฅผ ํด๊ฒฐํ๊ธฐ ์ํด์๋ application/octet-stream ํ์ ์ ์ฒ๋ฆฌํ ์ ์๋ ๋ฉ์์ง ์ปจ๋ฒํฐ๋ฅผ Spring ์ปจํ ์คํธ์ ์ถ๊ฐํด์ผ ํจ์ ์ ์ ์๋ค.
๋ฌธ์ ํด๊ฒฐ
์์์ ๋ถ์ํ ๋ฌธ์ ์ ๋ฐฐ๊ฒฝ์ ํตํด, ๊ฒฐ๊ตญ ๋ฌธ์ ๋ฅผ ํด๊ฒฐํ๊ธฐ ์ํด์๋ application/octet-stream์ ๋ค๋ค์ฃผ๋ HttpMessageConverter๋ฅผ ์ถ๊ฐํ๋ฉด ๋๋ค๋ ๊ฒ์ ์์๋ค. ์ด๋ฅผ ์ํด, ๋ค์ ๋ ํด๋์ค๋ฅผ ํ๋ก์ ํธ์ ์ถ๊ฐํด์ฃผ์๋ค.
@Configuration
public class WebConfig implements WebMvcConfigurer {
private OctetStreamReadMsgConverter octetStreamReadMsgConverter;
@Autowired
public WebConfig(OctetStreamReadMsgConverter octetStreamReadMsgConverter) {
this.octetStreamReadMsgConverter = octetStreamReadMsgConverter;
}
@Override
public void configureMessageConverters(List<HttpMessageConverter<?>> converters) {
converters.add(octetStreamReadMsgConverter);
}
}
@Component
public class OctetStreamReadMsgConverter extends AbstractJackson2HttpMessageConverter {
@Autowired
public OctetStreamReadMsgConverter(ObjectMapper objectMapper) {
super(objectMapper, MediaType.APPLICATION_OCTET_STREAM);
}
// ๊ธฐ์กด application/octet-stream ํ์
์ ์ฐ๊ธฐ๋ก ๋ค๋ฃจ๋ ๋ฉ์์ง ์ปจ๋ฒํฐ๊ฐ ์ด๋ฏธ ์กด์ฌ (ByteArrayHttpMessageConverter)
// ๋ฐ๋ผ์ ํด๋น ์ปจ๋ฒํฐ๋ ์ฐ๊ธฐ ์์
์๋ ์ด์ฉํ๋ฉด ์๋จ
@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;
}
}
๊ฐ ํ์ผ์ ๋ํ ์ค๋ช ์ ์๋์ ๊ฐ๋ค.
WebConfig
Spring์ด ๊ธฐ๋ณธ์ ์ผ๋ก ์์ฑํ๋ HttpMessageConverter ๋ฆฌ์คํธ์ ์ถ๊ฐ๋ก ์์ฑํด ์ค ์ปค์คํ ๋ฉ์์ง ์ปจ๋ฒํฐ๋ฅผ ์ถ๊ฐํด์ฃผ๊ธฐ ์ํ ์ค์ ํ์ผ. ์ด ์ค์ ์ ํตํด, ์๋ก ์ถ๊ฐํ ๋ฉ์์ง ์ปจ๋ฒํฐ๊ฐ Http ๋ฉ์์ง๋ฅผ ๋ณํํ๋ ๊ณผ์ ์์ ์ฐธ์ฌํ ์ ์์ด์ง.
OctetStreamReadMsgConverter
application/octet-stream ํ์ ์ Http ๋ฉ์์ง์ ๋ํ ์ฝ๊ธฐ ์์ (Http ๋ฉ์์ง -> Java class)์ ์ํํ ์ปค์คํ ๋ฉ์์ง ์ปจ๋ฒํฐ. ์ด ๋ AbstractJackson2HttpMessageConverter๋ฅผ ์์ํด์ค ์ด์ ๋ ์ด๋ฅผ ํตํ๋ฉด Jackson ๋ผ์ด๋ธ๋ฌ๋ฆฌ ๊ธฐ๋ฐ์ผ๋ก ๋ฌธ์์ด ํํ์ json์ ํด๋์ค๋ก ๋ณํํด์ค ์ ์๋๋ฐ, ํ์ฌ ์๋ฒ ์ธก์์ ๋ฌธ์ ๊ฐ ๋๋ ๊ฒฝ์ฐ๋ Multipart/form-data ์ดํ์ application/json ํ์ ์ด ๋๋ฝ๋๋ ๊ฒ์ด๊ธฐ ๋๋ฌธ.
๊ฒฐ๊ณผ
๋ฌธ์ ์ EP๋ฅผ ๋ค์ ํธ์ถํด๋ณด๋ฉด ์๋์ฒ๋ผ 200์ ๋ฐํํ๋ฉฐ ์๋๋ ๊ฒ์ ํ์ธํ ์ ์๋ค!
์ฐธ์กฐ