서버로 들어오는 데이터중에 null이 아니어야 하는 값들이 존재한다.
@Valid
를 알기 전에는 아래와 같은 코드들로 유효성 검사를 했었다.
if(input == null || input.isEmpty()){
// 익셉션 발생
}
// 또는
if(ObjectUtils.isEmpty(input){
// 익셉션 발생
}
자바에서 제공해주는 @Valid
어노테이션을 이용해서 이러한 코드를 바꿔보자
유효성을 검증하는 @Valid
같은 AOP를 사용하기 위해서는 의존성을 먼저 추가해야 한다.
아래
implementation 'javax.validation:validation-api:2.0.1.Final'
스프링 부트를 이용할 경우 스타터 의존성을 추가한다.
implementation 'org.springframework.boot:spring-boot-starter-validation'
이제 @Valid
를 유효성 검사가 필요한 오브젝트앞에 붙인다.
그리고 BindingResult
인터페이스도 추가한다.
public ResponseEntity<?> update(@RequestBody @Valid UpdateDto uDto, BindingResult bindingResult) {
//
}
그리고 유효성 검증이 필요한 오브젝트의 멤버변수에 검증 어노테이션을 추가한다.
public static class UpdateDto{
@NotNull(message = "지원아이디가 필요합니다/")
private Integer applyId;
@NotNull(message = "기업아이디가 필요합니다/")
private Integer compId;
@NotNull(message = "상태정보가 필요합니다/")
private Integer state;
}
@Valid
는 유효성 검사 어노테이션중 하나로 요청 데이터를 객체에 매핑할때 유효성 검사를 지원하는데 이때의 검사 결과를 BindingResult
에 저장하게 된다.
위 코멘트를 통과하지 못하면 익셉션이 발생하게 되고 해당 익셉션은 BindingResult
에 저장된다.
저장된 에러를 AOP를 구현해서 핸들링한다.
@Aspect
@Component
public class ValidAdvice {
@Pointcut("@annotation(org.springframework.web.bind.annotation.PostMapping)")
public void postMapping() {
}
@Pointcut("@annotation(org.springframework.web.bind.annotation.PutMapping)")
public void putMapping() {
}
@Around("postMapping() || putMapping()")
public Object validationAdvice(ProceedingJoinPoint proceedingJoinPoint) throws Throwable {
Object[] args = proceedingJoinPoint.getArgs();
for (Object arg : args) {
if (arg instanceof BindingResult) {
BindingResult bindingResult = (BindingResult) arg;
if (bindingResult.hasErrors()) {
Map<String, String> errorMap = new HashMap<>();
for (FieldError error : bindingResult.getFieldErrors()) {
errorMap.put(error.getField(),error.getDefaultMessage());
}
throw new MyValidationException(errorMap);
}
}
}
return proceedingJoinPoint.proceed(); // 정상적으로 해당 메서드를 실행해라!!
}
}
Pointcut
을 이용해서 요청바디가 들어가는 PUT 요청과 POST 요청시 BindingResult
를 파라미터로 가지는 메소드를 리플렉션으로 검사한다.
BindingResult
는 Errors
인터페이스를 상속한 인터페이스이다.
유효성 검사를 통과하지 못하면 @NotNull
, @NotBlank
, @NotEmpty
같은 다양한 유효성 체크 어노테이션이 에러를 발생시키고 bindingResult.hasErrors()
가 true가 되어 Map에 에러를 저장하게 된다.
ValidAdvice
클래스는 커스텀으로 만들어준 MyValidationException
에 예외를 전달하게 된다.
@Getter
public class MyValidationException extends RuntimeException {
private Map<String, String> erroMap;
public MyValidationException(Map<String, String> erroMap) {
this.erroMap = erroMap;
}
}
MyValidationException
는 런타임 익셉션을 상속하고 에러 정보가 담긴 Map을 저장한다.
발생한 익셉션을 처리하는 Advice는
@RestControllerAdvice
public class ExceptionAdvice {
@ExceptionHandler(MyValidationException.class)
public ResponseEntity<?> error(MyValidationException e){
String errMsg = e.getErroMap().toString();
String devideMsg = errMsg.split("/")[0].split("=")[1];
return new ResponseEntity<>(Script.back(devideMsg), HttpStatus.BAD_REQUEST);
}
@ExceptionHandler(CustomException.class)
public ResponseEntity<?> customException(CustomException e) {
return new ResponseEntity<>(Script.back(e.getMessage()), e.getStatus());
}
@ExceptionHandler(CustomApiException.class)
public ResponseEntity<?> customApiException(CustomApiException e) {
return new ResponseEntity<>(new ResponseDto<>(-1, e.getMessage(), null), e.getStatus());
}
}
익셉션을 핸들렁하는 코드를 등록하고 발생한 메세지를 적절하게 분리시켜서 뷰에 전달하도록 만들었다.
Script.back()
에는 alert
와 history.back()
코드가 들어 있다.
이러한 과정을 통해서 유효성 검사를 진행하는데 복잡한 if조건을 만들 필요가 없어 컨트롤러가 아주 깔끔해진다.
@Valid
어노테이션을 사용하지 않고 익셉션 핸들러만 사용했을때의 컨트롤러@Valid
어노테이션을 사용한 컨트롤러
Buildingresult
는 Errors
인터페이스를 구현한 인터페이스이다.
여기서는 발생한 익셉션의 에러 내용을 Buildingresult
에 저장했지만 Buildingresult
는 에러의 결과뿐만이 아니라 메소드에서 반환된 결과를 나타내기도 한다. ( 정상적인 로직 )
Errors
인터페이스를 구현하지 않고 직접 Errors
인터페이스를 사용하여 익셉션을 핸들링 해보자.
먼저 @Valid
를 핸들링하기 위한 익셉션 클래스를 만든다.
이번에는 발생하는 모든 익센션의 종류를 고려하여 다음과 같이 만들었다.
@Getter
public class Exception400 extends RuntimeException {
private String key;
private String value;
public Exception400(String key, String value) {
super(value);
this.key = key;
this.value = value;
}
public ResponseDTO<?> body(){
ResponseDTO<ValidDTO> responseDto = new ResponseDTO<>();
ValidDTO validDTO = new ValidDTO(key, value);
responseDto.fail(HttpStatus.BAD_REQUEST, "badRequest", validDTO);
return responseDto;
}
public HttpStatus status(){
return HttpStatus.BAD_REQUEST;
}
}
@Valid
어노테이션이 사용되는곳에서 익셉션의 정보를 Errors
인터페이스에 전달한다.
@PostMapping("/join")
public ResponseEntity<?> join(@RequestBody @Valid JoinInDTO joinInDTO, Errors errors) {
// 생략
return ResponseEntity.ok(responseDTO);
}
AOP를 구현해서 Errors
인터페이스에 에러 정보가 전달되었을 경우를 작성한다.
import org.aspectj.lang.JoinPoint;
import org.aspectj.lang.annotation.Aspect;
import org.aspectj.lang.annotation.Before;
import org.aspectj.lang.annotation.Pointcut;
import org.springframework.stereotype.Component;
import org.springframework.validation.Errors;
import shop.mtcoding.servicebank.core.exception.Exception400;
@Aspect
@Component
public class MyValidAdvice {
@Pointcut("@annotation(org.springframework.web.bind.annotation.PostMapping)")
public void postMapping() {
}
@Pointcut("@annotation(org.springframework.web.bind.annotation.PutMapping)")
public void putMapping() {
}
@Before("postMapping() || putMapping()")
public void validationAdvice(JoinPoint jp) {
Object[] args = jp.getArgs();
for (Object arg : args) {
if (arg instanceof Errors) {
Errors errors = (Errors) arg;
if (errors.hasErrors()) {
throw new Exception400(
errors.getFieldErrors().get(0).getField(),
errors.getFieldErrors().get(0).getDefaultMessage()
);
}
}
}
}
}
이번에는 Exception400
익셉션에 에러 데이터를 전달했다.
발생한 익셉션을 프론트로 전달하여 프론트에서 처리하도록 할수도 있다.
@RequiredArgsConstructor
@RestControllerAdvice
public class MyExceptionAdvice {
@ExceptionHandler(Exception400.class)
public ResponseEntity<?> badRequest(Exception400 e){
return new ResponseEntity<>(e.body(), e.status());
}
public ResponseDTO<?> body(){
ResponseDTO<ValidDTO> responseDto = new ResponseDTO<>();
ValidDTO validDTO = new ValidDTO(key, value);
responseDto.fail(HttpStatus.BAD_REQUEST, "badRequest", validDTO);
return responseDto;
}
// 생략
}
이와 같은 방법으로 Errors 인터페이스와 익셉션처리를 세분화하는 방법으로 @Valid
유효성을 검사할수도 있다.
데이터 검증을 Aspect로 분리시키지 않고 핸들링하는 방법을 알아보자.
기본적으로 @NotNull
, @NotBlank
, @NotEmpty
같은 코멘트들이 발생시키는 에러는 javax.validation.ConstraintViolationException
이다.
하지만 스프링에서는 다른 에러가 발생한다.
스프링은 Bean Validation(JSR 380)
의 구현체인 Hibernate Validator
를 이용하여 @Valid
어노테이션을 처리한다.
이때 조건을 위배하면 MethodArgumentNotValidException
에러가 발생한다.
이 에러는 BindException
의 하위 익셉션으로 우리는 BindException
만 핸들링 하면 간단하게 해결된다.
따라서 AOP를 구현할 필요없이 단순히 아래코드를 RestControllerAdvice
에 추가함으로써 핸들링을 구현할 수 있다.
@ExceptionHandler(BindException.class)
public ResponseEntity<?> customException(BindException e){
BindingResult bindingResult = e.getBindingResult();
List<FieldError> fieldErrors = bindingResult.getFieldErrors();
// 첫 번째 필드 에러의 메시지 가져오기
String errorMessage = fieldErrors.get(0).getDefaultMessage();
return new ResponseEntity<>(Script.back(errorMessage), HttpStatus.BAD_REQUEST);
}
하지만 스프링을 사용하지 않는다면 ConstraintViolationException
을 핸들링 해야 한다.