주니어 개발자 성장기

Swagger(스웨거) multipart/form-data에서 try out이 불가능한 문제 "Content-Type 'application/octet-stream' is not supported" 본문

Spring/프로젝트 pin

Swagger(스웨거) multipart/form-data에서 try out이 불가능한 문제 "Content-Type 'application/octet-stream' is not supported"

Junpyo Lee 2024. 4. 17. 01:47

개요

평소 API 문서 자동화 프레임워크로 `Swagger`를 많이 써왔는데 `multipart/form-data`를 Body로 받는 API의 경우 `Swagger` 문서에서 try out(실행)할 시에 예외(HttpMediaTypeNotSupportedException)가 발생하는 문제가 있었다. 내가 본격적으로 개발을 시작한 2022년부터 꽤 거슬렸다. 그렇지만 문제가 치명적인 것도 아니고 `Swagger`상에서만 실행이 안되는 것이라 적극적으로 해결할 생각은 안하다가, 이번년도 초에 집중해서 구글링하면서 해법을 찾아서 해결할 수 있었다. 

 미리 문제상황을 말하자면 파일과 JSON 값을 동시에 입력받으려고 할 때 발생하는 문제다.

 

Spring Fox와 Spring Doc

 먼저 `Swagger`는 Open Api Specification을 위한 프로젝트다. 그리고 `Swagger-ui`가 바로 문서를 렌더링하는 주요 프레임워크라고 할 수 있다. `Swagger-ui`는 HTML, CSS, JavaScript로 구성되어 있고 API 명세에 관한 JSON파일을 전달 받아 우리가 일반적으로 보는 Swagger 문서를 만들어 주는 메커니즘으로 동작한다. 그리고 Spring Fox와 Spring Doc는 ①`Swagger-ui`를 서빙하고 ②소스코드를 분석해 API 명세를 Json 파일로 만들어주는 역할을 한다.

 

 작년에 UMC에서 활동을 하면서 Spring Fox를 쓰는 사람들을 많이 봤었다. 하지만 나는 Spring Fox는 2020년 이후로 업데이트가 없어서 프로젝트를 새로 시작한다면 Spring Boot 버전과의 호환성을 고려해 Spring Doc을 사용하기를 강력 추천한다. 내 기억으로는 Spring initializer의 디펜던시 목록에서 Spring Doc을 찾을수가 있었던거 같은데 왠지는 모르지만 지금은 존재하지 않아서 따로 build.gradle에 추가해줘야 한다.

(Doc으로 검색하니 Spring REST Docs가 나왔다. Asciidoctor라는 마크업 언어를 사용한다고 한다. 이 라이브러리가 어떤건지는 나중에 알아보기로 하자.)

 

implementation 'org.springdoc:springdoc-openapi-starter-webmvc-ui:2.3.0'

이 한 줄만 추가하면 끝이다. 만약 기존에 Spring Fox가 있다면 지워주자. 참고로 어노테이션 사용법이 약간 달라지는 부분도 있을텐데 Spring Doc 공식문서에 마이그레이션 방법이 있으니 참고하자. 추가로 Spring Doc 버전 정보도 필요하신 분들은 참고하시길

 

 

기존 코드

public class TestController {

	private final MockFileUploadService mockFileUploadService;

	@PostMapping(value = "/file", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
	public Test.Response uploadFile(
		@RequestPart("files") @NotEmpty List<MultipartFile> files,
		@RequestPart("request") @Valid Test.Request request
	) {
		mockFileUploadService.upload(files, request);
		return new Test.Response("success");
	}

}

테스트로 작성해본 컨트롤러이다. 우선 `multipart/form-data`가 `Content-Type`이다. 대충 어떤 상황을 가정한 것이냐면.. 파일을 업로드하는 동시에 부가 정보를 DTO로 입력받는 상황이다.

 

위의 코드를 기반으로 서버를 실행시킬 때의 `Swagger`의 모습은 다음과 같다.

사진 1 - 멀쩡한 외관

외견상으로는 아무 문제가 없어 보이지만 사실 큰 문제가 있다.

 

 

사진 2 - 요청 실패

`Swagger`상에서 API를 실행해볼 경우 아래와 같이 "Content-Type 'application/octet-stream' is not supported"라는 메시지와 함께 요청에 실패한다.

 

원인

`Cotent-Type`이 `multipart/form-data`이라면 `boundary`를 기준으로 HTTP Body가 분할되어 인식된다. 아래 raw한 http message를 확인해보자.

출처: https://loco-motive.tistory.com/9

여기서 주목해야할 것은 `boundary` 안쪽의 `Content-Type`이다. 즉, `multipart/form-data`는 각 Part안에 헤더를 개별적으로 또 가질 수 있는 것이다. 그래서 2번 사진의 요청을 크롬 개발자 도구를 통해 확인하면 다음과 같은 모습을 볼 수 있다.

사진 3 - 그림 2의 요청에 쓰인 HTTP body

 우리가 `request` Part를 JSON 형태로 보냈는데 그림 3의 `request` 영역에는 `Content-Type`이 설정되어 있지 않다. 그래서 스프링 서버가 `request` Part의 타입을 `application/octet-stream`으로 인식해서 예외가 발생한 것이다.

 결국 해결 방법은 `Content-Type`을 `application/json`으로 설정해 저 안에 넣으면 되는 것이다.

 

해결 방법

사실 포스트맨에서는 간편하게 `Content-Type`설정이 가능하다. 실제로 나는 스웨거에서 테스트를 못했었을 때는 포스트맨을 다음과 같이 활용해서 API가 잘 동작하는지 확인했었다.

사진 4 - 포스트맨 사용 예시

우측 끝에 각 Part마다 `Content-Type`의 설정이 가능하다.

 

