Post

일관성 있는 에러 응답을 사용하자

웹 서비스를 잘 개발하고 운영하기 위해서는 HTTP API의 성공 응답뿐만 아니라 에러 응답도 잘 설계할 필요가 있다. 에러 응답 설계는 개발자들마다 생각이 다르고 절대적인 표준이 없다. 당연하게도 모든 서비스들의 에러 응답은 모두 동일하게 설계되어 있지 않다. 내가 생각하는 에러 응답을 잘 설계한다는 것은 사용자의 입장에서 코드와 관련된 내용이 아닌 사용자가 이해할 수 있는 에러 메시지를 사용함으로써 사용자가 상황을 이해하고 스스로 문제를 해결하는 데 도움이 되어야 하고 개발자의 입장에서 예측 가능하고 사용하기 편하도록 일관성 있는 에러 응답을 사용하는 것이다.

이 글에서는 개발자의 입장에서 좋은 에러 응답 설계하기 위해 일관성 있는 에러 응답을 사용하는 것에 대해 다뤄 보려고 한다.

사용된 개발 환경

  • spring boot 3.2.4
  • spring web mvc 6.1.5
  • tomcat embed core 10.1.19

Body

일관성 있는 에러 응답은 왜 필요한 걸까? 백엔드와 프론트는 미리 약속된 데이터 구조로 요청과 응답을 주고받는다. 만약 약속한 구조가 아닌 다른 구조를 사용하게 된다면 서버는 요청을 정상적으로 받아 처리할 수 없고 클라이언트는 응답을 정상적으로 받아 사용할 수 없을 것이다. 에러 응답 또한 마찬가지로 미리 약속된 메시지와 데이터 구조가 지켜지지 않으면 에러 처리가 잘 동작하지 않거나 예상치 못한 에러 메시지가 노출될 수 있다.

unexpected-error-message-example.png

예상치 못한 에러 메시지가 노출되는 이유는 에러 메시지를 서버 또는 클라이언트 중 어디에서 관리하는지에 따라 다를 수 있다. 서버에서 에러 메시지를 관리하는 상황을 예로 들면 일부 또는 모든 에러 응답에서 서버는 사용자가 이해할 수 있는 에러 메시지를 응답하고 클라이언트에서는 응답받은 메시지를 사용하는 것으로 백엔드와 프론트가 협의했지만 서버에서 사용자에게 노출하면 안 되는 코드와 관련된 에러 메시지를 내보내는 경우가 있다. 그렇게 되면 클라이언트는 미리 고려하지 못했던 상황이기 때문에 사용자에게 노출하면 안 되는 에러 메시지를 다른 문구로 대체하지 못하고 그대로 사용자에게 노출하게 된다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
// 에러가 발생하면 협의된 에러 응답 데이터 구조로 응답한다.
{
  "error": {
    "code": "E001",
    "message": "서비스에 문제가 발생했어요",
    "detail": "일시적인 문제인 경우 다시 시도하면 해결될 수 있지만 지속적으로 문제가 발생하는 경우 고객센터로 문의해주세요",
    "help": "https://example.com/docs/error/E001"
  }
}

// 하지만 종종 협의되지 않은 에러 응답 데이터 구조로 응답한다.
{
  "timestamp": "2024-07-10T06:32:04.361+00:00",
  "status": 500,
  "error": "서비스에 문제가 발생했어요",
  "path": "/api/v1/example"
}

에러 메시지뿐만 아니라 데이터 구조의 일관성도 중요하다. 데이터 구조가 약속한 것과 다르면 에러 응답을 사용한 클라이언트의 에러 처리가 잘 동작하지 않을 수 있다. 에러 처리가 잘 동작하지 않으면 사용자는 에러가 발생한 상황을 인지하지 못하거나 문제 상황을 해결하지 못함으로써 불편함을 겪을 수 있다.

서버 측에서는 보통 특정 예외의 처리가 누락되거나 사용하면 안 되는 에러 메시지를 사용한 경우가 가장 많은 것 같다. 이를 방지하기 위해서는 비즈니스 로직에서 발생하는 예외를 구분해서 사용하고 예외 메시지를 사용할 수 있는 예외 클래스를 구분할 필요가 있다. 그렇지 못한 예외는 실제 예외 메시지를 사용하지 않고 대체 메시지를 사용하는 것이 좋다고 생각한다. 그 외에도 예외 처리를 한곳에서 처리하도록 구현했으나 실제로는 여러 곳에서 처리되어 예상한 것과는 전혀 다른 에러 메시지나 데이터 구조로 응답이 내보내질 수 있다.

