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 new file mode 100644 index 0000000..335fa63 --- /dev/null +++ b/src/main/kotlin/com/nilgil/commerce/common/error/CoreError.kt @@ -0,0 +1,27 @@ +package com.nilgil.commerce.common.error + +import org.springframework.http.HttpStatus +import org.springframework.http.HttpStatusCode + +/** + * 공통으로 사용될 수 있는 에러입니다. + * [HttpStatus] 단위로 기본 에러를 제공하며, 각 도메인에서 명세하지 않을 수준의 에러인 경우 사용합니다. + */ +enum class CoreError( + override val httpStatus: HttpStatusCode, + override val errorCode: String, + override val message: String, +) : ErrorType { + 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 new file mode 100644 index 0000000..da17495 --- /dev/null +++ b/src/main/kotlin/com/nilgil/commerce/common/error/CoreException.kt @@ -0,0 +1,21 @@ +package com.nilgil.commerce.common.error + +import org.springframework.boot.logging.LogLevel + +/** + * [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 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 new file mode 100644 index 0000000..22849f2 --- /dev/null +++ b/src/main/kotlin/com/nilgil/commerce/common/error/ErrorResponse.kt @@ -0,0 +1,16 @@ +package com.nilgil.commerce.common.error + +data class ErrorResponse( + val errorCode: String, + val message: String, + val detail: Any? = null, +) { + 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 new file mode 100644 index 0000000..3577398 --- /dev/null +++ b/src/main/kotlin/com/nilgil/commerce/common/error/ErrorType.kt @@ -0,0 +1,13 @@ +package com.nilgil.commerce.common.error + +import org.springframework.http.HttpStatusCode + +/** + * 클라이언트와의 약속으로 정의된 에러이며 정적인 요소들로 이루어져 있습니다. + * 동적인 데이터를 클라이언트에 제공하고자 하는 경우 [CoreException.errorDetail] 를 사용합니다. + */ +interface ErrorType { + 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 new file mode 100644 index 0000000..91fc3a4 --- /dev/null +++ b/src/main/kotlin/com/nilgil/commerce/common/error/GlobalExceptionHandler.kt @@ -0,0 +1,110 @@ +package com.nilgil.commerce.common.error + +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 = KotlinLogging.logger {} + + @ExceptionHandler(CoreException::class) + 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 = e.logMessage, + throwable = determineThrowableForStackTrace(e), + ) + return ResponseEntity(ErrorResponse.from(e), e.errorType.httpStatus) + } + + private fun determineThrowableForStackTrace(e: CoreException): Throwable? = + if (e.isWrappedInHandler) { + e.cause + } else { + e + } + + private fun resolveHttpStatusCode(e: Throwable): HttpStatusCode { + if (e is org.springframework.web.ErrorResponse) { + return e.statusCode + } + + 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 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, + throwable: Throwable? = null, +) { + when (level) { + 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 021ab46..fa76a1b 100644 --- a/src/main/kotlin/com/nilgil/commerce/order/Order.kt +++ b/src/main/kotlin/com/nilgil/commerce/order/Order.kt @@ -22,7 +22,7 @@ class Order( fun pay(paymentId: Long) { if (this.status != OrderStatus.CREATED) { - throw IllegalStateException("결제를 진행할 수 없는 상태입니다.") + throw InvalidStatusTransitionException(this.status, OrderStatus.PAID) } this.paymentId = paymentId this.status = OrderStatus.PAID @@ -30,21 +30,21 @@ class Order( fun complete() { if (this.status != OrderStatus.PAID) { - throw IllegalStateException("완료 처리할 수 없는 상태입니다.") + throw InvalidStatusTransitionException(this.status, OrderStatus.COMPLETED) } this.status = OrderStatus.COMPLETED } fun cancel() { if (this.status != OrderStatus.CREATED && this.status != OrderStatus.PAID) { - throw IllegalStateException("취소할 수 없는 상태입니다.") + throw InvalidStatusTransitionException(this.status, OrderStatus.CANCELLED) } this.status = OrderStatus.CANCELLED } fun returnOrder() { if (this.status != OrderStatus.COMPLETED) { - throw IllegalStateException("반품할 수 없는 상태입니다.") + 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 new file mode 100644 index 0000000..b366481 --- /dev/null +++ b/src/main/kotlin/com/nilgil/commerce/order/OrderError.kt @@ -0,0 +1,13 @@ +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 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 25d64c2..af6502e 100644 --- a/src/test/kotlin/com/nilgil/commerce/order/OrderTest.kt +++ b/src/test/kotlin/com/nilgil/commerce/order/OrderTest.kt @@ -37,14 +37,18 @@ class OrderTest { } @Test - fun `CREATED 상태가 아니면 IllegalStateException이 발생한다`() { + fun `CREATED 상태가 아니면 InvalidStatusTransitionException이 발생한다`() { // given val order = OrderFixtures.anOrder() order.pay(paymentId = 123L) // when, then assertThatThrownBy { order.pay(paymentId = 456L) } - .isInstanceOf(IllegalStateException::class.java) + .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) + } } } @@ -65,13 +69,17 @@ class OrderTest { } @Test - fun `PAID 상태가 아니면 IllegalStateException이 발생한다`() { + fun `PAID 상태가 아니면 InvalidStatusTransitionException이 발생한다`() { // given val order = OrderFixtures.anOrder() // when, then assertThatThrownBy { order.complete() } - .isInstanceOf(IllegalStateException::class.java) + .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) + } } } @@ -104,7 +112,7 @@ class OrderTest { } @Test - fun `COMPLETED 상태이면 IllegalStateException이 발생한다`() { + fun `COMPLETED 상태이면 InvalidStatusTransitionException이 발생한다`() { // given val order = OrderFixtures.anOrder() order.pay(123L) @@ -112,12 +120,15 @@ class OrderTest { // when, then assertThatThrownBy { order.cancel() } - .isInstanceOf(IllegalStateException::class.java) - .hasMessage("취소할 수 없는 상태입니다.") + .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 상태이면 IllegalStateException이 발생한다`() { + fun `RETURNED 상태이면 InvalidStatusTransitionException이 발생한다`() { // given val order = OrderFixtures.anOrder() order.pay(123L) @@ -126,8 +137,11 @@ class OrderTest { // when, then assertThatThrownBy { order.cancel() } - .isInstanceOf(IllegalStateException::class.java) - .hasMessage("취소할 수 없는 상태입니다.") + .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) + } } } @@ -149,14 +163,17 @@ class OrderTest { } @Test - fun `COMPLETED 상태가 아니면 IllegalStateException이 발생한다`() { + fun `COMPLETED 상태가 아니면 InvalidStatusTransitionException이 발생한다`() { // given val order = OrderFixtures.anOrder() // when, then assertThatThrownBy { order.returnOrder() } - .isInstanceOf(IllegalStateException::class.java) - .hasMessage("반품할 수 없는 상태입니다.") + .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) + } } } }