 우선 오해하면 안되는 것은 해당 API는 정상적으로 동작하는 API다. 백은 포스트맨으로 테스트하면 되고 프론트도 요청 보낼때 각 Part의 데이터 타입을 설정할 수 있기 때문이다. 하지만 포스트맨에서 별도로 테스트해야 하기 때문에 은근 작업에 불편을 초래한다고 생각했다.(사실 나부터 귀찮았다.)

 

 어쨌든 해결하기로 마음을 먹고나서 어떻게 해결할지 방법을 일주일 동안 계속 찾았다. 그러다가 알 필요가 없는 것들도 알아냈다.

 

 위에서 말한 것처럼 `Swagger`는 json 혹은 yml 파일로 API를 이쁘게 자동 명세해주는 프레임워크다. 그리고 Spring Doc이 JSON 파일을 만들어 주는 것이다. 구체적으로 지금 상황에 대입하자면, http://localhost:8080/v3/api-docs 로 접속하면 JSON 파일이 나오는데, 이것이 Spring Doc이 소스코드를 분석해 만든 JSON 파일이고 `Swagger`가 이 파일을 문서화시켜주는 것이다.

 그리고 어찌저찌 구글링을 하다가 `encoding`이라는 속성이 있고 이것을 이용해서 실행시에 각 Part의 `Content-Type` 설정이 가능하다는 것을 알았다. 처음에는 static하게 JSON 파일에 `encoding` 속성을 넣고 서빙해 봤으나 개발을 함과 동시에 수백줄의 json을 수동으로 편집하는 것은 엄청나게 불편하고 시간낭비였다. 그러다 방법을 찾았는데 `@RequestBody`라는 어노테이션을 사용하는 것이다.

 

import io.swagger.v3.oas.annotations.parameters.RequestBody;
// 유의!! Spring Web의 @RequestBody가 아님!!!

public class TestController {

	private final MockFileUploadService mockFileUploadService;

	@RequestBody(content = @Content(
		encoding = @Encoding(name = "request", contentType = MediaType.APPLICATION_JSON_VALUE)))
	@PostMapping(value = "/file", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
	public Test.Response uploadFile(
		@RequestPart("files") @NotEmpty List<MultipartFile> files,
		@RequestPart("request") @Valid Test.Request request
	) {
		mockFileUploadService.upload(files, request);
		return new Test.Response("success");
	}
}

 

위 코드와 같이 `@RequestBody` 어노테이션을 이용해 각 Part의 `Content-Type`을 개별적으로 지정할 수 있다. 주의할 점은 Spring Web의 RequestBody와 네이밍이 동일해서 컨트롤러의 다른 곳에서 @ReqeustBody를 사용하면 상당히 곤란해진다.(왜인지는 자세한 설명은 생략한다.) 그래서 `@SwaggerBody`라는 커스텀 어노테이션을 만들었다.

 

// SwaggerBody.class
@Target({ElementType.METHOD, ElementType.PARAMETER, ElementType.ANNOTATION_TYPE})
@Retention(RetentionPolicy.RUNTIME)
@RequestBody
@Inherited
public @interface SwaggerBody {

	@AliasFor(annotation = RequestBody.class)
	String description() default "";

	@AliasFor(annotation = RequestBody.class)
	Content[] content() default {};

	@AliasFor(annotation = RequestBody.class)
	boolean required() default false;

	@AliasFor(annotation = RequestBody.class)
	Extension[] extensions() default {};

	@AliasFor(annotation = RequestBody.class)
	String ref() default "";

	@AliasFor(annotation = RequestBody.class)
	boolean useParameterTypeSchema() default false;

}

// TestController.class
@Validated
@RequiredArgsConstructor
@RestController
public class TestController {

	private final MockFileUploadService mockFileUploadService;

	@SwaggerBody(content = @Content(
		encoding = @Encoding(name = "request", contentType = MediaType.APPLICATION_JSON_VALUE)))
	@PostMapping(value = "/file", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
	public Test.Response uploadFile(
		@RequestPart("files") @NotEmpty List<MultipartFile> files,
		@RequestPart("request") @Valid Test.Request request
	) {
		mockFileUploadService.upload(files, request);
		return new Test.Response("success");
	}
}

참고로 `@SwaggerBody`를 쓰는 아이디어는 어딘가에서 봐서 따라한건데 지금은 출처가 잘 기억이 나지않는다. 어쨌든 다음과 같이 바꾸고 나서 서버를 실행하면 스웨거 문서에 아주 작은 변화가 생긴다.

 

사진 5 - Curl 명령어의 변화

`type=application/json`이라고 타입을 지정해주는 부분이 생겼다. 이제 스웨거에서 요청해도 정상적으로 동작하게 된다.

사진 6 - API 성공 로깅

 

사진 7 - 실제 요청에 Content-Type 헤더가 추가된 모습

 

실제로 Spring Doc이 생성한 JSON 파일의 어느 부분에서 `encoding` 속성이 추가되어야 하는 지도 올리려고 했는데 영양가 없는데 무지 길어서 따로 기록할 필요는 없을 것같다. 확인해보고 싶으신 분들은 깃허브에 들어가서 확인하시면 됩니다~

 

혹시 잘못된 내용이나 코멘트할 부분이 있다면 꼭 댓글 부탁드립니다!

 

쓰인 코드를 확인하려면 아래를 참조하자.

GitHub Link

 

 

참조

https://etloveguitar.tistory.com/58