본문 바로가기

스프링

Spring에서 요청값 검증하기

웹툰 등록 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로 바인딩했기 때문에 ExceptionHandlerBindingException을 잡아서 처리해줘야한다.).

 

이렇게 Validator를 만들면 검증하는 클래스를 따로 둬서 검증에 대한 책임을 분리할 수 있다. 하지만 단점은 검증이 필요한 객체가 많아 질수록 클래스가 증가하여 복잡도가 증가하고 @InitBinder에 등록된 Validator는 해당 컨트롤러의 모든 메소드를 호출할 때 @Valid가 붙은 객체를 검증하기 때문에 Validatorsupports()에 해당되지 않는 타입의 클래스가 들어오면 오류가 발생한다.

@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 옵션에 검증을 수행할 클래스를 지정해야 한다. @RetentionRUNTIME이어야 한다.

@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

 

https://kapentaz.github.io/java/Java-Bean-Validation-%EC%A0%9C%EB%8C%80%EB%A1%9C-%EC%95%8C%EA%B3%A0-%EC%93%B0%EC%9E%90/#