Modern Beautiful API Response Design: External Error Contract
TOC
- Overview
- 왜 에러 응답도 계약이어야 하는가
- 설계 목표
- 1. 내부 예외와 외부 응답은 분리되어야 한다
- 2. 왜 ProblemDetail인가
- 3. 어떤 정보를 응답에 담아야 하는가
- 4. 응답 필드의 책임 분리
- Closing
Overview
앞선 글에서는 서버 내부에서 실패를 어떻게 모델링할지 정리했다. 공통 부모 예외, 계층별 예외, ErrorCode, ProblemSpec까지 모두 내부 계약의 일부다.
하지만 내부 예외 계약이 잘 설계되어 있어도, 최종적으로 클라이언트가 받는 응답 포맷이 일관되지 않으면 계약은 다시 흔들린다. 그래서 이 단계에서는 예외 자체보다 응답 번역 규격이 핵심이 된다.
이 글에서는 내부 예외를 외부 API 에러 응답으로 어떻게 번역할지, 그리고 왜 ProblemDetail이 이 번역의 중심이 되어야 하는지를 다룬다.
왜 에러 응답도 계약이어야 하는가
에러 응답을 즉흥적으로 만들면 보통 아래 문제가 발생한다.
- 예외마다 JSON 구조가 달라진다.
- HTTP 상태 코드와 본문 의미가 어긋난다.
- 클라이언트가
message문구에 의존해 분기하게 된다. - 내부 예외 구조가 외부 계약에 그대로 새어 나온다.
즉, 내부 설계와 외부 계약 사이에 번역 계층이 없으면 응답은 오래 버티지 못한다.
설계 목표
이번 단계의 목표는 명확하다.
- 내부 예외와 외부 응답 포맷을 분리한다.
- 외부 응답은 하나의 표준 구조로 통일한다.
- 상태 코드, 식별 코드, 사용자 메시지의 역할을 분리한다.
- 번역 책임은 프레젠테이션 계층에서만 담당한다.
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와 번역 계층의 위치를 정리한다.