Search
🐞

웹 애플리케이션 에러 처리 가이드 (RFC 9457, 스프링 부트)

개념

에러의 본질은 ‘해결해야 하는 문제’다.
문제 해결에는 두 가지가 필요하다.
1.
주체
2.
정보
즉, 문제를 해결할 사람이 충분한 정보를 접할 수 있도록 하면 된다.
해결 주체는 세 가지다.
1.
유저 (또는 클라이언트 개발자)
2.
프론트 엔드 개발자
3.
백엔드 개발자
각 해결 주체가 문제에 대한 정보를 얻는 수단은 아래와 같다.
1.
유저: UI (모달, 토스트, inline 등) (클라이언트 개발자의 경우 로그)
2.
프론트엔드 개발자
a.
개발 환경: 브라우저 콘솔 로그
b.
운영 환경: 서버로 전송된 로그 (Sentry 등)
3.
백엔드 개발자: 서버 로그
Sentry
운영 환경에서는 개발자가 유저의 브라우저 콘솔 에러를 확인할 수 없는데다 프론트엔드 코드는 압축/난독화되어 에러 발생 지점을 파악하기 어렵다. Sentry같은 에러 트래킹 솔루션은 문제가 발생한 지점의 원본 코드 위치에 더해 에러가 발생하기까지 UI 이벤트(Breadcrumbs) 정보를 로그 서버로 보내주는 역할을 한다.
따라서 유저가 조치할 수 있는 문제라면 해결 방법을 UI로 안내하고, 반대로 앱 버그라면 UI에 사과의 메시지를 띄우고 개발자에게 로그와 알림이 제공돼야 한다.
네트워크 등의 외부 요인을 포함한 에러 전달 수단을 정리하면 아래와 같다.
조치 주체
UI에 띄울 메시지
로깅
유저
조치 방법 (ex: 이메일 형식을 다시 확인해주세요)
Optional
프론트엔드 개발자
죄송합니다. 에러가 발생했습니다.
개발 환경: 브라우저 콘솔 로그 운영 환경: 서버로 전송된 로그 (Sentry 등)
백엔드 개발자
죄송합니다. 에러가 발생했습니다.
서버 로그
외부 (네트워크 문제 등)
잠시 후 다시 시도해주세요.
-

에러 응답 메시지 공통화하기