예외가 발생하면 try-catch로 예외를 처리할 수 있지만 공통적으로 처리할 수 있는 예외 처리 로직을 매번 try-cach로 작성하게 되면 중복 코드를 작성하게 되고 예외 처리를 누락하기 쉽다. 이런 문제점들을 해결하기 위해 Spring Boot 애플리케이션에서는 동일한 곳에서 예외를 처리할 수 있는 기능들을 제공하고 있다.

1
2
3
4
5
6
{
  "timestamp": "2024-07-10T06:32:04.361+00:00",
  "status": 404,
  "error": "Not Found",
  "path": "/api/v1/users/13"
}

예외를 처리하지 않고 던지면 Spring Boot에서 사용하고 있는 기본 에러 응답이 내보내진다. 우리는 보통 기본 에러 메시지와 데이터 구조를 사용하지 않고 백엔드와 프론트가 협의해서 서비스에 적합한 더 좋은 에러 메시지와 데이터 구조를 설계하여 사용한다. 커스텀 한 에러 메시지와 데이터 구조를 적용했는데 적용되지 않은 곳에서 예외를 처리하고 다른 에러 메시지와 데이터 구조를 사용한다면 에러 응답은 일관성이 없어지게 된다. 그렇기 때문에 에러 응답의 일관성을 지키기 위해서는 예외 처리를 지원하는 기능들을 잘 이해할 필요가 있다.

사용하고 있는 프레임워크에 따라 예외가 처리되는 곳은 더 다양해질 수 있지만 Spring Boot Start Web 프레임워크를 사용하는 웹 애플리케이션에서 요청에서 발생한 예외를 처리하고 에러 응답을 내보내는 과정을 파악해 보자.

dispatcher-servlet-process-handler-exception.png

요청이 컨트롤러까지 도달한 후에 발생한 예외, 즉 DispatcherServlet에 의해 요청에 매핑된 핸들러 메소드가 invoke된 후에 발생한 예외는 HandlerExceptionResolver에 의해 처리된다.

default-handler-exception-resolver.png

예외는 HandlerExceptionResolver의 구현체에서 처리되는데 ExceptionHandlerExceptionResolver, ResponseStatusExceptionResolver, DefaultHandlerExceptionResolver들이 있고 우선순위대로 동작한다. 별도의 설정을 사용하지 않는 상황에서는 가장 낮은 우선순위인 DefaultHandlerExceptionResolver에서 예외가 처리되고 발생한 예외에 대한 정보를 응답 객체의 상태 값에 적용한다.

response-status-exception-resolver-apply-status-and-reason.png

예외 클래스가 ResponseStatusException을 상속받거나 @ResponseStatus 애노테이션이 정의되어 있다면 ResponseStatusExceptionResolver에서 예외가 처리된다. 마찬가지로 발생한 예외에 대한 정보를 응답 객체의 상태 값에 적용한다. 예외를 처리할 수 있는 @ExceptionHandler 메소드가 있다면 ExceptionHandlerExceptionResolver에서 예외를 처리하게 되는데 이 내용은 뒤에서 이어서 살펴보겠다.

standard-host-valve-invoke-status.png

Tomcat의 HTTP 요청 파이프라인을 처리하는 StandardHostValve에서 응답에서 에러가 발생했다면 예외를 처리한다.

standard-host-valve-status.png

예외 처리에 사용할 수 있는 에러 페이지를 찾아 예외 처리에 사용한다. 에러 페이지를 별도로 설정하지 않았다면 /error 경로를 에러 페이지로 설정하여 사용하는데 server.error.path 옵션으로 경로를 변경할 수 있다.

standard-host-valve-custom.png

에러가 발생한 응답은 에러 페이지의 경로로 요청을 포워딩한다.

standard-wrapper-valve-invoke.png

요청이 컨트롤러에 도달하기 전에 발생한 예외는 Tomcat의 서블릿 요청을 처리하는 StandardWrapperValve에서 예외를 처리한다.

standard-wrapper-valve-exception.png

발생한 예외에 대한 정보를 요청과 응답 객체의 상태 값에 적용한다.

