최근 새 프로젝트에서 에러 처리 방식을 통합하는 일을 맡았다.
그동안은 예외가 발생하면 서비스마다 직접 만든 ErrorResponse DTO를 만들어서 반환하는 방식이었다.
문제는 이 구조가 팀마다 제각각이라 API 문서 표준화가 힘들고, 공통 에러 코드나 필드 체계가 맞지 않는 경우가 잦았다는 점이다.
“혹시 표준화된 에러 응답 방식이 있지 않을까?” 하고 찾아보다가 Spring Framework 6에서 새롭게 제공하는 ProblemDetail 클래스를 알게 됐다.
이 클래스는 HTTP API 에러 응답을 표준화하는 국제 규격인 RFC 7807을 그대로 구현한 것이다.
이번 글에서는 기존 커스텀 DTO 방식과의 차이를 살펴보고, ProblemDetail을 활용해 에러를 처리하는 방법을 정리해본다.
기존에는 예외 발생 시 직접 만든 DTO를 반환하는 패턴
// error 처리용 DTO 정의
public record ErrorResponse(int status, String message, String code) {}
// 예외를 잡아서 ErrorResponse로 변환
@ExceptionHandler(CustomException.class)
public ResponseEntity<ErrorResponse> handle(CustomException ex) {
return ResponseEntity.status(HttpStatus.BAD_REQUEST)
.body(new ErrorResponse(400, ex.getMessage(), "BAD_REQUEST"));
}
이 방식은 단순하지만 단점이 많다.
ProblemDetail은 RFC 7807 – Problem Details for HTTP APIs를 구현한 클래스다.
이 RFC는 REST API에서 에러가 발생했을 때 일관된 JSON 응답 구조를 제공하기 위한 규격이다.
Spring 6 이상에서 기본으로 제공하며, 응답은 application/problem+json 형태로 내려간다.
기본 필드
| 필드 | 설명 |
|---|---|
type | 오류 유형을 식별하는 URI |
title | 오류 제목 |
status | HTTP 상태 코드 |
detail | 오류에 대한 상세 설명 |
instance | 오류가 발생한 리소스 경로 |
@ControllerAdvice
@Slf4j
public class ExceptionControllerAdvice extends ResponseEntityExceptionHandler {
// 도메인 예외(CustomException) 처리
@ExceptionHandler(CustomException.class)
public ProblemDetail handleCustomException(CustomException e) {
return createProblemDetail(e.getErrors(), e, e.getErrors().getMessage());
}
// ProblemDetail 생성 메서드
private ProblemDetail createProblemDetail(Errors error, Exception e, String message) {
// 상태 코드와 detail 설정
ProblemDetail detail = ProblemDetail.forStatusAndDetail(error.getHttpStatus(), e.getMessage());
// 표준 필드 외에 커스텀 정보 추가
detail.setProperty("errorCode", error.getErrorCode());
detail.setProperty("errorMessage", message);
detail.setProperty("timestamp", LocalDateTime.now());
detail.setProperty("exception", e.getClass().getSimpleName());
log.error("Exception caught: {}", e.toString());
return detail;
}
}
@ControllerAdvice
ResponseEntityExceptionHandler
@ExceptionHandler(CustomException.class)
ProblemDetail.forStatusAndDetail(...)
setProperty(...)
{
"type": "about:blank",
"title": "Bad Request",
"status": 400,
"instance": "/api/mini-room-component",
"errorCode": "E004",
"errorMessage": "이미 배치된 아이템이에요.",
"timestamp": "2025-08-08 15:56:25",
"exception": "DuplicateComponentException"
}
| 구분 | 기존 방식 (ErrorResponse DTO) | ProblemDetail |
|---|---|---|
| 표준 여부 | 팀·서비스마다 구조 제각각, 표준 없음 | RFC 7807 기반 국제 표준 |
| 필드 구성 | status, message, code 등 개발자가 임의 정의 | type, title, status, detail, instance + 커스텀 속성 |
| 호환성 | 내부 서비스 중심 설계, 외부 연동 시 변환 필요 | 표준 규격 준수, 외부 API/클라이언트와 호환 용이 |
| 확장성 | 필드 변경 시 클라이언트 파싱 로직 수정 필요 | setProperty()로 확장 가능, 표준 필드는 유지 |
| 문서화 | Swagger 등에서 별도 스키마 작성 필요 | 표준 필드 기반으로 문서화 용이 |
| 유지보수성 | 서비스별 DTO 관리 필요, 중복 코드 발생 | Spring 기본 지원, DTO 생성 불필요 |
| 도입 난이도 | 간단하지만 규칙 합의 필요 | Spring 6 이상 필요 |
ProblemDetail은 기존처럼 매번 에러 DTO를 만드는 수고를 줄여주고, 국제 표준을 기반으로 API 에러 응답을 통일할 수 있게 해준다.
이 방식으로 전환하면 API 문서화가 쉬워지고, 다른 시스템과의 연동에서도 호환성이 높아진다.
새로운 프로젝트라면 처음부터 ProblemDetail을 사용하는 걸 추천한다.
기존 프로젝트라면 전환 시점에 클라이언트 파싱 로직도 함께 맞춰주는 것이 좋다.