
본 글은 글쓴이의 개인적인 생각이 담겨있을 수 있습니다.
꼬리별 프로젝트 - Server Clematis
https://github.com/KKoRiByeol/Clematis
백엔드 단에서는 프론트엔드에게 적절한 상태 코드를 넘겨주기 위해서
Exception Handling이라는 작업을 반드시 거쳐야 한다.
Spring Boot에서는 @RestControllerAdvice와 @ExceptionHandler라는
어노테이션을 이용하여 Exception Handling을 할 수 있다.
그런데 내가 관리해야 하는 에러의 수는 점점 많아지고
그에 따라 @ExceptionHandler를 달고 있는 메소드의 수도 같이 많아졌다.
Exception Handling을 적절하게 하지 못한 예를 살펴보자.
@ControllerAdvice
public class ApiExceptionHandler {
private static final Logger log = LoggerFactory.getLogger(ApiExceptionHandler.class);
@ExceptionHandler(IdOrPasswordMismatchException.class)
public ResponseEntity<ApiErrorResponseForm> idOrPasswordMismatchExceptionHandler(IdOrPasswordMismatchException ex) {
ApiErrorResponseForm response = new ApiErrorResponseForm("ID or Password Mismatch Exception", "아이디 또는 비밀번호가 일치하는 계정을 찾을 수 없음");
log.warn("error " + ex.getMessage() + "[NOT FOUND]");
return new ResponseEntity<>(response, HttpStatus.NOT_FOUND);
}
@ExceptionHandler(ClubNotFoundException.class)
public ResponseEntity<ApiErrorResponseForm> clubNotFoundExceptionHandler(ClubNotFoundException ex) {
ApiErrorResponseForm response = new ApiErrorResponseForm("Club Not Found Exception", "조건에 맞는 클럽을 찾을 수가 없음");
log.warn("error " + ex.getMessage() + "[NOT FOUND]");
return new ResponseEntity<>(response, HttpStatus.NOT_FOUND);
}
@ExceptionHandler(NumberFormatException.class)
public ResponseEntity<ApiErrorResponseForm> numberFormatExceptionHandler(NumberFormatException ex) {
ApiErrorResponseForm response = new ApiErrorResponseForm("Number Format Exception", "요청의 값이 잘못됨");
log.warn("error " + ex.getMessage() + "[BAD REQUEST]");
return new ResponseEntity<>(response, HttpStatus.BAD_REQUEST);
}
@ExceptionHandler(NoSuchElementException.class)
public ResponseEntity<ApiErrorResponseForm> noSuchElementExceptionHandler(NoSuchElementException ex) {
ApiErrorResponseForm response = new ApiErrorResponseForm("No Such Element Exception", "일치하는 요소가 존재하지 않음");
log.warn("error " + ex.getMessage() + "[NOT FOUND]");
return new ResponseEntity<>(response, HttpStatus.NOT_FOUND);
}
@ExceptionHandler(TokenInvalidException.class)
public ResponseEntity<ApiErrorResponseForm> tokenInvalidExceptionHandler(TokenInvalidException ex) {
ApiErrorResponseForm response = new ApiErrorResponseForm("Token Invalid Exception", "토큰이 잘못됨");
log.warn("error " + ex.getMessage() + "[FORBIDDEN]");
return new ResponseEntity<>(response, HttpStatus.FORBIDDEN);
}
@ExceptionHandler(TokenExpirationException.class)
public ResponseEntity<ApiErrorResponseForm> tokenExpirationExceptionHandler(TokenExpirationException ex) {
ApiErrorResponseForm response = new ApiErrorResponseForm("Token Expiration Exception", "토큰이 만료됨");
log.warn("error " + ex.getMessage() + "[UNAUTHORIZED]");
return new ResponseEntity<>(response, HttpStatus.UNAUTHORIZED);
}
@ExceptionHandler(ActivityNotFoundException.class)
public ResponseEntity<ApiErrorResponseForm> activityNotFoundExceptionHandler(ActivityNotFoundException ex) {
ApiErrorResponseForm response = new ApiErrorResponseForm("Activity Not Found Exception", "일정이 존재하지 않음");
log.warn("error " + ex.getMessage() + "[NOT FOUND]");
return new ResponseEntity<>(response, HttpStatus.NOT_FOUND);
}
@ExceptionHandler(NonExistIdOrPasswordException.class)
public ResponseEntity<ApiErrorResponseForm> nonExistIdOrPasswordExceptionHandler(NonExistIdOrPasswordException ex) {
ApiErrorResponseForm response = new ApiErrorResponseForm("Non Exist ID or Password Exception", "아이디 또는 비밀번호가 존재하지 않음");
log.warn("error " + ex.getMessage() + "[BAD REQUEST]");
return new ResponseEntity<>(response, HttpStatus.BAD_REQUEST);
}
@ExceptionHandler(NonExistFloorException.class)
public ResponseEntity<ApiErrorResponseForm> nonExistFloorExceptionHandler(NonExistFloorException ex) {
ApiErrorResponseForm response = new ApiErrorResponseForm("Non Exist Floor Exception", "floor 가 1, 2, 3, 4가 아님");
log.warn("error " + ex.getMessage() + "[BAD REQUEST]");
return new ResponseEntity<>(response, HttpStatus.BAD_REQUEST);
}
@ExceptionHandler(NonExistFloorOrPriorityException.class)
public ResponseEntity<ApiErrorResponseForm> nonExistFloorOrPriorityExceptionHandler(NonExistFloorOrPriorityException ex) {
ApiErrorResponseForm response = new ApiErrorResponseForm("Non Exist floor or priority Exception", "floor 또는 priority 가 숫자가 아님");
log.warn("error " + ex.getMessage() + "[BAD REQUEST]");
return new ResponseEntity<>(response, HttpStatus.BAD_REQUEST);
}
@ExceptionHandler(NotClubAndSelfStudyException.class)
public ResponseEntity<ApiErrorResponseForm> notClubAndSelfStudyExceptionHandler(NotClubAndSelfStudyException ex) {
ApiErrorResponseForm response = new ApiErrorResponseForm("not a club and self-study exception", "schedule 이 club 또는 self-study 가 아닙니다.");
log.warn("error " + ex.getMessage() + "[UNPROCESSABLE ENTITY]");
return new ResponseEntity<>(response, HttpStatus.UNPROCESSABLE_ENTITY);
}
@ExceptionHandler(RuleViolationInformationException.class)
public ResponseEntity<ApiErrorResponseForm> ruleViolationInformationExceptionHandler(RuleViolationInformationException ex) {
ApiErrorResponseForm response = new ApiErrorResponseForm("rule violation information exception", "유저의 정보가 규칙을 위반합니다.");
log.warn("error " + ex.getMessage() + "[BAD REQUEST]");
return new ResponseEntity<>(response, HttpStatus.BAD_REQUEST);
}
@ExceptionHandler(InconsistentAuthenticationNumberException.class)
public ResponseEntity<ApiErrorResponseForm> inconsistentAuthenticationNumberExceptionHandler(InconsistentAuthenticationNumberException ex) {
ApiErrorResponseForm response = new ApiErrorResponseForm("Inconsistent Authentication Number Exception", "인증 번호가 일치하지 않습니다.");
log.warn("error " + ex.getMessage() + "[BAD REQUEST]");
return new ResponseEntity<>(response, HttpStatus.BAD_REQUEST);
}
@ExceptionHandler(AlreadyExistIdException.class)
public ResponseEntity<ApiErrorResponseForm> alreadyExistIdExceptionHandler(AlreadyExistIdException ex) {
ApiErrorResponseForm response = new ApiErrorResponseForm("Already ExistId Exception", "이미 존재하는 아이디입니다.");
log.warn("error " + ex.getMessage() + "[BAD REQUEST]");
return new ResponseEntity<>(response, HttpStatus.BAD_REQUEST);
}
@ExceptionHandler(AnInappropriateStateException.class)
public ResponseEntity<ApiErrorResponseForm> anInappropriateStateExceptionHandler(AnInappropriateStateException ex) {
ApiErrorResponseForm response = new ApiErrorResponseForm("An Inappropriate State Exception", "요청으로 들어온 학생의 상태가 적절하지 않습니다.");
log.warn("error " + ex.getMessage() + "[BAD REQUEST]");
return new ResponseEntity<>(response, HttpStatus.BAD_REQUEST);
}
}
와... 정말 대단한 코드이다.
이 코드는 프로젝트 PICK의 레거시 자바 버전 코드 중
Excepiton Handling 부분을 따로 가져온 것이다.
물론 지금은 코틀린으로 작성되어 있고 더 나은 방식으로 작성되어 있지만,
옛날에 저런 코드를 보면서 이렇게 짜야 하나 생각했지만,
개발 마감 기간이 다가오고 급해지면서 그런 생각은 더 이상 머리에 들어오지 않았다.
본 레거시 코드에는 다음과 같은 문제점들이 존재한다.
Custom Exception에 대한 정보(상태코드, 메시지)가 Exception Handler에게 종속적이다.이러한 문제들을 가만히 둘 수 없기에,
이참에 마음 먹고 이 문제 있는 코드들을 깔끔하게 해결하고자 한다.
막상 문제를 해결하는 것은 그다지 어렵지 않았다.
레거시 코드에서는 모든 Custom Exception이 RuntimeException을 상속받고 있었고,
그에 따라 Custom Exception의 정보를 담을 수 있는 곳이
RuntimeException의 message 필드 밖에 없었다.
그래서 나는 모든 Custom Exception 위에
CommonException이라는 상위 에러 클래스를 만들었고,
모든 Custom Exception이 상속 받게 하였다.
CommonException은 다음과 같은 정보를 가지고 있다.
이를 토대로 다음과 같이 CommonException을 만들었다.
open class CommonException(
val code: String,
message: String,
val status: HttpStatus,
) : RuntimeException(message)
그럼 이제 Exception Handler는 다른 Custom Exception을 받을 필요 없이,
Common Exception만 받으면 된다.
@RestControllerAdvice
class ExceptionHandler {
@ExceptionHandler(CommonException::class)
fun commonExceptionHandler(e: CommonException) =
ResponseEntity(
CommonExceptionResponse(
code = e.code,
message = e.message?: "알 수 없는 오류",
),
e.status,
)
}
여기서 등장하는 CommonExceptionResponse는
code와 message 프로퍼티를 가지고 있는 데이터 클래스이다.
이렇게 끝이라고 생각할 수도 있겠지만 다음과 같은 우리는 두 가지를 더 해결해야 한다.
CommonException의 Response와 형식이 다르다.CommonException의 상속을 받지 않은 클래스들에 대한 처리가 필요하다.1번 문제를 해결하는 것은 RuntimeException을 받는 @ExceptionHandler를 만들면 된다.
@ExceptionHandler(RuntimeException::class)
fun runtimeExceptionHandler(e: RuntimeException): ResponseEntity<CommonExceptionResponse> {
e.printStackTrace()
return ResponseEntity(
CommonExceptionResponse(
code = "INTERNAL_SERVER_ERROR",
message = e.message?: "알 수 없는 오류",
),
HttpStatus.INTERNAL_SERVER_ERROR,
)
}
500대 에러가 발생했을 대는 서버 로그에서 확인할 수 있어야 하기 때문에
e.printStackTrace()를 추가하였다.
이러면 CommonException도 RuntimeException을 상속받으므로 문제가 되지 않냐고 생각할 수도 있는데,
더 가까운 형태로 변환되어 캐치하기 때문에 문제가 발생하지 않는다.
2번 문제의 경우에는 참 난감한데, 이미 Spring Boot에서 정의한 Exception에게
부모 클래스를 심어줄 수는 없기 때문이다.
다행이게도 따로 처리되는 에러가 많지 않아서 개별적으로 처리해도 될만한 수준이었다.
Spring 설정 중에 이를 해결할 수 있는 게 있을지는 의문이다.
언젠가 다시 여유를 되찾으면 이런 문제를 해결해봐야겠다.
2021-03-23 수정
마지막으로 고민했던 2번 문제를 해결하는 방법에 대해서 알게 되었다.
원래는 @RequestBody, @RequestParam, @PathVariable의 Validation 처리를
Kotlin의 Nullable 하지 않은 타입을 이용해서 어떻게 해결할까에 대해서
연구하기 위해 다음 레포지토리를 생성하여 이리 저리 해보고 있었다.
[kotlin-request-validation-test] https://github.com/Lee-Jin-Hyeok/kotlin-request-validation-test
그런데 세 어노테이션을 사용해보면서 알게 된 게,
Exception Handler가 RuntimeException을 잡지 않으면 자동으로
Spring Boot가 400, 404 에러를 띄워준다는 것이다.
@RequestParam과 @RequestBody가 이상하면 400에러를 발생시키고,
@PathVariable이 이상하면 404에러를 발생시킨다.
@PathVariable만 404인 이유는
원래 없는 URL에 접속하면 404를 띄워주는데
@PathVariable이 없으면 URL이 달라지기 때문에 404에러가 발생하는 것 같다.
그래서 결론은 @ExceptionHandler로 RuntimeException을 잡지 않는 것이다.
2021-04-01 수정
2번 문제를 위 처럼 고치고 나니까 프론트엔드에서 예외처리를 할 때,
스프링에서 제공하는 디폴트 에러들은 내가 정의한 CommonException을 상속 받지 않기 때문에
code, message을 가지고 있지 않는다는 것을 확인했다.
처음에는 이것이 큰 문제가 될 것이라고는 생각 못 했는데,
꼬리별 프로젝트의 프론트엔드 개발을 하면서 code가 없으니까 일관성있게 구분하기가 힘들어졌다.
그래서 이를 해결하기 위해서 스프링이 핸들링하는 에러의 종류에 대해 알아보았다.
스프링이 요청에 대해서 띄우는 에러는 두 가지이다.
MethodArgumentNotValidException는 @NotBlank, @Pattern과 같은 Validation 어노테이션이
작동하여 조건에 맞지 않았을 때 발생시키는 에러이다.
HttpMessageNotReadableException는 스프링이 JSON을 파싱하다가 문제가 발생했을 때인데,
Request Body와 형식이 맞지 않거나, JSON 형태를 지키지 않았을 경우 발생한다.
이 두 에러가 스프링에서 잡아주는 에러이므로 이를 우리가 먼저 핸들링하면 문제를 해결할 수 있었다.
그래서 최종적으로 생긴 Exception Handler는 다음과 같이 생겼다.
@RestControllerAdvice
class ExceptionHandler {
@ExceptionHandler(CommonException::class)
fun commonExceptionHandler(e: CommonException) =
ResponseEntity(
CommonExceptionResponse(
code = e.code,
message = e.message?: "알 수 없는 오류",
),
e.status,
)
@ExceptionHandler(MethodArgumentNotValidException::class)
@ResponseStatus(HttpStatus.BAD_REQUEST)
fun methodArgumentNotValidExceptionHandler(e: MethodArgumentNotValidException) =
CommonExceptionResponse(
code = "INVALID_REQUEST",
message = e.bindingResult.fieldError?.defaultMessage ?: "알 수 없는 에러",
)
@ExceptionHandler(HttpMessageNotReadableException::class)
@ResponseStatus(HttpStatus.BAD_REQUEST)
fun httpMessageNotReadableExceptionHandler(e: HttpMessageNotReadableException) =
CommonExceptionResponse(
code = "INVALID_JSON",
message = "JSON 형식이 잘못되었습니다.",
)
}