웹툰 등록 API에서 요청값을 받을 때 정상적인 값이 받아졌는지 검증하는 로직이 필요했다. 우선 요청에는 다음과 같은 값들이 있다.
comicName:[웹툰이름]
genre:[장르]
summary:[줄거리]
publishDayOfWeek:[연재요일]
thumbnailCreateRequests[0].thumbnailType:[썸네일종류]
thumbnailCreateRequests[1].thumbnailType:[썸네일종류]
thumbnailCreateRequests[0].thumbnailImage:[썸네일파일]
thumbnailCreateRequests[1].thumbnailImage:[썸네일파일]
그리고 값들은 다음과 같은 제한을 가진다.
- comicName : 1~30자, 빈 값이면 안됨
- genre : 빈 값이면 안됨, Enum 값들과 일치해야함
- summary : 1~500자, 빈 값이면 안됨
- publishDayOfweek : 빈 값이면 안됨, Enum 값들과 일치해야함
- thumbnailCreateRequests[].thumbnailType : SMALL, MAIN 두 값이 모두 한 개씩 요청으로 와야함
- thumbnailCreateRequests[].thumbnailImage : 빈 값이면 안됨
@Length(min = 1, max = 30)
@NotBlank
private String comicName;
@NotNull
private Genre genre;
@Length(min = 1, max = 500)
@NotBlank
private String summary;
@NotNull
private PublishDayOfWeek publishDayOfWeek;
대부분의 검증은 BeanValidation
을 사용해서 위와 같이 어노테이션만으로 해결이 되었다.
하지만 thumbnailCreateRequests[].thumbnailType
에 들어오는 요청은 SMALL
, MAIN
값이 꼭 하나씩만 요청으로 들어와야 하는데 이 것을 검증하는 로직은 직접 구현해야 했다.
1. Custom Validator 만들기
Validator
인터페이스를 구현한 Custom Validator
를 만들어서 검증 로직을 작성하였다.
@Component
public class ComicCreateRequestValidator implements Validator {
@Override
public boolean supports(Class<?> clazz) {
return clazz.isAssignableFrom(ComicCreateRequest.class);
}
@Override
public void validate(Object target, Errors errors) {
ComicCreateRequest comicCreateRequest = (ComicCreateRequest)target;
List<ThumbnailCreateRequest> thumbnailCreateRequests =
comicCreateRequest.getThumbnailCreateRequests();
if (!hasAllThumbnailType(thumbnailCreateRequests)) {
errors.rejectValue("thumbnailCreateRequests", null,"모든 썸네일 타입을 가지고 있어야합니다.");
}
if (!(existsFile(thumbnailCreateRequests))) {
errors.rejectValue("thumbnailCreateRequests", null, "빈 파일을 보낼 수 없습니다.");
}
}
private boolean hasAllThumbnailType(List<ThumbnailCreateRequest> thumbnailCreateRequests) {
Map<ThumbnailType, Long> thumbnailTypes = thumbnailCreateRequests.stream()
.collect(Collectors.groupingBy(ThumbnailCreateRequest::getThumbnailType,
Collectors.counting()));
for (ThumbnailType thumbnailType : ThumbnailType.values()) {
if (!thumbnailTypes.containsKey(thumbnailType) || thumbnailTypes.get(thumbnailType) != 1) {
return false;
}
}
return true;
}
private boolean existsFile(List<ThumbnailCreateRequest> thumbnailCreateRequests) {
for (ThumbnailCreateRequest thumbnailCreateRequest : thumbnailCreateRequests) {
if (thumbnailCreateRequest.getThumbnailImage().isEmpty()) {
return false;
}
}
return true;
}
}
hasAllThumbnailType()
: 썸네일로MAIN
,SMALL
타입이 모두 하나씩 들어왔는지 검증existsFile()
: 들어온 이미지 파일이 비어있는지 검증
@RestController
@RequestMapping("/comics")
@RequiredArgsConstructor
public class ComicController {
private final ComicService comicService;
private final ComicCreateRequestValidator comicCreateRequestValidator;
@InitBinder
protected void initBinder(WebDataBinder webDataBinder) {
webDataBinder.setValidator(comicCreateRequestValidator);
}
@LoginCheck(authority = UserAuthority.AUTHOR)
@PostMapping
public ResponseEntity<Void> createComic(
@ModelAttribute @Valid ComicCreateRequest comicCreateRequest,
@SessionAttribute(value = UserSessionUtil.LOGIN_MEMBER_ID, required = false) UserAuthDTO userAuth,
HttpServletRequest httpServletRequest
) {
Long savedComicId = comicService.createComic(comicCreateRequest, userAuth.loginId());
return ResponseEntity
.created(URI.create(httpServletRequest.getRequestURI() + savedComicId))
.build();
}
}
@InitBinder
:Validator
를 등록한다.
그리고 잘못된 요청을 보냈을 때 위와같이 검증 로직을 응답한다(@ModelAttribute
로 바인딩했기 때문에 ExceptionHandler
로 BindingException
을 잡아서 처리해줘야한다.).
이렇게 Validator
를 만들면 검증하는 클래스를 따로 둬서 검증에 대한 책임을 분리할 수 있다. 하지만 단점은 검증이 필요한 객체가 많아 질수록 클래스가 증가하여 복잡도가 증가하고 @InitBinder
에 등록된 Validator
는 해당 컨트롤러의 모든 메소드를 호출할 때 @Valid
가 붙은 객체를 검증하기 때문에 Validator
의 supports()
에 해당되지 않는 타입의 클래스가 들어오면 오류가 발생한다.
@InitBinder(value = "comicCreateRequest")
protected void initBinder(WebDataBinder webDataBinder) {
webDataBinder.setValidator(comicCreateRequestValidator);
}
이 문제는 @InitBinder
에 검증할 객체의 이름을 명시해주어 해결할 수 있지만 검증해야할 객체가 많아진다면 코드가 지저분해질 수 있다.
@InitBinder(value = "comicCreateRequest")
protected void initBinder(WebDataBinder webDataBinder) {
webDataBinder.setValidator(comicCreateRequestValidator);
}
@InitBinder(value = "comicUpdateRequest")
protected void initBinder(WebDataBinder webDataBinder) {
webDataBinder.setValidator(comicUpdateRequestValidator);
}
@InitBinder(value = "comicDeleteRequest")
protected void initBinder(WebDataBinder webDataBinder) {
webDataBinder.setValidator(comicDeleteRequestValidator);
}
2. @AssertTrue 어노테이션 이용하기
@Getter
@Setter
@NoArgsConstructor
public class ComicCreateRequest {
@Length(min = 1, max = 30)
@NotBlank
private String comicName;
@NotNull
private Genre genre;
@Length(min = 1, max = 500)
@NotBlank
private String summary;
@NotNull
private PublishDayOfWeek publishDayOfWeek;
@NotNull
@Size(min = 2, max = 2)
@Valid
private List<ThumbnailCreateRequest> thumbnailCreateRequests;
@Getter
@Setter
@NoArgsConstructor
public static class ThumbnailCreateRequest {
@NotNull
private ThumbnailType thumbnailType;
private MultipartFile thumbnailImage;
}
@AssertTrue(message = "빈 파일은 보낼 수 없습니다.")
private boolean isNotEmptyFile() {
boolean isValid = true;
for (ThumbnailCreateRequest thumbnailCreateRequest : this.thumbnailCreateRequests) {
isValid = !thumbnailCreateRequest.getThumbnailImage().isEmpty();
}
return isValid;
}
@AssertTrue(message = "모든 종류의 썸네일을 보내야합니다.")
private boolean hasAllThumbnailType() {
Map<ThumbnailType, Long> thumbnailTypes = thumbnailCreateRequests.stream()
.collect(Collectors.groupingBy(ThumbnailCreateRequest::getThumbnailType, Collectors.counting()));
for (ThumbnailType thumbnailType : ThumbnailType.values()) {
if (!thumbnailTypes.containsKey(thumbnailType) || thumbnailTypes.get(thumbnailType) != 1) {
return false;
}
}
return true;
}
}
@AssertTrue
어노테이션을 메소드에 검증 적용하고 message
옵션에 검증 실패 시 출력할 메세지를 입력한다. 이때 메소드 명은 get, is, has
로 시작해야 한다.
잘못된 요청을 했을 때 위와같이 잘 검증된 것을 알 수 있다. 이 방법에 장점은 검증 로직이 DTO에 있기 때문에 새로운 클래스를 만들지 않아도 되지만 DTO는 데이터를 운반하는 책임 외에 검증하는 책임까지 갖게되어 단일책임 원칙에 위배된다. 또한 응답 로직에서 볼 수 있듯이 field에 의도한 필드명대신 메서드명이 적힌다는 단점이 있다. 응답을 받는 클라이언트 입장에서는 어떤 요청값에서 에러가 발생했는지 파악하기 힘들다.
3. Custom Constraint validation 만들기
어노테이션으로 검증하는 Validator
를 직접 구현하는 방법이다.
@Constraint(validatedBy = ComicRequestValidator.class)
@Target({ElementType.TYPE})
@Retention(RetentionPolicy.RUNTIME)
public @interface ComicRequestValid {
String message() default "invalid input";
Class<?>[] groups() default {};
Class<? extends Payload>[] payload() default {};
}
Custom Constraint validation
을 만들기 위해서 우선 검증할 객체를 지정할 어노테이션을 만들어야 한다. 이때 @Constraint
어노테이션을 지정해야 하고 validatedBy
옵션에 검증을 수행할 클래스를 지정해야 한다. @Retention
은 RUNTIME
이어야 한다.
@Component
public class ComicRequestValidator implements ConstraintValidator<ComicRequestValid, ComicCreateRequest> {
private static final String THUMBNAIL_INFO_REQUEST_FIRST_FILED = "thumbnailCreateRequests[]";
private static final String THUMBNAIL_TYPE_REQUEST_SECOND_FILED = "thumbnailType";
private static final String THUMBNAIL_IMAGE_REQUEST_SECOND_FILED = "thumbnailImage";
private static final String NOT_HAS_ALL_THUMBNAIL_TYPES_MESSAGE = "모든 썸네일 타입을 가지고 있어야합니다.";
private static final String NOT_EXISTS_FILE_MESSAGE = "빈 파일은 보낼 수 없습니다.";
@Override
public boolean isValid(ComicCreateRequest comicCreateRequest, ConstraintValidatorContext context) {
boolean isValid = true;
List<ThumbnailCreateRequest> thumbnailCreateRequests = comicCreateRequest.getThumbnailCreateRequests();
if (!hasAllThumbnailType(thumbnailCreateRequests)) {
addConstraintViolation(context,
NOT_HAS_ALL_THUMBNAIL_TYPES_MESSAGE,
THUMBNAIL_INFO_REQUEST_FIRST_FILED,
THUMBNAIL_TYPE_REQUEST_SECOND_FILED
);
isValid = false;
}
if (!existsFile(thumbnailCreateRequests)) {
addConstraintViolation(context,
NOT_EXISTS_FILE_MESSAGE,
THUMBNAIL_INFO_REQUEST_FIRST_FILED,
THUMBNAIL_IMAGE_REQUEST_SECOND_FILED
);
isValid = false;
}
return isValid;
}
private boolean hasAllThumbnailType(List<ThumbnailCreateRequest> thumbnailCreateRequests) {
Map<ThumbnailType, Long> thumbnailTypes = thumbnailCreateRequests.stream()
.collect(Collectors.groupingBy(ThumbnailCreateRequest::getThumbnailType, Collectors.counting()));
for (ThumbnailType thumbnailType : ThumbnailType.values()) {
if (!thumbnailTypes.containsKey(thumbnailType) || thumbnailTypes.get(thumbnailType) != 1) {
return false;
}
}
return true;
}
private boolean existsFile(List<ThumbnailCreateRequest> thumbnailCreateRequests) {
for (ThumbnailCreateRequest thumbnailCreateRequest : thumbnailCreateRequests) {
if (thumbnailCreateRequest.getThumbnailImage().isEmpty()) {
return false;
}
}
return true;
}
private void addConstraintViolation(
ConstraintValidatorContext context,
String errorMessage,
String firstValue,
String secondValue
) {
context.buildConstraintViolationWithTemplate(errorMessage)
.addPropertyNode(firstValue)
.addPropertyNode(secondValue)
.addConstraintViolation();
}
}
ConstraintValidator
를 구현하여 검증을 위한 클래스를 만든다. isValid()
메소드를 오버라이딩하여 검증 로직을 작성한다.
ConstraintViolation
: 유효성 검사를 실패한 경우 생성되는 객체이다. 어떤 제약 조건이 위배되었는지, 어떤 속성이나 매개 변수에서 위반이 발생했는지, 어떤 값이 위반이었는지 등의 정보를 담고 있다.addConstraintViolation()
: 검증에 실패했을 때ConstraintViolation
을 만들기 위한 메소드addPropertyNode()
:ConstraintViolation
의 경로를 정의하는 데 사용된다. 제약 조건이 위반될 때ConstraintViolation
은 기본적으로 속성 경로에 따라 생성되는데, 이 메소드를 사용하여 더 구체적인 경로를 지정할 수 있다. 예를 들어,User
객체 내부의Address
에서 검증 예외가 발생한다면addPropertyNode("address")
로 예외가 발생한 프로퍼티 경로를User.Address
로 더 상세하게 지정할 수 있다.
@RestController
@RequestMapping("/comics")
@RequiredArgsConstructor
public class ComicController {
private final ComicService comicService;
@LoginCheck(authority = UserAuthority.AUTHOR)
@PostMapping
public ResponseEntity<Void> createComic(
@ModelAttribute @Valid ComicCreateRequest comicCreateRequest,
@SessionAttribute(value = UserSessionUtil.LOGIN_MEMBER_ID, required = false) UserAuthDTO userAuth,
HttpServletRequest httpServletRequest
) {
Long savedComicId = comicService.createComic(comicCreateRequest, userAuth.loginId());
return ResponseEntity
.created(URI.create(httpServletRequest.getRequestURI() + savedComicId))
.build();
}
컨트롤러에서 검증할 객체에 @Valid
어노테이션을 작성하고
@Getter
@Setter
@NoArgsConstructor
@ComicRequestValid // 추가
public class ComicCreateRequest {
@Length(min = 1, max = 30)
@NotBlank
private String comicName;
@NotNull
private Genre genre;
@Length(min = 1, max = 500)
@NotBlank
private String summary;
@NotNull
private PublishDayOfWeek publishDayOfWeek;
@NotNull
@Size(min = 2, max = 2)
@Valid
private List<ThumbnailCreateRequest> thumbnailCreateRequests;
@Getter
@Setter
@NoArgsConstructor
public static class ThumbnailCreateRequest {
@NotNull
private ThumbnailType thumbnailType;
private MultipartFile thumbnailImage;
}
마지막으로 검증 객체에 생성한 어노테이션(@ComicRequestValid
)을 입력한다.
결과적으로 의도한대로 잘 검증되었다.
결과
나는 어노테이션을 사용하는 Custom Constraint validation
을 사용하는 방식으로 구현했다. 검증 객체가 필요할 때마다 어노테이션, 검증 클래스가 추가되지만 어노테이션을 사용하여 통일성있게 검증할 수 있다는 점과 예외 응답에 정확한 field
명을 남길 수 있다는 점, 컨트롤러 레이어에 검증에 필요한 로직이 추가되지 않는 점이 장점이라고 생각하기 때문이다.
📜 References
https://beanvalidation.org/2.0/spec/#constraintsdefinitionimplementation-validationimplementation:~:text=or presentation frameworks.-,3.4. Constraint validation implementation,-A constraint validation