@Valid와 예외 처리

2AST_\·2024년 5월 17일

스프링

목록 보기
2/2
post-thumbnail

요즘 동작 원리만 파서 피곤해 죽겠다. 그런 의미로 이번엔 @Valid 어노테이션과 여기서 발생한 Exception을 처리하는 방법만 알아보도록 하겠다. (작동원리는 너무 Deeeep해..)

@Valid

@Valid의 사용예는 정말 간단하다. 예시로 우리가 회원가입할 때, 이메일 형식인지 아닌지 RequestBody에서 검사하기 위해서 쓴다. 이메일 형식을 확인하는 과정은 굳이 서비스까지 타고 갈 필요가 없다.

    @PostMapping("/sign-up")
    public ResponseEntity signUp(@Valid @RequestBody SignUpRequest signUpRequest) {
        memberService.signUp(SignUpServiceRequest.builder()
                .email(signUpRequest.getEmail())
                .name(signUpRequest.getName())
                .contact(signUpRequest.getContact())
                .userInfo(signUpRequest.getUserInfo())
            .build());

        return ResponseEntity.status(HttpStatus.CREATED).body(
            ApiUtils.created("Member Created"));
    }

물론 @Valid만 기재한다고... 작동하는 것은 절대 아니고, 같이 써줘야 하는 부분들이 존재한다. 아래의 @Email, @NotNull 같은 것들이다.

@Getter
@NoArgsConstructor
@AllArgsConstructor(access = AccessLevel.PRIVATE)
@Builder
public class SignUpRequest {
    @NotNull(message = "email must be not null")
    @Email(message = "is not email format")
    private String email;

    @JsonProperty("user_info")
    @Pattern(regexp = "^[A-Za-z[0-9]]{10,40}$", message = "user_info must be a string of at least 10 characters combining English and numbers.")
    private String userInfo;

    @NotBlank(message = "name must be not blank")
    private String name;

    @NotNull(message = "contact must be not null")
    @Size(min = 11, max = 11, message = "contact's length must be 11 ex)01023333244")
    private String contact;
}

사실 뭐가 있는지는 구글에 찾아보면 다 나온다. (적기 귀찮아서 그런 거 아님)
아래의 블로그처럼 정리 잘 된 것들이 많으니 확인해보자.
@Valid 정리해보기

여기서 중요한 것(?)은 message이다. 여기서 message를 통해 Exception이 발생할 때 어떤 메시지로 나올지 설정할 수 있다.

@ControllerAdvice

전역적으로 Exception을 처리하기 위해 ControllerAdvice+ExceptionHandler조합을 많이 사용한다. 그래서 전역적인 예외 처리를 위해 나온 개념으로도 많이 소개되지만 사실 그 반대이다.

Typically, the @ExceptionHandler, @InitBinder, and @ModelAttribute methods apply within the @Controller class (or class hierarchy) in which they are declared.

Spring 공식문서: Controller Advice

대충 뜻은 일반적으로 ExceptionHandler, InitBinder, ModelAttribute가 선언된 컨트롤러 클래스란다. ExceptionHandler 때문에 ControllerAdvice가 아니라 전역적으로 처리할 것이 있기 때문에 ControllerAdvice를 쓰는 것이다.

하지만... 오늘은 예외 처리로 ControllerAdvice를 활용하는 것을 보도록 하겠다.
일단 Exception이 발생할 때, 줄 Response 형태를 살펴보겠다.

public class ApiUtils {
    public static <T> ApiResult<T> success(String message, T data) {
        return ApiResult.<T>builder()
            .success(true)
            .message(message)
            .data(data)
            .build();
    }

    public static <T> ApiResult<T> created(String message) {
        return ApiResult.<T>builder()
            .success(true)
            .message(message)
            .build();
    }

	// 사용할 메서드
    public static ApiException exceptionOccurred(String message) {
        return ApiException.builder()
            .success(false)
            .messages(Collections.singletonList(message))
            .build();
    }

    public static ApiException exceptionsOccurred(List<String> messages) {
        return ApiException.builder()
            .success(false)
            .messages(messages)
            .build();
    }


    @Getter
    @Builder
    @NoArgsConstructor
    @AllArgsConstructor(access = AccessLevel.PRIVATE)
    static class ApiResult<T> {
        private boolean success;
        private String message;
        private T data;
    }
	
    // 이게 Exception 포맷이다.
    @Getter
    @Builder
    @NoArgsConstructor
    @AllArgsConstructor(access = AccessLevel.PRIVATE)
    static class ApiException {
        private boolean success;
        private List<String> messages;
    }
}

messages를 List로 담은 이유는 Exception이 여러 개, 발생할 수 있기 때문이다. 당연히, List로 담지 않아도 된다. 취향에 맞게 설정하자(하지만 여기서는 해당 포맷으로 이해해주길 바란다.)

바로 구현한 코드부터 보고 가자

@RestControllerAdvice
public class GlobalExceptionController {
    @ExceptionHandler(MethodArgumentNotValidException.class)
    public ResponseEntity handleMethodArgumentNotValidException(final MethodArgumentNotValidException e) {
        return ResponseEntity.status(HttpStatus.BAD_REQUEST)
            .body(ApiUtils.exceptionsOccurred(e.getBindingResult().getFieldErrors().stream()
                    .map(DefaultMessageSourceResolvable::getDefaultMessage)
                .toList()));
    }
 	
    // ...생략
 }

여기서 @ExceptionHandler는 잡을 Exception 클래스를 기재한다. MethodArgumentValidException은 @Valid에서 검증에 실패하면 나오는 Exception이다. 여기서 모든 Exception의 메시지를 추출하려면 아래와 같이 해야한다.

e.getBindingResult()
	.getFieldErrors()
    .stream()
    .map(DefaultMessageSourceResolvable::getDefaultMessage)
    .toList()

참고로 이건 Java 17버전이기 때문에 Java 8버전을 쓴다면 .toList()는 대신 .collect(Collectors.toList())를 써야 한다. 또 @ControllerAdvice 대신 @RestControllerAdvice으로 썼는데, 이는 @Controller@RestController의 차이와 비슷하다.

Tip?

RuntimeException을 상속한 CustomException이 존재하고 RuntimeException과 CustomException을 처리한다면 어떻게 될까? RuntimeException이 우선적으로 처리되어 CustomException은 잡히지 못한다.

별 팁은 아닌 것 같긴한데, 만약 여러 Exception에 대해서 모아서 공통처리하고 싶으면 상속을 이용해서 할 수 있을 것이다.

0개의 댓글