Spring @Bean validation 뽐나게 써보지 않을래?

사용자는 언제나 예상을 넘어서는 데이터를 입력한다. 나 또한 그렇다. 그럴때 친절하게 안내를 해주는 서비스가 있는 반면, 어떠한 안내도 해주지 않는 서비스도 있기마련이다. 예상하지 못한 데이터에 대해서 어플리케이션에서 처리가 되어있더라 하더라도 사용자에게는 어떠한 안내도 주지않는다면, 사용자는 불편함을 느끼고 금방 떠나버리고 말것이다. 이런 로직을 잘 개발하는것이 어쩌면- 안정적인 서비스의 첫걸음이 아닐까 생각한다.

사용자에게 잘못된 데이터가 입력되었다는걸 어떻게 안내해줄 수 있을까?

  • 프론트에서 사용자의 입력을 검증해도 되지않을까?
    프론트에서 사용자의 입력을 검증한다면, 데이터 조작을 할 수 있어 보안에 취약하기 때문에 적절하지 않다고 생각한다.
  • 서버에서 검증을 해야할까?
    서버 데이터베이스까지 거치는 과정을 통해 검증을 한다면 반응성이 떨이지는 단점이 생긴다.
결국 이 둘을 적절하게 조합하는 방식을 이용하여 서버 검증을 해야한다! 즉, Controller Layer 에서 사용자 데이터를 검증하고 데이터가 invalid 하다면 database Layer까지 가지않고 응답 메세지를 내려주면 된다.

Bean validation

먼저 사용법부터 알아보자.

  1. Bean Validation 의존 관계 추가
  • 스프링 부트는 spring-boot-starter-validation 라이브러리를 넣으면 자동으로 LocalValidatorFactoryBean을 글로벌 Validator로 등록한다. 이 Validator는 검증 어노테이션을 통해 검증을 수행하게 된다.

Gradle

implementation 'org.springframework.boot:spring-boot-starter-validation'

Pom.xml

        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-validation</artifactId>
        </dependency>
  1. 검증이 필요한 dto 필드에 적합한 검증 어노테이션을 붙여준다.
    검증 어노테이션은 여기 서 확인할 수 있다.
public class UserCreateRequest {

    @NotBlank(message = "이름이 비어있습니다.")
    private String name;

    @Positive(message = "나이는 0보다 많아야 합니다.")
    private int age;

    private String hobby;

    ...
}
  1. Contoller에서 @Valid를 이용하여 검증하고자 하는 객체의 유효성 검사를 할 수 있도록 한다.
@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));
    }

}
  1. MethodArgumentNotValidException를 받아서 필요한 정보를 응답으로 내려준다. (예시 코드는 Global Exception으로 처리)
@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());
    }
}
  1. 응답값을 확인해보자!

    응답값을 확인해보면 age filed 가 0 보다 작은 값이 요청이 되었고, 검증이 실패한 field 이름과 설정해준 message가 응답값으로 내려진 것을 확인할 수 있다.

그렇다면 어떤 방식을 통해 돌아가는걸까?

  1. 유저가 보낸 데이터를 각각의 필드 타입 변환을 시도한다.
  2. 성공하면 다음 단계로 넘어간다.
  3. 타입 변환이 실패한 필드는 Bean Validation을 적용하지 않는다.
  4. 타입 변환은 성공했지만 바인딩 된 값이 Bean Validation 검증 범위내에 있지 않으면 BindingResult 객체를 생성한다.
  5. FieldError 객체를 생성하고 필요한 정보를 담는다.
  6. BindingResult에 FieldError를 담는다.

BindingResult

스프링 검증 오류를 보관하는 객체로 오류가 발생하면 BindingResult에 보관한다.
BindingResult는 인터페이스이고 BeanPropertyBindingResult 구현체를 사용하고 있다.

FieldError

문자열을 입력해야하는 필드에 숫자를 입력하면 이를 어떻게 보관할 수 있을까? 이런 오류가 발생한 경우 사용자 입력값을 보관하는 별도의 방법이 필요한데, 이를 FieldError가 제공한다.

objectName : 오류가 발생한 객체 이름
field : 오류 필드
rejectedValue : 사용자가 입력한 값(거절된 값)
bindingFailure : 타입 오류 같은 바인딩 실패인지, 검증 실패인지 구분 값
codes : 메시지 코드
arguments : 메시지에서 사용하는 인자
defaultMessage : 기본 오류 메시지

PathVariable, RequestParam

지금까지는 RequestBody에 대한 검증을 확인해봤다. 그럼 PathVariable, RequestParam에 대한 검증은 어떻게 할까?

PathVariable, RequestParam은 Integer 혹은 String과 같은 객체이기 때문에 복잡한 유효성 검사를 하지 않는다.

  1. class level에 @Validated을 설정해준다.
  2. 검증 어노테이션을 설정해준다.
  3. ConstraintViolationException 예외를 처리한다.
    • ConstraintViolationException 예외는 기본적으로 exception을 handling 하지 않아서 HTTP status code 500(Internal Server Error)을 내려준다.
@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) {
        ...
    }

}

Custom Anotation

@NotEmpty, @NotNull, @Negative 같은 Anotation으로 검증을 할 수 있지만, 기본적으로 제공하는 Anotation으로 검증이 안된다면 어떻게 할 수 있을까? 만약 이름을 검증한다고 하면 @Name이라는 Anotation이 없기도 하고, 이름을 검증하는 정책이 항상 똑같을수 없다고 볼 수 있다. 이럴 경우에 Custom Anotation을 만들어서 검증을 할 수 있다.

  1. Custom Anotation을 정의한다.
  2. Validator 로직을 작성한다.
  3. 검증하고 싶은 field 에 @Customm 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을 통해 사용자가 입력한 값을 보다 쉽고- 멋지게- 검증할 수 있다.

좋은 웹페이지 즐겨찾기