프론트에서 정보를 입력받을 때, 이름에 공백이 들어올 때도 있고, 핸드폰 번호가 정해진 형식대로 들어오지 않을 수도 있다. 관련된 정보 검증 로직을 처리하다보면 검증 로직이 길어지기도 하고 정작 중요한 서비스 로직에 그만큼 공을 들이지 못하게 되기도 한다. 이를 개선하고자 등장한 것이 Spring Validation으로, gradle dependency로 사용할 수 있다. 정규식도 사용이 가능하다.
Validation 사용 이유:
1. 유효성 검증 코드의 길이를 줄일 수 있다.
2. Service Logic에 대한 방해를 최소화한다.
3. 유효성 검증 코드가 흩어져 있는 경우 찾기 힘들기 때문이다.
4. 검증 로직의 변경이 전체 코드에 미치는 영향을 최소화한다.
주로 사용하는 Validation 애너테이션은 다음과 같다.
애너테이션 | 기능 | 특이사항 |
---|---|---|
@Size | 문자 길이 측정 | Int Type 불가 |
@NotNull | null 불가 | |
@NotEmpty | null, "" 불가 | |
@NotBlank | null, "", " " 불가 | |
@Pattern | 정규식 적용 | |
@Max | 최대값 | |
@Min | 최소값 | |
@AssertTrue/False | 별도의 logic 적용 | |
@Valid | 해당 object validation 실행 | |
@Past | 과거 날짜 | |
@PastOrPresent | 오늘이나 과거 날짜 | |
@Future | 미래 날짜 | |
@FutureOrPresent | 오늘이나 미래 날짜 |
회원가입을 위해 이름, 전화번호, 이메일, 나이 등을 받는다고 하자.
@Data
@NoArgsConstructor
@AllArgsConstructor
@Builder
@JsonNaming(value= PropertyNamingStrategies.SnakeCaseStrategy.class)
public class UserRegisterRequest {
@NotBlank // != null & != "" && != " "
private String name;
@Size(min=1,max=12)
@NotBlank
private String password;
@Min(1)
@Max(100)
private Integer age;
@Email
private String email;
@Pattern(regexp = "^\\d{2,3}-\\d{3,4}-\\d{4}$", message = "휴대폰 번호 양식을 지켜주세요.")
private String phoneNumber;
@FutureOrPresent
private LocalDateTime registerAt;
}
이름은 공백일 수 없고, 비밀번호는 1~12자 내여야 하며, 나이는 100살까지만 입력할 수 있다.
이메일은 이메일 형식 검증을 하는 애너테이션이 따로 있다.
Pattern은 regexp로 정규표현식을 받는다.
검증에 실패하거나 예외가 발생할 경우에도 클라이언트가 파싱할 수 있도록 UserRegisterRequest를 받아 규격화하는 Api 클래스를 하나 더 만든다.
@Data
@AllArgsConstructor
@NoArgsConstructor
@Builder
@JsonNaming(value = PropertyNamingStrategies.SnakeCaseStrategy.class)
public class Api<T> {
@Valid
private T data;
private String resultCode;
private String resultMessage;
private Error error;
@Data
@AllArgsConstructor
@NoArgsConstructor
@Builder
@JsonNaming(value = PropertyNamingStrategies.SnakeCaseStrategy.class)
public static class Error{
private List<String> errorMessage;
}
}
Api는 Error 객체를 가지고 있는데, 검증 과정에서 발생한 오류 메시지들을 리스트로 받는다.
UserRegisterRequest에 설정해둔 검증 애너테이션들이 실제로 검증 과정을 거치려면, 이를 받는 객체가 @Valid 애너테이션을 붙여주어야 한다.
@Slf4j
@RestController
@RequestMapping("/api/user")
public class UserApiController {
@PostMapping("")
public Api<UserRegisterRequest> register
(@Valid @RequestBody Api<UserRegisterRequest> userRegisterRequest){
log.info("init: {}",userRegisterRequest);
var body = userRegisterRequest.getData();
Api<UserRegisterRequest> response = Api.<UserRegisterRequest>builder()
.resultCode(String.valueOf(HttpStatus.OK.value()))
.resultMessage(HttpStatus.OK.getReasonPhrase())
.data(body)
.build();
return response;
}
}
POST 요청으로 Api<UserRegisterRequest> 를 넘겨받으면 유효성 검사를 하고 (@Valid 애너테이션 참고), 유저 정보를 새 Api<UserRegisterRequest>에 200 코드와 함께 담아 반환한다.
Body로 받은 게 Api객체여서 굳이 다시 Api 객체에 감싸 반환하는 것으로, 만약 UserRegisterRequest 객체로 받는다고 하면 꺼냈다 새로 감쌀 필요 없이 바로 반환할 Api 객체에 넣어줄 수 있다.
만약 유효성 검사를 통과하지 못한 경우에는 BindingResult의 getFieldError를 통해 오류를 받아올 수 있다. 이 때, BindingResult도 함께 파라미터로 받아와야 한다.
@PostMapping("")
public Api<UserRegisterRequest> register
(@Valid @RequestBody Api<UserRegisterRequest> userRegisterRequest, BindingResult bindingResult){
if (bindingResult.hasErrors()){
log.error("",e);
var errorMessageList = bindingResult.getFieldErrors().stream()
.map(it->{
var format = "{%s} : {%s} 은 {%s}";
var message = String.format(format, it.getField(), it.getRejectedValue(), it.getDefaultMessage());
return message;
}).collect(Collectors.toList());
var error = Api.Error.builder()
.errorMessage(errorMessageList)
.build();
var errorResponse = Api.builder()
.resultCode(String.valueOf(HttpStatus.BAD_REQUEST.value()))
.resultMessage(HttpStatus.BAD_REQUEST.getReasonPhrase())
.error(error)
.build();
return errorResponse;
}
log.info("init: {}",userRegisterRequest);
var body = userRegisterRequest.getData();
Api<UserRegisterRequest> response = Api.<UserRegisterRequest>builder()
.resultCode(String.valueOf(HttpStatus.OK.value()))
.resultMessage(HttpStatus.OK.getReasonPhrase())
.data(body)
.build();
return response;
실제 로직보다 검증 로직이 더 길어졌다. 만약에 유효성 검사 애너테이션도 없었다면 if문으로 공백인 경우나 이메일 형식 등을 하나하나 다 봐야 하는데, 그랬으면 더 길어졌을 것이다...
에러 발생시 400번 코드와 함께 유효성 검증 오류 사항을 반환하는 부분은 지난 시간에 배웠던 예외 핸들러로 분리할 수 있다.
BindingResult와 if문을 지우고 핸들러를 새로 만들었다.
@Slf4j
@RestControllerAdvice
public class ValidationExceptionHandler {
@ExceptionHandler(value={MethodArgumentNotValidException.class})
public ResponseEntity<Api> validationException
(MethodArgumentNotValidException e){
log.error("",e);
var errorMessageList = e.getFieldErrors().stream()
.map(it->{
var format = "{%s} : {%s} 은 {%s}";
var message = String.format(format, it.getField(), it.getRejectedValue(), it.getDefaultMessage());
return message;
}).collect(Collectors.toList());
var error = Api.Error.builder()
.errorMessage(errorMessageList)
.build();
var errorResponse = Api.builder()
.resultCode(String.valueOf(HttpStatus.BAD_REQUEST.value()))
.resultMessage(HttpStatus.BAD_REQUEST.getReasonPhrase())
.error(error)
.build();
return ResponseEntity.status(HttpStatus.BAD_REQUEST)
.body(errorResponse);
}
}
유효성 검증에 실패했을 때 발생하는 MethodArgumentNotValidException을 받아서 에러가 난 필드들을 받아오고, 이를 stream으로 매핑하고, format을 사용하여 문자열을 만들어 이를 Error 객체 안의 리스트로 만든다.
오류가 났더라 하더라도 객체를 받으면 200번 코드가 나오기 때문에 ResponseEntity로 한번 감싸 400번 코드가 나오게 했다.