스프링5, 스프링부트2에서 웹플럭스를 사용할 때 에러(예외)를 쉽게 처리할 수 있도록 스프링에서 지원하는 방법을 알아보자.
일단 셋팅..
예외를 처리하는 방법을 알기전에 예외를 발생시킬 수 있는 환경부터 만들어보자.
기본적으로, 라우터와 핸들러가 필요할 것이다.
HelloRouter.kt
@Configuration
class HelloRouter (
private val helloHandler: HelloHandler
) {
@Bean
fun routeHello(): RouterFunction<ServerResponse\> {
return coRouter {
"/hello".nest {
accept(MediaType.APPLICATION\_JSON).nest {
GET("", helloHandler::hello)
}
}
}
}
}
HelloHandler.kt
@Component
class HelloHandler {
suspend fun hello(request: ServerRequest): ServerResponse {
return ok().bodyValueAndAwait(sayHello(request))
}
fun sayHello(request: ServerRequest): String {
val name \= request.queryParamOrNull("name")
?: throw Exception()
return "hello to $name"
}
}
라우터와 핸들러를 간단하게 만들어보았다. 여기서 [GET] /hello?name=beer
로 요청을 보낸다면 hello to beer라는 응답이 나올 것이다. 하지만, [GET] /hello
만 보낸다면 name에 해당하는 queryParameter가 없기 때문에 예외가 발생하게 된다.
에러 내용을 보면 분명히 클라이언트가 name을 보내지 않아서 (API 형식에 맞게 보내지 않아서) 생기는 오류인데 서버측 잘못을 나타내는 500에러를 뱉게 된다. 즉 예외처리를 대충 하면(하지 않고 던져버리기만 하면) 서버측 잘못으로 떠넘겨진다.. 욕먹지 않으려면(?) 클라이언트에게 400대 에러를 뱉으면서 잘못된 형식으로 요청을 했으니 요청을 제대로 하라고 알려줘야 할 것이다.
일단 예외가 발생할 만한 상황을 만들어 놓았으니까 예외를 핸들링 할 수 있는 방법을 알아보자.
Global Error Handling
웹 애플리케이션에서 발생하는 모든 에러를 한 곳에 모아서 처리하는 방법이 있다. 그러기 위해서는 두 가지를 개발하면 된다.
- GolbalErrorAttribute 커스터마이징
- GlobalErrorWebExceptionHandler 개발
GlobalErrorAttributes
위에서 봤던 것 처럼 Spring에서는 핸들러에서 발생하는 예외에 대한 응답(HTTP Status 등등..)을 자동으로 만들어준다. (자동으로 만들어주기 때문에 문제가 될 수 있다..) 스프링에서 예외가 발생하면 ErrorAttributes 객체로 등록된 빈을 통해 응답값을 만드는데, 기본 ErrorAttributes가 autoconfiguration으로 등록되어서 해당 문제가 발생하는 것이다.
이러한 문제를 해결하기 위해서는 직접 ErrorAttributes를 개발하여 Bean으로 설정하면 된다. 즉, 커스터마이징을 하면 된다.
GlobalErrorAttributes.kt
@Component
class GlobalErrorAttributes : DefaultErrorAttributes() {
override fun getErrorAttributes (
request: ServerRequest,
options: ErrorAttributeOptions
): Map<String, Any> {
return super.getErrorAttributes(request, options).toMutableMap() + mutableMapOf (
"status" to HttpStatus.BAD_REQUEST,
"message" to "name is required"
)
}
}
GlobalErrorAttributes는 에러를 사용자에게 어떻게 보여줄지 정의하는 클래스라고 생각하면 된다.
먼저, DefaultErrorAttributes.getErrorAttributes() (super.getErrorAttributes()) 에서 기본적으로 보여주는 내용들은 핸들러의 옵션에 따라 달라지는데 간단히 요약하자면 다음과 같다. 옵션은 다음에 소개가 될 GlobalErrorWebExceptionHandler에서 다루겠다.
- timestamp
- path
- status
- errors - 옵션에 BINDING_ERRORS 포함
- message
- requestId
- exception - 옵션에 EXCEPTION 포함
- trace - 옵션에 STACK_TRACE 포함
그리고 그 뒤에 이어지는 mutableMap은 super.getErrorAttributes() 에서 기본으로 만들어주는 status와 message를 덮어버리는 것으로 생각하면 된다. (attribute 커스터마이징)
GlobalErrorWebExceptionHandler
에러 내용을 GlobalErrorAttributes에 정의했으니까 에러를 받는 핸들러를 정의해보자.
@Component
@Order(-2)
class GlobalErrorWebExceptionHandler (
errorAttributes: ErrorAttributes,
resourceProperties: ResourceProperties,
applicationContext: ApplicationContext,
serverCodecConfigurer: ServerCodecConfigurer
) : AbstractErrorWebExceptionHandler(
errorAttributes,
resourceProperties,
applicationContext
) {
init {
setMessageWriters(serverCodecConfigurer.writers)
setMessageReaders(serverCodecConfigurer.readers)
}
// 라우터마다 에러를 처리하는 라우팅 함수를 정의
override fun getRoutingFunction(errorAttributes: ErrorAttributes): RouterFunction<ServerResponse> {
return router { (all()) { renderErrorResponse(it) } }
}
// 에러를 처리하여 사용자에게 response를 만드는 함수
private fun renderErrorResponse(request: ServerRequest): Mono<ServerResponse> {
val errorPropertiesMap = getErrorAttributes(
request,
ErrorAttributeOptions.defaults()
)
return ServerResponse.status(HttpStatus.BAD_REQUEST)
.contentType(MediaType.APPLICATION_JSON)
.body(BodyInserters.fromValue(errorPropertiesMap))
}
}
getRoutingFunction()
은 라우터별로 에러를 처리하는 라우팅 함수를 정의하는 메서드이다. 해당 코드는 모든 라우터에 대해 동일한 방식으로 처리를 하도록 구현되었다.
여기서는 ErrorAttributeOptions을 넣을 수 있는데 이 options이 GlobalErrorAttributes에서 소개된 options이다. 이 값에 따라 DefaultErrorAttributes.getErrorAttributes()
가 보여지는 값이 달라지는데, 해당 코드에서는 default 값을 사용하였다. 코드를 까보면 default에는 아무 옵션도 포함되어있지 않다. 그래서 exception과 trace, errors 항목이 빠져버릴 것이다. 만약 여기서
ErrorAttributeOptions를 커스터마이징한다면 exception, trace, errors 항목을 추가하거나 뺄 수 있다.
renderErrorResponse()
를 보면 라우터에서 에러가 나면 해당 함수가 실행되는데, getErrorAttributes()
를 호출하여 이전에 정의했던 GlobalErrorAttributes.getErrorAttributes()
를 호출하여 그 값들을 사용자에게 보여주도록 ServerResponse객체에 값을 넣는다. 여기서 실질적으로 응답에 대한 status와 contentType, body를 만든다.
결과
이전과 같은 요청을 날려보면 error code와 message가 달라진 것을 확인할 수 있다. 그리고 사용자에게 보여줄 필요가 없는 trace도 사라졌다.
커스터마이징
그런데 위와 같이 하면 모든 에러가 400에러로 핸들링이 될 것이다. 코드를 조금 수정하여 유연하게 에러 핸들링을 하도록 구현해보자.
상황 추가
사용자에게 등록된 친구가 있고, /hello 는 등록된 친구에게만 인사를 할 수 있다고 가정해보자. 그럼 핸들러 코드는 다음과 같을 것이다.
@Component
class HelloHandler {
/*
Repository Mocking
사용자에게 등록된 친구가 이정도라고 가정한다.
*/
private val friendList = listOf("heineken", "hoegaarden", "kozel", "terra")
suspend fun hello(request: ServerRequest): ServerResponse {
return ok().bodyValueAndAwait(
sayHello(request).onErrorReturn("Hello Stranger")
.awaitFirst()
)
}
fun sayHello(request: ServerRequest): Mono<String> {
val name = request.queryParamOrNull("name")
?: throw InvalidParameterException("name")
return friendList.find { it == name }?.let { friend ->
Mono.just("hello to $friend")
} ?: throw FriendNotFoundException(name)
}
}
sayHello()를 보면 먼저 파라미터가 존재하는지 확인한 다음(1), name이 친구 목록이 있으면 인사말을 리턴한다.(2)
(1)에서 에러가 나면 클라이언트가 잘못된 방식으로 요청을 했으니 400(BAD_REQUEST)를 뱉는 것이 적절하고, (2)에서 에러가 나면 없는 자원을 찾아서 생기는 에러로 404(NOT_FOUND)를 뱉는 것이 적절할 것이다.
상황에 맞는 에러를 리턴하기 위해 에러를 커스터마이징 해보았다.
에러 커스터마이징
abstract class HelloException(override val message: String): RuntimeException()
@ResponseStatus(code = HttpStatus.BAD_REQUEST)
class InvalidParameterException(parameterName: String)
: HelloException("파라미터 $parameterName 의 값이 잘못되었거나 없습니다.")
@ResponseStatus(code = HttpStatus.NOT_FOUND)
class FriendNotFoundException(friendName: String)
: HelloException("$friendName 은 등록된 친구가 아닙니다.")
-
@ResponseStatus는 해당 에러를 어떤 StatusCode로 뱉을 것인지 설정하는 파라미터이다. 여기서 InvalidParameterException는 Bad Request, FriendNotFoundException은 Not found로 설정하였다.
-
message는 클라이언트에게 보여줄 적절한 메세지를 설정한다. 정의한 예외마다 보여줘야할 메세지가 다르므로 예외마다 다르게 설정하였다.
GlobalErrorAttributes 커스터마이징
에러를 커스터마이징 했으면 GlobalErrorAttributes도 커스터마이징 해보자. 필자는 DefaultErrorAttributes에 있는 코드를 일부 배꼈활용하였다. (이것도 공부가 되지 않을까,,?)
@Component
class GlobalErrorAttributes : DefaultErrorAttributes() {
companion object {
private const val ERROR_ATTRIBUTE = "ERROR"
}
override fun getErrorAttributes (
request: ServerRequest,
options: ErrorAttributeOptions
): Map<String, Any> {
val error = getError(request)
val errorStatus = determineHttpStatus(error)
return mapOf(
"timestamp" to LocalDateTime.now().format(DateTimeFormatter.ISO_DATE_TIME),
"path" to request.path(),
"status" to errorStatus.value(),
"error" to errorStatus.reasonPhrase,
"message" to error.message,
"requestId" to request.exchange().request.id
)
}
override fun getError(request: ServerRequest): HelloException {
return request.attributeOrNull(ERROR_ATTRIBUTE) as HelloException?
?: throw IllegalStateException("Missing exception attribute in ServerWebExchange")
}
override fun storeErrorInformation(
error: Throwable?,
exchange: ServerWebExchange
) {
exchange.attributes.putIfAbsent(ERROR_ATTRIBUTE, error)
}
private fun determineHttpStatus(error: Throwable): HttpStatus {
val responseStatusAnnotation = MergedAnnotations
.from(error.javaClass, MergedAnnotations.SearchStrategy.TYPE_HIERARCHY)[ResponseStatus::class.java]
return if (error is ResponseStatusException) {
error.status
} else responseStatusAnnotation.getValue("code", HttpStatus::class.java)
.orElse(HttpStatus.INTERNAL_SERVER_ERROR)
}
}
메서드별로 간단히 설명을 해보겠다.
-
storeErrorInformation(): 웹 애플리케이션에서 예외가 발생하면, 이 메서드를 사용하여 던져진 예외 객체 정보를 attribute에 저장한다. 커스터마이징한 예외 객체의 어노테이션, message를 활용해야 하기 때문에 예외 객체를 가져와야 한다.
-
determineHttpStatus(): 예외 객체에 달려있는 어노테이션 정보를 불러와 Status를 가져오는 메서드이다.
-
getErrorAttributes(): DefaultErrorAttributes에 있는 getErrorAttributes()를 사용하지 않고 필자가 직접 만들 메서드이다. 코드가 조금 더 명확해진 느낌이다. (부모객체가 어떻게 errorAttribute를 만들어내는지 몰라도 된다!)
GlobalErrorWebExceptionHandler 커스터마이징
@Component
@Order(-2)
class GlobalErrorWebExceptionHandler (
errorAttributes: ErrorAttributes,
resourceProperties: ResourceProperties,
applicationContext: ApplicationContext,
serverCodecConfigurer: ServerCodecConfigurer
) : AbstractErrorWebExceptionHandler(
errorAttributes,
resourceProperties,
applicationContext
) {
private val logger = LoggerFactory.getLogger(javaClass)
companion object {
private const val ERROR_ATTRIBUTE = "ERROR"
private const val STATUS = "status"
}
init {
setMessageWriters(serverCodecConfigurer.writers)
setMessageReaders(serverCodecConfigurer.readers)
}
override fun getRoutingFunction(errorAttributes: ErrorAttributes): RouterFunction<ServerResponse> {
logger.info("GlobalErrorWebExceptionHandler.getRoutingFunction()")
return router { (all()) { renderErrorResponse(it) } }
}
private fun renderErrorResponse(request: ServerRequest): Mono<ServerResponse> {
val errorPropertiesMap = getErrorAttributes(
request,
ErrorAttributeOptions.defaults()
)
val status = (errorPropertiesMap[STATUS] as Int)
.let { HttpStatus.valueOf(it) }
return ServerResponse.status(status)
.contentType(MediaType.APPLICATION_JSON)
.body(BodyInserters.fromValue(errorPropertiesMap))
}
}
GlobalErrorWebExceptionHandler은 renderErrorResponse()만 변경되었다. ErrorAttribute에서 status값을 저장한 것을 불러와서 ServerResponse의 status를 설정하는 것이 변경된 부분이다.
이렇게 하면 예외가 늘어나면 예외 객체만 커스터마이징하면 되기 때문에 전체적인 코드가 깔끔해지고 사용자에게 에러 정보를 더 정확하고 명확하게 보여줄 수 있을 것 같다.
결과
변경된 코드를 실행하여 응답 결과를 살펴보자. 에러 코드와 메세지가 이전보다 상황에 맞게 잘 나타난 것 같다.
200 OK: 정상
400 Error: 파라미터 미입력
404 Error: 목록에 없는 친구에 대해 요청할 경우
오늘 공부한 것을 잘 써먹는 다면 상황에 맞는 API 응답 결과를 더 잘 만들 수 있을 것 같다.
댓글