standard-host-valve-invoke-throwable.png

마찬가지로 StandardHostValve에서 응답에서 에러가 발생했다면 예외를 처리한다.

standard-host-valve-throwable.png

예외 처리에 사용할 수 있는 에러 페이지를 찾아 요청을 포워딩한다.

error-controller.png

요청이 컨트롤러에 도달하기 전과 후에 발생한 예외는 모두 에러 페이지의 경로로 포워딩되고 포워딩된 요청은 에러 컨트롤러가 요청을 처리한다.

default-error-attributes-get-error-attributes.png

에러 응답 구조는 ErrorAttributes 구현체인 DefaultErrorAttributes을 사용해서 에러 응답을 생성한다. 이런 과정들을 거쳐 예외 발생 시 에러 컨트롤러가 에러 응답을 내보내고 있다.

에러 응답이 만들어지는 과정을 보면 애플리케이션에서 글로벌한 예외 처리를 사용해서 커스텀 한 에러 메시지와 에러 응답 데이터 구조를 적용하기 위해서는 커스텀 한 에러 컨트롤러 또는 ErrorAttributes를 구현해야 한다는 것을 알 수 있다. 하지만 @ExceptionHandler 메소드만을 사용해서 예외를 처리하더라도 대부분의 예외가 처리되기 때문에 이를 사용해서 예외를 처리하는 경우가 가장 많이 보이는 것 같다. 그 이유는 쉽고 간단하게 글로벌한 예외를 처리할 수 있고 적용 대상을 핸들링할 수 있어서라고 생각한다. 만약 @ExceptionHandler 메소드를 사용하거나 ResponseEntityExceptionHandler 클래스를 사용해서 예외를 처리하더라도 에러 컨트롤러로 요청이 포워딩 될 수 있기 때문에 에러 컨트롤러 또는 ErrorAttributes에도 커스텀 한 에러 메시지와 에러 응답 데이터 구조를 동일하게 적용해 주어야 한다.

Spring Boot 3부터는 ResponseEntityExceptionHandler에서 RFC 9457(Problem Details for HTTP APIS)을 준수한 에러 메시지가 응답된다. problem details는 원래 RFC 9457이 기술되면서 폐기된 RFC 7807에서 먼저 기술되었다. HTTP API의 에러 응답에 대한 명세인 RFC 7807은 상세한 에러 내용뿐만 아니라 일관되고 예측 가능한 방식으로 에러를 처리할 수 있도록 표준화된 에러 처리 방식을 기술한다. RFC 9457은 RFC 7807에서 설정한 핵심 원칙을 기반으로 개선 및 확장되었고 원래 버전에서 해결되지 않았거나 예측하지 못했던 상황들을 해결하는 것을 목표로 하고 있다. 절대적으로 지켜야 하는 표준은 아니지만 좋은 에러 응답 설계를 고민 중이라면 RFC 9457을 준수한 에러 응답을 사용하는 것도 사용자의 경험을 개선할 수 있는 좋은 방향이라고 생각한다.

1
2
3
4
spring:
  mvc:
    problemdetails:
      enabled: true # default false

애플리케이션의 옵션을 활성화하면 ResponseEntityExceptionHandler의 구현체인 ProblemDetailsExceptionHandler가 글로벌한 예외를 처리하는 예외 핸들러로 적용된다. 주의해야 할 점은 스프링에서 지원하는 기능을 사용했다고 해서 예외 처리를 하는 모든 곳에 일관되게 적용되는 것은 아니다. 아직 일부 예외에 대해서만 처리를 지원하고 있고 에러 컨트롤러에서 에러 응답 생성에 사용되는 ErrorAttributes에는 problem details가 적용되지 않았기 때문에 에러 컨트롤러로 요청이 포워딩되면 에러 응답의 일관성이 보장되지 않을 수 있다. 스프링에서는 problem details에 대한 지원은 완전히 새로운 기능이고 Spring Boot 3에서 너무 많은 변화를 겪지 않도록 점진적으로 도입하고자 했으며 오류 처리에 대한 내용을 전반적으로 재검토 중이라고 한다. 따라서 일관성 있는 에러 응답을 보장하기 위해서는 problem details를 적용한 에러 컨트롤러 또는 ErrorAttributes를 구현해 주어야한다.

