RFC 9457과 헥사고날 아키텍처로 구현하는 예외 처리 설계서
이번 포스팅에서는 사내 웹서비스를 개발하며 예외처리에 대해 고민했던 부분을 정리해봤습니다.
참고 포스팅 Spring Security Filter 예외 처리 + 전략
0️⃣들어가며: 예외 처리에 대한 우리 팀의 고민
시스템이 성장하고 아키텍처가 복잡해질수록 '예외를 어떻게 정의하고, 어디서 던지며, 클라이언트에게 어떻게 보여줄 것인가'에 대한 기준이 모호해지곤 합니다. 특히 표준화되지 않은 에러 응답은 프트엔드와의 소통 비용을 증가시키고, 레이어 간 책임이 섞인 예외는 시스템의 결합도를 높입니다.
우리 팀 또한 절대적인 시간이 부족한 상태에서 서비스 개발을 진행하는 과정에서 예외 처리에 대한 설계, 컨벤션없이 진행하다보니 점점 복잡해지고 구조 파악에 상당 시간이 드는 것을 체감했습니다.
이러한 문제를 해결하기 위해 두 가지 핵심 축을 중심으로 예외 아키텍처를 리팩토링했습니다.
- RFC 9457(Problem Details for HTTP APIs) 표준 스펙 적용을 통한 에러 응답 포맷 규격화
- Service Layer와 Data Access Layer 사이 모호한 예외 처리 책임에 헥사고날 아키텍처(Hexagonal Architecture) 적용
사내에서 직접 겪은 문제에 대해 고민하고 구축한 예외 처리 설계 과정을 공유합니다.
1️⃣기존 예외 아키텍처
현재 운영중인 서비스는 웹 기반 서비스로 클라이언트와의 소통 및 비즈니스 로직 처리를 위한 서버를 SpringBoot를 통해 개발하고 있습니다.
서비스에는 써드파티 툴체인도 N개 포함되어 예외가 발생할 수 있는 지점이 상당히 많이 있습니다.
초기에는 이런 서비스가 있다면 업무에 도움이 될 것 같다는 생각에 POC로 진행하여 규모가 커질 것을 예상못해 설계가 제대로 진행되지 못했는데요. 이로 인해 규모 및 사용자 수 증가에 따른 버그 수에 대한 조치가 점점 늦어지는 문제를 인식했습니다.
실제로 서비스 내 예외처리는 아래와 같이 두서없이 진행되고 있었습니다.

