fix: 하네스 제거 및 keycloak 문서 보강

This commit is contained in:
DongHyeonka
2026-07-25 12:53:13 +09:00
parent 6c53ded9cb
commit d71669eb59
2329 changed files with 138239 additions and 172816 deletions
@@ -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에 태우는 과정을 다룰 예정입니다.
@@ -1 +0,0 @@
../../vault/40-publish/publish-blog/api-evolution-schema-blog.md
@@ -0,0 +1,201 @@
---
title: API Evolution은 버전 번호가 아니라 계약의 문제다
source_type: blog
status: draft
confidence: unknown
tags: [blog, ca-tmpl, api-design, spring-boot, api-contract, semver]
related_projects: [ca-tmpl]
last_reviewed:
canonical_sources: []
audience: backend-engineer
target_publish:
status_label: draft
---
# API Evolution은 버전 번호가 아니라 계약의 문제다
> `/v1`을 어디에 붙일지 고민하기 전에, API가 실제로 무엇을 약속하고 있는지부터 정리한 글입니다.
## TL;DR
- API versioning은 보통 `/v1` prefix 하나로 끝난다고 생각하기 쉽지만, 실제로는 pagination, 캐시 정책, conditional request, 직렬화 방식까지 전부 client와의 계약(contract surface)입니다.
- ca-tmpl은 이 계약을 세 갈래로 나눴습니다: **① API contract baseline**(구현·검증 완료), **② compatibility/deprecation 정책**(아직 문서 계약), **③ schema/serialization**(출력측만 검증 완료).
- 세 갈래의 **검증 수준이 다르다는 걸 숨기지 않는 것**이 이 글의 핵심입니다. "설계했다"와 "운영에서 검증했다"는 다른 문장입니다.
---
## 1. 버전 번호 하나로는 부족한 이유
API를 처음 설계할 때 가장 먼저 떠오르는 질문은 보통 이거죠. *"버전을 URL에 넣을까, 헤더로 받을까, 날짜 기반으로 갈까?"*
그런데 API가 한 번 배포되고 나면, 그 순간부터 client와의 **약속**이 시작됩니다. 응답 필드를 하나 빼는 일, enum 값을 줄이는 일, pagination 상한을 바꾸는 일, 날짜를 숫자에서 문자열로 바꾸는 일 — 이 모두가 client 입장에서는 "변화"입니다.
그래서 API evolution은 "버전을 어떻게 붙일까"보다 훨씬 넓은 문제입니다. 더 정확히 말하면, **API surface 전체가 언제 어떻게 변할 수 있고, 그 변화가 client를 언제 깨뜨리는지를 미리 정해두는 계약**입니다.
ca-tmpl은 이 문제를 세 갈래로 나눠서 다룹니다.
| 갈래 | 내용 | 검증 수준 |
|---|---|---|
| ① API contract baseline | `/v1`, pagination, ETag, 캐시 헤더, OpenAPI, LRO, batch endpoint | ✅ 코드 구현 + 로컬 테스트 검증 |
| ② compatibility/deprecation | breaking change 기준, migration window, `Sunset`/`Deprecation` 헤더 | 📝 문서 계약 (구현 아직) |
| ③ schema/serialization | 날짜·decimal 직렬화 형식 | ✅ 출력측만 검증 (입력측은 별도 트랙) |
이 표를 먼저 보여드리는 이유가 있습니다. 이 글에서 "구현됐다"와 "설계만 했다"를 섞어서 말하면, 읽기는 편해도 나중에 사실관계가 흐트러지거든요. 그래서 갈래별로 나눠서 설명하겠습니다.
---
## 2. ① 이미 구현되고 검증된 것들
### `/v1` — 단순한 prefix가 아니라 명시적 결정
`/v1`을 붙이는 건 URL을 예쁘게 만드는 선택이 아니라, **API의 major version을 route surface에 드러내겠다는 결정**입니다. ca-tmpl은 설정값이 비어있거나 `/`로 시작하지 않으면 자동으로 보정합니다.
```java
@ConfigurationProperties(prefix = "ca-skeleton.presentation")
public record PresentationSettings(String apiBasePath) {
public PresentationSettings {
if (apiBasePath == null) {
apiBasePath = "";
} else if (!apiBasePath.isEmpty() && !apiBasePath.startsWith("/")) {
apiBasePath = "/" + apiBasePath;
}
}
}
```
`/v1/probe`는 열리고 `/probe`는 열리지 않는다는 것까지 테스트로 고정되어 있습니다.
### Pagination — 표준이 아니라 프로젝트가 선택한 제한
client가 `size=100000` 같은 값을 자유롭게 보낼 수 있으면 서버 리소스가 쉽게 압박받습니다. ca-tmpl은 기본 size를 20으로 두고, 1~100 사이만 허용합니다.
```java
public record PageParams(int page, int size) {
public static final int DEFAULT_SIZE = 20;
public static final int MIN_SIZE = 1;
public static final int MAX_SIZE = 100;
public static final int DEEP_OFFSET_THRESHOLD = 10000;
public boolean isDeepOffset() {
return page > DEEP_OFFSET_THRESHOLD;
}
}
```
여기서 **100과 10000이라는 숫자는 표준이 정한 값이 아니라는 점**이 중요합니다. DoS 방어와 cursor pagination 유도를 위한 프로젝트 고유의 선택이에요. "표준이라서 100"이 아니라 "ca-tmpl이 skeleton 기본값으로 고른 제한"이라고 말하는 게 정확합니다.
### Conditional Request — ETag로 "내가 아는 버전과 같을 때만" 처리하기
conditional request는 client가 "내가 알고 있는 이전 상태와 같을 때만 처리해줘" 또는 "내가 가진 버전과 같으면 body를 다시 안 보내도 돼"라고 말하는 HTTP 메커니즘입니다. ca-tmpl은 엔티티의 optimistic lock 버전에서 ETag를 만들어, 읽기에서는 `If-None-Match`로 304를, 쓰기에서는 `If-Match` 불일치로 412를 반환합니다.
```java
public static String weakFromVersion(long version) {
return "W/\"" + version + "\"";
}
```
다만 여기엔 명확한 경계가 있습니다. 이 ETag 비교는 RFC 9110이 정의하는 엄격한 strong comparison 구현이 아니라, `W/` 마커와 따옴표를 벗겨 값을 비교하는 **lenient한 구현**이에요. optimistic lock을 이해하기 쉽게 연결한 것이지, production급 strong ETag semantics를 전부 구현했다고 말할 수는 없습니다.
### 캐시 정책 — 기본은 닫고, 필요한 곳만 연다
인증된 API에서 캐시를 기본으로 열어두면 proxy나 브라우저 캐시가 민감한 응답을 붙잡을 수 있습니다. 그래서 모든 응답에 `no-store`를 먼저 박아둡니다.
```java
@Override
protected void doFilterInternal(
HttpServletRequest request, HttpServletResponse response, FilterChain chain)
throws ServletException, IOException {
response.setHeader(ApiHeaders.CACHE_CONTROL, "no-store");
response.setHeader(ApiHeaders.VARY, "Accept, Accept-Encoding, Authorization");
chain.doFilter(request, response);
}
```
캐시 가능한 endpoint가 필요하면 명시적으로 opt-in해야 하는 구조입니다. 기본값을 안전한 쪽으로 닫아두고, 예외를 여는 방식이에요.
### 그 외 — OpenAPI, 비동기 작업, 배치
- **OpenAPI**: `/v3/api-docs`가 정상적으로 열리는 producer 수준까지 구현
- **Long-running operation**: `POST /worklogs:export`가 202 Accepted + `Location` 헤더로 polling URL을 돌려주는 흐름이 샘플로 구현됨
- **Batch endpoint**: `POST /worklogs:batchCreate`에서 단일 트랜잭션 원자성과 size cap을 검증
여기까지가 **"코드로 구현했고 로컬 검증했다"**고 자신 있게 말할 수 있는 범위입니다.
---
## 3. ② 아직은 문서 계약인 것들 — Compatibility & Deprecation
여기서부터는 톤이 달라집니다. ca-tmpl은 breaking change의 기준을 정해뒀습니다: 응답 필드 제거, 응답 필드 의미 변화, 필수 요청 필드 추가, enum 값 제거·의미 변화·축소, 기본값 변경 — 이런 것들을 breaking change로 분류하고, **90일(public) / 30일(internal) migration window**를 두기로 결정했습니다.
또한 두 개의 헤더를 함께 보내기로 했습니다.
- **`Sunset`**: 언제 사라질지 알려주는 날짜 신호
- **`Deprecation`**: 지금 이미 deprecated 상태인지 알려주는 신호
둘 중 하나만 보내면 정보가 반쪽이 됩니다. 그래서 항상 함께 보내기로 설계했습니다. 여기에 사람이 읽을 migration guide로 연결하는 `Link rel="deprecation"` / `Link rel="sunset"`도 문서에 잡혀 있습니다.
**하지만 이건 아직 구현이 아닙니다.** response interceptor나 release gate로 코드에 내려온 상태가 아니고, 실제로 API를 deprecated 상태로 운영해본 적도, 외부 client가 90일 안에 migration을 끝냈는지 검증해본 적도 없습니다.
그래서 이 갈래는 정확히 이렇게만 말할 수 있습니다: *"설계했다", "문서 계약으로 정했다", "표준과 사례를 비교해서 이 정책을 택했다"*. "운영에서 검증했다"는 표현은 아직 쓸 수 없습니다.
---
## 4. ③ 출력측만 검증된 것들 — Schema & Serialization
이 갈래는 조금 다릅니다. 여기서는 **출력측 일부가 실제로 구현되고 검증됐습니다.**
Jackson 설정에서 두 가지를 명시적으로 고정했습니다.
```java
// WRITE_DATES_AS_TIMESTAMPS=false
// → OffsetDateTime, LocalDate가 숫자·배열이 아니라 ISO-8601 문자열로 나감
// WRITE_BIGDECIMAL_AS_PLAIN=true
// → 큰 BigDecimal이 scientific notation으로 나가지 않음
```
흥미로운 점은, 이 설정들이 현재 Spring Boot 기본값과 크게 다르지 않다는 것입니다. 그런데도 명시적으로 pin을 둔 이유는 **default에 기대면 나중에 default가 바뀌었을 때 알아채기 어렵기 때문**입니다. 그래서 설정 바인딩만 확인하는 게 아니라, 실제로 배선된 `ObjectMapper`로 직렬화까지 해보는 테스트를 둡니다.
```java
String dateTimeJson = mapper.writeValueAsString(utc);
assertThat(dateTimeJson).isEqualTo("\"1985-04-12T23:20:50.52Z\"");
String scaledJson = mapper.writeValueAsString(new BigDecimal("1.10"));
assertThat(scaledJson).isEqualTo("1.10");
```
`JavaTimeModule`이 빠져서 날짜가 배열로 새는 회귀도 이 테스트가 잡아낼 수 있습니다.
`BigDecimal`에는 정적 차단도 걸어뒀습니다. Java에서 `new BigDecimal(0.1)`은 사람이 기대하는 0.1이 아니라 binary floating-point 오차를 품은 값을 만들 수 있기 때문입니다.
```java
@ArchTest
static final ArchRule NO_BIGDECIMAL_DOUBLE_CONSTRUCTOR =
noClasses()
.that()
.resideInAPackage("dev.caskeleton..")
.should()
.callConstructor(BigDecimal.class, double.class)
.orShould()
.callConstructor(BigDecimal.class, float.class);
```
이건 "조심하자"는 약속이 아니라 **빌드가 깨지는 계약**입니다.
다만 여기도 전부 끝난 건 아닙니다. 입력측 strict deserialization, null/empty/missing 3-state 처리, API별 money 값을 문자열로 보낼지 숫자로 보낼지 선택하는 정책, OpenAPI drift release gate, Avro 호환성 자동 검사는 각각 별도 트랙이거나 아직 계획 단계입니다. 특히 샘플 도메인에 money 필드가 없어서, money string serialization 코드 예제도 이 글엔 없습니다.
---
## 5. 정리 — 넓게 보되, 등급을 섞지 않기
이 글에서 가장 중요하게 지키고 싶었던 건 하나입니다. **API evolution을 넓게 다루되, 구현 등급을 섞지 않는 것.**
- `/v1`, pagination, ETag, 캐시 헤더, OpenAPI producer, LRO, batch endpoint → **로컬 검증된 구현**으로 말할 수 있습니다.
- deprecation 정책과 migration window → **문서 계약**으로만 말해야 합니다.
- serialization 출력 pin과 BigDecimal guard → **로컬 검증된 구현**으로 말할 수 있습니다.
- strong ETag, idempotency replay, OpenAPI release gate, 실제 운영 deprecation 경험 → **아직 말할 수 없습니다.**
좋은 API 설계는 "예제 endpoint가 잘 동작한다"에서 끝나지 않습니다. 나중에 API가 바뀔 때 어떤 변화가 안전하고, 어떤 변화가 client를 깨뜨리며, 어떤 값은 default에 기대지 않고 명시적으로 고정해야 하는지까지 답할 수 있어야 합니다.
ca-tmpl의 API Evolution & Schema 결정은 그 방향을 잡아둔 문서입니다. 일부는 이미 코드와 테스트로 내려왔고, 일부는 앞으로 구현해야 할 계약으로 남아 있습니다. 이 둘을 구분해서 말할 수 있을 때, 비로소 이 주제를 내 프로젝트 경험으로 이야기할 수 있습니다.
@@ -1 +0,0 @@
../../vault/40-publish/publish-blog/boundary-validation-mapping-blog.md
@@ -0,0 +1,193 @@
---
title: 입력 경계에서 검증과 매핑 책임을 분리하기
source_type: blog
status: draft
confidence: unknown
tags: [blog, ca-tmpl, validation, mapper, bean-validation, anti-corruption-layer]
related_projects: [ca-tmpl]
last_reviewed:
canonical_sources: []
audience: backend-engineer
target_publish:
status_label: draft
---
# 입력 경계에서 검증과 매핑 책임을 분리하기
> `@Valid` 하나로 끝날 것 같던 입력 검증이, 실제로는 최소 세 개의 서로 다른 책임으로 쪼개져야 하는 이유를 정리한 글입니다.
## TL;DR
- Request DTO가 application layer까지 새어 들어가거나, domain/JPA entity가 response로 그대로 나가거나, PATCH가 기존 값을 조용히 덮어쓰는 문제는 전부 **입력 경계가 흐려질 때** 생깁니다.
- ca-tmpl은 이 경계를 request parsing, mapper, PATCH 3-state, polymorphic deserialization, DTO/domain/persistence 분리, 이렇게 다섯 개 책임으로 나눴습니다.
- 중요한 규칙은 컨벤션 문서가 아니라 **ArchUnit rule로 build-time에 강제**했습니다. 사람이 리뷰에서 놓쳐도 빌드가 잡아냅니다.
- 검증 범위는 로컬/개발 환경까지입니다. 운영 배포, 실 DB 통합, 실 외부 HTTP 통합은 아직입니다.
---
## 1. 경계가 흐려지면 생기는 문제들
입력 검증은 처음엔 controller에 `@Valid` 하나 붙이면 끝나는 문제처럼 보입니다. Request body를 DTO로 받고, Bean Validation으로 검사하고, service로 넘기면 충분해 보이죠. 그런데 프로젝트가 커질수록 이 경계는 생각보다 쉽게 무너집니다.
- **Request DTO가 application layer까지 새어 들어가면**, application core가 web framework의 모양을 알게 됩니다.
- **Domain entity나 JPA entity가 controller response로 그대로 나가면**, 내부 모델이 그 자체로 외부 API 계약이 되어버립니다.
- **PATCH에서는 더 미묘한 문제가 생깁니다.** 필드가 아예 빠진 건지, 명시적으로 `null`을 보낸 건지, 새 값을 보낸 건지 구분하지 못하면 기존 값을 조용히 덮어쓰는 버그로 이어집니다.
ca-tmpl은 이 문제를 "입력 검증을 어디서 하느냐" 하나로 뭉치지 않았습니다. **request parsing, syntax validation, mapper, domain/application command, response shaping, outbound ACL**을 서로 다른 책임으로 나누고, 그중 중요한 경계는 ArchUnit rule과 wire-level 테스트로 고정했습니다.
---
## 2. Validation 실패와 Mapping 실패는 다른 문제다
가장 먼저 나눈 것은 **"형식이 틀렸다"**와 **"의미가 성립하지 않는다"**의 구분입니다.
Spring MVC가 request body를 파싱하지 못하거나 Bean Validation을 통과하지 못하면 `VALIDATION_FAILED`입니다. `MethodArgumentNotValidException`, `HttpMessageNotReadableException`, `ConstraintViolationException` 계열이 여기 속합니다.
반면 payload는 구조적으로 잘 들어왔는데, mapper가 의미상 domain command로 바꿀 수 없는 경우는 다릅니다.
```java
// shared-contract/.../MappingException.java
public class MappingException extends RuntimeException {
public MappingException(String message) {
super(message);
}
public MappingException(String message, Throwable cause) {
super(message, cause);
}
}
```
```java
// adapter-web/.../GlobalExceptionHandler.java
@ExceptionHandler(MappingException.class)
public ResponseEntity<Envelope<Void>> handleMapping(MappingException ex) {
return ErrorResponseFactory.envelope(OperationalError.MAPPING_FAILED, ex.getMessage(), null);
}
```
이 둘을 분리하는 이유는 **실패의 원인이 다르기 때문**입니다. validation 실패는 보통 client가 입력 형식을 잘못 보냈다는 뜻입니다. mapping 실패는 한 단계 더 안쪽입니다. 예를 들어 링크 URI가 문자열 형식은 맞지만 프로젝트가 받아들일 수 없는 형태라면, mapper가 그걸 domain command로 바꾸지 못합니다. 두 경우를 전부 "bad request"로 뭉개면 운영 분류와 client 디버깅이 모두 어려워집니다.
---
## 3. PATCH의 세 번째 상태 — absent, null, value
PATCH를 다뤄본 사람이라면 한 번쯤 겪는 문제가 있습니다. 일반 update에서는 `null`을 "값을 지운다"로 볼 수 있지만, PATCH에서는 **필드가 빠진 상태**와 **필드가 명시적으로 `null`인 상태**가 다릅니다. 빠졌다는 건 "건드리지 마"라는 뜻이고, 명시적 `null`은 "비워달라"는 뜻일 수 있습니다.
ca-tmpl은 web adapter에서 Jackson의 `JsonNullable<T>`을 받고, application으로 넘기기 전에 **Jackson을 전혀 모르는 타입인 `Patch<T>`**로 변환합니다.
```java
// shared-contract/.../Patch.java
public final class Patch<T> {
public static <T> Patch<T> absent() { ... }
public static <T> Patch<T> ofNull() { ... }
public static <T> Patch<T> of(T value) { ... }
public boolean isAbsent() { return !present; }
public boolean isExplicitNull() { return present && value == null; }
public boolean hasValue() { return present && value != null; }
}
```
```java
// sample-portfolio/.../UpdateWorkLogRequest.java
private static <T> Patch<T> toPatch(JsonNullable<T> field) {
if (field == null || !field.isPresent()) {
return Patch.absent();
}
return field.get() == null ? Patch.ofNull() : Patch.of(field.get());
}
```
이렇게 나누면 **application-core는 Jackson의 존재 자체를 모릅니다.** application은 `Patch.absent()`, `Patch.ofNull()`, `Patch.of(value)` 세 가지만 보고 의도를 판단하면 됩니다. web adapter는 wire 표현을 domain/application이 이해할 수 있는 작은 값 객체로 번역하는 역할만 맡습니다. 이게 정확히 DTO와 command 사이 mapper의 책임입니다.
---
## 4. Polymorphic Deserialization은 allowlist로만
다형성 역직렬화도 경계 문제입니다. Jackson의 default typing은 임의의 subtype을 받아들일 수 있는데, 이건 과거 CVE-2019-14379 같은 gadget chain 취약점과 연결된 전례가 있습니다.
ca-tmpl은 `ObjectMapper.enableDefaultTyping()` 호출과 `LaissezFaireSubTypeValidator` 의존을 ArchUnit으로 원천 차단합니다. 대신 명시적인 이름 기반 매핑만 허용합니다.
```java
// sample-portfolio/.../SamplePolymorphicRequest.java
@JsonTypeInfo(use = JsonTypeInfo.Id.NAME, property = "kind")
@JsonSubTypes({
@JsonSubTypes.Type(value = SamplePolymorphicRequest.Text.class, name = "text"),
@JsonSubTypes.Type(value = SamplePolymorphicRequest.Image.class, name = "image")
})
public sealed interface SamplePolymorphicRequest
permits SamplePolymorphicRequest.Text, SamplePolymorphicRequest.Image {}
```
즉 "다형성을 아예 쓰지 않는다"가 아니라, **허용된 이름과 타입만 받게 하는 것**입니다.
---
## 5. 경계를 문서가 아니라 빌드로 강제하기
DTO/domain/persistence 사이의 경계는 정적 규칙으로 고정했습니다.
- controller의 public 메서드가 domain entity, JPA entity, repository 타입을 반환하지 못하게 막고
- application의 public 메서드가 web DTO를 파라미터로 받지 못하게 막고
- RFC 7807 `ProblemDetail` import를 막고 (이전 글에서 다룬 것과 같은 이유입니다)
- `application/merge-patch+json` media type 문자열 사용도 막고
- outbound adapter의 public 메서드가 raw external response 타입을 밖으로 흘리지 못하게 하는 ACL 규칙도 둡니다
```java
// app-bootstrap/.../CleanArchitectureTest.java
@ArchTest
static final ArchRule APPLICATION_METHODS_DO_NOT_ACCEPT_WEB_DTOS =
methods()
.that()
.areDeclaredInClassesThat()
.resideInAPackage("..application..")
.and()
.arePublic()
.should()
.notHaveRawParameterTypes(...);
```
여기서 ArchUnit은 단순한 "아키텍처 다이어그램 검사기"가 아닙니다. 사람이 코드 리뷰에서 놓치기 쉬운 경계 위반을 **빌드 타임에 잡아내는 fitness function**에 가깝습니다.
특히 ca-tmpl은 **violations-as-data** 방식을 씁니다. 의도적으로 잘못된 fixture 코드를 만들어서, 규칙이 실제로 그 위반을 잡아내는지 테스트합니다. 이건 "규칙은 있는데 사실 아무것도 검사하지 않아서 항상 통과하는" vacuous pass를 줄이는 장치입니다.
Outbound 방향의 예시도 하나 보겠습니다. 외부 응답 raw 타입을 도메인으로 정리하는 mapper는 이렇게 생겼습니다.
```java
// sample-portfolio/.../RepoStatsAclMapper.java
static RepoStats toDomain(RawRepoStatsResponse raw) {
if (raw == null || raw.fullName() == null || raw.fullName().isBlank()) {
throw new MappingException("repo provider: missing 'fullName'");
}
return new RepoStats(
raw.fullName().toLowerCase(), Math.max(0, raw.stargazers()), raw.pushedAt());
}
```
외부 서비스가 이상한 응답을 주더라도, 그 raw 타입이 domain까지 새어 들어가지 않고 이 지점에서 정리되거나 `MappingException`으로 명확하게 실패합니다.
---
## 6. 지금까지 검증된 것, 아직인 것
이 계약이 모든 걸 해결한 건 아닙니다. 현재 검증 범위는 **로컬/개발 환경**입니다.
**검증된 것**
- `WorkLogControllerWireTest`, unit/contract 테스트
- virtual-thread MDC 테스트
- `CleanArchitectureTest`와 violation fixture
**아직 아닌 것**
- 운영 배포 검증
- 실 DB(Testcontainers 등) 통합 테스트
- 실 외부 HTTP(WireMock 등)를 통한 outbound ACL 검증
- OpenAPI `oneOf` response shape 명세
---
## 마무리
ca-tmpl의 boundary validation/mapping 결정은 "controller에 `@Valid` 붙였다"보다 훨씬 넓은 계약입니다. request DTO가 어디까지 들어갈 수 있는지, mapper 실패는 어떤 error code가 되는지, PATCH의 세 상태를 어떻게 보존하는지, 외부 응답의 raw 타입이 domain으로 어떻게 정리되는지, 그리고 그 규칙을 어떤 ArchUnit rule로 고정하는지를 함께 다룹니다.
좋은 경계 설계는 한 번의 아름다운 mapper 코드가 아니라, **다음 사람이 무심코 경계를 깨뜨려도 빌드가 알려주는 구조**에 가깝습니다.
@@ -1 +0,0 @@
../../vault/40-publish/publish-blog/ci-supply-chain-blog.md
+138
View File
@@ -0,0 +1,138 @@
---
title: CI와 Supply Chain을 Skeleton 계약으로 묶기
source_type: blog
status: draft
confidence: unknown
tags: [blog, ca-tmpl, ci-cd, gradle, supply-chain, reproducible-builds]
related_projects: [ca-tmpl]
last_reviewed:
canonical_sources: []
audience: backend-engineer
target_publish:
status_label: draft
---
# CI와 Supply Chain을 Skeleton 계약으로 묶기
> CI에 도구를 붙이는 건 쉽습니다. 어려운 건 "이게 실패하면 누가 책임지는가"를 정하는 일입니다.
## TL;DR
- CI 파이프라인에 formatter, linter, scanner, SBOM, signing을 순서대로 추가하는 건 어렵지 않지만, **어떤 gate가 release를 막는지, 실패하면 누가 고치는지**를 정해두지 않으면 CI는 금방 장식이 됩니다.
- ca-tmpl은 이걸 `.github/ci-gate-matrix.yml`이라는 **gate ownership matrix**로 정리하고, 스크립트로 문서와 실제 workflow가 어긋나지 않는지 검사합니다.
- Supply chain(SBOM, Cosign, SLSA)도 마찬가지로 workflow와 검증 스크립트가 **repo-level에서** 존재합니다. 다만 **실제 hosted CI에서 release를 발행하고 Rekor/GHCR로 검증한 경험은 아직 없습니다** — 이 경계를 이 글에서 분명히 하려 합니다.
---
## 1. CI는 도구 목록이 아니라 release 계약이다
CI를 설계할 때 흔한 실수는 "무엇을 실행할지"만 정하는 것입니다. formatter, linter, unit test, integration test, vulnerability scanner, SBOM, signing, provenance — 순서대로 추가하면 화면은 그럴듯해 보입니다.
하지만 실제로 더 중요한 질문은 따로 있습니다.
- 이 검사가 실패하면 **release가 막히는가?**
- **누가** 이 정책을 소유하는가?
- 문서에 적힌 gate가 **실제 workflow에도 남아 있는가?**
이 세 질문에 답하지 못하면, CI는 시간이 지날수록 "돌아는 가는데 아무도 그 의미를 모르는" 상태가 됩니다.
---
## 2. Gate Matrix — 문서가 아니라 검사 대상
ca-tmpl의 `.github/ci-gate-matrix.yml`은 바로 이 질문에 답하기 위한 파일입니다. 각 gate는 다음 정보를 가집니다.
```yaml
gates:
- id: architecture-test
release_blocking: true
owner_branch: feature-architecture-enforcement-rules
mechanism: contract-test
ref: app-bootstrap/src/test/java/dev/caskeleton/bootstrap/architecture/CleanArchitectureTest.java
runs_in: ci-quality-gates
```
- **release_blocking**: 이 검사가 실패하면 release가 막히는가
- **owner_branch**: 실패했을 때 누가 고쳐야 하는가
- **mechanism / ref**: 실제로 무엇으로 구현되어 있는가
여기서 중요한 지점은, **이 matrix가 그냥 참고 문서로 끝나지 않는다는 것**입니다. `verify-gate-matrix.sh`가 matrix의 각 row를 읽고, `mechanism`별로 실제 존재 여부를 확인합니다.
```bash
# Cross-checks every row of .github/ci-gate-matrix.yml against reality:
# gradle-custom-task -> a tasks.register('<ref>') exists
# contract-test -> the <ref> test-class file exists under src/
# workflow-job -> the <ref> job id exists in .github/workflows/<runs_in>.yml
```
`gradle-custom-task`라고 적혀 있으면 실제로 `tasks.register('<ref>')`가 존재해야 하고, `contract-test`라고 적혀 있으면 그 test 클래스 파일이 실제로 있어야 하고, `workflow-job`이라고 적혀 있으면 workflow 안에 그 job id가 실제로 존재해야 합니다.
이렇게 하면 **"문서에는 gate가 있는데 실제 CI에서는 빠진 상태"**를 줄일 수 있습니다. 흔히 일어나는 일이죠 — 누군가 workflow를 리팩터링하면서 job 이름을 바꿨는데, 문서는 그대로 남아있는 경우요.
---
## 3. Gradle Baseline — Entropy를 줄이는 것과 증명하는 것은 다르다
Gradle 쪽 baseline도 여러 층으로 나뉩니다. `src/build.gradle`은 Spotless, Checkstyle, SpotBugs, ErrorProne을 subproject에 적용하고, dependency locking을 strict mode로 켭니다.
```groovy
dependencyLocking {
lockAllConfigurations()
lockMode = LockMode.STRICT
}
tasks.withType(AbstractArchiveTask).configureEach {
preserveFileTimestamps = false
reproducibleFileOrder = true
}
```
archive task는 timestamp, file order, permission을 고정해서 build artifact가 host 환경에 따라 덜 흔들리게 만듭니다.
여기서 짚어야 할 경계가 있습니다. **이건 production artifact reproducibility를 완전히 증명한다는 뜻이 아닙니다.** skeleton 단계에서 entropy source(빌드할 때마다 달라질 수 있는 요인)를 줄이는 baseline일 뿐이에요. "재현 가능한 빌드를 만들었다"와 "재현 가능한 빌드의 조건 몇 가지를 미리 고정해뒀다"는 다른 문장입니다.
---
## 4. Supply Chain — 증거를 digest 중심으로 엮기
Supply chain 쪽은 release evidence를 digest 중심으로 묶으려는 방향입니다. `.github/workflows/build-release-supply-chain.yml`에는 SBOM generation, Trivy image scan, Cosign sign/attest, SLSA provenance, verification, promotion step이 순서대로 들어 있습니다.
`.github/scripts/verify-supply-chain-contract.sh`는 workflow와 policy 파일에 필요한 문자열과 job wiring이 실제로 남아 있는지 확인합니다.
```bash
require_fixed "${workflow}" 'cosign sign --yes' 'D6 keyless image signature'
require_fixed "${workflow}" 'cosign attest --yes' 'D4 digest-bound SBOM attestation'
require_fixed "${workflow}" '--certificate-identity' 'D12 signer identity verification'
require_fixed "${workflow}" '--certificate-oidc-issuer' 'D12 OIDC issuer verification'
require_fixed "${workflow}" 'generator_container_slsa3.yml@v2.1.0' 'D7 isolated SLSA generator'
```
Cosign의 keyless 서명, digest에 바인딩된 SBOM attestation, signer identity 검증, OIDC issuer 검증, 그리고 격리된 SLSA generator 사용까지 — 이런 조건들이 workflow에서 실제로 지켜지고 있는지를 스크립트가 확인합니다.
### 여기서 가장 중요한 경계선
**ca-tmpl에 workflow와 검증 스크립트가 존재한다는 사실**과, **실제 release artifact가 public registry에 올라갔고 Rekor/GHCR evidence로 검증됐다는 사실**은 다릅니다.
project canonical은 후자를 확인하지 않았다고 명시적으로 밝힙니다. 그래서 이 글은 **"supply-chain release를 운영했다"가 아니라 "supply-chain release contract를 repo-level workflow와 script로 고정했다"**까지만 말할 수 있습니다.
이 구분이 왜 중요하냐면, "Cosign이랑 SLSA를 붙였어요"라는 말만 들으면 이미 실제 release에서 검증까지 끝난 것처럼 들리기 쉽거든요. 하지만 workflow 파일이 존재하는 것과, 그 workflow가 실제로 몇 번 돌아서 서명된 아티팩트가 검증된 것은 완전히 다른 단계의 증거입니다.
---
## 5. DX — 진입점을 하나로 줄이기
DX(Developer Experience)도 같은 관점으로 다룹니다. `./gradlew bootstrap` 명령 하나가 compile, Docker preflight, dependency/migration/startup, sample contract, HTTP smoke test까지를 하나의 진입점으로 묶습니다.
이 명령이 **모든 OS와 CI 환경을 보장한다는 뜻은 아닙니다.** 대신 새 프로젝트를 받은 사람이 "무엇부터 실행해야 하나"를 덜 고민하게 만들고, 실패 지점을 단계별로 나눠서 보여주려는 목적입니다.
---
## 6. 정리 — "도구를 썼다"가 아니라 "어떤 증거가 release를 통과시키는가"
결국 ca-tmpl의 DevOps baseline은 "이 도구를 썼다"보다 **"어떤 증거가 release를 통과시키는가"**에 가깝습니다.
- gate matrix가 실제 workflow와 어긋나지 않아야 하고
- dependency lock이 조용히 풀리면 안 되며
- vulnerability suppression은 사유와 만료일 없이 남아있으면 안 됩니다
이 정도가 **local/repo-level에서 검증된 범위**입니다. 실제 hosted CI run, release publication, live Cosign/Rekor 검증은 별도의 근거가 쌓인 뒤에만 말할 수 있는 다음 단계로 남겨둡니다.
@@ -1 +0,0 @@
../../vault/40-publish/publish-blog/clean-architecture-package-layout-blog.md
@@ -0,0 +1,197 @@
---
title: Clean Architecture를 패키지 구조로 강제하기
source_type: blog
status: draft
confidence: unknown
tags: [blog, ca-tmpl, architecture, archunit, clean-architecture, package-structure]
related_projects: [ca-tmpl]
last_reviewed:
canonical_sources: []
audience: backend-engineer
target_publish:
status_label: draft
---
# Clean Architecture를 패키지 구조로 강제하기
> 그림으로 그린 계층 구조가 6개월 뒤에도 그대로 지켜지려면, 문서가 아니라 빌드가 그걸 지켜줘야 합니다.
## TL;DR
- Clean Architecture는 그림으로 보면 단순하지만, 시간이 지나면 controller가 repository를 직접 부르고 application이 web DTO를 받는 식으로 흐트러지기 쉽습니다.
- ca-tmpl은 이 경계를 **두 겹**으로 강제합니다: Gradle 모듈 의존성(1차 경계) + ArchUnit import 규칙(2차 경계).
- `shared-contract`는 "아무 공통 코드나 넣는 곳"이 아니라 **운영 계약만 허용하는 제한된 통로**로 정의했습니다.
- 검증 범위는 로컬/개발까지입니다. 운영에서 이 구조가 유지보수 비용을 얼마나 줄였는지는 아직 측정하지 않았습니다.
---
## 1. 그림은 쉽지만, 코드는 시간이 지나면 배신한다
Clean Architecture를 그림으로 그리면 단순합니다. domain은 가장 안쪽에 있고, application은 use case를 담고, adapter는 바깥쪽 기술(웹, DB, 외부 API)을 맡습니다.
문제는 그림이 아니라 **시간이 지난 뒤의 코드**입니다.
- controller가 편의상 repository를 직접 부르기 시작하고
- application이 web DTO를 파라미터로 받기 시작하고
- "일단 공통이니까"라며 shared 패키지가 온갖 것의 dumping ground가 되기 시작하면
구조는 다이어그램에만 남고 실제 코드는 이름만 Clean Architecture인 상태가 됩니다.
ca-tmpl은 이 문제를 패키지 네이밍 컨벤션만으로 풀지 않았습니다. **Gradle 멀티모듈을 1차 경계**로 두고, **ArchUnit을 2차 경계**로 뒀습니다. Build graph에서는 어떤 모듈이 어떤 모듈을 의존할 수 있는지 검사하고, source import graph에서는 domain/application/adapter/shared-contract가 금지된 타입을 가져오는지 검사합니다.
즉 "Clean Architecture로 짰다"가 목표가 아니라, **깨지는 순간 빌드가 알려주는 skeleton**을 만드는 게 목표였습니다.
---
## 2. 모듈 구조 먼저 보기
현재 ca-tmpl의 production root는 `dev.caskeleton`이고, 다음 모듈로 나뉘어 있습니다.
```groovy
include 'app-bootstrap'
include 'domain-core'
include 'application-core'
include 'adapter-web'
include 'adapter-persistence-rdbms'
include 'adapter-persistence-postgresql'
include 'adapter-outbound'
include 'adapter-identifier'
include 'shared-contract'
include 'sample-portfolio'
```
`app-bootstrap`은 composition root라서 Spring Boot의 component scan 대상을 명시적으로 나열합니다.
```java
@SpringBootApplication(
scanBasePackages = {
"dev.caskeleton.bootstrap",
"dev.caskeleton.adapter",
"dev.caskeleton.application",
"dev.caskeleton.domain",
"dev.caskeleton.shared"
})
public class CaSkeletonApplication {
public static void main(String[] args) {
SpringApplication.run(CaSkeletonApplication.class, args);
}
}
```
참고로 이 10개 모듈 구성은 처음부터 이랬던 건 아니에요. 초기 설계는 8개 모듈이었고, 이후 `adapter-identifier`와 persistence 세분화가 추가됐습니다. 그래서 이 글에서는 "지금 HEAD 기준 모듈 수"와 "그 결정이 언제 검증됐는지"를 섞어 말하지 않으려고 합니다.
---
## 3. 1차 경계: Gradle이 프로젝트 의존성을 막는다
핵심은 `verifyCleanArchitectureDependencies`라는 커스텀 Gradle task입니다. 각 모듈이 의존할 수 있는 모듈을 whitelist로 들고 있다가, 허용되지 않은 `project()` 의존성이 들어오면 빌드를 실패시킵니다.
```groovy
tasks.register('verifyCleanArchitectureDependencies') {
doLast {
Map<String, Set<String>> allowedProjectDependencies = [
'domain-core' : ['shared-contract'] as Set,
'application-core' : ['domain-core', 'shared-contract'] as Set,
'adapter-web' : ['application-core', 'domain-core', 'shared-contract'] as Set,
'adapter-outbound' : ['application-core', 'domain-core', 'shared-contract'] as Set,
'shared-contract' : [] as Set
]
// 허용되지 않은 ProjectDependency가 있으면 GradleException을 던진다.
}
}
```
예를 들어 web adapter가 persistence adapter나 outbound adapter를 직접 의존하면 안 됩니다. `app-bootstrap`은 composition root라서 여러 모듈을 조립할 수 있지만, **production 코드가 `sample-portfolio`에 의존하는 것은 금지**됩니다. 샘플 코드는 학습과 fixture 역할을 하는 소비자 모듈이지, production core가 기대는 기반 모듈이 아니기 때문입니다.
```java
@ArchTest
static final ArchRule PRODUCTION_CODE_DOES_NOT_DEPEND_ON_SAMPLE_PORTFOLIO =
noClasses()
.that()
.resideOutsideOfPackage("..sample.portfolio..")
.should()
.dependOnClassesThat()
.resideInAPackage("..sample.portfolio..");
```
---
## 4. 2차 경계: ArchUnit이 import 방향을 막는다
모듈 단위 경계만으로는 부족합니다. 같은 모듈 안에서도 패키지 간 import 방향이 흐트러질 수 있거든요. 여기서부터는 ArchUnit이 맡습니다.
**domain은 순수해야 합니다.**
```java
@ArchTest
static final ArchRule DOMAIN_IS_PURE =
noClasses()
.that()
.resideInAPackage("..domain..")
.should()
.dependOnClassesThat()
.resideInAnyPackage(
"org.springframework..",
"jakarta.persistence..",
"org.hibernate..",
"lombok..",
"..application..",
"..adapter..",
"..bootstrap..")
.allowEmptyShould(true);
```
domain 패키지는 Spring, JPA, Hibernate, Lombok은 물론이고 application, adapter, bootstrap에도 의존할 수 없습니다.
**application도 마찬가지로 갇혀 있습니다.** adapter, bootstrap, persistence, Spring Web, Hibernate에 의존하지 못하고, Spring의 `@Transactional`도 직접 쓸 수 없습니다. 이건 트랜잭션을 다루지 않는다는 뜻이 아니라, 그 책임을 `TransactionPort` 같은 application port로 명시적으로 드러내겠다는 결정입니다.
**adapter끼리도 서로 직접 알면 안 됩니다.**
- web adapter가 persistence/outbound adapter를 직접 알면, controller가 DB나 외부 client의 세부 구현을 우회할 길이 생깁니다.
- persistence adapter가 web adapter를 알면, 저장소 계층이 transport 모양을 알게 됩니다.
- outbound adapter가 persistence adapter를 직접 알면, 외부 호출과 저장소 구현이 서로 엮입니다.
ca-tmpl은 adapter 간의 연결이 반드시 application/domain/shared-contract를 거쳐서만 흐르도록 강제합니다.
---
## 5. `shared`는 편의 패키지가 아니다
이름이 "shared"라고 해서 아무 공통 코드나 넣을 수 있는 곳이 아닙니다. ca-tmpl에서 `shared-contract`는 response, request, error, headers, logging, tracing, metrics, registry, annotation 같은 **operational contract 패키지만** 허용합니다.
비즈니스 개념(business concept)이 shared로 들어오기 시작하면, 서로 다른 도메인들이 같은 이름의 공통 모델에 묶여버리기 쉽습니다. 그래서 shared는 편의 패키지가 아니라 **운영 계약이 흐르는 제한된 통로**로 정의했습니다.
---
## 6. 규칙이 진짜로 동작하는지는 어떻게 아는가 — violations-as-data
ArchUnit rule의 함정 중 하나는, 매칭 대상이 비어 있으면 아무것도 검사하지 않으면서 그냥 green이 될 수 있다는 점입니다. rule 이름은 그럴듯한데 실제로는 아무 위반도 못 잡는 상태죠.
ca-tmpl은 이걸 막기 위해 **의도적으로 잘못된 fixture 클래스**를 test tree에 만들어두고, 각 rule이 그 위반을 실제로 잡아내는지 확인합니다.
```java
class ArchitectureViolationFixtureTest {
private static final JavaClasses VIOLATION_CLASSES =
new ClassFileImporter().importPackages("dev.caskeleton.bootstrap.architecture.violations");
// intentional fixture classes를 읽어 각 ArchUnit rule이 실제 위반을 잡는지 확인한다.
}
```
이건 architecture rule 자체를 테스트하는 장치입니다. "규칙을 만들었다"와 "그 규칙이 실제로 동작한다"는 다른 문장이니까요.
---
## 7. 이 구조가 못 잡는 것들
이 구조가 만능은 아닙니다. ArchUnit은 bytecode에 남는 import, call, annotation은 잘 잡아내지만, `ApplicationContext.getBean(String)`이나 `Class.forName(String)` 같은 **문자열 기반 reflection 우회**는 정적으로 잡기 어렵습니다. 마음만 먹으면 규칙을 우회할 방법은 여전히 존재한다는 뜻입니다.
또한 운영 배포나 장기 유지보수 효과에 대한 측정은 아직 없습니다. 이 글에서 말할 수 있는 범위는 **ca-tmpl 저장소에 실제로 구현되어 있고, 로컬/dev 검증으로 확인된 모듈/패키지 경계까지**입니다.
---
## 마무리
ca-tmpl의 Clean Architecture 패키지 레이아웃에서 핵심은 "domain, application, adapter로 나눴다"는 사실 자체가 아닙니다. 핵심은 **그 나눔을 Gradle dependency matrix와 ArchUnit fitness function으로 계속 확인한다는 점**입니다.
좋은 skeleton은 한 번 예쁘게 그려둔 다이어그램이 아니라, 새 도메인을 추가하려는 사람이 실수로 경계를 깨뜨렸을 때 **어디서 무엇이 잘못됐는지 빌드가 바로 알려주는 구조**여야 합니다.
@@ -1 +0,0 @@
../../vault/40-publish/publish-blog/data-layer-persistence-cache-outbound-blog.md
@@ -0,0 +1,196 @@
---
title: Persistence와 Cache, Outbound 경계를 한 문서에서 분리하기
source_type: blog
status: draft
confidence: unknown
tags: [blog, ca-tmpl, persistence, caching, spring-data, outbox-pattern]
related_projects: [ca-tmpl]
last_reviewed:
canonical_sources: []
audience: backend-engineer
target_publish:
status_label: draft
---
# Persistence와 Cache, Outbound 경계를 한 문서에서 분리하기
> "data layer를 구현했다"는 문장은 편리하지만, 그 안엔 서로 완전히 다른 실패 계약을 가진 세 가지가 섞여 있습니다.
## TL;DR
- Persistence, cache, outbound HTTP는 모두 "data layer"로 뭉뚱그려지기 쉽지만, 실패했을 때 **무엇을 보존해야 하는지**가 완전히 다릅니다.
- ca-tmpl에서 구현된 범위는 서로 다릅니다 — idempotency/outbox adapter, OSIV/Hikari startup guard, cache SPI/fail-open, outbound HTTP client는 로컬 검증까지 끝났고, SQLState classifier 전체나 read replica lag metric 같은 건 아직 planned입니다.
- 핵심 원칙 하나: **cache는 fail-open이어도 되지만, outbox는 fail-open이면 안 됩니다.** 같은 "adapter 실패"라도 업무 의미가 다르기 때문입니다.
---
## 1. "Data Layer"라는 이름 뒤에 숨은 세 가지 다른 문제
data layer라고 부르면 하나로 보이지만, 실제로는 서로 다른 실패 모드가 섞여 있습니다.
- **Persistence**는 DB transaction, constraint, connection pool 문제가 중심입니다.
- **Cache**는 빠른 조회와 stale data, backend 장애 시 어떻게 degrade할지가 중심입니다.
- **Outbound HTTP**는 외부 dependency의 timeout, retry, circuit breaker, shutdown 처리가 중심입니다.
ca-tmpl은 이 셋을 한 문서 안에 두되, **구현된 범위와 계획만 있는 범위를 분리**합니다. 이 분리가 중요한 이유는 과장하기 쉽기 때문입니다. "data layer baseline을 구현했다"고 말하면 persistence classifier, cache consistency, outbound resilience가 전부 같은 수준으로 끝난 것처럼 들리거든요. 실제로는 그렇지 않습니다.
**구현되고 로컬 검증된 것**: idempotency/outbox RDBMS adapter, PostgreSQL migration, OSIV/Hikari startup guard, lower-layer cache SPI/router/fail-open, outbound HTTP client
**아직 planned이거나 부분 구현인 것**: SQLState classifier 전체, read replica lag metric, cache-aside + Redisson distributed mutex + after-commit invalidation의 전체 contract, 운영 tuning
---
## 2. Persistence — 시작 시점에 잘못된 조합을 잡아내기
### OSIV를 꺼두고, 꺼져 있는지 시작할 때 확인한다
OSIV(Open Session In View)는 web response를 렌더링하는 시점까지 Hibernate session을 열어두는 방식입니다. 편리하긴 한데, presentation layer에서 실수로 lazy association을 건드리는 순간 DB 쿼리가 튀어나갈 수 있습니다. 이게 나쁜 이유는 **레이어 경계가 코드 리뷰가 아니라 우연에 의해 지켜지기 때문**입니다.
ca-tmpl은 `spring.jpa.open-in-view=true`가 설정되어 있으면 애플리케이션 시작 자체를 실패시킵니다.
```java
public void afterSingletonsInstantiated() {
Boolean openInView = environment.getProperty("spring.jpa.open-in-view", Boolean.class);
if (Boolean.TRUE.equals(openInView)) {
throw new IllegalStateException("APP_DATASOURCE_OPEN_IN_VIEW must be false");
}
}
```
레이어 경계를 코드 리뷰로만 지키는 게 아니라, **런타임 설정 레벨에서도 깨지지 않게** 만든 셈입니다.
### HikariCP — 잘못 조합된 숫자를 미리 잡기
Connection pool 설정은 값 하나하나는 멀쩡해 보여도 조합이 잘못되면 문제가 생깁니다. ca-tmpl은 이런 조합을 startup guard로 걸러냅니다.
```java
if (validationTimeout != null
&& connectionTimeout != null
&& validationTimeout >= connectionTimeout) {
violations.add("validation-timeout must be < connection-timeout");
}
if (keepaliveTime != null && maxLifetime != null && keepaliveTime >= maxLifetime) {
violations.add("keepalive-time must be < max-lifetime");
}
```
connection-timeout은 최소 250ms 이상, validation-timeout은 connection-timeout보다 작아야 하고, keepalive-time은 max-lifetime보다 작아야 합니다. leak-detection-threshold를 켤 거면 2000ms 이상이어야 하고요.
**여기서 오해하면 안 되는 부분이 있습니다.** 이 guard는 pool sizing을 운영 환경에서 측정하고 튜닝했다는 뜻이 아닙니다. 그냥 잘못 조합된 숫자를 애플리케이션이 시작하는 시점에 빨리 실패시키는 안전장치입니다. 운영 튜닝과 startup guard는 다른 문제예요.
---
## 3. Cache — fail-open, 그러나 무조건은 아니다
Cache 쪽에서 구현된 핵심은 **fail-open 경계**입니다. `CacheStoreRouter`는 logical cache 이름을 backend id로 라우팅합니다.
```java
public Optional<String> get(String logicalName, String key) {
return resolve(logicalName).get(key);
}
private CacheStore resolve(String logicalName) {
String backendId = bindings.get(logicalName);
if (backendId == null) {
throw new AdapterDisabledException("cache", "no cache backend bound");
}
return backends.get(backendId);
}
```
주목할 점은, **binding 자체가 없는 logical cache를 호출하면 조용히 no-op 하지 않고 예외를 던진다**는 것입니다. 이건 설정 실수를 숨기지 않겠다는 뜻이에요.
반면 backend가 정상적으로 구성된 뒤 실제 cache 호출이 실패하는 경우는 다르게 다룹니다.
```java
public Optional<String> get(String key) {
try {
return delegate.get(key);
} catch (Exception ex) {
dependencyLogger.logFailure(delegate.backendId(), "cache", "get", ex);
return Optional.empty();
}
}
```
get은 miss로, put은 관찰된 실패로 낮춥니다. **cache는 성능을 보조하는 장치**이므로, cache backend 장애가 그대로 5xx로 이어지지 않게 하는 방향입니다.
즉 정리하면: **"바인딩이 안 된 것"은 설정 실수라서 즉시 실패**시키고, **"바인딩은 됐는데 backend가 죽은 것"은 운영 중 발생 가능한 일이라서 degrade**시킵니다. 같은 "cache 문제"처럼 보여도 원인에 따라 대응이 다릅니다.
---
## 4. 같은 "실패"인데 왜 outbox는 다르게 다루는가
여기가 이 글에서 가장 중요한 지점입니다. **Outbox publish는 fail-open이면 안 됩니다.**
메시지 발행 실패를 cache처럼 조용히 삼키면, downstream 시스템이 **영원히 변경 사실을 모를 수 있습니다.** cache miss는 다시 조회하면 그만이지만, 발행되지 않은 이벤트는 재시도하지 않는 한 영영 사라집니다.
그래서 outbox는 `FAILED`/`DEAD` state machine과 error log를 갖습니다. 실패를 숨기는 대신, **실패를 상태로 남겨서 다시 처리할 수 있게** 만든 것입니다.
| | Cache | Outbox |
|---|---|---|
| 실패 시 동작 | miss로 degrade (fail-open) | 상태로 기록, 재처리 대상 (fail-closed 성격) |
| 이유 | 성능 보조 장치라서 장애가 5xx로 번지면 안 됨 | 발행 실패를 숨기면 downstream이 변경을 영영 모름 |
같은 "adapter failure"라도 **업무적 의미가 다르면 대응도 달라야 한다**는 게 이 비교가 전하려는 요점입니다.
---
## 5. Outbound HTTP — 재시도해도 되는 것과 안 되는 것
`OutboundHttpClient`는 dependency 이름별로 baseline client를 만들고, shutdown이 진행 중이면 네트워크 연결을 맺기도 전에 fail-fast합니다.
```java
if (shutdownGuard.isShuttingDown()) {
throw observer.rejectShutdown("shutdown in progress — outbound call rejected fail-fast");
}
retryPolicy.beginCall(method, deadline);
try {
Supplier<T> decorated = countingSupplier;
if (retry.isPresent()) decorated = Retry.decorateSupplier(retry.get(), decorated);
if (cb.isPresent()) decorated = CircuitBreaker.decorateSupplier(cb.get(), decorated);
return decorated.get();
} finally {
retryPolicy.endCall();
}
```
여기서 흥미로운 구분이 하나 있습니다. **Buffered 호출은 retry/circuit breaker를 거치지만, streaming 호출은 재시도하지 않습니다.** 이미 일부 bytes를 소비한 스트림은 안전하게 재시도하기 어렵기 때문입니다. 한번 읽기 시작한 스트림을 재시도하면 데이터가 중복되거나 깨질 수 있으니까요.
Retry policy도 method 종류를 가립니다.
```java
private static final Set<HttpMethod> IDEMPOTENT_METHODS =
Set.of(HttpMethod.GET, HttpMethod.HEAD, HttpMethod.PUT, HttpMethod.DELETE);
if (!IDEMPOTENT_METHODS.contains(ctx.method())) {
return false;
}
```
GET/HEAD/PUT/DELETE처럼 **멱등한(idempotent) method만 재시도 대상**이 됩니다. POST/PATCH는 기본적으로 제외되는데, 같은 요청이 두 번 실행되면 의도치 않게 리소스가 중복 생성될 수 있기 때문입니다.
### Timeout은 하나가 아니라 세 축이다
outbound HTTP에서 중요한 개념이 하나 더 있습니다. Timeout을 하나의 값으로 뭉치지 않고 **세 축으로 나눠서 생각**합니다.
- **Connect timeout**: TCP 연결을 맺는 단계
- **Read timeout**: 소켓에서 데이터를 읽는 단계
- **Global call timeout**: retry를 포함한 전체 호출 예산
ca-tmpl의 현재 구현은 이 값들을 기본값으로 그냥 박아두기보다, **필수 설정으로 요구**하고 누락되거나 잘못 등록된 raw `RestClient`를 시작 시점에 막는 방향을 택했습니다.
---
## 6. 정리 — 이름이 아니라 실패 계약으로 나누기
ca-tmpl의 data layer baseline은 "DB, cache, HTTP를 다 구현했다"는 단순한 한 문장이 아닙니다. 구현된 것은 구현됐다고 말하고, planned인 것은 planned로 남겨두는 문서입니다.
이 글의 가장 중요한 학습 포인트도 여기 있습니다. **data layer의 경계는 기술 이름(DB냐 cache냐 HTTP냐)으로 나뉘는 게 아니라, 실패했을 때 무엇을 보존해야 하는지로 나뉩니다.**
- DB transaction은 정합성을 보존해야 하고
- cache는 miss로 degrade해도 되며
- outbound HTTP는 retry/timeout 예산 안에서 실패를 분류해야 합니다
세 가지를 같은 잣대로 재려고 하면 어느 하나는 반드시 잘못 다뤄지게 됩니다.
@@ -1 +0,0 @@
../../vault/40-publish/publish-blog/optional-adapter-config-contract-blog.md
@@ -0,0 +1,162 @@
---
title: Optional Adapter를 설정 계약으로 다루기
source_type: blog
status: draft
confidence: unknown
tags: [blog, ca-tmpl, integration, spring-boot, externalized-config, component-scan]
related_projects: [ca-tmpl]
last_reviewed:
canonical_sources: []
audience: backend-engineer
target_publish:
status_label: draft
---
# Optional Adapter를 설정 계약으로 다루기
> `@ConditionalOnProperty` 하나만 붙이면 될 것 같지만, "꺼져 있어도 안전한가"까지 물으면 이야기가 달라집니다.
## TL;DR
- Optional adapter(Redis, Kafka, Slack 등)는 "있으면 쓰고 없으면 말고"로 접근하기 쉽지만, 실제로는 env key drift, 조건 없이 등록되는 bean, disabled 상태인데 조용히 흘러가는 코드 경로 같은 실패 모드를 만듭니다.
- ca-tmpl은 이걸 **세 개의 층**으로 나눠서 다룹니다: env registry gate, bean gating(`@ConditionalOnProperty` + 정적 검사), startup fail-fast.
- `@ConditionalOnProperty`는 bean 등록 조건은 보여주지만, **런타임에 실제로 어떤 property가 적용됐는지까지 증명하지는 않습니다.** 이 한계를 인정하는 게 이 글의 핵심입니다.
- 모든 provider(Kafka, Redis, Slack 등)의 완성을 주장하지 않습니다. "켜지고 꺼지는 실패 모드를 계약으로 드러내기 시작했다"까지만 말할 수 있습니다.
---
## 1. Optional adapter는 왜 조용히 무너지는가
Optional adapter는 처음엔 꽤 편해 보입니다. Redis가 있으면 cache adapter를 켜고, Kafka가 있으면 message broker adapter를 켜고, Slack이나 이메일 provider는 필요할 때만 붙이면 되니까요.
문제는 **"꺼져 있어도 정말 안전한가?"**라는 질문입니다. 실제로는 이런 일들이 조용히 쌓입니다.
- env key가 `.env`에는 있는데 `application.yml`에서는 안 쓰이고 있거나
- optional adapter의 bean이 아무 조건 없이 그냥 등록되거나
- adapter가 disabled 상태인데 application layer가 그 adapter 패키지를 직접 import하고 있거나
이런 상태가 쌓이면 설정은 **계약이 아니라 "대충 이런 분위기"**가 되어버립니다. 누군가 `.env`에 값을 하나 빼먹어도 아무도 모르고, 배포 후에야 터지는 식이죠.
ca-tmpl은 이 문제를 `@ConditionalOnProperty` 하나로 끝내지 않았습니다. env registry, `.env` drift gate, `@ConfigurationProperties`, optional adapter package isolation, `@ConditionalOnProperty` 정적 규칙, startup failure exception을 각각 나눠뒀습니다.
---
## 2. 세 개의 층으로 보기
전체 구조를 표로 먼저 보겠습니다.
| 층 | 잡는 문제 | ca-tmpl 구현 |
|---|---|---|
| **① Env registry gate** | `.env` / `application.yml` / registry 간의 drift | `verifyEnvKeys` |
| **② Bean gating** | optional adapter bean이 조건 없이 등록되는 문제 | `@ConditionalOnProperty` + `DisabledAdapterArchitectureTest` |
| **③ Startup/runtime fail-fast** | required adapter가 disabled인데 조용히 진행되는 문제 | startup failure exception, router/config guard |
하나씩 보겠습니다.
---
## 3. ① 설정 파일들이 서로 어긋나지 않게 — Env Registry Gate
설정값은 코드 밖에 있지만, 실제로는 코드의 실행 경로를 바꿉니다. `APP_CACHE_REDIS_ENABLED=true`가 들어오면 Redis cache backend가 생기고, `app.messaging.broker=kafka`가 들어오면 Kafka broker bean이 등록되는 식입니다.
이런 설정을 문서로만 관리하면 drift가 생깁니다. `.env`에만 남아있는 key, `application.yml`에만 있는 placeholder, 어디에도 등록 안 된 `APP_` key가 조금씩 쌓이는 거죠.
ca-tmpl은 이 drift를 **Gradle task로 막습니다.** `verifyEnvKeys``src/.env`, `application.yml`, `docs/registries/env-keys.yaml` 세 파일을 함께 읽습니다.
```groovy
tasks.register('verifyEnvKeys') {
description = 'Verifies src/.env covers application.yml placeholders and every APP_ key is registered.'
File envFile = file("${rootProject.projectDir}/.env")
File appYml = file("${rootProject.projectDir}/app-bootstrap/src/main/resources/application.yml")
File registryFile = file("${rootProject.projectDir}/../docs/registries/env-keys.yaml")
}
```
이 task는 세 가지를 검사합니다.
- `application.yml`의 required placeholder가 `.env`에 없으면 → 실패
- `.env`의 key가 어떤 placeholder에도 안 쓰이면 → 실패
- `APP_` 접두사를 가진 key가 env registry에 등록 안 돼 있으면 → 실패
즉 설정 문서와 실제 boot 설정이 따로 움직이지 않도록 **빌드 단계에서 묶어버립니다.**
---
## 4. ② Adapter가 켜지는 조건을 코드로 선언하기 — Bean Gating
Adapter activation은 Spring bean 조건으로 표현합니다. Redis cache backend는 `app.cache.redis.enabled=true`일 때만 등록됩니다.
```java
@Bean
@ConditionalOnProperty(
name = "app.cache.redis.enabled",
havingValue = "true",
matchIfMissing = false)
public CacheBackend redisCacheBackend(RedisClient redisClient) {
return new RedisCacheStore(redisClient);
}
```
Kafka broker도 마찬가지입니다. 게다가 조건을 통과해도 설정값 자체가 비어있으면 즉시 실패하도록 되어 있습니다.
```java
@Bean
@ConditionalOnProperty(name = "app.messaging.broker", havingValue = "kafka")
public MessageBroker kafkaMessageBroker(KafkaSender sender, KafkaAdapterSettings settings) {
if (settings.brokers().isEmpty()) {
throw new IllegalStateException("app.messaging.broker=kafka requires a non-empty broker list");
}
return new KafkaMessageBroker(sender);
}
```
이 방식의 장점은, adapter 구현체가 중앙 router나 use case 코드를 직접 건드리지 않고도 **"내가 활성화되는 조건"을 자기 config 안에 스스로 선언**할 수 있다는 점입니다.
### 하지만 `@ConditionalOnProperty`만으로는 부족하다
여기서 중요한 인정이 필요합니다. **ArchUnit은 런타임 property evaluation을 실행하지 않습니다.** 즉 "지금 이 profile에서 이 bean이 실제로 켜져 있는가"를 증명하는 도구가 아니에요.
대신 ca-tmpl은 정적 분석으로 두 가지만 확인합니다.
1. application layer가 optional adapter 패키지를 import하지 않는지
2. optional adapter 패키지 안의 `@Bean` 메서드가 `@ConditionalOnProperty`를 갖고 있는지
```java
static final ArchRule APPLICATION_DOES_NOT_DEPEND_ON_OPTIONAL_ADAPTERS =
noClasses()
.that()
.resideInAPackage("..application..")
.should()
.dependOnClassesThat()
.resideInAnyPackage(OPTIONAL_ADAPTER_PACKAGES);
```
이건 "현재 어떤 profile에서 bean이 켜졌는가"를 증명하는 게 아니라, **disabled-default를 우회할 수 있는 코드 구조 자체를 막는 쪽**입니다. 증명과 방지는 다른 문제이고, ca-tmpl이 하는 건 후자입니다.
---
## 5. ③ 꺼져 있는데 필요한 경로라면 빨리 실패하기 — Startup Fail-fast
세 번째 층은 startup 시점의 fail-fast입니다. required capability adapter가 꺼져 있거나 coordination bean이 없으면 `RequiredAdapterDisabledException` 계열의 startup failure로 드러납니다.
cache router나 messaging config 같은 중앙 binding 지점에서도, disabled backend로의 binding이 **조용한 no-op으로 흘러가지 않도록** 설계되어 있습니다.
여기서 skeleton이 지키려는 원칙은 이겁니다: **"꺼져 있으면 아무 일도 하지 않는다"가 목표가 아니라, "꺼져 있는데 필요한 경로라면 빨리 실패한다"가 목표입니다.** 조용히 무시되는 것과 시작하자마자 명확하게 실패하는 것은 운영 관점에서 완전히 다른 경험입니다.
---
## 6. 아직 말할 수 없는 것들
ca-tmpl에는 env registry/gate와 optional adapter guard가 구현되어 있고, `./gradlew check`로 로컬 검증됐습니다. 여기까지는 분명하게 말할 수 있습니다.
하지만 **모든 provider-specific adapter template가 완성됐다고 말하면 안 됩니다.** Kafka, Redis, Slack, Google Email 같은 표면이 일부 존재하더라도, "모든 외부 provider 전환을 검증했다"는 주장은 이 프로젝트가 실제로 확인한 범위를 넘어섭니다.
그래서 이 글의 결론은 **"optional adapter를 완성했다"가 아니라, "optional adapter가 켜지고 꺼지는 실패 모드를 설정 계약으로 드러내기 시작했다"**입니다.
---
## 마무리
Optional adapter를 다루는 방식은 결국 "런타임에 신뢰할 수 있는 상태만 켜지게 하려면 무엇을 정적으로, 무엇을 시작 시점에 확인해야 하는가"의 문제입니다.
ca-tmpl은 이 질문에 세 개의 층으로 답했습니다 — 설정 파일 간의 drift를 빌드에서 막고, bean이 켜지는 조건을 코드에 선언하고 우회 경로를 정적으로 막고, 그래도 필요한 게 꺼져 있으면 시작 시점에 실패시키는 것. 이 셋 중 하나만 있었다면 여전히 구멍이 남았을 겁니다.