fix: 하네스 제거 및 keycloak 문서 보강
This commit is contained in:
@@ -1 +0,0 @@
|
||||
../../vault/40-publish/publish-blog/api-error-envelope-blog.md
|
||||
@@ -0,0 +1,209 @@
|
||||
---
|
||||
title: 표준 대신 계약을 선택하다 — API Error Envelope 설계기
|
||||
source_type: blog
|
||||
status: draft
|
||||
confidence: unknown
|
||||
tags: [blog, ca-tmpl, error-handling, api-design, spring-boot, archunit]
|
||||
related_projects: [ca-tmpl]
|
||||
last_reviewed:
|
||||
canonical_sources: []
|
||||
audience: backend-engineer
|
||||
target_publish:
|
||||
status_label: draft
|
||||
---
|
||||
|
||||
# 표준 대신 계약을 선택하다: API Error Envelope 설계기
|
||||
|
||||
> Spring Boot API에서 `ProblemDetail` 대신 custom error envelope을 선택한 이유와, 그 결정을 코드로 어떻게 고정했는지 정리합니다.
|
||||
|
||||
## TL;DR
|
||||
|
||||
- Validation, 인증, transport failure가 각자 다른 JSON 모양으로 응답하면 클라이언트도 운영자도 힘들어집니다.
|
||||
- Spring 6+가 제공하는 `ProblemDetail`은 훌륭한 표준이지만, 저희 프로젝트(ca-tmpl)가 원하는 **성공/실패 대칭 구조**와는 결이 달랐습니다.
|
||||
- 그래서 `{ success, data, error, meta }` 형태의 커스텀 envelope을 프로젝트 계약으로 정하고, ArchUnit 룰과 설정 테스트로 되돌아가지 못하게 막았습니다.
|
||||
- 현재까지 **로컬/개발 환경 검증**은 끝났지만, 운영 환경 검증은 아직입니다. 이 글에서는 그 경계를 명확히 짚습니다.
|
||||
|
||||
---
|
||||
|
||||
## 1. 문제: 실패 응답의 모양이 제각각이라면
|
||||
|
||||
API 실패 응답은 처음엔 사소해 보입니다. 적당한 HTTP status와 메시지만 내려주면 될 것 같죠. 하지만 프로젝트가 커지면 이야기가 달라집니다.
|
||||
|
||||
- Validation 실패는 필드별 에러 목록을 내려줘야 하고
|
||||
- 인증 실패는 Spring Security가 알아서 다른 모양의 응답을 만들고
|
||||
- 잘못된 `Content-Type`이나 너무 큰 요청 본문은 Spring MVC의 transport 레이어에서 또 다른 응답을 만듭니다
|
||||
|
||||
이 상태가 계속되면 클라이언트 개발자는 "실패했다"는 사실보다 **"이번엔 또 어떤 모양으로 오지?"**를 먼저 걱정하게 됩니다. 운영자 입장도 비슷합니다. 응답에 trace id가 있는지, 재시도 가능한 오류인지, 어느 계층에서 실패했는지가 매번 다른 위치에 있으면 로그와 응답을 이어서 보기가 어렵습니다.
|
||||
|
||||
ca-tmpl 프로젝트는 이 문제를 초기에 **계약**으로 못 박기로 했습니다. 목표는 모든 실패를 하나의 원인으로 뭉개는 것이 아니라, HTTP status와 error code가 가진 의미는 그대로 보존하면서 **바깥 구조만큼은 하나로 통일**하는 것이었습니다.
|
||||
|
||||
---
|
||||
|
||||
## 2. 왜 `ProblemDetail`을 그대로 쓰지 않았나
|
||||
|
||||
가장 먼저 나온 질문은 당연히 이거였습니다. *"Spring 6+에 이미 `ProblemDetail`이 있는데, 그냥 쓰면 안 되나?"*
|
||||
|
||||
`ProblemDetail`은 RFC 7807 계열의 잘 만들어진 실패 응답 모델이고, Spring에서 기본으로 지원합니다. 그런데 ca-tmpl이 원하는 것과는 두 가지 지점에서 어긋났습니다.
|
||||
|
||||
| 요구 사항 | `ProblemDetail` | ca-tmpl이 원한 것 |
|
||||
|---|---|---|
|
||||
| 응답 구조 | 실패 전용 평면(flat) 구조 | 성공/실패가 같은 top-level envelope을 공유 |
|
||||
| 1급 필드 | `type`, `title`, `detail` 등 표준 필드 | `code`, `category`, `retryable`, `meta`를 프로젝트 계약으로 |
|
||||
|
||||
`ProblemDetail` 위에 커스텀 필드를 계속 얹는 방식도 고려했지만, 그렇게 되면 결국 "표준을 쓰는 척하면서 실제로는 또 다른 custom envelope을 만드는" 셈이 됩니다. 그래서 저희는 우회하지 않고 **명시적으로 프로젝트 전용 envelope을 선택**했습니다.
|
||||
|
||||
> 이건 `ProblemDetail`이 나쁜 설계라서가 아닙니다. 이 프로젝트가 원하는 success/error 대칭성과 운영 메타데이터가, 표준을 따르는 것보다 더 중요했기 때문입니다.
|
||||
|
||||
---
|
||||
|
||||
## 3. Envelope의 생김새
|
||||
|
||||
말로만 설명하면 추상적이니, 실제 응답 예시부터 보겠습니다.
|
||||
|
||||
```json
|
||||
{
|
||||
"success": false,
|
||||
"data": null,
|
||||
"error": {
|
||||
"code": "VALIDATION_FAILED",
|
||||
"category": "VALIDATION",
|
||||
"message": "Request body failed validation",
|
||||
"retryable": false,
|
||||
"details": []
|
||||
},
|
||||
"meta": {
|
||||
"requestId": "...",
|
||||
"traceId": "...",
|
||||
"correlationId": "..."
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
성공 응답이든 실패 응답이든 바깥 구조는 항상 같습니다. 실패라면 `success=false`이고 `data=null`, `error`에 실제 정보가 담깁니다. 필드별 역할은 다음과 같습니다.
|
||||
|
||||
- **`success`**: 클라이언트가 가장 먼저 확인하는 1차 분기 기준
|
||||
- **`error.code`**: 클라이언트가 로직으로 분기할 수 있는 machine-readable identifier. 반대로 `message`는 사람이 읽는 문장이라, 클라이언트 로직이 여기에 의존하면 안 됩니다.
|
||||
- **`error.category`**: validation / auth / dependency처럼 운영자가 보는 큰 분류
|
||||
- **`error.retryable`**: 클라이언트가 재시도를 검토할 수 있는 최소한의 힌트
|
||||
- **`error.details`**: validation field error처럼 항목별 정보가 필요할 때만 채우는 필드
|
||||
- **`meta`**: `requestId`, `traceId`, `correlationId`로 이 응답을 로그·트레이스와 이어 붙이는 영역
|
||||
|
||||
---
|
||||
|
||||
## 4. 결정을 코드로 고정하기
|
||||
|
||||
이 구조가 README 문장으로만 남아 있으면 시간이 지나면서 흐트러지기 마련입니다. 그래서 ca-tmpl은 이 계약을 **컴파일되는 타입과 테스트 가능한 경로**로 내렸습니다.
|
||||
|
||||
### 4-1. 핵심 타입
|
||||
|
||||
```java
|
||||
// shared-contract/src/main/java/dev/caskeleton/shared/response/Envelope.java
|
||||
public record Envelope<T>(boolean success, T data, ApiError error, ResponseMeta meta) {
|
||||
|
||||
public static <T> Envelope<T> ok(T data, ResponseMeta meta) {
|
||||
return new Envelope<>(true, data, null, meta);
|
||||
}
|
||||
|
||||
public static <T> Envelope<T> failure(ApiError error, ResponseMeta meta) {
|
||||
return new Envelope<>(false, null, error, meta);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
```java
|
||||
// shared-contract/src/main/java/dev/caskeleton/shared/response/ApiError.java
|
||||
public record ApiError(
|
||||
String code, String category, String message, boolean retryable, Object details) {
|
||||
|
||||
public static ApiError of(String code, String category, String message, boolean retryable) {
|
||||
return new ApiError(code, category, message, retryable, null);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 4-2. 실패를 envelope으로 바꾸는 관문
|
||||
|
||||
핸들러마다 JSON을 직접 조립하지 않도록, 실패를 envelope으로 변환하는 지점을 하나로 좁혔습니다.
|
||||
|
||||
```java
|
||||
// adapter-web/src/main/java/dev/caskeleton/adapter/web/error/ErrorResponseFactory.java
|
||||
public static Envelope<Void> body(ApiErrorCode code, String message, Object details) {
|
||||
ApiError err =
|
||||
details == null
|
||||
? ApiError.of(code.code(), code.category().name(), message, code.retryable())
|
||||
: ApiError.withDetails(
|
||||
code.code(), code.category().name(), message, code.retryable(), details);
|
||||
return Envelope.failure(err, ResponseMetaFactory.fromMdc());
|
||||
}
|
||||
```
|
||||
|
||||
### 4-3. `ProblemDetail`이 다시 들어오지 못하게 막기
|
||||
|
||||
가장 중요한 장치는 이 부분입니다. 설계 결정을 문서에만 남기지 않고, **되돌아가면 빌드가 깨지도록** 만들었습니다.
|
||||
|
||||
```java
|
||||
// app-bootstrap/src/test/java/.../CleanArchitectureTest.java
|
||||
@ArchTest
|
||||
static final ArchRule NO_PROBLEM_DETAIL_USAGE =
|
||||
noClasses()
|
||||
.that()
|
||||
.resideInAPackage("dev.caskeleton..")
|
||||
.should()
|
||||
.dependOnClassesThat()
|
||||
.haveFullyQualifiedName("org.springframework.http.ProblemDetail");
|
||||
```
|
||||
|
||||
```yaml
|
||||
# app-bootstrap/src/main/resources/application.yml
|
||||
spring:
|
||||
mvc:
|
||||
problemdetails:
|
||||
enabled: false
|
||||
```
|
||||
|
||||
이 두 가지는 "개발자가 조심하자" 수준의 약속이 아닙니다. ArchUnit 룰은 빌드 단계에서, 설정값은 회귀 테스트로 각각 강제됩니다.
|
||||
|
||||
---
|
||||
|
||||
## 5. Transport 실패도 같은 봉투에 담기
|
||||
|
||||
Validation 실패만 envelope으로 감싸는 건 절반의 해결책입니다. 실제로는 요청이 컨트롤러에 도달하기도 전에 실패하는 경우가 많습니다.
|
||||
|
||||
- 요청 본문이 너무 크면 **413**
|
||||
- 지원하지 않는 `Content-Type`이면 **415**
|
||||
- 지원하지 않는 HTTP method면 **405**
|
||||
|
||||
ca-tmpl은 이런 실패들을 전부 `VALIDATION_FAILED` 하나로 뭉개지 않고, `PAYLOAD_TOO_LARGE`, `UNSUPPORTED_MEDIA_TYPE`, `METHOD_NOT_ALLOWED`처럼 **구분된 code와 status**로 같은 envelope에 담습니다.
|
||||
|
||||
여기서 한 가지 주의할 점이 있습니다. Spring MVC의 `ResponseEntityExceptionHandler`가 이미 처리하고 있는 예외 계열을 `@ExceptionHandler`로 다시 등록하면 프레임워크의 기본 처리 흐름과 충돌할 수 있습니다. 그래서 이런 경우엔 새로 핸들러를 추가하는 대신 **protected override**를 사용해, Spring MVC가 가진 흐름 위에서 응답 body만 envelope 모양으로 바꿉니다. 예를 들어 405 응답에서는 `Allow` 헤더도 그대로 보존합니다.
|
||||
|
||||
즉, 바깥 모양은 통일하되 HTTP가 원래 가진 의미까지 지워버리지는 않는다는 원칙입니다.
|
||||
|
||||
현재까지 **413, 406, 415, 405(+`Allow`), 412**가 이 방식으로 테스트를 통과했습니다.
|
||||
|
||||
---
|
||||
|
||||
## 6. 아직은 말할 수 없는 것들
|
||||
|
||||
이 글이 과장되지 않도록, 지금 시점에서 확실한 것과 아닌 것을 분리해 둡니다.
|
||||
|
||||
**확실한 것 (로컬/개발 검증 완료)**
|
||||
- `Envelope`, `ApiError`, `ResponseMeta` 등 핵심 타입이 코드로 존재하고 컴파일됩니다.
|
||||
- `ProblemDetail`은 ArchUnit 룰과 설정값으로 금지·비활성화되어 있습니다.
|
||||
- `./gradlew check`가 통과했고, 위에서 언급한 transport failure row들이 테스트로 검증됐습니다.
|
||||
|
||||
**아직 아닌 것**
|
||||
- 운영 환경 배포 및 실제 production metric을 통한 검증은 이루어지지 않았습니다.
|
||||
- `Retry-After` 헤더 발행은 아직 계획 단계입니다.
|
||||
- 5xx 오류를 트레이싱 span에 ERROR로 기록하는 부분도 계획 단계입니다.
|
||||
- business rule violation을 어떤 category와 details로 세분화할지는 이 설계의 범위 밖이며, 별도 트랙에서 다룹니다.
|
||||
|
||||
---
|
||||
|
||||
## 마무리
|
||||
|
||||
API error envelope 설계는 예쁜 JSON을 만드는 작업이 아니라, **실패를 다루는 책임을 어디에 둘 것인지 정하는 작업**에 가깝습니다.
|
||||
|
||||
ca-tmpl은 `ProblemDetail`, JSON:API errors, Google `rpc.Status` 같은 여러 선택지를 검토한 뒤, 성공/실패 응답의 대칭성, 클라이언트가 안정적으로 분기할 수 있는 error code, 운영자가 볼 수 있는 category와 meta, 그리고 예외가 그대로 새어 나가지 않는 일관된 실패 응답 경로를 우선순위로 두고 custom envelope을 선택했습니다.
|
||||
|
||||
그리고 그 선택을 문서에만 남기지 않고, 테스트와 ArchUnit 룰로 붙잡아 뒀습니다. 다음 글에서는 Spring Security 필터 레이어의 예외를 같은 envelope에 태우는 과정을 다룰 예정입니다.
|
||||
Reference in New Issue
Block a user