Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down
27 changes: 27 additions & 0 deletions src/main/kotlin/com/nilgil/commerce/common/error/CoreError.kt
Original file line number Diff line number Diff line change
@@ -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 }
}
}
21 changes: 21 additions & 0 deletions src/main/kotlin/com/nilgil/commerce/common/error/CoreException.kt
Original file line number Diff line number Diff line change
@@ -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
}
16 changes: 16 additions & 0 deletions src/main/kotlin/com/nilgil/commerce/common/error/ErrorResponse.kt
Original file line number Diff line number Diff line change
@@ -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,
)
}
}
13 changes: 13 additions & 0 deletions src/main/kotlin/com/nilgil/commerce/common/error/ErrorType.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
package com.nilgil.commerce.common.error

import org.springframework.http.HttpStatusCode

/**
* 클라이언트와의 약속으로 정의된 에러이며 정적인 요소들로 이루어져 있습니다.
* 동적인 데이터를 클라이언트에 제공하고자 하는 경우 [CoreException.errorDetail] 를 사용합니다.
*/
interface ErrorType {
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

정말 좋네요. 👍👍👍
CoreError 외에도 모듈이나 도메인별로 Error Code 를 쉽게 추가 가능할 것 같고, 특정 enum 이 불필요하게 커지지도 않을 것 같아요.

val httpStatus: HttpStatusCode
val errorCode: String
val message: String
}
Original file line number Diff line number Diff line change
@@ -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<ErrorResponse> = logAndRespond(e)

@ExceptionHandler(Exception::class)
fun handleException(e: Exception): ResponseEntity<ErrorResponse> {
val httpStatusCode = resolveHttpStatusCode(e)
val errorType = determineErrorType(httpStatusCode)
return wrapAndProcess(e, errorType)
}

@ExceptionHandler(IllegalArgumentException::class)
fun handleIllegalArgumentException(e: IllegalArgumentException): ResponseEntity<ErrorResponse> =
wrapAndProcess(e, CoreError.VALIDATION_ERROR, LogLevel.WARN)

@ExceptionHandler(IllegalStateException::class)
fun handleIllegalStateException(e: IllegalStateException): ResponseEntity<ErrorResponse> =
wrapAndProcess(e, CoreError.CONFLICT_ERROR, LogLevel.WARN)

private fun wrapAndProcess(
e: Exception,
errorType: CoreError,
logLevel: LogLevel = LogLevel.ERROR,
): ResponseEntity<ErrorResponse> =
logAndRespond(
CoreException(
errorType = errorType,
cause = e,
logLevel = logLevel,
),
)

private fun logAndRespond(e: CoreException): ResponseEntity<ErrorResponse> {
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 -> {}
}
}
Original file line number Diff line number Diff line change
@@ -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'로 변경할 수 없습니다."
},
)
8 changes: 4 additions & 4 deletions src/main/kotlin/com/nilgil/commerce/order/Order.kt
Original file line number Diff line number Diff line change
Expand Up @@ -22,29 +22,29 @@ 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
}

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
}
Expand Down
13 changes: 13 additions & 0 deletions src/main/kotlin/com/nilgil/commerce/order/OrderError.kt
Original file line number Diff line number Diff line change
@@ -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", "주문 상태를 변경할 수 없습니다."),
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

크게 중요하진 않은데 여기 적힌 message 는 쓰임이 없는 것 같긴 하네욥 ㅋ_ㅋ

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

이때는 쓰임이 없었던 것 같습니다... 그런데 이제는 쓰임이 생겼습니다!ㅎ

예외 응답 시 직접 작성한 메시지를 사용하지 않고 규약으로 정의된 메시지를 사용하고자 했습니다.
지금은 저 값이 그렇게 사용되도록 해 두었습니다.

INVALID_STATUS_TRANSITION 에러로 예외를 발생시키면 response에 O001 코드와 "주문 상태를 변경할 수 없습니다."라는 메시지가 응답에 포함되고, 부가 정보를 담고자 한다면 errorDetail을 사용하여 제공할 수 있게 구성했습니다.

}
43 changes: 30 additions & 13 deletions src/test/kotlin/com/nilgil/commerce/order/OrderTest.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
}
}

Expand All @@ -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)
}
}
}

Expand Down Expand Up @@ -104,20 +112,23 @@ class OrderTest {
}

@Test
fun `COMPLETED 상태이면 IllegalStateException이 발생한다`() {
fun `COMPLETED 상태이면 InvalidStatusTransitionException이 발생한다`() {
// given
val order = OrderFixtures.anOrder()
order.pay(123L)
order.complete()

// 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)
Expand All @@ -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)
}
}
}

Expand All @@ -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)
}
}
}
}
Loading