-
Notifications
You must be signed in to change notification settings - Fork 0
[#21] 전역 예외 처리 구성 #22
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: develop
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
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 } | ||
} | ||
} |
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 | ||
} |
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, | ||
) | ||
} | ||
} |
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 { | ||
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'로 변경할 수 없습니다." | ||
}, | ||
) |
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", "주문 상태를 변경할 수 없습니다."), | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 크게 중요하진 않은데 여기 적힌 message 는 쓰임이 없는 것 같긴 하네욥 ㅋ_ㅋ There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 이때는 쓰임이 없었던 것 같습니다... 그런데 이제는 쓰임이 생겼습니다!ㅎ 예외 응답 시 직접 작성한 메시지를 사용하지 않고 규약으로 정의된 메시지를 사용하고자 했습니다.
|
||
} |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
정말 좋네요. 👍👍👍
CoreError 외에도 모듈이나 도메인별로 Error Code 를 쉽게 추가 가능할 것 같고, 특정 enum 이 불필요하게 커지지도 않을 것 같아요.