오늘은 스프링 WebFlux를 이용해서 Exception을 전역 처리하는 방법을 포스티하려고한다.
이게 나는 굉장히 중요하다고 생각한다..
Exception별로 모아서 처리를 하면 비즈니스 로직에서 가독성도 올라가고 유지보수성이 올라간다 근데 이게 WebFlux로 하려고 하다보니까 기존 MVC랑 구조가 달라서 전역처리가 불가능한 줄 알았다..
그냥 컨트롤러 단에서 if 뇌절로 처리해야 하나? 생각을 했지만 그런 짓은 하고 싶지 않았다..
바로 본론으로 들어가서 평소에 우리는
@RestControllerAdvice를 이용해서 Exception을 전역으로 처리해서 참 편했다. Exception을 정의하고 해당 Exception 만 Handling 하면 되니 매우 편했다.
근데 Webflux를 이용하다 보니 해당 기능을 그대로 가지고 오고싶어서 어떻게 해야하나 집중하고 파고들었다..
결론은 성공~!
일단 나는 이렇게 ErrorMessage를 정의했다.
open class CustomException(
val resultCode: ResultCode
) : RuntimeException(resultCode.message)
enum class ResultCode (
val code : String,
var message : String
) {
SUCCESS("D-0", "OK"),
ERROR("D-99", "ERROR"),
INVALID_PARAMETER("D-01", "입력값이 올바르지 않습니다."),
NOT_FOUND("D-02", "일치하는 데이터가 없습니다."),
ALREADY_DATA("D-03", "이미 데이터가 존재합니다."),
}
enum 클래스를 이용해서 한 파일에서 에러 메시지를 관리하려고 진행했다. 근데, 내가 아무리 Throw CustomException 을 던져도 내가 원하는 형태로 안떨어졌다.
원인을 찾아보니 WebFlux는 기존의 방식인 RestControllerAdvice로 처리하지않고, 다른 방식으로 처리를 하고 있었다.
웹플럭스 내부 라이브러리를 까보면 이렇게 해당 클래스로 Exception을 핸들링하고있는데, 우리기 참고해야할 부분은 여기다.
우리는 늘 그래왔던 것처럼 상속을 통해 오버라이딩 하고 원하는 부분만 커스텀해서 바꿔버리자!
먼저 AbstractErrorWebExceptionHandler를 상속받아 쓰고 있는 DefaultErrorWebExceptionHandler를 우리는 사용하지 않고, 새로 만들거다.
GlobalErrorWebExceptionHandler라는 이름으로 클래스를 만들고 AbstractErrorWebExceptionHandler를 상속받자!
@Component
@Order(-2)
class GlobalErrorWebExceptionHandler(
globalErrorAttributes: GlobalErrorAttributes,
applicationContext: ApplicationContext,
serverCodecConfigurer: ServerCodecConfigurer
) : AbstractErrorWebExceptionHandler(globalErrorAttributes, WebProperties.Resources(), applicationContext) {
init {
this.setMessageReaders(serverCodecConfigurer.readers)
this.setMessageWriters(serverCodecConfigurer.writers)
}
override fun getRoutingFunction(errorAttributes: ErrorAttributes): RouterFunction<ServerResponse> {
return RouterFunctions.route(RequestPredicates.all(), this::renderErrorResponse)
}
private fun renderErrorResponse(request: ServerRequest) : Mono<ServerResponse> {
val errorProperties = getErrorAttributes(request, ErrorAttributeOptions.defaults());
return ServerResponse.status(errorProperties.get("status") as Int)
.contentType(MediaType.APPLICATION_JSON)
.body(BodyInserters.fromValue(errorProperties))
}
}
이렇게 작성해주었다.
근데 여기서 Bean 등록 순서를 -2로 한 이유는 DefaultErrorWebExceptionHandler의 경우 빈 등록 순서가 -1이라는데 충돌을 방지하고자 Order(-2)로 먼저 등록을 명시적으로 적어준거다.
근데 안적어도 충돌나진 않던데, 나중에 프로젝트에서 핸들러가 또 추가될 수도 있다는 점을 고려해 명시해 놓자!
여기서 중요한 점은 주생성자 부분에서 errorAttributes 부분의 의존성 주입을 커스텀 할거다. 기본 구현클래스는 아래와같은 정보만 담아준다.
그래서 실제 에러가 발생하면 아래처럼만 뜬다.
하지만 우리는 에러를 좀더 명확하고, 이쁘게 꾸며주기 위해서 해당 클래스를 재 구현할거다.
@Component
class GlobalErrorAttributes : DefaultErrorAttributes() {
private val logger = LoggerFactory.getLogger(GlobalErrorAttributes::class.java)
override fun getErrorAttributes(request: ServerRequest, options: ErrorAttributeOptions) : Map<String, *> {
var map = super.getErrorAttributes(request, options);
val throwable: Throwable = getError(request)
when(throwable) {
is CustomException -> {
val ex = getError(request) as CustomException
map = generateExceptionMessage(map, ex, ex.resultCode)
}
else -> {
logger.error("Unhandled exception occurred", throwable)
map["exception"] = throwable.javaClass.simpleName
map["message"] = throwable.message ?: "Unexpected error occurred"
map["status"] = HttpStatus.INTERNAL_SERVER_ERROR.value()
}
}
return map;
}
private fun generateExceptionMessage(map: MutableMap<String, Any>, ex: Exception, resultCode: ResultCode) : Map<String, *> {
map.put("exception", ex.javaClass.simpleName)
map.put("error", resultCode.code)
map.put("message", resultCode.message)
map.put("status", HttpStatus.BAD_REQUEST.value())
return map
}
}
요렇게 작성을 하면 끝이다.
아까 기존에 DefaultErrorAttributes의 getErrorAttributes 메서드를 살펴보면 저 폼은 그대로 유지하려고한다.
다만 내가 원하는 값을 반환해주고 싶어서 CustomException이 들어왔을 때 When 절로 처리를 했다.
여기서 else문으로 원래 예외대로 처리를 안해주면, 어디서 에러가 났는지 로그에 전혀 안쌓이기 떄문에 꼭 else문을 넣어주자
이렇게 진행한다음에 Service로직에서 이렇게 Exception을 날려보면!
override fun deleteProduct(id: Long): Mono<ProductDto> {
return productsRepository.findById(id)
.flatMap { product ->
productsRepository.delete(product)
.thenReturn(product)
}
.map { deletedProduct ->
ProductDto(
id = deletedProduct.id!!,
category = deletedProduct.category)
}
.switchIfEmpty(
Mono.error(CustomException(ResultCode.NOT_FOUND))
)
}
삭제할 id가 없으면 .switchIfEmpty 블럭에 들어오는데 이 때 에러를 던졌다.!
그럼 이렇게 이쁘게 나온느걸 볼 수 있다.
여기 까지만 하면 에러 핸들링을 원하는 타임으로 할 수 있어서 끝! 일 수 있으나!!
나는 항상 협업할 때 사소한 에러도 처리하는걸 선호한다.
예를 들면 문서화로 a, b, c 이 값이 있다곤 하지만 이 문서를 꼼꼼히 보면서 개발할거란 생각은 안하기 때문에, 어떤 값이 빠졋고 Validation 처리 메시지도 같이 전달한다. 이렇게 하면 완성도가 더 높아보임 ㅎㅎ,,
그 부분은 아래와같이 GlobalErrorAttributes 클래스에 when 절에 아래와 같이 추가해준다.
@Component
class GlobalErrorAttributes : DefaultErrorAttributes() {
private val logger = LoggerFactory.getLogger(GlobalErrorAttributes::class.java)
override fun getErrorAttributes(request: ServerRequest, options: ErrorAttributeOptions) : Map<String, *> {
var map = super.getErrorAttributes(request, options);
val throwable: Throwable = getError(request)
when(throwable) {
is CustomException -> {
val ex = getError(request) as CustomException
map = generateExceptionMessage(map, ex, ex.resultCode)
}
is ServerWebInputException -> {
val ex = getError(request) as ServerWebInputException
val errors: MutableList<FieldErrorDetail> = ArrayList<FieldErrorDetail>()
if(ex is WebExchangeBindException) {
ex.bindingResult.fieldErrors.map { fieldError ->
errors.add(
FieldErrorDetail(
field = fieldError.field,
message = fieldError.defaultMessage ?: "Invalid value"
))
}
}else if (ex.cause is DecodingException) {
val decodingException = ex.cause as DecodingException
val rootCause = decodingException.cause
if (rootCause is JsonMappingException) {
for (reference in rootCause.path) {
errors.add(
FieldErrorDetail(
field = reference.fieldName ?: "unknown",
message = "[${reference.fieldName}]가 누락되었습니다."
)
)
}
}
}
map = generateExceptionMessage(map, ex, ResultCode.INVALID_PARAMETER)
map.put("errors", errors)
}
else -> {
logger.error("Unhandled exception occurred", throwable)
map["exception"] = throwable.javaClass.simpleName
map["message"] = throwable.message ?: "Unexpected error occurred"
map["status"] = HttpStatus.INTERNAL_SERVER_ERROR.value()
}
}
return map;
}
private fun generateExceptionMessage(map: MutableMap<String, Any>, ex: Exception, resultCode: ResultCode) : Map<String, *> {
map.put("exception", ex.javaClass.simpleName)
map.put("error", resultCode.code)
map.put("message", resultCode.message)
map.put("status", HttpStatus.BAD_REQUEST.value())
return map
}
}
아까와는 다르게 좀더 길어졌는데, 이거 하나로 개발의 질이 올라가서, 컬럼 누락으로 인한 연락을 피할 수 있다!!
이거는 내가 breakPoint를 까보면서 넣은거라 하나하나가 소중한 코드다...
결과를 보게되면!
@PostMapping("")
fun createProduct(@Valid @RequestBody productsCreateReq: Mono<ProductsCreateReq>) : Mono<BaseResponse<ProductsCreateRes>> {
return productsCreateReq
.flatMap { product ->
productsService.createProduct(product.toEntity())
}
.map { res -> BaseResponse(data = res) }
}
일단 나는 Router 방식이 아닌 RestController 방식으로 구현했기 때문에 Valid 체크를 간편하게 진행했다.
여기서 ProductsCreateReq 클래스를 보면,
class ProductsCreateReq (
@field:NotBlank(message = "Category는 필수 값입니다.")
var category: String,
@field:NotBlank(message = "Type은 필수 값입니다.")
var type: String
) {
fun toEntity() : Products = Products(category = category)
}
이렇게 처리 했는데, 값이 비어있으면 저런 메시지를 보낸다. 이거를 아까 GlobalErrorAttributes 에서 추가한거다.
어떤가! 아까보다 완성도 있고 프론트에서 백엔드 API를 붙일때도 더욱 수월하게 진행이 가능할거다!
근데 문제는 1개씩밖에 표기가 안돼서 둘다 null일경우 한개씩 처리를 해야한다.
이 외에도 내가 기존에 짜놓은게 Kotlin (Spring Boot) 기준으로 아래를 핸들링 해놨는데 이거를 다 WebFlux에서도 동작 가능하게 하도록 구현할 예정이다.
@ExceptionHandler(MethodArgumentNotValidException::class)
fun methodValidException (
ex : MethodArgumentNotValidException
) : ResponseEntity<ExceptionMsg> {
val errors: MutableList<FieldErrorDetail> = ArrayList<FieldErrorDetail>()
ex.bindingResult.allErrors.forEach(Consumer { error : ObjectError ->
errors.add(
FieldErrorDetail(
field = (error as FieldError).field,
message = error.defaultMessage ?: "Invalid Params"
)
)
})
return ResponseEntity(
ExceptionMsg(
code = ResultCode.INVALID_PARAMETER.code,
message = ResultCode.INVALID_PARAMETER.message,
success = false,
errors = errors
),
HttpStatus.OK
)
}
@ExceptionHandler(HttpMessageNotReadableException::class)
fun handleHttpMessageNotReadableException(
ex: HttpMessageNotReadableException,
request: WebRequest
): ResponseEntity<ExceptionMsg> {
val rootCause = ex.cause
val errors : MutableList<FieldErrorDetail> = ArrayList<FieldErrorDetail>()
if (rootCause is InvalidFormatException) {
for (reference in rootCause.path) {
errors.add(
FieldErrorDetail(
field = reference.fieldName,
message = "[" + reference.fieldName + "] 가 타입이 알맞지 않습니다."
)
)
}
} else if (rootCause is JsonMappingException) {
for (reference in rootCause.path) {
errors.add(
FieldErrorDetail(
field = reference.fieldName,
message = "[" + reference.fieldName + "] 가 누락되었습니다."
)
)
}
} else {
errors.add(
FieldErrorDetail(
field = "",
message = rootCause?.message?:"에러 발생"
))
}
return ResponseEntity(
ExceptionMsg(
code = ResultCode.INVALID_PARAMETER.code,
message = ResultCode.INVALID_PARAMETER.message,
success = false,
errors = errors
),
HttpStatus.OK
)
}
@ExceptionHandler(MissingServletRequestParameterException::class)
fun handleMissingServletRequestParameterException(
ex: MissingServletRequestParameterException,
request: WebRequest
): ResponseEntity<ExceptionMsg> {
return ResponseEntity(
ExceptionMsg(
code = ResultCode.INVALID_PARAMETER.code,
message = ResultCode.INVALID_PARAMETER.message,
success = false,
errors = listOf(FieldErrorDetail(
field = ex.parameterName,
message = ex.message
))
),
HttpStatus.OK
)
}
@ExceptionHandler(MethodArgumentTypeMismatchException::class)
@ResponseStatus(HttpStatus.OK)
fun handleTypeMismatch(ex: MethodArgumentTypeMismatchException): ResponseEntity<ExceptionMsg> {
return if (ex.requiredType?.isEnum == true) {
val enumValues = ex.requiredType!!.enumConstants?.joinToString(", ")
ResponseEntity(
ExceptionMsg(
code = ResultCode.INVALID_PARAMETER.code,
message = ResultCode.INVALID_PARAMETER.message,
success = false,
errors = listOf(FieldErrorDetail(
field = ex.name,
message = enumValues.toString()
))
),
HttpStatus.OK
)
} else {
ResponseEntity(
ExceptionMsg(
code = ResultCode.INVALID_PARAMETER.code,
message = ResultCode.INVALID_PARAMETER.message,
success = false,
errors = listOf(FieldErrorDetail(
field = ex.name,
message = "Invalid value for parameter '${ex.name}'"
))
),
HttpStatus.OK
)
}
}
참고 자료 - https://riverblue.tistory.com/69