1. Validation이란?

데이터 값의 유효성을 검증하는 것입니다. 유효성 검증에는 다양한 유형이 있습니다.

ex) email형식, password설정 시 특수문자와 길이 지정, 숫자만 입력 가능

이러한 것들을 검증합니다.  Controller내에서 하나씩 검증하는 것과 BindingResult, Bean Validation, RestController를 이용할 때 사용하는 ResponseEntityExceptionHandler에 대해서 하나씩 알아보겠습니다. 

일단 사용되는 Dto(데이터 전송 목적 객체)와 Controller에 대해서 살펴보겠습니다.

 

 

1. UserDto

간단하게 email과 password, age를 입력받습니다.

여기서 조건은

1. email -> email 형식만, 공백 x

2. password -> 8~16자리의 String, 공백 x

3. age -> 0~100 범위를 만족하는 숫자, 공백 x

@Data
@AllArgsConstructor
@NoArgsConstructor
public class UserDto {

    public String email;

    public String password;

    public Long age;

}

 

 

2.  UserController

RestController이고, @RequestBody를 사용하여 mediaType이 application/json입니다.

@RestController
@RequiredArgsConstructor
public class UserController {

    @PostMapping("/basic")
    public ResponseEntity<?> basic(@RequestBody UserDto dto){
        return new ResponseEntity<>(dto,HttpStatus.OK);
    }
}

위와 같이 구성되어 있고, 먼저 정상적인 실행을 해보겠습니다. 

{
    "email""rkdlem48@gmail.com",
    "password""rkdlem48",
    "age"10
}

위와 같이 보냈을 때 정상 작동합니다. 하지만 email, password, age에 대한 유효성 검증을 하지 못합니다. 이제 하나씩 알아보겠습니다. 

 

 

2. 직접 검증

직접 검증이기 때문에 간단하다고, 생각할 수 있지만 더 많은 비용이 듭니다. 코드를 보겠습니다.

@PostMapping("/validation/v1")
public ResponseEntity<?> validationV1(@RequestBody UserDto dto){

    HashMap<String, String> errors = new HashMap<>(); //


	// 예제이니 까다롭게 체크하지는 않겠습니다. 
    int contain1 = StringUtils.countOccurrencesOf(dto.getEmail(), "@"); // @ 등장 횟수를 카운트,
    int contain2 = StringUtils.countOccurrencesOf(dto.getEmail(), ".com"); // .com을 가지고 있는지 카운트

    if(contain1!=1 || contain2!=1){ // 둘다 1이 아니라면 email 형식이 아니다.
        errors.put("email", "email 형식으로 기입해주세요.");
    }

    if(dto.getPassword().length()<8 || dto.getPassword().length()>16){ // passowrd의 길이확인
        errors.put("password", "password의 길이는 8~16자리로 해주세요.");
    }
    
    if(dto.getAge()<0 || dto.getAge()>100){ // 나이 범위 확인
        errors.put("age","age는 0~100 사이로 입력해주세요.");
    }
    
    if(errors.size()!=0){ // 만약 오류가 하나라도 있다면, 오류에 대한 내용을 보낸다. 
        return new ResponseEntity<>(errors, HttpStatus.BAD_REQUEST);
    }
    
    return new ResponseEntity<>(dto,HttpStatus.OK);
}

설명은 주석을 참고 부탁드립니다.

 

 

 

테스트를 해보겠습니다.

아래 내용으로 요청을 보냈을 때 응답을 확인해보겠습니다. 

{
    "email""rkdlem48",
    "password""hi",
    "age"101
}

오류가 정상적으로 출력되는 것을 확인할 수 있습니다. 하지만 여기에는 문제점들이 있습니다.

 

1. 검증을 하기 위해선 모든 메서드에 검증 로직을 넣어야 한다.

2. TypeMismatch은 해결 불가능하다. 즉 age에 String 값이 넘어오면 예외가 발생한다.

 

이것들을 해결하기 위해서 BindingResult를 활용해보겠습니다.

 

 

3. BindingResult

BindingResult는 Validator를 상속받는 클래스에서 유효성 검증을 진행한 후 오류가 있다면 오류를 담게 됩니다. 

