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 클래스는 사용할 수 없다. 이유는 아래와 같다.
- data 클래스는 클래스 선언에 Args가 포함된 주생성자가 반드시 포함되어야 하고 그곳에는 별도 로직을 넣을수가 없다.
- 부생성자를 추가하고 그곳에는 로직을 넣으면 된다고 생각할 수 있지만 유효성 검증을 위해 AllArgsConstructor 가 필요하기 때문에 주생성자는 임의는 임의의 Arg만 포함하고 부생성자가 AllArgs를 포함하게 해야 한다.
- 그리고 이렇게 주생성자, 부생성자가 모두 존재할 경우 부생성자에서는 무조건 주생성자가 우선 호출되어야 하기 때문에 부생성자에서 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 ...
'Backend > Spring+Boot' 카테고리의 다른 글
swagger 사용시 패키지가 다른 동일 클래스를 구별하지 못하는 이슈 (1) | 2023.08.30 |
---|---|
spring cloud vault 으로 vault 연동 (0) | 2023.06.14 |
@JsonCreator 주의사항 (0) | 2022.11.02 |
[SpringBoot] SpringMVC 에서 WebClient 사용시 주의사항 (0) | 2022.07.28 |
WebClient max connection 그리고 mutate() (0) | 2022.07.22 |