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();
}
}
특정 클래스에 대해서
- 지정해주기 RestControllerAdvice annotation에서 basePackageClasses 속성을 통해서 특정 클래스에 대해서만 처리를 해줄 수 있다.
@RestControllerAdvice(basePackageClasses = { RestAPIExceptionCController.class, RestAPIController.class })
- 같은 클래스 내에 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
Validation
일반적으로 Bean Validation 2.0 version (opens in a new tab)을 많이 사용한다.
사용할 수 있는 annotation 👇
⚠️ spring-boot-starter-validation
을 implements 해주고 import jakarta.validation.Valid;
을 사용한다.
implementation 'org.springframework.boot:spring-boot-starter-validation'
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이 동작한다.
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 { };
}