개발 팁

SpringBoot 3 (Spring 6.x) Error Response Formatting

북치던노인 2024. 4. 2. 20:02

자체 요구 사항

  • response의 http status를 원하는 코드로 응답할 수 있을 것
  • 커스텀 프로퍼티를 추가할 수 있을 것
  • Spring 기본 에러 응답과 동일한 형태의 response json 구조를 유지 할 것
  • 별도 라이브러리 안쓸것

공통 - 커스텀 프로퍼티 없는 경우

ResponseStatusException 사용

throw ResponseStatusException(HttpStatus.LOCKED, "잠겨있는 리소스 입니다.")

방법 1 - spring.mvc.problemdetails.enabled: true

application.yml 에 아래 설정을 추가한 뒤,

spring.mvc.problemdetails.enabled: true

아래와 같은 형태로 커스텀 익셉션을 구성해서 throw.

import org.springframework.http.HttpStatus
import org.springframework.web.ErrorResponseException

class UserAlreadyExistException : ErrorResponseException(
    HttpStatus.CONFLICT
) {
    init {
        this.body.setProperty("a", "2")
    }
}

이 경우 에러 response는 아래와 같은 형태가 된다.

{
  "type": "about:blank",
  "title": "Conflict",
  "status": 409,
  "instance": "/users/123123",
  "a": "2"
}

각 필드는 익셉션 안에서 ErrorResponseException을 override 하거나, init 블록에서 수정하는 식으로 변경 가능

단점 : stacktrace나 exception 종류를 확인 할 수 없음

방법 2 - spring.mvc.problemdetails.enabled: false (기본값)

response 필드가 조금 다름

{
  "timestamp": "2024-04-02T10:21:46.812+00:00",
  "status": 409,
  "error": "Conflict",
  "path": "/users/123123"
}

노출시킬 필드를 프로퍼티 파일로 설정 가능

server.error:
  include-message: always
  include-binding-errors: always
  include-stacktrace: always
  include-exception: true

모두 포함시키면 아래와 같이 출력됨

{
  "timestamp": "2024-04-02T10:13:12.616+00:00",
  "status": 409,
  "error": "Conflict",
  "exception": "***.service.user.UserAlreadyExistException",
  "trace": "***.service.user.UserAlreadyExistException: 409 CONFLICT, ProblemDetail[type='about:blank', title='Conflict', status=409, detail='null', instance='null', properties='{a=2}']\n\tat ***.service.user.UserService.addUser(UserService.kt:16)\n\tat ***.controller.UserController.addUser(UserController.kt:23)\n(생략)",
  "message": "409 CONFLICT, ProblemDetail[type='about:blank', title='Conflict', status=409, detail='null', instance='null', properties='{a=2}']",
  "path": "/users/121231"
}

단점 1) 커스텀 프로퍼티가 출력되지 않는다.

CustomErrorAttributes 컴포넌트를 구현해줘야 한다.

import org.springframework.boot.web.error.ErrorAttributeOptions
import org.springframework.boot.web.servlet.error.DefaultErrorAttributes
import org.springframework.stereotype.Component
import org.springframework.web.ErrorResponseException
import org.springframework.web.context.request.WebRequest

@Component
class CustomErrorAttributes: DefaultErrorAttributes() {
    override fun getErrorAttributes(webRequest: WebRequest?, options: ErrorAttributeOptions?): MutableMap<String, Any> {
        val attributes = super.getErrorAttributes(webRequest, options)
        val error = getError(webRequest)
        if (error is ErrorResponseException && error.body.properties != null) {
            attributes.putAll(error.body.properties!!)
        }
        return attributes
    }
}

단점 2) GET 메서드인 경우 브라우저에서 접근시 /error 매핑으로 이동하므로 아래와 같은 에러 화면이 보인다.

Whitelabel Error Page
This application has no explicit mapping for /error, so you are seeing this as a fallback.

Tue Apr 02 18:57:04 KST 2024
There was an unexpected error (type=Locked, status=423).

