Spring
Spring Boot
Exception & Validation

Exception

Spring Boot의 동작과정

Spring에서는 다음과 같이 Exception Handler(사진에서 7번과 같음)에서 에러를 처리해주게 되는데 이를 사용하는 방법을 알아보겠다.

Basic: Global

아래와 같이 RestControllerAdvice annotation을 붙여주게 되면 현재 프로젝트의 모든 에러에 대해서 처리를 해주게 된다. 이 때, 개별적으로 처리된 Error(같은 클래스 내에서 처리된 에러 등)들이 먼저 처리되고 그렇게 처리가 되지 않았을 경우 이곳에서 처리를 해주게 된다.

@Slf4j
// 모든 프로젝트에 대해서 처리하고 싶을 때
@RestControllerAdvice()
public class RestAPIExceptionHandler {
  // 만약 개별로 따로 지정해주면 여기서는 안잡히고 다른 곳에서 잡힌다
  // 모든 에러에 대해서 처리하고 싶을 때
  @ExceptionHandler(value = { Exception.class })
  public ResponseEntity<Exception> exception(
      Exception e) {
    log.error("RestApiExceptionHandler", e);
 
    // status 값 지정 가능
    return ResponseEntity.status(200).build();
  }
 
  // 특정 에러에 대해서만 처리하고 싶을 때
  // 여기서는 List의 Index Out Of Range에러에 대해서 처리해줌
  @ExceptionHandler(value = { IndexOutOfBoundsException.class })
  public ResponseEntity<Exception> outOfBound(
      Exception e) {
    log.error("IndexOutOfBoundsException", e);
 
    return ResponseEntity.status(200).build();
  }
}

특정 클래스에 대해서

  1. 지정해주기 RestControllerAdvice annotation에서 basePackageClasses 속성을 통해서 특정 클래스에 대해서만 처리를 해줄 수 있다.
@RestControllerAdvice(basePackageClasses = { RestAPIExceptionCController.class, RestAPIController.class })
  1. 같은 클래스 내에 Exception 정의 아래와 같이 같은 클래스 파일 내에 Exception이 정의가 되어 있으면 해당 파일내의 지정된 Error에 대한 처리는 저 곳에서 해준다.
    다만 이는 클래스 파일이 커질 경우
public class RestAPIBController {
  @GetMapping(path = "")
  public void hello() {
    throw new NumberFormatException("number format exception");
  }
  // 현재 class내의 에러에 대해서만 처리
  @ExceptionHandler(value = {NumberFormatException.class})
  public ResponseEntity<Exception> numberFormatException(
    NumberFormatException e
  ) {
    log.error("RestAPIBController", e);
    return ResponseEntity.status(200).build();
  }
}
 

특정 패키지에 대해서

RestControllerAdvice annotation에 basePackages 속성을 추가하면 해당하는 패키지에 대해서 Error 처리를 해줄 수 있다.

@RestControllerAdvice(basePackages = {"com.example.exception.controller" })

예외처리 우선순위

Oder annotation을 통해서 우선순위를 정해줄 수 있다. 숫자가 낮을수록 우선순위가 높다.

@RestControllerAdvice
// 먼저 처리되게 하고 싶으면 MIN_VALUE로 설정해주면 되고
// 가장 나중에 처리되게 하고 싶으면 MAX_VALUE로 설정해준다
@Order(value = Integer.MAX_VALUE) 
public class GlobalExceptionHandler {
  @ExceptionHandler(value = { Exception.class })
  public ResponseEntity<UserApi> exception(
    Exception e
  ) {
    var response = UserApi.builder().resultCode(String.valueOf(HttpStatus.INTERNAL_SERVER_ERROR.value())).resultMessage("접근이 금지된 사용자 입니다!!!").build();
    return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body(response);
  }
}

Commit

Commit 1
Commit 2
Commit 3

Validation

일반적으로 Bean Validation 2.0 version (opens in a new tab)을 많이 사용한다.
사용할 수 있는 annotation 👇

Confuse Annotation

  • NotNull : Null을 허용하지 않겠다.
  • NotEmpty : Not Null and Empty가 아니어야 한다. // String에만 사용 가능
  • NotBlank : Not Null and Empty and 공백이 없어야 한다. // String에만 사용 가능

Null과 Emtpy의 차이

"null" : null,
"empty" : "",

예시

public class UserRegisterRequest {
  @NotBlank
  private String name;
  @NotBlank
  @Size(min = 8, max = 12)
  private String password;
  @NotNull
  @Range(min = 1, max = 200)
  private int age;
  @Email
  private String email;
  @Pattern(regexp = "^\\d{2,3}-\\d{3,4}-\\d{4}$", message = "휴대폰 번호의 형식이 맞지 않습니다.")
  private String phoneNumber;
  // ISO 8601 방식으로 포맷해야함 ex) 2018-02-18T10:15:30
  @FutureOrPresent
  private LocalDateTime registerAt;
}

Valid Annotation

Request 요청을 보낼 때 @Valid Annotation을 붙여줘야지 해당 Request에 대해서 Validation이 동작한다.

a
public class UserApiController {
  @PostMapping("")
  public Api<UserRegisterRequest> register(
    @Valid
    @RequestBody Api<UserRegisterRequest> userRegisterRequest
  ) {
    log.info("init : {}", userRegisterRequest);
    return userRegisterRequest;
  }
}

Wrapping된 Class의 Validation

