Backend/Spring+Boot

@RequestPart 사용할 때 주의사항. 그리고 @ModelAttribute

findmypiece 2022. 11. 7. 11:01
728x90

application/json + multipart/form-data 를 파라미터로 받아야 할 경우 @RequestPart 를 사용하곤 한다. json 문자열은 자동으로 VO로 컨버팅되고 MultipartFile도 잘 받아진다. @RequestParam 를 사용해도 되지만 이 경우 json 문자열의 VO 컨버팅이 자동으로 수행되지 않는다.

 

그런데 json 문자열 VO 컨버팅  라는 말에는 함정이 있다. 그냥 하면 절대로 되지 않고 json 문자열에 대한 body 값에 대해 Content-Type을 반드시 application/json 로 지정해줘야 한다.

 

즉, 클라이언트 측에서 body 에 json 과 MultipartFile 을 함께 넣는다면 Content-Type 을 각각 application/json, multipart/form-data 으로 지정해줘야 한다는 말이다. 만약 RestTemplate 를 사용한다면 MultipartBodyBuilder 를 활용해야 한다. 

 

클라이언트 입장에서는 매우 번거로운 일이기 때문에 일반적으로 그냥 body의 Content-Type 을 multipart/form-data 로만 지정해서 보내고 서버쪽에서 json 문자열을 수동으로 Object 으로 변환해서 사용한다.(최소한 mapper는 유틸로 만들어놓고 쓰기 때문)

 

그런데 여기에 Swagger 를 적용할 경우 추가로 주의해야 할 점이 있다. Swagger 는 이글을 쓰는 현재 최신버전인 1.6.12 기준이다.

 

@RequestMapping 선언시 consumes에 multipart/form-data 를 반드시 지정해줘야 한다. 그래야 Swagger 상에서 파일첨부가 가능해진다.

 

json 을 String 타입으로 받기 때문에 역직렬화시 사용될 클래스를 지정할 수 없고 이에 해당 클래스에 지정된 Swagger 정보인 @Schema 값들을 표현할 수 없을 것이다.

 

이에 Controller 부분에서 @RequestPart 를 사용한 매개변수 영역에 @Schema를 별도로 지정해야 한다. 만약 @Schema 대신이때 @Parameter 를 사용할 경우 example 로 지정한 값이 제대로 표기되지 않을 것이다.

 

MultipartFile 파라미터의 경우 @RequestPart 로 지정할 경우 required 가 무조건 필수로만 지정되기 때문에 @RequestParam 으로 지정해야 한다. 그리고 @Schema 이 아닌 @Parameter 를 사용해야 한다.

 

여기에서 이럴거면 에초에 @RequestParam 으로만 지정하면 되는 게 아닌지 의문이 들 수 있다. 그런데 @RequestParam 로 지정할 경우 Swagger 에서는 String 타입은 request body 가 아니라 Parameters 로 표기를 해버린다.

 

이에 Swagger 를 함께 사용할 경우 json 문자열은 @RequestPart 로, MultipartFile 은 @RequestParam 으로 지정해야 한다.

 

여기까지의 설명을 토대로 Kotlin 기준 Controller 메소드는 아래와 같은 형태가 된다.

@Operation(summary = "mail 발송")
@PostMapping(value = ["/v1"], consumes = [MediaType.MULTIPART_FORM_DATA_VALUE])
fun sendMail(
    @Schema(name = "mailReqInfo",
        description = """
            메일 발송 요청정보(json)
            title*(String): 메일제목
            content*(String): 메일내용(HTML)
            fromName(String): 보내는 사람 이름(지정하면 해당이름에 메일주소가 링크됨)
            fromMail(String): 보내는 사람 메일주소(default: test@test.com)
            toName(String): 받는 사람 이름(지정하면 해당이름에 메일주소가 링크됨)
            toMail*(String): 받는 사람 메일주소
        """,
        required = true,
        example = """
            {"title": "메일제목","content":"<html><body>Helooo~~~w</body></html>", "fromName": "보내는사람", "fromMail": "test@test.com", "toName": "받는사람", "toMail": "test@test.com"}
        """
    )
    @RequestPart mailReqInfo: String,
    @Parameter(name = "attachFileList", description = "첨부파일리스트(maxSize: 10MB)", required = false)
    @RequestParam attachFileList: List<MultipartFile>?
){
   ...
}

 

그리고 이에 따른 Swagger 화면은 아래와 같게 된다.

 

보면 알겠지만 정석적으로 되지 않는 기능을 어거지로 구현하다보니 코드가 굉장히 지져분해지고 Swagger 로 보여지는 내용도 전혀 깔끔하지 않다.

 

에초에 RequestPart 가 body 에 포함하는 자원들의 Content-Type 이 다를 때 활용하기 위함인데 여기에서 처럼 form-data 로 모두 처리가 가능한 경우에는 그냥 @ModelAttribute 를 사용하는게 낫다. 이 경우 MultipartFile 도 VO에 변수로 포함시킬수 있고 자동으로 매핑된다.

 

무엇보다 코드가 아래와 같이 깔끔해지고 

@Operation(summary = "mail 발송")
@PostMapping(value = ["/v1"], consumes = [MediaType.MULTIPART_FORM_DATA_VALUE])
fun sendMail(
    @ModelAttribute mailReqInfo: MailReqInfo
){
    logger.info("/mail/v1")
    mailService.sendMail(mailReqInfo)
}

 

Swagger 도 지원하는 기능이 제대로 활용되어 아래와 같이 깔끔하게 보여준다.

 

다만 @ModelAttribute 는 아래와 같은 조건이 성립되어야 데이터가 정상적으로 매핑된다. 그런데 vo는 불변객체여야 관리가 편하고 이를 위해 1번 보다는 2번이 선호되는 편이다.