exception-handler-exception-resolver-do-resolve-handler-method-exception.png

@ExceptionHandler 메소드를 사용한 예외 처리는 ExceptionHandlerExceptionResolver에 의해서 처리되고 발생한 예외 타입을 처리할 수 있는 메소드를 찾아서 invoke한다.

servlet-invocable-handler-method-invoke-and-handle.png

예외를 처리하는 메소드를 invoke한 후에는 다른 구현체들(DefaultHandlerExceptionResolver, ResponseStatusExceptionResolver)과는 다르게 일반적인 경우에는 예외에 대한 정보를 응답 객체의 상태 값에 적용하지 않고 예외를 처리해서 응답을 내보내기 때문에 에러 컨트롤러로 요청이 포워딩되지 않는다.

servlet-invocable-handler-method-set-response-status.png

하지만 @ExceptionHandler 메소드에 @ResponseStatus 애노테이션을 적용해서 code와 reason을 정의한 경우에는 에러 컨트롤러로 요청이 포워딩된다.

exception-handler-exception-resolver-catch-exception.png

@ExceptionHandler 메소드에서 예외가 발생한 경우에도 요청이 포워딩 되는데 ExceptionHandlerExceptionResolver는 클라이언트와의 연결로 인한 예외(AbortedException, ClientAbortException 등)가 아니라면 null을 리턴한다.

handler-exception-resolver-composite-resolve-exception.png

HandlerExceptionResolver 구현체인 HandlerExceptionResolverComposite는 HandlerExceptionResolver 구현체가 null을 리턴하면 처리되지 않은 것으로 간주하고 다른 구현체로 예외 처리를 시도하고 예외에 대한 정보가 응답 객체의 상태 값에 적용되면 요청이 포워딩된다.

반대로 에러 컨트롤러에서 예외가 발생하는 경우에도 DispatcherServlet에 의해 핸들러 메소드가 invoke된 이후에 발생한 예외이기 때문에 HandlerExceptionResolver에서 예외가 처리된다. 에러 컨트롤러와 예외 핸들러에서 반복적으로 예외가 발생하더라도 에러 컨트롤러로 요청을 2번 이상 포워딩 하지는 않기 때문에 순환 참조 형태로 포워딩 되는 현상은 발생하지 않는다. 그렇지만 예외를 처리하는 핸들러나 컨트롤러에서 예외를 처리하는 도중에 예외가 발생하지 않도록 구현할 필요가 있다.

이렇게 예외 처리는 반드시 한 곳(에러 컨트롤러, 예외 핸들러, 비동기 예외 핸들러 등)에서 처리되지 않고 사용하고 있는 프레임워크에 따라 예외가 처리되는 곳은 더 다양해질 수 있기 때문에 글로벌한 예외 처리에서 반드시 처리가 필요한 로직(ex. 슬랙 알람 등)이나 커스텀 한 에러 메시지와 데이터 구조(ex. RFC 9457, 사내 표준 등)를 사용하고 있다면 예외가 처리되는 곳을 잘 파악하여 동일하게 적용해 주어야 일관성 있는 에러 응답을 보장할 수 있다.

일관성 있는 에러 응답을 사용하는 것은 사소하면서도 사소하지 않은 문제인 것 같다. 그렇기 때문에 생각보다 깊게 고민하는 사람을 별로 본 적이 없어서 나만 쓸데없이 과하게 생각하고 있는 건 아닌지 생각한 적도 있다.

HTTP API를 사용하다 보면 협의되지 않았거나 문서에도 정의되어 있지 않은 에러 응답을 마주하게 되는 경우가 있는데 다른 형태의 응답이 나올 수 있는 경우가 더 있는지 확인하게 되고 일관성 없는 에러 응답을 처리하기 위해서 고민하는 데 시간을 써야 한다.

예외적으로 특정 엔드 포인트에서 에러 응답 구조를 다르게 사용해야 하는 경우는 있다. 예를 들면 Oauth 프로토콜에서는 에러 응답에 사용될 수 있는 필드가 정의되어 있는데 이처럼 의도한 경우는 예측이 가능하기 때문에 문제가 되지 않는다. 하지만 의도적으로 적용한 게 아니라면 일관성 있는 에러 응답을 사용해서 좋은 사용자 경험을 줄 수 있도록 하자.

This post is licensed under CC BY 4.0 by the author.