티스토리 뷰

개요

  • Spring Boot 기반의 REST API를 설계할 경우, 예외 상황에 대한 응답 구조 설계에 있어 신중한 접근이 요구된다. 프로젝트 초기에 충분한 고민 없이 무작정 오류 응답을 처리하게 되면, 참여 개발자가 늘어날수록 자기 입맛에 따라 그 때 그 때 다른 구조로 응답하여 예외를 통제하기가 매우 힘들어진다.(나는 이런 사례를 Exception Hell이라고 부른다.) 이번 글에서는 철저히 통제된 예외 응답 구조를 설계하는 방법을 설명하고자 한다.

build.gradle

  • 프로젝트 루트의 /build.gradle 파일에 아래 내용을 추가한다.
dependencies {
    compile group: 'org.apache.commons', name: 'commons-lang3', version: '3.9'
}
  • commons-lang3 아티팩트는 예외 발생시 최초 발생한 조상 예외를 식별하는데 유용한 유틸리티를 제공하므로 사용을 추천한다. 의외로 이름만 대도 알만한 회사들의 프로젝트 현장을 가보면 조상 예외를 식별하지 않아, 예외 발생시 원인 파악에 애로를 겪는 경우를 무수히 많이 보아 왔다.

Error 응답 클래스 설계

  • 실제 REST API를 소비할 클라이언트에게 예외를 출력할 응답 클래스를 작성할 차례이다. 예외 발생시 응답 필드는 errorCode, errorMessage 2개로 한정한다. errorCode는 아래 설명할 사전 정의된 오류 코드를 의미하며, errorMessage는 사람이 인지할 수 있는 해당 오류 코드에 대한 설명을 의미한다.
package com.jsonobject.example

import com.fasterxml.jackson.annotation.JsonProperty

data class FooErrorResponse(

        @JsonProperty
        val errorCode: String,

        @JsonProperty
        val errorMessage: String
)
  • 예외 출력의 관건은 절대로 애플리케이션에서 발생하는 날 것의 예외 정보를 클라이언트에게 그대로 보여주지 않는 것이다. 의도된 예외는 철저하게 사전 정의된 오류 코드에 맵핑하여 응답하도록 하며, 의도하지 않은 예외 역시 사전 정의된 시스템 오류 코드를 응답해야 한다. 모든 경우에 있어 철저히 로그를 남겨 클라이언트보다 먼저 예외 발생을 인지하는 것은 필수이다.

ErrorCode 응답 오류 코드 정의

  • 애플리케이션에서 발생하는 다양한 예외를 맵핑하여 클라이언트가 노출할 오류 코드를 정의하는 단계이다.
package com.jsonobject.example

enum class FooErrorCode(val message: String) {

    INTERNAL_SERVER_ERROR("알 수 없는 오류가 발생했습니다. 관계자에게 문의하세요."),
    UNSUPPORTED_HTTP_METHOD("지원하지 않는 HTTP 메써드입니다."),
    INVALID_HTTP_MESSAGE_BODY("HTTP 요청 바디의 형식이 잘못되었습니다.")
}
  • 위 3개는 가장 일반적은 공통 오류 코드를 정의한 것이다. 애플리케이션의 비지니스 로직에 따라 발생하는 다양한 오류 코드를 추가로 정의하면 된다.

Exception 커스텀 예외 클래스 설계

  • 사전에 개발자가 의도한 예외를 설계할 차례이다. 오류 코드, 오류 메시지, HTTP 응답 상태 코드 3개를 담도록 설계했다. 개발자는 비지니스 로직에서 적절한 오류 코드를 담아 이 예외를 발생시키면, 아래 설명할 @ControllerAdvice가 적절한 오류 응답을 하게 된다.
package com.jsonobject.example

import com.jsonobject.example.FooErrorCode
import org.springframework.http.HttpStatus

data class FooException(

        var errorCode: FooErrorCode = FooErrorCode.INTERNAL_SERVER_ERROR,
        var errorMessage: String = "",
        var httpStatus: HttpStatus = HttpStatus.BAD_REQUEST

) : RuntimeException() {

    constructor(errorCode: FooErrorCode) : this() {
        this.errorCode = errorCode
        this.errorMessage = errorCode.message
    }

    constructor(errorCode: FooErrorCode, httpStatus: HttpStatus) : this() {
        this.errorCode = errorCode
        this.errorMessage = errorCode.message
        this.httpStatus = httpStatus
    }
}

@ControllerAdvice

  • 실제 오류 응답을 수행할 @ControllerAdvice를 작성할 차례이다. 앞서 공들여 설계한 응답 구조에 따라 특정 예외 발생시 자동으로 클라이언트에게 사전 정의된 오류 코드를 응답하도록 작성했다.