1. 기본생성자+setter

2. 커스텀 생성자

주의해야 할 점은 기본적으로 생성자로 초기화되고 setter 에 의해 값이 할다되는 구조이기 때문에 커스텀 생성자를 사용할 경우 setter 를 포함하면 안된다. 커스텀 생성자에서 백날 초기값 넣어봐야 setter 에 의해 재할당된다.

 

물론 @RequestBody 도 아래와 같은 데이터 매핑을 조건은 존재한다. 보면 알겠지만 무엇을 선택하든 불변은 지켜진다.

1. 기본생성자+getter

2. @JsonCreator 로 지정된 펙토리 메소드

 

---

댓글 요청이 있어 내가 작성한 MailReqInfo VO 클래스를 추가로 공유한다. 

@Schema(description = "메일 발송 요청정보")
class MailReqInfo {

    @field:Schema(description = "메일제목", required = false, example = "신규 기기(브라우저)에서 로그인 되었습니다.")
    val title: String?

    @field:Schema(description = "메일내용(HTML)", required = true, example = "<html><body>Helooo~~~w</body></html>")
    val content: String

    @field:Schema(description = "보내는 메일주소", required = false, example = "noreply@test.com", defaultValue = "noreply@test.com")
    val fromMail: String

    @field:Schema(description = "받는 메일주소", required = true, example = "test@test.com")
    val toMail: String

    @field:Schema(description = "보내는 사람 이름(지정하면 해당이름에 메일주소가 링크됨)", required = false, example = "관리자")
    val fromName: String?

    @field:Schema(description = "받는 사람 이름(지정하면 해당이름에 메일주소가 링크됨)", required = false, example = "받는사람")
    val toName: String?

    @field:Schema(
        description = """
            첨부파일리스트(maxSize: 10MB)
             - 첨부파일이 없다면 'Send empty value' 체크해제하고 테스트하셔야 합니다.
               Swagger 기본스펙이라 수정이 안됨...
        """,
        required = false
    )
    val attachFileList: List<MultipartFile>?

    @field:Schema(
        description = """
            메일발송요청 서비스
        """,
        required = true,
        example = "MEMBER"
    )
    val regSvc: RegSvc

    constructor (
        title: String?,
        content: String?,
        fromMail: String?,
        toMail: String?,
        fromName: String?,
        toName: String?,
        attachFileList: List<MultipartFile>?,
        regSvc: RegSvc?
    ) {
        when {
            content.isNullOrBlank() ->
                throw InmsException("'content' is required")
            !fromMail.isNullOrBlank() && !fromMail.matches(Regex("^.*@test.com$")) ->
                throw InmsException("'fromMail' format is ...@test.com")
            toMail.isNullOrBlank() ->
                throw InmsException("'toMail' is required")
            regSvc == null ->
                throw InmsException("'regSvc' is required")
            else -> {
                this.title = title
                this.content = content
                this.fromName = fromName
                this.fromMail = if(fromMail.isNullOrBlank()) {
                    "noreply@test.com"
                } else {
                    fromMail
                }
                this.toName = toName
                this.toMail = toMail
                this.attachFileList = attachFileList
                this.regSvc = regSvc
            }

        }
    }
}

 

나의 경우 VO 클래스 내에서 유효성 검증도 하는 것을 선호하기 때문에 커스텀 생성자에서 유효성 검증을 진행하도록 했다. 보면 알겠지만 default 값 할당도 이곳에서 진행한다.

 

참고로 코틀린에서 VO 클래스 선언시 일반적으로 사용하는 data 클래스는 사용할 수 없다. 이유는 아래와 같다.

 

  1. data 클래스는 클래스 선언에 Args가 포함된 주생성자가 반드시 포함되어야 하고 그곳에는 별도 로직을 넣을수가 없다.
  2. 부생성자를 추가하고 그곳에는 로직을 넣으면 된다고 생각할 수 있지만 유효성 검증을 위해 AllArgsConstructor 가 필요하기 때문에 주생성자는 임의는 임의의 Arg만 포함하고 부생성자가 AllArgs를 포함하게 해야 한다.
  3. 그리고 이렇게 주생성자, 부생성자가 모두 존재할 경우 부생성자에서는 무조건 주생성자가 우선 호출되어야 하기 때문에 부생성자에서 default 값 할당을 위해서는 해당 변수가 var 로 선언되어야 하고 불변이 깨지게 된다.

 

마지막으로 또 한가지... 생성자를 아래와 같이 정의하면 안된다. 

constructor (
    title: String? = null,
    content: String?,
    fromMail: String?,
    toMail: String?,
    fromName: String?,
    toName: String?,
    attachFileList: List<MultipartFile>?,
    regSvc: RegSvc?
) {
    ...
}

 

아래와 같이 인자에 default 값을 할당할 수 있고 이 경우 생성자 호출시 해당 값을 아예 지정하지 않을 수 있다. 극단적으로 아래와 같이 모두 default 값을 포함해서 정의할 경우 생성자는 MailReqInfo() 와 같이 호출할 수도 있다. NoArgsConstructor 처럼 사용할 수 있게 되는 것이다.

constructor (
    title: String? = null,
    content: String? = null,
    fromMail: String? = null,
    toMail: String? = null,
    fromName: String? = null,
    toName: String? = null,
    attachFileList: List<MultipartFile>? = null,
    regSvc: RegSvc? = null
) {
    ...
}

 

하지만 인자에 default 값을 하나라도 정의해버리면 ModelAttribute 에서 VO 매핑시 아래와 같은 에러가 발생한다.

No primary or single unique constructor found for class ...
728x90