BindingResult의 구현체는 여러 가지가 있지만, 사용하게 되는 구현체는 BeanPropertyBindingResult입니다. 

해당 구현체는 AbstractPropertyBindingResult를 상속받고 AbstractPropertyBindingResult는 AbstractBindingResult를 상속받고, AbstractBindingResult는 BindingResult를 상속받습니다. 

 

결국 아래의 구조입니다.  

BeanPropertyBindingResult -> AbstractPropertyBindingResult -> AbstractBindingResult -> BindingResult

 

동작 과정에 대해서는 다음 포스팅에서 설명하겠습니다. 사용 방법에 대해 설명드리겠습니다. 

 

Springboot에서 지원해주는 Validation을 사용하기 위해선 아래 디펜던시를 추가해줘야 합니다.

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

 

BindingResult를 적절하게 사용하기 위해서는 여러가지 방법이 있습니다. html, jsp 등등 SSR 사용법, RestController CSR 사용법

 

 

SSR BindingResult

1. 오류 코드와 메시지 작성(이것을 작성하지 않는다면 spring에서 제공하는 기본 message가 나가게 됩니다.)

2. Controller에서 BindingResult설정

2. 검증을 하는 Validator 구현 or (@Validated, @Valid 어노테이션 활용 Bean Validation에서 보겠습니다.) 

 

오류 코드와 메시지 작성에 대해서 알아보겠습니다. 

errors.yml or errors.properties 파일을 resoureces 밑에 생성합니다. 

그 후 아래와 같이 작성합니다.

 

 

errors.yml

notEmail: 
  email: email 형식으로 입력해주세요.
range:
  password: password의 길이는 {0}~{1}으로 해주세요.
  age: age는 0~100 사이로 입력해주세요.

 

 

여기서 오류 메시지 생성 규칙은 

아래와 같습니다.

## 오류 생성 규칙
오류코드:
  객체명:
    필드명:

오류코드:
  필드명:


오류코드:
  필드타입:

오류코드:


## 인자로 넘길 게 있을 때
오류코드:
  객체명:
    필드명: {0} ~ {1} 메시지

오류 코드들은 조건이 상세한 것 즉 까다로운 것부터 살펴보고 만족한다면 반환하고, 없다면 점차 낮은 레벨로 가게 됩니다. 

여기서 만약 인자로 넘기고 싶은 숫자가 있다면 넘기면 됩니다.

 

이제 오류 메시지에 대한 설정은 끝났습니다. 

 

 

Controller에서 BindingResult 오류 설정

@PostMapping("/validation/v2")
public String validationV2(@RequestBody UserDto dto, BindingResult bindingResult){
	// BindResult는 반드시 검증을 수행할 객체 뒤에 바로 와야합니다. 


    int contain1 = StringUtils.countOccurrencesOf(dto.getEmail(), "@");
    int contain2 = StringUtils.countOccurrencesOf(dto.getEmail(), ".com");

    if(contain1!=1 || contain2!=1){
        bindingResult.rejectValue("email","notEmail","email 형식으로 입력해주세요.");

    }

    if(dto.getPassword().length()<8 || dto.getPassword().length()>16){
        bindingResult.rejectValue("password","range","password의 길이는 8~16으로 해주세요.");
    }

    if(dto.getAge()<0 || dto.getAge()>100){
        bindingResult.rejectValue("age","range","age는 0~100 사이로 입력해주세요.");
    }
    if(bindingResult.hasErrors()){
    	return "/page" // 원래 페이지로 다시 이동 임의로 page로 설정한 것임
    }

    return "redirect:/" // 정상 동작 임의로 /로 리다이렉팅한 것임
}

주석으로 설명을 추가했습니다. 

rejectValue와 reject에 대해서 알아보겠습니다. 

 

 

----- 필드 오류
void rejectValue(@Nullable String field, String errorCode);
void rejectValue(@Nullable String field, String errorCode, String defaultMessage);
void rejectValue(@Nullable String field, String errorCode,
			@Nullable Object[] errorArgs, @Nullable String defaultMessage);
            
            
----- 객체 오류

void reject(String errorCode);
void reject(String errorCode, String defaultMessage);
void reject(String errorCode, @Nullable Object[] errorArgs, @Nullable String defaultMessage);

