본 글은 글쓴이의 개인적인 생각이 담겨있을 수 있습니다.
꼬리별 프로젝트 - 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 형식이 잘못되었습니다.",
)
}