@Valid 검증

merci·2023년 3월 27일
0

Rest Api 프로젝트

목록 보기
4/6

서버로 들어오는 데이터중에 null이 아니어야 하는 값들이 존재한다.
@Valid 를 알기 전에는 아래와 같은 코드들로 유효성 검사를 했었다.

	if(input == null || input.isEmpty()){
    	// 익셉션 발생 	
    }
        // 또는
	if(ObjectUtils.isEmpty(input){
    	// 익셉션 발생 	
    }    

자바에서 제공해주는 @Valid 어노테이션을 이용해서 이러한 코드를 바꿔보자


@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 에 저장된다.


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 를 파라미터로 가지는 메소드를 리플렉션으로 검사한다.

BindingResultErrors 인터페이스를 상속한 인터페이스이다.

유효성 검사를 통과하지 못하면 @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() 에는 alerthistory.back() 코드가 들어 있다.

이러한 과정을 통해서 유효성 검사를 진행하는데 복잡한 if조건을 만들 필요가 없어 컨트롤러가 아주 깔끔해진다.

  • @Valid 어노테이션을 사용하지 않고 익셉션 핸들러만 사용했을때의 컨트롤러
  • @Valid 어노테이션을 사용한 컨트롤러


Errors

BuildingresultErrors 인터페이스를 구현한 인터페이스이다.
여기서는 발생한 익셉션의 에러 내용을 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 유효성을 검사할수도 있다.


AOP없이 핸들링

데이터 검증을 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을 핸들링 해야 한다.


유효성 검사에 사용되는 어노테이션

profile
작은것부터

0개의 댓글