1. rejectValue: 데이터 객체의 하나의 필드 값에서 유효성 검증이 실패했을 때 사용합니다. 

- errorCode, field-> errors.yml에서 적은 것

- errorsArgs -> errors.yml의 적은 것 중 인자로 넘길 것

- defaultMessage -> errors.yml에서 못 찾았을 경우 들어갈 기본 메시지를 설정할 수 있다. 

2. reject: 데이터 객체의 하나의 필드 값이 아닌 복합적으로 필드 값을 사용하며, 유효성 검증이 실패했을 때 사용합니다. 

 

아래와 같이 html을 작성한다면 th:errors="*{email}"에 대한 메시지가 있으므로 이메일 오류는 무시가 되고, 저희가 errors.yml에 작성한 내용으로 치환되어 반환됩니다.

<div>
    <label for="email" th:text="#{label.userDto.Email}">상품명</label>
    <input type="text" id="email" th:field="*{email}"
           th:errorclass="field-error" class="form-control"
           placeholder="이름을 입력하세요">
    <div class="field-error" th:errors="*{email}">
        이메일 오류
    </div>
</div>

검증 과정 분리를 분리하도록 Validator를 구현해보겠습니다. 

 

 

Validator 

@Component
public class UserValidator implements Validator {
    @Override
    public boolean supports(Class<?> clazz) {
        return UserDto.class.isAssignableFrom(clazz);
    }

    @Override
    public void validate(Object target, Errors errors) {
        UserDto dto = (UserDto) target;

        int contain1 = StringUtils.countOccurrencesOf(dto.getEmail(), "@");
        int contain2 = StringUtils.countOccurrencesOf(dto.getEmail(), ".com");

        if(contain1!=1 || contain2!=1){
            errors.rejectValue("email","notEmail");
        }

        if(dto.getPassword().length()<8 || dto.getPassword().length()>16){
            errors.rejectValue("password","range",new Object[]{8,16},null);
        }

        if((dto.getAge()<0 || dto.getAge()>100) || dto.getAge().getClass().isAssignableFrom(Number.class)){
            errors.rejectValue("age","range");
        }
    }
}

support는 검증하려는 객체가 유효한 객체인지 판별합니다. 그 후 아래의 validate를 수행합니다. 

@PostMapping("/validation/v3")
public String validationV3(@RequestBody UserDto dto, BindingResult bindingResult) {

    if (userValidator.supports(UserDto.class)) {
        userValidator.validate(dto, bindingResult);
    }

    if(bindingResult.hasErrors()){
        return "/page";  // 원래 페이지로 다시 이동 임의로 page로 설정한 것임
    }

    return "redirect:/"; // 정상 동작 임의로 /로 리다이렉팅한 것임

}

Controller를 위와 같이 수정할 수 있습니다. 더 이상 Controller에서 검증을 진행하지 않습니다. 하지만 userValidaor를 계속해서 Controller내에서 호출해야 하는 단점이 있습니다. 이것을 개선해보겠습니다. 

 

 

UserController 추가

만든 UserValidator를 추가해줍니다.

@InitBinder
public void init(DataBinder binder){
    binder.addValidators(userValidator);
}
@PostMapping("/validation/v4")
public String validationV4(@Validated @RequestBody UserDto dto, BindingResult bindingResult) {

    if(bindingResult.hasErrors()){
        return "/page";  // 원래 페이지로 다시 이동 임의로 page로 설정한 것임
    }
    return "redirect:/"; // 정상 동작 임의로 /로 리다이렉팅한 것임
}

@Validated 어노테이션: 검증을 진행하는 객체임을 뜻합니다. 

더이상 Controller에서 검증 로직을 실행하지 않아도 됩니다

 

 

CSR BindingResult

UserValidtor의 필드 오류들에 defaultMessage를 추가해줍니다.

@Component
public class UserValidator implements Validator {
    @Override
    public boolean supports(Class<?> clazz) {
        return UserDto.class.isAssignableFrom(clazz);
    }

