[Spring] Swagger + RequestPart๋ฅผ ํตํด ํ์ผ, Dto ๋์ ์์ฒญ ์ ๋ฐ์ ์๋ฌ ํธ๋ค๋ง
ํ์ฌ Spring Boot๋ฅผ ํตํ ๋น๋์ค ์คํธ๋ฆฌ๋ฐ ์๋ฒ๋ฅผ ๊ฐ๋ฐํ๋ ๊ฐ๋จํ ํ๋ก์ ํธ๋ฅผ ์งํ ํด๋ณด๊ณ ์๋๋ฐ, ์ด๋ฅผ ๊ฐ๋ฐํ๋ฉด์ ๋ง์ฃผํ ์๋ฌ์ ๋ํด ์๊ฐํ๊ณ ์ด๋ฅผ ํด๊ฒฐํ ๋ฐฉ์, ๊ทธ๋ฆฌ๊ณ ์ด์งธ์ ํด๊ฒฐ์ด ๋๋์ง ๊น์ง๋ ๋ค๋ค๋ณด๊ณ ์ ํ๋ค.
GitHub - One-armed-boy/spring_stream_video: ๋น๋์ค ์คํธ๋ฆฌ๋ฐ ์๋ฒ with Spring boot
๋น๋์ค ์คํธ๋ฆฌ๋ฐ ์๋ฒ with Spring boot. Contribute to One-armed-boy/spring_stream_video development by creating an account on GitHub.
github.com
๋ฌธ์ ์ํฉ ๋ฌ์ฌ
์๋ํฌ์ธํธ์ ๋ํด ์ง์ ์์ฒญ์ ๋ณด๋ด์ ํ ์คํธ๋ฅผ ํ ๋, ํฌ์คํธ๋งจ์ ์ฌ์ฉํ ์๋ ์๊ฒ ์ง๋ง ๊ทธ๊ฒ๋ณด๋จ ์ค์จ๊ฑฐ(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์ ๋ฐํํ๋ฉฐ ์๋๋ ๊ฒ์ ํ์ธํ ์ ์๋ค!
์ฐธ์กฐ
MIME types (IANA media types) - HTTP | MDN
A media type (also known as a Multipurpose Internet Mail Extensions or MIME type) indicates the nature and format of a document, file, or assortment of bytes. MIME types are defined and standardized in IETF's RFC 6838.
developer.mozilla.org
@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