API 서버 엔드포인트 개발을 할 때, 요청으로 전달 받은 Request Body에 대한 검증 로직을 자주 작성한다.
간단하게는 특정 필드의 크기, null 여부 등이 주로 검사되고, 비즈니스 로직과 관련된 요소들도 검증된다.
Spring 에선 Request Body를 객체화 하기 위해 사용되는 DTO 클래스의 검증을 애노테이션 기반으로 쉽게 수행할 수 있다.
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-validation</artifactId>
</dependency>
public class UserDto {
private long id;
@NotBlank(message = "Name is mandatory")
private String name;
@NotBlank(message = "Email is mandatory")
@Size(min = 1, max = 100)
private String email;
@NotNull
@Positive
private Integer age:
@NotBlank
@Future
private LocalDateTime createdTime
}
DTO 클래스 필드를 애노테이션을 통해 쉽게 검증할 수 있다.
또한 해당 필드가 유효하지 않을 경우의 메시지 또한 지정할 수 있다.
만약 각각의 필드를 검증하기 위한 로직이 모두 서비스 레이어에 위치했다면, 서비스 레이어가 굉장히 무거워졌을 것이다.
@RestController
public class UserController {
@PostMapping("/users")
ResponseEntity<String> addUser(@Valid @RequestBody UserDto userDto) {
// persisting the user
return ResponseEntity.ok("User is valid");
}
}
POST /users 엔드포인트의 RequestBody UserDto 클래스가 사용되었고,
해당 클래스의 Spring Validation을 적용하기 위해 @Valid 애노테이션을 추가한다.
만약 위의 검증로직들이 서비스 레이어에 존재했다면, 검증이 실패하였을 때 발생할 예외를 직접 지정하였을 것이다.
Spring Validation을 통해 RequestBody의 사용한 경우엔 컨트롤러 단에서 해당 객체의 역직렬화를 완료한 이후에
MethodArgumentNotValidException가 발생한다.
따라서 ExceptionHandler 단에서 다음과 같이 핸들링을 구현해야 한다.
@ResponseStatus(HttpStatus.BAD_REQUEST)
@ExceptionHandler(MethodArgumentNotValidException.class)
public Map<String, String> handleValidationExceptions(
MethodArgumentNotValidException ex) {
Map<String, String> errors = new HashMap<>();
ex.getBindingResult().getAllErrors().forEach((error) -> {
String fieldName = ((FieldError) error).getField();
String errorMessage = error.getDefaultMessage();
errors.put(fieldName, errorMessage);
});
return errors;
}
위의 애노테이션을 이용한 방식은 필드의 크기, 범위, null 여부 등 간단한 조건만을 검증할 수 있었다.
만약 필드 검증에 비즈니스로직과 관련된 사항이 추가되거나,
Repository에 의존해서 DB 에 저장된 정보를 참조해야만 검증할 수 있는 상황이라면
애노테이션 만으론 해결할 수 없고, 또 다시 서비스 레이어가 검증 로직을 무거워 질 것이다.
이를 해결하기 위해 사용할 수 있는 것이 Validator Interface 이다.
public interface Validator {
boolean supports(Class<?> clazz);
void validate(Object target, Errors errors);
}
Valiator 인터페이스는 두 개의 메서드를 가지고 있다.
supports(): 검증하려는 클래스가 맞는지 확인하는 메서드
validate(): 실제 검증 로직을 포함하는 메서드
@Component
@RequiredArgsConstructor
public UserValiator implements Valiator {
private final UserRepository userRepository;
@Override
public boolean supports(Class clazz) {
return User.class.isAssignableFrom(clazz);
}
@Override
public void validate(Object target, Errors errors) {
User user = (User) target;
if(!duplicateName(user)) {
errors.rejectValue("name", "DUPLICATE_USER_NAME","사용자 이름이 중복되었습니다.");
} else if(isInvalidEmail(signupRequest)) {
errors.rejectValue("email", "INVALID_EMAIL","유효하지 않은 이메일입니다.");
}
}
private boolean duplicateName(User user){
return userRepository.existsByName(user.getName());
}
private boolean isInvalidEmail(User user){
// 이메일 유효성검증 로직
}
}
Validator 구현체를 빈으로 등록하고, repostory를 주입받아 사용자 이름 중복 검사와 같은 로직을 수행할 수 있다.
또한 애노테이션 만으로 수행할 수 없는 복잡한 검증 로직을 Valiator 구현체의 메서드로 구현하여
서비스 코드에는 비즈니스 로직만을 두고, 검증 로직을 분리하여 보다 객체지향적인 코드를 작성할 수 있다.