    @Override
    public void validate(Object target, Errors errors) {
        UserDto dto = (UserDto) target;

        int contain1 = StringUtils.countOccurrencesOf(dto.getEmail(), "@");
        int contain2 = StringUtils.countOccurrencesOf(dto.getEmail(), ".com");

        if(contain1!=1 || contain2!=1){
            errors.rejectValue("email","notEmail","email 형식으로 입력해주세요.");
        }

        if(dto.getPassword().length()<8 || dto.getPassword().length()>16){
            errors.rejectValue("password","range","password의 길이는 8~16으로 해주세요.");
        }

        if((dto.getAge()<0 || dto.getAge()>100) || dto.getAge().getClass().isAssignableFrom(Number.class)){
            errors.rejectValue("age","range","age는 0~100 사이로 입력해주세요.");
        }
    }
}

 

 

UserController

@PostMapping("/validation/v5")
public ResponseEntity<?> validationV5(@RequestBody UserDto dto, BindingResult bindingResult){

    HashMap<String, Object> errors = new HashMap<>();

    userValidator.validate(dto, bindingResult);

    if(bindingResult.hasErrors()){
        bindingResult.getAllErrors().stream()
                .forEach(a->{
                    String field = ((FieldError) a).getField();
                    String defaultMessage = a.getDefaultMessage();
                    errors.put(field, defaultMessage);
                });
        return new ResponseEntity<>(errors, HttpStatus.BAD_REQUEST);
    }

    return new ResponseEntity<>(dto,HttpStatus.OK);
}

SSR과 대부분 비슷하지만 다른 점은 일일이 오류를 추가한 후에 보내줘야 합니다. 이제 일일히 Controller에서와 Validator를 구현하는 것과 다르게 간단한 어노테이션으로 검증하는 BeanValidation에 대해서 알아보겠습니다.

 

 

4. BeanValidation

Bean Validation은 특정한 구현체가 아니라 Bean Validation 2.0(JSR-380)이라는 기술 표준입니다. 
간단하게 이야기한다면  검증 애노테이션과 여러 인터페이스의 모음입니다. 

Bean Validation을 확인하기 위해 @InitBinder를 주석 처리합니다. 

UserDto 수정

@Data
@AllArgsConstructor
@NoArgsConstructor
public class UserDto {


    @Email(message = "email 형식으로 기입해주세요.")
    public String email;

    @Size(min = 8, max = 16, message = "password의 길이는 8~16자리로 해주세요.")
    public String password;

    @Range(min = 0,max = 100, message = "age는 0~100 사이로 입력해주세요.")
    public Long age;

}

필드에 어노테이션을 선엄함으로써 사용할 수 있습니다. 여기서 사용한 어노테이션들은 각각 eamil 양식 확인, String의 길이 확인, 숫자의 범위 확인입니다. 

 

어떤 어노테이션들이 있는지 알고 싶다면 Externa Libraries -> jakarta.validation.javax.constraints에서 확인하실 수 있습니다.  

 

동작하는지 확인해보겠습니다. 

{
    "email""rkdlem48",
    "password""hi",
    "age"101
}

위와 같은 요청을 보냈을 때 아래와 같은 오류가 오는 것을 볼 수 있습니다.

\

어노테이션만으로 간단하게 유효성을 검증할 수 있었습니다.

하지만 BeanValidation으로는 TypeMismatch를 잡지는 못했습니다.(물론 제가 찾지 못한 것일 수도 있습니다.) 

그래서 ResponseEntityExceptionHandler로 이것을 해결해보겠습니다.

 

 

5. ResponseEntityExceptionHandler

@ExceptionHandler 메서드를 제공합니다. 이 메서드는 ModelAndView를 반환하는 DefaultHandlerExceptionResolver와 달리 메시지 변환기를 사용하여 응답에 쓰기 위한 ResponseEntity를 반환합니다. 

사용법에 대해서 알아보겠습니다. 

 

 

TypeMismatchConverter

TypeMismatc가 발생했을 때 필드와 메시지를 바꿔서 반환해주는 컨버터를 정의합니다.

필드와 메시지를 뽑아내는 로직은 직접 한 번 오류를 터트리고, 문장을 가지고 작성해보시는 것이 좋습니다.

@Component
public class TypeMismatchConvertor {
    private final Map<String, String> typeMismatchMessages = new HashMap<>();


    @PostConstruct
    public void init(){
        typeMismatchMessages.put("age", "나이는 숫자로 입력해주세요.");
    }

