스프링 프레임워크 6.x를 살펴보면서 ProblemDetail 클래스가 추가되어 있는 것을 발견했습니다. 이 클래스는 RFC 7807 스펙에 대응하는 클래스입니다.
API 스펙은 회사나 프로젝트마다 다르기 때문에 항상 고민이 되는 부분입니다. 정답이 없다고 생각하는 편이지만, 표준을 알게 되면 트레이드오프를 결정하는 데 도움이 많이 되는 것 같습니다. Spring Boot 3.x에서는 바로 사용할 수 있지만, 이전 2.x 버전(Spring framework 5.x)에서는 해당 클래스가 존재하지 않기 때문에 적절히 참고하여 재구성해봤습니다.
/**
* @apiNote RFC 7807에 정의된 문제 세부 정보 객체입니다.
* <p>
* 문제 세부 정보는 HTTP 응답 본문에 포함되는 JSON 객체입니다.
* <p>
* @see <a href="https://tools.ietf.org/html/rfc7807">RFC 7807</a>
*/
public class ProblemDetails {
private static final URI BLANK_TYPE = URI.create("about:blank");
/**
* 문제 유형을 식별하는 URI 참조입니다.
* 이 URI가 참조되면 문제 유형에 대한 사람이 읽을 수 있는 문서를 제공해야 합니다.
* 값이 없는 경우 "about:blank"으로 가정됩니다.
*/
private URI type;
/**
* 문제 유형의 간단한 사람이 읽을 수 있는 요약입니다.
* 문제의 발생마다 이 값은 로컬화 목적을 제외하고는 변경되지 않아야 합니다.
*/
@Nullable
private String title;
/**
* 문제의 발생에 대해 원 서버에서 생성된 HTTP 상태 코드입니다.
*/
private int status;
/**
* 문제의 발생에 대한 사람이 읽을 수 있는 구체적인 설명입니다.
*/
@Nullable
private String detail;
/**
* 문제의 구체적인 발생을 식별하는 URI 참조입니다.
* 참조된 경우 추가 정보를 제공할 수도 있고 그렇지 않을 수도 있습니다.
*/
@Nullable
private URI instance;
/**
* 문제 유형 정의는 문제 세부 정보 객체를 추가 멤버로 확장할 수 있습니다.
*/
@Nullable
private Map<String, Object> properties;
protected ProblemDetails(int rawStatusCode) {
this.type = BLANK_TYPE;
this.status = rawStatusCode;
}
/**
* @param type 문제 유형을 식별하는 URI 참조입니다. 이 URI가 참조되면 문제 유형에 대한 사람이 읽을 수 있는 문서를 제공해야 합니다. 값이 없는 경우 "about:blank"으로 가정됩니다.
* @param title 문제 유형의 간단한 사람이 읽을 수 있는 요약입니다. 문제의 발생마다 이 값은 로컬화 목적을 제외하고는 변경되지 않아야 합니다.
* @param status 문제의 발생에 대해 원 서버에서 생성된 HTTP 상태 코드입니다.
* @param detail 문제의 발생에 대한 사람이 읽을 수 있는 구체적인 설명입니다.
* @param instance 문제의 구체적인 발생을 식별하는 URI 참조입니다. 참조된 경우 추가 정보를 제공할 수도 있고 그렇지 않을 수도 있습니다.
* @param properties 문제 유형 정의는 문제 세부 정보 객체를 추가 멤버로 확장할 수 있습니다.
*/
@Builder
public ProblemDetails(final URI type, @Nullable final String title, final int status, @Nullable final String detail, @Nullable final URI instance, @Nullable final Map<String, Object> properties) {
this.type = type;
this.title = title;
this.status = status;
this.detail = detail;
this.instance = instance;
this.properties = properties;
}
protected ProblemDetails(ProblemDetails other) {
this.type = BLANK_TYPE;
this.type = other.type;
this.title = other.title;
this.status = other.status;
this.detail = other.detail;
this.instance = other.instance;
this.properties = other.properties != null ? new LinkedHashMap<>(other.properties) : null;
}
protected ProblemDetails() {
this.type = BLANK_TYPE;
}
public static ProblemDetails forStatus(HttpStatus status) {
Assert.notNull(status, "HttpStatusCode is required");
return forStatus(status.value());
}
public static ProblemDetails forStatus(int status) {
return new ProblemDetails(status);
}
public static ProblemDetails forStatusAndDetail(HttpStatus status, String detail) {
Assert.notNull(status, "HttpStatusCode is required");
ProblemDetails problemDetails = forStatus(status.value());
problemDetails.setDetail(detail);
return problemDetails;
}
public static ProblemDetails forStatusAndDetailAndInstance(HttpStatus status, String detail, URI instance) {
ProblemDetails problemDetails = forStatusAndDetail(status, detail);
problemDetails.setInstance(instance);
return problemDetails;
}
public static ProblemDetails forStatusAndDetailAndInstance(HttpStatus status, String detail, String instance) {
if (instance == null) return forStatusAndDetail(status, detail);
return forStatusAndDetailAndInstance(status, detail, URI.create(instance));
}
public URI getType() {
return this.type;
}
public void setType(URI type) {
Assert.notNull(type, "'type' is required");
this.type = type;
}
@Nullable
public String getTitle() {
if (this.title == null) {
HttpStatus httpStatus = HttpStatus.resolve(this.status);
if (httpStatus != null) {
return httpStatus.getReasonPhrase();
}
}
return this.title;
}
public void setTitle(@Nullable String title) {
this.title = title;
}
public int getStatus() {
return this.status;
}
public void setStatus(HttpStatus httpStatus) {
this.status = httpStatus.value();
}
public void setStatus(int status) {
this.status = status;
}
@Nullable
public String getDetail() {
return this.detail;
}
public void setDetail(@Nullable String detail) {
this.detail = detail;
}
@Nullable
public URI getInstance() {
return this.instance;
}
public void setInstance(@Nullable URI instance) {
this.instance = instance;
}
public void setProperty(String name, @Nullable Object value) {
this.properties = this.properties != null ? this.properties : new LinkedHashMap<>();
this.properties.put(name, value);
}
@Nullable
public Map<String, Object> getProperties() {
return this.properties;
}
}
우리가 흔히 보는 스프링 전역 에러 응답
{
"timestamp": "2024-01-13T05:03:16.807+00:00",
"status": 404,
"error": "Not Found",
"message": "No message available",
"path": "/v1/not-found"
}
스프링 프레임워크가 정의한 에러 응답도 아래와 같이 지정한 응답(RFC 7807 표준 스펙)으로 전부 바꿔주고 싶습니다.
{
"type": "about:blank",
"title": "Not Found",
"status": 404,
"detail": "No handler found for POST /v1/not-found",
"instance": "/v1/test2",
"properties": null
}
HandlerExceptionResolver가 적절하게 예외에 대한 처리를 담당하는데, 만져볼 것은
ResponseEntityExceptionHandler 입니다.
해당 클래스는 abstract class
이며, 내부 코드를 살펴보면
handleException에 들어온 Exceltion 객체의 인스턴스를 판단하여 세터 메서드를 콜하고 세터 메서드들은 바디를 빌딩하고 handleExceptionInternal를 통해 응답 객체를 생성해내고 있습니다. 해당 메서드를 오버라이딩 하여 ProblemDetails 객체로 바꿔봤습니다.
@RestControllerAdvice
public class ProblemDetailsResponseEntityExceptionHandler extends ResponseEntityExceptionHandler {
@NotNull
@Override
protected ResponseEntity<Object> handleExceptionInternal(
@NotNull final Exception ex,
@Nullable final Object body,
@NotNull final HttpHeaders headers,
@NotNull final HttpStatus status,
@NotNull final WebRequest request
) {
String requestUri = null;
if (request instanceof ServletWebRequest) {
ServletWebRequest servletWebRequest = (ServletWebRequest) request;
requestUri = servletWebRequest.getRequest().getRequestURI();
}
ProblemDetails problemDetails = ProblemDetails.forStatusAndDetailAndInstance(status, ex.getMessage(), requestUri);
return new ResponseEntity<>(problemDetails, headers, status);
}
}
NoHandlerFoundException
도 처리하기 위해서 따로 정의 해주어야할 것들이 있는데, 이 부분은 자료가 많이 나오니 생략하겠습니다.
요청
curl --location 'localhost:8080/v1/test' \
--header 'Authorization: Bearer ?????' \
응답
{
"type": "about:blank",
"title": "Unauthorized",
"status": 401,
"detail": "Authentication failed. Please check your credentials.",
"instance": "/v1/test",
"properties": null
}
참고
spring boot 3.x
은 자동으로 설정 할 수 있습니다.
spring.mvc.problemdetails.enabled=true
멋있네요