..

Modern Beautiful API Response Design: External Error Contract

TOC


  1. Overview
  2. 왜 에러 응답도 계약이어야 하는가
  3. 설계 목표
  4. 1. 내부 예외와 외부 응답은 분리되어야 한다
  5. 2. 왜 ProblemDetail인가
  6. 3. 어떤 정보를 응답에 담아야 하는가
  7. 4. 응답 필드의 책임 분리
  8. Closing

Overview


앞선 글에서는 서버 내부에서 실패를 어떻게 모델링할지 정리했다. 공통 부모 예외, 계층별 예외, ErrorCode, ProblemSpec까지 모두 내부 계약의 일부다.

하지만 내부 예외 계약이 잘 설계되어 있어도, 최종적으로 클라이언트가 받는 응답 포맷이 일관되지 않으면 계약은 다시 흔들린다. 그래서 이 단계에서는 예외 자체보다 응답 번역 규격이 핵심이 된다.

이 글에서는 내부 예외를 외부 API 에러 응답으로 어떻게 번역할지, 그리고 왜 ProblemDetail이 이 번역의 중심이 되어야 하는지를 다룬다.

왜 에러 응답도 계약이어야 하는가


에러 응답을 즉흥적으로 만들면 보통 아래 문제가 발생한다.

  1. 예외마다 JSON 구조가 달라진다.
  2. HTTP 상태 코드와 본문 의미가 어긋난다.
  3. 클라이언트가 message 문구에 의존해 분기하게 된다.
  4. 내부 예외 구조가 외부 계약에 그대로 새어 나온다.

즉, 내부 설계와 외부 계약 사이에 번역 계층이 없으면 응답은 오래 버티지 못한다.

설계 목표


이번 단계의 목표는 명확하다.

  1. 내부 예외와 외부 응답 포맷을 분리한다.
  2. 외부 응답은 하나의 표준 구조로 통일한다.
  3. 상태 코드, 식별 코드, 사용자 메시지의 역할을 분리한다.
  4. 번역 책임은 프레젠테이션 계층에서만 담당한다.

1. 내부 예외와 외부 응답은 분리되어야 한다


ApplicationException, DomainException, InfrastructureException은 모두 서버 내부 모델이다. 이 객체들은 서비스 코드와 아키텍처 책임 분리를 위해 존재한다.

반면 클라이언트가 받아야 하는 것은 서버 내부 클래스 이름이 아니라, 안정적인 외부 계약이다.
즉 내부에서는:

  • 어떤 계층에서 실패했는가
  • 어떤 상태 범주인가
  • 어떤 ProblemSpec을 사용하는가

를 다루고, 외부에서는:

  • HTTP 상태 코드
  • 문제 식별자
  • 사용자 메시지
  • 요청 문맥

을 다뤄야 한다.

이 둘을 같은 객체로 처리하려고 하면 내부 구조가 외부로 새고, 외부 포맷 변경이 내부 설계까지 흔들게 된다.

2. 왜 ProblemDetail인가


스프링 6부터는 에러 응답을 표현하는 표준 구조로 ProblemDetail을 제공한다. 이 객체는 HTTP 에러 응답을 일관되게 만들기 위한 최소 골격을 제공한다.

핵심은 단순하다.

  • status: HTTP 상태 코드
  • title: 문제의 짧은 이름
  • detail: 사람이 읽을 설명
  • type: 문제 유형 식별자
  • properties: 팀이 추가로 넣고 싶은 확장 필드

ProblemDetail은 “에러 응답의 기본 뼈대”를 제공하고, 우리 시스템은 그 위에 code, source 같은 팀 규칙을 얹으면 된다.

ProblemDetail problem = ProblemDetail.forStatus(HttpStatus.NOT_FOUND);
problem.setTitle("Resource not found");
problem.setDetail("User not found");
problem.setType(URI.create("https://api.example.com/problems/user-not-found"));
problem.setProperty("code", "USER-404");
problem.setProperty("source", "application");

3. 어떤 정보를 응답에 담아야 하는가


여기서 중요한 것은 “많이 담는 것”이 아니라, 각 필드가 무엇을 책임지는지 분리하는 것이다.

  • HTTP status: 전송 계층의 의미다. 요청이 실패했는지, 권한 문제인지, 서버 문제인지 표현한다.
  • type: 문제 유형의 안정적인 식별자다.
  • code: 프론트엔드나 운영 도구가 분기하기 쉬운 팀 내부 규격 코드다.
  • detail: 사람이 읽을 수 있는 설명이다.
  • source: 선택적이다. 어느 계층에서 번역되었는지 드러내고 싶을 때만 둔다.

즉 같은 “에러”라도 역할은 다르다.

  • 상태 코드는 HTTP를 위한 것
  • 식별 코드는 클라이언트 분기를 위한 것
  • 메시지는 사람을 위한 것

이 셋을 하나의 필드로 뭉개면 다시 문자열 파싱 구조로 되돌아간다.

4. 응답 필드의 책임 분리


앞선 두 글과 이번 글을 합치면 역할은 이렇게 정리된다.

  • Exception Hierarchy: 실패의 출처와 책임을 분리한다.
  • ErrorCode: 각 계층이 가질 수 있는 본질적인 상태 범주를 표현한다.
  • ProblemSpec: 외부에 노출할 식별 코드와 메시지 키를 관리한다.
  • ProblemDetail: 외부 에러 응답 계약의 기본 뼈대를 제공한다.

즉 내부에서는 아키텍처를 지키고, 외부에서는 표준 응답 포맷을 지키는 구조다. 이 둘을 하나로 합치지 않고, 번역 지점을 둔 것이 핵심이다.

Closing


예외 계층과 ErrorCode, ProblemSpec이 내부 계약의 뼈대라면, ProblemDetail은 그 계약을 외부 세계로 내보내는 표준 포맷이다. 내부 설계와 외부 응답을 분리해 두면, 서버는 더 안정적으로 진화하고 클라이언트는 더 예측 가능한 계약 위에서 동작할 수 있다.

다음 글에서는 이 외부 계약을 실제로 어디서, 어떻게 번역해야 하는지 다룬다. 즉 @RestControllerAdvice와 번역 계층의 위치를 정리한다.