관련된 포스트
[Java] Exception 분류 및 처리방법
[Spring] 사용자 정의 예외 활용하기
기존의 예외처리 방식에선 모든 예외 발생 사항을 직접 만든 CustomException 클래스에 담아 보낸다. 모든 예외응답의 http status는 BAD_REQUEST(400)를 담고, 모든 메시지는 개발자가 각 예외 인스턴스마다 직접 쓴다.
이렇다보니 예외 분류가 안돼서 예외별로 처리해주는 것이 까다로워 졌다. 현재는 예외를 받으면 단순히 response body 에 담아서 응답하는 것으로 구현되어 있다. 분류를 위해 Enum 클래스로 ErrorMessage 를 선언하였지만, 이를 잘 사용하지 않고 있다.
CustomException 을 잘 분류해 계층화해서 가독성과 사용성을 높여보자.
먼저, 우리 서버에서 뱉는 모든 예외를 모아놓고 보니 메시지만 따로모아서 도메인 별로 나눌 수 있었다.
도메인별로 정리해 보니 다음과 같았다.
post 도메인
├ 존재하지 않는 청원입니다.
├ 이미 답변이 존재합니다.
├ 이미 동의하셨습니다.
└ 답변이 존재하지 않습니다.
user 도메인
├ 존재하지 않는 회원입니다.
├ 이메일 인증이 필요합니다.
├ 인증되지 않은 사용자입니다.
├ 권한이 없습니다.
├ 이미 존재하는 회원입니다.
├ 이메일 형식이 올바르지 않습니다.
└ 비밀번호가 맞지 않습니다.
comment 도메인
└ 존재하지 않는 댓글입니다.
verification 도메인
├ 존재하지 않는 인증 코드 입니다.
├ 인증되지 않은 코드입니다.
├ 유효하지 않은 회원인증 정보입니다.
├ 만료된 인증 코드입니다.
└ 이미 인증된 정보입니다.
직접 만든 예외. 자바 혹은 다른 라이브러리에서 제공하는 예외와는 맞지 않아서 직접 정의한 예외.
보통 실행중 예외사항을 상정하므로 RuntimeException 을 상속시켰다.
public class ApplicationException extends RuntimeException {
private final HttpStatus httpStatus;
public ApplicationException(String message, HttpStatus httpStatus) {
super(message);
this.httpStatus = httpStatus;
}
public HttpStatus getHttpStatus() {
return httpStatus;
}
}
기존에 CustomException 으로 던지던 예외를 도메인 별로 분류해서 ApplicationExcetion을 상속하도록 하였다.
//상속 관계
ApplicationException.java
├── CommentException.java
│ └── NoSuchCommentException.java
├── PostException.java
│ ├── DuplicatedAgreementException.java
│ ├── DuplicatedAnswerException.java
│ ├── NoSuchPostException.java
│ └── UnAnsweredPostException.java
├── UserException.java
│ ├── DuplicatedUserException.java
│ ├── InvalidEmailFormException.java
│ ├── NoSuchUserException.java
│ ├── NotConfirmedEmailException.java
│ ├── NotMatchedPasswordException.java
│ ├── UnAuthenticatedException.java
│ └── UnAuthorizedUserException.java
└── VerificationException.java
├── DuplicatedVerificationException.java
├── ExpiredVerificationCodeException.java
├── InvalidVerificationInfoException.java
├── NoSuchVerificationCodeException.java
├── NoSuchVerificationInfoException.java
└── NotConfirmedVerificationCodeException.java
checked exception 을 잡아야 할때 이를 WrappeException 에 넣어서 처리한다.
예외처리회피의 일종.
public class WrappedException extends ApplicationException {
private final Throwable cause;
public WrappedException(String message, Throwable cause) {
this(message, cause, HttpStatus.BAD_REQUEST);
}
public WrappedException(String message, Throwable cause, HttpStatus httpStatus) {
super(message, httpStatus);
this.cause = cause;
}
@Override
public Throwable getCause() {
return cause;
}
}
예외를 전역 @ExceptionHandler 에서 받아서 메시지와 HttpStatus 를 response body 에 json 형식으로 담아서 보낸다. 이 때 'ErrorResponse' 클래스를 사용한다.
// ErrorResponse.java
public class ErrorResponse {
private String message;
public ErrorResponse() {
}
public ErrorResponse(String message) {
this.message = message;
}
public String getMessage() {
return message;
}
}
// ControllerAdvice.java
@RestControllerAdvice
public class ControllerAdvice {
@ExceptionHandler(ApplicationException.class)
public ResponseEntity<ErrorResponse> handle(ApplicationException ex) {
return ResponseEntity.status(ex.getHttpStatus()).body(new ErrorResponse(ex.getMessage()));
}
@ExceptionHandler(WrappedException.class)
public ResponseEntity<ErrorResponse> handle(WrappedException ex) {
return ResponseEntity.status(ex.getHttpStatus()).body(new ErrorResponse(ex.getCause().getMessage()));
}
@ExceptionHandler(MethodArgumentNotValidException.class)
public ResponseEntity<ErrorResponse> validException(MethodArgumentNotValidException ex) {
String message = ex.getBindingResult().getAllErrors().get(0).getDefaultMessage();
return ResponseEntity.badRequest().body(new ErrorResponse(message));
}
}
예외를 다룬다는 것에 이렇게 로드가 크게 들지 몰랐다. 그렇지만, 자세히 분류를 하고 나니 예외처리 상황을 더 다양하게 다룰 수 있게 되었고, 테스트를 못했던 경우들이 잘 보였던 것 같다.
다만, 너무 많은 경우의 수를 하나하나 예외로 만들다보니 만들어놓은 예외들의 재사용성이 떨어짐을 느꼈고, 이를 위해 더 세세한 계층화가 필요할 수 있겠다고 생각했다.
고생많으셨어요 체리천사님! 👍👍