사용자는 언제나 예상을 넘어서는 데이터를 입력한다. 나 또한 그렇다. 그럴때 친절하게 안내를 해주는 서비스가 있는 반면, 어떠한 안내도 해주지 않는 서비스도 있기마련이다. 예상하지 못한 데이터에 대해서 어플리케이션에서 처리가 되어있더라 하더라도 사용자에게는 어떠한 안내도 주지않는다면, 사용자는 불편함을 느끼고 금방 떠나버리고 말것이다. 이런 로직을 잘 개발하는것이 어쩌면- 안정적인 서비스의 첫걸음이 아닐까 생각한다.
사용자에게 잘못된 데이터가 입력되었다는걸 어떻게 안내해줄 수 있을까?
먼저 사용법부터 알아보자.
Gradle
implementation 'org.springframework.boot:spring-boot-starter-validation'
Pom.xml
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-validation</artifactId>
</dependency>
public class UserCreateRequest {
@NotBlank(message = "이름이 비어있습니다.")
private String name;
@Positive(message = "나이는 0보다 많아야 합니다.")
private int age;
private String hobby;
...
}
@RequestMapping("/users")
@RestController
public class UserController {
private final UserService userService;
public UserController(UserService userService) {
this.userService = userService;
}
@PostMapping
public ResponseEntity<Long> createUser(final @Valid @RequestBody UserCreateRequest userRequest) {
return ResponseEntity.ok(userService.createUser(userRequest));
}
}
@RestControllerAdvice
public class InvalidExceptionHandler {
@ExceptionHandler(MethodArgumentNotValidException.class)
protected ResponseEntity<InvalidErrorResponse> argumentNotValidException(MethodArgumentNotValidException ex) {
return ResponseEntity
.status(HttpStatus.BAD_REQUEST)
.body(InvalidErrorResponse.builder()
.field(ex.getBindingResult().getFieldErrors().get(0).getField())
.message(ex.getBindingResult().getFieldErrors().get(0).getDefaultMessage())
.build());
}
}
그렇다면 어떤 방식을 통해 돌아가는걸까?
스프링 검증 오류를 보관하는 객체로 오류가 발생하면 BindingResult에 보관한다.
BindingResult는 인터페이스이고 BeanPropertyBindingResult 구현체를 사용하고 있다.
문자열을 입력해야하는 필드에 숫자를 입력하면 이를 어떻게 보관할 수 있을까? 이런 오류가 발생한 경우 사용자 입력값을 보관하는 별도의 방법이 필요한데, 이를 FieldError가 제공한다.
objectName : 오류가 발생한 객체 이름
field : 오류 필드
rejectedValue : 사용자가 입력한 값(거절된 값)
bindingFailure : 타입 오류 같은 바인딩 실패인지, 검증 실패인지 구분 값
codes : 메시지 코드
arguments : 메시지에서 사용하는 인자
defaultMessage : 기본 오류 메시지
PathVariable, RequestParam은 Integer 혹은 String과 같은 객체이기 때문에 복잡한 유효성 검사를 하지 않는다.
@Validated
@RequestMapping("/users")
@RestController
public class UserController {
private final UserService userService;
public UserController(UserService userService) {
this.userService = userService;
}
@GetMapping("/{id}")
public ResponseEntity<UserFindRequest> findUser(final @PathVariable @Positive Long id) {
return ResponseEntity.ok(userService.findUser(id));
}
}
@RestControllerAdvice
public class InvalidExceptionHandler {
@ExceptionHandler(ConstraintViolationException.class)
protected ResponseEntity<InvalidErrorResponse> constraintViolationException(ConstraintViolationException ex) {
...
}
}
@NotEmpty, @NotNull, @Negative 같은 Anotation으로 검증을 할 수 있지만, 기본적으로 제공하는 Anotation으로 검증이 안된다면 어떻게 할 수 있을까? 만약 이름을 검증한다고 하면 @Name이라는 Anotation이 없기도 하고, 이름을 검증하는 정책이 항상 똑같을수 없다고 볼 수 있다. 이럴 경우에 Custom Anotation을 만들어서 검증을 할 수 있다.
@Documented
@Constraint(validatedBy = NameValidator.class)
@Target({ElementType.METHOD, ElementType.FIELD})
@Retention(RetentionPolicy.RUNTIME)
public @interface Name {
String message() default "한글과 영어만 가능합니다.";
Class<?>[] groups() default {};
Class<? extends Payload>[] payload() default {};
}
public class NameValidator implements ConstraintValidator<Name, String> {
@Override
public void initialize(Name name){
}
@Override
public boolean isValid(String field, ConstraintValidatorContext cxt){
return field != null && field.matches("^[가-힣a-zA-Z]*$");
}
}
public class UserCreateRequest {
@Name
private String name;
..
}
이처럼 Bean validation을 통해 사용자가 입력한 값을 보다 쉽고- 멋지게- 검증할 수 있다.