From 8d45aa033b22d1e6a7d0b96c10c0c659ca70f0e7 Mon Sep 17 00:00:00 2001 From: Younggil An Date: Thu, 31 Jul 2025 23:47:15 +0900 Subject: [PATCH 1/3] =?UTF-8?q?feat:=20=EC=A0=84=EC=97=AD=20=EC=98=88?= =?UTF-8?q?=EC=99=B8=20=EC=B2=98=EB=A6=AC=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../nilgil/commerce/common/error/CoreError.kt | 16 ++++++ .../commerce/common/error/CoreException.kt | 10 ++++ .../commerce/common/error/ErrorResponse.kt | 14 ++++++ .../nilgil/commerce/common/error/ErrorType.kt | 9 ++++ .../common/error/GlobalExceptionHandler.kt | 50 +++++++++++++++++++ 5 files changed, 99 insertions(+) create mode 100644 src/main/kotlin/com/nilgil/commerce/common/error/CoreError.kt create mode 100644 src/main/kotlin/com/nilgil/commerce/common/error/CoreException.kt create mode 100644 src/main/kotlin/com/nilgil/commerce/common/error/ErrorResponse.kt create mode 100644 src/main/kotlin/com/nilgil/commerce/common/error/ErrorType.kt create mode 100644 src/main/kotlin/com/nilgil/commerce/common/error/GlobalExceptionHandler.kt diff --git a/src/main/kotlin/com/nilgil/commerce/common/error/CoreError.kt b/src/main/kotlin/com/nilgil/commerce/common/error/CoreError.kt new file mode 100644 index 0000000..96d53cc --- /dev/null +++ b/src/main/kotlin/com/nilgil/commerce/common/error/CoreError.kt @@ -0,0 +1,16 @@ +package com.nilgil.commerce.common.error + +import org.springframework.http.HttpStatus + +enum class CoreError( + override val status: HttpStatus, + override val code: String, + override val message: String, +) : ErrorType { + UNKNOWN_ERROR(HttpStatus.INTERNAL_SERVER_ERROR, "C001", "알 수 없는 오류가 발생했습니다."), + VALIDATION_ERROR(HttpStatus.BAD_REQUEST, "C002", "요청 값이 올바르지 않습니다."), + AUTHENTICATION_ERROR(HttpStatus.UNAUTHORIZED, "C003", "인증되지 않은 사용자입니다."), + AUTHORIZATION_ERROR(HttpStatus.FORBIDDEN, "C004", "접근 권한이 없습니다."), + NOT_FOUND_ERROR(HttpStatus.NOT_FOUND, "C005", "리소스를 찾을 수 없습니다."), + CONFLICT_ERROR(HttpStatus.CONFLICT, "C006", "충돌되는 리소스가 존재합니다."), +} diff --git a/src/main/kotlin/com/nilgil/commerce/common/error/CoreException.kt b/src/main/kotlin/com/nilgil/commerce/common/error/CoreException.kt new file mode 100644 index 0000000..308f675 --- /dev/null +++ b/src/main/kotlin/com/nilgil/commerce/common/error/CoreException.kt @@ -0,0 +1,10 @@ +package com.nilgil.commerce.common.error + +import org.springframework.boot.logging.LogLevel + +data class CoreException( + val type: ErrorType = CoreError.UNKNOWN_ERROR, + override val cause: Throwable? = null, + val logLevel: LogLevel = LogLevel.ERROR, + val detail: Any? = null, +) : RuntimeException(type.message, cause) diff --git a/src/main/kotlin/com/nilgil/commerce/common/error/ErrorResponse.kt b/src/main/kotlin/com/nilgil/commerce/common/error/ErrorResponse.kt new file mode 100644 index 0000000..438581c --- /dev/null +++ b/src/main/kotlin/com/nilgil/commerce/common/error/ErrorResponse.kt @@ -0,0 +1,14 @@ +package com.nilgil.commerce.common.error + +data class ErrorResponse( + val code: String, + val message: String, + val detail: Any? = null, +) + +fun CoreException.toResponse(): ErrorResponse = + ErrorResponse( + code = this.type.code, + message = this.type.message, + detail = this.detail, + ) diff --git a/src/main/kotlin/com/nilgil/commerce/common/error/ErrorType.kt b/src/main/kotlin/com/nilgil/commerce/common/error/ErrorType.kt new file mode 100644 index 0000000..941ba51 --- /dev/null +++ b/src/main/kotlin/com/nilgil/commerce/common/error/ErrorType.kt @@ -0,0 +1,9 @@ +package com.nilgil.commerce.common.error + +import org.springframework.http.HttpStatus + +interface ErrorType { + val status: HttpStatus + val code: String + val message: String +} diff --git a/src/main/kotlin/com/nilgil/commerce/common/error/GlobalExceptionHandler.kt b/src/main/kotlin/com/nilgil/commerce/common/error/GlobalExceptionHandler.kt new file mode 100644 index 0000000..4f88f24 --- /dev/null +++ b/src/main/kotlin/com/nilgil/commerce/common/error/GlobalExceptionHandler.kt @@ -0,0 +1,50 @@ +package com.nilgil.commerce.common.error + +import org.slf4j.Logger +import org.slf4j.LoggerFactory +import org.springframework.boot.logging.LogLevel +import org.springframework.http.ResponseEntity +import org.springframework.web.bind.annotation.ExceptionHandler +import org.springframework.web.bind.annotation.RestControllerAdvice + +@RestControllerAdvice +class GlobalExceptionHandler { + private val log: Logger = LoggerFactory.getLogger(javaClass) + + @ExceptionHandler(CoreException::class) + fun handleCoreException(e: CoreException): ResponseEntity { + log.logOnLevel( + level = e.logLevel, + message = "[ERR-${e.type.code}] ${e.message}", + throwable = e, + ) + return ResponseEntity(e.toResponse(), e.type.status) + } + + @ExceptionHandler(Exception::class) + fun handleException(e: Exception): ResponseEntity = + handleCoreException(CoreException(type = CoreError.UNKNOWN_ERROR, cause = e)) + + @ExceptionHandler(IllegalArgumentException::class) + fun handleIllegalArgumentException(e: Exception): ResponseEntity = + handleCoreException(CoreException(type = CoreError.VALIDATION_ERROR, cause = e)) + + @ExceptionHandler(IllegalStateException::class) + fun handleIllegalStateException(e: Exception): ResponseEntity = + handleCoreException(CoreException(type = CoreError.CONFLICT_ERROR, cause = e)) +} + +private fun Logger.logOnLevel( + level: LogLevel, + message: String, + throwable: Throwable? = null, +) { + when (level) { + LogLevel.ERROR -> error(message, throwable) + LogLevel.WARN -> warn(message, throwable) + LogLevel.INFO -> info(message, throwable) + LogLevel.DEBUG -> debug(message, throwable) + LogLevel.TRACE -> trace(message, throwable) + else -> error(message, throwable) + } +} From 0d4d886a2b5ad9ccafac913b9c996c122c46fb66 Mon Sep 17 00:00:00 2001 From: Younggil An Date: Fri, 1 Aug 2025 00:54:11 +0900 Subject: [PATCH 2/3] =?UTF-8?q?feat:=20=EC=A3=BC=EB=AC=B8=20=EC=83=81?= =?UTF-8?q?=ED=83=9C=20=EC=A0=84=ED=99=98=20=EA=B2=80=EC=A6=9D=20=EB=A1=9C?= =?UTF-8?q?=EC=A7=81=EC=97=90=20CoreException=20=EB=8F=84=EC=9E=85=20?= =?UTF-8?q?=EB=B0=8F=20=ED=85=8C=EC=8A=A4=ED=8A=B8=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../kotlin/com/nilgil/commerce/order/Order.kt | 21 ++++++++-- .../com/nilgil/commerce/order/OrderError.kt | 12 ++++++ .../com/nilgil/commerce/order/OrderTest.kt | 39 ++++++++++++------- 3 files changed, 55 insertions(+), 17 deletions(-) create mode 100644 src/main/kotlin/com/nilgil/commerce/order/OrderError.kt diff --git a/src/main/kotlin/com/nilgil/commerce/order/Order.kt b/src/main/kotlin/com/nilgil/commerce/order/Order.kt index 021ab46..5a4ba40 100644 --- a/src/main/kotlin/com/nilgil/commerce/order/Order.kt +++ b/src/main/kotlin/com/nilgil/commerce/order/Order.kt @@ -1,6 +1,7 @@ package com.nilgil.commerce.order import com.nilgil.commerce.common.BaseEntity +import com.nilgil.commerce.common.error.CoreException import jakarta.persistence.Column import jakarta.persistence.Entity import jakarta.persistence.EnumType @@ -22,7 +23,10 @@ class Order( fun pay(paymentId: Long) { if (this.status != OrderStatus.CREATED) { - throw IllegalStateException("결제를 진행할 수 없는 상태입니다.") + throw CoreException( + type = OrderError.INVALID_STATUS_TRANSITION, + detail = "결제를 진행할 수 없는 상태입니다.", + ) } this.paymentId = paymentId this.status = OrderStatus.PAID @@ -30,21 +34,30 @@ class Order( fun complete() { if (this.status != OrderStatus.PAID) { - throw IllegalStateException("완료 처리할 수 없는 상태입니다.") + throw CoreException( + type = OrderError.INVALID_STATUS_TRANSITION, + detail = "완료 처리할 수 없는 상태입니다.", + ) } this.status = OrderStatus.COMPLETED } fun cancel() { if (this.status != OrderStatus.CREATED && this.status != OrderStatus.PAID) { - throw IllegalStateException("취소할 수 없는 상태입니다.") + throw CoreException( + type = OrderError.INVALID_STATUS_TRANSITION, + detail = "취소할 수 없는 상태입니다.", + ) } this.status = OrderStatus.CANCELLED } fun returnOrder() { if (this.status != OrderStatus.COMPLETED) { - throw IllegalStateException("반품할 수 없는 상태입니다.") + throw CoreException( + type = OrderError.INVALID_STATUS_TRANSITION, + detail = "반품할 수 없는 상태입니다.", + ) } this.status = OrderStatus.RETURNED } diff --git a/src/main/kotlin/com/nilgil/commerce/order/OrderError.kt b/src/main/kotlin/com/nilgil/commerce/order/OrderError.kt new file mode 100644 index 0000000..744b1f1 --- /dev/null +++ b/src/main/kotlin/com/nilgil/commerce/order/OrderError.kt @@ -0,0 +1,12 @@ +package com.nilgil.commerce.order + +import com.nilgil.commerce.common.error.ErrorType +import org.springframework.http.HttpStatus + +enum class OrderError( + override val status: HttpStatus, + override val code: String, + override val message: String, +) : ErrorType { + INVALID_STATUS_TRANSITION(HttpStatus.CONFLICT, "O001", "주문 상태를 변경할 수 없습니다."), +} diff --git a/src/test/kotlin/com/nilgil/commerce/order/OrderTest.kt b/src/test/kotlin/com/nilgil/commerce/order/OrderTest.kt index 25d64c2..3fa7930 100644 --- a/src/test/kotlin/com/nilgil/commerce/order/OrderTest.kt +++ b/src/test/kotlin/com/nilgil/commerce/order/OrderTest.kt @@ -1,5 +1,6 @@ package com.nilgil.commerce.order +import com.nilgil.commerce.common.error.CoreException import org.assertj.core.api.Assertions.assertThat import org.assertj.core.api.Assertions.assertThatThrownBy import org.junit.jupiter.api.DisplayName @@ -37,14 +38,17 @@ class OrderTest { } @Test - fun `CREATED 상태가 아니면 IllegalStateException이 발생한다`() { + fun `CREATED 상태가 아니면 INVALID_STATUS_TRANSITION 에러가 발생한다`() { // given val order = OrderFixtures.anOrder() order.pay(paymentId = 123L) // when, then assertThatThrownBy { order.pay(paymentId = 456L) } - .isInstanceOf(IllegalStateException::class.java) + .isInstanceOf(CoreException::class.java) + .extracting { it as CoreException } + .extracting(CoreException::type) + .isEqualTo(OrderError.INVALID_STATUS_TRANSITION) } } @@ -65,13 +69,16 @@ class OrderTest { } @Test - fun `PAID 상태가 아니면 IllegalStateException이 발생한다`() { + fun `PAID 상태가 아니면 INVALID_STATUS_TRANSITION 에러가 발생한다`() { // given val order = OrderFixtures.anOrder() // when, then assertThatThrownBy { order.complete() } - .isInstanceOf(IllegalStateException::class.java) + .isInstanceOf(CoreException::class.java) + .extracting { it as CoreException } + .extracting(CoreException::type) + .isEqualTo(OrderError.INVALID_STATUS_TRANSITION) } } @@ -104,7 +111,7 @@ class OrderTest { } @Test - fun `COMPLETED 상태이면 IllegalStateException이 발생한다`() { + fun `COMPLETED 상태이면 INVALID_STATUS_TRANSITION 에러가 발생한다`() { // given val order = OrderFixtures.anOrder() order.pay(123L) @@ -112,12 +119,14 @@ class OrderTest { // when, then assertThatThrownBy { order.cancel() } - .isInstanceOf(IllegalStateException::class.java) - .hasMessage("취소할 수 없는 상태입니다.") + .isInstanceOf(CoreException::class.java) + .extracting { it as CoreException } + .extracting(CoreException::type) + .isEqualTo(OrderError.INVALID_STATUS_TRANSITION) } @Test - fun `RETURNED 상태이면 IllegalStateException이 발생한다`() { + fun `RETURNED 상태이면 INVALID_STATUS_TRANSITION 에러가 발생한다`() { // given val order = OrderFixtures.anOrder() order.pay(123L) @@ -126,8 +135,10 @@ class OrderTest { // when, then assertThatThrownBy { order.cancel() } - .isInstanceOf(IllegalStateException::class.java) - .hasMessage("취소할 수 없는 상태입니다.") + .isInstanceOf(CoreException::class.java) + .extracting { it as CoreException } + .extracting(CoreException::type) + .isEqualTo(OrderError.INVALID_STATUS_TRANSITION) } } @@ -149,14 +160,16 @@ class OrderTest { } @Test - fun `COMPLETED 상태가 아니면 IllegalStateException이 발생한다`() { + fun `COMPLETED 상태가 아니면 INVALID_STATUS_TRANSITION 에러가 발생한다`() { // given val order = OrderFixtures.anOrder() // when, then assertThatThrownBy { order.returnOrder() } - .isInstanceOf(IllegalStateException::class.java) - .hasMessage("반품할 수 없는 상태입니다.") + .isInstanceOf(CoreException::class.java) + .extracting { it as CoreException } + .extracting(CoreException::type) + .isEqualTo(OrderError.INVALID_STATUS_TRANSITION) } } } From 669e1a3fd35cc445573db4c00cf00345378745f9 Mon Sep 17 00:00:00 2001 From: Younggil An Date: Wed, 6 Aug 2025 05:51:07 +0900 Subject: [PATCH 3/3] =?UTF-8?q?refactor:=20=EC=A0=84=EC=97=AD=20=EC=98=88?= =?UTF-8?q?=EC=99=B8=20=EC=B2=98=EB=A6=AC=20=EA=B5=AC=EC=A1=B0=20=EA=B0=9C?= =?UTF-8?q?=EC=84=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- build.gradle.kts | 1 + .../nilgil/commerce/common/error/CoreError.kt | 27 +++-- .../commerce/common/error/CoreException.kt | 19 ++- .../commerce/common/error/ErrorResponse.kt | 20 ++-- .../nilgil/commerce/common/error/ErrorType.kt | 10 +- .../common/error/GlobalExceptionHandler.kt | 108 ++++++++++++++---- .../order/InvalidStatusTransitionException.kt | 13 +++ .../kotlin/com/nilgil/commerce/order/Order.kt | 21 +--- .../com/nilgil/commerce/order/OrderError.kt | 5 +- .../com/nilgil/commerce/order/OrderTest.kt | 56 ++++----- 10 files changed, 187 insertions(+), 93 deletions(-) create mode 100644 src/main/kotlin/com/nilgil/commerce/order/InvalidStatusTransitionException.kt diff --git a/build.gradle.kts b/build.gradle.kts index feb9480..29d2dd0 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -31,6 +31,7 @@ dependencies { implementation("org.springframework.boot:spring-boot-starter-data-jpa") implementation("com.fasterxml.jackson.module:jackson-module-kotlin") implementation("org.jetbrains.kotlin:kotlin-reflect") + implementation("io.github.oshai:kotlin-logging-jvm:7.0.3") runtimeOnly("com.h2database:h2") runtimeOnly("com.mysql:mysql-connector-j") annotationProcessor("org.springframework.boot:spring-boot-configuration-processor") diff --git a/src/main/kotlin/com/nilgil/commerce/common/error/CoreError.kt b/src/main/kotlin/com/nilgil/commerce/common/error/CoreError.kt index 96d53cc..335fa63 100644 --- a/src/main/kotlin/com/nilgil/commerce/common/error/CoreError.kt +++ b/src/main/kotlin/com/nilgil/commerce/common/error/CoreError.kt @@ -1,16 +1,27 @@ package com.nilgil.commerce.common.error import org.springframework.http.HttpStatus +import org.springframework.http.HttpStatusCode +/** + * 공통으로 사용될 수 있는 에러입니다. + * [HttpStatus] 단위로 기본 에러를 제공하며, 각 도메인에서 명세하지 않을 수준의 에러인 경우 사용합니다. + */ enum class CoreError( - override val status: HttpStatus, - override val code: String, + override val httpStatus: HttpStatusCode, + override val errorCode: String, override val message: String, ) : ErrorType { - UNKNOWN_ERROR(HttpStatus.INTERNAL_SERVER_ERROR, "C001", "알 수 없는 오류가 발생했습니다."), - VALIDATION_ERROR(HttpStatus.BAD_REQUEST, "C002", "요청 값이 올바르지 않습니다."), - AUTHENTICATION_ERROR(HttpStatus.UNAUTHORIZED, "C003", "인증되지 않은 사용자입니다."), - AUTHORIZATION_ERROR(HttpStatus.FORBIDDEN, "C004", "접근 권한이 없습니다."), - NOT_FOUND_ERROR(HttpStatus.NOT_FOUND, "C005", "리소스를 찾을 수 없습니다."), - CONFLICT_ERROR(HttpStatus.CONFLICT, "C006", "충돌되는 리소스가 존재합니다."), + VALIDATION_ERROR(HttpStatus.BAD_REQUEST, "C400", "요청 값이 올바르지 않습니다."), + AUTHENTICATION_ERROR(HttpStatus.UNAUTHORIZED, "C401", "인증되지 않은 사용자입니다."), + AUTHORIZATION_ERROR(HttpStatus.FORBIDDEN, "C403", "접근 권한이 없습니다."), + NOT_FOUND_ERROR(HttpStatus.NOT_FOUND, "C404", "리소스를 찾을 수 없습니다."), + NOT_ALLOWED_METHOD_ERROR(HttpStatus.METHOD_NOT_ALLOWED, "C405", "허용되지 않은 Http Method 입니다."), + CONFLICT_ERROR(HttpStatus.CONFLICT, "C409", "충돌되는 리소스가 존재합니다."), + INTERNAL_SERVER_ERROR(HttpStatus.INTERNAL_SERVER_ERROR, "C500", "알 수 없는 오류가 발생했습니다."), + ; + + companion object { + fun findByStatus(status: HttpStatusCode): CoreError? = CoreError.entries.firstOrNull { it.httpStatus == status } + } } diff --git a/src/main/kotlin/com/nilgil/commerce/common/error/CoreException.kt b/src/main/kotlin/com/nilgil/commerce/common/error/CoreException.kt index 308f675..da17495 100644 --- a/src/main/kotlin/com/nilgil/commerce/common/error/CoreException.kt +++ b/src/main/kotlin/com/nilgil/commerce/common/error/CoreException.kt @@ -2,9 +2,20 @@ package com.nilgil.commerce.common.error import org.springframework.boot.logging.LogLevel -data class CoreException( - val type: ErrorType = CoreError.UNKNOWN_ERROR, +/** + * [Throwable.message]를 final로 선언하여 확장을 막으며 [ErrorType.message]를 사용하도록 고정합니다. + * + * 클라이언트에 응답되는 메세지는 [ErrorType.message] 고정입니다. + * 응답에 부가적인 내용을 담고자 한다면 [errorDetail]을 사용합니다. + * + * 로그에 남는 메세지는 [logMessage]를 지정하지 않으면 기본적으로 [ErrorType.message]가 사용됩니다. + */ +open class CoreException( + val errorType: ErrorType, + val errorDetail: Any? = null, override val cause: Throwable? = null, val logLevel: LogLevel = LogLevel.ERROR, - val detail: Any? = null, -) : RuntimeException(type.message, cause) + val logMessage: () -> String = { errorType.message }, +) : RuntimeException(errorType.message, cause) { + final override val message: String = errorType.message +} diff --git a/src/main/kotlin/com/nilgil/commerce/common/error/ErrorResponse.kt b/src/main/kotlin/com/nilgil/commerce/common/error/ErrorResponse.kt index 438581c..22849f2 100644 --- a/src/main/kotlin/com/nilgil/commerce/common/error/ErrorResponse.kt +++ b/src/main/kotlin/com/nilgil/commerce/common/error/ErrorResponse.kt @@ -1,14 +1,16 @@ package com.nilgil.commerce.common.error data class ErrorResponse( - val code: String, + val errorCode: String, val message: String, val detail: Any? = null, -) - -fun CoreException.toResponse(): ErrorResponse = - ErrorResponse( - code = this.type.code, - message = this.type.message, - detail = this.detail, - ) +) { + companion object { + fun from(e: CoreException) = + ErrorResponse( + errorCode = e.errorType.errorCode, + message = e.errorType.message, + detail = e.errorDetail, + ) + } +} diff --git a/src/main/kotlin/com/nilgil/commerce/common/error/ErrorType.kt b/src/main/kotlin/com/nilgil/commerce/common/error/ErrorType.kt index 941ba51..3577398 100644 --- a/src/main/kotlin/com/nilgil/commerce/common/error/ErrorType.kt +++ b/src/main/kotlin/com/nilgil/commerce/common/error/ErrorType.kt @@ -1,9 +1,13 @@ package com.nilgil.commerce.common.error -import org.springframework.http.HttpStatus +import org.springframework.http.HttpStatusCode +/** + * 클라이언트와의 약속으로 정의된 에러이며 정적인 요소들로 이루어져 있습니다. + * 동적인 데이터를 클라이언트에 제공하고자 하는 경우 [CoreException.errorDetail] 를 사용합니다. + */ interface ErrorType { - val status: HttpStatus - val code: String + val httpStatus: HttpStatusCode + val errorCode: String val message: String } diff --git a/src/main/kotlin/com/nilgil/commerce/common/error/GlobalExceptionHandler.kt b/src/main/kotlin/com/nilgil/commerce/common/error/GlobalExceptionHandler.kt index 4f88f24..91fc3a4 100644 --- a/src/main/kotlin/com/nilgil/commerce/common/error/GlobalExceptionHandler.kt +++ b/src/main/kotlin/com/nilgil/commerce/common/error/GlobalExceptionHandler.kt @@ -1,50 +1,110 @@ package com.nilgil.commerce.common.error -import org.slf4j.Logger -import org.slf4j.LoggerFactory +import io.github.oshai.kotlinlogging.KLogger +import io.github.oshai.kotlinlogging.KotlinLogging import org.springframework.boot.logging.LogLevel +import org.springframework.http.HttpStatus +import org.springframework.http.HttpStatusCode import org.springframework.http.ResponseEntity import org.springframework.web.bind.annotation.ExceptionHandler +import org.springframework.web.bind.annotation.ResponseStatus import org.springframework.web.bind.annotation.RestControllerAdvice +/** + * 모든 예외를 [CoreException]으로 래핑 하여 핸들링합니다. + * + * 명시적으로 핸들링하지 않은 예외들의 경우 일괄적으로 [handleException]에서 핸들링합니다. + * 해당 예외에서 상태 코드 정보를 추출할 수 있고, [CoreError]에 해당 상태 코드와 매핑되는 에러가 존재하는 경우 해당 에러가 사용되며, + * 그 외 [CoreError.INTERNAL_SERVER_ERROR]가 사용되어 500 상태 코드로 응답됩니다. + * 이는 [CoreError]에 존재하지 않는 상태 코드에 대해 클라이언트에게 표현하지 않는다는 의미를 가집니다. + * + * 현 클래스에서 래핑 된 경우 [CoreException.cause]를 사용하여 실제 발생 위치부터 StackTrace를 남깁니다. + */ @RestControllerAdvice class GlobalExceptionHandler { - private val log: Logger = LoggerFactory.getLogger(javaClass) + private val log = KotlinLogging.logger {} @ExceptionHandler(CoreException::class) - fun handleCoreException(e: CoreException): ResponseEntity { + fun handleCoreException(e: CoreException): ResponseEntity = logAndRespond(e) + + @ExceptionHandler(Exception::class) + fun handleException(e: Exception): ResponseEntity { + val httpStatusCode = resolveHttpStatusCode(e) + val errorType = determineErrorType(httpStatusCode) + return wrapAndProcess(e, errorType) + } + + @ExceptionHandler(IllegalArgumentException::class) + fun handleIllegalArgumentException(e: IllegalArgumentException): ResponseEntity = + wrapAndProcess(e, CoreError.VALIDATION_ERROR, LogLevel.WARN) + + @ExceptionHandler(IllegalStateException::class) + fun handleIllegalStateException(e: IllegalStateException): ResponseEntity = + wrapAndProcess(e, CoreError.CONFLICT_ERROR, LogLevel.WARN) + + private fun wrapAndProcess( + e: Exception, + errorType: CoreError, + logLevel: LogLevel = LogLevel.ERROR, + ): ResponseEntity = + logAndRespond( + CoreException( + errorType = errorType, + cause = e, + logLevel = logLevel, + ), + ) + + private fun logAndRespond(e: CoreException): ResponseEntity { log.logOnLevel( level = e.logLevel, - message = "[ERR-${e.type.code}] ${e.message}", - throwable = e, + message = e.logMessage, + throwable = determineThrowableForStackTrace(e), ) - return ResponseEntity(e.toResponse(), e.type.status) + return ResponseEntity(ErrorResponse.from(e), e.errorType.httpStatus) } - @ExceptionHandler(Exception::class) - fun handleException(e: Exception): ResponseEntity = - handleCoreException(CoreException(type = CoreError.UNKNOWN_ERROR, cause = e)) + private fun determineThrowableForStackTrace(e: CoreException): Throwable? = + if (e.isWrappedInHandler) { + e.cause + } else { + e + } - @ExceptionHandler(IllegalArgumentException::class) - fun handleIllegalArgumentException(e: Exception): ResponseEntity = - handleCoreException(CoreException(type = CoreError.VALIDATION_ERROR, cause = e)) + private fun resolveHttpStatusCode(e: Throwable): HttpStatusCode { + if (e is org.springframework.web.ErrorResponse) { + return e.statusCode + } - @ExceptionHandler(IllegalStateException::class) - fun handleIllegalStateException(e: Exception): ResponseEntity = - handleCoreException(CoreException(type = CoreError.CONFLICT_ERROR, cause = e)) + e::class.java.getAnnotation(ResponseStatus::class.java)?.let { + return it.code + } + + return HttpStatus.INTERNAL_SERVER_ERROR + } + + private fun determineErrorType(httpStatusCode: HttpStatusCode): CoreError = + CoreError.findByStatus(httpStatusCode) ?: CoreError.INTERNAL_SERVER_ERROR } -private fun Logger.logOnLevel( +private val CoreException.isWrappedInHandler: Boolean + get() { + val wrappedAt = this.stackTrace.firstOrNull()?.className + return wrappedAt == GlobalExceptionHandler::class.java.name + } + +private fun KLogger.logOnLevel( level: LogLevel, - message: String, + message: () -> String, throwable: Throwable? = null, ) { when (level) { - LogLevel.ERROR -> error(message, throwable) - LogLevel.WARN -> warn(message, throwable) - LogLevel.INFO -> info(message, throwable) - LogLevel.DEBUG -> debug(message, throwable) - LogLevel.TRACE -> trace(message, throwable) - else -> error(message, throwable) + LogLevel.FATAL -> error(throwable, message) + LogLevel.ERROR -> error(throwable, message) + LogLevel.WARN -> warn(throwable, message) + LogLevel.INFO -> info(throwable, message) + LogLevel.DEBUG -> debug(throwable, message) + LogLevel.TRACE -> trace(throwable, message) + LogLevel.OFF -> {} } } diff --git a/src/main/kotlin/com/nilgil/commerce/order/InvalidStatusTransitionException.kt b/src/main/kotlin/com/nilgil/commerce/order/InvalidStatusTransitionException.kt new file mode 100644 index 0000000..3b3f4b6 --- /dev/null +++ b/src/main/kotlin/com/nilgil/commerce/order/InvalidStatusTransitionException.kt @@ -0,0 +1,13 @@ +package com.nilgil.commerce.order + +import com.nilgil.commerce.common.error.CoreException + +class InvalidStatusTransitionException( + val current: OrderStatus, + val target: OrderStatus, +) : CoreException( + errorType = OrderError.INVALID_STATUS_TRANSITION, + logMessage = { + "상태를 '$current'에서 '$target'로 변경할 수 없습니다." + }, + ) diff --git a/src/main/kotlin/com/nilgil/commerce/order/Order.kt b/src/main/kotlin/com/nilgil/commerce/order/Order.kt index 5a4ba40..fa76a1b 100644 --- a/src/main/kotlin/com/nilgil/commerce/order/Order.kt +++ b/src/main/kotlin/com/nilgil/commerce/order/Order.kt @@ -1,7 +1,6 @@ package com.nilgil.commerce.order import com.nilgil.commerce.common.BaseEntity -import com.nilgil.commerce.common.error.CoreException import jakarta.persistence.Column import jakarta.persistence.Entity import jakarta.persistence.EnumType @@ -23,10 +22,7 @@ class Order( fun pay(paymentId: Long) { if (this.status != OrderStatus.CREATED) { - throw CoreException( - type = OrderError.INVALID_STATUS_TRANSITION, - detail = "결제를 진행할 수 없는 상태입니다.", - ) + throw InvalidStatusTransitionException(this.status, OrderStatus.PAID) } this.paymentId = paymentId this.status = OrderStatus.PAID @@ -34,30 +30,21 @@ class Order( fun complete() { if (this.status != OrderStatus.PAID) { - throw CoreException( - type = OrderError.INVALID_STATUS_TRANSITION, - detail = "완료 처리할 수 없는 상태입니다.", - ) + throw InvalidStatusTransitionException(this.status, OrderStatus.COMPLETED) } this.status = OrderStatus.COMPLETED } fun cancel() { if (this.status != OrderStatus.CREATED && this.status != OrderStatus.PAID) { - throw CoreException( - type = OrderError.INVALID_STATUS_TRANSITION, - detail = "취소할 수 없는 상태입니다.", - ) + throw InvalidStatusTransitionException(this.status, OrderStatus.CANCELLED) } this.status = OrderStatus.CANCELLED } fun returnOrder() { if (this.status != OrderStatus.COMPLETED) { - throw CoreException( - type = OrderError.INVALID_STATUS_TRANSITION, - detail = "반품할 수 없는 상태입니다.", - ) + throw InvalidStatusTransitionException(this.status, OrderStatus.RETURNED) } this.status = OrderStatus.RETURNED } diff --git a/src/main/kotlin/com/nilgil/commerce/order/OrderError.kt b/src/main/kotlin/com/nilgil/commerce/order/OrderError.kt index 744b1f1..b366481 100644 --- a/src/main/kotlin/com/nilgil/commerce/order/OrderError.kt +++ b/src/main/kotlin/com/nilgil/commerce/order/OrderError.kt @@ -2,10 +2,11 @@ package com.nilgil.commerce.order import com.nilgil.commerce.common.error.ErrorType import org.springframework.http.HttpStatus +import org.springframework.http.HttpStatusCode enum class OrderError( - override val status: HttpStatus, - override val code: String, + override val httpStatus: HttpStatusCode, + override val errorCode: String, override val message: String, ) : ErrorType { INVALID_STATUS_TRANSITION(HttpStatus.CONFLICT, "O001", "주문 상태를 변경할 수 없습니다."), diff --git a/src/test/kotlin/com/nilgil/commerce/order/OrderTest.kt b/src/test/kotlin/com/nilgil/commerce/order/OrderTest.kt index 3fa7930..af6502e 100644 --- a/src/test/kotlin/com/nilgil/commerce/order/OrderTest.kt +++ b/src/test/kotlin/com/nilgil/commerce/order/OrderTest.kt @@ -1,6 +1,5 @@ package com.nilgil.commerce.order -import com.nilgil.commerce.common.error.CoreException import org.assertj.core.api.Assertions.assertThat import org.assertj.core.api.Assertions.assertThatThrownBy import org.junit.jupiter.api.DisplayName @@ -38,17 +37,18 @@ class OrderTest { } @Test - fun `CREATED 상태가 아니면 INVALID_STATUS_TRANSITION 에러가 발생한다`() { + fun `CREATED 상태가 아니면 InvalidStatusTransitionException이 발생한다`() { // given val order = OrderFixtures.anOrder() order.pay(paymentId = 123L) // when, then assertThatThrownBy { order.pay(paymentId = 456L) } - .isInstanceOf(CoreException::class.java) - .extracting { it as CoreException } - .extracting(CoreException::type) - .isEqualTo(OrderError.INVALID_STATUS_TRANSITION) + .isInstanceOfSatisfying(InvalidStatusTransitionException::class.java) { e -> + assertThat(e.errorType).isEqualTo(OrderError.INVALID_STATUS_TRANSITION) + assertThat(e.current).isEqualTo(OrderStatus.PAID) + assertThat(e.target).isEqualTo(OrderStatus.PAID) + } } } @@ -69,16 +69,17 @@ class OrderTest { } @Test - fun `PAID 상태가 아니면 INVALID_STATUS_TRANSITION 에러가 발생한다`() { + fun `PAID 상태가 아니면 InvalidStatusTransitionException이 발생한다`() { // given val order = OrderFixtures.anOrder() // when, then assertThatThrownBy { order.complete() } - .isInstanceOf(CoreException::class.java) - .extracting { it as CoreException } - .extracting(CoreException::type) - .isEqualTo(OrderError.INVALID_STATUS_TRANSITION) + .isInstanceOfSatisfying(InvalidStatusTransitionException::class.java) { e -> + assertThat(e.errorType).isEqualTo(OrderError.INVALID_STATUS_TRANSITION) + assertThat(e.current).isEqualTo(OrderStatus.CREATED) + assertThat(e.target).isEqualTo(OrderStatus.COMPLETED) + } } } @@ -111,7 +112,7 @@ class OrderTest { } @Test - fun `COMPLETED 상태이면 INVALID_STATUS_TRANSITION 에러가 발생한다`() { + fun `COMPLETED 상태이면 InvalidStatusTransitionException이 발생한다`() { // given val order = OrderFixtures.anOrder() order.pay(123L) @@ -119,14 +120,15 @@ class OrderTest { // when, then assertThatThrownBy { order.cancel() } - .isInstanceOf(CoreException::class.java) - .extracting { it as CoreException } - .extracting(CoreException::type) - .isEqualTo(OrderError.INVALID_STATUS_TRANSITION) + .isInstanceOfSatisfying(InvalidStatusTransitionException::class.java) { e -> + assertThat(e.errorType).isEqualTo(OrderError.INVALID_STATUS_TRANSITION) + assertThat(e.current).isEqualTo(OrderStatus.COMPLETED) + assertThat(e.target).isEqualTo(OrderStatus.CANCELLED) + } } @Test - fun `RETURNED 상태이면 INVALID_STATUS_TRANSITION 에러가 발생한다`() { + fun `RETURNED 상태이면 InvalidStatusTransitionException이 발생한다`() { // given val order = OrderFixtures.anOrder() order.pay(123L) @@ -135,10 +137,11 @@ class OrderTest { // when, then assertThatThrownBy { order.cancel() } - .isInstanceOf(CoreException::class.java) - .extracting { it as CoreException } - .extracting(CoreException::type) - .isEqualTo(OrderError.INVALID_STATUS_TRANSITION) + .isInstanceOfSatisfying(InvalidStatusTransitionException::class.java) { e -> + assertThat(e.errorType).isEqualTo(OrderError.INVALID_STATUS_TRANSITION) + assertThat(e.current).isEqualTo(OrderStatus.RETURNED) + assertThat(e.target).isEqualTo(OrderStatus.CANCELLED) + } } } @@ -160,16 +163,17 @@ class OrderTest { } @Test - fun `COMPLETED 상태가 아니면 INVALID_STATUS_TRANSITION 에러가 발생한다`() { + fun `COMPLETED 상태가 아니면 InvalidStatusTransitionException이 발생한다`() { // given val order = OrderFixtures.anOrder() // when, then assertThatThrownBy { order.returnOrder() } - .isInstanceOf(CoreException::class.java) - .extracting { it as CoreException } - .extracting(CoreException::type) - .isEqualTo(OrderError.INVALID_STATUS_TRANSITION) + .isInstanceOfSatisfying(InvalidStatusTransitionException::class.java) { e -> + assertThat(e.errorType).isEqualTo(OrderError.INVALID_STATUS_TRANSITION) + assertThat(e.current).isEqualTo(OrderStatus.CREATED) + assertThat(e.target).isEqualTo(OrderStatus.RETURNED) + } } } }