=== Exception.class ===
Exception 계층 구조
Java/Spring의 예외는 계층 구조를 가집니다.
```
Throwable
├── Error (시스템 레벨 오류 - 처리 불가)
│ ├── OutOfMemoryError
│ └── StackOverflowError
│
└── Exception (애플리케이션 레벨 오류 - 처리 가능)
├── IOException (Checked Exception)
├── SQLException (Checked Exception)
│
└── RuntimeException (Unchecked Exception) ⭐ 주로 사용
├── NullPointerException
├── IllegalArgumentException
├── IllegalStateException
└── [커스텀 예외들]
RuntimeException을 상속한 BusinessLogicException 클래스를 추상화 역할로 사용하며, 실제로 발생하는 모든 예외에 대해서는 개별 예외 클래스를 다시 서브타입으로 변환하여 Throw하고 있습니다.
또한 Handler에서 응답을 조립하여 클라이언트로 전달하는 구조로 되어 있었습니다.
위 구조도 규모가 작을 때는 사용성이 괜찮았습니다만, 규모가 커질수록 두 가지 문제가 상당히 골치아파졌습니다.
⚡예외 클래스 수 폭발
당연한 결과였습니다. 규모가 커질수록 예외의 종류도 함께 증가하게되고, 예외마다 클래스를 생성하다 보니 관리 포인트 증가, Handler 비대화 등 다양한 문제가 발생했습니다.
응답의 경우도 Handler 별 개별 조립을 하다보니, 클라이언트와의 명확한 통신 규약이 존재하지 않았던 것도 유지보수를 힘들게 하는 문제 중 하나였습니다.
⚡레이어 책임 분산
BusinessLogicException 단일 클래스로 인한 3Tier Architecture 각 레이어 별 예외 책임이 명확하지 않다는 문제도 있었습니다.
서비스가 비대해질수록 관리하는 툴체인에 대한 써드파티 통신도 늘어나면서 Data Access Layer가 급격히 커지면서, 서비스 오류인지 외부 툴 문제로 발생한 오류인지 추적이 힘들었습니다.
위 문제를 조치하기위해 팀 내에서 재설계를 진행했고, 논의한 내용을 바탕으로 아래와 같은 작업을 진행했습니다.
2️⃣RFC 9457 표준 스펙 적용을 통한 에러 응답 포맷 규격화
첫번째로 클라이언트와의 명확한 통신 규약을 명세하기위해 예외 응답 규격화를 진행했습니다.
규격화에 사용한 포맷은 RFC 9457(HTTP API 문제 상세 정보)로 API 오류 응답을 위한 표준 형식입니다.
RFC 9457은 아래와 같은 데이터를 가집니다.
에러 응답 필드 명세
| 필트 | 타입 | 출처 | 설명 |
| type | String | ErrorCode.typeUri | 에러 종류 식별 URI. Confluence 문서 없는 경우 about:blank 고정 |
| title | String | ErrorCode.title | 에러 종류 고정 요약 (동일 ErrorCode면 항상 동일) |
| status | int | ErrorCode.httpStatus | HTTP 상태 코드 |
| detail | String | 호출자 주입 or ErrorCode.title | 이번 발생의 구체적 맥락 (nullable → title fallback) |
| instance | String | 요청 URI | 문제가 발생한 요청 경로 |
| timestamp | LocalDateTime | 핸들러 생성 | 발생 시각 |
| code | String | ErrorCode.code | 프론트엔드 분기 처리용 내부 코드 |
| data | Object | 호출자 주입 | 클라이언트 복구용 부가 데이터 (nullable) |
- 사내 서비스로 에러 종류 식별 URI는 필요 없지만, 이후 Confluence 문서로 오류 명세를 진행할 수도 있을 것이라는 판단하에 필드를 유지했습니다.
- 프론트에서는 code 필드를 통해 분기 처리를 진행하도록 명세했습니다.
응답 예시
HTTP/1.1 404 Not Found
Content-Type: application/problem+json
{
"type": "about:blank", // Confluence 에러 문서 작성 전까지 about:blank 고정
"title": "사용자를 찾을 수 없습니다",
"status": 404,
"detail": "username 'kim.jeonghyeon'에 해당하는 사용자가 없습니다",
"instance": "/api/v1/user/token",
"timestamp": "2026-05-22T10:30:00",
"code": "U001",
"data": null
}
| Prefix | 도메인 |
| U | User |
| A | Auth |
| P | Project |
| J | Jenkins |
| B | SCM (Bitbucket) |
| C | Common / 공통 |
4️⃣레이어 별 예외 클래스 분기
예외도 계층에 맞는 책임이 있습니다. Service Layer와 Data Access Layer의 예외를 철저히 격리하여 외부 기술 종속성이 도메인으로 흘러 들어오는 것을 방지하고자 했습니다.
BusinessLogicException 단일 클래스로 인한 3Tier Architecture 각 레이어 별 예외 책임이 명확하지 않다는 문제를 해결하기 위해 레이어 별 RuntimeException 서브타입을 정의했습니다.
예외 상속 구조 모식도는 아래와 같습니다.

✅레이어 격리와 클래스 수 감소
핵심 문제 중 하나였던 예외 클래스 수 폭발 문제를 해결하기 위해 위 모식도의 각 레이어 별 대표 서브타입 클래스만을 사용하여 ErrorCode를 주입하는 방식으로 처리하여 클래스 수를 줄이고자 했습니다.
다만 모식도에서도 나오듯 대표 클래스를 상속받는 서브타입들도 있는데요.
원칙적으로는 레이어 별 대표 클래스를 사용하지만, 아래 두 조건 중 하나 이상 해당할 경우 상속 구현체를 사용합니다.
- RestControllerAdvice 핸들러에서 별도 후처리 로직이 필요한 경우
- 동일 ErrorCode 내에서 처리 방식이 케이스마다 달라지는 경우
주된 활용으로는 별도 Handler 등록이 필요한 경우에 사용하게 됩니다.
// 상속 구현체 예시 - 핸들러에서 별도 처리 필요 시
public class FileUploadException extends BusinessLogicException {
private final String fileName;
public FileUploadException(String fileName) {
super(ErrorCode.FILE_UPLOAD_FAILED, "파일명: " + fileName);
this.fileName = fileName;
}
}
✅Presentation Layer
위 모식도에서 Presentation Layer에 대한 내용은 없는데, Controller에서 발생하는 예외의 경우 기본적으로 Spring에서 제공하는 기본 예외 클래스를 사용하는 경우가 많습니다. (Ex : MethodArgumentNotValidException, HttpMessageNotReadableException, ...)
따라서 별도의 상속 구조를 가지지 않고, @RestControllerAdvice 내 Handler로만 정의하는 것으로 진행했습니다.
✅Service Layer
도메인 정보 및 의미론적 데이터를 주입하여 비즈니스 로직 예외를 던집니다.
클래스 예시는 아래와 같고, 생성자 오버로딩 대신 정적 팩토리 패턴을 사용하는 것도 괜찮을 것으로 판단됩니다.
@Getter
public class BusinessLogicException extends RuntimeException {
private final ErrorCode errorCode;
private final String detail;
private final Object data;
// 생성자 오버로딩 1: 상세 + 데이터 포함
public BusinessLogicException(ErrorCode errorCode, String detail, Object data) { ... }
// 생성자 오버로딩 2: 데이터 생략
public BusinessLogicException(ErrorCode errorCode, String detail) { ... }
// 생성자 오버로딩 3: detail, data 모두 생략
public BusinessLogicException(ErrorCode errorCode) { ... }
}
✅Data Access Layer: 기술 종속적 예외
해당 레이어는 외부 의존성(ThirdParty API, 데이터베이스 등)과 소통하는 계층입니다. 이 계층에서 발생하는 예외는 오직 외부 기술 실패에 대한 정보만을 담아야 합니다.
클린 아키텍처의 의존성 방향 원칙을 기반으로 해당 레이어가 도메인 정보를 알아서도 안되며, 의미론적 추론을 하는것도 안된다고 판단하여 위와 같이 명세를 진행했습니다.
✅실제 레이어 별 사용 예시
실제 사용에서는 두 레이어 모두 예외의 로깅 및 최종 변환 책임은 @RestControllerAdvice에게 위임하므로, 깔끔하게 예외에 대한 정보만을 주입하는데만 집중합니다.
// [Service Layer] - 상속체 유저 예외 발생 시, 구체적인 맥락(detail)을 담아 전송
public User findUser(String username) {
return userRepository.findByUsername(username)
.orElseThrow(() -> new UserNotFoundException(
"username '" + username + "'에 해당하는 사용자가 없습니다" // 구체적 맥락
));
}
// [Data Access Layer] - 외부 기술 실패 시 업스트림의 정보를 투명하게 캡처
public List<Project> getProjects(String token) {
try {
return bitbucketClient.fetchProjects(token);
} catch (WebClientResponseException e) {
throw new DataAccessException(
e.getStatusCode().value(), // 업스트림 상태 코드 (ex. 401, 404)
e.getRequest().getURI(), // 호출을 시도했던 외부 API URI
e // 원인(Cause) 예외 추적용
);
}
}
5️⃣Data Access → Service 예외 변환에 헥사고날 아키텍처 적용
3번에서 정의한 레이어 별 예외 분리 원칙을 따르면 한 가지 문제 상황에 마주하게 됩니다.
Data Access Layer에서 발생한 예외이지만, Service Layer의 비즈니스적인 의미가 필요한 경우입니다.
예를 들어 외부 API가 404를 반환했을 때, 이것이 단순한 기술 실패인지 혹은 "리소스가 존재하지 않는다"는 도메인 의미를 가지는지 판단이 필요한 경우가 있습니다.
원칙적으로 진행해도 큰 문제는 없지만, 도메인 의미를 가지는 것으로 클라이언트(사용자)에게 편의성을 제공해주는 케이스가 있었습니다. 제 주관은 2번에 걸쳐 진행될 UX를 1번으로 끝낼 수 있다면 굳이 원칙적으로 하지 않고 융퉁성을 적용해도 괜찮을 것이라 생각합니다.
앞서 정의한 원칙대로라면 Data Access Layer는 도메인 정보를 알아서도, 의미론적 추론을 해서도 안됩니다.
그러나 이 판단을 Service Layer에서 처리하게 되면, 기술 종속적인 상태 코드(404, 401 등)가 도메인으로 흘러들어오는 문제가 생깁니다.
이 문제를 해결하기 위해 해당 경계에만 부분적으로 헥사고날 아키텍처를 적용했습니다.
⚡ 적용 원칙
서비스 레이어와 인프라 레이어의 격리를 원칙으로 합니다. 단, 외부 기술 실패에 도메인 의미가 필요한 경우에 한해 아래 방식으로 처리합니다.
- 포트(Port) 인터페이스에 해당 변환의 목적을 명확히 문서화한다.
- 어댑터(Adapter)가 외부 응답 상태를 판단하여 도메인 예외(BusinessLogicException)를 던진다.
- 어댑터 내 비즈니스 판단은 최소화하며, 단순 상태 코드 매핑 수준에 한정한다.
⚡ 구조
[Port 인터페이스]
/**
* 404 응답 시 ProjectNotFoundException을 던진다.
* 인프라 예외를 도메인 의미로 변환하는 유일한 지점.
*/
List<Project> getProjects(String userId);
[BitbucketAdapter] - Port 구현체
public List<Project> getProjects(String userId) {
try {
return bitbucketClient.fetchProjects(userId);
} catch (WebClientResponseException e) {
if (e.getStatusCode() == HttpStatus.NOT_FOUND) {
// 상태 코드 → 도메인 예외 매핑 (비즈니스 판단 최소화)
throw new BusinessLogicException(ErrorCode.PROJECT_NOT_FOUND);
}
throw new DataAccessException(
e.getStatusCode().value(),
e.getRequest().getURI(),
e
);
}
}
[Service Layer]
// Port 인터페이스만 바라봄
// 인프라 기술(HTTP, 상태 코드) 완전히 모름
public List<Project> getUserProjects(String userId) {
return bitbucketPort.getProjects(userId);
}
어댑터가 비즈니스 판단을 많이 하기 시작하면 책임이 서비스로 새는 것이므로, 상태 코드 → 도메인 예외 매핑 수준만 유지하는 것이 해당 명세의 중요한 부분이 될 것이라 생각됩니다.
6️⃣마치며: 예외 재설계를 진행하며,
다음과 같은 수확이 있었습니다.
- 예외 클래스 N개를 레이어별 대표 클래스 + ErrorCode Enum으로 관리 포인트를 감소시킬 수 있었습니다.
- RFC 9457 적용으로 프론트엔드와의 통신 규약이 명확해질 수 있었습니다.
- 레이어별 예외 책임 분리로 오류 발생 시 추적이 쉬워질 것으로 예상됩니다.
다음과 같은 것을 체감했습니다.
- 설계 없이 빠르게 치고 나간 POC가 서비스로 굳어졌을 때의 기술 부채를 체감했습니다.
- 아키텍처 패턴(헥사고날)을 전체 적용이 아닌 문제가 있는 경계에만 부분 적용하는 것도 유효한 선택이라는 주관을 얻었습니다.
- 예외 처리는 기능 구현만큼 설계가 중요하며, 특히 팀 단위에서는 컨벤션이 없으면 결국 모두가 다른 방식으로 던지게 된다는 것을 경험했습니다.
혹시 예외 처리 아키텍처로 고민 중인 다른 분들이 있다면, 이번 포스팅이 좋은 힌트가 되기를 바랍니다.😊
Reference
RFC 9457
https://www.rfc-editor.org/rfc/rfc9457.html
RFC 9457: Problem Details for HTTP APIs
The key words "MUST", "MUST NOT", "REQUIRED", "SHALL", "SHALL NOT", "SHOULD", "SHOULD NOT", "RECOMMENDED", "NOT RECOMMENDED", "MAY", and "OPTIONAL" in this document are to be interpreted as described in BCP 14 [RFC2119] [RFC8174] when, and only when, they
www.rfc-editor.org
https://apidog.com/kr/blog/what-is-rfc-9457-api-error-responses/
RFC 9457이란 무엇이며 API는 오류를 어떻게 반환해야 할까요?
요약 (TL;DR) RFC 9457 (HTTP API 문제 상세 정보)은 API 오류 응답을 위한 표준 형식입니다. 이 표준은 사용자 지정 오류 형식을 일관된 구조(유형, 제목, 상태, 상세 정보, 인스턴스)로 대체합니다. Modern
apidog.com
예외 설계서.md
https://github.com/Be-HinD/TIL/blob/main/ETC/%EC%98%88%EC%99%B8_%EC%84%A4%EA%B3%84.md
TIL/ETC/예외_설계.md at main · Be-HinD/TIL
누가 공부를 한다고? Contribute to Be-HinD/TIL development by creating an account on GitHub.
github.com
'Develop > Spring' 카테고리의 다른 글
| Spring Security Filter 예외 처리 및 전략 (0) | 2026.05.23 |
|---|---|
| [Springboot] 액추에이터(Actuator) (1) | 2024.04.16 |
| [JPA] 인스타그램 유저 검색 N+1 문제 (0) | 2024.04.02 |
| [JWT] 동작 원리 (Feat. Refresh Token) (0) | 2024.03.11 |
| [JWT] JSON Web Token - With JWT.io (0) | 2024.03.11 |
