비즈니스 로직 실행 중 올바르지 않은 결과인 경우 예외를 발생시킨다. 클라이언트로의 응답에는 예외 케이스별로 각기 다른 메시지를 건내주어야한다. 때문에 예외별 필요한 정보들을 따로 정리하여 관리하기도 한다.
'개발 한 스푼' 서비스도 Enum을 활용하여 예외별 정보를 모아 관리한다. 하지만 예외 케이스가 많아질수록 예외 정보를 가지는 Enum이 비대해지고 가독성 문제가 생길 수 있기 때문에 프로젝트에 맞게 조금 변형하여 활용해보았다.
이번 글에서는 예외 정보를 확장성 및 가독성 좋게(필자 프로젝트만의 특성일 수도 있지만) 관리하기 위해 적용한 방식을 공유해본다.
본 포스트는 다음의 순서로 진행됩니다.
1. 단일 Enum으로 예외관리 시 문제점
2. 개선하기
2.1 Enum을인터페이스로 확장성 좋게 개선하기
2.2 에러코드를 infix 함수로 가독성 좋게 개선하기
3. 정리
1. 단일 Enum으로 예외관리 시 문제점
기존 단일 Enum으로 관리했을때의 모습을 살펴보면, 모든 예외정보는 다음과 같이 하나의 Enum내에서 모든 정보를 관리했다.
enum class ErrorCode(
val status: Int, // 상태코드
val code: Int, // 에러코드 - 형식은 {상태코드}_{도메인넘버}_{넘버}
val message: String, // 메세지
) {
A_BAD_REQUEST(400, 400_000_001, "잘못된 요청입니다"),
B_NOT_FOUND(404, 404_000_001, "없는 정보입니다"),
// ...
}
앱 내 커스텀 런타임 객체들은 해당 Enum에서 알맞은 값을 참조한다.
// 루트 커스텀 예외
abstract class AdevspoonException(
val errorCode: ErrorCode,
) : RuntimeException()
// 각 디테일 예외 정의
class CommonBadRequestException: AdevspoonException(ErrorCode.A_BAD_REQUEST)
덕분에 Global 예외처리 시 모든 예외에 대한 응답을 다음과 같이 동일한 방식으로 처리할 수 있다.
@RestControllerAdvice
class GlobalExceptionHandler: ResponseEntityExceptionHandler() {
@ExceptionHandler(AdevspoonException::class)
fun handleAdevspoonException(e: AdevspoonException): ResponseEntity<ErrorResponse> {
// 예외정보(ErrorCode)를 ErrorResponse에 활용
return ResponseEntity
.status(e.errorCode.status)
.body(ErrorResponse(e.errorCode.code, e.errorCode.message))
}
}
또한, 각 예외 정보들을 중복없이 쉽게 관리할 수 있다는 장점 중 하나인 것 같다.
하지만 예외 케이스가 많아질수록 성격이 아예 다른 예외정보들도 Enum에 섞이면서 가독성을 문제가 생긴다. Enum이 너무 비대해지는 것도 문제이다. 필자는 이러한 문제를 인터페이스와 infix 함수를 활용해 개선해보았다.
2. 개선하기
2.1 Enum을 인터페이스로 확정성 좋게 개선하기
예외 객체가 Enum에 의존하고 있기 때문에 Enum을 분리하기는 쉽지 않다. 이는 의존성을 바꾸는 것으로 해결할 수 있다. 예외 객체가 가지는 Enum의 역할은 예외정보 제공이다. 따라서, 이 역할을 인터페이스로 분리하고 Enum과 예외객체가 인터페이스에 의존성을 가지도록 바꾼다.
이러한 형태라면 필요에 따라 예외정보를 관리하는 Enum을 분리 확장시킬 수 있다. 관련 있는 예외정보끼리 따로 Enum으로 그루핑시켜 관리할 수 있다는 뜻이다. 이를 구현한 모습은 다음과 같다.
인터페이스를 정의하고 예외정보에 필요한 값인 status, code, message와 예외정보를 반환시키는 프로퍼티까지 담는다.
interface AdevspoonErrorCode {
val status: Int
val code: Int
val message: String
// ErrorInfo 객체 선언 필요
val errorInfo: ErrorInfo
get() = ErrorInfo(error.status, error.errorCode, message)
}
예외 객체는 해당 인터페이스에 의존성을 갖고 각 Errocode는 해당 인터페이스를 구현하기만 하면된다.
// 예외객체는 인터페이스인 AdevspoonErrorCode에 의존
abstract class AdevspoonException(
errorCode: AdevspoonErrorCode,
) : RuntimeException() {
val errorInfo = errorCode.errorInfo
}
class MemberNotFoundException: AdevspoonException(MemberErrorCode.MEMBER_NOT_FOUND)
class QuestionNotFoundException: AdevspoonException(QuestionErrorCode.QUESTION_NOT_FOUND)
// 기능별로 Enum 분리 가능
enum class MemberErrorCode(
override val status: Int,
override val code: Int,
override val message: String,
): AdevspoonErrorCode {
MEMBER_NOT_FOUND(404, 404_001_001, "등록되지 않은 유저입니다");
}
enum class QuestionErrorCode(
override val status: Int,
override val code: Int,
override val message: String,
): AdevspoonErrorCode {
QUESTION_NOT_FOUND(404, 404_002_001, "등록되지 않은 질문입니다");
}
매번 status, code, message를 override 해야하지만 예외정보를 나누어 관리할 수 있다는 장점에 이 정도 번거로움은 참을 수 있다.
2.2 에러코드를 infix 함수로 가독성 좋게 개선하기
위 코드에서 몇가지 문제가 더 보였다.
먼저, code의 가독성이 좋지 못하다. code는 404_003_001 형태로 Int형이며 각 자리가 `{status}_{Domain번호}_{number}` 를 의미한다. Int형 이지만 각 자리가 의미하는 바가 있는데 하드코딩 되어 있다. 이는 휴먼 에러가 발생할 가능성이 상당히 높다는 의미이다. 우연찮게도 글을 작성하는 동안 code를 잘못 작성하는 실수가 발생했다.
또한, 예외정보 Enum을 분리해서 관리하므로 code의 중복이 발생할 가능성이 크다. 휴먼에러라든지 예외 케이스가 너무 많아져 중복을 관리하기가 힘들어질 수도 있다. 추가적으로 code내에 status가 존재하므로 code와 status가 같이 존재하는 것 자체가 중복처럼 보이기도 한다.
정리하자면, code(에러코드)의 중복이 없어야 하며 작성시 가독성 좋게 풀어낼 수 있어야한다. 이를 위해, Domain을 활용하여 code를 관리하고 infix 함수를 통해 code를 작성하는 방식을 적용히였다.
2.2.1 Domain 단위로 에러코드 관리하기
{status}_{Domain번호}_{number} 형태의 code에서 의미를 명확하게 나눌 수 있는 부분이라면 Domain 번호라고 볼 수 있다. Domain번호로 code를 관리할 수 있고 Enum도 도메인단위로 나눈다면, code가 중복될 확률은 현저히 적어진다. 사실 code 도메인 에러코드라고 봐도 무방해보인다.
Domain 번호를 관리하는 Enum을 아래와 같이 정의한다. 이를 기준으로 code를 작성하면 ErrorCode Enum 간에 중복이 발생할 확률은 거의 없다고 볼 수 있다. 각 ErrorCode들은 하나의 도메인만 다루고 Domain번호는 다름이 보장되므로 각 ErrorCode 내부에서만 조심하면 중복문제는 발생하지 않을 것이다.
enum class DomainType(
private val number: Int,
) {
COMMON(0),
MEMBER(1),
QUESTION(2),
//...
}
2.2.2 infix 함수로 의미를 알 수 있게 작성하기
DomainType을 이용한다 하더라도 code의 의미를 알아보기가 어렵다. 따라서, infix함수를 이용해 code를 표현하기로 했다. 결과적으로 아래의 방식으로 code를 표현할 수 있다.
// Member 도메인의 404 상태코드를 가지는 첫번째 에러코드 = 404_001_001
DomainType.MEMBER code 404 no 1
이를위해, 기존 DomainType을 이용해서 code를 만들 수 있게 다음과 같이 추가해줬다.
enum class DomainType(
private val number: Int,
) {
COMMON(0),
AUTH(0),
MEMBER(1),
TECH_QUESTION(2);
infix fun code(status: Int): Error {
return Error(number, status)
}
data class Error(
val domainNo: Int,
val status: Int,
var no: Int = 0,
) {
infix fun no(number: Int): Error {
no = number
return this
}
// code를 만드는 함수
val errorCode: Int
get() = (status * 1000 + domainNo) * 1000 + no
}
}
DomainType을 이용해서 DomainType.Error 를 만들되 이를 infix 함수로 가독성 좋게 풀어낼 수 있다. 마지막으로 ErrorCode 인터페이스에서 status, code 프로퍼티를 가졌는데 이를 DomainType.Error로 수정하면 마무리된다.
interface AdevspoonErrorCode {
val error: DomainType.Error
val message: String
val errorInfo: ErrorInfo
get() = ErrorInfo(error.status, error.errorCode, message)
}
status와 code를 모두 DomainType.Error로 관리되니 코드를 줄일 수 있다는 것도 장점이 될 것 같다. 전체 코드는 '정리' 섹션에 정리해두었다.
3. 정리
인테페이스와 infix 함수를 활용하여 확정성 있게 예외를 관리하고, 의미가 드러나게 에러코드를 작성할 수 있었다. 과정을 정리하면 다음과 같은 형태로 예외를 관리할 수 있게 된다.
// ErrorCode 인터페이스
interface AdevspoonErrorCode {
val error: DomainType.Error
val message: String
val errorInfo: ErrorInfo
get() = ErrorInfo(error.status, error.errorCode, message)
}
// 커스텀 예외 - ErrorCode 인터페이스에 의존
abstract class AdevspoonException(
errorCode: AdevspoonErrorCode,
) : RuntimeException() {
val errorInfo = errorCode.errorInfo
}
class MemberNotFoundException: AdevspoonException(MemberErrorCode.MEMBER_NOT_FOUND)
class QuestionNotFoundException: AdevspoonException(QuestionErrorCode.QUESTION_NOT_FOUND)
// 기능별 Enum 분리 사용 가능
enum class CommonErrorCode(
override val error: DomainType.Error,
override val message: String,
): AdevspoonErrorCode {
// code = 400_000_000
BAD_REQUEST(DomainType.COMMON code 400 no 0, "잘못된 요청입니다");
}
enum class MemberErrorCode(
override val error: DomainType.Error,
override val message: String,
): AdevspoonErrorCode {
// code = 404_001_000
MEMBER_NOT_FOUND(DomainType.MEMBER code 404 no 0, "등록되지 않은 유저입니다");
}
전체 코드는 아래 레포에서 확인 가능하다.
adevspoon-backend/adevspoon-domain/src/main/kotlin/com/adevspoon/domain/techQuestion/exception/QuestionDomainErrorCode.kt at dev
CS 질문 - adevspoon-backend. Contribute to kids-ground/adevspoon-backend development by creating an account on GitHub.
github.com
'Spring' 카테고리의 다른 글
이벤트 발행 로직 AOP로 분리하여 선언적으로 처리하기 (0) | 2024.04.24 |
---|---|
MySQL 네임드락으로 분산락 구성, AOP로 유연하게 적용하기 (0) | 2024.04.24 |
API별 인증 해제, 어노테이션으로 효율적으로 처리하기 (2) | 2024.04.09 |
문자열 응답 시 공통 응답형식이 적용되지 않는 문제 해결하기 (0) | 2024.04.05 |
공통 형식의 응답 효율적으로 처리하기 (1) | 2024.04.04 |