๐Ÿค– Backend/SpringBoot

Swagger์—์„œ MultipartFile๊ณผ DTO ํ•œ ๋ฒˆ์— ๋ฐ›๋Š” @RequestPart ์š”์ฒญ์„ ์‹คํ–‰ํ•  ์ˆ˜ ์žˆ๋„๋ก ๋งŒ๋“ค๊ธฐ

sckwon770 2024. 2. 25. 19:26

์ด์ „ ๋…ธ์…˜ ๋ธ”๋กœ๊ทธ์˜ 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์œผ๋กœ ์„ค์ •ํ•˜๊ธฐ ๋•Œ๋ฌธ์— ์ด๋Ÿฐ ๋ฌธ์ œ๊ฐ€ ๋ฐœ์ƒํ•˜๋Š” ๊ฒƒ์ด๋‹ค.

 

ํ•ด๊ฒฐ๋ฒ•

ํ•ด๊ฒฐ๋ฒ•์€ ๊ฐ„๋‹จํ•˜๋‹ค. @PostMapping ์— consumes = 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