Spring에서 MIME 타입 요청 데이터를 처리하는 방법(feat. @RequestPart, MultipartFile)

Minjae An·2024년 1월 19일
0

Spring Web

목록 보기
5/9
post-custom-banner

이 포스트에서 다루고자 하는 주요한 내용에 관련도니 코드외 나머지 코드는 분량상 생략하였습니다. 양해바랍니다.

상황

기존에 리뷰 데이터를 저장하는 API에서는 S3를 이용한 멀티미디어 파일을 아직 다루지 않아 리뷰 이미지 데이터를 저장하는 로직을 포함하고 있지 않았다. 따라서 하나의 리뷰 이미지를 가지는 리뷰 데이터를 생성하는 API를 개발하고자 하였다.

StoreRestController - createReview

@RestController
@RequiredArgsConstructor
@RequestMapping("/store")
@Validated
@ApiResponses({
        @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "COMMON200", description = "OK, 성공"),
        @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "AUTH003", description = "access 토큰을 주세요!",
                content = @Content(schema = @Schema(implementation = ApiResponse.class))),
        @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "AUTH004", description = "acess 토큰 만료",
                content = @Content(schema = @Schema(implementation = ApiResponse.class))),
        @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "AUTH006", description = "acess 토큰 모양이 이상함",
                content = @Content(schema = @Schema(implementation = ApiResponse.class))),
})
@Slf4j
public class StoreRestController {
    private final StoreQueryService storeQueryService;
    private final StoreCommandService storeCommandService;

		// ...
		@PostMapping(value = "/{storeId}/review", consumes = "multipart/form-data")
    @Operation(
            summary = "하나의 리뷰 이미지를 가진 리뷰 추가 API",
            description = "특정 가게에 리뷰를 추가하는 API, 하나의 리뷰 이미지를 가지는 리뷰만 추가 가능."
                    + "query string으로 멤버의 ID(memberId)를 주세요"
    )
    @ApiResponses({
            @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "STORE4001",
                    description = "해당 id를 가진 Store가 존재하지 않음",
                    content = @Content(schema = @Schema(implementation = ApiResponse.class))),
            @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "MEMBER4001",
                    description = "해당 id를 가진 Member가 존재하지 않음",
                    content = @Content(schema = @Schema(implementation = ApiResponse.class)))
    })
    @Parameters({
            @Parameter(name = "storeId", description = "가게의 아이디, path variable")
    })
    public ApiResponse<CreateReviewResultDTO> createReview(@RequestBody @Valid ReviewDTO request,
                                                           @ExistStores @PathVariable(name = "storeId") Long storeId) {
        log.info("request = {}", request);
				Review review = storeCommandService.createReview(request.getMemberId(), storeId, request);
        return ApiResponse.onSuccess(StoreConverter.toCreateReviewResultDTO(review));
    }
}

StoreRequestDTO - ReviewDTO

public class StoreRequestDTO {
		@Getter
    @ToString
    public static class ReviewDTO {
        @NotNull
        private Long memberId;
        @NotBlank
        private String title;
        @NotNull
        private Float score;
        @NotBlank
        private String body;
        private MultipartFile reviewPicture;
    }
}

위와 같이 코드를 구성하고 Postman을 통해 API에 form-data 형식으로 요청을 보내면 다음과 같이 415 Unsupported Media Type 에러가 발생한다.

@RequestBody + DTO

@RequestBody 는 기본적으로 JSON 포맷의 요청 바디 데이터를 처리하는데 사용된다. 따라서 위와 같이 로직을 구성하면 form-data 형식으로 들어오는 요청 데이터를 DTO를 통해 매핑하여 처리할 수 없다.

@RequestPart + DTO

application/json + multipart/form-data를 함께 파라미터로 받아 처리하고 싶을 때 @RequestPart 어노테이션을 이용할 수 있다. 이 때 유의할 점은 클라이언트에서 요청 데이터의 타입을 반드시 명시해주어야 제대로 된 처리가 일어난다는 점이다. 즉, 클라이언트 측에서 요청 바디에 JSON과 MultipartFile을 함께 넣는다면 Content-Type을 각각 application/json, multipart/form-data로 지정해줘야 한다는 의미이다. 보통 이는 클라이언트 측이 매우 번거로워지기 때문에 클라이언트에서는 그냥 바디의 Content-Type을 multipart/form-data로 지정해서 보내고 서버쪽에서 json 값을 수동으로 Object로 변환해서 사용한다. (mapper를 유틸로 만들어서 사용)

@ModelAttribute + DTO

그렇다면 그냥 multipart/form-data 형식의 데이터를 DTO 파라미터로 매핑하여 처리하고 싶을 땐 어떻게 로직을 구성해야 할까? 이 때는 @ModelAttribute 를 사용하면 된다. 해당 어노테이션은 클라이언트로부터 multipart/form-data 형태의 파라미터를 받아 처리할 때 사용된다. @ModelAttribute

객체 생성 → 데이터 바인딩 → 검증

위 순서로 데이터를 처리한다. 해당 어노테이션은 메서드와 파라미터 레벨에 사용할 수 있으며, 값을 바인딩하지 못하는 경우 BindException 이 발생하여 400 에러를 반환한다. 사용 시 유의점은 파라미터 객체에 데이터를 바인딩할 수 있는 생성자나 setter가 필요하다는 점이다. 따라서 요청 데이터를 매핑할 요청 DTO 클래스에 적절히 구현이 되어있어야 한다.

@ModelAttribute 를 활용하여 위 잘못된 로직을 수정하면 다음과 같다.

StoreRequestDTO - ReviewDTO

@Getter
@AllArgsConstructor
public static class ReviewDTO {
    @NotNull
    private Long memberId;
    @NotBlank
    private String title;
    @NotNull
    private Float score;
    @NotBlank
    private String body;
    private MultipartFile reviewPicture;
}

StoreRestController - createReview

public ApiResponse<CreateReviewResultDTO> createReview(@ModelAttribute @Valid ReviewDTO request,
                                                           @ExistStores @PathVariable(name = "storeId") Long storeId) {
        log.info("request = {}", request);
        log.info("reviewPicture = {}", request.getReviewPicture());
        Review review = storeCommandService.createReview(request.getMemberId(), storeId, request);
        return ApiResponse.onSuccess(StoreConverter.toCreateReviewResultDTO(review));
    }

정상 동작 확인

해당 API에 요청을 보내보자

데이터를 정상적으로 바인딩하고 리뷰 데이터를 생성하는 것을 로그를 통해 확인할 수 있다.

참고

profile
먹고 살려고 개발 시작했지만, 이왕 하는 거 잘하고 싶다.
post-custom-banner

0개의 댓글