package com.jsonobject.example

import com.jsonobject.example.FooErrorResponse
import com.jsonobject.example.FooErrorCode
import org.apache.commons.lang3.exception.ExceptionUtils
import org.slf4j.MDC
import org.springframework.core.Ordered
import org.springframework.core.annotation.Order
import org.springframework.http.HttpStatus
import org.springframework.http.ResponseEntity
import org.springframework.http.converter.HttpMessageNotReadableException
import org.springframework.validation.BindException
import org.springframework.web.HttpRequestMethodNotSupportedException
import org.springframework.web.bind.MethodArgumentNotValidException
import org.springframework.web.bind.annotation.ControllerAdvice
import org.springframework.web.bind.annotation.ExceptionHandler
import org.springframework.web.bind.annotation.ResponseBody
import javax.servlet.http.HttpServletRequest

@ControllerAdvice
@Order(Ordered.HIGHEST_PRECEDENCE)
class FooControllerAdvice {

    @ExceptionHandler(FooException::class)
    @ResponseBody
    fun handleFooException(

            request: HttpServletRequest,
            ex: FooException

    ): ResponseEntity<*> {

        MDC.put("message", "API_REQUEST_REJECTED")
        MDC.put("error_code", ex.errorCode.name)
        MDC.put("error_message", ex.errorMessage)

        return ResponseEntity(
                FooErrorResponse(
                        ex.errorCode.name,
                        ex.errorMessage
                ),
                ex.httpStatus
        )
    }

    @ExceptionHandler(HttpRequestMethodNotSupportedException::class)
    @ResponseBody
    fun handleHttpRequestMethodNotSupportedException(

            request: HttpServletRequest,
            ex: HttpRequestMethodNotSupportedException

    ): ResponseEntity<*> {

        return this.handleFooException(
                request,
                FooException(
                        FooErrorCode.UNSUPPORTED_HTTP_METHOD,
                        FooErrorCode.UNSUPPORTED_HTTP_METHOD.message,
                        HttpStatus.BAD_REQUEST
                )
        )
    }

    @ExceptionHandler(value = [HttpMessageConversionException::class, HttpMessageNotReadableException::class])
    @ResponseBody
    fun handleInvalidHttpMessageBodyException(

            request: HttpServletRequest,
            ex: Exception

    ): ResponseEntity<*> {

        val rootException = ExceptionUtils.getRootCause(ex)
        if (rootException is FooException) {
            return handleFooException(request, rootException)
        }

        return this.handleFooException(
                request,
                FooException(
                        FooErrorCode.INVALID_HTTP_MESSAGE_BODY
                        FooErrorCode.INVALID_HTTP_MESSAGE_BODY.message
                        HttpStatus.BAD_REQUEST
                )
        )
    }

    @ExceptionHandler(value = [BindException::class, MethodArgumentNotValidException::class, ConstraintViolationException::class])
    @ResponseBody
    fun handleHttpRequestValidationException(

            request: HttpServletRequest,
            ex: Exception

    ): ResponseEntity<*> {

        val errorCode = when (ex) {
            is BindException -> ex.bindingResult.allErrors[0].defaultMessage
            is MethodArgumentNotValidException -> ex.bindingResult.allErrors[0].defaultMessage
            is ConstraintViolationException -> ex.constraintViolations.first().message
            else -> null
        }
        val error = FooErrorCode.valueOf(errorCode ?: FooErrorCode.INTERNAL_SERVER_ERROR.name)

        return this.handleFooException(
                request,
                FooException(
                        error,
                        error.message,
                        HttpStatus.BAD_REQUEST
                )
        )
    }

    @ExceptionHandler(Exception::class)
    @ResponseBody
    fun handleException(

            request: HttpServletRequest,
            ex: Exception

    ): ResponseEntity<*> {

        MDC.put("message", "API_REQUEST_FAILED")
        MDC.put("error_code", FooErrorCode.INTERNAL_SERVER_ERROR.name)
        MDC.put("error_exception", ex.javaClass.name)
        MDC.put("error_message", ex.message)
        ExceptionUtils.getRootCause(ex)?.let {
            MDC.put("error_root_exception", ExceptionUtils.getRootCause(ex).javaClass.name)
            MDC.put("error_root_message", ExceptionUtils.getRootCauseMessage(ex))
        }
        MDC.put("error_trace", ExceptionUtils.getStackTrace(ex))

        return ResponseEntity(
                FooErrorResponse
                (
                        FooErrorCode.INTERNAL_SERVER_ERROR.name,
                        FooErrorCode.INTERNAL_SERVER_ERROR.message
                ),
                HttpStatus.INTERNAL_SERVER_ERROR
        )
    }
}

다음 단계로 읽어볼만한 글

댓글
댓글쓰기 폼