    public Result convert(String originalMessage){
        int chain = originalMessage.lastIndexOf("[");
        String prefix = originalMessage.substring(chain);
        String resultString = prefix.substring(2, prefix.length() - 3);
        return new Result(resultString, typeMismatchMessages.get(resultString));
    }

    @Data
    @AllArgsConstructor
    @NoArgsConstructor
    public static class Result{
        String field;
        String description;

    }
}

 

 

UserValidationHandler

UserValidationHandler를 적용할 범위를 @RestControllerAdvice를 통해서 지정합니다. 

@RestControllerAdvice(basePackages = "com.example.mvc")
@RequiredArgsConstructor
public class UserValidationHandler extends ResponseEntityExceptionHandler {

    private final TypeMismatchConvertor typeMismatchConvertor;


    @Override
    protected ResponseEntity<Object> handleMethodArgumentNotValid(MethodArgumentNotValidException ex, HttpHeaders headers, HttpStatus status, WebRequest request) {
        Map<String, Object> map = new HashMap<>();
        ex.getBindingResult().getAllErrors().stream()
                .forEach(a -> {
                    String fieldName = ((FieldError)a).getField();
                    String message = a.getDefaultMessage();
                    map.put(fieldName,message);
                });
        return new ResponseEntity<>(map, HttpStatus.BAD_REQUEST);
    }

    @Override
    protected ResponseEntity<Object> handleHttpMessageNotReadable(HttpMessageNotReadableException ex, HttpHeaders headers, HttpStatus status, WebRequest request) {
        TypeMismatchConvertor.Result convert = typeMismatchConvertor.convert(ex.getMessage());
        return new ResponseEntity<>(convert,HttpStatus.BAD_REQUEST);
    }
}

그 후 두 가지 메서드를 오버 라이딩합니다. (설명은 주석에 써놓겠습니다.) 

1. handleMethodArgumentNotValid

-> 위에서 만든 Bean Validation을 통과하지 못했을 때 Error가 담기게 됩니다.

2. handleHttpMessageNotReadable

-> TypeMismatch로 인한 오류가 발생할 때 Error가 담기게 됩니다

 

 

UserController

이전 것들과 비교해서 간단해진 것을 볼 수 있습니다. 

@PostMapping("/validation/v6")
public ResponseEntity<?> validationV6(@Validated @RequestBody UserDto dto){
    return new ResponseEntity<>(dto,HttpStatus.OK);
}

 

 

5-1. Test

아래 요청을 보내겠습니다. 
{
    "email""rkdlem48",
    "password""hi",
    "age""101"
}

 

정상 작동을 확인할 수 있고, age에 대해서 TypeMismatch도 해결이 가능한지 보겠습니다. 

아래 요청을 보내겠습니다.

{
    "email""rkdlem48@gmail.com",
    "password""123456789",
    "age""a"
}

이전에는 서버 쪽에서 예외가 터졌지만 이번에는 정상적으로 동작한 것을 볼 수 있습니다.


정리하기

Validation -> 데이터의 유효성을 검증하는 방법

 

Controller 직접 검증 -> Controller내에서 데이터를 검증하고 Map에 담아서 오류를 담아 보냄.

->검증을 하려는 메서드에 로직이 들어가므로 복잡하고, 비용이 높다.

 

BindingResult -> SSR, CSR 방법에서도 모두 편리하지만 검증 로직이 들어가고, Validator를 구현해야 함

 

Bean Validation -> 검증 로직과, Validator를 구현해야 하는 번거로움이 줄어듬, BindingResult와 같이 사용할 수 있고, 어노테이션만으로 간단하게 유효성을 검증할 수 있다.

-> Bean Validation에서는 TypeMismatch를 잡지 못했다.

 

ResponseEntityExceptionHandler -> Bean Validation으로 잡지 못한 TypeMismatch를 잡을 수 있다. 

 

읽어주셔서 감사합니다. 다음 포스팅은 이러한 Validation이 어떻게 동작하는지에 대해서 알아보겠습니다. 감사합니다.

 

모든 코드는 아래 링크에서 확인 가능합니다.

https://github.com/rlaehdals/spring-validation

 

GitHub - rlaehdals/spring-validation

Contribute to rlaehdals/spring-validation development by creating an account on GitHub.

github.com