RFC 9457은 HTTP API에서 에러 응답 메시지 형식을 정한 표준이다. Problem Detail이라고 부른다. 줄여서 PD라고 하자.
응답 JSON의 각 멤버에 아래 지침에 따른 값을 넣는다.
멤버
표준 설명
예시
status
HTTP 원본 status code
HTTP status와 동일하게 설정
403
type
문제 type 별 machine-readable URI 식별자
앱 base url(https://xxx.gyuray.dev/errors) + 문제 유형 (케밥 케이스)
https://pay.gyuray.deverrors/not-enough-balance
title
human-readable 요약
문제 유형을 Abcd Efgh 형식으로 반복 기술 (실무적으로 큰 의미 X)
Not Enough Balance
detail
개별 문제 건에 대한 상세한 설명
UI에 표시할 유저 메시지
잔액이 부족합니다.
instance
개별 문제 건에 대한 URI 식별자
endpoint URL + errorId 파라미터
https://pay.gyuray.dev/api/payment/123?errorId=abc123
에러 추적 시 보안 원칙
중요한 원칙 중 하나는 서버의 에러 메시지와 스택 트레이스를 응답 메시지에 절대 싣지 않는 것이다. 백엔드 서버의 구조를 해커에게 노출해서 취약점을 드러낼 수 있기 때문이다.
파라미터 검증 시 발생하는 Validation Error는 Status code 422(Unprocessable Content)를 응답하고, 아래 예시와 같은 errors 확장 멤버를 응답에 추가한다.
"errors": [ { "detail": "must be a positive integer", "pointer": "#/age" }, { "detail": "must be 'green', 'red' or 'blue'", "pointer": "#/profile/color" } ]
Java
복사
pointer는 JSON Pointer 문법을 사용한다.

응답 코드별 프론트 처리 지침

프론트엔드에서 에러 응답에 대해 처리하는 방법이다.

400대 코드

기본적으로 백엔드에서 보내준 detail을 UI에 표시해주고, 401, 422 코드만 따로 분기해 처리해준다.
HTTP Status code
Phrase
조치 방법
400
Bad Request
detail을 UI에 표시
401
Unauthorized
로그인 페이지로 리다이렉트
402
Payment Required
detail을 UI에 표시
403
Forbidden
detail을 UI에 표시
404
Not found
detail을 UI에 표시
409
Conflict
detail을 UI에 표시
415
Unsupported Media Type
detail을 UI에 표시
422
Unprocessable Content
유저가 잘못된 파라미터 값을 고칠 수 있도록 errors 확장 멤버를 파싱하여 적절한 방법으로 표시
429
Too Many Requests
UI에 “잠시 후 다시 시도해주세요” 표시

500대 코드

백엔드 애플리케이션이 처리하는 서버 에러는 500으로 통일(Internal Server Error)하고, 501(Bad gateway), 503(Service Unavailable) 등 이외의 코드는 리버스 프록시 등으로 부터 온 응답이기 때문에 Problem Detail 응답이 아니다. 따라서 500 에러만 detail을 UI에 표시하고, 나머지는 프론트에서 적절한 문구를 띄워준다.

스프링 부트 에러 처리

ErrorResponseException

스프링 부트 3.2 버전(2023년 11월)부터 PD를 적용할 수 있다.
아래처럼 따로 옵션을 주면 스프링 예외가 PD로 나가지만 추천하지 않는다.
spring: mvc: # RFC 9457 Problem Details for HTTP APIs 지원 활성화 problem-details: enabled: true
YAML
복사
스프링 예외가 아닌 일반 예외는 Problem Detail이 적용되지 않기도 하고, 각 필드를 @ExceptionHandler에서 커스터마이징하려면 중간에 스프링이 예외를 낚아채지 않도록 해야 하기 때문이다.
위 옵션을 활성화하지 않더라도 ErrorResponseException 형식의 예외를 던지면 스프링은 PD 응답을 한다.
ErrorResponseException은 아래처럼 ProblemDetail 객체를 가지고 있는데,
public class ErrorResponseException extends NestedRuntimeException implements ErrorResponse { ... private final ProblemDetail body; ...
Java
복사
ProblemDetail 객체는 PD 표준 멤버(type, title, status, detail, instance)에 대응하는 멤버변수와 확장 멤버를 위한 properties 멤버변수를 가지고 있다.
public class ProblemDetail implements Serializable { private static final URI BLANK_TYPE = URI.create("about:blank"); private URI type = BLANK_TYPE; @Nullable private String title; private int status; @Nullable private String detail; @Nullable private URI instance; @Nullable private Map<String, Object> properties;
Java
복사
아래처럼 에러를 터뜨리면
import org.springframework.http.HttpStatus; import org.springframework.http.ProblemDetail; import org.springframework.web.ErrorResponseException; ProblemDetail pd = ProblemDetail.forStatusAndDetail( HttpStatus.FORBIDDEN, // status "물건 가격은 1,000원인데 계좌에 0원밖에 없습니다." // detail ); pd.setTitle("잔액이 부족합니다."); pd.setType(URI.create("https://pay.gyuray.dev/errors/not-enough-balance")); throw new ErrorResponseException(HttpStatus.FORBIDDEN, pd, null);
Java
복사
아래와 같은 응답이 나간다.
{ "type": "https://pay.gyuray.dev/errors/not-enough-balance", "title": "잔액이 부족합니다.", "status": 403, "detail": "물건 가격은 1,000원인데 계좌에 0원밖에 없습니다.", "instance": "/api/payment/123" }
JSON
복사
이제 모든 에러는 이렇게 ErrorResponseException을 만들어 던져주면 된다.

Global Exception Handler

나머지 Unhandled 에러들도 PD로 응답하기 위해 Global Exception Handler를 만들어준다.
@RestControllerAdvice @RequiredArgsConstructor @Slf4j public class GlobalExceptionHandler { /** * 별도로 핸들링되지 않는 에러를 default로 처리하는 전역 핸들러 */ @ExceptionHandler(Exception.class) public ProblemDetail handleAllExceptions( Exception ex, HttpServletRequest request ) { ProblemDetail pd = ProblemDetail.forStatusAndDetail( HttpStatus.INTERNAL_SERVER_ERROR, // status "서버측 에러가 발생했습니다." // detail ); pd.setTitle("Internal Server Error"); pd.setType(URI.create("https://pay.gyuray.dev/errors/internal-server-error")); return pd; } }
Java
복사
PD 응답을 위한 유틸리티 클래스를 만들 수도 있다.
public class ErrorUtils { /** * 필드 검증 오류 내역을 담는 레코드 * pointer: 오류가 발생한 JSON 필드의 JSON Pointer (예: "#/username") * detail: 해당 필드에 대한 오류 설명 (예: "Username must not be empty") */ public record FieldError(String pointer, String detail) { } /** * RFC 9457 규격에 맞는 ErrorResponseException을 생성 */ public ErrorResponseException createException( HttpStatus status, String title, String detail, ErrorType errorType, Map<String, Object> properties ) { ProblemDetail pd = ProblemDetail.forStatusAndDetail(status, detail); pd.setTitle(title); pd.setType(URI.create("https://xxx.gyuray.dev/errors/" + errorType.getTypePath())); if (properties != null) { properties.forEach(pd::setProperty); } return new ErrorResponseException(status, pd, null); } /** * 400 Bad Request */ public ErrorResponseException badRequest(String detail) { return createException(HttpStatus.BAD_REQUEST, HttpStatus.BAD_REQUEST.getReasonPhrase(), detail, ErrorType.BAD_REQUEST, null); } ... /** * 422 Unprocessable Content */ public ErrorResponseException unprocessableContent( String detail, List<FieldError> fieldErrors ) { Map<String, Object> properties = null; if (fieldErrors != null && !fieldErrors.isEmpty()) { properties = Map.of("errors", fieldErrors); } // RFC 4918 -> RFC 9110으로 넘어오면서 Unprocessable Entity에서 Unprocessable Content로 명칭이 변경됨 return createException(HttpStatus.UNPROCESSABLE_ENTITY, "Unprocessable Content", detail, ErrorType.UNPROCESSABLE_CONTENT, properties); } /** * 500 Internal Server Error */ public ErrorResponseException internalServerError() { return createException(HttpStatus.INTERNAL_SERVER_ERROR, HttpStatus.INTERNAL_SERVER_ERROR.getReasonPhrase(), "An unexpected error occurred on the server.", ErrorType.INTERNAL_SERVER_ERROR, null); } }
Java
복사
유틸리티를 활용해 Exception Handler를 간소화할 수 있다.
@ExceptionHandler(Exception.class) public ProblemDetail handleAllExceptions( Exception ex, HttpServletRequest request ) { // Exception 객체를 생성 후 응답용 데이터 추출 ErrorResponseException errorResponse = errorUtils.internalServerError(); ProblemDetail pd = errorResponse.getBody(); return pd; }
Java
복사

Error ID 부여하기

운영 중에 에러를 식별해야 하는 경우가 생길 수 있다. 프론트쪽에서 발생한 에러에 대응하는 백엔드쪽 에러 로그를 찾을 때 등에 유용하다.
이 가이드에서는 errorId를 아래처럼 PD의 instance 필드 URL의 파라미터로 넣는다.
{ ... "instance": "https://xxx.gyuray.dev/api/payment/123?errorId=abc" ... }
JSON
복사
ErrorUtils에 아래와 같이 PD instance를 설정하는 메서드를 만들고
public String setInstanceForProblemDetail( ProblemDetail pd, HttpServletRequest request ) { String errorId = UUID.randomUUID().toString().substring(0, 8); pd.setInstance(URI.create(problemDetailProperty.baseUrl() + request.getRequestURI() + "?errorId=" + errorId)); return errorId; }
Java
복사
전역 Exception Handler에서 아래처럼 instance를 설정해준다.
@ExceptionHandler(Exception.class) public ProblemDetail handleAllExceptions( Exception ex, HttpServletRequest request ) { // Exception 객체를 생성 후 응답용 데이터 추출 ErrorResponseException errorResponse = errorUtils.internalServerError(); ProblemDetail pd = errorResponse.getBody(); String errorId = errorUtils.setInstanceForProblemDetail(pd, request); return pd; }
Java
복사
그리고 컨트롤러/서비스 계층에서 직접 던졌던 ErrorResponseException에도 instance를 새로 설정해준다.
@RestControllerAdvice @Order(Ordered.HIGHEST_PRECEDENCE) @RequiredArgsConstructor @Slf4j public class GlobalExceptionHandler { private final ErrorUtils errorUtils; @ExceptionHandler(ErrorResponseException.class) public ProblemDetail handleErrorResponse( ErrorResponseException ex, HttpServletRequest request ) { ProblemDetail pd = ex.getBody(); String errorId = errorUtils.setInstanceForProblemDetail(pd, request); return pd; }
Java
복사
참고로 위처럼 클래스 레벨에 @Order(Ordered.HIGHEST_PRECEDENCE) 애노테이션을 반드시 설정해줘야 중간에 스프링이 ErrorResponseException을 낚아채가지 않고 커스텀할 수 있다.
표준에서는 instance URI를 절대 경로로 할 것을 권장하는데, 아래 옵션을 켜면 instance URI가 상대경로로 나가고, errorId같은 파라미터도 추가하기 힘들다.
spring: mvc: # RFC 9457 Problem Details for HTTP APIs 지원 활성화 problem-details: enabled: true
YAML
복사
따라서 이 옵션을 켤 이유가 없다.

에러 로그

로그 찍는 위치

에러 로그는 응답이 나가기 직전 가급적 한 곳에서 찍는 것이 좋다. 여기에서는 Global Exception Handler에서 에러를 로깅한다.
자세한 에러 정보와 스택 트레이스는 대부분 서버 에러의 경우에만 찍으면 된다.
ErrorUtils의 createException 메서드에 아래처럼 cause 파라미터를 추가해서 ErrorResponseException을 생성할 때 넣어준다.
public ErrorResponseException createException( HttpStatus status, String title, String detail, ErrorType errorType, Map<String, Object> properties, Throwable cause ) { ProblemDetail pd = ProblemDetail.forStatusAndDetail(status, detail); pd.setTitle(title); pd.setType(URI.create(problemDetailProperty.baseUrl() + errorType.getTypePath())); if (properties != null) { properties.forEach(pd::setProperty); } return new ErrorResponseException(status, pd, cause); } public ErrorResponseException createException( HttpStatus status, String title, String detail, ErrorType errorType, Map<String, Object> properties ) { return createException(status, title, detail, errorType, properties, null); }
Java
복사
그리고 아래처럼 Internal Server Error인 경우에는 cause를 인자로 받도록 하고, 추가적인 로그를 받을 경우 RuntimeException으로 감싼다.
/** * 500 Internal Server Error */ public ErrorResponseException internalServerError(Throwable cause) { return createException( HttpStatus.INTERNAL_SERVER_ERROR, HttpStatus.INTERNAL_SERVER_ERROR.getReasonPhrase(), "An unexpected error occurred on the server.", ErrorType.INTERNAL_SERVER_ERROR, null, cause ); } public ErrorResponseException internalServerError(Throwable cause, String log) { return internalServerError(new RuntimeException(log, cause)); }
Java
복사
컨트롤러/서비스 계층에서 아래처럼 사용한다.
try { Files.createDirectories(recordDirPath); } catch (IOException e) { throw errorUtils.internalServerError(e, String.format("Failed to create record directory for [%s] at path: %s", name, recordDirPath)); }
Java
복사
이렇게 전달된 메시지와 상세 로그는 Global Exception Handler 레벨에서 로깅을 해준다.
@ExceptionHandler(ErrorResponseException.class) public ProblemDetail handleErrorResponse( ErrorResponseException ex, HttpServletRequest request ) { ProblemDetail pd = ex.getBody(); String errorId = errorUtils.setInstanceForProblemDetail(pd, request); if (pd.getStatus() >= 500) { // 서버 에러인 경우 원본 예외(커스텀 RuntimeException)를 꺼내서 로깅 Throwable rootCause = ex.getCause() != null ? ex.getCause() : ex; log.error("errorId={} status={} path={}", errorId, pd.getStatus(), request.getRequestURI(), rootCause); } return pd; }
Java
복사