Exception Handling을 어떻게 하면 더 잘할 수 있을까?

Jaychy·2021년 3월 22일
3

프로젝트 꼬리별

목록 보기
1/4
post-thumbnail

본 글은 글쓴이의 개인적인 생각이 담겨있을 수 있습니다.

꼬리별 프로젝트 - 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 부분을 따로 가져온 것이다.
물론 지금은 코틀린으로 작성되어 있고 더 나은 방식으로 작성되어 있지만,
옛날에 저런 코드를 보면서 이렇게 짜야 하나 생각했지만,
개발 마감 기간이 다가오고 급해지면서 그런 생각은 더 이상 머리에 들어오지 않았다.

본 레거시 코드에는 다음과 같은 문제점들이 존재한다.

  1. 메소드가 많아도 너무 많다.
  2. Custom Exception에 대한 정보(상태코드, 메시지)가 Exception Handler에게 종속적이다.

이러한 문제들을 가만히 둘 수 없기에,
이참에 마음 먹고 이 문제 있는 코드들을 깔끔하게 해결하고자 한다.

해결

CommonException 만들기

막상 문제를 해결하는 것은 그다지 어렵지 않았다.
레거시 코드에서는 모든 Custom ExceptionRuntimeException을 상속받고 있었고,
그에 따라 Custom Exception의 정보를 담을 수 있는 곳이
RuntimeExceptionmessage 필드 밖에 없었다.

그래서 나는 모든 Custom Exception 위에
CommonException이라는 상위 에러 클래스를 만들었고,
모든 Custom Exception이 상속 받게 하였다.

CommonException은 다음과 같은 정보를 가지고 있다.

  • status: HTTP 상태 코드
  • code: 같은 상태 코드를 대비한 에러 정보 상수
  • message: 에러에 대한 설명

이를 토대로 다음과 같이 CommonException을 만들었다.

open class CommonException(
    val code: String,
    message: String,
    val status: HttpStatus,
) : RuntimeException(message)

Exception Handler 만들기

그럼 이제 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 프로퍼티를 가지고 있는 데이터 클래스이다.

이렇게 끝이라고 생각할 수도 있겠지만 다음과 같은 우리는 두 가지를 더 해결해야 한다.

  • 500 에러의 Response가 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()를 추가하였다.
이러면 CommonExceptionRuntimeException을 상속받으므로 문제가 되지 않냐고 생각할 수도 있는데,
더 가까운 형태로 변환되어 캐치하기 때문에 문제가 발생하지 않는다.

2번 문제의 경우에는 참 난감한데, 이미 Spring Boot에서 정의한 Exception에게
부모 클래스를 심어줄 수는 없기 때문이다.

다행이게도 따로 처리되는 에러가 많지 않아서 개별적으로 처리해도 될만한 수준이었다.
Spring 설정 중에 이를 해결할 수 있는 게 있을지는 의문이다.
언젠가 다시 여유를 되찾으면 이런 문제를 해결해봐야겠다.

Request Validation에 대한 문제 해결

2021-03-23 수정

마지막으로 고민했던 2번 문제를 해결하는 방법에 대해서 알게 되었다.
원래는 @RequestBody, @RequestParam, @PathVariableValidation 처리를
KotlinNullable 하지 않은 타입을 이용해서 어떻게 해결할까에 대해서
연구하기 위해 다음 레포지토리를 생성하여 이리 저리 해보고 있었다.

[kotlin-request-validation-test] https://github.com/Lee-Jin-Hyeok/kotlin-request-validation-test

그런데 세 어노테이션을 사용해보면서 알게 된 게,
Exception HandlerRuntimeException을 잡지 않으면 자동으로
Spring Boot400, 404 에러를 띄워준다는 것이다.

@RequestParam@RequestBody가 이상하면 400에러를 발생시키고,
@PathVariable이 이상하면 404에러를 발생시킨다.
@PathVariable404인 이유는
원래 없는 URL에 접속하면 404를 띄워주는데
@PathVariable이 없으면 URL이 달라지기 때문에 404에러가 발생하는 것 같다.

그래서 결론은 @ExceptionHandlerRuntimeException을 잡지 않는 것이다.

2021-04-01 수정

2번 문제를 위 처럼 고치고 나니까 프론트엔드에서 예외처리를 할 때,
스프링에서 제공하는 디폴트 에러들은 내가 정의한 CommonException을 상속 받지 않기 때문에
code, message을 가지고 있지 않는다는 것을 확인했다.

처음에는 이것이 큰 문제가 될 것이라고는 생각 못 했는데,
꼬리별 프로젝트의 프론트엔드 개발을 하면서 code가 없으니까 일관성있게 구분하기가 힘들어졌다.
그래서 이를 해결하기 위해서 스프링이 핸들링하는 에러의 종류에 대해 알아보았다.

스프링이 요청에 대해서 띄우는 에러는 두 가지이다.

  • MethodArgumentNotValidException
  • HttpMessageNotReadableException

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 형식이 잘못되었습니다.",
        )
}
profile
아름다운 코드를 꿈꾸는 백엔드 주니어 개발자입니다.

0개의 댓글