Swagger에서 MultipartFile과 DTO 한 번에 받는 @RequestPart 요청을 실행할 수 있도록 만들기

sckwon770·2023년 10월 30일
1

스프링 부트

목록 보기
7/10
post-thumbnail

이전 노션 블로그의 Swagger에서 MultipartFile과 DTO 한 번에 받는 @RequestPart 요청을 실행할 수 있도록 만들기 (2023.08.29)로부터 마이그레이션된 글입니다.


번거롭게 요청을 요청할 필요 없이 하나의 요청에서 파일과 데이터를 전송할 수 있도록 컨트롤러를 힘들게 구현했더니, 프론트에서 사용할 때 요청이 발생하고 Swagger에서 제대로 동작하지 않았다. 본 포스팅은 노력이 헛되지 않도록 문제를 하나씩 고쳐나간 기록들이다.

1. Request body의 Content-type이 올바르지 않음

문제 상황

MultipartFile와 DTO를 한 번에 받을 수 있는 요청을 만들때, 다른 여타 요청들처럼 @PostMapping에 URL만 설정하면 Swagger의 Request body 섹션에서 올바르게 보지도 않을뿐더러 API Test할 때 파일(사진)을 넣을수도 없다.


Request body섹션의 Content type을 보면 알 수 있는데, ‘application/json’으로 설정되어 있다. Postman이나 클라이언트에서는 올바르게 Content type을 지정해서 보내기 때문에 문제를 못느낄 수도 있지만, Swagger는 디폴트값인 application/json으로 설정하기 때문에 이런 문제가 발생하는 것이다.

해결법

해결법은 간단하다. @PostMappingconsumes = MediaType.*MULTIPART_FORM_DATA_VALUE* 을 추가해주면 된다.

@PostMapping(value = "{partnerDomain}/products/{travelProductPartnerCustomId}/reviews"**, consumes = MediaType.MULTIPART_FORM_DATA_VALUE)**
public ResponseEntity<Void> createReview(@PathVariable String partnerDomain,
                                         @PathVariable String travelProductPartnerCustomId,
                                         @Valid @RequestPart ReviewCreateRequest reviewCreateRequest,
                                         @RequestPart(required = false) List<MultipartFile> reviewImageFiles) {

// ...



2. HttpMediaTypeNotSupportedException: Content type 'application/octet-stream' not supported

만약 MultipartFile와 DTO를 한 번에 받을 수 있는 요청을 만들기 위해, @ModelAttribute 혹은 @RequestPart 을 쓰는 사람이라면 이 에러를 한 번씩은 봤을 것이다.

HttpMediaTypeNotSupportedException: Content type 'application/octet-stream' not supported

전송받은 파라미터들의 Content-type이 올바르지 않은 타입인 ‘application/octet-stream’으로 들어와서 이다. 예시와 함께 설명하겠다.

문제 상황

아래는 프로젝트 리뷰메이트의 ReviewController의 리뷰 생성 요청이다.

@PostMapping(value = "/api/widget/v1/{partnerDomain}/products/{travelProductPartnerCustomId}/reviews", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
public ResponseEntity<Void> createReview(@PathVariable String partnerDomain,
                                         @PathVariable String travelProductPartnerCustomId,
                                         @Valid @RequestPart ReviewCreateRequest reviewCreateRequest,
                                         @RequestPart(required = false) List<MultipartFile> reviewImageFiles) {

    Long reviewId = reviewService.create(partnerDomain, travelProductPartnerCustomId, reviewCreateRequest, reviewImageFiles);

    return ResponseEntity.created(URI.create("/api/widget/v1/reviews/" + reviewId)).build();
}

ReviewCreateRequest 라는 생성할 리뷰 데이터 DTO와 reviewImages 라는 리스트 형태의 MultipartFile인 파라미터들을 @RequestPart 로 요청받고 있다. 이 경우 파라미터를 첨부하는 쪽에서 명확히 Content type을 명시해야 한다.

  • reviewCreateRequest ⇒ Content-type: application/json
  • reviewImages ⇒ Content-type: image/{image-extension} (ex. image/jpeg)

하지만 Swagger의 경우, 파라미터마다 Content type을 지정할 수가 없다.

Swagger에서 createReview 요청을 API test 통해 실행하기 위해 데이터를 입력하는 모습

해결법

HttpMessageConverter나 HttpMessageConverter를 구현하는 커스텀 컨버터 컴포넌트를 추가해야 한다. 그리고 ‘application/octet-stream’ 사용을 비활성화함으로서, 에러를 방지할 수 있다. 실제로 구현할 클래스는 AbstractJackson2HttpMessageConverter로 충분하다.

@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;
    }
}
⚠️ 본 문제가 해결되더라도 파라미터 별로 Content type을 지정할 수 있는 경우에는 올바른 사용법대로 파라미터별로 Content type을 지정하는 것이 좋다.
  • Postman

  • React