현재까지의 강의 진행을 보면 Api 요청을 보낼 때 기본적으로 Api라는 클래스에서 Response를 Wrapping해서 보내고 있다.
이 때 repsonse body에 해당하는 data에 Validation을 적용하려면 Response를 받는 Controller뿐만 아니라 모델에서 한 번 더 @Valid annotation을 붙여줘야 한다.

public class Api<T> {
  private String resultCode;
  private String resultMessage;
  @Valid
  private T data;
}

Valid In Kotlin

코틀린에서 Annotation을 변수에 Annotation을 붙이기 위해서 공통적으로 적용되는 사항 같지만 @Valid만 해주는 것이 아니라 @field:Valid를 해줘야 한다.

data class Api<T>(
	val result: Result? = null,
	/// Kotlin에서는 변수에 Annotation을 붙이기 위해선
	/// @field:Annotation 형태로 붙여야 한다.
	@field:Valid
	val body: T? = null,
) 

BindingResult

BindingResult는 Validation에 대한 결과를 담고 있는 객체이다.
BindingResult에 Error가 있는지 확인하고 Error가 있다면 해당하는 Error를 조작해줄 수 있는데 이를 통해서 Validation에 걸린 Error를 조작할 수 있다. 다만 이걸 비즈니스 로직에서 사용하게 되면 비즈니스 로직과 Validation 로직이 섞이게 되므로 코드가 더러워질 수 있다는 단점이 있다.
이를 방지하기 위해서 ExceptionHandler를 따로 선언하고 그곳에서 Validation 관련 Error를 핸들해주는게 좋다.

@PostMapping("")
public ApiModel<UserRegisterRequestModel> register(
        @RequestBody
        @Valid
        ApiModel<UserRegisterRequestModel> userRegisterRequest,
        BindingResult bindingResult
) {
    log.info(" init {}", userRegisterRequest);
    UserRegisterRequestModel body = userRegisterRequest.getData();
    if(bindingResult.hasErrors()) {
        List<String> errorMessageList = bindingResult.getFieldErrors()
                .stream()
                .map(
                        objectError -> {
                            String format = "%s : { %s } 은 %s";
                            return String.format(format, objectError.getField(), objectError.getRejectedValue(), objectError.getDefaultMessage());
                        })
                .toList();
 
        ApiModel.Error error = ApiModel.Error.builder()
                .errorMessage(errorMessageList)
                .build();
 
        ApiModel<UserRegisterRequestModel> errorResponse = ApiModel.<UserRegisterRequestModel>builder()
                .err(error)
                .resultMessages(HttpStatus.BAD_REQUEST.getReasonPhrase())
                .resultCode(String.valueOf(HttpStatus.BAD_REQUEST.value()))
                .build();
 
        return errorResponse;
    }
 
    ApiModel<UserRegisterRequestModel> response = ApiModel.<UserRegisterRequestModel>builder()
            .resultCode(String.valueOf(HttpStatus.OK.value()))
            .resultMessages(HttpStatus.OK.getReasonPhrase())
            .data(body)
            .build();
    return response;
}

Custom Validation

만약에 nickName과 name 둘 중 하나를 반드시 입력 받도록 강제하고 싶다면 이런 annotation은 존재하지 않는다. 이럴 경우 @AssertTrue 또는 @AssertFalse를 사용해서 Custom Validation을 만들어 줄 수 있다.

  • AssertTrue : true일 경우 Validation 통과
  • AssertFalse : false일 경우 Validation 통과
public class UserRegisterRequestModel {
    private String name;
    private String nickName;
    ...
 
    // 반드시 이름은 is 또는 get
    @AssertTrue(message = "name or nickName is required")
    public boolean isNameCheck() {
        if(Objects.nonNull(nickName) && !nickName.isBlank()) {
            return true;
        }
        if(Objects.nonNull(name) && !name.isBlank()) {
            return true;
        }
 
        return false;
    }
}

Annotation & Validation

Java에서는 Annotation을 Custom하게 만드는 방법을 제공한다. 이를 통해 Validation을 제작해서 사용할 수 있다.

  • Constraint : Annotation을 어떻게 검증할 때 사용할건지 지정해줄 수 있다.
  • Target : Annotation을 어디에 사용할건지 결정할 수 있다. 아래 코드에서는 FIELD, 즉, 클래스의 변수에 지정할 수 있다는 뜻이다.
  • Retention : Annotation을 언제까지 유지할건지 결정할 수 있다. Runtime은 Compile시 뿐만 아니라 Runtime시에도 유지한다는 뜻이다.
  • @interface : Annotation을 만들 때 사용하는 키워드이다.
@Constraint(validatedBy = com.example.validation.validator.PhoneNumberValidator.class)
@Target({ElementType.FIELD})
@Retention(RetentionPolicy.RUNTIME)
public @interface PhoneNumberAnnotation {
    String message() default "핸드폰  번호 양식에 맞지 않습니다. ex) 000-0000-0000";
    String regexp() default "^\\d{3}-\\d{3,4}-\\d{4}$";
    // 안가지고 있으면 에러남
    Class<?>[] groups() default { };
    Class<? extends Payload>[] payload() default { };
}
YearMonthAnnotation
@Constraint(validatedBy = com.example.validation.validator.YearMonthValidator.class)
@Target({ElementType.FIELD})
@Retention(RetentionPolicy.RUNTIME)
@NotBlank // Custom한 Annotation에 다른 Annotation을 붙여도 복합적으로 동작하게 된다.
public @interface YearMonthAnnotation {
    String message() default "Year Month 양식에 맞지 않습니다. ex) 000-0000-0000";
    String pattern() default "yyyy-MM";
    Class<?>[] groups() default { };
    Class<? extends Payload>[] payload() default { };
}