Spring/Sample Project

[Spring Boot] Exception Handling

shininghyunho 2023. 10. 13. 20:18

Java 에서 오류는 Error, Exception이 존재한다.

Error 는 StackOverflow 같이 시스템적인 문제인데,

이는 예상할수도 막을수도 없는 것들을 말한다.

 

그래서 이는 우리의 관심사가 아니다.

Exception

우리가 눈여겨 봐야할것들은 Exception 이다.

 

Java 에서는 특정한 Exception을 특별 관리한다.

그게 Checked Exception이다.

 

Checked Exception 은 시스템 외부에서 발생하는 Exception이다.

IOException, SQLException 같이 프로그래머의 잘못인 아닌,

시스템 밖에서 무언가 잘못되었을때 발생한다.

 

그래서 반드시 try-catch 문을 사용하거나 throws하여 compile 단계에서 오류를 관리해야한다.

 

그러나 예외 종류가 많고 비지니스 로직에 불필요하게 예외처리 코드가 많아져 코드가 복잡해진다.

그래서 JVM을 사용하는 Kotlin, Scala에서는 Checked Exeption을 사용하지 않는다.

 

Checked Exception이 있으면 특정 오류처리를 더 능동적이고 확실하게 할수있지만,

그에비해 복잡성이 늘어난다는 특징이 있어 오늘날에도 논란이 끊임이 없다.

 

Unchecked Exception은

- 매개변수를 잘못 넘긴다던지(Illegalargumentexception)

- 0으로 나눈다던지(ArithmeticException)

같은 프로그램 논리 오류나 시스템 상황에서 발생하는 오류다.

 

기존의 방식

기존에는 Exception이 발생하면 어떤 오류인지를 나타내 프론트에 전달했다.

// 유저의 이메일이 중복되면 입력을 다시 받도록 유도한다.
if (userRepository.findByEmail(email) != null) {
    throw Exception("DUPLICATED_EMAIL")
}

그러나 위와같은 방식에는 몇가지 문제점이 있다.

1. 직접 문자열을 입력해 오타가 날수있다.

2. 프론트에서 문자를 읽고 파악해야한다.

3. 똑같은 오류가 발생하더라도 다른 예외를 던질수있다.

 

즉 일관되고 명확하지 않은 오류로 인해 프론트에서 혼란이 올 수 있다.

 

ErrorCode

그래서 오류코드를 적용해 에외를 명확하게 명세화했다.

enum class ErrorCode (
    val status: HttpStatus,
    val code: String,
    val message: String,
) {
    /* 400 BAD_REQUEST */
    // USER
    NOT_EXISTED_USER(status = HttpStatus.BAD_REQUEST, code = "U001", message = "존재하지 않는 유저입니다."),
    DUPLICATED_EMAIL(status = HttpStatus.BAD_REQUEST, code = "U002", message = "이미 존재하는 이메일입니다."),
    DUPLICATED_NICKNAME(status = HttpStatus.BAD_REQUEST, code = "U003", message = "이미 존재하는 닉네임입니다."),

    // ITEM
    NOT_EXISTED_ITEM(status = HttpStatus.BAD_REQUEST, code = "I001", message = "존재하지 않는 아이템입니다."),
    DUPLICATED_ITEM_NAME(status = HttpStatus.BAD_REQUEST, code = "I002", message = "이미 존재하는 아이템 이름입니다."),
    NOT_ENOUGH_ITEM_QUANTITY(status = HttpStatus.BAD_REQUEST, code = "I003", message = "아이템의 재고가 부족합니다."),

    // ORDER
    NOT_EXISTED_ORDER(status = HttpStatus.BAD_REQUEST, code = "O001", message = "존재하지 않는 주문입니다."),
    NOT_EXISTED_ORDER_ITEM(status = HttpStatus.BAD_REQUEST, code = "O002", message = "존재하지 않는 주문 아이템입니다."),

    /* 500 SERVER_ERROR */
    INTERNAL_SERVER_ERROR(status = HttpStatus.INTERNAL_SERVER_ERROR, code = "S001", message = "서버 내부 에러"),
}

그래서 이제는 프론트에서 같은 예외사항이 발생시 동일한 오류코드를 받을 수 있다.

또한 "U001", "I002" 같이 오류를 분류하여 프론트에서 분기 작업이 더 용이해졌다.

 

GlobalExceptionHandler

예외를 명세화 했다면 이제는 예외들을 일관되게 처리해야한다.

 

먼저 ErrorCode를 적용할 Custom Exception class를 만들어준다.

class CustomException(
    val errorCode: ErrorCode,
    override val message: String? = errorCode.message,
): RuntimeException()

Unchecked Exception이므로 RuntimeException을 상속해야한다.

 

다음으로 2가지 어노테이션을 사용해 예외들을 처리해줄 GlobalExceptionHandler을 만들어준다.

1. @ExceptionHandler : 특정 예외를 catch 하여 처리해준다. (단 한가지 Controller에 한해서만)

2. @RestControllerAdvice : 모든 Controller에 해당하는 오류를 catch해준다.

 

@RestControllerAdvice
class GlobalExceptionHandler {
    @ExceptionHandler(CustomException::class)
    fun handleCustomException(e: CustomException): ResponseEntity<CustomBody> {
        return CustomResponse(
            body = ErrorBody(e.errorCode)
        ).toResponseEntity()
    }

    @ExceptionHandler(Exception::class)
    fun handleException(e: Exception): ResponseEntity<CustomBody> {
        return CustomResponse(
            body = ErrorBody(
                errorCode = ErrorCode.INTERNAL_SERVER_ERROR,
                message = e.message
            )
        ).toResponseEntity()
    }
}

현재는 2가지 Exception만 Catch한다.

1. CustomException

2. Exception (나머지 예외 모두)

여기에 ArithmeticException같은 다른 Exception을 추가한다면 그 예외도 잡을 수 있게된다.

 

다시 Email 이 중복될때 오류를 작성해보면 다음과 같다.

 

 if (userRepository.findByEmail(email) != null) {
     throw CustomException(ErrorCode.DUPLICATED_EMAIL)
}

그러면 GlobalExceptionHandler에서 오류를 Catch해서 프론트에 다음과 같이 전달된다.

{
    "errorCode": "U002",
    "message": "이미 존재하는 이메일입니다.",
    "timestamp": "2023-10-13T20:16:47.4482862",
    "status": 400
}