아래와 같이 비활성화 시킬 수 있지만, 그러면 톰캣 정보가 출력 됨 (ResponseStatusException을 반환한 경우)

server.error.whitelabel.enabled: false

싫으면 ErrorController를 구현해야 함 (https://sh970901.tistory.com/132 참고)

방법 3 - @ExceptionHander 사용

Exception에 따로 어노테이션을 달거나 상속받거나 하지 않고 처리하는 방법

Controller에 아래 내용 추가

    @ExceptionHandler(UserAlreadyExistException::class)
    fun handle(e: UserAlreadyExistException): ProblemDetail {
        val problemDetail = ProblemDetail.forStatus(HttpStatus.CONFLICT)
        problemDetail.setProperty("a", "1")
        return problemDetail
    }

response 형태는 spring.mvc.problemdetails.enabled = true 일 때와 동일함

{
  "type": "about:blank",
  "title": "Conflict",
  "status": 409,
  "instance": "/users/new",
  "a": "1"
}

Controller 구분 없이 전역으로 적용 하고 싶을 땐

@RestControllerAdvice(annotations = RestController.class)
public class GlobalRestControllerAdvice {

    @ResponseStatus(HttpStatus.NOT_FOUND)
    @ExceptionHandler(UserAlreadyExistsException::class)
    fun handle(e: UserAlreadyExistsException): ProblemDetail {

단점 : 1번 방법일때랑 동일..

결론

1. 제일 간단한 방법

throw ResponseStatusException(HttpStatus.LOCKED, "잠겨있는 리소스 입니다.")

2. throw 된 exception을 다른 로직에서 catch 해서 사용하기도 하는 경우

@ResponseStatus(code = HttpStatus.NOT_FOUND, reason = "Data Not Found")
public class DataNotFoundException extends RuntimeException { }

3. custom property가 필요한 경우 (stack trace도 보고싶음)

import org.springframework.http.HttpStatus
import org.springframework.web.ErrorResponseException

class UserAlreadyExistException : ErrorResponseException(
    HttpStatus.CONFLICT
) {
    init {
        this.body.setProperty("a", "2")
    }
}
import org.springframework.boot.web.error.ErrorAttributeOptions
import org.springframework.boot.web.servlet.error.DefaultErrorAttributes
import org.springframework.stereotype.Component
import org.springframework.web.ErrorResponseException
import org.springframework.web.context.request.WebRequest

@Component
class CustomErrorAttributes: DefaultErrorAttributes() {
    override fun getErrorAttributes(webRequest: WebRequest?, options: ErrorAttributeOptions?): MutableMap<String, Any> {
        val attributes = super.getErrorAttributes(webRequest, options)
        val error = getError(webRequest)
        if (error is ErrorResponseException && error.body.properties != null) {
            attributes.putAll(error.body.properties!!)
        }
        return attributes
    }
}

4. custom property가 필요한 경우 (단 특정 Controller에서만 사용, stack trace 안봄)

class DataNotFoundException(val type: String): RuntimeException() {
}

@ExceptionHandler(DataNotFoundException::class)
fun handle(e: DataNotFoundException): ProblemDetail {
    val problemDetail = ProblemDetail.forStatus(HttpStatus.CONFLICT)
    problemDetail.setProperty("type", e.type)
    return problemDetail
}

5. custom property가 필요한 경우(모든 Controller에서 사용, stack trace 안봄)

spring.mvc.problemdetails.enabled: true
import org.springframework.http.HttpStatus
import org.springframework.web.ErrorResponseException

class UserAlreadyExistException : ErrorResponseException(
    HttpStatus.CONFLICT
) {
    init {
        this.body.setProperty("a", "2")
    }
}

또는

@RestControllerAdvice(annotations = RestController.class)
public class GlobalRestControllerAdvice {

    @ResponseStatus(HttpStatus.NOT_FOUND)
    @ExceptionHandler(UserAlreadyExistsException::class)
    fun handle(e: UserAlreadyExistsException): ProblemDetail {