let reviewForm = new FormData();
const data = {
  title : reviewInput.title ,
// ~~~
}

reviewForm.append('reviewImages', image);
reviewForm.append("reviewCreateRequest", new Blob([JSON.stringify(data)], { type: "application/json" }));
axios
  .post(`/reviews`, reviewForm)

3. No validator could be found for constraint ‘javax.validation.constraints.NotBlank’ on Enum

문제 상황

DTO를 Swagger 문서화하는 과정에서 가장 많이 사용하는 것이 @NotNull @NotBlank 이다. Enum 타입의 필드의 경우, 엄밀히 따지면 문자열 형태로 데이터를 전달받기 때문에 @NotBlank 을 사용하지였지만 다음의 에러가 발생하였다.

javax.validation.UnexpectedTypeException: HV000030: No validator could be found for constraint 'javax.validation.constraints.NotBlank' validating type 'com.xxx.xxx.xxx.xxx’

에러가 발생한 DTO는 다음과 같다.

public class SingleTravelProductCreateRequest {

    @NotBlank
    @Schema(description = "파트너사가 정의하는 상품 커스텀 ID (unique)\n\n⚠️ 서로 절대 겹치면 안됨", example = "PRODUCT-0001")
    private String partnerCustomId;

    @NotBlank
    @Schema(description = "상품명", example = "신라더스테이 호텧")
    private String name;

    @NotBlank
    @Schema(description = "여행상품 카테고리", example = "ACCOMMODATION")
    private SingleTravelProductCategory singleTravelProductCategory;

// ...
}

에러를 해석해보자면 적절한 Validator가 없다는 뜻인데, 일반적으로 사용되는 Validator 어노테이션을 살펴보자

  • @NotBlank : The annotated element must not be null and must contain at least one non-whitespace character. Accepts CharSequence.
    • 즉, 문자열이여야만 한다. (String, CharSequence)
  • @NotNull : The annotated element must not be null. Accepts any type
    • 아무 타입이나 가능하다고 하지만, 정확히는 Nullable한 타입이어야 한다. 즉, int나 char처럼 primitive 타입은 불가하다.
  • @NotEmpty : 본 어노테이션들은 설명에 사용가능한 타입이 명시되어 있다.
    • The annotated element must not be null nor empty. Supported types are:
      • CharSequence (length of character sequence is evaluated)
      • Collection (collection size is evaluated)
      • Map (map size is evaluated)
      • Array (array length is evaluated

@NotBlank 는 문자열이어야 한데, Enum은 결국 자료형이 문자열이 아니기 때문이다. 따라서 empty 혹은 null이 아닌 Enum을 validation하기 위해서는 커스텀 Validator를 만들어야 한다. 다음 두 가지를 추가하고,

public class EnumValidValidator implements ConstraintValidator<EnumNotNull, Enum<?>> {
    @Override
    public void initialize(EnumNotNull constraintAnnotation) {
        ConstraintValidator.super.initialize(constraintAnnotation);
    }

    @Override
    public boolean isValid(Enum<?> value, ConstraintValidatorContext context) {
        return value != null;
    }
}
@Target({ElementType.METHOD, ElementType.FIELD, ElementType.ANNOTATION_TYPE, ElementType.CONSTRUCTOR, ElementType.PARAMETER})
@Retention(RetentionPolicy.RUNTIME)
@Documented
@Constraint(validatedBy=EnumValidValidator.class)
public @interface EnumNotNull {

    String message() default "Invalid value";

    Class<?>[] groups() default {};

    Class<? extends Payload>[] payload() default {};
}

해결법

DTO를 다음과 같이 수정하면 정상 작동한다.

public class SingleTravelProductCreateRequest {

    @NotBlank
    @Schema(description = "파트너사가 정의하는 상품 커스텀 ID (unique)\n\n⚠️ 서로 절대 겹치면 안됨", example = "PRODUCT-0001")
    private String partnerCustomId;

    @NotBlank
    @Schema(description = "상품명", example = "신라더스테이 호텧")
    private String name;

    @EnumNotNull
    @Schema(description = "여행상품 카테고리", example = "ACCOMMODATION")
    private SingleTravelProductCategory singleTravelProductCategory;

// ...
}

참고자료

https://velog.io/@jsb100800/Spring-boot-Swagger-issue1
https://velog.io/@hm5395/Spring-Boot-content-type-applicationoctet-stream-not-supported-오류-해결

https://stackoverflow.com/questions/16230291/requestpart-with-mixed-multipart-request-spring-mvc-3-2

https://github.com/swagger-api/swagger-ui/issues/6462

profile
늘 학습하고 적용하고 개선하는 개발자

0개의 댓글