SW 개발/Spring

Spring Boot, REST API 예외 응답 구조 설계하기

지단로보트 2022. 7. 4. 11:25

개요

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

Spring Boot의 기본 에러 응답

  • Spring Boot의 요청 처리 과정에서 어떠한 설정도 하지 않은채 예외가 발생하면 아래 형식으로 오류를 응답한다.
# 400 BAD_REQUEST
{
 "timestamp": 1500597044204,
 "status": 400,
 "exception": "com.jsonobject.example.InvalidHttpMessageBodyException",
 "message": "HTTP 요청 바디의 형식이 잘못되었습니다."
 "error": "Bad Request",
 "path": "/v1/foo",
}
  • 위 형식은 발생한 예외 클래스가 그대로 노출되는데 이번 글을 통해 아래와 같이 변경하는 방법을 설명할 것이다.
# 400 BAD_REQUEST
{
    "errorCode": "INVALID_HTTP_MESSAGE_BODY",
    "errorMessage": "HTTP 요청 바디의 형식이 잘못되었습니다."
}

build.gradle

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

Error 응답 클래스 설계

  • 실제 REST API를 소비할 클라이언트에게 예외를 출력할 응답 클래스를 작성할 차례이다. 예외 발생시 응답 필드는 errorCode, errorMessage 2개로 한정한다. errorCode는 아래 설명할 사전 정의된 오류 코드를 의미하며, errorMessage는 사람이 인지할 수 있는 해당 오류 코드에 대한 설명을 의미한다. (상황에 따라 필요에 의해 errorDetail 필드를 추가로 응답하도록 설계했는데, 이 필드에는 어떤 타입도 자유롭게 저장할 수 있다.)
import com.fasterxml.jackson.annotation.JsonInclude
import com.fasterxml.jackson.annotation.JsonProperty

data class FooErrorResponse(

    @JsonProperty
    val errorCode: String,

    @JsonProperty
    val errorMessage: String,

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

ErrorCode 응답 오류 코드 정의

  • 애플리케이션에서 발생하는 다양한 예외를 맵핑하여 클라이언트가 노출할 오류 코드를 정의하는 단계이다.
enum class FooErrorCode(val message: String) {

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

Exception 커스텀 예외 클래스 설계

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

data class FooException(

    var errorCode: FooErrorCode = FooErrorCode.INTERNAL_SERVER_ERROR,
    var errorMessage: String = "",
    var errorDetail: Any? = null,
    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
    }

    constructor(errorCode: FooErrorCode, errorData: Any?) : this() {
        this.errorCode = errorCode
        this.errorMessage = errorCode.message
        this.errorDetail = errorData
    }

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

@ControllerAdvice

  • 실제 오류 응답을 수행할 @ControllerAdvice를 작성할 차례이다. 앞서 공들여 설계한 응답 구조에 따라 특정 예외 발생시 자동으로 클라이언트에게 사전 정의된 오류 코드를 응답하도록 작성했다.
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(value = [HttpMessageNotWritableException::class, 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
        )
    }
}

예외 발생

  • 아래는 HTTP 요청을 처리하는 과정에서 설계된 코드를 이용하여 의도적인 예외를 발생시키는 예이다.
// 예외 발생
throw FooException(FooErrorCode.INVALID_HTTP_MESSAGE_BODY)

// HTTP 응답 코드를 포함해서 예외 발생
throw FooException(FooErrorCode.INVALID_HTTP_MESSAGE_BODY, HttpStatus.BAD_REQUEST)
  • 예외를 발생시키는 순간 아래와 같이 클라이언트 오류를 응답한다.
# 400 BAD_REQUEST
{
    "errorCode": "INVALID_HTTP_MESSAGE_BODY",
    "errorMessage": "HTTP 요청 바디의 형식이 잘못되었습니다."
}

다음 단계로 읽어볼만한 글