fix: 하네스 제거 및 keycloak 문서 보강
This commit is contained in:
@@ -1 +0,0 @@
|
||||
../../vault/40-publish/blog/ca-tmpl-api-error-envelope-design-2026-07-02.md
|
||||
@@ -0,0 +1,239 @@
|
||||
---
|
||||
title: API Error Envelope을 프로젝트 계약으로 고정하기
|
||||
source_type: blog
|
||||
status: verified
|
||||
confidence: high
|
||||
tags: [blog, ca-tmpl, api-design, error-handling]
|
||||
related_projects: [ca-tmpl]
|
||||
last_reviewed: 2026-07-02
|
||||
canonical_sources:
|
||||
- wiki/projects/ca-tmpl/api-error-envelope-design
|
||||
- wiki/concepts/api-error-envelope-design
|
||||
audience: backend-engineer
|
||||
target_publish:
|
||||
status_label: ready
|
||||
---
|
||||
|
||||
# API Error Envelope을 프로젝트 계약으로 고정하기
|
||||
|
||||
## Parent / 부모 (필수)
|
||||
|
||||
- Canonical source: [[wiki/projects/ca-tmpl/api-error-envelope-design]]
|
||||
- Supporting concept: [[wiki/concepts/api-error-envelope-design]]
|
||||
|
||||
## 타깃 독자 / Target reader
|
||||
|
||||
- Spring Boot 기반 REST API에서 error response contract를 정해야 하는 백엔드 엔지니어.
|
||||
- 이미 HTTP status, Spring MVC exception handling, validation error mapping의 기본은 알고 있다고 가정한다.
|
||||
- 이 글에서 처음 보게 될 포인트: `ProblemDetail`을 거부하는 이유가 "표준이 싫어서"가 아니라, 프로젝트가 요구한 success/error 대칭 envelope과 운영 메타데이터 계약 때문이라는 점.
|
||||
|
||||
## 도입 / Hook
|
||||
|
||||
API 실패 응답은 보통 나중에 정리하려고 미루기 쉽다. 그런데 validation, security, transport failure, business rule violation이 각자 다른 JSON shape을 반환하기 시작하면 client는 실패 원인을 안정적으로 분기할 수 없고, 운영자는 응답과 로그/트레이스를 한 번에 이어 보기 어렵다.
|
||||
|
||||
ca-tmpl에서는 Spring 6+의 `ProblemDetail`을 그대로 쓰지 않고, `{ success, data, error, meta }` 형태의 custom envelope을 프로젝트 계약으로 고정했다. 이 글은 그 결정이 어떤 요구에서 나왔고, 어디까지 코드로 구현되고 로컬 검증됐으며, 아직 구현됐다고 말하면 안 되는 부분이 무엇인지 정리한다.
|
||||
|
||||
## 본문 outline / Body outline
|
||||
|
||||
1. 실패 응답 shape이 흩어질 때 생기는 문제
|
||||
- validation, security, transport failure가 서로 다른 응답 구조를 만들면 client 분기와 테스트가 어려워진다.
|
||||
- ca-tmpl의 목표는 모든 실패를 같은 원인으로 섞는 것이 아니라, 같은 envelope 안에서 status/code/category 의미를 보존하는 것이다.
|
||||
|
||||
2. 왜 `ProblemDetail`을 그대로 쓰지 않았나
|
||||
- `ProblemDetail`은 실패 전용 평면 shape이다.
|
||||
- ca-tmpl은 성공과 실패를 같은 top-level envelope으로 감싸고, `error.code`, `error.category`, `error.retryable`, `error.details`, `meta`를 1급 계약으로 두고 싶었다.
|
||||
- 따라서 표준 위에 다시 custom 확장층을 얹기보다 프로젝트 전용 envelope을 명시적으로 선택했다.
|
||||
|
||||
3. ca-tmpl envelope의 핵심 필드
|
||||
- `success`: client의 1차 분기 기준.
|
||||
- `data`: 성공 응답 payload.
|
||||
- `error.code`: machine-readable error identifier.
|
||||
- `error.category`: 운영 분류.
|
||||
- `error.retryable`: client retry 판단의 최소 힌트.
|
||||
- `error.details`: validation field error 같은 항목별 오류.
|
||||
- `meta`: `requestId`, `traceId`, `correlationId`로 응답과 로그/트레이스를 연결하는 영역.
|
||||
|
||||
4. 구현으로 고정한 계약
|
||||
- `Envelope`, `ApiError`, `ResponseMeta`, `Category`, `OperationalError`, `ApiErrorCode`.
|
||||
- `GlobalExceptionHandler`, `ErrorResponseFactory`, `EnvelopeBodyAdvice`.
|
||||
- `ProblemDetail` import 금지 ArchUnit rule.
|
||||
- `spring.mvc.problemdetails.enabled: false` pin과 config regression test.
|
||||
|
||||
5. transport failure까지 같은 shape으로 태우기
|
||||
- 413, 406, 415, 405(+`Allow`), 412는 envelope shape으로 반환되도록 테스트됐다.
|
||||
- Spring MVC `ResponseEntityExceptionHandler`가 이미 다루는 umbrella exception은 중복 `@ExceptionHandler`가 아니라 protected override로 다룬다.
|
||||
- 이 범위는 검증된 transport row에 한정한다.
|
||||
|
||||
6. 아직 말하면 안 되는 부분
|
||||
- 운영 배포와 prod metric 검증은 없다.
|
||||
- `Retry-After` header 발행은 planned/stub이다.
|
||||
- 5xx span ERROR 기록도 planned/stub이다.
|
||||
- business rule violation의 세부 category/details mapping은 별도 owner branch 책임이다.
|
||||
|
||||
## 본문 / Body
|
||||
|
||||
API error response는 처음에는 작아 보입니다. 실패하면 적당한 HTTP status와 message만 내려주면 될 것처럼 보입니다. 그런데 프로젝트가 커지면 이야기가 달라집니다. validation 실패는 field 목록을 내려주고, 인증 실패는 Spring Security가 다른 shape을 만들고, 잘못된 `Content-Type`이나 큰 request body는 Spring MVC transport layer에서 또 다른 응답을 만들 수 있습니다.
|
||||
|
||||
이 상태가 오래가면 client 입장에서는 "실패했다"는 사실보다 "이번 실패는 어떤 모양으로 오지?"를 먼저 걱정해야 합니다. 운영하는 사람 입장에서도 비슷합니다. 응답에 trace id가 있는지, 재시도해도 되는 오류인지, validation 문제인지 인증 문제인지가 매번 다른 위치에 있으면 로그와 응답을 이어서 보기 어렵습니다.
|
||||
|
||||
ca-tmpl에서 API Error Envelope을 먼저 계약으로 잡은 이유는 여기에 있습니다. 목표는 모든 실패를 똑같은 원인으로 뭉개는 것이 아니었습니다. HTTP status와 error code의 의미는 유지하되, client가 읽는 바깥 구조를 하나로 맞추는 것이었습니다.
|
||||
|
||||
쉽게 말하면 실패 응답에도 "봉투"를 하나 씌운 셈입니다. 봉투 바깥에는 `success`, `data`, `error`, `meta`가 있고, 실패일 때는 `success=false`, `data=null`, `error`에 실제 오류 정보가 들어갑니다. `meta`에는 요청과 로그, trace를 이어 볼 수 있는 id들이 들어갑니다.
|
||||
|
||||
```json
|
||||
{
|
||||
"success": false,
|
||||
"data": null,
|
||||
"error": {
|
||||
"code": "VALIDATION_FAILED",
|
||||
"category": "VALIDATION",
|
||||
"message": "Request body failed validation",
|
||||
"retryable": false,
|
||||
"details": []
|
||||
},
|
||||
"meta": {
|
||||
"requestId": "...",
|
||||
"traceId": "...",
|
||||
"correlationId": "..."
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
여기서 중요한 점은 이 구조가 단순히 보기 좋은 JSON이 아니라는 것입니다. `error.code`는 client가 분기할 수 있는 machine-readable identifier입니다. `message`는 사람이 읽는 문장이므로 client 로직이 여기에 의존하면 안 됩니다. `error.category`는 운영 분류입니다. validation 문제인지, auth 문제인지, dependency 문제인지 같은 큰 묶음을 나타냅니다. `retryable`은 client가 재시도를 검토할 수 있게 해 주는 최소 힌트입니다. `details`는 validation field error처럼 항목별 정보가 필요한 경우에만 채웁니다.
|
||||
|
||||
그럼 Spring 6+에서 제공하는 `ProblemDetail`을 쓰면 되지 않을까요? 이 질문이 자연스럽습니다. `ProblemDetail`은 RFC 7807 계열의 실패 응답 모델이고, Spring에서도 기본 지원합니다. 하지만 ca-tmpl의 요구와는 결이 달랐습니다.
|
||||
|
||||
`ProblemDetail`은 실패 전용 평면 shape입니다. 반면 ca-tmpl은 성공과 실패를 모두 같은 top-level envelope으로 감싸고 싶었습니다. 성공 응답도 `success=true`, 실패 응답도 `success=false`로 읽히게 만들고 싶었던 것입니다. 또한 ca-tmpl은 `code`, `category`, `retryable`, `meta`를 프로젝트 계약의 1급 필드로 두고 싶었습니다. `ProblemDetail` 위에 확장 필드를 계속 얹으면 결국 표준을 쓰는 척하면서 실제로는 custom envelope을 하나 더 만든 셈이 됩니다.
|
||||
|
||||
그래서 ca-tmpl의 선택은 "ProblemDetail이 나쁜 설계라서 버린다"가 아니었습니다. 실패 전용 표준 모델보다, 이 skeleton이 원하는 success/error 대칭 구조와 운영 메타데이터가 더 중요했기 때문에 custom envelope을 명시적으로 선택한 것입니다.
|
||||
|
||||
이 결정은 문서에만 남아 있지 않습니다. `shared-contract` 모듈에는 `Envelope`, `ApiError`, `ResponseMeta`가 있고, web adapter에는 예외를 envelope으로 바꾸는 `GlobalExceptionHandler`와 `ErrorResponseFactory`가 있습니다. 즉 "우리 프로젝트는 이런 실패 응답을 쓴다"가 README 문장에 머문 것이 아니라, 컴파일되는 타입과 테스트 가능한 경로로 내려왔습니다.
|
||||
|
||||
`Envelope`는 성공과 실패가 같은 바깥 구조를 공유한다는 결정을 담습니다. 성공이면 `data`가 있고 `error`가 없습니다. 실패이면 `error`가 있고 `data`가 없습니다. `ApiError`는 실패 안쪽의 구조를 고정합니다. 특히 `category`와 `retryable`을 field로 올려 둔 점이 중요합니다. 이 둘을 message 안에 섞어 두면 client와 운영 도구가 안정적으로 읽기 어렵습니다.
|
||||
|
||||
`ErrorResponseFactory`는 이 결정을 Spring MVC 응답으로 바꾸는 관문입니다. `ApiErrorCode`가 가진 HTTP status, code, category, retryable 값을 읽고 `Envelope.failure(...)`를 만들어 냅니다. 이 관문이 있으면 handler마다 JSON을 직접 조립하지 않아도 됩니다. 실패 응답을 만드는 길을 하나로 좁혀 두는 효과가 있습니다.
|
||||
|
||||
또 하나 중요한 장치는 `ProblemDetail`을 다시 들여오지 못하게 막는 것입니다. ca-tmpl은 `spring.mvc.problemdetails.enabled=false`를 application.yml에 명시하고, 그 값이 유지되는지 테스트합니다. 여기에 더해 ArchUnit rule로 production code가 `org.springframework.http.ProblemDetail`에 의존하지 못하게 막습니다. 이것은 "개발자가 조심하자" 수준의 약속이 아니라, build가 깨지는 계약입니다.
|
||||
|
||||
transport failure를 같은 envelope에 태운 것도 이 글의 핵심입니다. 예를 들어 request body가 너무 크면 413, 지원하지 않는 `Content-Type`이면 415, 지원하지 않는 HTTP method면 405가 됩니다. 이때 status의 의미는 그대로 보존해야 합니다. ca-tmpl은 이런 실패들을 모두 `VALIDATION_FAILED` 같은 하나의 오류로 뭉개지 않고, `PAYLOAD_TOO_LARGE`, `UNSUPPORTED_MEDIA_TYPE`, `METHOD_NOT_ALLOWED`처럼 구분된 code/status로 envelope에 담습니다.
|
||||
|
||||
특히 Spring MVC의 `ResponseEntityExceptionHandler`가 이미 처리하는 계열은 주의가 필요합니다. 같은 예외를 `@ExceptionHandler`로 다시 등록하면 framework가 가진 처리 흐름과 충돌할 수 있습니다. ca-tmpl은 이런 경우 protected override를 사용해서 Spring MVC의 흐름 위에서 body만 envelope shape으로 바꿉니다. 예를 들어 405에서는 `Allow` header도 함께 보존합니다. 실패 응답의 바깥 shape은 통일하지만, HTTP가 가진 의미까지 지워 버리지는 않는다는 뜻입니다.
|
||||
|
||||
다만 이 글에서 말할 수 있는 범위는 분명히 제한해야 합니다. 현재 근거는 코드 구현과 로컬 검증입니다. canonical 문서 기준으로 `./gradlew check`가 통과했고, 413/406/415/405(+`Allow`)/412 같은 transport failure row가 테스트됐다고 말할 수 있습니다. 하지만 운영 배포에서 검증했다거나, 실제 production metric으로 개선을 확인했다고 말할 수는 없습니다.
|
||||
|
||||
아직 planned/stub으로 남은 것도 있습니다. `Retry-After` header 발행은 rate-limit owner branch의 책임으로 남아 있습니다. 5xx span ERROR 기록도 tracer-neutral seam은 있지만, 이 글에서 운영 추적이 완성됐다고 말하면 안 됩니다. business rule violation을 어떤 `category`와 `details`로 세분화할지도 foundation이 아니라 별도 owner branch의 범위입니다.
|
||||
|
||||
정리하면 ca-tmpl의 API Error Envelope은 "표준을 몰라서 만든 custom JSON"이 아닙니다. `ProblemDetail`, JSON:API errors, Google `rpc.Status` 같은 선택지를 비교한 뒤, 이 skeleton이 더 중요하게 본 요구를 코드 계약으로 고정한 결과입니다. 그 요구는 성공/실패 응답의 대칭성, client가 읽을 수 있는 안정적인 error code, 운영자가 볼 수 있는 category와 meta, 그리고 exception leak을 막는 일관된 실패 응답 경로였습니다.
|
||||
|
||||
좋은 error response 설계는 예쁜 JSON을 만드는 일이 아니라, 실패를 다루는 책임을 어디에 둘지 정하는 일에 가깝습니다. ca-tmpl의 선택은 그 책임을 프로젝트 초기에 명시하고, 테스트와 ArchUnit rule로 회귀하지 않게 붙잡아 둔 사례입니다.
|
||||
|
||||
## 코드 예제 / Code samples (있다면)
|
||||
|
||||
```java
|
||||
// 출처: [[wiki/projects/ca-tmpl/api-error-envelope-design]]
|
||||
// 실제 파일: 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
|
||||
// 출처: [[wiki/projects/ca-tmpl/api-error-envelope-design]]
|
||||
// 실제 파일: 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);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
```java
|
||||
// 출처: [[wiki/projects/ca-tmpl/api-error-envelope-design]]
|
||||
// 실제 파일: 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());
|
||||
}
|
||||
```
|
||||
|
||||
```java
|
||||
// 출처: [[wiki/projects/ca-tmpl/api-error-envelope-design]]
|
||||
// 실제 파일: 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
|
||||
# 출처: [[wiki/projects/ca-tmpl/api-error-envelope-design]]
|
||||
# 실제 파일: app-bootstrap/src/main/resources/application.yml
|
||||
spring:
|
||||
mvc:
|
||||
problemdetails:
|
||||
enabled: false
|
||||
```
|
||||
|
||||
## Sources / 근거 (canonical 인용 필수, derived layer 의무)
|
||||
|
||||
- [[wiki/projects/ca-tmpl/api-error-envelope-design]] - 이 글의 1차 canonical. `verified`, `actually-implemented`, `locally-verified` 범위와 과장 금지 항목을 따른다.
|
||||
- [[wiki/concepts/api-error-envelope-design]] - `ProblemDetail`, Google `rpc.Status`, JSON:API errors, GraphQL errors, custom envelope trade-off를 정리한 개념 canonical.
|
||||
- [[raw/project-notes/ca-skeleton-operational-contract]] - Structured API Response Contract, Exception Ownership Contract, Operational Error Category, Topic 4 envelope 대안 검토.
|
||||
- [[raw/branch-notes/feature-operational-error-observability-foundation]] - envelope schema SSOT와 exception leak 금지 catalog.
|
||||
- [[raw/branch-notes/feature-api-contract-baseline]] - 413/406/415/405(+`Allow`)/412 transport failure envelope mapping 검증.
|
||||
- [[raw/blog-topics/spring-responseentityexceptionhandler-transport-failure-envelope-2026-07-02]] - Spring MVC transport failure envelope 글감.
|
||||
- [[raw/blog-topics/operational-error-envelope-meta-category-migration-2026-06-01]] - `error.category`와 `meta` migration 글감.
|
||||
- [[raw/blog-topics/spring-security-filter-layer-error-envelope-2026-06-08]] - Spring Security filter-layer envelope 후속 글감.
|
||||
|
||||
## 사실 vs 의견 / Fact vs opinion 구분
|
||||
|
||||
- 사실: ca-tmpl에는 `Envelope`, `ApiError`, `ResponseMeta`, `Category`, `OperationalError`, `GlobalExceptionHandler`, `ErrorResponseFactory`, `EnvelopeBodyAdvice`가 코드로 존재한다.
|
||||
- 사실: `ProblemDetail`은 ArchUnit rule과 `spring.mvc.problemdetails.enabled: false` 설정으로 금지/비활성화되어 있다.
|
||||
- 사실: `./gradlew check`가 2026-06-01에 통과했고, 413/406/415/405(+`Allow`)/412 transport failure mapping이 테스트로 검증됐다.
|
||||
- 사실: 운영 배포와 prod 검증은 없다.
|
||||
- 의견: ca-tmpl의 요구 조합에서는 `ProblemDetail` 위에 확장을 쌓는 것보다 custom envelope을 명시적으로 고정하는 편이 더 설명 가능하다.
|
||||
- 의견: `retryable`과 `category`를 1급 필드로 두면 client/retry/observability 설계가 단순해진다. 다만 `RetryInfo.retry_delay` 같은 더 구체적인 표준 정보를 잃는 trade-off가 있다.
|
||||
|
||||
## 답할 수 있는 범위 / Answer boundary
|
||||
|
||||
- 자신 있게 답할 수 있음: ca-tmpl이 왜 `ProblemDetail`을 채택하지 않았는지.
|
||||
- 자신 있게 답할 수 있음: envelope field shape과 각 필드의 책임.
|
||||
- 자신 있게 답할 수 있음: 어떤 클래스와 테스트로 계약을 고정했는지.
|
||||
- 자신 있게 답할 수 있음: 어떤 transport failure row가 envelope으로 검증됐는지.
|
||||
- 제한해서 답해야 함: 운영에서의 동작. 현재는 local/dev verification까지만 말한다.
|
||||
- 제한해서 답해야 함: `Retry-After`, 5xx span ERROR, business rule details mapping. 현재 문서 기준으로는 planned/stub 또는 별도 owner branch 범위다.
|
||||
|
||||
## 게시 체크리스트 / Publish checklist
|
||||
|
||||
- [x] 원천 canonical이 `reviewed | verified | published-ready` 상태인지 확인
|
||||
- [x] derived 문서가 raw를 1차 근거처럼 사용하지 않는지 확인
|
||||
- [x] 코드 발췌가 실제 ca-tmpl 코드와 일치하는지 확인
|
||||
- [x] `actually-implemented`, `locally-verified`, `prod-verified` 범위를 분리했는지 확인
|
||||
- [x] 금지 마케팅 표현을 쓰지 않았는지 확인
|
||||
- [x] `canonical_sources`를 실제 인용 canonical로 채웠는지 확인
|
||||
- [x] 본문 작성 후 `status_label`을 `ready`로 갱신했는지 확인
|
||||
|
||||
## Related / 관련
|
||||
|
||||
- 관련 project 문서: [[wiki/projects/ca-tmpl/api-error-envelope-design]]
|
||||
- 관련 concept 문서: [[wiki/concepts/api-error-envelope-design]]
|
||||
- 후속 글 후보: [[raw/blog-topics/spring-responseentityexceptionhandler-transport-failure-envelope-2026-07-02]]
|
||||
- 후속 글 후보: [[raw/blog-topics/spring-security-filter-layer-error-envelope-2026-06-08]]
|
||||
- 후속 글 후보: [[raw/blog-topics/operational-error-envelope-meta-category-migration-2026-06-01]]
|
||||
@@ -1 +0,0 @@
|
||||
../../vault/40-publish/blog/ca-tmpl-api-evolution-and-schema-2026-07-02.md
|
||||
@@ -0,0 +1,257 @@
|
||||
---
|
||||
title: API Evolution은 버전 번호가 아니라 계약의 문제다
|
||||
source_type: blog
|
||||
status: verified
|
||||
confidence: high
|
||||
tags: [blog, ca-tmpl, api-design, versioning, schema]
|
||||
related_projects: [ca-tmpl]
|
||||
last_reviewed: 2026-07-02
|
||||
canonical_sources:
|
||||
- wiki/projects/ca-tmpl/api-evolution-and-schema
|
||||
- wiki/concepts/api-evolution-and-schema
|
||||
audience: backend-engineer
|
||||
target_publish:
|
||||
status_label: ready
|
||||
---
|
||||
|
||||
# API Evolution은 버전 번호가 아니라 계약의 문제다
|
||||
|
||||
> Layer: `wiki/blog/` — **외부 공개용 블로그 글 초안·완성본**. canonical (`wiki/concepts/` + `wiki/projects/`) 에서 파생된 산출물.
|
||||
> 상태: draft → reviewed → verified → **published-ready** (외부 게시 가능)
|
||||
> `status_label`: `outline` | `drafting` | `review` | `ready` | `published` | `retired`
|
||||
> `audience`: `backend-engineer` | `senior-engineer` | `tech-lead` | `general` — 깊이·전문용어 사용량이 달라짐.
|
||||
|
||||
## Parent / 부모 (필수)
|
||||
|
||||
> wiki/blog/ 는 derived layer. **반드시 canonical wiki/concepts/ 또는 wiki/projects/ 에서 파생.** raw 또는 branch에서 직접 파생 금지.
|
||||
|
||||
- 핵심 canonical:
|
||||
- [[wiki/projects/ca-tmpl/api-evolution-and-schema]] — ca-tmpl API contract baseline, compatibility/deprecation, schema/serialization 결정과 검증 범위.
|
||||
- [[wiki/concepts/api-evolution-and-schema]] — API versioning, deprecation, schema compatibility, serialization policy 일반 개념.
|
||||
- 영감 출처:
|
||||
- [[raw/blog-topics/api-deprecation-sunset-header-migration-window-2026-07-02]] — Sunset/Deprecation header와 migration window 글감.
|
||||
- [[raw/blog-topics/spring-boot-serialization-contract-pins-2026-07-02]] — Jackson serialization pin과 BigDecimal constructor guard 글감.
|
||||
|
||||
## 타깃 독자 / Target reader
|
||||
|
||||
> 이 글을 누가 읽을 것인지. 톤·전문용어·깊이가 결정됨.
|
||||
|
||||
- 독자 profile: Spring Boot 기반 REST API를 만들면서 versioning, pagination, deprecation, serialization contract를 어디까지 정해야 하는지 고민하는 백엔드 엔지니어.
|
||||
- 독자가 이미 알고 있을 것이라 가정하는 것: HTTP status, REST endpoint, OpenAPI, Jackson, Spring MVC의 기본 역할.
|
||||
- 독자가 처음 듣는다고 가정하는 것: API evolution을 단순히 `/v1` prefix가 아니라 migration window, compatibility catalog, conditional request, serialization pin까지 포함하는 계약으로 보는 관점.
|
||||
|
||||
## 도입 / Hook
|
||||
|
||||
> 왜 이 글을 쓰는가. 독자에게 이 글의 가치를 1~2문장으로.
|
||||
|
||||
- 문제 / 궁금증: API versioning을 `/v1`만 붙이면 끝난다고 생각하기 쉽지만, 실제로는 pagination cap, ETag, cache header, deprecation signal, serialization default drift까지 모두 contract surface가 된다.
|
||||
- 이 글이 답하는 것: ca-tmpl이 API evolution을 어떤 하위 계약으로 쪼갰고, 그중 무엇은 코드로 구현·로컬 검증됐으며, 무엇은 아직 documented-only인지 구분한다.
|
||||
- 이 글이 답하지 않는 것 (스코프): 실제 외부 client migration 운영 경험, production cutover, 410 응답 전환 실측, Avro Schema Registry 운영 경험.
|
||||
|
||||
## 본문 outline / Body outline
|
||||
|
||||
> 글의 흐름. 초안 단계에서는 outline 만, drafting 단계부터 본문.
|
||||
|
||||
1. API evolution은 `/v1` prefix 하나가 아니다 — versioning, pagination, cache, conditional request, OpenAPI, batch/LRO, serialization policy까지 surface로 본다.
|
||||
2. ca-tmpl에서 실제 구현된 contract baseline — `/v1`, pagination/sort, ETag/If-Match/304/412, `no-store`/`Vary`, OpenAPI producer, LRO, batch endpoint.
|
||||
3. compatibility/deprecation은 아직 문서 계약이다 — 90d/30d window, 7행 breaking change catalog, `Sunset` + `Deprecation`, OpenAPI `deprecated: true`는 구현됐다고 말하지 않는다.
|
||||
4. serialization은 출력측만 로컬 검증됐다 — datetime/BigDecimal pin, effective `ObjectMapper` test, `new BigDecimal(double/float)` ArchUnit ban.
|
||||
5. 표준과 project-local trade-off를 분리하기 — RFC 8594, RFC 9110, RFC 3339, OpenAPI 같은 source-backed 사실과 90d/30d·size cap 100·weak ETag 같은 project decision을 구분한다.
|
||||
6. 블로그에서 과장하면 안 되는 경계 — 운영 deprecation 경험, strong ETag, idempotency replay, OpenAPI drift release gate, money string serialization은 아직 말하면 안 된다.
|
||||
|
||||
## 본문 / Body
|
||||
|
||||
API evolution을 처음 생각할 때 가장 먼저 떠오르는 것은 보통 version number입니다. `/v1`을 붙일지, header로 받을지, 날짜 기반으로 갈지 같은 질문입니다. 그런데 실제로 API가 오래 살아남으려면 version number만으로는 부족합니다.
|
||||
|
||||
API는 한 번 배포되면 client와 약속이 됩니다. 응답 field를 없애는 일, enum 값을 줄이는 일, pagination limit을 바꾸는 일, datetime을 숫자로 보내던 것을 문자열로 바꾸는 일도 모두 client에게는 변화입니다. 그래서 API evolution은 "버전을 어떻게 붙일까"보다 넓은 문제입니다. 더 정확히는 API surface 전체가 어떻게 변할 수 있고, 그 변화가 client를 언제 깨뜨리는지 정하는 계약입니다.
|
||||
|
||||
ca-tmpl의 API Evolution & Schema 문서는 이 문제를 세 갈래로 나눕니다. 첫 번째는 실제 HTTP API의 기본 계약입니다. `/v1` prefix, pagination/sort, ETag와 conditional request, cache header, OpenAPI producer, long-running operation, batch endpoint 같은 것들입니다. 두 번째는 compatibility와 deprecation입니다. 어떤 변경을 breaking으로 볼지, deprecated API를 얼마나 오래 살릴지, `Sunset`과 `Deprecation` header를 어떻게 보낼지에 대한 정책입니다. 세 번째는 schema와 serialization입니다. 날짜와 decimal이 어떤 JSON 모양으로 나가야 하는지, Jackson default가 바뀌어도 계약이 흔들리지 않게 어떻게 고정할지에 대한 문제입니다.
|
||||
|
||||
중요한 점은 이 세 갈래의 검증 수준이 서로 다르다는 것입니다. ca-tmpl에서 API contract baseline은 상당 부분 코드로 구현되고 로컬 테스트로 검증됐습니다. 반면 compatibility/deprecation 정책은 아직 문서 계약입니다. serialization은 출력측 일부가 구현·검증됐지만, 모든 schema evolution 도구가 구현된 것은 아닙니다. 이 구분을 흐리면 블로그 글은 읽기 좋아져도 사실 경계가 무너집니다.
|
||||
|
||||
먼저 구현된 API contract baseline부터 보겠습니다. ca-tmpl은 public endpoint에 `/v1` prefix를 사용합니다. 이것은 단지 URL을 예쁘게 만드는 선택이 아니라, API major version을 route surface에 드러내는 결정입니다. `PresentationSettings`는 `ca-skeleton.presentation.api-base-path`를 읽고, 값이 빠졌거나 `/`로 시작하지 않을 때 보정합니다. canonical 문서 기준으로 운영 default는 `/v1`이고, `VersioningPrefixTest`가 `/v1/probe`는 열리고 `/probe`는 열리지 않는다는 것을 검증합니다.
|
||||
|
||||
pagination도 계약입니다. client가 `size=100000`을 던질 수 있게 두면 서버 resource를 쉽게 압박할 수 있습니다. ca-tmpl의 `PageParams`는 기본 size를 20으로 두고, 1 이상 100 이하만 허용합니다. `page`는 0-indexed이며, deep offset은 `page > 10000`일 때 표시합니다. 여기서 숫자 100과 10000은 표준이 정한 값이 아닙니다. DoS 방어와 cursor pagination 유도라는 project-local trade-off입니다. 따라서 글에서는 "표준이라서 100"이라고 말하면 안 되고, "ca-tmpl이 skeleton 기본값으로 선택한 제한"이라고 말해야 합니다.
|
||||
|
||||
conditional request도 흥미로운 부분입니다. conditional request는 client가 "내가 알고 있는 이전 상태와 같을 때만 처리해 달라" 또는 "내가 가진 버전과 같으면 body를 다시 보내지 않아도 된다"고 말하는 HTTP 메커니즘입니다. ca-tmpl은 entity의 optimistic lock version에서 `W/"<version>"` 형태의 ETag를 만들고, read에서는 `If-None-Match`로 304를, write에서는 `If-Match` mismatch로 412를 냅니다. 이 흐름은 `ETags`와 `PreconditionFailedException`, controller wire test로 검증됩니다.
|
||||
|
||||
다만 여기에도 경계가 있습니다. ca-tmpl의 ETag 비교는 RFC 9110의 strict한 strong comparison 구현이 아닙니다. `ETags.matches`는 `W/` marker와 따옴표를 벗겨 opaque value를 비교하는 lenient 구현입니다. skeleton에서 이해하기 쉬운 optimistic lock bridge를 택한 것이지, production-grade strong ETag semantics를 모두 구현했다고 말하면 안 됩니다.
|
||||
|
||||
cache policy는 더 보수적입니다. 인증된 API에서 cache default를 열어 두면 proxy나 browser cache가 민감한 응답을 붙잡을 수 있습니다. ca-tmpl의 `CacheControlFilter`는 모든 응답에 `Cache-Control: no-store`와 `Vary: Accept, Accept-Encoding, Authorization`을 먼저 박습니다. cacheable endpoint가 필요하면 명시적으로 opt-in해야 합니다. 기본값을 닫고 예외를 열게 만든 셈입니다.
|
||||
|
||||
OpenAPI producer, long-running operation, batch endpoint도 baseline에 들어갑니다. OpenAPI는 `/v3/api-docs`가 열리는지 확인하는 producer 수준까지 구현됐습니다. long-running operation은 `POST /worklogs:export`가 202 Accepted와 `Location` header, polling URL을 돌려주는 sample fixture로 구현됐습니다. batch endpoint는 `POST /worklogs:batchCreate`에서 단일 transaction atomic 처리와 size cap을 검증합니다. 여기까지는 "코드로 구현했고 로컬 검증했다"고 말할 수 있는 범위입니다.
|
||||
|
||||
반대로 compatibility/deprecation은 조심해야 합니다. ca-tmpl은 90일 public, 30일 internal migration window를 문서 계약으로 정했습니다. 응답 field 제거, 응답 field 의미 변화, required request field 추가, enum value 제거, enum value 의미 변화, narrow enum, 기본값 변경을 breaking change catalog로 분류했습니다. 또한 `Sunset` header와 `Deprecation` header를 함께 보내기로 결정했습니다.
|
||||
|
||||
하지만 이것들은 아직 response interceptor나 release gate로 구현된 것이 아닙니다. 실제 API를 deprecated 상태로 운영해 본 것도 아니고, 외부 client가 90일 안에 migration을 끝냈는지 검증한 경험도 없습니다. 따라서 이 부분은 "설계했다", "문서 계약으로 잡았다", "표준과 사례를 비교해 이런 정책을 택했다"까지만 말해야 합니다. "운영에서 검증했다"는 표현은 쓰면 안 됩니다.
|
||||
|
||||
`Sunset`과 `Deprecation`의 차이는 글에서 꼭 풀어야 합니다. `Sunset`은 언제 사라질지를 알려주는 날짜 신호입니다. `Deprecation`은 지금 이미 deprecated 상태인지를 알려주는 신호입니다. 하나만 보내면 정보가 반쪽이 됩니다. ca-tmpl은 그래서 둘을 함께 보내기로 결정했습니다. 여기에 `Link rel="deprecation"`이나 `Link rel="sunset"`을 붙여 사람이 읽을 migration guide로 연결하는 방향도 문서에 잡혀 있습니다. 다시 말하지만, 현재는 결정과 설계이지 구현은 아닙니다.
|
||||
|
||||
schema/serialization 축은 조금 다릅니다. 여기서는 출력측 일부가 실제로 구현됐습니다. ca-tmpl은 Jackson 설정에서 `WRITE_DATES_AS_TIMESTAMPS=false`를 명시해 `OffsetDateTime`과 `LocalDate`가 숫자나 배열이 아니라 ISO-8601 문자열로 나가도록 고정합니다. `WRITE_BIGDECIMAL_AS_PLAIN=true`도 명시해 큰 `BigDecimal`이 scientific notation으로 나가지 않게 합니다.
|
||||
|
||||
흥미로운 점은 이 설정들이 현재 Spring Boot 기본값과 크게 어긋나지 않는다는 것입니다. 그런데도 ca-tmpl은 명시적으로 pin을 둡니다. 이유는 default에 기대면 future default drift를 잡기 어렵기 때문입니다. 그래서 `JacksonSerializationPolicyTest`는 설정 binding만 보는 것이 아니라 실제 wired `ObjectMapper`로 `OffsetDateTime`, `LocalDate`, `BigDecimal`을 직렬화해 봅니다. `JavaTimeModule`이 빠져서 날짜가 배열로 나가는 회귀도 이 테스트가 잡을 수 있습니다.
|
||||
|
||||
`BigDecimal`은 정적 차단까지 들어갑니다. Java에서 `new BigDecimal(0.1)`은 사람이 기대하는 0.1이 아니라 binary floating-point 오차를 품은 값을 만들 수 있습니다. ca-tmpl은 production code에서 `new BigDecimal(double)`과 `new BigDecimal(float)` 생성자를 호출하지 못하도록 ArchUnit rule을 둡니다. 이건 "조심하자"가 아니라 build에서 깨지는 계약입니다.
|
||||
|
||||
하지만 serialization도 모든 것이 끝난 것은 아닙니다. 입력측 strict deserialization, null/empty/missing 3-state 처리, per-API money string-vs-number 선택, OpenAPI drift release gate, 제거 field 재사용 방지 도구, Avro compatibility 자동검사는 각각 다른 owner나 planned 범위에 있습니다. 특히 sample domain에 money field가 없기 때문에 `@JsonSerialize(ToStringSerializer)` 같은 money string serialization 코드 시연은 없습니다.
|
||||
|
||||
이 글의 핵심은 API evolution을 넓게 보되, 구현 등급을 섞지 않는 데 있습니다. `/v1`, pagination, ETag, cache header, OpenAPI producer, LRO, batch endpoint는 로컬 검증된 구현으로 말할 수 있습니다. deprecation policy는 문서 계약으로 말해야 합니다. serialization output pin과 BigDecimal guard는 로컬 검증으로 말할 수 있습니다. strong ETag, idempotency replay, OpenAPI release gate, production deprecation 운영은 아직 말하면 안 됩니다.
|
||||
|
||||
좋은 skeleton은 단지 "예제 endpoint가 동작한다"에서 끝나지 않습니다. 나중에 API가 변할 때 어떤 변화가 안전하고, 어떤 변화가 client를 깨뜨리며, 어떤 값은 default에 기대지 않고 명시적으로 고정해야 하는지까지 알려 줘야 합니다. ca-tmpl의 API Evolution & Schema 결정은 그 방향을 잡은 문서입니다. 일부는 이미 코드와 테스트로 내려왔고, 일부는 앞으로 구현해야 할 계약으로 남아 있습니다. 이 둘을 구분해서 설명할 수 있을 때, 비로소 이 주제를 내 프로젝트 경험으로 말할 수 있습니다.
|
||||
|
||||
## 코드 예제 / Code samples (있다면)
|
||||
|
||||
> 가능한 실제 프로젝트 코드 인용. 가짜 예제 금지. 추출 시 출처 PR·커밋 명시.
|
||||
|
||||
```java
|
||||
// 출처: [[wiki/projects/ca-tmpl/api-evolution-and-schema]]
|
||||
// 실제 파일: adapter-web/src/main/java/dev/caskeleton/adapter/web/settings/PresentationSettings.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;
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
```java
|
||||
// 출처: [[wiki/projects/ca-tmpl/api-evolution-and-schema]]
|
||||
// 실제 파일: adapter-web/src/main/java/dev/caskeleton/adapter/web/pagination/PageParams.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;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
```java
|
||||
// 출처: [[wiki/projects/ca-tmpl/api-evolution-and-schema]]
|
||||
// 실제 파일: adapter-web/src/main/java/dev/caskeleton/adapter/web/conditional/ETags.java
|
||||
public static String weakFromVersion(long version) {
|
||||
return "W/\"" + version + "\"";
|
||||
}
|
||||
```
|
||||
|
||||
```java
|
||||
// 출처: [[wiki/projects/ca-tmpl/api-evolution-and-schema]]
|
||||
// 실제 파일: adapter-web/src/main/java/dev/caskeleton/adapter/web/filter/CacheControlFilter.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);
|
||||
}
|
||||
```
|
||||
|
||||
```java
|
||||
// 출처: [[wiki/projects/ca-tmpl/api-evolution-and-schema]]
|
||||
// 실제 파일: sample-portfolio/src/main/java/.../OperationsController.java
|
||||
@PostMapping("/worklogs:export")
|
||||
public ResponseEntity<Operation<WorkLogExportResult>> export() {
|
||||
Operation<WorkLogExportResult> accepted =
|
||||
operations.startExport(presentationSettings.apiBasePath(), 0L);
|
||||
return ResponseEntity.accepted().location(URI.create(accepted.statusUrl())).body(accepted);
|
||||
}
|
||||
```
|
||||
|
||||
```java
|
||||
// 출처: [[wiki/projects/ca-tmpl/api-evolution-and-schema]]
|
||||
// 실제 파일: app-bootstrap/src/test/java/.../JacksonSerializationPolicyTest.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");
|
||||
```
|
||||
|
||||
```java
|
||||
// 출처: [[wiki/projects/ca-tmpl/api-evolution-and-schema]]
|
||||
// 실제 파일: app-bootstrap/src/test/java/.../CleanArchitectureTest.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);
|
||||
```
|
||||
|
||||
## Sources / 근거 (canonical 인용 필수, derived layer 의무)
|
||||
|
||||
> 모든 사실 주장은 canonical 또는 raw 인용으로 뒷받침. 자기 추론은 명시적으로 "내 해석" 으로 분리.
|
||||
|
||||
- [[wiki/projects/ca-tmpl/api-evolution-and-schema]] — 이 글의 1차 canonical. API contract baseline과 schema/serialization 출력측은 구현·로컬 검증 범위, compatibility/deprecation은 documented-only 범위로 구분한다.
|
||||
- [[wiki/concepts/api-evolution-and-schema]] — API versioning/deprecation/schema compatibility의 일반 개념과 표준·사례 비교.
|
||||
- [[raw/branch-notes/feature-api-contract-baseline]] — `/v1`, pagination/sort, conditional request, cache header, OpenAPI producer, LRO, batch endpoint 구현·검증 근거.
|
||||
- [[raw/branch-notes/feature-api-compatibility-deprecation-contract]] — 90d/30d migration window, breaking change catalog, Sunset+Deprecation decision. 현재 documented-only.
|
||||
- [[raw/branch-notes/feature-schema-serialization-contract]] — Jackson serialization output pin, BigDecimal constructor guard, serialization policy test.
|
||||
- [[raw/blog-topics/api-deprecation-sunset-header-migration-window-2026-07-02]] — deprecation/migration window 블로그 글감 raw seed.
|
||||
- [[raw/blog-topics/spring-boot-serialization-contract-pins-2026-07-02]] — Spring Boot serialization contract pin 블로그 글감 raw seed.
|
||||
- [[raw/official-docs/sunset-deprecation-headers-paired-usage]] — `Sunset` + `Deprecation` header paired usage 근거.
|
||||
- [[raw/official-docs/rfc9110-http-semantics]] — conditional request, 304/412, transport status 의미.
|
||||
- [[raw/official-docs/rfc3339-datetime-utc]] — datetime serialization 표현 근거.
|
||||
|
||||
## 사실 vs 의견 / Fact vs opinion 구분
|
||||
|
||||
> 독자가 자신 있게 인용할 수 있도록.
|
||||
|
||||
- **사실 (검증됨)**:
|
||||
- ca-tmpl의 API contract baseline 일부는 코드로 구현되어 있고 `./gradlew check`/테스트로 로컬 검증됐다. 근거: [[wiki/projects/ca-tmpl/api-evolution-and-schema]]
|
||||
- `/v1` prefix, pagination/sort, ETag/If-Match/304/412, cache header, OpenAPI producer, LRO, batch endpoint는 구현 범위에 포함된다. 근거: [[wiki/projects/ca-tmpl/api-evolution-and-schema]]
|
||||
- Jackson serialization 출력측 pin과 `new BigDecimal(double/float)` 정적 차단은 로컬 검증됐다. 근거: [[wiki/projects/ca-tmpl/api-evolution-and-schema]]
|
||||
- **내 해석·의견 (검증 안 된 추론)**:
|
||||
- API evolution을 versioning 하나가 아니라 "API surface 전체의 변화 관리"로 보면 skeleton 단계에서 정해야 할 계약이 더 선명해진다.
|
||||
- default 값을 그대로 믿는 것보다 명시 pin과 effective-bean test를 두는 편이 skeleton template에는 설명 가능하다.
|
||||
- **알지 못하는 것**:
|
||||
- 실제 external client migration이 90d/30d window로 충분했는지 알 수 없다. 운영 배포가 없다.
|
||||
- compatibility/deprecation header를 실제 response interceptor로 구현하고 cutover까지 운영해 본 경험은 없다.
|
||||
- OpenAPI drift release gate, Avro compatibility, money string-vs-number per-API serialization은 아직 구현·검증 범위가 아니다.
|
||||
|
||||
## 답할 수 있는 범위 / Answer boundary
|
||||
|
||||
> 이 글을 읽은 사람에게 후속 질문을 받았을 때 자신 있게 답할 수 있는 범위.
|
||||
|
||||
- 자신 있게 답할 수 있는 후속 질문:
|
||||
- ca-tmpl에서 API contract baseline을 어떤 항목으로 나눴는가?
|
||||
- `/v1` path prefix와 ETag/If-Match/304/412를 어떤 테스트로 검증했는가?
|
||||
- `Sunset`과 `Deprecation` header는 어떤 차이가 있고 왜 함께 보내기로 했는가?
|
||||
- Jackson serialization pin과 BigDecimal constructor guard는 왜 두었는가?
|
||||
- "그건 다음 글에서 다루겠다" 라고 해야 하는 부분:
|
||||
- 실제 deprecation 운영과 client migration coordination.
|
||||
- release-blocking OpenAPI drift gate 구현.
|
||||
- idempotency replay semantics.
|
||||
- strong ETag 전환.
|
||||
- per-API money string serialization code sample.
|
||||
|
||||
## 게시 체크리스트 / Publish checklist
|
||||
|
||||
`ready` → `published` 로 올리기 전 확인.
|
||||
|
||||
- [x] 모든 사실 주장에 canonical 링크 있음
|
||||
- [x] 사실 vs 의견 분리 명시됨
|
||||
- [x] 금지 마케팅 표현 없음
|
||||
- [x] 코드 예제 출처 명시
|
||||
- [x] 타깃 독자 가정과 톤 일치
|
||||
- [x] `/lint` 통과
|
||||
- [ ] 게시 URL 기록 (게시 후):
|
||||
|
||||
## Related / 관련
|
||||
|
||||
- 후속 글 후보: [[wiki/blog/ca-tmpl-api-error-envelope-design-2026-07-02]]
|
||||
- 관련 포트폴리오 항목: `[[wiki/portfolio/<...>]]`
|
||||
- 영감을 받은 raw 자료: [[raw/blog-topics/api-deprecation-sunset-header-migration-window-2026-07-02]], [[raw/blog-topics/spring-boot-serialization-contract-pins-2026-07-02]]
|
||||
@@ -1 +0,0 @@
|
||||
../../vault/40-publish/blog/ca-tmpl-boundary-validation-mapping-2026-07-02.md
|
||||
@@ -0,0 +1,255 @@
|
||||
---
|
||||
title: 입력 경계에서 검증과 매핑 책임을 분리하기
|
||||
source_type: blog
|
||||
status: verified
|
||||
confidence: high
|
||||
tags: [blog, ca-tmpl, validation, mapper, archunit]
|
||||
related_projects: [ca-tmpl]
|
||||
last_reviewed: 2026-07-02
|
||||
canonical_sources:
|
||||
- wiki/projects/ca-tmpl/boundary-validation-mapping
|
||||
audience: backend-engineer
|
||||
target_publish:
|
||||
status_label: ready
|
||||
---
|
||||
|
||||
# 입력 경계에서 검증과 매핑 책임을 분리하기
|
||||
|
||||
> Layer: `wiki/blog/` — **외부 공개용 블로그 글 초안·완성본**. canonical (`wiki/concepts/` + `wiki/projects/`) 에서 파생된 산출물.
|
||||
> 상태: draft → reviewed → verified → **published-ready** (외부 게시 가능)
|
||||
> `status_label`: `outline` | `drafting` | `review` | `ready` | `published` | `retired`
|
||||
> `audience`: `backend-engineer` | `senior-engineer` | `tech-lead` | `general` — 깊이·전문용어 사용량이 달라짐.
|
||||
|
||||
## Parent / 부모 (필수)
|
||||
|
||||
> wiki/blog/ 는 derived layer. **반드시 canonical wiki/concepts/ 또는 wiki/projects/ 에서 파생.** raw 또는 branch에서 직접 파생 금지.
|
||||
|
||||
- 핵심 canonical:
|
||||
- [[wiki/projects/ca-tmpl/boundary-validation-mapping]] — ca-tmpl 입력 경계 검증, DTO↔도메인 매핑, ArchUnit 정적 강제 구현·검증 범위.
|
||||
- 관련 개념 문서:
|
||||
- [[wiki/concepts/boundary-validation-and-dto-mapping]] — boundary validation과 DTO/domain mapping 일반 개념. 현재 concept 문서는 `draft`이므로 이 글의 구현 사실 근거는 verified project canonical에 둔다.
|
||||
- 영감 출처:
|
||||
- [[raw/blog-topics/boundary-validation-mapper-responsibility-map-2026-07-02]] — validation/mapper 책임 분리 글감.
|
||||
- [[raw/blog-topics/archunit-jackson-default-typing-cve-2019-14379-block-2026-05-29]] — Jackson default typing 위험 API 정적 차단 글감.
|
||||
|
||||
## 타깃 독자 / Target reader
|
||||
|
||||
> 이 글을 누가 읽을 것인지. 톤·전문용어·깊이가 결정됨.
|
||||
|
||||
- 독자 profile: Spring Boot API에서 request DTO, validation, mapper, domain model 경계를 어디에 둘지 고민하는 백엔드 엔지니어.
|
||||
- 독자가 이미 알고 있을 것이라 가정하는 것: Bean Validation, DTO, controller/service 계층, Jackson, 기본적인 Clean Architecture 용어.
|
||||
- 독자가 처음 듣는다고 가정하는 것: PATCH 3-state, mapper 실패와 validation 실패의 분리, ArchUnit으로 boundary rule을 build-time contract로 만드는 방식.
|
||||
|
||||
## 도입 / Hook
|
||||
|
||||
> 왜 이 글을 쓰는가. 독자에게 이 글의 가치를 1~2문장으로.
|
||||
|
||||
- 문제 / 궁금증: 입력 검증과 DTO↔domain mapping을 한 계층에 몰아두면 request DTO가 application layer까지 새거나, domain/entity가 response로 silent 직렬화되거나, PATCH가 기존 값을 조용히 덮어쓰는 회귀가 생긴다.
|
||||
- 이 글이 답하는 것: ca-tmpl이 입력 syntax, mapper, response shaping, outbound ACL, polymorphic deserialization, envelope error mapping을 어떻게 나누고 어떤 것은 ArchUnit으로 강제했는지 정리한다.
|
||||
- 이 글이 답하지 않는 것 (스코프): 운영 트래픽 검증, 실 DB/Testcontainers 통합 검증, 실 외부 HTTP/WireMock 통합, OpenAPI `oneOf` response shape 명세.
|
||||
|
||||
## 본문 outline / Body outline
|
||||
|
||||
> 글의 흐름. 초안 단계에서는 outline 만, drafting 단계부터 본문.
|
||||
|
||||
1. 경계가 흐려질 때 생기는 문제 — request DTO leak, domain/entity response leak, PATCH silent overwrite, raw external response leak.
|
||||
2. validation 실패와 mapping 실패를 분리하기 — Spring이 다루는 request parsing/validation은 `VALIDATION_FAILED`, mapper 내부 의미 실패는 `MappingException`→`MAPPING_FAILED`.
|
||||
3. PATCH 3-state를 `JsonNullable<T>`에서 `Patch<T>`로 옮기기 — web adapter의 Jackson-aware 타입을 application-core 밖으로 가두고 absent/null/value를 보존한다.
|
||||
4. polymorphic deserialization을 allowlist로 제한하기 — Jackson default typing 위험 API를 ArchUnit rule로 막고, `@JsonTypeInfo` + subtype allowlist만 허용한다.
|
||||
5. DTO/domain/persistence 경계를 ArchUnit fitness function으로 고정하기 — controller 반환 타입, application method parameter, ProblemDetail import, merge-patch media type, `@Valid` cascade depth, outbound ACL return type을 build-time으로 검증한다.
|
||||
6. 검증된 범위와 아직 아닌 범위 — WorkLog wire/unit test, ArchitectureViolationFixtureTest, virtual-thread MDC e2e는 검증됐지만 운영, 실 DB 통합, 실 외부 HTTP 통합, OpenAPI shape 명세는 아니다.
|
||||
|
||||
## 본문 / Body
|
||||
|
||||
입력 경계는 처음에는 controller의 `@Valid` 정도로 끝나는 문제처럼 보입니다. request body를 DTO로 받고, Bean Validation으로 검사하고, service에 넘기면 충분해 보입니다. 그런데 프로젝트가 커질수록 이 경계는 생각보다 쉽게 흐려집니다.
|
||||
|
||||
예를 들어 request DTO가 application layer까지 들어가면 application core가 web framework의 모양을 알게 됩니다. 반대로 domain entity나 JPA entity가 controller response로 바로 나가면, 내부 모델이 외부 API contract가 되어 버립니다. PATCH에서는 더 미묘한 문제가 생깁니다. field가 아예 빠진 것인지, 명시적으로 `null`을 보낸 것인지, 새 값을 보낸 것인지 구분하지 못하면 기존 값을 조용히 덮어쓰는 버그가 생깁니다.
|
||||
|
||||
ca-tmpl의 boundary validation/mapping 계약은 이 문제를 “입력 검증을 어디서 하느냐” 하나로 보지 않습니다. request parsing, syntax validation, mapper, domain/application command, response shaping, outbound ACL을 서로 다른 책임으로 나눕니다. 그리고 중요한 경계는 ArchUnit rule과 wire-level test로 고정합니다. 컨벤션 문서에만 적어두는 것이 아니라, 누군가 실수로 깨면 build가 실패하게 만드는 방식입니다.
|
||||
|
||||
먼저 validation 실패와 mapping 실패를 분리합니다. Spring MVC가 request body를 읽지 못하거나 Bean Validation을 통과하지 못한 경우는 `VALIDATION_FAILED`입니다. 예를 들어 `MethodArgumentNotValidException`, `HttpMessageNotReadableException`, `ConstraintViolationException` 계열입니다. 반면 payload는 구조적으로 들어왔지만 mapper가 의미상 domain command로 바꿀 수 없는 경우는 `MappingException`입니다. ca-tmpl은 이것을 `MAPPING_FAILED`로 분류합니다.
|
||||
|
||||
이 분리가 중요한 이유는 실패의 원인이 다르기 때문입니다. validation failure는 보통 client가 입력 형식을 잘못 보냈다는 뜻입니다. mapping failure는 한 단계 더 안쪽입니다. 예를 들어 link URI가 형식은 문자열이지만 project가 받아들일 수 없는 형태라면, mapper가 그것을 domain command로 바꾸지 못합니다. 둘을 모두 “bad request”로만 뭉개면 운영 분류와 client 디버깅이 어려워집니다.
|
||||
|
||||
PATCH 3-state도 이 글의 핵심입니다. 일반 update에서는 `null`을 “값을 지운다”로 볼 수 있지만, PATCH에서는 field가 빠진 상태와 field가 `null`인 상태가 다릅니다. 빠졌다는 것은 변경하지 말라는 뜻이고, 명시적 `null`은 비우라는 뜻일 수 있습니다. ca-tmpl은 web adapter에서 `JsonNullable<T>`를 받고, application으로 넘기기 전에 Jackson-free 타입인 `Patch<T>`로 변환합니다.
|
||||
|
||||
이렇게 하면 application-core는 Jackson을 모릅니다. application은 `Patch.absent()`, `Patch.ofNull()`, `Patch.of(value)`만 보고 의도를 판단합니다. web adapter는 wire 표현을 domain/application이 이해할 수 있는 작은 값 객체로 번역하는 역할만 맡습니다. 이것이 DTO와 command 사이의 mapper 책임입니다.
|
||||
|
||||
polymorphic deserialization도 경계 문제입니다. Jackson default typing은 임의 subtype을 열어 둘 수 있고, 과거 CVE-2019-14379 같은 gadget chain 위험과 연결됩니다. ca-tmpl은 `ObjectMapper.enableDefaultTyping()` 호출과 `LaissezFaireSubTypeValidator` 의존을 ArchUnit으로 막습니다. 대신 `@JsonTypeInfo(use = NAME)`과 명시적인 `@JsonSubTypes` allowlist를 사용합니다. 즉 “다형성을 쓰지 않는다”가 아니라, 허용된 이름과 타입만 받게 하는 것입니다.
|
||||
|
||||
DTO/domain/persistence 경계는 정적 rule로 고정합니다. controller public method가 domain entity, JPA entity, repository type을 반환하지 못하게 막습니다. application public method가 web DTO를 parameter로 받지 못하게 막습니다. RFC 7807 `ProblemDetail` import도 막고, `application/merge-patch+json` media type 문자열도 막습니다. outbound adapter public method가 raw external response type을 밖으로 흘리지 못하게 하는 ACL rule도 있습니다.
|
||||
|
||||
여기서 ArchUnit은 “아키텍처 다이어그램 검사기”가 아닙니다. 사람이 리뷰에서 놓치기 쉬운 carrier를 build-time에 잡는 fitness function에 가깝습니다. 특히 ca-tmpl은 violations-as-data 방식을 씁니다. 의도적으로 잘못된 fixture를 만들어 rule이 실제 위반을 잡는지 테스트합니다. 이것은 rule이 아무 것도 검사하지 않는데 green이 되는 vacuous pass를 줄이는 장치입니다.
|
||||
|
||||
물론 이 계약이 모든 것을 해결한 것은 아닙니다. 현재 검증 범위는 local/dev입니다. `WorkLogControllerWireTest`, unit/contract test, virtual-thread MDC test, `CleanArchitectureTest`와 violation fixture가 있습니다. 하지만 운영 배포는 없고, 실 DB 통합 테스트도 없습니다. outbound ACL도 실 `WebClient`/`RestClient`와 WireMock 왕복으로 검증된 것은 아닙니다. OpenAPI `oneOf` response shape 명세도 아직 planned입니다.
|
||||
|
||||
정리하면 ca-tmpl의 boundary validation/mapping 결정은 “controller에 `@Valid` 붙였다”보다 훨씬 넓은 계약입니다. request DTO가 어디까지 들어갈 수 있는지, mapper 실패는 어떤 error code가 되는지, PATCH의 세 상태를 어떻게 보존하는지, 외부 응답 raw type이 domain으로 어떻게 정리되는지, 그리고 그 규칙을 어떤 ArchUnit rule로 고정하는지를 함께 다룹니다. 좋은 경계 설계는 한 번의 아름다운 mapper가 아니라, 다음 사람이 무심코 깨뜨려도 build가 알려주는 구조에 가깝습니다.
|
||||
|
||||
## 코드 예제 / Code samples (있다면)
|
||||
|
||||
> 가능한 실제 프로젝트 코드 인용. 가짜 예제 금지. 추출 시 출처 PR·커밋 명시.
|
||||
|
||||
```java
|
||||
// 출처: [[wiki/projects/ca-tmpl/boundary-validation-mapping]]
|
||||
// 실제 파일: shared-contract/src/main/java/dev/caskeleton/shared/request/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
|
||||
// 출처: [[wiki/projects/ca-tmpl/boundary-validation-mapping]]
|
||||
// 실제 파일: sample-portfolio/.../CreateWorkLogRequest.java
|
||||
@GroupSequence({Syntax.class, Invariant.class, CreateWorkLogRequest.class})
|
||||
public record CreateWorkLogRequest(
|
||||
@NotBlank(groups = Syntax.class) @Size(max = 200, groups = Syntax.class) String title,
|
||||
@NotNull(groups = Syntax.class) WorkCategory category,
|
||||
@NotNull(groups = Syntax.class) LocalDate periodStart,
|
||||
LocalDate periodEnd) {
|
||||
|
||||
@AssertTrue(message = "periodEnd must not be before periodStart", groups = Invariant.class)
|
||||
public boolean isPeriodOrdered() { ... }
|
||||
}
|
||||
```
|
||||
|
||||
```java
|
||||
// 출처: [[wiki/projects/ca-tmpl/boundary-validation-mapping]]
|
||||
// 실제 파일: 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());
|
||||
}
|
||||
```
|
||||
|
||||
```java
|
||||
// 출처: [[wiki/projects/ca-tmpl/boundary-validation-mapping]]
|
||||
// 실제 파일: 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 {}
|
||||
```
|
||||
|
||||
```java
|
||||
// 출처: [[wiki/projects/ca-tmpl/boundary-validation-mapping]]
|
||||
// 실제 파일: shared-contract/src/main/java/dev/caskeleton/shared/error/MappingException.java
|
||||
public class MappingException extends RuntimeException {
|
||||
public MappingException(String message) {
|
||||
super(message);
|
||||
}
|
||||
|
||||
public MappingException(String message, Throwable cause) {
|
||||
super(message, cause);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
```java
|
||||
// 출처: [[wiki/projects/ca-tmpl/boundary-validation-mapping]]
|
||||
// 실제 파일: adapter-web/src/main/java/dev/caskeleton/adapter/web/error/GlobalExceptionHandler.java
|
||||
@ExceptionHandler(MappingException.class)
|
||||
public ResponseEntity<Envelope<Void>> handleMapping(MappingException ex) {
|
||||
return ErrorResponseFactory.envelope(OperationalError.MAPPING_FAILED, ex.getMessage(), null);
|
||||
}
|
||||
```
|
||||
|
||||
```java
|
||||
// 출처: [[wiki/projects/ca-tmpl/boundary-validation-mapping]]
|
||||
// 실제 파일: 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());
|
||||
}
|
||||
```
|
||||
|
||||
```java
|
||||
// 출처: [[wiki/projects/ca-tmpl/boundary-validation-mapping]]
|
||||
// 실제 파일: app-bootstrap/src/test/java/.../CleanArchitectureTest.java
|
||||
@ArchTest
|
||||
static final ArchRule APPLICATION_METHODS_DO_NOT_ACCEPT_WEB_DTOS =
|
||||
methods()
|
||||
.that()
|
||||
.areDeclaredInClassesThat()
|
||||
.resideInAPackage("..application..")
|
||||
.and()
|
||||
.arePublic()
|
||||
.should()
|
||||
.notHaveRawParameterTypes(...);
|
||||
```
|
||||
|
||||
## Sources / 근거 (canonical 인용 필수, derived layer 의무)
|
||||
|
||||
> 모든 사실 주장은 canonical 또는 raw 인용으로 뒷받침. 자기 추론은 명시적으로 "내 해석" 으로 분리.
|
||||
|
||||
- [[wiki/projects/ca-tmpl/boundary-validation-mapping]] — 이 글의 1차 canonical. `verified`, `actually-implemented`, `locally-verified` 범위와 planned 항목을 따른다.
|
||||
- [[wiki/concepts/boundary-validation-and-dto-mapping]] — boundary validation, DTO/domain mapping, mapper responsibility의 관련 개념 문서. 현재 `draft`이므로 project 구현 사실의 출처로 쓰지 않는다.
|
||||
- [[raw/branch-notes/feature-boundary-validation-mapping-contract]] — feature branch 결정, Decision Evidence Map, 구현 결과.
|
||||
- [[raw/blog-topics/boundary-validation-mapper-responsibility-map-2026-07-02]] — validation/mapper responsibility map 블로그 글감 raw seed.
|
||||
- [[raw/blog-topics/archunit-jackson-default-typing-cve-2019-14379-block-2026-05-29]] — Jackson default typing 위험 API static block 블로그 글감 raw seed.
|
||||
- [[raw/official-docs/validation-jakarta-bean-validation-3.0-spec]] — `@GroupSequence`, `@Valid` cascade 근거.
|
||||
- [[raw/official-docs/spring-mvc-rest-exception-handling]] — Spring MVC validation/deserialization exception 처리 근거.
|
||||
- [[raw/official-docs/patch-json-merge-rfc7396]] — JSON Merge Patch null semantics와 미채택 근거.
|
||||
- [[raw/official-docs/schema-jackson-polymorphic-deserialization]] — Jackson polymorphic deserialization 위험과 allowlist API 근거.
|
||||
|
||||
## 사실 vs 의견 / Fact vs opinion 구분
|
||||
|
||||
> 독자가 자신 있게 인용할 수 있도록.
|
||||
|
||||
- **사실 (검증됨)**:
|
||||
- `Patch<T>`, `MappingException`, `GlobalExceptionHandler`, `EnvelopeBodyAdvice`, WorkLog request/mapper, outbound ACL mapper, boundary ArchUnit rules는 ca-tmpl 코드에 존재한다. 근거: [[wiki/projects/ca-tmpl/boundary-validation-mapping]]
|
||||
- `WorkLogControllerWireTest`, unit/contract tests, virtual-thread MDC tests, `CleanArchitectureTest` + violation fixtures가 로컬 검증 범위에 포함된다. 근거: [[wiki/projects/ca-tmpl/boundary-validation-mapping]]
|
||||
- 운영 배포와 prod 검증은 없다. 근거: [[wiki/projects/ca-tmpl/boundary-validation-mapping]]
|
||||
- **내 해석·의견 (검증 안 된 추론)**:
|
||||
- validation/mapping 책임을 하나의 mapper나 controller에 몰지 않고 boundary rule로 쪼개면, skeleton 사용자가 깨뜨리기 쉬운 회귀를 더 빨리 발견할 수 있다.
|
||||
- violations-as-data 방식은 ArchUnit rule이 실제 위반을 잡는지 확인하는 데 좋은 학습 소재다.
|
||||
- **알지 못하는 것**:
|
||||
- 실 DB 통합에서 PATCH/mapper 정책이 어떻게 동작하는지는 아직 검증되지 않았다.
|
||||
- 실 외부 HTTP + WireMock 기반 outbound ACL 검증은 아직 없다.
|
||||
- OpenAPI `oneOf` response shape 명세는 아직 planned다.
|
||||
|
||||
## 답할 수 있는 범위 / Answer boundary
|
||||
|
||||
> 이 글을 읽은 사람에게 후속 질문을 받았을 때 자신 있게 답할 수 있는 범위.
|
||||
|
||||
- 자신 있게 답할 수 있는 후속 질문:
|
||||
- validation 실패와 mapping 실패를 왜 다른 error category로 나눴는가?
|
||||
- PATCH absent/null/value를 왜 구분해야 하는가?
|
||||
- Jackson default typing 위험 API를 어떤 ArchUnit rule로 막았는가?
|
||||
- controller/application/outbound boundary를 어떤 정적 rule로 강제했는가?
|
||||
- violations-as-data fixture가 왜 필요한가?
|
||||
- "그건 다음 글에서 다루겠다" 라고 해야 하는 부분:
|
||||
- 실 DB/Testcontainers 통합 검증.
|
||||
- 실 외부 HTTP/WireMock 기반 ACL 검증.
|
||||
- OpenAPI response shape `oneOf` 명세.
|
||||
- 운영 트래픽에서 이 계약이 인시던트를 줄였는지에 대한 측정.
|
||||
|
||||
## 게시 체크리스트 / Publish checklist
|
||||
|
||||
`ready` → `published` 로 올리기 전 확인.
|
||||
|
||||
- [x] 모든 사실 주장에 canonical 링크 있음
|
||||
- [x] 사실 vs 의견 분리 명시됨
|
||||
- [x] 금지 마케팅 표현 없음
|
||||
- [x] 코드 예제 출처 명시
|
||||
- [x] 타깃 독자 가정과 톤 일치
|
||||
- [x] `/lint` 통과
|
||||
- [ ] 게시 URL 기록 (게시 후):
|
||||
|
||||
## Related / 관련
|
||||
|
||||
- 후속 글 후보: [[wiki/blog/ca-tmpl-api-error-envelope-design-2026-07-02]]
|
||||
- 영감을 받은 raw 자료: [[raw/blog-topics/boundary-validation-mapper-responsibility-map-2026-07-02]], [[raw/blog-topics/archunit-jackson-default-typing-cve-2019-14379-block-2026-05-29]]
|
||||
@@ -1 +0,0 @@
|
||||
../../vault/40-publish/blog/ca-tmpl-clean-architecture-package-layout-2026-07-02.md
|
||||
@@ -0,0 +1,199 @@
|
||||
---
|
||||
title: Clean Architecture를 패키지 구조로 강제하기
|
||||
source_type: blog
|
||||
status: verified
|
||||
confidence: high
|
||||
tags: [blog, ca-tmpl, clean-architecture, package-layout]
|
||||
related_projects: [ca-tmpl]
|
||||
last_reviewed: 2026-07-02
|
||||
canonical_sources:
|
||||
- wiki/projects/ca-tmpl/clean-architecture-package-layout
|
||||
audience: backend-engineer
|
||||
target_publish:
|
||||
status_label: ready
|
||||
---
|
||||
|
||||
# Clean Architecture를 패키지 구조로 강제하기
|
||||
|
||||
## Parent / 부모 (필수)
|
||||
|
||||
- 핵심 canonical: [[wiki/projects/ca-tmpl/clean-architecture-package-layout]] — ca-tmpl module/package blueprint, Gradle dependency matrix, ArchUnit boundary rule, negative fixture 검증 범위.
|
||||
- 관련 개념 문서: [[wiki/concepts/clean-architecture-package-layout]] — Clean Architecture package layout 일반 비교. 현재 concept 문서는 `draft`이므로 이 글의 구현 사실 근거는 verified project canonical에 둔다.
|
||||
|
||||
## 타깃 독자 / Target reader
|
||||
|
||||
- 독자 profile: Clean Architecture를 Java/Spring 멀티모듈 skeleton에 적용하려는 백엔드 엔지니어.
|
||||
- 이미 안다고 가정하는 것: controller, application service, domain, adapter 계층.
|
||||
- 처음 듣는다고 가정하는 것: package layout 자체를 ArchUnit fitness function으로 고정하는 방식.
|
||||
|
||||
## 도입 / Hook
|
||||
|
||||
- 문제 / 궁금증: Clean Architecture는 그림으로는 쉽지만, package가 흐트러지면 금방 관례가 된다.
|
||||
- 이 글이 답하는 것: ca-tmpl이 package/module layout과 dependency rule을 어떻게 구현·검증했는지.
|
||||
- 이 글이 답하지 않는 것: 모든 도메인에 맞는 universal package 구조.
|
||||
|
||||
## 본문 outline / Body outline
|
||||
|
||||
1. 계층 그림만으로는 부족하다 — import 방향이 깨지면 architecture도 깨진다.
|
||||
2. ca-tmpl의 module/package layout — domain, application, adapters, shared contract의 책임.
|
||||
3. ArchUnit rule로 강제하기 — 금지 import와 boundary violation을 build에서 잡는다.
|
||||
4. sample-portfolio 격리 — 예제 코드는 template core와 분리한다.
|
||||
5. 말할 수 있는 범위 — local verification과 planned/open risk를 구분한다.
|
||||
|
||||
## 본문 / Body
|
||||
|
||||
Clean Architecture는 그림으로 보면 단순합니다. domain은 안쪽에 있고, application은 use case를 담고, adapter는 바깥쪽 기술을 맡습니다. 문제는 그림이 아니라 시간이 지난 뒤의 코드입니다. controller가 repository를 직접 부르거나, application이 web DTO를 parameter로 받거나, shared package가 business common dumping ground가 되기 시작하면 구조는 이름만 남습니다.
|
||||
|
||||
ca-tmpl은 이 문제를 package naming convention만으로 해결하지 않았습니다. Gradle multi-module을 1차 경계로 두고, ArchUnit을 2차 경계로 둡니다. build graph에서는 어떤 module이 어떤 module을 의존할 수 있는지 검사하고, source import graph에서는 domain/application/adapter/shared-contract가 금지된 타입을 가져오는지 검사합니다. 즉 “Clean Architecture로 짰다”가 아니라, 깨졌을 때 build가 알려주는 skeleton을 만들려는 결정입니다.
|
||||
|
||||
현재 ca-tmpl의 production root는 `dev.caskeleton`입니다. bootstrap은 `dev.caskeleton.bootstrap`에 있고, domain/application/adapter/shared package를 component scan 대상으로 명시합니다. module은 `domain-core`, `application-core`, `adapter-web`, `adapter-persistence-rdbms`, `adapter-persistence-postgresql`, `adapter-outbound`, `adapter-identifier`, `shared-contract`, `app-bootstrap`, `sample-portfolio`로 나뉘어 있습니다. project canonical의 최초 slice는 8개 module blueprint였고, 이후 다른 slice에서 identifier와 persistence 세분화가 추가됐습니다. 그래서 이 글에서는 “현재 HEAD의 module 수”와 “그 slice가 검증한 결정”을 섞어 말하지 않습니다.
|
||||
|
||||
Gradle 쪽 핵심은 `verifyCleanArchitectureDependencies`입니다. 이 task는 module별 허용 dependency를 whitelist로 들고 있다가, 허용되지 않은 `project()` dependency가 들어오면 실패합니다. 예를 들어 web adapter가 persistence adapter나 outbound adapter를 직접 의존하면 안 됩니다. app-bootstrap은 composition root라 여러 module을 조립할 수 있지만, production code가 `sample-portfolio`에 의존하는 것은 금지됩니다. sample은 학습과 fixture 역할을 하는 소비자 module이지 production core가 기대는 기반 module이 아니기 때문입니다.
|
||||
|
||||
ArchUnit 쪽 핵심은 import 방향입니다. `domain_is_pure` rule은 domain package가 Spring, JPA, Hibernate, Lombok, application, adapter, bootstrap에 의존하지 못하게 합니다. application package도 adapter, bootstrap, persistence, Spring Web, Hibernate에 의존하지 못합니다. application에서 Spring `@Transactional`을 직접 쓰지 못하게 막는 rule도 여기에 놓여 있습니다. transaction 자체를 다루지 않는다는 뜻이 아니라, 그 책임을 `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를 통해서만 흐르게 하려 합니다.
|
||||
|
||||
`shared-contract`도 별도 경계가 있습니다. 이름이 shared라고 해서 아무 공통 코드를 넣는 곳이 아닙니다. ca-tmpl에서는 response, request, error, headers, logging, tracing, metrics, registry, annotation 같은 operational contract package만 허용합니다. business concept가 shared로 들어오면 여러 domain이 같은 이름의 공통 모델에 묶이기 쉽습니다. 그래서 shared는 편의 package가 아니라 운영 계약의 제한된 통로로 둡니다.
|
||||
|
||||
또 하나 중요한 장치는 violations-as-data입니다. ArchUnit rule은 매칭 대상이 비어 있으면 의미 없이 green이 될 수 있습니다. ca-tmpl은 의도적으로 잘못된 fixture class를 test tree에 두고, rule이 그 위반을 실제로 잡는지 확인합니다. 이렇게 하면 rule 이름만 있고 아무 것도 검사하지 않는 상태를 줄일 수 있습니다. 이것은 architecture test 자체를 테스트하는 장치입니다.
|
||||
|
||||
다만 이 구조도 만능은 아닙니다. ArchUnit은 bytecode에 남는 import, call, annotation을 잘 잡지만, string-key `ApplicationContext.getBean(String)`, `Class.forName(String)` 같은 reflection-style 우회는 정적으로 잡기 어렵습니다. 또한 운영 배포나 장기 유지보수 효과 측정은 없습니다. 이 글에서 말할 수 있는 범위는 ca-tmpl repository에서 구현됐고, 로컬/dev 검증으로 확인된 module/package boundary까지입니다.
|
||||
|
||||
정리하면 ca-tmpl의 Clean Architecture package layout은 “도메인, 애플리케이션, 어댑터로 나눴다”가 핵심이 아닙니다. 핵심은 그 나눔을 Gradle dependency matrix와 ArchUnit fitness function으로 계속 확인한다는 점입니다. skeleton은 한 번 예쁘게 만든 구조보다, 새 도메인을 추가하는 사람이 실수했을 때 어디서 잘못됐는지 알려주는 구조여야 합니다.
|
||||
|
||||
## 코드 예제 / Code samples (있다면)
|
||||
|
||||
```groovy
|
||||
// 출처: [[wiki/projects/ca-tmpl/clean-architecture-package-layout]]
|
||||
// 실제 파일: settings.gradle, ca-tmpl @f6fbd4e196b4
|
||||
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'
|
||||
```
|
||||
|
||||
```java
|
||||
// 출처: [[wiki/projects/ca-tmpl/clean-architecture-package-layout]]
|
||||
// 실제 파일: app-bootstrap/.../CaSkeletonApplication.java, ca-tmpl @f6fbd4e196b4
|
||||
@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);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
```groovy
|
||||
// 출처: [[wiki/projects/ca-tmpl/clean-architecture-package-layout]]
|
||||
// 실제 파일: build.gradle, ca-tmpl @f6fbd4e196b4
|
||||
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을 던진다.
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
```java
|
||||
// 출처: [[wiki/projects/ca-tmpl/clean-architecture-package-layout]]
|
||||
// 실제 파일: app-bootstrap/.../CleanArchitectureTest.java, ca-tmpl @f6fbd4e196b4
|
||||
@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);
|
||||
```
|
||||
|
||||
```java
|
||||
// 출처: [[wiki/projects/ca-tmpl/clean-architecture-package-layout]]
|
||||
// 실제 파일: app-bootstrap/.../CleanArchitectureTest.java, ca-tmpl @f6fbd4e196b4
|
||||
@ArchTest
|
||||
static final ArchRule PRODUCTION_CODE_DOES_NOT_DEPEND_ON_SAMPLE_PORTFOLIO =
|
||||
noClasses()
|
||||
.that()
|
||||
.resideOutsideOfPackage("..sample.portfolio..")
|
||||
.should()
|
||||
.dependOnClassesThat()
|
||||
.resideInAPackage("..sample.portfolio..");
|
||||
```
|
||||
|
||||
```java
|
||||
// 출처: [[wiki/projects/ca-tmpl/clean-architecture-package-layout]]
|
||||
// 실제 파일: app-bootstrap/.../ArchitectureViolationFixtureTest.java, ca-tmpl @f6fbd4e196b4
|
||||
class ArchitectureViolationFixtureTest {
|
||||
private static final JavaClasses VIOLATION_CLASSES =
|
||||
new ClassFileImporter().importPackages("dev.caskeleton.bootstrap.architecture.violations");
|
||||
|
||||
// intentional fixture classes를 읽어 각 ArchUnit rule이 실제 위반을 잡는지 확인한다.
|
||||
}
|
||||
```
|
||||
|
||||
## Sources / 근거 (canonical 인용 필수, derived layer 의무)
|
||||
|
||||
- [[wiki/projects/ca-tmpl/clean-architecture-package-layout]] — 이 글의 1차 canonical. module/package blueprint, Gradle dependency guard, ArchUnit enforcement, negative fixture, 검증 범위와 과장 금지 항목을 따른다.
|
||||
- [[wiki/concepts/clean-architecture-package-layout]] — 관련 개념 문서. 현재 `draft`이므로 구현 사실의 출처로 쓰지 않는다.
|
||||
|
||||
## 사실 vs 의견 / Fact vs opinion 구분
|
||||
|
||||
- 사실: ca-tmpl에는 Gradle module dependency matrix, `CleanArchitectureTest`, `ArchitectureViolationFixtureTest`, production → sample dependency ban, `shared-contract` package scope rule이 존재한다. 근거: [[wiki/projects/ca-tmpl/clean-architecture-package-layout]]
|
||||
- 사실: 검증 범위는 local/dev이며, 운영 배포나 운영 metric 검증은 없다. 근거: [[wiki/projects/ca-tmpl/clean-architecture-package-layout]]
|
||||
- 의견: package layout은 문서보다 build-time guardrail과 함께 있을 때 skeleton 학습 효과가 커진다.
|
||||
- 알지 못하는 것: 운영 조직에서 이 구조가 장기 유지보수 비용을 얼마나 줄였는지는 측정하지 않았다.
|
||||
|
||||
## 답할 수 있는 범위 / Answer boundary
|
||||
|
||||
- 자신 있게 답할 수 있는 후속 질문:
|
||||
- 왜 Gradle multi-module을 1차 boundary로 두었는가?
|
||||
- Gradle dependency matrix와 ArchUnit rule은 각각 무엇을 막는가?
|
||||
- `sample-portfolio`를 production code가 의존하지 못하게 한 이유는 무엇인가?
|
||||
- violations-as-data fixture가 왜 필요한가?
|
||||
- 다음 글로 넘길 부분:
|
||||
- Spring Modulith 도입 여부.
|
||||
- 대규모 도메인에서 feature module을 더 쪼개는 전략.
|
||||
- runtime lookup/reflection 우회를 자동으로 잡는 방법.
|
||||
|
||||
## 게시 체크리스트 / Publish checklist
|
||||
|
||||
- [x] 모든 사실 주장에 canonical 링크 있음
|
||||
- [x] 사실 vs 의견 분리 명시됨
|
||||
- [x] 금지 마케팅 표현 없음
|
||||
- [x] 코드 예제 출처 명시
|
||||
- [x] 타깃 독자 가정과 톤 일치
|
||||
- [x] `/lint` 통과
|
||||
- [ ] 게시 URL 기록 (게시 후):
|
||||
|
||||
## Related / 관련
|
||||
|
||||
- 후속 글 후보: [[wiki/blog/ca-tmpl-skeleton-governance-registry-verification-test-scorecard-2026-07-02]]
|
||||
- 관련 개념 문서: [[wiki/concepts/clean-architecture-package-layout]]
|
||||
@@ -1 +0,0 @@
|
||||
../../vault/40-publish/blog/ca-tmpl-config-and-adapter-templates-2026-07-02.md
|
||||
@@ -0,0 +1,154 @@
|
||||
---
|
||||
title: Optional Adapter를 설정 계약으로 다루기
|
||||
source_type: blog
|
||||
status: verified
|
||||
confidence: high
|
||||
tags: [blog, ca-tmpl, config, adapter, conditional-on-property]
|
||||
related_projects: [ca-tmpl]
|
||||
last_reviewed: 2026-07-03
|
||||
canonical_sources:
|
||||
- wiki/projects/ca-tmpl/config-and-adapter-templates
|
||||
audience: backend-engineer
|
||||
target_publish:
|
||||
status_label: ready
|
||||
---
|
||||
|
||||
# Optional Adapter를 설정 계약으로 다루기
|
||||
|
||||
## Parent / 부모 (필수)
|
||||
|
||||
- 핵심 canonical: [[wiki/projects/ca-tmpl/config-and-adapter-templates]]
|
||||
- 관련 개념 문서: [[wiki/concepts/config-and-adapter-templates]] - 일반 config/adapter template 비교. 현재 이 글의 구현 사실 근거는 verified project canonical에 둔다.
|
||||
|
||||
## 타깃 독자 / Target reader
|
||||
|
||||
- 독자 profile: Spring Boot optional adapter와 env-driven 설정을 skeleton에 넣으려는 백엔드 엔지니어.
|
||||
- 이미 안다고 가정하는 것: `@ConfigurationProperties`, `@ConditionalOnProperty`, env var.
|
||||
- 처음 듣는다고 가정하는 것: adapter 추가를 설정값 하나가 아니라 registry, bean gating, static rule, startup fail-fast가 맞물린 계약으로 보는 방식.
|
||||
|
||||
## 도입 / Hook
|
||||
|
||||
Optional adapter는 처음에는 편해 보입니다. Redis가 있으면 cache adapter를 켜고, Kafka가 있으면 message broker adapter를 켜고, Slack이나 email provider는 필요할 때만 붙이면 됩니다. 문제는 “꺼져 있어도 안전한가”입니다. env key가 `.env`에는 있는데 `application.yml`에서 안 쓰이거나, optional adapter bean이 조건 없이 등록되거나, disabled 상태인데 application layer가 adapter package를 직접 import하면 설정은 계약이 아니라 분위기가 됩니다.
|
||||
|
||||
ca-tmpl은 이 문제를 `@ConditionalOnProperty` 하나로 끝내지 않았습니다. `APP_` env registry와 `.env` drift gate, `@ConfigurationProperties` settings, optional adapter package isolation, `@ConditionalOnProperty` annotation rule, startup failure exception을 나눠 두었습니다. 이 글은 optional adapter를 “있으면 쓰고 없으면 말고”가 아니라 “켜지는 조건과 실패 방식이 검증되는 계약”으로 만든 이유를 정리합니다.
|
||||
|
||||
## 본문 outline / Body outline
|
||||
|
||||
1. optional adapter의 흔한 실패 - env drift, ungated bean, hidden direct import.
|
||||
2. 설정은 runtime contract다 - `.env`, `application.yml`, env registry를 같이 검증한다.
|
||||
3. `@ConditionalOnProperty`의 역할과 한계 - bean 등록 조건은 보지만 runtime activation 전체를 증명하지는 않는다.
|
||||
4. static isolation과 startup fail-fast - disabled adapter가 조용히 섞이지 않게 한다.
|
||||
5. 구현된 것과 provider-specific template의 남은 범위를 분리한다.
|
||||
|
||||
## 본문 / Body
|
||||
|
||||
설정값은 코드 밖에 있지만, 실제로는 코드의 실행 경로를 바꿉니다. `APP_CACHE_REDIS_ENABLED=true`가 들어오면 Redis cache backend가 생기고, `app.messaging.broker=kafka`가 들어오면 Kafka broker bean이 등록됩니다. 이런 설정을 문서로만 관리하면 drift가 생깁니다. `.env`에만 남은 key, `application.yml`에만 있는 placeholder, registry에 등록되지 않은 `APP_` key가 조금씩 쌓입니다.
|
||||
|
||||
ca-tmpl은 이 drift를 Gradle task로 막습니다. `verifyEnvKeys`는 `src/.env`, `application.yml`, `docs/registries/env-keys.yaml`을 함께 읽습니다. `application.yml`의 required placeholder가 `.env`에 없으면 실패하고, `.env` key가 어떤 placeholder에도 쓰이지 않으면 실패합니다. 또 `APP_` key는 env registry에 등록되어 있어야 합니다. 즉 설정 문서와 실제 boot 설정이 따로 움직이지 않게 빌드 단계에서 묶습니다.
|
||||
|
||||
adapter activation은 Layer 1에서 Spring bean 조건으로 표현합니다. Redis cache backend는 `app.cache.redis.enabled=true`일 때만 `CacheBackend` bean을 제공합니다. Kafka broker도 `app.messaging.broker=kafka`일 때만 `MessageBroker` bean을 등록합니다. 이 방식의 장점은 adapter 구현이 중앙 router나 use case를 직접 수정하지 않아도 “내가 활성화되는 조건”을 자기 config에 선언할 수 있다는 점입니다.
|
||||
|
||||
하지만 `@ConditionalOnProperty`만으로는 충분하지 않습니다. ArchUnit은 런타임 property evaluation을 실행하지 않습니다. 대신 ca-tmpl은 정적 분석으로 두 가지를 봅니다. application layer가 optional adapter package를 import하지 않는지, optional adapter package 안의 `@Bean` method가 `@ConditionalOnProperty`를 갖고 있는지입니다. 이건 “현재 어떤 profile에서 bean이 켜졌는가”를 증명하는 것이 아니라, disabled-default를 우회할 수 있는 코드 구조를 막는 쪽입니다.
|
||||
|
||||
Layer 3는 startup fail-fast입니다. required capability adapter가 꺼져 있거나 coordination bean이 없으면 `RequiredAdapterDisabledException` 계열 startup failure로 드러납니다. cache router나 messaging config처럼 중앙 binding 지점에서도 disabled backend binding이 조용한 no-op으로 흘러가지 않도록 설계합니다. skeleton에서 중요한 것은 “꺼져 있으면 아무 일도 하지 않는다”가 아니라, “꺼져 있는데 필요한 경로라면 빨리 실패한다”입니다.
|
||||
|
||||
이 결정을 그림으로 보면 세 층입니다.
|
||||
|
||||
| 층 | 잡는 문제 | 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 |
|
||||
|
||||
이 글에서 조심해야 할 경계도 있습니다. ca-tmpl에는 env registry/gate와 optional adapter guard가 구현되어 있고 `./gradlew check`로 로컬 검증됐습니다. 반면 모든 provider-specific adapter template가 완성됐다고 말하면 안 됩니다. Kafka, Redis, Slack, Google Email 같은 표면이 일부 존재하더라도, “모든 외부 provider 전환을 검증했다”는 주장은 project canonical 범위를 넘습니다. 이 글의 결론은 “optional adapter를 완성했다”가 아니라 “optional adapter가 켜지고 꺼지는 실패 모드를 설정 계약으로 드러내기 시작했다”입니다.
|
||||
|
||||
## 코드 예제 / Code samples (있다면)
|
||||
|
||||
```groovy
|
||||
// 출처: [[wiki/projects/ca-tmpl/config-and-adapter-templates]]
|
||||
// 실제 파일: src/build.gradle, ca-tmpl @f6fbd4e196b4
|
||||
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")
|
||||
}
|
||||
```
|
||||
|
||||
```java
|
||||
// 출처: [[wiki/projects/ca-tmpl/config-and-adapter-templates]]
|
||||
// 실제 파일: adapter-outbound/.../RedisCacheAdapterConfig.java, ca-tmpl @f6fbd4e196b4
|
||||
@Bean
|
||||
@ConditionalOnProperty(
|
||||
name = "app.cache.redis.enabled",
|
||||
havingValue = "true",
|
||||
matchIfMissing = false)
|
||||
public CacheBackend redisCacheBackend(RedisClient redisClient) {
|
||||
return new RedisCacheStore(redisClient);
|
||||
}
|
||||
```
|
||||
|
||||
```java
|
||||
// 출처: [[wiki/projects/ca-tmpl/config-and-adapter-templates]]
|
||||
// 실제 파일: adapter-outbound/.../KafkaAdapterConfig.java, ca-tmpl @f6fbd4e196b4
|
||||
@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);
|
||||
}
|
||||
```
|
||||
|
||||
```java
|
||||
// 출처: [[wiki/projects/ca-tmpl/config-and-adapter-templates]]
|
||||
// 실제 파일: app-bootstrap/.../DisabledAdapterArchitectureTest.java, ca-tmpl @f6fbd4e196b4
|
||||
static final ArchRule APPLICATION_DOES_NOT_DEPEND_ON_OPTIONAL_ADAPTERS =
|
||||
noClasses()
|
||||
.that()
|
||||
.resideInAPackage("..application..")
|
||||
.should()
|
||||
.dependOnClassesThat()
|
||||
.resideInAnyPackage(OPTIONAL_ADAPTER_PACKAGES);
|
||||
```
|
||||
|
||||
## Sources / 근거 (canonical 인용 필수, derived layer 의무)
|
||||
|
||||
- [[wiki/projects/ca-tmpl/config-and-adapter-templates]] - 이 글의 1차 canonical. env registry/gate, `@ConfigurationProperties`, optional adapter `@ConditionalOnProperty`, ArchUnit static guard, startup fail-fast, local verification, provider별 미완성 범위를 따른다.
|
||||
- [[wiki/concepts/config-and-adapter-templates]] - 관련 개념 문서. Spring Cloud Config, ConfigMap reload, Consul, Parameter Store, feature flag SaaS 같은 대안 비교 배경으로만 둔다.
|
||||
|
||||
## 사실 vs 의견 / Fact vs opinion 구분
|
||||
|
||||
- 사실: ca-tmpl에는 `verifyEnvKeys`, `docs/registries/env-keys.yaml`, 여러 `@ConfigurationProperties` settings, optional adapter `@ConditionalOnProperty` config, `DisabledAdapterArchitectureTest`, startup failure exception이 존재한다. 근거: [[wiki/projects/ca-tmpl/config-and-adapter-templates]]
|
||||
- 사실: `./gradlew check`가 2026-07-02 기준 통과했고, `verifyEnvKeys`와 optional adapter 관련 검증이 local/dev 범위에 포함된다. 근거: [[wiki/projects/ca-tmpl/config-and-adapter-templates]]
|
||||
- 사실: 모든 provider-specific adapter template와 모든 disabled runtime path가 완성됐다고 말하지 않는다. 근거: [[wiki/projects/ca-tmpl/config-and-adapter-templates]]
|
||||
- 의견: optional adapter는 silent noop보다 fail-fast 계약으로 두는 편이 skeleton 학습과 장애 분석에 더 유리하다.
|
||||
- 알지 못하는 것: 실제 환경에서 adapter on/off를 전환한 운영 경험, provider SDK별 production tuning.
|
||||
|
||||
## 답할 수 있는 범위 / Answer boundary
|
||||
|
||||
- 자신 있게 답할 수 있는 후속 질문:
|
||||
- env key drift를 왜 build gate로 잡는가?
|
||||
- `@ConditionalOnProperty`는 어떤 문제를 해결하고 어떤 문제를 해결하지 못하는가?
|
||||
- optional adapter static isolation과 startup fail-fast가 왜 둘 다 필요한가?
|
||||
- 다음 글로 넘길 부분:
|
||||
- 특정 provider SDK별 timeout/retry/auth 설정.
|
||||
- runtime reload나 feature flag SaaS가 필요한 제품 단계.
|
||||
- 실제 환경에서 adapter toggle을 운영한 경험.
|
||||
|
||||
## 게시 체크리스트 / Publish checklist
|
||||
|
||||
- [x] 모든 사실 주장에 canonical 링크 있음
|
||||
- [x] 사실 vs 의견 분리 명시됨
|
||||
- [x] 금지 마케팅 표현 없음
|
||||
- [x] 코드 예제 출처 명시
|
||||
- [x] 타깃 독자 가정과 톤 일치
|
||||
- [x] `/lint` 통과
|
||||
- [ ] 게시 URL 기록 (게시 후):
|
||||
|
||||
## Related / 관련
|
||||
|
||||
- 후속 글 후보: [[wiki/blog/ca-tmpl-runtime-container-health-migration-2026-07-02]]
|
||||
- 후속 글 후보: [[wiki/blog/ca-tmpl-data-layer-persistence-cache-outbound-2026-07-02]]
|
||||
@@ -1 +0,0 @@
|
||||
../../vault/40-publish/blog/ca-tmpl-data-layer-persistence-cache-outbound-2026-07-02.md
|
||||
@@ -0,0 +1,184 @@
|
||||
---
|
||||
title: Persistence와 Cache, Outbound 경계를 한 문서에서 분리하기
|
||||
source_type: blog
|
||||
status: verified
|
||||
confidence: high
|
||||
tags: [blog, ca-tmpl, persistence, cache, outbound]
|
||||
related_projects: [ca-tmpl]
|
||||
last_reviewed: 2026-07-02
|
||||
canonical_sources:
|
||||
- wiki/projects/ca-tmpl/data-layer-persistence-cache-outbound
|
||||
audience: backend-engineer
|
||||
target_publish:
|
||||
status_label: ready
|
||||
---
|
||||
|
||||
# Persistence와 Cache, Outbound 경계를 한 문서에서 분리하기
|
||||
|
||||
## Parent / 부모 (필수)
|
||||
|
||||
- 핵심 canonical: [[wiki/projects/ca-tmpl/data-layer-persistence-cache-outbound]]
|
||||
- 관련 개념 문서: [[wiki/concepts/data-layer-persistence-cache-outbound]] — data layer 일반 개념. 현재 이 글의 구현 사실 근거는 verified project canonical에 둔다.
|
||||
|
||||
## 타깃 독자 / Target reader
|
||||
|
||||
- 독자 profile: data layer baseline을 skeleton 수준에서 정하려는 백엔드 엔지니어.
|
||||
- 이미 안다고 가정하는 것: JPA, cache-aside, outbound HTTP, connection pool.
|
||||
- 처음 듣는다고 가정하는 것: persistence/cache/outbound를 한데 묶되 증거 등급을 분리하는 방식.
|
||||
|
||||
## 도입 / Hook
|
||||
|
||||
- 문제 / 궁금증: data layer는 persistence, cache, outbound가 섞여 보여도 실패 모드와 검증 범위가 다르다.
|
||||
- 이 글이 답하는 것: ca-tmpl에서 구현된 항목과 문서/계획만 있는 항목을 구분한다.
|
||||
- 이 글이 답하지 않는 것: 실제 운영 DB latency와 cache hit ratio 개선 측정.
|
||||
|
||||
## 본문 outline / Body outline
|
||||
|
||||
1. data layer baseline을 쪼개서 보기 — persistence, cache, outbound.
|
||||
2. 실제 구현된 범위 — idempotency/outbox adapter, migration, OSIV/Hikari guard, lower-layer cache SPI.
|
||||
3. cache와 outbound의 실패 계약 — fail-open/fail-closed를 구분한다.
|
||||
4. Hikari/startup guard와 slow query 논의 — 구현/계획/needs-confirmation을 분리한다.
|
||||
5. 운영 검증 없음 — metric과 incident 경험처럼 말하지 않는다.
|
||||
|
||||
## 본문 / Body
|
||||
|
||||
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 문서는 이 셋을 한 문서에 두되, 구현된 범위와 계획만 있는 범위를 분리합니다.
|
||||
|
||||
이 분리가 중요한 이유는 과장하기 쉽기 때문입니다. “data layer baseline을 구현했다”고 말하면 persistence classifier, cache consistency, outbound resilience가 모두 같은 수준으로 끝난 것처럼 들립니다. 하지만 ca-tmpl 기준으로는 구현된 slice가 서로 다릅니다. idempotency/outbox RDBMS adapter, PostgreSQL migration, OSIV/Hikari startup guard, lower-layer cache SPI/router/fail-open, outbound HTTP client는 구현·로컬 검증됐습니다. 반면 SQLState classifier 전체, read replica lag metric, cache-aside + Redisson distributed mutex + after-commit invalidation 전체 contract, 운영 tuning은 아직 planned 또는 부분 구현입니다.
|
||||
|
||||
persistence 쪽에서 구현된 대표 guard는 OSIV off입니다. OSIV(Open Session In View)는 web response 렌더링 시점까지 Hibernate session을 열어두는 방식입니다. 편리하지만 presentation layer에서 lazy association을 만지는 순간 DB query가 나갈 수 있습니다. ca-tmpl은 `spring.jpa.open-in-view=true`가 명시되면 startup에서 실패시키는 validator를 둡니다. 즉 layer boundary를 runtime configuration에서도 깨지 않게 합니다.
|
||||
|
||||
HikariCP 설정도 startup guard로 다룹니다. connection-timeout은 최소 250ms 이상이어야 하고, validation-timeout은 connection-timeout보다 작아야 하며, keepalive-time은 max-lifetime보다 작아야 합니다. leak-detection-threshold도 켤 거면 2000ms 이상이어야 합니다. 이것은 pool sizing을 운영에서 측정했다는 뜻이 아닙니다. 잘못 조합된 knob를 애플리케이션 시작 시점에 빨리 실패시키는 guard입니다.
|
||||
|
||||
cache 쪽은 fail-open 경계를 구현했습니다. ca-tmpl의 `CacheStoreRouter`는 logical cache name을 backend id로 라우팅합니다. binding이 없는 logical cache를 호출하면 조용히 no-op 하지 않고 `AdapterDisabledException`을 던집니다. 반대로 backend가 구성된 뒤 실제 cache backend 호출이 실패하면 `FailOpenCacheStore`가 get은 miss로, put은 관찰된 실패로 낮춥니다. cache는 성능 보조 장치이므로 backend 장애가 곧 5xx가 되지 않게 하는 쪽입니다.
|
||||
|
||||
이 차이가 outbox와 다릅니다. outbox publish는 fail-open이면 안 됩니다. 메시지 발행 실패를 조용히 삼키면 downstream이 영원히 변경 사실을 모를 수 있습니다. 그래서 outbox는 `FAILED`/`DEAD` state machine과 error log를 갖습니다. cache는 장애 시 miss로 degrade할 수 있지만, outbox는 실패를 상태로 남기고 다시 처리해야 합니다. 같은 “adapter failure”라도 업무 의미가 다릅니다.
|
||||
|
||||
outbound HTTP는 또 다른 경계입니다. ca-tmpl의 `OutboundHttpClient`는 dependency name별 baseline client를 만들고, shutdown 중이면 네트워크를 맺기 전에 fail-fast합니다. buffered 호출은 retry/circuit breaker decorator를 거치고, streaming 호출은 retry하지 않습니다. 이미 일부 bytes를 소비한 stream은 안전하게 재시도하기 어렵기 때문입니다. retry policy도 GET/HEAD/PUT/DELETE 같은 idempotent method만 재시도 대상으로 둡니다. POST/PATCH는 기본적으로 제외됩니다.
|
||||
|
||||
outbound HTTP에서 중요한 것은 timeout 3축입니다. connect timeout, read timeout, global call timeout을 분리해서 생각합니다. connect timeout은 TCP 연결 단계, read timeout은 socket read 단계, global call timeout은 retry를 포함한 전체 예산입니다. ca-tmpl의 현재 구현은 이 값을 기본값으로 박아두기보다 필수 설정으로 요구하고, 누락 또는 잘못된 raw RestClient 등록을 startup에서 막는 방향입니다.
|
||||
|
||||
정리하면 ca-tmpl의 data layer baseline은 “DB, cache, HTTP를 다 구현했다”는 단순한 문장이 아닙니다. 구현된 것은 구현됐다고 말하고, planned인 것은 planned라고 남기는 문서입니다. 이 글에서 가장 중요한 학습 포인트도 여기에 있습니다. data layer의 경계는 기술 이름으로 나뉘는 것이 아니라, 실패했을 때 무엇을 보존해야 하는지로 나뉩니다. DB transaction은 정합성을 보존해야 하고, cache는 miss로 degrade할 수 있으며, outbound HTTP는 retry/timeout 예산 안에서 실패를 분류해야 합니다.
|
||||
|
||||
## 코드 예제 / Code samples (있다면)
|
||||
|
||||
```java
|
||||
// 출처: [[wiki/projects/ca-tmpl/data-layer-persistence-cache-outbound]]
|
||||
// 실제 파일: app-bootstrap/.../OpenInViewSafetyValidator.java, ca-tmpl @f6fbd4e196b4
|
||||
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");
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
```java
|
||||
// 출처: [[wiki/projects/ca-tmpl/data-layer-persistence-cache-outbound]]
|
||||
// 실제 파일: app-bootstrap/.../HikariPoolConstraintValidator.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");
|
||||
}
|
||||
```
|
||||
|
||||
```java
|
||||
// 출처: [[wiki/projects/ca-tmpl/data-layer-persistence-cache-outbound]]
|
||||
// 실제 파일: adapter-outbound/.../CacheStoreRouter.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);
|
||||
}
|
||||
```
|
||||
|
||||
```java
|
||||
// 출처: [[wiki/projects/ca-tmpl/data-layer-persistence-cache-outbound]]
|
||||
// 실제 파일: adapter-outbound/.../FailOpenCacheStore.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();
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
```java
|
||||
// 출처: [[wiki/projects/ca-tmpl/data-layer-persistence-cache-outbound]]
|
||||
// 실제 파일: adapter-outbound/.../OutboundHttpClient.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();
|
||||
}
|
||||
```
|
||||
|
||||
```java
|
||||
// 출처: [[wiki/projects/ca-tmpl/data-layer-persistence-cache-outbound]]
|
||||
// 실제 파일: adapter-outbound/.../OutboundRetryPolicy.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;
|
||||
}
|
||||
```
|
||||
|
||||
## Sources / 근거 (canonical 인용 필수, derived layer 의무)
|
||||
|
||||
- [[wiki/projects/ca-tmpl/data-layer-persistence-cache-outbound]] — 이 글의 1차 canonical. persistence/cache/outbound 각각의 구현·부분 구현·planned 경계를 따른다.
|
||||
- [[wiki/concepts/data-layer-persistence-cache-outbound]] — 관련 개념 문서. 구현 사실 출처로 쓰지 않는다.
|
||||
|
||||
## 사실 vs 의견 / Fact vs opinion 구분
|
||||
|
||||
- 사실: ca-tmpl에는 idempotency/outbox RDBMS adapter와 migration, OSIV-off startup guard, Hikari inter-knob startup guard, lower-layer cache SPI/router/fail-open, outbound HTTP baseline이 존재한다. 근거: [[wiki/projects/ca-tmpl/data-layer-persistence-cache-outbound]]
|
||||
- 사실: SQLState classifier 전체, read replica lag metric/alert, cache-aside + Redisson distributed mutex + after-commit invalidation 전체 contract, 운영 tuning은 구현 완료로 말하지 않는다. 근거: [[wiki/projects/ca-tmpl/data-layer-persistence-cache-outbound]]
|
||||
- 사실: 운영 배포, pool wait p99, cache hit ratio, circuit breaker 운영 경험은 없다. 근거: [[wiki/projects/ca-tmpl/data-layer-persistence-cache-outbound]]
|
||||
- 의견: persistence/cache/outbound를 함께 다루더라도 실패 계약은 분리해서 설명해야 한다.
|
||||
- 알지 못하는 것: production pool wait, slow query, cache hit rate, outbound dependency 장애율.
|
||||
|
||||
## 답할 수 있는 범위 / Answer boundary
|
||||
|
||||
- 자신 있게 답할 수 있는 후속 질문:
|
||||
- fail-open cache와 fail-closed outbox의 차이는 무엇인가?
|
||||
- OSIV off startup guard가 layer boundary와 어떤 관련이 있는가?
|
||||
- Hikari knob guard는 운영 tuning과 어떻게 다른가?
|
||||
- outbound HTTP retry에서 POST/PATCH를 제외한 이유는 무엇인가?
|
||||
- 다음 글로 넘길 부분:
|
||||
- 실제 DB/cache 운영 metric.
|
||||
- SQLState classifier 전체 구현과 운영 alert.
|
||||
- cache-aside after-commit invalidation의 full contract.
|
||||
|
||||
## 게시 체크리스트 / Publish checklist
|
||||
|
||||
- [x] 모든 사실 주장에 canonical 링크 있음
|
||||
- [x] 사실 vs 의견 분리 명시됨
|
||||
- [x] 금지 마케팅 표현 없음
|
||||
- [x] 코드 예제 출처 명시
|
||||
- [x] 타깃 독자 가정과 톤 일치
|
||||
- [x] `/lint` 통과
|
||||
- [ ] 게시 URL 기록 (게시 후):
|
||||
|
||||
## Related / 관련
|
||||
|
||||
- 후속 글 후보: [[wiki/blog/ca-tmpl-transactional-outbox-pattern-2026-07-02]]
|
||||
@@ -1 +0,0 @@
|
||||
../../vault/40-publish/blog/ca-tmpl-devops-ci-supply-chain-dx-2026-07-02.md
|
||||
@@ -0,0 +1,143 @@
|
||||
---
|
||||
title: CI와 Supply Chain을 Skeleton 계약으로 묶기
|
||||
source_type: blog
|
||||
status: verified
|
||||
confidence: high
|
||||
tags: [blog, ca-tmpl, devops, ci-cd, supply-chain]
|
||||
related_projects: [ca-tmpl]
|
||||
last_reviewed: 2026-07-03
|
||||
canonical_sources:
|
||||
- wiki/projects/ca-tmpl/devops-ci-supply-chain-dx
|
||||
audience: backend-engineer
|
||||
target_publish:
|
||||
status_label: ready
|
||||
---
|
||||
|
||||
# CI와 Supply Chain을 Skeleton 계약으로 묶기
|
||||
|
||||
## Parent / 부모 (필수)
|
||||
|
||||
- 핵심 canonical: [[wiki/projects/ca-tmpl/devops-ci-supply-chain-dx]]
|
||||
- 관련 개념 문서: [[wiki/concepts/devops-ci-supply-chain-dx]] - 일반 CI/supply-chain/DX 개념. 현재 이 글의 구현 사실 근거는 verified project canonical에 둔다.
|
||||
|
||||
## 타깃 독자 / Target reader
|
||||
|
||||
- 독자 profile: template repo의 CI, static analysis, release/supply-chain baseline을 설계하려는 엔지니어.
|
||||
- 이미 안다고 가정하는 것: Gradle, GitHub Actions, dependency lock, SBOM, static analysis.
|
||||
- 처음 듣는다고 가정하는 것: CI를 도구 목록이 아니라 gate ownership, drift check, release evidence의 계약으로 보는 방식.
|
||||
|
||||
## 도입 / Hook
|
||||
|
||||
CI에 도구를 많이 붙이는 것은 어렵지 않습니다. formatter, linter, unit test, integration test, vulnerability scanner, SBOM, signing, provenance를 순서대로 추가하면 화면은 그럴듯해집니다. 그런데 어떤 gate가 release를 막는지, 실패하면 어느 branch contract가 책임지는지, 문서의 gate matrix가 실제 workflow와 어긋나면 누가 잡는지 정하지 않으면 CI는 금방 장식이 됩니다.
|
||||
|
||||
ca-tmpl은 DevOps baseline을 “workflow 파일 몇 개”가 아니라 skeleton contract로 보려 했습니다. gate ownership matrix를 repo 안에 두고, Gradle custom task와 workflow job이 실제로 존재하는지 검사하며, dependency lock과 reproducible archive 설정, Cosign/SLSA 관련 workflow와 검증 스크립트를 둡니다. 단, hosted GitHub Actions에서 release를 실제 발행하고 Rekor/GHCR evidence까지 확인한 것은 아닙니다. 이 글은 구현된 local/repo-level gate와 live release 검증의 경계를 분리합니다.
|
||||
|
||||
## 본문 outline / Body outline
|
||||
|
||||
1. CI gate는 tool list가 아니라 release contract다.
|
||||
2. gate matrix와 owner branch - 무엇이 실패하면 누가 고쳐야 하는가.
|
||||
3. Gradle baseline - static analysis, dependency locking, reproducible archive.
|
||||
4. supply-chain workflow - SBOM, Cosign, SLSA, Trivy를 증거 체인으로 묶는다.
|
||||
5. local/dev portability와 hosted release 검증의 차이를 분리한다.
|
||||
|
||||
## 본문 / Body
|
||||
|
||||
CI를 설계할 때 흔한 실수는 “무엇을 실행할지”만 정하는 것입니다. 실제로 더 중요한 질문은 “이 검사가 실패하면 release가 막히는가”, “누가 policy를 소유하는가”, “문서에 적힌 gate가 실제 workflow에 남아 있는가”입니다. ca-tmpl의 `.github/ci-gate-matrix.yml`은 이 질문에 답하기 위한 파일입니다. 각 gate에는 id, release blocking 여부, owner branch, mechanism, ref, workflow가 붙습니다.
|
||||
|
||||
이 matrix는 문서가 아니라 검사 대상입니다. `verify-gate-matrix.sh`는 matrix row를 읽고, mechanism별로 실제 존재 여부를 확인합니다. `gradle-custom-task`라면 `tasks.register('<ref>')`가 있어야 하고, `contract-test`라면 test class 파일이 있어야 하며, `workflow-job`이라면 workflow 안에 job id가 있어야 합니다. 이렇게 하면 “문서에는 gate가 있는데 CI에서는 빠진 상태”를 줄일 수 있습니다.
|
||||
|
||||
Gradle baseline도 여러 층으로 나뉩니다. `src/build.gradle`은 Spotless, Checkstyle, SpotBugs, ErrorProne을 subproject에 적용하고, dependency locking을 strict mode로 켭니다. archive task는 timestamp, file order, permission을 고정해 build artifact가 host 환경에 덜 흔들리도록 합니다. 이것은 production artifact reproducibility를 완전히 증명한다는 뜻이 아니라, skeleton에서 entropy source를 줄이는 baseline입니다.
|
||||
|
||||
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 file에 필요한 문자열과 job wiring이 남아 있는지 확인합니다. 예를 들어 `cosign sign --yes`, `cosign attest --yes`, `--certificate-identity`, `--certificate-oidc-issuer`, SLSA v1 predicate, exact builder identity 같은 조건을 검사합니다.
|
||||
|
||||
여기서 중요한 경계가 있습니다. ca-tmpl에 workflow와 검증 스크립트가 존재한다는 사실과, 실제 release artifact가 public registry에 올라갔고 Rekor/GHCR evidence로 검증됐다는 사실은 다릅니다. project canonical은 후자를 확인하지 않았다고 명시합니다. 따라서 이 글은 “supply-chain release를 운영했다”가 아니라 “supply-chain release contract를 repo-level workflow와 script로 고정했다”까지만 말합니다.
|
||||
|
||||
DX도 같은 관점입니다. `./gradlew bootstrap`은 compile, Docker preflight, dependency/migration/startup, sample contract, HTTP smoke를 하나의 진입점으로 묶습니다. 이 명령이 모든 OS와 CI 환경을 보장한다는 뜻은 아닙니다. 대신 새 프로젝트를 받은 사람이 “무엇부터 실행해야 하는가”를 덜 고민하게 만들고, 실패 지점을 단계로 나누려는 목적입니다.
|
||||
|
||||
결국 ca-tmpl의 DevOps baseline은 “이 도구를 썼다”보다 “어떤 증거가 release를 통과시키는가”에 가깝습니다. gate matrix가 workflow와 drift 나지 않아야 하고, dependency lock이 조용히 풀리면 안 되며, vulnerability suppression은 사유와 만료일 없이 남으면 안 됩니다. 이 정도가 local/repo-level에서 검증된 범위입니다. 실제 hosted CI run, release publication, live Cosign/Rekor 검증은 별도의 근거가 생긴 뒤에만 말할 수 있습니다.
|
||||
|
||||
## 코드 예제 / Code samples (있다면)
|
||||
|
||||
```yaml
|
||||
# 출처: [[wiki/projects/ca-tmpl/devops-ci-supply-chain-dx]]
|
||||
# 실제 파일: .github/ci-gate-matrix.yml, ca-tmpl @f6fbd4e196b4
|
||||
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
|
||||
```
|
||||
|
||||
```bash
|
||||
# 출처: [[wiki/projects/ca-tmpl/devops-ci-supply-chain-dx]]
|
||||
# 실제 파일: .github/scripts/verify-gate-matrix.sh, ca-tmpl @f6fbd4e196b4
|
||||
# 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
|
||||
```
|
||||
|
||||
```groovy
|
||||
// 출처: [[wiki/projects/ca-tmpl/devops-ci-supply-chain-dx]]
|
||||
// 실제 파일: src/build.gradle, ca-tmpl @f6fbd4e196b4
|
||||
dependencyLocking {
|
||||
lockAllConfigurations()
|
||||
lockMode = LockMode.STRICT
|
||||
}
|
||||
|
||||
tasks.withType(AbstractArchiveTask).configureEach {
|
||||
preserveFileTimestamps = false
|
||||
reproducibleFileOrder = true
|
||||
}
|
||||
```
|
||||
|
||||
```bash
|
||||
# 출처: [[wiki/projects/ca-tmpl/devops-ci-supply-chain-dx]]
|
||||
# 실제 파일: .github/scripts/verify-supply-chain-contract.sh, ca-tmpl @f6fbd4e196b4
|
||||
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'
|
||||
```
|
||||
|
||||
## Sources / 근거 (canonical 인용 필수, derived layer 의무)
|
||||
|
||||
- [[wiki/projects/ca-tmpl/devops-ci-supply-chain-dx]] - 이 글의 1차 canonical. CI gate matrix, Gradle gates, supply-chain workflow/script, local verification, live release 미검증 경계를 따른다.
|
||||
- [[wiki/concepts/devops-ci-supply-chain-dx]] - 관련 개념 문서. CI provider, signing, provenance, dependency lock, dev environment 대안 비교 배경으로만 둔다.
|
||||
|
||||
## 사실 vs 의견 / Fact vs opinion 구분
|
||||
|
||||
- 사실: ca-tmpl에는 CI workflow, `.github/ci-gate-matrix.yml`, gate matrix 검증 스크립트, supply-chain policy/script, Gradle dependency lock/reproducible archive 설정, 여러 custom verification task가 존재한다. 근거: [[wiki/projects/ca-tmpl/devops-ci-supply-chain-dx]]
|
||||
- 사실: `./gradlew check`가 2026-07-02 기준 통과했고, local gate 검증이 project canonical에 기록되어 있다. 근거: [[wiki/projects/ca-tmpl/devops-ci-supply-chain-dx]]
|
||||
- 사실: hosted GitHub Actions run, 실제 release publication, live Rekor/GHCR/Cosign 검증은 확인하지 않았다. 근거: [[wiki/projects/ca-tmpl/devops-ci-supply-chain-dx]]
|
||||
- 의견: skeleton에서는 CI tool list보다 gate ownership matrix가 더 오래 남는 설계 자산이다.
|
||||
- 알지 못하는 것: public artifact 소비자, 실제 release incident, live registry rollback 경험.
|
||||
|
||||
## 답할 수 있는 범위 / Answer boundary
|
||||
|
||||
- 자신 있게 답할 수 있는 후속 질문:
|
||||
- gate matrix가 왜 필요한가?
|
||||
- Gradle dependency locking과 reproducible archive 설정이 어떤 drift를 줄이는가?
|
||||
- Cosign/SLSA/Trivy workflow와 검증 스크립트가 repo-level에서 무엇을 고정하는가?
|
||||
- 다음 글로 넘길 부분:
|
||||
- 실제 release signing 운영.
|
||||
- public artifact distribution과 rollback manifest 운영.
|
||||
- SLSA level 달성 여부와 live provenance 검증.
|
||||
|
||||
## 게시 체크리스트 / Publish checklist
|
||||
|
||||
- [x] 모든 사실 주장에 canonical 링크 있음
|
||||
- [x] 사실 vs 의견 분리 명시됨
|
||||
- [x] 금지 마케팅 표현 없음
|
||||
- [x] 코드 예제 출처 명시
|
||||
- [x] 타깃 독자 가정과 톤 일치
|
||||
- [x] `/lint` 통과
|
||||
- [ ] 게시 URL 기록 (게시 후):
|
||||
|
||||
## Related / 관련
|
||||
|
||||
- 후속 글 후보: [[wiki/blog/ca-tmpl-skeleton-governance-registry-verification-test-scorecard-2026-07-02]]
|
||||
- 후속 글 후보: [[wiki/blog/ca-tmpl-sample-fixture-and-adoption-2026-07-02]]
|
||||
@@ -1 +0,0 @@
|
||||
../../vault/40-publish/blog/ca-tmpl-idempotency-key-design-2026-07-02.md
|
||||
@@ -0,0 +1,160 @@
|
||||
---
|
||||
title: Idempotency Key를 API 표면이 아니라 실행 계약으로 보기
|
||||
source_type: blog
|
||||
status: verified
|
||||
confidence: high
|
||||
tags: [blog, ca-tmpl, idempotency, api-design]
|
||||
related_projects: [ca-tmpl]
|
||||
last_reviewed: 2026-07-02
|
||||
canonical_sources:
|
||||
- wiki/projects/ca-tmpl/idempotency-key-design
|
||||
audience: backend-engineer
|
||||
target_publish:
|
||||
status_label: ready
|
||||
---
|
||||
|
||||
# Idempotency Key를 API 표면이 아니라 실행 계약으로 보기
|
||||
|
||||
## Parent / 부모 (필수)
|
||||
|
||||
- 핵심 canonical: [[wiki/projects/ca-tmpl/idempotency-key-design]]
|
||||
- 관련 개념 문서: [[wiki/concepts/idempotency-key-design]] — 일반 idempotency key 개념. 현재 이 글의 구현 사실 근거는 verified project canonical에 둔다.
|
||||
|
||||
## 타깃 독자 / Target reader
|
||||
|
||||
- 독자 profile: POST 중복 요청과 retry를 안전하게 처리하려는 백엔드 엔지니어.
|
||||
- 이미 안다고 가정하는 것: HTTP retry, unique key, transaction.
|
||||
- 처음 듣는다고 가정하는 것: scope, body digest, replay/in-flight/mismatch 분류를 API 계약으로 고정하는 방식.
|
||||
|
||||
## 도입 / Hook
|
||||
|
||||
- 문제 / 궁금증: `Idempotency-Key` header만 받는다고 idempotency가 구현되는 것은 아니다.
|
||||
- 이 글이 답하는 것: ca-tmpl이 key scope, request digest, status classification, persistence/executor 경계를 어떻게 나눴는지.
|
||||
- 이 글이 답하지 않는 것: production retry traffic과 duplicate suppression metric.
|
||||
|
||||
## 본문 outline / Body outline
|
||||
|
||||
1. header surface와 실제 executor의 차이.
|
||||
2. triple scope와 request digest — 같은 key가 무엇을 의미하는지 고정한다.
|
||||
3. in-flight/replay/mismatch 분류 — client가 무엇을 해야 하는지 알려준다.
|
||||
4. transaction boundary와 persistence adapter — local verification 범위.
|
||||
5. 아직 운영 검증은 없다.
|
||||
|
||||
## 본문 / Body
|
||||
|
||||
`Idempotency-Key` header를 받는 것만으로 idempotency가 구현되지는 않습니다. header는 단지 client가 “이 요청은 같은 의도로 다시 보낼 수 있다”고 알려주는 표면입니다. 서버가 실제로 해야 할 일은 더 많습니다. 같은 요청인지 판단해야 하고, 이미 처리 중인지 구분해야 하며, 완료된 결과를 replay할 수 있어야 하고, 같은 key로 다른 body가 들어오면 client bug로 돌려줘야 합니다.
|
||||
|
||||
ca-tmpl은 이 문제를 web filter 하나로 처리하지 않았습니다. 핵심 실행 계약은 application layer의 `IdempotencyExecutor`에 둡니다. web adapter는 header, principal, use case name, request fingerprint를 모아 context를 만들고, executor는 store port를 통해 claim/replay/mismatch/in-flight를 판정합니다. persistence adapter는 DB table과 unique constraint로 scope 충돌을 실제로 막습니다.
|
||||
|
||||
scope는 `(authenticatedPrincipal, idempotencyKey, useCaseName)` triple입니다. tenant isolation이 활성화되면 tenant가 앞에 붙어 4-tuple이 됩니다. 여기서 `useCaseName`을 넣는 이유가 중요합니다. 같은 principal이 같은 idempotency key를 두 다른 use case에 보냈을 때 충돌하면 안 됩니다. URL path를 scope에 넣지 않는 것도 의도입니다. path version이 바뀌어도 같은 application use case의 실행 의미가 유지될 수 있기 때문입니다.
|
||||
|
||||
request fingerprint는 같은 key가 같은 body를 뜻하는지 확인하는 장치입니다. ca-tmpl의 executor는 live record를 찾으면 fingerprint를 먼저 비교합니다. 같으면 상태에 따라 replay 또는 in-flight 처리로 갑니다. 다르면 `IdempotencyRequestMismatchException`을 던지고, web boundary에서 `422`로 매핑합니다. 같은 key를 재사용했지만 body가 다르다는 것은 보통 client가 idempotency key를 잘못 관리한다는 신호입니다.
|
||||
|
||||
동시 도착은 `409`로 분리합니다. executor는 record가 `IN_FLIGHT`이면 바로 실패시키지 않고 최대 200ms 동안 짧게 기다립니다. 그 안에 선행 요청이 완료되면 저장된 response를 replay할 수 있습니다. 그래도 완료되지 않으면 `IdempotencyInFlightException`이 나고 `409`로 응답합니다. 이 200ms는 부하 테스트로 튜닝된 수치가 아니라 ca-tmpl 기본 정책값입니다.
|
||||
|
||||
완료된 요청은 저장된 response를 replay합니다. action이 성공하면 codec이 response를 직렬화해 store에 저장하고, 같은 scope의 후속 요청은 action을 다시 실행하지 않고 그 payload를 역직렬화합니다. action이 예외를 던지면 executor는 record를 discard합니다. 실패한 실행을 영구적으로 replay하지 않기 위해서입니다. 즉 idempotency는 성공 응답 replay와 실행 중 충돌 제어를 다루며, 모든 실패를 캐시하는 장치가 아닙니다.
|
||||
|
||||
저장소는 DB table입니다. Redis나 in-memory cache만으로 두지 않은 이유는 skeleton에서 transaction boundary와 운영 복구 가능성을 우선했기 때문입니다. PostgreSQL migration에는 `tenant`, `principal`, `idempotency_key`, `use_case_name` unique constraint가 있습니다. TTL은 기본 24h이고, executor는 per-use-case override가 있더라도 72h cap을 넘지 못하게 합니다.
|
||||
|
||||
응답 코드도 의도적으로 나뉩니다. in-flight 충돌은 `409 Conflict`, 같은 key와 다른 body fingerprint는 `422 Unprocessable Entity`입니다. 두 상황은 client가 해야 할 일이 다릅니다. `409`은 조금 뒤 다시 시도할 수 있지만, `422`는 key/body 조합을 고쳐야 합니다. ca-tmpl은 이 차이를 error envelope mapping까지 이어갑니다.
|
||||
|
||||
검증 범위는 local/dev입니다. `IdempotencyExecutor`, web helper/codec, RDBMS store, PostgreSQL unique scope contract가 존재하고 `./gradlew check`로 검증됐습니다. 하지만 운영 duplicate suppression metric, 실제 retry traffic 비율, 200ms wait의 부하 기반 튜닝은 없습니다. 따라서 이 글에서 말할 수 있는 것은 구현과 로컬 검증이지 운영 효과 측정이 아닙니다.
|
||||
|
||||
## 코드 예제 / Code samples (있다면)
|
||||
|
||||
```java
|
||||
// 출처: [[wiki/projects/ca-tmpl/idempotency-key-design]]
|
||||
// 실제 파일: application-core/.../IdempotencyScope.java, ca-tmpl @f6fbd4e196b4
|
||||
public record IdempotencyScope(
|
||||
String tenant, String principal, String idempotencyKey, String useCaseName) {
|
||||
|
||||
public static IdempotencyScope of(String principal, String idempotencyKey, String useCaseName) {
|
||||
return of(null, principal, idempotencyKey, useCaseName);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
```java
|
||||
// 출처: [[wiki/projects/ca-tmpl/idempotency-key-design]]
|
||||
// 실제 파일: application-core/.../IdempotencyExecutor.java, ca-tmpl @f6fbd4e196b4
|
||||
public final class IdempotencyExecutor {
|
||||
public static final Duration IN_FLIGHT_WAIT = Duration.ofMillis(200);
|
||||
public static final Duration MAX_TTL = Duration.ofHours(72);
|
||||
|
||||
public <R> R execute(
|
||||
IdempotencyContext context, Supplier<R> action, IdempotentResponseCodec<R> codec) {
|
||||
// claim -> fingerprint mismatch -> replay -> in-flight wait -> 409
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
```java
|
||||
// 출처: [[wiki/projects/ca-tmpl/idempotency-key-design]]
|
||||
// 실제 파일: application-core/.../IdempotencyExecutor.java
|
||||
if (!record.fingerprint().equals(fingerprint)) {
|
||||
throw new IdempotencyRequestMismatchException(scope);
|
||||
}
|
||||
if (record.status() == IdempotencyStatus.COMPLETED) {
|
||||
return codec.deserialize(record.response().payload());
|
||||
}
|
||||
if (!now.isBefore(deadline)) {
|
||||
throw new IdempotencyInFlightException(scope);
|
||||
}
|
||||
```
|
||||
|
||||
```sql
|
||||
-- 출처: [[wiki/projects/ca-tmpl/idempotency-key-design]]
|
||||
-- 실제 파일: adapter-persistence-postgresql/.../V1__idempotency_record.sql
|
||||
CREATE TABLE idempotency_record (
|
||||
id uuid NOT NULL,
|
||||
tenant varchar(128) NOT NULL DEFAULT '',
|
||||
principal varchar(256) NOT NULL,
|
||||
idempotency_key varchar(256) NOT NULL,
|
||||
use_case_name varchar(256) NOT NULL,
|
||||
request_hash char(64) NOT NULL,
|
||||
status varchar(16) NOT NULL,
|
||||
response_payload text NULL,
|
||||
expires_at timestamptz NOT NULL,
|
||||
CONSTRAINT uq_idempotency_scope
|
||||
UNIQUE (tenant, principal, idempotency_key, use_case_name)
|
||||
);
|
||||
```
|
||||
|
||||
## Sources / 근거 (canonical 인용 필수, derived layer 의무)
|
||||
|
||||
- [[wiki/projects/ca-tmpl/idempotency-key-design]] — 이 글의 1차 canonical. triple scope, 24h TTL, 200ms wait, 409/422 mapping, 구현/로컬 검증 범위와 과장 금지 경계를 따른다.
|
||||
- [[wiki/concepts/idempotency-key-design]] — 관련 개념 문서. 구현 사실 출처로 쓰지 않는다.
|
||||
|
||||
## 사실 vs 의견 / Fact vs opinion 구분
|
||||
|
||||
- 사실: ca-tmpl에는 `IdempotencyExecutor`, `IdempotencyStorePort`, `IdempotencyScope`, `RequestFingerprint`, web helper/codec, RDBMS store, PostgreSQL unique scope migration이 존재한다. 근거: [[wiki/projects/ca-tmpl/idempotency-key-design]]
|
||||
- 사실: `./gradlew check`, executor/store/web mapping/unique scope contract test가 로컬 검증 범위에 포함된다. 근거: [[wiki/projects/ca-tmpl/idempotency-key-design]]
|
||||
- 사실: 운영 배포, duplicate suppression metric, 200ms wait 부하 튜닝은 없다. 근거: [[wiki/projects/ca-tmpl/idempotency-key-design]]
|
||||
- 의견: idempotency는 API header보다 application execution contract로 설명할 때 설계가 더 잘 보인다.
|
||||
- 알지 못하는 것: 운영 retry traffic에서 replay/in-flight/mismatch 비율이 어떻게 나오는지.
|
||||
|
||||
## 답할 수 있는 범위 / Answer boundary
|
||||
|
||||
- 자신 있게 답할 수 있는 후속 질문:
|
||||
- replay, in-flight, mismatch를 왜 나눴는가?
|
||||
- triple scope에 `useCaseName`을 넣은 이유는 무엇인가?
|
||||
- 같은 key + 다른 body를 왜 `422`로 보는가?
|
||||
- DB table unique constraint가 idempotency executor와 어떻게 맞물리는가?
|
||||
- 다음 글로 넘길 부분:
|
||||
- 운영 duplicate suppression metric.
|
||||
- multi-node production race 부하 테스트.
|
||||
- long-term retention policy와 비용 모델.
|
||||
|
||||
## 게시 체크리스트 / Publish checklist
|
||||
|
||||
- [x] 모든 사실 주장에 canonical 링크 있음
|
||||
- [x] 사실 vs 의견 분리 명시됨
|
||||
- [x] 금지 마케팅 표현 없음
|
||||
- [x] 코드 예제 출처 명시
|
||||
- [x] 타깃 독자 가정과 톤 일치
|
||||
- [x] `/lint` 통과
|
||||
- [ ] 게시 URL 기록 (게시 후):
|
||||
|
||||
## Related / 관련
|
||||
|
||||
- 후속 글 후보: [[wiki/blog/ca-tmpl-api-evolution-and-schema-2026-07-02]]
|
||||
@@ -1 +0,0 @@
|
||||
../../vault/40-publish/blog/ca-tmpl-knowledge-capture-workflow-2026-07-02.md
|
||||
@@ -0,0 +1,121 @@
|
||||
---
|
||||
title: 구현 후 지식을 다시 Wiki로 회수하기
|
||||
source_type: blog
|
||||
status: verified
|
||||
confidence: medium
|
||||
tags: [blog, ca-tmpl, workflow, documentation]
|
||||
related_projects: [ca-tmpl]
|
||||
last_reviewed: 2026-07-03
|
||||
canonical_sources:
|
||||
- wiki/projects/ca-tmpl/knowledge-capture-workflow
|
||||
audience: backend-engineer
|
||||
target_publish:
|
||||
status_label: ready
|
||||
---
|
||||
|
||||
# 구현 후 지식을 다시 Wiki로 회수하기
|
||||
|
||||
## Parent / 부모 (필수)
|
||||
|
||||
- 핵심 canonical: [[wiki/projects/ca-tmpl/knowledge-capture-workflow]]
|
||||
- 관련 개념 문서: [[wiki/concepts/clean-architecture-package-layout]] - ca-tmpl 문서 구조와 project/concept 분리의 배경으로만 참고한다.
|
||||
|
||||
## 타깃 독자 / Target reader
|
||||
|
||||
- 독자 profile: 구현 과정에서 생긴 결정을 branch note, wiki, blog로 회수하고 싶은 개발자.
|
||||
- 이미 안다고 가정하는 것: branch note, project note, blog draft, wiki 문서화.
|
||||
- 처음 듣는다고 가정하는 것: raw 증거, canonical 문서, derived 산출물을 분리해서 학습 루프를 만드는 방식.
|
||||
|
||||
## 도입 / Hook
|
||||
|
||||
구현이 끝난 뒤 가장 빨리 사라지는 것은 코드가 아닙니다. 코드는 repository에 남습니다. 사라지는 것은 “왜 이 선택을 했는가”, “어떤 대안을 버렸는가”, “어디까지 검증했고 어디부터는 추측인가” 같은 맥락입니다. 이 맥락은 채팅 로그, branch note, 테스트 실패, 작은 TODO 사이에 흩어지기 쉽습니다.
|
||||
|
||||
ca-tmpl의 knowledge capture workflow는 이 문제를 줄이기 위한 문서화 규칙입니다. raw 자료를 증거로 보관하고, `wiki/projects`와 `wiki/concepts`를 canonical로 정리한 뒤, blog/interview/portfolio 같은 derived 산출물을 canonical에서만 만듭니다. 이 글은 애플리케이션 기능이 아니라 작업 종료 조건으로서의 지식 회수 구조를 설명합니다.
|
||||
|
||||
## 본문 outline / Body outline
|
||||
|
||||
1. 구현 후 사라지는 것은 코드가 아니라 결정 맥락이다.
|
||||
2. raw, canonical, derived를 섞지 않는다.
|
||||
3. branch-note와 blog-topic은 증거이고 project 문서는 설명 가능한 결정이다.
|
||||
4. blogify는 raw가 아니라 verified canonical에서 시작한다.
|
||||
5. 이 workflow는 자동화가 아니라 documentation rule이다.
|
||||
|
||||
## 본문 / Body
|
||||
|
||||
개발자가 나중에 다시 공부하기 어려운 이유는 “기록이 없어서”만은 아닙니다. 기록은 많습니다. branch note도 있고, daily note도 있고, 테스트 로그도 있고, raw blog-topic도 있습니다. 문제는 그 기록들이 서로 다른 신뢰도를 갖는다는 점입니다. 구현 중 적은 메모와 코드 대조를 마친 project canonical, 그리고 외부에 내보낼 블로그 초안은 같은 레이어가 아닙니다.
|
||||
|
||||
ca-tmpl workflow의 첫 원칙은 raw를 증거로 두는 것입니다. branch-note, daily-note, error note, blog-topic은 생각의 흔적과 구현 증거를 보관합니다. 여기에는 미확정 판단, 실패한 시도, 나중에 다듬을 글감이 들어갈 수 있습니다. raw는 귀중하지만 그대로 블로그가 되지는 않습니다.
|
||||
|
||||
두 번째 레이어는 canonical입니다. `wiki/projects/`는 내 프로젝트에서 실제로 구현됐거나 로컬 검증된 결정을 정리합니다. `wiki/concepts/`는 특정 프로젝트를 떠난 일반 개념과 trade-off를 정리합니다. ca-tmpl의 20개 project 문서가 중요한 이유도 여기에 있습니다. 여러 branch-note에 흩어진 결정을 주제별로 합치고, 구현 범위와 미검증 범위를 분리해서 “내가 설명할 수 있는 지식”으로 바꾸기 때문입니다.
|
||||
|
||||
세 번째 레이어가 derived 산출물입니다. blog, interview, portfolio는 canonical에서 파생됩니다. raw branch-note에서 바로 blog를 만들지 않는 이유는 간단합니다. raw에는 사실, 추측, 계획, 감정, 작업 중간 판단이 섞입니다. canonical을 거치면 “실제로 코드가 있는가”, “로컬에서 검증됐는가”, “prod 검증은 없는가”, “planned를 구현처럼 말하고 있지 않은가”를 먼저 정리할 수 있습니다.
|
||||
|
||||
이번 ca-tmpl 블로그 작업도 같은 흐름입니다. raw/blog-topics 59개는 글감 원석이었고, ingest를 통해 project canonical에 반영됐습니다. 그 뒤 `blogify`는 `wiki/projects/ca-tmpl/*.md`에서 시작했습니다. 그래서 블로그 20개는 branch-note 50여 개를 1:1로 그대로 옮긴 것이 아니라, 프로젝트 결정 주제 20개로 녹인 뒤 다시 읽을 수 있는 글로 풀어내는 구조입니다.
|
||||
|
||||
이 workflow의 장점은 학습 경로가 보인다는 점입니다. 어떤 글을 쓰다가 근거가 약하면 raw로 돌아가는 것이 아니라 canonical을 먼저 고칩니다. canonical이 draft라면 verified로 올릴 근거를 대조합니다. blog에 쓸 수 없는 planned 항목은 planned라고 표시합니다. 이렇게 하면 글쓰기 자체가 복습이 됩니다. 단순히 문장을 만드는 것이 아니라, 내가 어디까지 알고 어디부터 모르는지 나누는 과정이기 때문입니다.
|
||||
|
||||
다만 이 글은 높은 자동화를 주장하지 않습니다. canonical에 따르면 이 workflow는 runtime 기능이 아니고, git hook이나 CI로 강제되는 구조도 아닙니다. 일부 branch에서 branch-note 갱신과 derived raw note 생성이 실제로 수행됐고, raw/blog-topics 59개 ingest batch가 적용 사례로 남아 있을 뿐입니다. 따라서 confidence도 `medium`으로 둡니다. 문서화 규칙으로는 검증됐지만, 자동 강제 장치가 있는 것은 아닙니다.
|
||||
|
||||
결론적으로 knowledge capture는 ca-tmpl의 코드 기능이 아니라 학습과 설명을 위한 작업 방식입니다. 구현이 끝난 뒤 branch-note를 닫고, raw 글감을 canonical에 반영하고, verified project 문서에서 blog를 파생합니다. 이 구조를 따르면 “왜 그렇게 결정했는지”를 나중에 다시 따라갈 수 있습니다. 그게 이 블로그 묶음의 진짜 목적입니다.
|
||||
|
||||
## 코드 예제 / Code samples (있다면)
|
||||
|
||||
이 글은 runtime code를 설명하는 글이 아니므로 애플리케이션 코드 예제는 두지 않는다. 대신 실제 workflow는 아래 흐름으로 읽는다.
|
||||
|
||||
```text
|
||||
# 출처: [[wiki/projects/ca-tmpl/knowledge-capture-workflow]]
|
||||
raw/branch-notes + raw/blog-topics
|
||||
-> wiki/projects 또는 wiki/concepts canonical
|
||||
-> wiki/blog, wiki/interview, wiki/portfolio derived output
|
||||
```
|
||||
|
||||
```text
|
||||
# 출처: [[wiki/projects/ca-tmpl/knowledge-capture-workflow]]
|
||||
blogify 입력으로 적합한 것:
|
||||
wiki/projects/ca-tmpl/<verified-project-canonical>.md
|
||||
wiki/concepts/<reviewed-or-verified-concept>.md
|
||||
|
||||
blogify 입력으로 피해야 하는 것:
|
||||
raw/branch-notes/<branch-note>.md
|
||||
raw/blog-topics/<topic-seed>.md
|
||||
```
|
||||
|
||||
## Sources / 근거 (canonical 인용 필수, derived layer 의무)
|
||||
|
||||
- [[wiki/projects/ca-tmpl/knowledge-capture-workflow]] - 이 글의 1차 canonical. runtime 구현 없음, documentation workflow, partial local 사례, 자동 강제 부재, confidence medium 경계를 따른다.
|
||||
- [[wiki/concepts/clean-architecture-package-layout]] - 관련 개념 문서. ca-tmpl 문서 구조와 project/concept 분리 배경으로만 둔다.
|
||||
|
||||
## 사실 vs 의견 / Fact vs opinion 구분
|
||||
|
||||
- 사실: 이 문서가 다루는 것은 runtime 기능이나 애플리케이션 코드가 아니라 workflow rule과 문서화 결정이다. 근거: [[wiki/projects/ca-tmpl/knowledge-capture-workflow]]
|
||||
- 사실: 일부 branch에서 branch-note 갱신과 derived raw note 생성이 수행됐고, raw/blog-topics 59개 ingest batch가 raw에서 canonical로 승격된 사례로 기록되어 있다. 근거: [[wiki/projects/ca-tmpl/knowledge-capture-workflow]]
|
||||
- 사실: git hook/CI enforcement는 없고 agent workflow rule에 의존한다. 근거: [[wiki/projects/ca-tmpl/knowledge-capture-workflow]]
|
||||
- 의견: 블로그 작성은 문장 생산보다 canonical을 다시 검증하는 학습 루프로 볼 때 더 효과적이다.
|
||||
- 알지 못하는 것: 장기적으로 회고 품질, 면접 성과, 외부 글 반응이 얼마나 좋아지는지.
|
||||
|
||||
## 답할 수 있는 범위 / Answer boundary
|
||||
|
||||
- 자신 있게 답할 수 있는 후속 질문:
|
||||
- 왜 raw에서 바로 blog를 만들지 않는가?
|
||||
- branch-note와 project canonical의 역할은 어떻게 다른가?
|
||||
- ca-tmpl 20개 project blog가 branch-note 묶음을 어떻게 학습 가능한 구조로 바꾸는가?
|
||||
- 다음 글로 넘길 부분:
|
||||
- git hook/CI 기반 documentation gate.
|
||||
- 자동 품질 검사 확장.
|
||||
- 블로그 게시 후 독자 반응이나 회고 효과 측정.
|
||||
|
||||
## 게시 체크리스트 / Publish checklist
|
||||
|
||||
- [x] 모든 사실 주장에 canonical 링크 있음
|
||||
- [x] 사실 vs 의견 분리 명시됨
|
||||
- [x] 금지 마케팅 표현 없음
|
||||
- [x] 코드 예제 출처 명시
|
||||
- [x] 타깃 독자 가정과 톤 일치
|
||||
- [x] `/lint` 통과
|
||||
- [ ] 게시 URL 기록 (게시 후):
|
||||
|
||||
## Related / 관련
|
||||
|
||||
- 후속 글 후보: [[wiki/blog/ca-tmpl-skeleton-governance-registry-verification-test-scorecard-2026-07-02]]
|
||||
- 후속 글 후보: [[wiki/blog/ca-tmpl-api-error-envelope-design-2026-07-02]]
|
||||
- 후속 글 후보: [[wiki/blog/ca-tmpl-api-evolution-and-schema-2026-07-02]]
|
||||
@@ -1 +0,0 @@
|
||||
../../vault/40-publish/blog/ca-tmpl-multi-tenancy-isolation-patterns-2026-07-02.md
|
||||
@@ -0,0 +1,146 @@
|
||||
---
|
||||
title: Multi-tenancy를 기본값이 아니라 Opt-in 계약으로 두기
|
||||
source_type: blog
|
||||
status: verified
|
||||
confidence: high
|
||||
tags: [blog, ca-tmpl, multi-tenancy, saas]
|
||||
related_projects: [ca-tmpl]
|
||||
last_reviewed: 2026-07-03
|
||||
canonical_sources:
|
||||
- wiki/projects/ca-tmpl/multi-tenancy-isolation-patterns
|
||||
audience: backend-engineer
|
||||
target_publish:
|
||||
status_label: ready
|
||||
---
|
||||
|
||||
# Multi-tenancy를 기본값이 아니라 Opt-in 계약으로 두기
|
||||
|
||||
## Parent / 부모 (필수)
|
||||
|
||||
- 핵심 canonical: [[wiki/projects/ca-tmpl/multi-tenancy-isolation-patterns]]
|
||||
- 관련 개념 문서: [[wiki/concepts/multi-tenancy-isolation-patterns]] - Pool/Silo/Bridge와 Hibernate multi-tenancy 전략의 일반 배경. 현재 이 글의 구현 사실 근거는 verified project canonical에 둔다.
|
||||
|
||||
## 타깃 독자 / Target reader
|
||||
|
||||
- 독자 profile: SaaS skeleton에서 tenant isolation을 어디까지 기본 제공할지 고민하는 백엔드 엔지니어.
|
||||
- 이미 안다고 가정하는 것: `tenant_id`, shared DB, schema-per-tenant.
|
||||
- 처음 듣는다고 가정하는 것: multi-tenancy를 default feature가 아니라 opt-in guardrail과 migration trigger로 다루는 방식.
|
||||
|
||||
## 도입 / Hook
|
||||
|
||||
Multi-tenancy는 SaaS에서 중요하지만, skeleton에 처음부터 강하게 박아 넣기 어렵습니다. 모든 query에 tenant predicate를 강제하고, tenant resolver filter를 만들고, admin tenant switching을 열고, schema-per-tenant까지 고려하면 single-tenant 서비스에도 비용이 따라옵니다. 반대로 아무 계약도 없으면 나중에 tenant를 얹을 때 권한, idempotency, logging, repository 경계가 한꺼번에 흔들립니다.
|
||||
|
||||
ca-tmpl은 이 사이에서 opt-in 방향을 택했습니다. 기본은 `APP_TENANT_ENABLED=false`이고, shared DB + `tenant_id` 방향을 문서화하되 현재 구현은 registry, capability, idempotency scope, runbook stub 같은 foundation에 머뭅니다. repository-level tenant filter와 cross-tenant E2E isolation은 아직 planned입니다. 이 글은 multi-tenancy를 “완성했다”고 말하지 않고, 어디까지 foundation을 깔았는지 정리합니다.
|
||||
|
||||
## 본문 outline / Body outline
|
||||
|
||||
1. multi-tenancy를 skeleton 기본값으로 강제하지 않는 이유.
|
||||
2. shared DB + `tenant_id`와 schema/db-per-tenant의 trade-off.
|
||||
3. 현재 구현된 registry/capability/idempotency foundation.
|
||||
4. 아직 없는 tenant resolver와 repository filter.
|
||||
5. migration trigger와 운영 검증 없음.
|
||||
|
||||
## 본문 / Body
|
||||
|
||||
Multi-tenancy의 첫 갈림길은 격리 수준입니다. 모든 tenant를 같은 DB와 table에 두고 `tenant_id` column으로 나누는 방식은 운영이 단순합니다. 반면 schema-per-tenant나 db-per-tenant는 isolation은 강하지만 migration, backup, connection pool, monitoring 비용이 빠르게 늘어납니다. ca-tmpl은 B2B 초기 단계, tenant 수 수십에서 수백 정도의 가정을 두고 shared DB + `tenant_id`를 baseline 후보로 잡았습니다.
|
||||
|
||||
하지만 이 선택은 “항상 shared DB가 낫다”는 뜻이 아닙니다. 규제 산업, data residency 요구, enterprise tier처럼 격리를 상품 가치로 팔아야 하는 경우에는 schema나 DB를 나누는 쪽이 맞을 수 있습니다. 그래서 ca-tmpl canonical은 migration trigger도 함께 기록합니다. 규제 요구, tenant 수와 row 수 증가, enterprise tier 등장 같은 조건이 생기면 Pool 모델에서 더 강한 isolation으로 넘어갈 수 있다는 판단입니다.
|
||||
|
||||
현재 코드로 구현된 것은 storage isolation 전체가 아니라 foundation입니다. env registry에는 `APP_TENANT_ENABLED`가 있고, header registry에는 `X-Tenant-Id`가 있습니다. capability registry에는 `CROSS_TENANT_ADMIN`이 존재합니다. error-code registry에는 tenant 미지원 상태에서 tenant header가 들어왔을 때의 `TENANT_NOT_SUPPORTED`가 정의되어 있고, cross-tenant mismatch runbook stub도 있습니다.
|
||||
|
||||
application layer에도 일부 표현이 있습니다. `UseCaseCapability`에는 `crossTenantAdmin` flag가 있습니다. 이것은 tenant 경계를 넘는 admin use case가 명시적으로 선언해야 하는 capability입니다. idempotency 쪽에는 `IdempotencyScope`가 single-tenant triple뿐 아니라 tenant를 앞에 둔 4-tuple을 표현할 수 있습니다. tenant가 활성화되면 idempotency key 충돌도 tenant boundary 안에서 해석되어야 하기 때문입니다.
|
||||
|
||||
다만 중요한 enforcement가 아직 없습니다. request에서 tenant를 해석하는 tenant resolver filter는 구현됐다고 말할 수 없습니다. repository 진입점에서 `tenant_id` predicate를 강제하는 rule도 아직 planned입니다. `CROSS_TENANT_ADMIN`을 가진 admin만 `X-Tenant-Id` header로 tenant switching을 할 수 있다는 정책은 문서/registry 수준에 가깝고, E2E isolation으로 검증된 상태는 아닙니다.
|
||||
|
||||
이 경계가 이 글의 핵심입니다. ca-tmpl은 multi-tenancy를 처음부터 모든 서비스에 강제하지 않습니다. 대신 나중에 tenant를 열 때 필요한 vocabulary와 일부 cross-cutting surface를 미리 잡아 둡니다. header, env key, capability, idempotency scope, runbook link가 그 foundation입니다. 반면 실제 data isolation은 repository filter와 E2E test가 들어와야 닫힙니다.
|
||||
|
||||
따라서 면접이나 블로그에서 말할 때도 “multi-tenancy를 구현했다”보다 “multi-tenancy를 opt-in으로 열 수 있게 foundation을 만들었고, storage isolation enforcement는 planned로 남겼다”가 정확합니다. 이 차이를 숨기지 않는 것이 오히려 설계 이해를 더 잘 보여줍니다.
|
||||
|
||||
## 코드 예제 / Code samples (있다면)
|
||||
|
||||
```yaml
|
||||
# 출처: [[wiki/projects/ca-tmpl/multi-tenancy-isolation-patterns]]
|
||||
# 실제 파일: docs/registries/env-keys.yaml, ca-tmpl @f6fbd4e196b4
|
||||
- name: APP_TENANT_ENABLED
|
||||
type: boolean
|
||||
default: false
|
||||
allowed_values: [true, false]
|
||||
reload_policy: restart-only
|
||||
owner_branch: feature-tenant-context-policy
|
||||
```
|
||||
|
||||
```yaml
|
||||
# 출처: [[wiki/projects/ca-tmpl/multi-tenancy-isolation-patterns]]
|
||||
# 실제 파일: docs/registries/capabilities.yaml, ca-tmpl @f6fbd4e196b4
|
||||
- name: CROSS_TENANT_ADMIN
|
||||
scope: use_case_method
|
||||
enforcement: archunit
|
||||
annotation: "@UseCaseCapability(crossTenantAdmin = true)"
|
||||
```
|
||||
|
||||
```java
|
||||
// 출처: [[wiki/projects/ca-tmpl/multi-tenancy-isolation-patterns]]
|
||||
// 실제 파일: application-core/.../UseCaseCapability.java, ca-tmpl @f6fbd4e196b4
|
||||
public @interface UseCaseCapability {
|
||||
TransactionMode transactionMode();
|
||||
Idempotency idempotency();
|
||||
RepositoryAccess repositoryAccess();
|
||||
boolean crossTenantAdmin() default false;
|
||||
}
|
||||
```
|
||||
|
||||
```java
|
||||
// 출처: [[wiki/projects/ca-tmpl/multi-tenancy-isolation-patterns]]
|
||||
// 실제 파일: application-core/.../IdempotencyScope.java, ca-tmpl @f6fbd4e196b4
|
||||
public record IdempotencyScope(
|
||||
String tenant, String principal, String idempotencyKey, String useCaseName) {
|
||||
|
||||
public static IdempotencyScope of(
|
||||
String tenant, String principal, String idempotencyKey, String useCaseName) {
|
||||
requirePresent("principal", principal);
|
||||
requirePresent("idempotencyKey", idempotencyKey);
|
||||
requirePresent("useCaseName", useCaseName);
|
||||
String normalizedTenant = (tenant == null || tenant.isBlank()) ? null : tenant;
|
||||
return new IdempotencyScope(normalizedTenant, principal, idempotencyKey, useCaseName);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Sources / 근거 (canonical 인용 필수, derived layer 의무)
|
||||
|
||||
- [[wiki/projects/ca-tmpl/multi-tenancy-isolation-patterns]] - 이 글의 1차 canonical. opt-in policy, shared DB + `tenant_id`, registry/capability/idempotency foundation, planned repository filter, 운영 미검증 경계를 따른다.
|
||||
- [[wiki/concepts/multi-tenancy-isolation-patterns]] - 관련 개념 문서. Pool/Silo/Bridge와 Hibernate strategy의 일반 비교 배경으로만 둔다.
|
||||
|
||||
## 사실 vs 의견 / Fact vs opinion 구분
|
||||
|
||||
- 사실: ca-tmpl에는 `APP_TENANT_ENABLED`, `X-Tenant-Id`, `CROSS_TENANT_ADMIN`, tenant 관련 error code/runbook stub, `UseCaseCapability.crossTenantAdmin`, tenant-aware `IdempotencyScope`가 존재한다. 근거: [[wiki/projects/ca-tmpl/multi-tenancy-isolation-patterns]]
|
||||
- 사실: repository-level tenant predicate 강제, tenant resolver filter, cross-tenant E2E isolation은 구현/검증됐다고 말하지 않는다. 근거: [[wiki/projects/ca-tmpl/multi-tenancy-isolation-patterns]]
|
||||
- 사실: 운영 배포, tenant isolation audit, penetration test 결과는 없다. 근거: [[wiki/projects/ca-tmpl/multi-tenancy-isolation-patterns]]
|
||||
- 의견: ca-tmpl 같은 skeleton에서는 multi-tenancy를 default feature가 아니라 opt-in foundation으로 두는 편이 적용 범위를 넓힌다.
|
||||
- 알지 못하는 것: 실제 tenant 수, row 수, noisy neighbor metric, schema/db-per-tenant migration 경험.
|
||||
|
||||
## 답할 수 있는 범위 / Answer boundary
|
||||
|
||||
- 자신 있게 답할 수 있는 후속 질문:
|
||||
- 왜 schema-per-tenant를 skeleton 기본값으로 두지 않았는가?
|
||||
- `X-Tenant-Id` header를 왜 admin only로 제한해야 하는가?
|
||||
- idempotency scope에 tenant dimension이 왜 필요한가?
|
||||
- 다음 글로 넘길 부분:
|
||||
- tenant resolver filter 구현.
|
||||
- repository-level `tenant_id` predicate 강제.
|
||||
- cross-tenant E2E isolation과 audit evidence.
|
||||
|
||||
## 게시 체크리스트 / Publish checklist
|
||||
|
||||
- [x] 모든 사실 주장에 canonical 링크 있음
|
||||
- [x] 사실 vs 의견 분리 명시됨
|
||||
- [x] 금지 마케팅 표현 없음
|
||||
- [x] 코드 예제 출처 명시
|
||||
- [x] 타깃 독자 가정과 톤 일치
|
||||
- [x] `/lint` 통과
|
||||
- [ ] 게시 URL 기록 (게시 후):
|
||||
|
||||
## Related / 관련
|
||||
|
||||
- 후속 글 후보: [[wiki/blog/ca-tmpl-security-baseline-jwt-actuator-secrets-2026-07-02]]
|
||||
- 후속 글 후보: [[wiki/blog/ca-tmpl-idempotency-key-design-2026-07-02]]
|
||||
@@ -1 +0,0 @@
|
||||
../../vault/40-publish/blog/ca-tmpl-observability-log-metric-trace-runbook-2026-07-02.md
|
||||
@@ -0,0 +1,165 @@
|
||||
---
|
||||
title: Observability를 로그 한 줄이 아니라 운영 계약으로 보기
|
||||
source_type: blog
|
||||
status: verified
|
||||
confidence: high
|
||||
tags: [blog, ca-tmpl, observability, logging, metrics, tracing]
|
||||
related_projects: [ca-tmpl]
|
||||
last_reviewed: 2026-07-03
|
||||
canonical_sources:
|
||||
- wiki/projects/ca-tmpl/observability-log-metric-trace-runbook
|
||||
audience: backend-engineer
|
||||
target_publish:
|
||||
status_label: ready
|
||||
---
|
||||
|
||||
# Observability를 로그 한 줄이 아니라 운영 계약으로 보기
|
||||
|
||||
## Parent / 부모 (필수)
|
||||
|
||||
- 핵심 canonical: [[wiki/projects/ca-tmpl/observability-log-metric-trace-runbook]]
|
||||
- 관련 개념 문서: [[wiki/concepts/observability-log-metric-trace-runbook]] - 일반 observability 개념. 현재 이 글의 구현 사실 근거는 verified project canonical에 둔다.
|
||||
|
||||
## 타깃 독자 / Target reader
|
||||
|
||||
- 독자 profile: skeleton에 log/metric/trace/runbook baseline을 넣고 싶은 백엔드 엔지니어.
|
||||
- 이미 안다고 가정하는 것: MDC, Micrometer, trace id, runbook.
|
||||
- 처음 듣는다고 가정하는 것: 관측 가능성을 응답 `meta`, 로그 MDC, trace context, runbook의 연결 계약으로 보는 관점.
|
||||
|
||||
## 도입 / Hook
|
||||
|
||||
로그가 많다고 장애 대응이 쉬워지는 것은 아닙니다. 요청 id가 응답에는 있는데 로그에는 없거나, 로그에는 trace id가 있는데 client가 받은 error envelope에는 없거나, metric alert는 울렸는데 어떤 runbook을 봐야 하는지 연결되지 않으면 관측 데이터는 흩어진 조각이 됩니다.
|
||||
|
||||
ca-tmpl은 observability를 “로그 한 줄 찍기”가 아니라 연결 가능한 계약으로 다루려 했습니다. request id, trace id, correlation id를 MDC에 올리고, 응답 envelope의 `meta`에도 투영하며, inbound header는 sanitize하고, user principal은 pseudonymize해서 로그에 싣는 식입니다. 다만 project canonical 기준으로 전체 log/metric/trace/runbook 체계가 모두 구현된 것은 아닙니다. 이 글은 구현된 foundation slice와 아직 planned/documented-only인 부분을 나눠서 정리합니다.
|
||||
|
||||
## 본문 outline / Body outline
|
||||
|
||||
1. observability는 출력 포맷이 아니라 join 가능성이다.
|
||||
2. requestId/traceId/correlationId와 envelope meta.
|
||||
3. MDC key, header sanitizer, request logging filter.
|
||||
4. logback JSON/MDC include와 masking/sampling의 구현 범위.
|
||||
5. metric/trace/runbook 중 구현된 범위와 planned 범위.
|
||||
6. 운영 장애 대응 효과와 alert tuning 검증은 없음.
|
||||
|
||||
## 본문 / Body
|
||||
|
||||
장애 상황에서 가장 먼저 필요한 것은 “이 응답이 어떤 로그와 이어지는가”입니다. client가 받은 실패 응답에 `requestId`와 `traceId`가 있어도, 서버 로그에 같은 key가 없으면 검색이 끊깁니다. 반대로 로그에만 trace id가 있고 응답에는 없으면 client 문의에서 출발해 서버 이벤트로 들어가기 어렵습니다. ca-tmpl의 observability foundation은 이 연결을 기본 계약으로 둡니다.
|
||||
|
||||
구현의 중심에는 MDC key가 있습니다. `MdcKeys`는 `request_id`, `trace_id`, `span_id`, `correlation_id`, `user_principal`을 snake_case로 정의합니다. `RequestLoggingFilter`는 inbound `X-Request-Id`, `X-Correlation-Id`, `traceparent`를 읽고, 없거나 유효하지 않으면 서버에서 생성합니다. 값은 MDC에 들어가고 response header에도 다시 설정됩니다. 그래서 request 처리 중 남는 로그와 client가 받은 header가 같은 id로 이어질 수 있습니다.
|
||||
|
||||
`ResponseMetaFactory`는 이 MDC 값을 API envelope의 `meta`로 투영합니다. 로그에서는 snake_case key를 쓰지만, JSON wire format은 `requestId`, `traceId`, `correlationId` camelCase record입니다. 이 작은 변환이 중요합니다. 로그의 key naming과 API contract naming을 억지로 같게 만들지 않고, 각 영역의 규칙을 유지한 채 mapping 지점을 명확히 둔 것입니다.
|
||||
|
||||
header는 그대로 믿지 않습니다. `HeaderSanitizer`는 inbound header value에서 `\r`, `\n`, ASCII control char를 제거하고 길이를 제한합니다. request id나 correlation id는 로그/MDC에 들어가므로 log injection을 피해야 합니다. `RequestLoggingFilter`는 user principal도 raw id를 MDC에 넣지 않고 `UserPrincipalPseudonymizerPort`를 거쳐 pseudonymized value만 싣습니다. 즉 “관측 가능하게 남긴다”와 “민감 정보를 그대로 남긴다”를 구분합니다.
|
||||
|
||||
logback 설정도 이 계약을 받쳐줍니다. local/dev는 사람이 읽기 쉬운 pattern layout을 쓰고, 그 외 profile은 structured JSON encoder에 MDC key를 포함합니다. 설정에는 `trace_id`, `span_id`, `request_id`, `correlation_id`, `user_principal` include가 명시되어 있습니다. 또한 masking converter/decorator와 sampling turbo filter, async appender 설정도 존재합니다. 다만 이 글에서 말할 수 있는 것은 코드와 local verification 범위입니다. production log pipeline에서의 실제 누락률이나 비용 절감 효과는 검증된 주장이 아닙니다.
|
||||
|
||||
traceparent 처리도 선을 분명히 해야 합니다. 현재 `RequestLoggingFilter`는 inbound W3C `traceparent`가 유효하면 채택하고, 없으면 fresh ROOT traceparent를 생성합니다. 이것은 trace id를 응답/log에 연결하기 위한 foundation입니다. 하지만 실제 distributed tracer가 붙어 span tree를 export하고, 5xx span을 ERROR로 기록하고, trace backend에서 검색된다는 주장까지는 별도 구현/운영 검증이 필요합니다.
|
||||
|
||||
metric과 runbook도 마찬가지입니다. project canonical에는 log/metric/trace/runbook을 하나의 운영 계약으로 보는 방향이 있지만, 모든 항목이 같은 증거 등급은 아닙니다. 구현된 foundation은 MDC/header/meta/logback 중심입니다. Prometheus alert, alert tuning, runbook link-check와 실제 incident response 효과는 documented-only 또는 planned 범위로 남아 있습니다. 따라서 이 글의 결론은 “운영 관측성이 완성됐다”가 아니라 “ca-tmpl은 응답 meta와 로그 context를 연결하는 관측성 foundation을 구현했다”입니다.
|
||||
|
||||
## 코드 예제 / Code samples (있다면)
|
||||
|
||||
```java
|
||||
// 출처: [[wiki/projects/ca-tmpl/observability-log-metric-trace-runbook]]
|
||||
// 실제 파일: adapter-web/.../MdcKeys.java, ca-tmpl @f6fbd4e196b4
|
||||
public final class MdcKeys {
|
||||
public static final String REQUEST_ID = "request_id";
|
||||
public static final String TRACE_ID = "trace_id";
|
||||
public static final String SPAN_ID = "span_id";
|
||||
public static final String CORRELATION_ID = "correlation_id";
|
||||
public static final String USER_PRINCIPAL = "user_principal";
|
||||
}
|
||||
```
|
||||
|
||||
```java
|
||||
// 출처: [[wiki/projects/ca-tmpl/observability-log-metric-trace-runbook]]
|
||||
// 실제 파일: adapter-web/.../RequestLoggingFilter.java, ca-tmpl @f6fbd4e196b4
|
||||
String requestId = resolveOrGenerate(req.getHeader("X-Request-Id"));
|
||||
String correlationId = resolveOrGenerate(req.getHeader("X-Correlation-Id"));
|
||||
res.setHeader("X-Request-Id", requestId);
|
||||
res.setHeader("X-Correlation-Id", correlationId);
|
||||
|
||||
MDC.put(MdcKeys.REQUEST_ID, requestId);
|
||||
MDC.put(MdcKeys.CORRELATION_ID, correlationId);
|
||||
|
||||
TraceParent traceParent = resolveOrGenerateTraceParent(req.getHeader("traceparent"));
|
||||
MDC.put(MdcKeys.TRACE_ID, traceParent.traceId());
|
||||
MDC.put(MdcKeys.SPAN_ID, traceParent.spanId());
|
||||
res.setHeader("traceparent", traceParent.toHeader());
|
||||
```
|
||||
|
||||
```java
|
||||
// 출처: [[wiki/projects/ca-tmpl/observability-log-metric-trace-runbook]]
|
||||
// 실제 파일: adapter-web/.../HeaderSanitizer.java, ca-tmpl @f6fbd4e196b4
|
||||
public static String sanitize(String raw, int maxLength) {
|
||||
if (raw == null) {
|
||||
return null;
|
||||
}
|
||||
StringBuilder sb = new StringBuilder(Math.min(raw.length(), maxLength));
|
||||
for (int i = 0; i < raw.length() && sb.length() < maxLength; i++) {
|
||||
char c = raw.charAt(i);
|
||||
if (c >= 0x20) {
|
||||
sb.append(c);
|
||||
}
|
||||
}
|
||||
return sb.toString();
|
||||
}
|
||||
```
|
||||
|
||||
```java
|
||||
// 출처: [[wiki/projects/ca-tmpl/observability-log-metric-trace-runbook]]
|
||||
// 실제 파일: adapter-web/.../ResponseMetaFactory.java, ca-tmpl @f6fbd4e196b4
|
||||
public static ResponseMeta fromMdc() {
|
||||
return new ResponseMeta(
|
||||
MDC.get(MdcKeys.REQUEST_ID), MDC.get(MdcKeys.TRACE_ID), MDC.get(MdcKeys.CORRELATION_ID));
|
||||
}
|
||||
```
|
||||
|
||||
```xml
|
||||
<!-- 출처: [[wiki/projects/ca-tmpl/observability-log-metric-trace-runbook]] -->
|
||||
<!-- 실제 파일: app-bootstrap/src/main/resources/logback-spring.xml, ca-tmpl @f6fbd4e196b4 -->
|
||||
<includeMdcKeyName>trace_id</includeMdcKeyName>
|
||||
<includeMdcKeyName>span_id</includeMdcKeyName>
|
||||
<includeMdcKeyName>request_id</includeMdcKeyName>
|
||||
<includeMdcKeyName>correlation_id</includeMdcKeyName>
|
||||
<includeMdcKeyName>user_principal</includeMdcKeyName>
|
||||
```
|
||||
|
||||
## Sources / 근거 (canonical 인용 필수, derived layer 의무)
|
||||
|
||||
- [[wiki/projects/ca-tmpl/observability-log-metric-trace-runbook]] - 이 글의 1차 canonical. MDC/header/meta/logback foundation, local verification, planned/documented-only 항목 경계를 따른다.
|
||||
- [[wiki/concepts/observability-log-metric-trace-runbook]] - 관련 개념 문서. 로그, metric, trace, runbook의 일반 개념 배경으로만 둔다.
|
||||
|
||||
## 사실 vs 의견 / Fact vs opinion 구분
|
||||
|
||||
- 사실: ca-tmpl에는 `MdcKeys`, `HeaderSanitizer`, `RequestLoggingFilter`, `ResponseMetaFactory`, `ResponseMeta`, `logback-spring.xml` MDC include 설정이 존재한다. 근거: [[wiki/projects/ca-tmpl/observability-log-metric-trace-runbook]]
|
||||
- 사실: inbound request/correlation id sanitize, traceparent 채택/생성, response header 설정, envelope meta projection은 구현된 foundation 범위다. 근거: [[wiki/projects/ca-tmpl/observability-log-metric-trace-runbook]]
|
||||
- 사실: production alert tuning, 실제 incident response 효과, trace backend export 검증, runbook 운영 검증은 project canonical 기준으로 구현/운영 검증 범위가 아니다. 근거: [[wiki/projects/ca-tmpl/observability-log-metric-trace-runbook]]
|
||||
- 의견: observability를 “데이터 출력”보다 “서로 join 가능한 운영 계약”으로 설명하면 skeleton 설계 의도가 더 잘 드러난다.
|
||||
- 알지 못하는 것: 실제 alert fatigue, log volume/cost 변화, production trace 검색 성공률.
|
||||
|
||||
## 답할 수 있는 범위 / Answer boundary
|
||||
|
||||
- 자신 있게 답할 수 있는 후속 질문:
|
||||
- response meta와 log context를 왜 연결하는가?
|
||||
- snake_case MDC와 camelCase JSON meta를 왜 분리하는가?
|
||||
- inbound header sanitize와 principal pseudonymization은 어떤 위험을 줄이는가?
|
||||
- 다음 글로 넘길 부분:
|
||||
- production alert threshold.
|
||||
- OpenTelemetry exporter와 trace backend 운영.
|
||||
- runbook link-check와 incident review 결과.
|
||||
|
||||
## 게시 체크리스트 / Publish checklist
|
||||
|
||||
- [x] 모든 사실 주장에 canonical 링크 있음
|
||||
- [x] 사실 vs 의견 분리 명시됨
|
||||
- [x] 금지 마케팅 표현 없음
|
||||
- [x] 코드 예제 출처 명시
|
||||
- [x] 타깃 독자 가정과 톤 일치
|
||||
- [x] `/lint` 통과
|
||||
- [ ] 게시 URL 기록 (게시 후):
|
||||
|
||||
## Related / 관련
|
||||
|
||||
- 후속 글 후보: [[wiki/blog/ca-tmpl-api-error-envelope-design-2026-07-02]]
|
||||
- 후속 글 후보: [[wiki/blog/ca-tmpl-security-baseline-jwt-actuator-secrets-2026-07-02]]
|
||||
@@ -1 +0,0 @@
|
||||
../../vault/40-publish/blog/ca-tmpl-privacy-file-domain-modeling-2026-07-02.md
|
||||
@@ -0,0 +1,152 @@
|
||||
---
|
||||
title: Privacy와 File Handling을 Domain Modeling과 함께 보기
|
||||
source_type: blog
|
||||
status: verified
|
||||
confidence: high
|
||||
tags: [blog, ca-tmpl, privacy, file-upload, domain-modeling]
|
||||
related_projects: [ca-tmpl]
|
||||
last_reviewed: 2026-07-03
|
||||
canonical_sources:
|
||||
- wiki/projects/ca-tmpl/privacy-file-domain-modeling
|
||||
audience: backend-engineer
|
||||
target_publish:
|
||||
status_label: ready
|
||||
---
|
||||
|
||||
# Privacy와 File Handling을 Domain Modeling과 함께 보기
|
||||
|
||||
## Parent / 부모 (필수)
|
||||
|
||||
- 핵심 canonical: [[wiki/projects/ca-tmpl/privacy-file-domain-modeling]]
|
||||
- 관련 개념 문서: [[wiki/concepts/privacy-file-domain-modeling]] - privacy, file upload, DDD guardrail의 일반 배경. 현재 이 글의 구현 사실 근거는 verified project canonical에 둔다.
|
||||
|
||||
## 타깃 독자 / Target reader
|
||||
|
||||
- 독자 profile: skeleton에서 privacy, file upload, domain modeling guardrail을 함께 고민하는 백엔드 엔지니어.
|
||||
- 이미 안다고 가정하는 것: PII, file upload, DDD aggregate.
|
||||
- 처음 듣는다고 가정하는 것: privacy/file/domain을 각각 따로 구현하지 않고 “어떤 값이 어디에 남으면 안 되는가”라는 모델링 경계로 연결하는 관점.
|
||||
|
||||
## 도입 / Hook
|
||||
|
||||
Privacy는 보안팀 문서처럼 보이고, file upload는 adapter 이슈처럼 보이며, domain modeling은 DDD 문법처럼 보입니다. 그런데 실제 프로젝트에서는 세 가지가 자주 엮입니다. 사용자의 원본 식별자가 로그에 남으면 안 되고, upload payload는 app/proxy/gateway 어디에서 막을지 정해야 하며, domain model은 ORM이나 logger에 오염되지 않아야 합니다.
|
||||
|
||||
ca-tmpl은 이 세 축을 한 문서에 묶었지만, 구현 범위는 균등하지 않습니다. HMAC 기반 principal pseudonymization, MDC logging path, domain purity/aggregate/value object guardrail은 코드와 테스트가 있습니다. 반면 file upload handler, DSR workflow, backup cryptographic erasure, ICAP integration은 아직 문서/계획 범위입니다. 이 글은 이 차이를 숨기지 않고, privacy와 modeling을 함께 보는 이유를 정리합니다.
|
||||
|
||||
## 본문 outline / Body outline
|
||||
|
||||
1. privacy/file/domain modeling을 같은 문서에서 보는 이유.
|
||||
2. 구현된 privacy foundation - principal pseudonymization과 MDC logging.
|
||||
3. 구현된 domain guardrail - pure domain, logger ban, value object, aggregate setter rule.
|
||||
4. file handling과 DSR은 아직 planned 범위다.
|
||||
5. compliance/운영 검증은 없다.
|
||||
|
||||
## 본문 / Body
|
||||
|
||||
Privacy의 첫 번째 실수는 “민감 정보를 나중에 마스킹하면 된다”고 생각하는 것입니다. 하지만 로그에 raw principal이 들어가면, 이후 retention이나 DSR을 논의하기 전에 이미 추적 가능한 식별자가 퍼져 있습니다. ca-tmpl은 request logging path에서 raw principal을 바로 MDC에 넣지 않고, `UserPrincipalPseudonymizerPort`를 거친 pseudonymized value만 `user_principal` key에 넣습니다.
|
||||
|
||||
구현체는 `HmacUserPrincipalPseudonymizer`입니다. 입력 principal을 HMAC-SHA-256으로 64자 lowercase hex token으로 바꿉니다. `PseudonymizationConfig`는 이 구현체를 `UserPrincipalPseudonymizerPort` bean으로 제공합니다. `PrivacySettings`는 `ca-skeleton.privacy.*` 설정에서 salt를 읽고, local/test에서 blank salt면 dev sentinel을 사용합니다. 이 흐름은 “raw subject를 로그에 그대로 쓰지 않는다”는 최소 foundation입니다.
|
||||
|
||||
다만 이것을 anonymization이라고 부르면 안 됩니다. HMAC token은 같은 salt에서 같은 입력을 안정적으로 같은 token으로 만들기 때문에, 추적 가능성을 줄이는 pseudonymization에 가깝습니다. input space가 작으면 brute-force 위험도 남습니다. 또한 backup 안에 이미 남은 값을 단건 삭제하는 GDPR Art.17 문제까지 해결하지 않습니다. project canonical도 per-principal envelope key 구조는 미결정이라고 명시합니다.
|
||||
|
||||
Domain modeling guardrail은 privacy와 다른 문제처럼 보이지만, 같은 방향을 봅니다. domain layer가 logger를 직접 잡으면 domain invariant violation이 곧바로 log payload가 될 수 있습니다. domain class가 JPA annotation이나 framework type에 묶이면 persistence detail이 domain boundary로 들어옵니다. ca-tmpl의 `CleanArchitectureTest`는 domain package가 Spring/JPA/Hibernate/Lombok/application/adapter/bootstrap에 의존하지 못하게 하고, domain logger dependency도 별도 rule로 금지합니다.
|
||||
|
||||
Value Object와 Aggregate Root rule도 있습니다. `@ValueObject`는 public no-arg constructor를 금지합니다. 값 객체가 빈 생성자로 만들어지고 나중에 setter로 채워지면 invariant를 우회할 수 있기 때문입니다. `@AggregateRoot`에는 public `set*` mutator를 금지합니다. aggregate state는 의도가 드러나는 method를 통해 바뀌어야 하고, 그 method가 invariant를 확인해야 합니다.
|
||||
|
||||
File handling 쪽은 아직 구현됐다고 말하면 안 됩니다. app 10MB, proxy 12MB, gateway 20MB 같은 size limit, content-type allowlist, temp orphan cleanup, ICAP antivirus gateway는 project canonical에 문서화되어 있지만 file upload handler나 scan integration으로 닫힌 상태가 아닙니다. DSR SLA, backup cryptographic erasure도 마찬가지입니다. 설계 방향은 있지만, 실제 요청 처리 workflow나 법무/compliance review가 있는 것은 아닙니다.
|
||||
|
||||
그래서 이 글의 결론은 조심스럽습니다. ca-tmpl은 privacy/file/domain 전체를 완성한 것이 아닙니다. 구현된 것은 pseudonymized principal logging foundation과 domain modeling guardrail 일부입니다. file upload, DSR, backup erasure는 후속 구현이 필요합니다. 하지만 세 축을 함께 보는 관점은 유효합니다. 어떤 값이 어디에 남는지, 어떤 계층이 어떤 타입을 알 수 있는지, 어떤 construction path가 invariant를 우회하는지 모두 결국 boundary 문제이기 때문입니다.
|
||||
|
||||
## 코드 예제 / Code samples (있다면)
|
||||
|
||||
```java
|
||||
// 출처: [[wiki/projects/ca-tmpl/privacy-file-domain-modeling]]
|
||||
// 실제 파일: adapter-identifier/.../HmacUserPrincipalPseudonymizer.java, ca-tmpl @f6fbd4e196b4
|
||||
public String pseudonymize(String rawPrincipal) {
|
||||
if (rawPrincipal == null || rawPrincipal.isBlank()) {
|
||||
return null;
|
||||
}
|
||||
Mac mac = Mac.getInstance("HmacSHA256");
|
||||
mac.init(key);
|
||||
byte[] digest = mac.doFinal(rawPrincipal.getBytes(StandardCharsets.UTF_8));
|
||||
return HexFormat.of().formatHex(digest);
|
||||
}
|
||||
```
|
||||
|
||||
```java
|
||||
// 출처: [[wiki/projects/ca-tmpl/privacy-file-domain-modeling]]
|
||||
// 실제 파일: adapter-web/.../RequestLoggingFilter.java, ca-tmpl @f6fbd4e196b4
|
||||
private void putUserPrincipalIfAvailable() {
|
||||
Authentication auth = SecurityContextHolder.getContext().getAuthentication();
|
||||
if (auth != null && auth.getPrincipal() instanceof AuthenticatedPrincipal user) {
|
||||
String pseudo = pseudonymizer.pseudonymize(user.idpUserId());
|
||||
if (pseudo != null) {
|
||||
MDC.put(MdcKeys.USER_PRINCIPAL, pseudo);
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
```java
|
||||
// 출처: [[wiki/projects/ca-tmpl/privacy-file-domain-modeling]]
|
||||
// 실제 파일: app-bootstrap/.../CleanArchitectureTest.java, ca-tmpl @f6fbd4e196b4
|
||||
static final ArchRule DOMAIN_IS_PURE =
|
||||
noClasses()
|
||||
.that()
|
||||
.resideInAPackage("..domain..")
|
||||
.should()
|
||||
.dependOnClassesThat()
|
||||
.resideInAnyPackage("org.springframework..", "jakarta.persistence..", "org.hibernate..");
|
||||
```
|
||||
|
||||
```java
|
||||
// 출처: [[wiki/projects/ca-tmpl/privacy-file-domain-modeling]]
|
||||
// 실제 파일: app-bootstrap/.../CleanArchitectureTest.java, ca-tmpl @f6fbd4e196b4
|
||||
static final ArchRule AGGREGATE_ROOT_SETTERS_ARE_NOT_PUBLIC =
|
||||
methods()
|
||||
.that()
|
||||
.haveNameMatching("set.*")
|
||||
.and()
|
||||
.areDeclaredInClassesThat()
|
||||
.areAnnotatedWith(AggregateRoot.class)
|
||||
.should()
|
||||
.notBePublic();
|
||||
```
|
||||
|
||||
## Sources / 근거 (canonical 인용 필수, derived layer 의무)
|
||||
|
||||
- [[wiki/projects/ca-tmpl/privacy-file-domain-modeling]] - 이 글의 1차 canonical. pseudonymization/logging path, domain guardrail, file/DSR/backup planned 범위, 운영/법무 미검증 경계를 따른다.
|
||||
- [[wiki/concepts/privacy-file-domain-modeling]] - 관련 개념 문서. GDPR, cryptographic erase, file upload, DDD guardrail의 일반 배경으로만 둔다.
|
||||
|
||||
## 사실 vs 의견 / Fact vs opinion 구분
|
||||
|
||||
- 사실: ca-tmpl에는 `HmacUserPrincipalPseudonymizer`, `PseudonymizationConfig`, `PrivacySettings`, `RequestLoggingFilter`의 pseudonymized principal logging path가 존재한다. 근거: [[wiki/projects/ca-tmpl/privacy-file-domain-modeling]]
|
||||
- 사실: domain purity, domain logger ban, value object no public no-arg constructor, aggregate setter visibility rule이 `CleanArchitectureTest` 계열에 존재한다. 근거: [[wiki/projects/ca-tmpl/privacy-file-domain-modeling]]
|
||||
- 사실: file upload handler, DSR workflow, backup cryptographic erasure, ICAP integration은 구현/로컬 검증 범위가 아니다. 근거: [[wiki/projects/ca-tmpl/privacy-file-domain-modeling]]
|
||||
- 의견: privacy와 domain modeling은 서로 다른 주제처럼 보여도 “값이 어디에 남고 어떤 경계가 우회되는가”라는 같은 질문으로 연결된다.
|
||||
- 알지 못하는 것: 실제 GDPR DSR 처리, legal review, antivirus gateway 운영, backup erasure 검증.
|
||||
|
||||
## 답할 수 있는 범위 / Answer boundary
|
||||
|
||||
- 자신 있게 답할 수 있는 후속 질문:
|
||||
- HMAC pseudonymization이 anonymization이 아닌 이유.
|
||||
- raw principal을 MDC에 직접 쓰지 않는 이유.
|
||||
- domain layer logger ban과 aggregate/value object guardrail이 어떤 우회를 막는가.
|
||||
- 다음 글로 넘길 부분:
|
||||
- file upload handler와 antivirus integration.
|
||||
- DSR workflow와 backup cryptographic erasure.
|
||||
- compliance review와 production privacy workflow.
|
||||
|
||||
## 게시 체크리스트 / Publish checklist
|
||||
|
||||
- [x] 모든 사실 주장에 canonical 링크 있음
|
||||
- [x] 사실 vs 의견 분리 명시됨
|
||||
- [x] 금지 마케팅 표현 없음
|
||||
- [x] 코드 예제 출처 명시
|
||||
- [x] 타깃 독자 가정과 톤 일치
|
||||
- [x] `/lint` 통과
|
||||
- [ ] 게시 URL 기록 (게시 후):
|
||||
|
||||
## Related / 관련
|
||||
|
||||
- 후속 글 후보: [[wiki/blog/ca-tmpl-clean-architecture-package-layout-2026-07-02]]
|
||||
- 후속 글 후보: [[wiki/blog/ca-tmpl-observability-log-metric-trace-runbook-2026-07-02]]
|
||||
@@ -1 +0,0 @@
|
||||
../../vault/40-publish/blog/ca-tmpl-resource-identifier-format-2026-07-02.md
|
||||
@@ -0,0 +1,160 @@
|
||||
---
|
||||
title: Resource Identifier를 UUID 대신 ULID로 고정한 이유
|
||||
source_type: blog
|
||||
status: verified
|
||||
confidence: high
|
||||
tags: [blog, ca-tmpl, resource-identifier, ulid]
|
||||
related_projects: [ca-tmpl]
|
||||
last_reviewed: 2026-07-03
|
||||
canonical_sources:
|
||||
- wiki/projects/ca-tmpl/resource-identifier-format
|
||||
audience: backend-engineer
|
||||
target_publish:
|
||||
status_label: ready
|
||||
---
|
||||
|
||||
# Resource Identifier를 UUID 대신 ULID로 고정한 이유
|
||||
|
||||
## Parent / 부모 (필수)
|
||||
|
||||
- 핵심 canonical: [[wiki/projects/ca-tmpl/resource-identifier-format]]
|
||||
- 관련 개념 문서: [[wiki/concepts/resource-identifier-format]] - 일반 identifier format 비교. 현재 이 글의 구현 사실 근거는 verified project canonical에 둔다.
|
||||
|
||||
## 타깃 독자 / Target reader
|
||||
|
||||
- 독자 profile: API resource id 규칙을 skeleton 수준에서 정하려는 백엔드 엔지니어.
|
||||
- 이미 안다고 가정하는 것: UUID, database primary key, API id.
|
||||
- 처음 듣는다고 가정하는 것: ULID의 표기 규칙, 생성 위치, id-kind governance를 architecture rule로 고정하는 방식.
|
||||
|
||||
## 도입 / Hook
|
||||
|
||||
API의 id는 처음에는 단순한 문자열처럼 보입니다. 하지만 id가 어디에서 생성되는지, 어떤 형태로 외부에 노출되는지, DB에는 어떤 타입으로 저장되는지, 어떤 계층이 id를 만들 수 있는지까지 정하지 않으면 나중에 작은 균열이 생깁니다. controller에서 `UUID.randomUUID()`를 부르고, 다른 use case에서는 DB sequence를 쓰고, 또 다른 API는 문자열 id를 그대로 반환하는 식입니다.
|
||||
|
||||
ca-tmpl은 이 문제를 “UUID냐 ULID냐”의 취향 싸움으로만 보지 않았습니다. 외부 API에는 26자 대문자 Crockford base32 ULID를 노출하고, domain은 id를 value object로 다루며, 생성은 adapter port 뒤로 숨기고, persistence는 PostgreSQL `uuid` column으로 저장하는 계약으로 묶었습니다. 이 글은 그 결정이 왜 필요했는지, 어떤 코드로 고정됐는지, 그리고 아직 검증했다고 말하면 안 되는 부분이 무엇인지 정리합니다.
|
||||
|
||||
## 본문 outline / Body outline
|
||||
|
||||
1. identifier를 나중에 정하면 생기는 문제.
|
||||
2. ULID를 API 표기 규칙으로 선택한 이유와 trade-off.
|
||||
3. domain value object, generation port, adapter module의 역할 분리.
|
||||
4. persistence와 wire format의 분리.
|
||||
5. ArchUnit rule로 id governance를 고정한 범위.
|
||||
6. 운영 규모에서의 index/locality 검증은 없음.
|
||||
|
||||
## 본문 / Body
|
||||
|
||||
Resource id 설계에서 먼저 정해야 하는 것은 “id 값이 무엇인가”보다 “누가 id를 만들 수 있는가”입니다. controller가 직접 `UUID.randomUUID()`를 호출하면 use case마다 생성 방식이 갈라질 수 있습니다. application service가 라이브러리에 직접 의존하면 domain model은 순수해 보여도 use case가 infrastructure detail을 알고 있게 됩니다. DB가 id 생성을 전담하면 API에 노출되는 id format과 persistence type이 묶입니다.
|
||||
|
||||
ca-tmpl은 이 지점을 domain port로 끊었습니다. domain-core에는 `ResourceId` marker와 `IdFactory<T>` port가 있고, sample domain에는 `WorkLogId`와 `WorkLogIdFactory`가 있습니다. `WorkLogId`는 26자 대문자 Crockford base32 ULID 문자열만 받는 value object입니다. domain은 ULID library를 직접 알지 않습니다. 실제 생성은 `adapter-identifier` module의 `UlidWorkLogIdFactory`가 맡습니다. 이렇게 하면 domain은 “id shape”만 알고, “id를 어떻게 mint하는가”는 adapter가 책임집니다.
|
||||
|
||||
ULID를 고른 이유는 API 표기와 정렬성의 균형입니다. ULID는 26자 문자열이라 URL path에 넣기 쉽고, 시간 성분이 앞에 있어 생성 시점 기준 정렬 가능성이 있습니다. ca-tmpl에서는 이를 외부 wire format으로 삼았습니다. 다만 이 말이 곧 “모든 DB에서 insert 성능이 검증됐다”는 뜻은 아닙니다. project canonical은 PostgreSQL index locality benchmark가 없다고 명시합니다. 이 글도 그 선을 넘지 않습니다.
|
||||
|
||||
재미있는 부분은 DB 저장 방식입니다. API와 domain에서는 ULID 문자열을 쓰지만, JPA entity는 `UUID` field를 PostgreSQL native `uuid` column에 저장합니다. `UlidCodec`이 ULID 문자열과 UUID 사이 변환을 맡고, persistence mapper가 domain `WorkLogId`와 entity `UUID` 사이를 변환합니다. 즉 외부 계약은 “대문자 ULID 문자열”이고, DB 저장 계약은 “native uuid type”입니다. 두 계약을 같은 문자열 column으로 합쳐버리지 않은 셈입니다.
|
||||
|
||||
wire format도 별도로 고정했습니다. Java record인 `WorkLogId`를 그대로 Jackson이 직렬화하면 `{ "value": "..." }` 형태가 될 수 있습니다. ca-tmpl은 `WorkLogIdSerializer`를 두어 응답에서는 bare ULID string이 나가도록 했습니다. 이 결정 덕분에 API 소비자는 id field를 객체가 아니라 문자열로 다룹니다. 내부 value object와 외부 JSON shape를 분리한 것입니다.
|
||||
|
||||
id governance는 ArchUnit rule로도 고정되어 있습니다. domain entity의 `id` field는 `ResourceId`여야 하고, controller/application layer는 resource id 생성을 위해 `UUID.randomUUID()`나 `UlidCreator`에 직접 닿지 않아야 합니다. `Math.random()`도 id seed로 쓰지 못하게 막습니다. JPA `@Column`으로 매핑된 id field가 기본 `varchar(255)`로 떨어지는 것도 금지합니다. 또 `adapter-identifier`는 sibling adapter나 bootstrap에 의존하지 못합니다.
|
||||
|
||||
여기까지가 ca-tmpl이 실제로 구현하고 로컬 검증한 범위입니다. `adapter-identifier` module, `WorkLogId`, `UlidCodec`, persistence mapper, JSON serializer, architecture rule과 테스트가 존재합니다. 반면 CUID2 override, multi-tenancy까지 포함한 id scoping, log scrubber, PostgreSQL index benchmark는 구현됐다고 말하면 안 됩니다. 이 글의 결론은 “ULID가 어디서나 이긴다”가 아니라, “ca-tmpl은 id format을 API/Domain/Persistence/Architecture rule까지 이어지는 계약으로 만들었다”입니다.
|
||||
|
||||
## 코드 예제 / Code samples (있다면)
|
||||
|
||||
```java
|
||||
// 출처: [[wiki/projects/ca-tmpl/resource-identifier-format]]
|
||||
// 실제 파일: domain-core/.../ResourceId.java, ca-tmpl @f6fbd4e196b4
|
||||
public interface ResourceId<SELF extends ResourceId<SELF>> {
|
||||
/** The canonical 26-character uppercase Crockford base32 ULID string. */
|
||||
String value();
|
||||
}
|
||||
```
|
||||
|
||||
```java
|
||||
// 출처: [[wiki/projects/ca-tmpl/resource-identifier-format]]
|
||||
// 실제 파일: sample-portfolio/.../WorkLogId.java, ca-tmpl @f6fbd4e196b4
|
||||
public record WorkLogId(String value) implements ResourceId<WorkLogId> {
|
||||
private static final Pattern PATTERN = Pattern.compile("^[0-9A-HJKMNP-TV-Z]{26}$");
|
||||
|
||||
public WorkLogId {
|
||||
if (value == null || !PATTERN.matcher(value).matches()) {
|
||||
throw new IllegalArgumentException("Invalid WorkLogId format: " + value);
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
```java
|
||||
// 출처: [[wiki/projects/ca-tmpl/resource-identifier-format]]
|
||||
// 실제 파일: adapter-identifier/.../UlidCodec.java, ca-tmpl @f6fbd4e196b4
|
||||
public static String normalize(String input) {
|
||||
if (input == null) {
|
||||
return null;
|
||||
}
|
||||
return Ulid.from(input.toUpperCase(Locale.ROOT)).toString();
|
||||
}
|
||||
|
||||
public static UUID toUuid(String ulidString) {
|
||||
return Ulid.from(ulidString).toUuid();
|
||||
}
|
||||
```
|
||||
|
||||
```java
|
||||
// 출처: [[wiki/projects/ca-tmpl/resource-identifier-format]]
|
||||
// 실제 파일: sample-portfolio/.../WorkLogEntity.java, ca-tmpl @f6fbd4e196b4
|
||||
@Id
|
||||
@Column(name = "id", columnDefinition = "uuid", nullable = false, updatable = false)
|
||||
@JdbcTypeCode(SqlTypes.UUID)
|
||||
private UUID id;
|
||||
```
|
||||
|
||||
```java
|
||||
// 출처: [[wiki/projects/ca-tmpl/resource-identifier-format]]
|
||||
// 실제 파일: app-bootstrap/.../CleanArchitectureTest.java, ca-tmpl @f6fbd4e196b4
|
||||
static final ArchRule NO_UUID_RANDOM_IN_CONTROLLER =
|
||||
noClasses()
|
||||
.that()
|
||||
.resideInAnyPackage("..adapter.web..controller..", "..application..")
|
||||
.should()
|
||||
.callMethod(UUID.class, "randomUUID")
|
||||
.orShould()
|
||||
.dependOnClassesThat()
|
||||
.haveFullyQualifiedName("com.github.f4b6a3.ulid.UlidCreator");
|
||||
```
|
||||
|
||||
## Sources / 근거 (canonical 인용 필수, derived layer 의무)
|
||||
|
||||
- [[wiki/projects/ca-tmpl/resource-identifier-format]] - 이 글의 1차 canonical. ULID wire format, `ResourceId`, `IdFactory`, `adapter-identifier`, persistence UUID column, ArchUnit rule, local verification, 미구현 항목 경계를 따른다.
|
||||
- [[wiki/concepts/resource-identifier-format]] - 관련 개념 문서. UUIDv7/ULID/Snowflake/NanoID/CUID2 등 일반 비교를 위한 배경으로만 둔다.
|
||||
|
||||
## 사실 vs 의견 / Fact vs opinion 구분
|
||||
|
||||
- 사실: ca-tmpl에는 `ResourceId`, `IdFactory`, `WorkLogId`, `WorkLogIdFactory`, `UlidWorkLogIdFactory`, `UlidCodec`, `WorkLogEntity`, `WorkLogPersistenceMapper`, `WorkLogIdSerializer`가 존재한다. 근거: [[wiki/projects/ca-tmpl/resource-identifier-format]]
|
||||
- 사실: `adapter-identifier` module과 identifier 관련 ArchUnit rule이 존재하고, project canonical은 이를 local verification 범위로 기록한다. 근거: [[wiki/projects/ca-tmpl/resource-identifier-format]]
|
||||
- 사실: PostgreSQL uuid index locality benchmark, CUID2 override, multi-tenancy id scoping, `UlidLogScrubber`는 구현/운영 검증으로 말하지 않는다. 근거: [[wiki/projects/ca-tmpl/resource-identifier-format]]
|
||||
- 의견: ca-tmpl 같은 skeleton에서는 id를 단순 primitive로 두는 것보다 value object와 generation port로 고정하는 편이 이후 boundary rule을 설명하기 쉽다.
|
||||
- 알지 못하는 것: production insert/index metric, tenant별 id collision/lookup 운영 결과.
|
||||
|
||||
## 답할 수 있는 범위 / Answer boundary
|
||||
|
||||
- 자신 있게 답할 수 있는 후속 질문:
|
||||
- ca-tmpl에서 resource id 생성이 왜 adapter port 뒤에 있는가?
|
||||
- API에는 ULID string을 노출하면서 DB에는 왜 native `uuid` column을 쓰는가?
|
||||
- 어떤 ArchUnit rule이 id 생성 위치와 id column mapping을 막는가?
|
||||
- 다음 글로 넘길 부분:
|
||||
- sharding/partitioning 환경의 id 전략.
|
||||
- PostgreSQL index locality benchmark.
|
||||
- multi-tenant id scoping과 tenant-aware repository rule.
|
||||
|
||||
## 게시 체크리스트 / Publish checklist
|
||||
|
||||
- [x] 모든 사실 주장에 canonical 링크 있음
|
||||
- [x] 사실 vs 의견 분리 명시됨
|
||||
- [x] 금지 마케팅 표현 없음
|
||||
- [x] 코드 예제 출처 명시
|
||||
- [x] 타깃 독자 가정과 톤 일치
|
||||
- [x] `/lint` 통과
|
||||
- [ ] 게시 URL 기록 (게시 후):
|
||||
|
||||
## Related / 관련
|
||||
|
||||
- 후속 글 후보: [[wiki/blog/ca-tmpl-api-evolution-and-schema-2026-07-02]]
|
||||
- 후속 글 후보: [[wiki/blog/ca-tmpl-multi-tenancy-isolation-patterns-2026-07-02]]
|
||||
@@ -1 +0,0 @@
|
||||
../../vault/40-publish/blog/ca-tmpl-runtime-container-health-migration-2026-07-02.md
|
||||
@@ -0,0 +1,166 @@
|
||||
---
|
||||
title: Runtime 설정 오류를 Startup에서 실패시키기
|
||||
source_type: blog
|
||||
status: verified
|
||||
confidence: high
|
||||
tags: [blog, ca-tmpl, runtime, container, health, migration]
|
||||
related_projects: [ca-tmpl]
|
||||
last_reviewed: 2026-07-03
|
||||
canonical_sources:
|
||||
- wiki/projects/ca-tmpl/runtime-container-health-migration
|
||||
audience: backend-engineer
|
||||
target_publish:
|
||||
status_label: ready
|
||||
---
|
||||
|
||||
# Runtime 설정 오류를 Startup에서 실패시키기
|
||||
|
||||
## Parent / 부모 (필수)
|
||||
|
||||
- 핵심 canonical: [[wiki/projects/ca-tmpl/runtime-container-health-migration]]
|
||||
- 관련 개념 문서: [[wiki/concepts/runtime-container-health-migration]] - 일반 runtime/container/health/migration 개념. 현재 이 글의 구현 사실 근거는 verified project canonical에 둔다.
|
||||
|
||||
## 타깃 독자 / Target reader
|
||||
|
||||
- 독자 profile: container readiness, health check, migration, runtime config guard를 skeleton에 넣고 싶은 엔지니어.
|
||||
- 이미 안다고 가정하는 것: Docker/Kubernetes probe, Flyway, env config, Spring Boot Actuator.
|
||||
- 처음 듣는다고 가정하는 것: runtime safety를 startup validator, health group, migration strategy, exit code로 묶는 방식.
|
||||
|
||||
## 도입 / Hook
|
||||
|
||||
운영 설정 오류는 배포 뒤에 늦게 발견될수록 비쌉니다. pool size가 음수로 들어가거나, `open-in-view`가 켜지거나, prod profile에서 Flyway safety option이 풀린 상태로 애플리케이션이 올라오면, 문제는 요청을 받기 시작한 뒤에 드러날 수 있습니다.
|
||||
|
||||
ca-tmpl은 이런 오류를 startup 단계에서 실패시키는 방향으로 runtime contract를 잡았습니다. env-driven configuration을 쓰되 잘못된 값은 lenient default로 숨기지 않고, health group은 liveness/readiness/startup 역할을 나누며, Flyway migration 실패는 startup failure와 exit code로 드러냅니다. 이 글은 ca-tmpl에 실제로 구현된 runtime/container/health/migration baseline과 아직 운영 검증으로 말하면 안 되는 범위를 정리합니다.
|
||||
|
||||
## 본문 outline / Body outline
|
||||
|
||||
1. startup fail-fast의 가치.
|
||||
2. runtime numeric bounds와 OSIV/Hikari guard.
|
||||
3. health probe group split과 readiness gate.
|
||||
4. Flyway migration startup contract와 exit code.
|
||||
5. container image/runtime baseline.
|
||||
6. Kubernetes cluster rollout 검증은 없음.
|
||||
|
||||
## 본문 / Body
|
||||
|
||||
runtime 설정은 코드보다 덜 중요해 보이지만, 실제로는 애플리케이션의 동작 경계를 바꿉니다. DB pool max size, Tomcat thread count, shutdown timeout, Flyway option, Actuator exposure는 모두 장애 양상을 바꿀 수 있습니다. 그래서 ca-tmpl은 “값이 이상하면 프레임워크 기본값으로 알아서 흘러가게 둔다”보다 “startup에서 실패한다”는 쪽을 택했습니다.
|
||||
|
||||
`RuntimeNumericBoundsValidator`는 대표적인 예입니다. `spring.datasource.hikari.maximum-pool-size`, `server.tomcat.threads.max`, `server.tomcat.max-connections` 같은 값은 1 이상이어야 하고, minimum idle이나 accept count처럼 0을 허용하는 값은 0 이상이어야 합니다. key가 없으면 framework default에 맡기지만, key가 있는데 범위를 벗어나면 `IllegalStateException`으로 startup을 막습니다. env-driven 설정을 쓰면서도 잘못된 env 값을 조용히 묻지 않는 장치입니다.
|
||||
|
||||
OSIV와 Hikari 설정도 별도 guard로 다룹니다. `OpenInViewSafetyValidator`는 `spring.jpa.open-in-view=true`를 거부합니다. Hikari validator는 `connection-timeout`, `validation-timeout`, `keepalive-time`, `max-lifetime`, `leak-detection-threshold`의 상호 관계를 검사합니다. 예를 들어 validation timeout이 connection timeout보다 길면 pool 동작을 예측하기 어려워집니다. ca-tmpl은 이런 값을 요청 처리 뒤의 증상으로 발견하기보다 startup에서 configuration error로 드러내려 합니다.
|
||||
|
||||
health check는 endpoint 하나로 뭉개지 않습니다. `application.yml`에는 Actuator health group이 `liveness`, `readiness`, `startup`으로 나뉘어 있습니다. liveness는 JVM이 계속 살아갈 수 있는지를 보며 dependency health를 포함하지 않습니다. DB가 잠깐 내려갔다고 pod를 재시작하는 것은 보통 원하는 동작이 아니기 때문입니다. readiness는 traffic을 받아도 되는지를 판단하므로 `readinessState,db`를 포함합니다. startup은 context initialization과 migration 완료 후 준비 상태를 드러내는 gate로 둡니다.
|
||||
|
||||
Flyway도 startup contract의 일부입니다. `MigrationStartupRunner`는 context refresh 중 Flyway migration을 수행하고, 실패하면 `MigrationFailedException` 계열로 바꿔 exit code 70에 연결합니다. `StartupErrorCode`에는 startup validation, migration failure, profile mismatch, required adapter disabled가 각각 다른 exit code와 phase로 정의되어 있습니다. 실패 원인을 process exit status와 structured startup log에서 분리해 보려는 설계입니다.
|
||||
|
||||
prod profile의 Flyway safety guard도 들어 있습니다. `FlywayProdSafetyValidator`는 prod에서 `baseline-on-migrate=true`, `out-of-order=true`, `clean-disabled=false` 같은 위험한 override를 막습니다. 여기서 중요한 것은 “Flyway를 쓰면 안전하다”가 아닙니다. migration 도구를 쓰더라도 prod에서 안전망을 푸는 설정이 들어오면 애플리케이션이 올라오지 않게 만드는 것입니다.
|
||||
|
||||
container/runtime baseline도 project canonical에 포함되어 있습니다. `src/Dockerfile`, graceful shutdown 설정, Actuator health group, startup validator, migration strategy가 묶여 있습니다. 그러나 실제 Kubernetes manifest, rolling update, probe tuning, cluster에서의 rollout incident 검증은 없습니다. 따라서 이 글은 “Kubernetes 운영에서 검증된 lifecycle 설계”가 아니라 “ca-tmpl이 startup fail-fast와 health/migration baseline을 코드와 설정으로 고정하고 로컬 검증했다”까지 말합니다.
|
||||
|
||||
## 코드 예제 / Code samples (있다면)
|
||||
|
||||
```java
|
||||
// 출처: [[wiki/projects/ca-tmpl/runtime-container-health-migration]]
|
||||
// 실제 파일: app-bootstrap/.../RuntimeNumericBoundsValidator.java, ca-tmpl @f6fbd4e196b4
|
||||
static final List<Bound> BOUNDS =
|
||||
List.of(
|
||||
new Bound("spring.datasource.hikari.maximum-pool-size", "APP_DATASOURCE_POOL_MAX_SIZE", 1),
|
||||
new Bound("server.tomcat.threads.max", "APP_SERVER_TOMCAT_MAX_THREADS", 1),
|
||||
new Bound("server.tomcat.max-connections", "APP_SERVER_TOMCAT_MAX_CONNECTIONS", 1),
|
||||
new Bound("spring.datasource.hikari.minimum-idle", "APP_DATASOURCE_POOL_MIN_IDLE", 0),
|
||||
new Bound("server.tomcat.accept-count", "APP_SERVER_TOMCAT_ACCEPT_COUNT", 0));
|
||||
```
|
||||
|
||||
```java
|
||||
// 출처: [[wiki/projects/ca-tmpl/runtime-container-health-migration]]
|
||||
// 실제 파일: app-bootstrap/.../StartupErrorCode.java, ca-tmpl @f6fbd4e196b4
|
||||
public enum StartupErrorCode {
|
||||
STARTUP_VALIDATION_FAILED(78, StartupPhase.ENV_VALIDATION),
|
||||
MIGRATION_FAILED(70, StartupPhase.MIGRATION),
|
||||
PROFILE_MISMATCH(71, StartupPhase.PROFILE_CHECK),
|
||||
REQUIRED_ADAPTER_DISABLED(72, StartupPhase.ADAPTER_ENABLEMENT);
|
||||
}
|
||||
```
|
||||
|
||||
```java
|
||||
// 출처: [[wiki/projects/ca-tmpl/runtime-container-health-migration]]
|
||||
// 실제 파일: app-bootstrap/.../FlywayProdSafetyValidator.java, ca-tmpl @f6fbd4e196b4
|
||||
if (isTrue(BASELINE_ON_MIGRATE_KEY)) {
|
||||
violations.add(BASELINE_ON_MIGRATE_KEY + "=true (removes the missing-migration safety net)");
|
||||
}
|
||||
if (isTrue(OUT_OF_ORDER_KEY)) {
|
||||
violations.add(OUT_OF_ORDER_KEY + "=true (breaks migration ordering consistency)");
|
||||
}
|
||||
if (isFalse(CLEAN_DISABLED_KEY)) {
|
||||
violations.add(CLEAN_DISABLED_KEY + "=false (re-arms destructive Flyway clean)");
|
||||
}
|
||||
```
|
||||
|
||||
```java
|
||||
// 출처: [[wiki/projects/ca-tmpl/runtime-container-health-migration]]
|
||||
// 실제 파일: app-bootstrap/.../MigrationStartupRunner.java, ca-tmpl @f6fbd4e196b4
|
||||
try {
|
||||
MigrateResult result = flyway.migrate();
|
||||
int executed = (result != null) ? result.migrationsExecuted : 0;
|
||||
log.info("startup phase {}: migration complete, {} migration(s) applied",
|
||||
kv("startup.phase", StartupPhase.MIGRATION.wireName()), executed);
|
||||
} catch (FlywayException e) {
|
||||
throw StartupFailures.migrationFailed("Flyway forward-only migration failed during startup", e);
|
||||
}
|
||||
```
|
||||
|
||||
```yaml
|
||||
# 출처: [[wiki/projects/ca-tmpl/runtime-container-health-migration]]
|
||||
# 실제 파일: app-bootstrap/src/main/resources/application.yml, ca-tmpl @f6fbd4e196b4
|
||||
management:
|
||||
endpoint:
|
||||
health:
|
||||
probes:
|
||||
enabled: true
|
||||
group:
|
||||
liveness:
|
||||
include: livenessState
|
||||
readiness:
|
||||
include: readinessState,db
|
||||
startup:
|
||||
include: readinessState
|
||||
```
|
||||
|
||||
## Sources / 근거 (canonical 인용 필수, derived layer 의무)
|
||||
|
||||
- [[wiki/projects/ca-tmpl/runtime-container-health-migration]] - 이 글의 1차 canonical. startup validators, Actuator health group, Flyway startup migration/safety guard, exit code mapping, local verification, 운영 미검증 경계를 따른다.
|
||||
- [[wiki/concepts/runtime-container-health-migration]] - 관련 개념 문서. container lifecycle, health probe, migration strategy 일반 배경으로만 둔다.
|
||||
|
||||
## 사실 vs 의견 / Fact vs opinion 구분
|
||||
|
||||
- 사실: ca-tmpl에는 runtime numeric bounds validator, OSIV/Hikari safety validator, Actuator health group 설정, Flyway startup migration strategy, prod safety validator, startup exit code mapping이 존재한다. 근거: [[wiki/projects/ca-tmpl/runtime-container-health-migration]]
|
||||
- 사실: startup validation과 migration failure는 local/dev verification 범위로 기록되어 있다. 근거: [[wiki/projects/ca-tmpl/runtime-container-health-migration]]
|
||||
- 사실: Kubernetes manifest, rolling update, production probe tuning, cluster-level incident 검증은 없다. 근거: [[wiki/projects/ca-tmpl/runtime-container-health-migration]]
|
||||
- 의견: skeleton에서는 잘못된 runtime env를 lenient default로 흘리는 것보다 startup에서 실패시키는 쪽이 학습과 운영 설명에 유리하다.
|
||||
- 알지 못하는 것: 실제 orchestrator rollout behavior, migration lock contention, production shutdown latency.
|
||||
|
||||
## 답할 수 있는 범위 / Answer boundary
|
||||
|
||||
- 자신 있게 답할 수 있는 후속 질문:
|
||||
- ca-tmpl은 어떤 runtime config 오류를 startup에서 막는가?
|
||||
- liveness와 readiness health group을 왜 나누는가?
|
||||
- Flyway migration 실패가 어떻게 startup failure와 exit code로 연결되는가?
|
||||
- 다음 글로 넘길 부분:
|
||||
- Kubernetes production probe tuning.
|
||||
- rolling update와 graceful shutdown 실측.
|
||||
- DB migration 운영 runbook과 장애 복구 사례.
|
||||
|
||||
## 게시 체크리스트 / Publish checklist
|
||||
|
||||
- [x] 모든 사실 주장에 canonical 링크 있음
|
||||
- [x] 사실 vs 의견 분리 명시됨
|
||||
- [x] 금지 마케팅 표현 없음
|
||||
- [x] 코드 예제 출처 명시
|
||||
- [x] 타깃 독자 가정과 톤 일치
|
||||
- [x] `/lint` 통과
|
||||
- [ ] 게시 URL 기록 (게시 후):
|
||||
|
||||
## Related / 관련
|
||||
|
||||
- 후속 글 후보: [[wiki/blog/ca-tmpl-config-and-adapter-templates-2026-07-02]]
|
||||
- 후속 글 후보: [[wiki/blog/ca-tmpl-devops-ci-supply-chain-dx-2026-07-02]]
|
||||
@@ -1 +0,0 @@
|
||||
../../vault/40-publish/blog/ca-tmpl-sample-fixture-and-adoption-2026-07-02.md
|
||||
@@ -0,0 +1,158 @@
|
||||
---
|
||||
title: Sample Fixture를 버리는 예제가 아니라 Adoption 계약으로 만들기
|
||||
source_type: blog
|
||||
status: verified
|
||||
confidence: high
|
||||
tags: [blog, ca-tmpl, sample-fixture, adoption]
|
||||
related_projects: [ca-tmpl]
|
||||
last_reviewed: 2026-07-03
|
||||
canonical_sources:
|
||||
- wiki/projects/ca-tmpl/sample-fixture-and-adoption
|
||||
audience: backend-engineer
|
||||
target_publish:
|
||||
status_label: ready
|
||||
---
|
||||
|
||||
# Sample Fixture를 버리는 예제가 아니라 Adoption 계약으로 만들기
|
||||
|
||||
## Parent / 부모 (필수)
|
||||
|
||||
- 핵심 canonical: [[wiki/projects/ca-tmpl/sample-fixture-and-adoption]]
|
||||
- 관련 개념 문서: [[wiki/concepts/sample-fixture-and-adoption]] - sample fixture/adoption의 일반 배경. 현재 이 글의 구현 사실 근거는 verified project canonical에 둔다.
|
||||
|
||||
## 타깃 독자 / Target reader
|
||||
|
||||
- 독자 profile: template project의 sample domain을 어떻게 유지/제거할지 고민하는 개발자.
|
||||
- 이미 안다고 가정하는 것: sample app, fixture, template adoption.
|
||||
- 처음 듣는다고 가정하는 것: sample을 데모가 아니라 architecture rule과 operational contract를 검증하는 corpus로 사용하는 방식.
|
||||
|
||||
## 도입 / Hook
|
||||
|
||||
Template repository의 sample code는 애매합니다. 남겨두면 실제 서비스 코드처럼 오해받고, 지우면 skeleton이 정말 동작하는지 보여줄 corpus가 사라집니다. 특히 Clean Architecture skeleton에서는 sample이 단순 CRUD 데모를 넘어 envelope, authorization, transaction, idempotency, outbox, OpenAPI snapshot 같은 계약을 실제 흐름으로 건드리는 역할을 합니다.
|
||||
|
||||
ca-tmpl은 sample을 “나중에 지울 예제”로만 보지 않았습니다. `sample-portfolio` module을 fixture로 유지하고, 동시에 `sampleOffTest`로 sample이 빠진 classpath에서도 core test suite가 컴파일/실행되는지 확인합니다. sample-on과 sample-off를 둘 다 검증하는 구조입니다. 이 글은 sample fixture를 adoption 계약으로 다루는 이유와, 아직 실제 외부 프로젝트 adoption 경험으로 말하면 안 되는 부분을 정리합니다.
|
||||
|
||||
## 본문 outline / Body outline
|
||||
|
||||
1. sample domain의 목적 - 데모가 아니라 contract proof.
|
||||
2. sample-on과 sample-off를 둘 다 검증하는 이유.
|
||||
3. `sampleFixture` configuration과 `sampleOffTest` source set.
|
||||
4. `SampleRemovalSmokeContractTest`가 막는 회귀.
|
||||
5. 외부 adoption 사례는 없음.
|
||||
|
||||
## 본문 / Body
|
||||
|
||||
좋은 skeleton에는 작동하는 예제가 필요합니다. 문서만 보고 architecture rule을 이해하기는 어렵습니다. ca-tmpl의 `sample-portfolio`는 WorkLog 도메인을 통해 use case, controller, persistence adapter, id generation, validation, idempotency, outbox, OpenAPI snapshot 같은 표면을 실제로 건드립니다. 그래서 sample은 “보여주기 화면”이 아니라 contract를 깨뜨렸을 때 테스트가 반응하는 corpus입니다.
|
||||
|
||||
하지만 sample이 production runtime에 섞이면 다른 문제가 생깁니다. downstream project가 template을 가져간 뒤에도 sample package가 core module의 production dependency에 남아 있으면, sample을 지우는 순간 build가 깨질 수 있습니다. 더 나쁘게는 production app이 sample route나 sample bean을 몰래 품은 채 출발할 수 있습니다. 그래서 ca-tmpl은 sample 제거를 runtime toggle이 아니라 build/test classpath 문제로 다룹니다.
|
||||
|
||||
핵심은 `sampleFixture` configuration과 `sampleOffTest`입니다. ordinary test는 sample fixture를 볼 수 있습니다. sample-on axis에서 sample이 contract corpus로 작동해야 하기 때문입니다. 반면 `sampleOffTest`는 같은 app-bootstrap core test source를 sample-portfolio 없이 컴파일하고 실행합니다. 즉 sample이 빠져도 core skeleton이 sample type에 의존하지 않는지 확인합니다.
|
||||
|
||||
`SampleRemovalSmokeContractTest`는 이 경계를 여러 방식으로 확인합니다. production module의 build.gradle에서 `sample-portfolio`가 test 또는 sampleFixture scope 밖으로 들어오지 않는지 봅니다. app-bootstrap core test가 `dev.caskeleton.sample.portfolio.*`를 import하지 않는지도 확인합니다. `sampleOffTest` source set과 task가 선언되어 있는지, CI workflow에 `sample-off` job과 `./gradlew :app-bootstrap:sampleOffTest`가 있는지도 검사합니다.
|
||||
|
||||
GitHub Actions에도 sample-off axis가 있습니다. ordinary quality-gates job은 sample-on axis이고, `sample-off` job은 sample-portfolio가 compile/runtime classpath에 없는 상태에서 `:app-bootstrap:sampleOffTest`와 architecture dependency matrix를 돌립니다. 이것은 실제 외부 프로젝트 adoption을 검증했다는 뜻은 아닙니다. 하지만 template 내부에서는 “sample을 지워도 core가 sample에 기대지 않는다”는 방향을 테스트로 표현합니다.
|
||||
|
||||
이 방식은 sample을 무조건 오래 남기자는 뜻도 아닙니다. downstream project에서는 sample을 지울 수 있습니다. 다만 지우기 전에 sample-off build가 green이어야 합니다. sample을 먼저 지워서 어떤 계약이 깨졌는지 모르게 만드는 것보다, sample-on으로 reference behavior를 보고 sample-off로 제거 가능성을 확인하는 편이 안전합니다.
|
||||
|
||||
주의할 점도 있습니다. canonical에는 예전 `sample-ticket` 12 scenario matrix 같은 계획성 문장과 현재 `sample-portfolio` 구현이 함께 남아 있습니다. 이 글에서 구현 사실로 말할 수 있는 것은 `sample-portfolio`, `sampleFixture`, `sampleOffTest`, `SampleRemovalSmokeContractTest`, CI sample-off job, 로컬 `./gradlew check` 범위입니다. 외부 프로젝트가 ca-tmpl을 adoption했고 도입 시간이 줄었다는 식의 주장은 아직 없습니다.
|
||||
|
||||
## 코드 예제 / Code samples (있다면)
|
||||
|
||||
```groovy
|
||||
// 출처: [[wiki/projects/ca-tmpl/sample-fixture-and-adoption]]
|
||||
// 실제 파일: app-bootstrap/build.gradle, ca-tmpl @f6fbd4e196b4
|
||||
configurations {
|
||||
sampleFixture {
|
||||
canBeConsumed = false
|
||||
canBeResolved = false
|
||||
}
|
||||
}
|
||||
|
||||
sourceSets {
|
||||
sampleOffTest {
|
||||
java.srcDirs = sourceSets.test.java.srcDirs
|
||||
resources.srcDirs = sourceSets.test.resources.srcDirs
|
||||
compileClasspath += sourceSets.main.output
|
||||
runtimeClasspath += sourceSets.main.output
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
```groovy
|
||||
// 출처: [[wiki/projects/ca-tmpl/sample-fixture-and-adoption]]
|
||||
// 실제 파일: app-bootstrap/build.gradle, ca-tmpl @f6fbd4e196b4
|
||||
dependencies {
|
||||
sampleFixture project(':sample-portfolio')
|
||||
}
|
||||
|
||||
tasks.register('sampleOffTest', Test) {
|
||||
description = 'Compiles and runs the core test suite without sample-portfolio on the classpath.'
|
||||
testClassesDirs = sourceSets.sampleOffTest.output.classesDirs
|
||||
classpath = sourceSets.sampleOffTest.runtimeClasspath
|
||||
systemProperty 'ca.sample.mode', 'off'
|
||||
}
|
||||
```
|
||||
|
||||
```java
|
||||
// 출처: [[wiki/projects/ca-tmpl/sample-fixture-and-adoption]]
|
||||
// 실제 파일: app-bootstrap/.../SampleRemovalSmokeContractTest.java, ca-tmpl @f6fbd4e196b4
|
||||
void sampleClassIsAbsentFromTheSampleOffTestClasspath() {
|
||||
Assumptions.assumeTrue(
|
||||
"off".equals(System.getProperty("ca.sample.mode")),
|
||||
"sample classpath absence is verified only by sampleOffTest");
|
||||
|
||||
assertThat(isClassPresent("dev.caskeleton.sample.portfolio.SamplePortfolioApplication"))
|
||||
.as("sampleOffTest must not contain the sample-portfolio jar")
|
||||
.isFalse();
|
||||
}
|
||||
```
|
||||
|
||||
```yaml
|
||||
# 출처: [[wiki/projects/ca-tmpl/sample-fixture-and-adoption]]
|
||||
# 실제 파일: .github/workflows/ci-quality-gates.yml, ca-tmpl @f6fbd4e196b4
|
||||
sample-off:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: sampleOffTest + clean architecture dependency matrix
|
||||
working-directory: src
|
||||
run: ./gradlew :app-bootstrap:sampleOffTest verifyCleanArchitectureDependencies --no-daemon --stacktrace
|
||||
```
|
||||
|
||||
## Sources / 근거 (canonical 인용 필수, derived layer 의무)
|
||||
|
||||
- [[wiki/projects/ca-tmpl/sample-fixture-and-adoption]] - 이 글의 1차 canonical. `sample-portfolio`, sample fixture/adoption decision, `sampleFixture`, `sampleOffTest`, `SampleRemovalSmokeContractTest`, CI sample-off job, 외부 adoption 미검증 경계를 따른다.
|
||||
- [[wiki/concepts/sample-fixture-and-adoption]] - 관련 개념 문서. template sample과 adoption strategy의 일반 배경으로만 둔다.
|
||||
|
||||
## 사실 vs 의견 / Fact vs opinion 구분
|
||||
|
||||
- 사실: ca-tmpl에는 `sample-portfolio` module, `sampleFixture` configuration, `sampleOffTest` task, `SampleRemovalSmokeContractTest`, CI `sample-off` job이 존재한다. 근거: [[wiki/projects/ca-tmpl/sample-fixture-and-adoption]]
|
||||
- 사실: `./gradlew check`가 2026-07-02 기준 통과했고, sample-off 관련 task가 check graph에 포함되어 실행된 것으로 기록되어 있다. 근거: [[wiki/projects/ca-tmpl/sample-fixture-and-adoption]]
|
||||
- 사실: 외부 프로젝트 adoption 사례, hosted release 차단 사례, adoption 시간 측정값은 없다. 근거: [[wiki/projects/ca-tmpl/sample-fixture-and-adoption]]
|
||||
- 의견: sample은 빨리 지울 데모보다 architecture contract를 증명하는 corpus로 남기는 편이 skeleton 학습에 유리하다.
|
||||
- 알지 못하는 것: 실제 template consumer의 migration friction, sample 제거에 걸린 시간, 조직별 adoption pattern.
|
||||
|
||||
## 답할 수 있는 범위 / Answer boundary
|
||||
|
||||
- 자신 있게 답할 수 있는 후속 질문:
|
||||
- sample domain이 어떤 contract를 검증하는 corpus인가?
|
||||
- sample-on과 sample-off를 둘 다 검증하는 이유는 무엇인가?
|
||||
- `sampleOffTest`가 runtime toggle이 아니라 classpath contract인 이유는 무엇인가?
|
||||
- 다음 글로 넘길 부분:
|
||||
- 실제 외부 프로젝트 adoption report.
|
||||
- sample 제거 자동화 script.
|
||||
- Backstage나 Cookiecutter 같은 generator형 adoption과의 비교.
|
||||
|
||||
## 게시 체크리스트 / Publish checklist
|
||||
|
||||
- [x] 모든 사실 주장에 canonical 링크 있음
|
||||
- [x] 사실 vs 의견 분리 명시됨
|
||||
- [x] 금지 마케팅 표현 없음
|
||||
- [x] 코드 예제 출처 명시
|
||||
- [x] 타깃 독자 가정과 톤 일치
|
||||
- [x] `/lint` 통과
|
||||
- [ ] 게시 URL 기록 (게시 후):
|
||||
|
||||
## Related / 관련
|
||||
|
||||
- 후속 글 후보: [[wiki/blog/ca-tmpl-clean-architecture-package-layout-2026-07-02]]
|
||||
- 후속 글 후보: [[wiki/blog/ca-tmpl-devops-ci-supply-chain-dx-2026-07-02]]
|
||||
@@ -1 +0,0 @@
|
||||
../../vault/40-publish/blog/ca-tmpl-security-baseline-jwt-actuator-secrets-2026-07-02.md
|
||||
@@ -0,0 +1,164 @@
|
||||
---
|
||||
title: Security Baseline을 JWT, Actuator, Secrets로 나누기
|
||||
source_type: blog
|
||||
status: verified
|
||||
confidence: high
|
||||
tags: [blog, ca-tmpl, security, jwt, actuator, secrets]
|
||||
related_projects: [ca-tmpl]
|
||||
last_reviewed: 2026-07-03
|
||||
canonical_sources:
|
||||
- wiki/projects/ca-tmpl/security-baseline-jwt-actuator-secrets
|
||||
audience: backend-engineer
|
||||
target_publish:
|
||||
status_label: ready
|
||||
---
|
||||
|
||||
# Security Baseline을 JWT, Actuator, Secrets로 나누기
|
||||
|
||||
## Parent / 부모 (필수)
|
||||
|
||||
- 핵심 canonical: [[wiki/projects/ca-tmpl/security-baseline-jwt-actuator-secrets]]
|
||||
- 관련 개념 문서: [[wiki/concepts/security-baseline-jwt-actuator-secrets]] - JWT Resource Server, actuator 노출, secret source/rotation의 일반 배경. 현재 이 글의 구현 사실 근거는 verified project canonical에 둔다.
|
||||
|
||||
## 타깃 독자 / Target reader
|
||||
|
||||
- 독자 profile: Spring Boot skeleton에 보안 baseline을 넣으려는 백엔드 엔지니어.
|
||||
- 이미 안다고 가정하는 것: JWT, OAuth2 Resource Server, Spring Security filter chain, actuator, 환경 변수 기반 secret 주입.
|
||||
- 처음 듣는다고 가정하는 것: security baseline을 인증 설정 하나가 아니라 데이터면 인증/인가, 제어면 actuator, secret lifecycle의 세 계약으로 나누는 방식.
|
||||
|
||||
## 도입 / Hook
|
||||
|
||||
Spring Boot 프로젝트에 보안을 붙인다고 하면 보통 `SecurityFilterChain`부터 떠올립니다. JWT를 검증하고, public path를 열고, 나머지는 인증을 요구하면 일단 그림은 그려집니다. 그런데 운영 관점에서 보면 그 정도로는 baseline이라고 부르기 어렵습니다. 인증 실패가 어떤 JSON shape으로 내려가는지, actuator endpoint가 앱 트래픽과 같은 경계에 놓이는지, secret rotation을 runtime reload로 볼지 restart-only로 볼지까지 같이 정해야 합니다.
|
||||
|
||||
ca-tmpl은 이 문제를 세 표면으로 나눴습니다. 데이터면은 JWT Resource Server와 AuthN/AuthZ 실패 envelope으로, 제어면은 management actuator chain으로, secret은 `SecretSource`와 restart-only guard로 다룹니다. 이 글은 ca-tmpl에 실제로 구현되고 로컬 검증된 범위와, 아직 IdP/secret manager 운영 경험처럼 말하면 안 되는 범위를 분리합니다.
|
||||
|
||||
## 본문 outline / Body outline
|
||||
|
||||
1. security baseline은 인증 설정 하나가 아니다.
|
||||
2. 데이터면: JWT Resource Server와 filter-layer error envelope.
|
||||
3. 제어면: actuator를 별도 security chain으로 본다.
|
||||
4. secret: source abstraction과 restart-only rotation guard.
|
||||
5. 구현된 baseline과 운영 미검증 범위를 분리한다.
|
||||
|
||||
## 본문 / Body
|
||||
|
||||
보안 baseline을 좁게 잡으면 “JWT를 검증한다”가 전부가 됩니다. 하지만 skeleton/template에서는 다음 프로젝트가 무엇을 가져가야 하는지까지 보여줘야 합니다. ca-tmpl의 기준은 세 가지였습니다. 첫째, 사용자 요청이 들어오는 데이터면 인증/인가를 stateless JWT Resource Server로 고정합니다. 둘째, actuator 같은 제어면은 일반 API와 다른 노출 정책을 갖게 합니다. 셋째, secret은 문자열 설정값이 아니라 source와 reload 정책이 있는 runtime 계약으로 봅니다.
|
||||
|
||||
데이터면의 핵심은 `adapter-web`의 `SecurityConfig`와 `JwtDecoderConfig`입니다. `SecurityConfig`는 `exceptionHandling`과 `oauth2ResourceServer` 양쪽에 같은 entry point와 access denied handler를 연결합니다. Spring Security filter layer에서 발생한 401/403은 `@ControllerAdvice`까지 내려오지 않는 경우가 많습니다. 그래서 filter layer 자체가 ca-tmpl의 API error envelope을 쓰도록 entry point/denied handler를 맞춘 것입니다.
|
||||
|
||||
JWT decoder도 framework 기본값에만 맡기지 않습니다. `JwtDecoderConfig`는 `SupplierJwtDecoder`를 사용해 JWKS discovery를 기동 시점이 아니라 첫 decode 시점으로 미룹니다. validator chain에는 60초 clock skew, issuer validation, 선택적 audience validation이 명시됩니다. 여기서 구현된 것은 “JWT 검증 baseline”입니다. 외부 IdP 운영, JWKS rotation latency, unknown `kid` 상황의 실측값은 아직 없습니다.
|
||||
|
||||
제어면은 actuator입니다. ca-tmpl의 `ManagementSecurityConfig`는 actuator endpoint용 `SecurityFilterChain`을 `@Order(0)`으로 별도 구성합니다. `health`, `info`, `prometheus`는 allowlist로 열고, `POST/DELETE /actuator/loggers/**`는 deny합니다. 이 결정의 요지는 “actuator도 Spring Security가 보호한다”가 아니라, application API와 다른 security matcher, 다른 노출 정책, 다른 ingress/network boundary를 가져야 한다는 점입니다.
|
||||
|
||||
secret 쪽에서는 `SecretSource` abstraction과 restart-only 원칙이 중요합니다. ca-tmpl은 local `.env`와 prod secret source를 같은 소비자 코드가 보게 하되, runtime reload를 기본 경로로 만들지 않습니다. `SecretReloadContractTest`는 context refresh 이후 property source를 바꿔도 이미 바인딩된 configuration property 값이 바뀌지 않는다는 것을 확인합니다. 또한 Spring Cloud refresh scope machinery가 runtime classpath에 없다는 점도 검증합니다. 이것은 secret manager 연동을 구현했다는 뜻이 아니라, ca-tmpl의 secret 소비 계약이 restart-only라는 뜻입니다.
|
||||
|
||||
이 세 영역을 묶으면 security baseline의 의미가 달라집니다. JWT는 데이터면 인증을 담당하고, actuator는 제어면 노출을 담당하며, secret source는 runtime config lifecycle을 담당합니다. 세 영역은 모두 Spring Boot 설정처럼 보이지만 실패 형태, 네트워크 경계, lifecycle 위험이 다릅니다. ca-tmpl은 그 차이를 문서에만 남기지 않고 `SecurityErrorClassifierTest`, `JwtDecoderConfigTest`, `ActuatorSecurityHttpTest`, `SecretReloadContractTest` 같은 테스트로 일부 고정했습니다.
|
||||
|
||||
주의할 점도 분명합니다. 이 글에서 “구현됐다”고 말할 수 있는 것은 ca-tmpl repo 안의 filter chain, lazy decoder, actuator policy, secret source/reload guard, 로컬 `./gradlew check` 통과 범위입니다. 실제 Keycloak이나 외부 IdP를 붙여 token lifecycle을 검증한 것이 아니고, Vault/AWS Secrets Manager/GCP Secret Manager 통합도 없습니다. actuator endpoint에 대한 침투 테스트나 production metric도 없습니다. security baseline을 설명할 때는 이 경계를 같이 말해야 합니다.
|
||||
|
||||
## 코드 예제 / Code samples (있다면)
|
||||
|
||||
```java
|
||||
// 출처: [[wiki/projects/ca-tmpl/security-baseline-jwt-actuator-secrets]]
|
||||
// 실제 파일: adapter-web/.../auth/SecurityConfig.java, ca-tmpl @f6fbd4e196b4
|
||||
.exceptionHandling(
|
||||
ex ->
|
||||
ex.authenticationEntryPoint(authenticationEntryPoint)
|
||||
.accessDeniedHandler(accessDeniedHandler))
|
||||
.oauth2ResourceServer(
|
||||
oauth ->
|
||||
oauth
|
||||
.authenticationEntryPoint(authenticationEntryPoint)
|
||||
.accessDeniedHandler(accessDeniedHandler)
|
||||
.jwt(jwt -> jwt.jwtAuthenticationConverter(jwtConverter)));
|
||||
```
|
||||
|
||||
```java
|
||||
// 출처: [[wiki/projects/ca-tmpl/security-baseline-jwt-actuator-secrets]]
|
||||
// 실제 파일: adapter-web/.../auth/JwtDecoderConfig.java, ca-tmpl @f6fbd4e196b4
|
||||
return new SupplierJwtDecoder(
|
||||
() -> {
|
||||
NimbusJwtDecoder decoder =
|
||||
NimbusJwtDecoder.withIssuerLocation(settings.issuerUri()).build();
|
||||
decoder.setJwtValidator(jwtValidator(settings.issuerUri(), settings.audience()));
|
||||
return decoder;
|
||||
});
|
||||
|
||||
validators.add(new JwtTimestampValidator(Duration.ofSeconds(60)));
|
||||
validators.add(new JwtIssuerValidator(issuerUri));
|
||||
```
|
||||
|
||||
```java
|
||||
// 출처: [[wiki/projects/ca-tmpl/security-baseline-jwt-actuator-secrets]]
|
||||
// 실제 파일: app-bootstrap/.../management/security/ManagementSecurityConfig.java, ca-tmpl @f6fbd4e196b4
|
||||
http.securityMatcher(EndpointRequest.toAnyEndpoint())
|
||||
.sessionManagement(s -> s.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
|
||||
.authorizeHttpRequests(
|
||||
auth ->
|
||||
auth
|
||||
.requestMatchers(EndpointRequest.to("health", "info", "prometheus"))
|
||||
.permitAll()
|
||||
.requestMatchers(HttpMethod.POST, "/actuator/loggers/**")
|
||||
.denyAll()
|
||||
.requestMatchers(HttpMethod.DELETE, "/actuator/loggers/**")
|
||||
.denyAll()
|
||||
.anyRequest()
|
||||
.authenticated());
|
||||
```
|
||||
|
||||
```java
|
||||
// 출처: [[wiki/projects/ca-tmpl/security-baseline-jwt-actuator-secrets]]
|
||||
// 실제 파일: app-bootstrap/.../contract/SecretReloadContractTest.java, ca-tmpl @f6fbd4e196b4
|
||||
sources.addFirst(
|
||||
new MapPropertySource(
|
||||
"rotated-secret-source",
|
||||
Map.of("secret-reload-probe.value", "rotated-secret")));
|
||||
|
||||
SecretHolder afterRotation = context.getBean(SecretHolder.class);
|
||||
assertThat(afterRotation.value()).isEqualTo("initial-secret");
|
||||
|
||||
assertThatThrownBy(
|
||||
() -> Class.forName("org.springframework.cloud.context.scope.refresh.RefreshScope"))
|
||||
.isInstanceOf(ClassNotFoundException.class);
|
||||
```
|
||||
|
||||
## Sources / 근거 (canonical 인용 필수, derived layer 의무)
|
||||
|
||||
- [[wiki/projects/ca-tmpl/security-baseline-jwt-actuator-secrets]] - 이 글의 1차 canonical. JWT Resource Server, filter-layer envelope, actuator security chain, secret source/reload guard, local verification, IdP/secret manager/prod 미검증 경계를 따른다.
|
||||
- [[wiki/concepts/security-baseline-jwt-actuator-secrets]] - 관련 개념 문서. JWT, actuator, secret rotation의 일반 배경으로만 둔다.
|
||||
|
||||
## 사실 vs 의견 / Fact vs opinion 구분
|
||||
|
||||
- 사실: ca-tmpl에는 `SecurityConfig`, `JwtDecoderConfig`, `SecurityErrorClassifier`, envelope entry point/denied handler, `ManagementSecurityConfig`, `SecretSource*`, `SecretReloadContractTest`가 존재한다. 근거: [[wiki/projects/ca-tmpl/security-baseline-jwt-actuator-secrets]]
|
||||
- 사실: `./gradlew check`가 2026-07-02 기준 통과했고, security/error path, actuator policy, secret source/reload guard 관련 테스트가 canonical에 기록되어 있다. 근거: [[wiki/projects/ca-tmpl/security-baseline-jwt-actuator-secrets]]
|
||||
- 사실: 외부 IdP 운영, JWKS rotation latency, secret manager integration, real secret rotation automation, pentest, prod metric 검증은 없다. 근거: [[wiki/projects/ca-tmpl/security-baseline-jwt-actuator-secrets]]
|
||||
- 의견: skeleton의 security baseline은 JWT 검증 코드보다 실패 계약, control plane 노출, secret lifecycle을 함께 묶을 때 설명력이 높아진다.
|
||||
- 알지 못하는 것: 실제 IdP 장애 상황, secret manager rotation window, actuator 노출 사고 대응 경험.
|
||||
|
||||
## 답할 수 있는 범위 / Answer boundary
|
||||
|
||||
- 자신 있게 답할 수 있는 후속 질문:
|
||||
- filter-layer 401/403을 error envelope으로 맞춘 이유는 무엇인가?
|
||||
- `SupplierJwtDecoder`와 60초 clock skew를 명시한 이유는 무엇인가?
|
||||
- actuator를 일반 API security chain과 분리해서 보는 이유는 무엇인가?
|
||||
- secret runtime reload를 기본 경로로 두지 않은 이유는 무엇인가?
|
||||
- 다음 글로 넘길 부분:
|
||||
- real IdP integration과 token lifecycle.
|
||||
- Vault/Secrets Manager/KMS 통합.
|
||||
- actuator endpoint penetration test나 prod metric 기반 검증.
|
||||
|
||||
## 게시 체크리스트 / Publish checklist
|
||||
|
||||
- [x] 모든 사실 주장에 canonical 링크 있음
|
||||
- [x] 사실 vs 의견 분리 명시됨
|
||||
- [x] 금지 마케팅 표현 없음
|
||||
- [x] 코드 예제 출처 명시
|
||||
- [x] 타깃 독자 가정과 톤 일치
|
||||
- [x] `/lint` 통과
|
||||
- [ ] 게시 URL 기록 (게시 후):
|
||||
|
||||
## Related / 관련
|
||||
|
||||
- 후속 글 후보: [[wiki/blog/ca-tmpl-api-error-envelope-design-2026-07-02]]
|
||||
- 후속 글 후보: [[wiki/blog/ca-tmpl-runtime-container-health-migration-2026-07-02]]
|
||||
- 후속 글 후보: [[wiki/blog/ca-tmpl-config-and-adapter-templates-2026-07-02]]
|
||||
-1
@@ -1 +0,0 @@
|
||||
../../vault/40-publish/blog/ca-tmpl-skeleton-governance-registry-verification-test-scorecard-2026-07-02.md
|
||||
+148
@@ -0,0 +1,148 @@
|
||||
---
|
||||
title: Skeleton Governance를 Registry와 Verification으로 닫기
|
||||
source_type: blog
|
||||
status: verified
|
||||
confidence: high
|
||||
tags: [blog, ca-tmpl, governance, archunit, testing]
|
||||
related_projects: [ca-tmpl]
|
||||
last_reviewed: 2026-07-03
|
||||
canonical_sources:
|
||||
- wiki/projects/ca-tmpl/skeleton-governance-registry-verification-test-scorecard
|
||||
audience: backend-engineer
|
||||
target_publish:
|
||||
status_label: ready
|
||||
---
|
||||
|
||||
# Skeleton Governance를 Registry와 Verification으로 닫기
|
||||
|
||||
## Parent / 부모 (필수)
|
||||
|
||||
- 핵심 canonical: [[wiki/projects/ca-tmpl/skeleton-governance-registry-verification-test-scorecard]]
|
||||
- 관련 개념 문서: [[wiki/concepts/skeleton-governance-registry-verification-test-scorecard]] - registry, verification, test taxonomy, scorecard의 일반 배경. 현재 이 글의 구현 사실 근거는 verified project canonical에 둔다.
|
||||
|
||||
## 타깃 독자 / Target reader
|
||||
|
||||
- 독자 profile: template skeleton의 품질 기준을 registry, test, gate로 유지하려는 백엔드/플랫폼 엔지니어.
|
||||
- 이미 안다고 가정하는 것: Gradle test, ArchUnit, YAML registry, CI gate.
|
||||
- 처음 듣는다고 가정하는 것: governance를 규칙 문서가 아니라 owner, registry row, verification mechanism, test taxonomy로 연결하는 방식.
|
||||
|
||||
## 도입 / Hook
|
||||
|
||||
Skeleton 프로젝트에서 좋은 규칙을 많이 쓰는 것은 어렵지 않습니다. “application layer는 framework에 의존하지 않는다”, “환경 변수는 registry에 등록한다”, “quarantine test는 만료일을 가진다” 같은 문장을 README에 적으면 됩니다. 문제는 시간이 지난 뒤입니다. 규칙은 남아 있는데 owner가 사라지고, gate matrix는 workflow와 어긋나고, registry row는 코드와 다른 이름을 가리키기 시작합니다.
|
||||
|
||||
ca-tmpl은 이 문제를 governance 계약으로 다뤘습니다. registry family를 두고, 각 row가 owner와 required test를 갖게 하며, gate matrix가 실제 Gradle task/test/workflow job과 맞는지 검사합니다. 다만 scorecard badge, 11 gate 전체 hosted release-blocking history, 5분 budget 강제 같은 항목은 아직 구현됐다고 말하면 안 됩니다. 이 글은 구현된 registry/verification slice와 계획으로 남은 governance slice를 분리합니다.
|
||||
|
||||
## 본문 outline / Body outline
|
||||
|
||||
1. governance는 규칙 목록이 아니라 drift를 줄이는 구조다.
|
||||
2. registry는 row owner와 required test를 연결한다.
|
||||
3. verification은 matrix와 실제 task/test/job을 대조한다.
|
||||
4. test taxonomy는 classpath와 boundary를 지킨다.
|
||||
5. scorecard는 아이디어와 자동화 범위를 나눠 말한다.
|
||||
|
||||
## 본문 / Body
|
||||
|
||||
governance라는 단어는 무겁지만, skeleton에서 필요한 질문은 단순합니다. “이 규칙을 누가 소유하는가?”, “이 규칙이 깨지면 어떤 테스트가 실패하는가?”, “문서에 적힌 gate가 실제 CI에 남아 있는가?” ca-tmpl은 이 질문에 답하기 위해 registry, verification, test taxonomy, scorecard를 한 묶음으로 기록했습니다.
|
||||
|
||||
registry 축은 `docs/registries/` 아래의 7개 YAML family에서 시작합니다. `error-codes.yaml`, `env-keys.yaml`, `secrets-classification.yaml`, `headers.yaml`, `mdc-keys.yaml`, `metrics.yaml`, `capabilities.yaml`가 있고, `ContractRegistrySchemaGovernanceTest`가 각 family의 schema owner header, identity column, `owner_branch`, `compatibility_impact`, `required_test` 같은 필드를 확인합니다. 핵심은 registry row가 단순 목록이 아니라 “누가 책임지고 어떤 test가 지키는가”를 담는다는 점입니다.
|
||||
|
||||
이 registry는 모든 것을 해결하지 않습니다. canonical은 markdown SSOT와 YAML registry의 관계, generated constants/code generator, markdown과 YAML의 full drift gate가 아직 남았다고 구분합니다. 따라서 이 글에서 말할 수 있는 것은 7개 registry artifact와 schema governance test가 존재한다는 사실입니다. registry YAML이 모든 계약의 최종 SSOT라고 말하면 범위를 넘습니다.
|
||||
|
||||
verification 축은 `.github/ci-gate-matrix.yml`와 `verify-gate-matrix.sh`에서 잘 드러납니다. matrix row에는 gate id, release blocking 여부, owner branch, mechanism, ref, workflow가 들어갑니다. script는 mechanism별로 실제 존재를 확인합니다. `gradle-custom-task`라면 `tasks.register('<ref>')`가 있어야 하고, `contract-test`라면 test class 파일이 있어야 하며, `workflow-job`이라면 workflow에 job id가 있어야 합니다. 문서와 실행 경로가 벌어지는 것을 줄이려는 구조입니다.
|
||||
|
||||
Gradle 쪽 custom gate도 같은 방향입니다. `verifyEnvKeys`는 `.env`, `application.yml`, `docs/registries/env-keys.yaml` 사이를 맞춥니다. required placeholder가 `.env`에 없거나, `.env`의 `APP_` key가 registry에 없으면 실패합니다. `verifyTrivyignore`, `verifyQuarantineSunset`, `verifyCleanArchitectureDependencies` 같은 task도 같은 계열입니다. 규칙은 글로만 남지 않고 build graph에 들어가야 회귀를 잡습니다.
|
||||
|
||||
test taxonomy는 boundary를 강제하는 쪽에 가깝습니다. `CleanArchitectureTest`, `DisabledAdapterArchitectureTest`, `NamingConventionTest`, `ProductionClassImportOption`, sample-off source set은 production classpath와 test fixture boundary가 섞이는 것을 줄입니다. Testcontainers integration test도 outbox/idempotency 같은 runtime contract를 검증하는 데 쓰입니다. 다만 canonical은 6 level 전체의 budget 측정과 강제 mechanism이 아직 없다고 명시합니다.
|
||||
|
||||
scorecard는 더 조심해서 말해야 합니다. ca-tmpl은 binary pass/fail readiness scorecard를 설계했지만, 별도 CI badge나 자동 산출물까지 구현한 것은 아닙니다. 그래서 이 글에서는 scorecard를 “좋은 방향의 governance 모델”로 설명할 수는 있어도, 자동화된 release readiness dashboard가 존재한다고 쓰면 안 됩니다. 현재 구현의 중심은 registry와 verification, 그리고 일부 Gradle/test gate입니다.
|
||||
|
||||
결국 ca-tmpl의 skeleton governance는 개발자의 선의에만 기대지 않으려는 시도입니다. registry row에 owner와 required test를 붙이고, gate matrix와 실제 task/test/job을 대조하며, architecture boundary를 ArchUnit으로 고정합니다. 구현된 것은 이 정도입니다. 조직 전체 rollout, 장기적 defect 감소, hosted release gate 차단 이력은 아직 별도의 근거가 필요합니다.
|
||||
|
||||
## 코드 예제 / Code samples (있다면)
|
||||
|
||||
```yaml
|
||||
# 출처: [[wiki/projects/ca-tmpl/skeleton-governance-registry-verification-test-scorecard]]
|
||||
# 실제 파일: .github/ci-gate-matrix.yml, ca-tmpl @f6fbd4e196b4
|
||||
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
|
||||
```
|
||||
|
||||
```java
|
||||
// 출처: [[wiki/projects/ca-tmpl/skeleton-governance-registry-verification-test-scorecard]]
|
||||
// 실제 파일: app-bootstrap/.../contract/ContractRegistrySchemaGovernanceTest.java, ca-tmpl @f6fbd4e196b4
|
||||
private static final List<Registry> REGISTRIES =
|
||||
List.of(
|
||||
new Registry("error-codes.yaml", "errors", "code"),
|
||||
new Registry("env-keys.yaml", "env_keys", "name"),
|
||||
new Registry("secrets-classification.yaml", "secrets", "name"),
|
||||
new Registry("headers.yaml", "headers", "name"),
|
||||
new Registry("mdc-keys.yaml", "mdc_keys", "key"),
|
||||
new Registry("metrics.yaml", "metrics", "name"),
|
||||
new Registry("capabilities.yaml", "capabilities", "name"));
|
||||
```
|
||||
|
||||
```groovy
|
||||
// 출처: [[wiki/projects/ca-tmpl/skeleton-governance-registry-verification-test-scorecard]]
|
||||
// 실제 파일: src/build.gradle, ca-tmpl @f6fbd4e196b4
|
||||
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")
|
||||
}
|
||||
```
|
||||
|
||||
```bash
|
||||
# 출처: [[wiki/projects/ca-tmpl/skeleton-governance-registry-verification-test-scorecard]]
|
||||
# 실제 파일: .github/scripts/verify-gate-matrix.sh, ca-tmpl @f6fbd4e196b4
|
||||
# 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
|
||||
```
|
||||
|
||||
## Sources / 근거 (canonical 인용 필수, derived layer 의무)
|
||||
|
||||
- [[wiki/projects/ca-tmpl/skeleton-governance-registry-verification-test-scorecard]] - 이 글의 1차 canonical. registry files, schema governance test, gate matrix, Gradle verification tasks, test taxonomy, scorecard 미자동화 경계를 따른다.
|
||||
- [[wiki/concepts/skeleton-governance-registry-verification-test-scorecard]] - 관련 개념 문서. governance/test taxonomy/scorecard의 일반 배경으로만 둔다.
|
||||
|
||||
## 사실 vs 의견 / Fact vs opinion 구분
|
||||
|
||||
- 사실: ca-tmpl에는 7개 registry family, `.github/ci-gate-matrix.yml`, `ContractRegistrySchemaGovernanceTest`, registry/gate 관련 contract tests, 여러 Gradle verification task가 존재한다. 근거: [[wiki/projects/ca-tmpl/skeleton-governance-registry-verification-test-scorecard]]
|
||||
- 사실: `./gradlew check`가 2026-07-02 기준 통과했고, `verifyCleanArchitectureDependencies`, `verifyEnvKeys`, `verifyQuarantineSunset`, `verifyReadmeCommands`, `verifyTrivyignore`가 canonical에 기록되어 있다. 근거: [[wiki/projects/ca-tmpl/skeleton-governance-registry-verification-test-scorecard]]
|
||||
- 사실: scorecard CI badge/auto artifact, 11 gate 전체 hosted release-blocking history, generated constants/code generator 전체, 5분 budget 강제는 구현됐다고 말할 수 없다. 근거: [[wiki/projects/ca-tmpl/skeleton-governance-registry-verification-test-scorecard]]
|
||||
- 의견: skeleton governance는 규칙 문서보다 owner와 verification mechanism을 같이 남길 때 오래 유지된다.
|
||||
- 알지 못하는 것: 팀 단위 rollout 효과, 장기 defect 감소율, 실제 release 차단 사례.
|
||||
|
||||
## 답할 수 있는 범위 / Answer boundary
|
||||
|
||||
- 자신 있게 답할 수 있는 후속 질문:
|
||||
- registry row에 owner와 required test를 두는 이유는 무엇인가?
|
||||
- gate matrix와 실제 Gradle/test/workflow를 대조하는 이유는 무엇인가?
|
||||
- ArchUnit/test taxonomy가 skeleton governance에서 맡는 역할은 무엇인가?
|
||||
- 다음 글로 넘길 부분:
|
||||
- scorecard badge와 자동 산출물.
|
||||
- multi-team governance process.
|
||||
- hosted release gate 차단 이력.
|
||||
|
||||
## 게시 체크리스트 / Publish checklist
|
||||
|
||||
- [x] 모든 사실 주장에 canonical 링크 있음
|
||||
- [x] 사실 vs 의견 분리 명시됨
|
||||
- [x] 금지 마케팅 표현 없음
|
||||
- [x] 코드 예제 출처 명시
|
||||
- [x] 타깃 독자 가정과 톤 일치
|
||||
- [x] `/lint` 통과
|
||||
- [ ] 게시 URL 기록 (게시 후):
|
||||
|
||||
## Related / 관련
|
||||
|
||||
- 후속 글 후보: [[wiki/blog/ca-tmpl-devops-ci-supply-chain-dx-2026-07-02]]
|
||||
- 후속 글 후보: [[wiki/blog/ca-tmpl-sample-fixture-and-adoption-2026-07-02]]
|
||||
- 후속 글 후보: [[wiki/blog/ca-tmpl-knowledge-capture-workflow-2026-07-02]]
|
||||
@@ -1 +0,0 @@
|
||||
../../vault/40-publish/blog/ca-tmpl-streaming-response-support-2026-07-02.md
|
||||
@@ -0,0 +1,144 @@
|
||||
---
|
||||
title: Streaming Response를 지원하지 않는 결정도 계약이다
|
||||
source_type: blog
|
||||
status: verified
|
||||
confidence: high
|
||||
tags: [blog, ca-tmpl, streaming, archunit, api-design]
|
||||
related_projects: [ca-tmpl]
|
||||
last_reviewed: 2026-07-03
|
||||
canonical_sources:
|
||||
- wiki/projects/ca-tmpl/streaming-response-support
|
||||
audience: backend-engineer
|
||||
target_publish:
|
||||
status_label: ready
|
||||
---
|
||||
|
||||
# Streaming Response를 지원하지 않는 결정도 계약이다
|
||||
|
||||
## Parent / 부모 (필수)
|
||||
|
||||
- 핵심 canonical: [[wiki/projects/ca-tmpl/streaming-response-support]]
|
||||
- 관련 개념 문서: [[wiki/concepts/streaming-response-patterns]] - SSE/WebSocket/long-polling/chunked transfer의 일반 trade-off. 현재 이 글의 구현 사실 근거는 verified project canonical에 둔다.
|
||||
|
||||
## 타깃 독자 / Target reader
|
||||
|
||||
- 독자 profile: template skeleton에서 streaming/SSE/WebSocket response를 언제 열지 고민하는 백엔드 엔지니어.
|
||||
- 이미 안다고 가정하는 것: `SseEmitter`, `ResponseBodyEmitter`, WebSocket, `StreamingResponseBody`.
|
||||
- 처음 듣는다고 가정하는 것: “지원하지 않음”도 문서 문장이 아니라 build-time rule로 고정할 수 있다는 관점.
|
||||
|
||||
## 도입 / Hook
|
||||
|
||||
기술 선택은 보통 “무엇을 지원할 것인가”로 기록됩니다. 그런데 skeleton에서는 “지금은 열지 않을 것”도 중요한 결정입니다. SSE나 WebSocket을 한 번 열면 응답 envelope, timeout, heartbeat, reconnect, observability, connection cap, reverse proxy 설정까지 같이 따라옵니다. use case가 없는데 surface만 열면 템플릿은 빨리 무거워집니다.
|
||||
|
||||
ca-tmpl은 streaming response를 지원하지 않는다는 결정을 그냥 README에 쓰지 않았습니다. production code가 `SseEmitter`, `ResponseBodyEmitter`, Spring/Jakarta WebSocket surface를 import하면 ArchUnit rule이 잡도록 했습니다. 단, 대용량 다운로드용 `StreamingResponseBody`는 차단하지 않았습니다. 이 글은 “미지원도 계약이 될 수 있다”는 관점과, 어디까지가 실제 구현인지 정리합니다.
|
||||
|
||||
## 본문 outline / Body outline
|
||||
|
||||
1. 미지원도 설계 결정이다.
|
||||
2. streaming이 깨뜨리는 기존 request-response baseline.
|
||||
3. 차단 대상과 제외 대상 - `SseEmitter`/WebSocket은 막고 `StreamingResponseBody`는 막지 않는다.
|
||||
4. ArchUnit rule과 violations-as-data fixture로 검증한다.
|
||||
5. 나중에 streaming을 열려면 필요한 선행 계약.
|
||||
|
||||
## 본문 / Body
|
||||
|
||||
Streaming은 매력적인 기능입니다. LLM token streaming, 실시간 알림, export 진행률처럼 server가 client에게 계속 event를 보내야 하는 use case가 생기면 request-response만으로는 답답합니다. 하지만 skeleton의 default surface로 넣기에는 비용이 큽니다. event envelope을 어떻게 만들지, error를 mid-stream에서 어떻게 표현할지, trace id는 connection 단위인지 event 단위인지, proxy timeout과 heartbeat는 어떻게 둘지 정해야 합니다.
|
||||
|
||||
ca-tmpl은 현재 sample fixture에 server-push use case가 없기 때문에 streaming을 기본 지원하지 않기로 했습니다. 여기서 핵심은 “아직 안 만들었다”가 아니라 “지금은 열지 않는다는 결정을 build-time rule로 고정했다”입니다. production code가 `SseEmitter`를 import하거나, `ResponseBodyEmitter`를 쓰거나, Spring/Jakarta WebSocket package에 의존하면 ArchUnit rule이 실패합니다.
|
||||
|
||||
차단 대상은 이벤트/server-push streaming입니다. `SseEmitter`는 Server-Sent Events surface이고, `ResponseBodyEmitter`는 incremental object emit surface이며, WebSocket은 full-duplex connection model입니다. 이 셋은 request-response API baseline과 다른 운영 계약을 요구합니다. ca-tmpl은 이 표면을 기본 skeleton에 열지 않았습니다.
|
||||
|
||||
반대로 `StreamingResponseBody`는 차단하지 않습니다. 이름은 비슷하지만, ca-tmpl project canonical은 이를 대용량 파일 다운로드나 chunked body처럼 request-response 모델을 유지하는 관심사로 봅니다. 이벤트를 계속 push하는 계약과, 하나의 요청에 대해 body를 stream으로 쓰는 계약은 다릅니다. 그래서 over-block guard fixture가 있습니다. `StreamingResponseBodyAllowedFixture`는 streaming ban rule이 이 허용 케이스를 잡지 않아야 통과합니다.
|
||||
|
||||
이 구조가 좋은 이유는 “금지 rule이 진짜로 작동하는가”까지 테스트한다는 점입니다. `ArchitectureViolationFixtureTest`는 `SseEmitterUsingFixture`, `ResponseBodyEmitterUsingFixture`, Spring WebSocket fixture, Jakarta WebSocket fixture를 의도적 위반 데이터로 둡니다. 각 rule이 이 fixture를 잡는지 확인하고, WebSocket은 Spring glob과 Jakarta glob을 따로 가져와 vacuous pass를 줄입니다. 동시에 `StreamingResponseBody`는 잡지 않는지 확인합니다.
|
||||
|
||||
나중에 streaming을 열 수 없는 것은 아닙니다. 다만 그때는 단순히 controller return type을 바꾸는 일이 아닙니다. SSE인지 WebSocket인지, event envelope을 기존 `{ success, data, meta }`와 어떻게 맞출지, per-event trace를 만들지, reconnect와 timeout, connection cap, reverse proxy 설정을 어떻게 둘지 결정해야 합니다. ca-tmpl 문서는 이 지원 계약을 planned/open 범위로 남겨 두고 있습니다.
|
||||
|
||||
따라서 이 글의 결론은 “streaming은 나쁘다”가 아닙니다. ca-tmpl의 결론은 더 좁습니다. 현재 skeleton의 sync request-response baseline에서는 server-push streaming을 기본 surface로 열지 않고, 그 미지원 상태가 우연히 깨지지 않도록 ArchUnit으로 막습니다. 운영 streaming endpoint, connection load, SSE/WebSocket 장애 대응은 이 글에서 말할 수 있는 범위가 아닙니다.
|
||||
|
||||
## 코드 예제 / Code samples (있다면)
|
||||
|
||||
```java
|
||||
// 출처: [[wiki/projects/ca-tmpl/streaming-response-support]]
|
||||
// 실제 파일: app-bootstrap/.../CleanArchitectureTest.java, ca-tmpl @f6fbd4e196b4
|
||||
static final ArchRule NO_SSE_EMITTER =
|
||||
noClasses()
|
||||
.that()
|
||||
.resideInAPackage("dev.caskeleton..")
|
||||
.should()
|
||||
.dependOnClassesThat()
|
||||
.haveFullyQualifiedName("org.springframework.web.servlet.mvc.method.annotation.SseEmitter");
|
||||
```
|
||||
|
||||
```java
|
||||
// 출처: [[wiki/projects/ca-tmpl/streaming-response-support]]
|
||||
// 실제 파일: app-bootstrap/.../CleanArchitectureTest.java, ca-tmpl @f6fbd4e196b4
|
||||
static final ArchRule NO_WEBSOCKET_HANDLER =
|
||||
noClasses()
|
||||
.that()
|
||||
.resideInAPackage("dev.caskeleton..")
|
||||
.should()
|
||||
.dependOnClassesThat()
|
||||
.resideInAnyPackage("org.springframework.web.socket..", "jakarta.websocket..");
|
||||
```
|
||||
|
||||
```java
|
||||
// 출처: [[wiki/projects/ca-tmpl/streaming-response-support]]
|
||||
// 실제 파일: app-bootstrap/.../ArchitectureViolationFixtureTest.java, ca-tmpl @f6fbd4e196b4
|
||||
void noWebsocketHandlerCatchesJakartaWebsocketFixture() {
|
||||
EvaluationResult result =
|
||||
CleanArchitectureTest.NO_WEBSOCKET_HANDLER.evaluate(JAKARTA_WEBSOCKET_FIXTURE_ONLY);
|
||||
|
||||
assertThat(result.hasViolation()).isTrue();
|
||||
}
|
||||
```
|
||||
|
||||
```java
|
||||
// 출처: [[wiki/projects/ca-tmpl/streaming-response-support]]
|
||||
// 실제 파일: app-bootstrap/.../StreamingResponseBodyAllowedFixture.java, ca-tmpl @f6fbd4e196b4
|
||||
public class StreamingResponseBodyAllowedFixture {
|
||||
public StreamingResponseBody allowed() {
|
||||
return outputStream -> outputStream.write("data".getBytes());
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Sources / 근거 (canonical 인용 필수, derived layer 의무)
|
||||
|
||||
- [[wiki/projects/ca-tmpl/streaming-response-support]] - 이 글의 1차 canonical. streaming 미지원 결정, ArchUnit import-ban rule, violations-as-data fixture, `StreamingResponseBody` 제외 경계, local verification, 운영 미검증 범위를 따른다.
|
||||
- [[wiki/concepts/streaming-response-patterns]] - 관련 개념 문서. SSE/WebSocket/long-polling/chunked transfer의 일반 trade-off 배경으로만 둔다.
|
||||
|
||||
## 사실 vs 의견 / Fact vs opinion 구분
|
||||
|
||||
- 사실: ca-tmpl에는 `NO_SSE_EMITTER`, `NO_RESPONSE_BODY_EMITTER`, `NO_WEBSOCKET_HANDLER` ArchUnit rule과 streaming violation fixtures, `StreamingResponseBody` over-block guard fixture가 존재한다. 근거: [[wiki/projects/ca-tmpl/streaming-response-support]]
|
||||
- 사실: 구현된 것은 streaming 지원이 아니라 streaming 미지원을 강제하는 build-time guard다. 근거: [[wiki/projects/ca-tmpl/streaming-response-support]]
|
||||
- 사실: 실제 SSE/WebSocket endpoint, connection load test, production streaming metric은 없다. 근거: [[wiki/projects/ca-tmpl/streaming-response-support]]
|
||||
- 의견: skeleton 초기 surface에서는 real-time use case가 나타나기 전까지 server-push streaming을 닫아두는 편이 계약을 단순하게 유지한다.
|
||||
- 알지 못하는 것: 실제 streaming workload 요구사항, proxy timeout tuning, per-event tracing 운영 효과.
|
||||
|
||||
## 답할 수 있는 범위 / Answer boundary
|
||||
|
||||
- 자신 있게 답할 수 있는 후속 질문:
|
||||
- 왜 streaming을 지금 열지 않았는가?
|
||||
- 어떤 Spring/Jakarta streaming surface를 ArchUnit으로 막았는가?
|
||||
- 왜 `StreamingResponseBody`는 차단하지 않았는가?
|
||||
- violations-as-data fixture가 vacuous pass를 어떻게 줄이는가?
|
||||
- 다음 글로 넘길 부분:
|
||||
- SSE/WebSocket을 실제로 열 때 필요한 API envelope와 observability 계약.
|
||||
- connection cap, heartbeat, reconnect, proxy timeout 설계.
|
||||
- production streaming endpoint 검증.
|
||||
|
||||
## 게시 체크리스트 / Publish checklist
|
||||
|
||||
- [x] 모든 사실 주장에 canonical 링크 있음
|
||||
- [x] 사실 vs 의견 분리 명시됨
|
||||
- [x] 금지 마케팅 표현 없음
|
||||
- [x] 코드 예제 출처 명시
|
||||
- [x] 타깃 독자 가정과 톤 일치
|
||||
- [x] `/lint` 통과
|
||||
- [ ] 게시 URL 기록 (게시 후):
|
||||
|
||||
## Related / 관련
|
||||
|
||||
- 후속 글 후보: [[wiki/blog/ca-tmpl-api-error-envelope-design-2026-07-02]]
|
||||
- 후속 글 후보: [[wiki/blog/ca-tmpl-runtime-container-health-migration-2026-07-02]]
|
||||
@@ -1 +0,0 @@
|
||||
../../vault/40-publish/blog/ca-tmpl-transaction-boundary-abstraction-2026-07-02.md
|
||||
@@ -0,0 +1,201 @@
|
||||
---
|
||||
title: Transaction을 Annotation이 아니라 Application Port로 다루기
|
||||
source_type: blog
|
||||
status: verified
|
||||
confidence: high
|
||||
tags: [blog, ca-tmpl, transaction, clean-architecture]
|
||||
related_projects: [ca-tmpl]
|
||||
last_reviewed: 2026-07-02
|
||||
canonical_sources:
|
||||
- wiki/projects/ca-tmpl/transaction-boundary-abstraction
|
||||
audience: backend-engineer
|
||||
target_publish:
|
||||
status_label: ready
|
||||
---
|
||||
|
||||
# Transaction을 Annotation이 아니라 Application Port로 다루기
|
||||
|
||||
## Parent / 부모 (필수)
|
||||
|
||||
- 핵심 canonical: [[wiki/projects/ca-tmpl/transaction-boundary-abstraction]] — ca-tmpl `TransactionPort`, Spring adapter, ArchUnit rule, local verification 범위.
|
||||
- 관련 개념 문서: [[wiki/concepts/transaction-boundary-abstraction]] — Spring transaction boundary 대안 비교. 현재 concept 문서는 `draft`이므로 이 글의 구현 사실 근거는 verified project canonical에 둔다.
|
||||
|
||||
## 타깃 독자 / Target reader
|
||||
|
||||
- 독자 profile: Clean Architecture에서 transaction boundary를 application layer에 어떻게 둘지 고민하는 백엔드 엔지니어.
|
||||
- 이미 안다고 가정하는 것: `@Transactional`, propagation, read/write transaction.
|
||||
- 처음 듣는다고 가정하는 것: `TransactionPort`로 framework 의존을 adapter에 밀어내는 방식.
|
||||
|
||||
## 도입 / Hook
|
||||
|
||||
- 문제 / 궁금증: `@Transactional`은 편하지만 application core가 Spring에 묶일 수 있다.
|
||||
- 이 글이 답하는 것: ca-tmpl이 transaction boundary를 port로 추상화하고 어떤 범위를 검증했는지.
|
||||
- 이 글이 답하지 않는 것: 모든 DB vendor isolation tuning.
|
||||
|
||||
## 본문 outline / Body outline
|
||||
|
||||
1. transaction boundary는 use case 책임이다.
|
||||
2. Spring annotation을 core에 두지 않는 이유.
|
||||
3. `TransactionPort.inRead`/`inWrite` 류의 모델.
|
||||
4. propagation/isolation의 owner 분리.
|
||||
5. local verification과 운영 DB 검증 경계.
|
||||
|
||||
## 본문 / Body
|
||||
|
||||
Spring Boot에서 transaction을 다루는 가장 익숙한 방법은 `@Transactional`입니다. service method에 annotation을 붙이면 Spring AOP proxy가 method 호출을 감싸고, commit과 rollback을 처리합니다. 실무에서 널리 쓰이고, 단순 CRUD에서는 이 방식이 가장 읽기 쉽습니다.
|
||||
|
||||
그런데 Clean Architecture 관점에서는 질문이 하나 생깁니다. application layer가 Spring transaction annotation을 직접 import해도 괜찮은가? ca-tmpl은 이 질문에 대해 보수적인 답을 택했습니다. application core가 Spring transaction API를 직접 알지 않도록 `TransactionPort`를 두고, 실제 Spring transaction 실행은 persistence adapter의 `SpringTransactionPort`가 맡게 했습니다.
|
||||
|
||||
여기서 핵심은 `@Transactional`이 나쁘다는 주장이 아닙니다. Spring의 declarative transaction은 표준적이고 좋은 도구입니다. 다만 ca-tmpl은 skeleton template입니다. skeleton은 새 프로젝트가 어떤 adapter와 운영 계약을 붙이더라도 application core의 dependency direction이 유지되어야 합니다. 그래서 transaction도 repository나 HTTP client처럼 port 뒤로 밀어내는 쪽을 선택했습니다.
|
||||
|
||||
`TransactionPort`의 표면은 작습니다. write use case는 `inWrite`, read use case는 `inRead`, 독립 commit이 필요한 outbox/audit/compensation 흐름은 `inNew`를 사용합니다. callback은 `Supplier<T>` 또는 `Runnable`입니다. checked exception을 port signature에 노출하지 않고, runtime exception은 Spring transaction template을 통해 rollback되고 다시 전파됩니다. 이 API만 보면 application은 Spring의 propagation enum이나 `TransactionTemplate`을 알 필요가 없습니다.
|
||||
|
||||
Spring 구현체는 adapter-persistence 쪽에 있습니다. 현재 코드에서는 `SpringTransactionPort`가 `PlatformTransactionManager`를 주입받고, write/read/requires-new용 `TransactionTemplate`을 미리 만들어 둡니다. write는 `PROPAGATION_REQUIRED` + readOnly false, read는 `PROPAGATION_REQUIRED` + readOnly true, requires-new는 `PROPAGATION_REQUIRES_NEW` + readOnly false입니다. 모두 `ISOLATION_READ_COMMITTED`를 명시합니다.
|
||||
|
||||
미리 만들어 둔 template을 쓰는 이유도 중요합니다. `TransactionTemplate`은 설정을 가진 객체입니다. 호출할 때마다 같은 template의 propagation/readOnly/isolation을 바꾸는 방식은 동시성 상황에서 읽기 어려운 race를 만들 수 있습니다. ca-tmpl은 mode별 template을 분리해서 “이 method는 어떤 transaction mode로 실행되는가”를 코드 구조로 고정합니다.
|
||||
|
||||
use case 쪽에서는 `@UseCaseCapability`가 같이 등장합니다. 이 annotation은 use case의 transaction mode, idempotency, repository access, 외부 outbound 허용 여부를 드러냅니다. 그러면 class 이름이나 body를 끝까지 읽지 않아도 이 use case가 read인지 write인지, repository를 쓰는지, 외부 호출을 하는지 볼 수 있습니다. 그리고 ArchUnit은 이 선언과 실제 `TransactionPort` 호출이 맞는지 검사합니다.
|
||||
|
||||
예를 들어 `CreateWorkLogUseCase`는 `transactionMode = WRITE`, `repositoryAccess = WRITE_REPOSITORY`를 선언하고 `tx.inWrite(...)` 안에서 aggregate 저장과 outbox append를 함께 수행합니다. 반대로 query use case는 `tx.inRead(...)`를 사용합니다. ca-tmpl은 application package에서 `org.springframework.transaction.annotation.Transactional`에 의존하는 것도 ArchUnit으로 막습니다. 즉 annotation을 몰래 붙여서 port를 우회하는 경로를 build-time에 차단합니다.
|
||||
|
||||
하지만 `TransactionPort`가 항상 더 좋은 선택이라는 뜻은 아닙니다. framework 교체 가능성이 낮고, 팀이 Spring transaction에 익숙하며, 대부분이 단순 CRUD라면 `@Transactional`을 직접 쓰는 편이 더 단순합니다. `TransactionPort`는 interface, adapter 구현, rule, test를 추가합니다. 이 비용은 skeleton처럼 경계를 학습하고 재사용해야 하는 프로젝트에서는 설명 가능하지만, 모든 팀의 기본값이 될 필요는 없습니다.
|
||||
|
||||
검증 범위도 분명히 나눠야 합니다. ca-tmpl에는 `TransactionPort`, `SpringTransactionPort`, capability annotation, ArchUnit rule, unit test가 존재하고 로컬/dev 수준으로 검증됐습니다. 하지만 운영 배포는 없고, 실 DB connection에서 `readOnly`가 flush mode를 어떻게 바꾸는지 측정한 자료도 없습니다. `REQUIRES_NEW`가 outbox/audit에서 실제 connection pool을 얼마나 쓰는지도 별도 통합 검증 대상입니다.
|
||||
|
||||
정리하면 ca-tmpl의 transaction boundary 결정은 “Spring을 쓰지 않겠다”가 아닙니다. Spring transaction은 adapter에서 사용합니다. 대신 application core는 “나는 read transaction이 필요하다”, “나는 write transaction이 필요하다”라는 의도만 port로 말합니다. 이 작은 우회 덕분에 application layer의 dependency rule, use case capability, ArchUnit fitness function이 한 줄로 이어집니다.
|
||||
|
||||
## 코드 예제 / Code samples (있다면)
|
||||
|
||||
```java
|
||||
// 출처: [[wiki/projects/ca-tmpl/transaction-boundary-abstraction]]
|
||||
// 실제 파일: application-core/.../TransactionPort.java, ca-tmpl @f6fbd4e196b4
|
||||
public interface TransactionPort {
|
||||
<T> T inWrite(Supplier<T> action);
|
||||
<T> T inRead(Supplier<T> action);
|
||||
<T> T inNew(Supplier<T> action);
|
||||
|
||||
default void inWrite(Runnable action) { ... }
|
||||
default void inRead(Runnable action) { ... }
|
||||
default void inNew(Runnable action) { ... }
|
||||
}
|
||||
```
|
||||
|
||||
```java
|
||||
// 출처: [[wiki/projects/ca-tmpl/transaction-boundary-abstraction]]
|
||||
// 실제 파일: adapter-persistence-rdbms/.../SpringTransactionPort.java, ca-tmpl @f6fbd4e196b4
|
||||
@Component
|
||||
public class SpringTransactionPort implements TransactionPort {
|
||||
private final TransactionTemplate writeTemplate;
|
||||
private final TransactionTemplate readTemplate;
|
||||
private final TransactionTemplate requiresNewTemplate;
|
||||
|
||||
public SpringTransactionPort(PlatformTransactionManager transactionManager) {
|
||||
this.writeTemplate = template(transactionManager, TransactionMode.WRITE,
|
||||
TransactionDefinition.PROPAGATION_REQUIRED, false);
|
||||
this.readTemplate = template(transactionManager, TransactionMode.READ_ONLY,
|
||||
TransactionDefinition.PROPAGATION_REQUIRED, true);
|
||||
this.requiresNewTemplate = template(transactionManager, TransactionMode.REQUIRES_NEW,
|
||||
TransactionDefinition.PROPAGATION_REQUIRES_NEW, false);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
```java
|
||||
// 출처: [[wiki/projects/ca-tmpl/transaction-boundary-abstraction]]
|
||||
// 실제 파일: application-core/.../UseCaseCapability.java, ca-tmpl @f6fbd4e196b4
|
||||
@Retention(RetentionPolicy.RUNTIME)
|
||||
@Target(ElementType.TYPE)
|
||||
public @interface UseCaseCapability {
|
||||
TransactionMode transactionMode();
|
||||
Idempotency idempotency();
|
||||
RepositoryAccess repositoryAccess();
|
||||
boolean externalOutboundAllowed() default false;
|
||||
}
|
||||
```
|
||||
|
||||
```java
|
||||
// 출처: [[wiki/projects/ca-tmpl/transaction-boundary-abstraction]]
|
||||
// 실제 파일: sample-portfolio/.../CreateWorkLogUseCase.java, ca-tmpl @f6fbd4e196b4
|
||||
@UseCaseCapability(
|
||||
transactionMode = TransactionMode.WRITE,
|
||||
idempotency = Idempotency.NOT_IDEMPOTENT,
|
||||
repositoryAccess = RepositoryAccess.WRITE_REPOSITORY)
|
||||
public class CreateWorkLogUseCase implements CommandUseCase<CreateWorkLogCommand, WorkLog> {
|
||||
@Override
|
||||
public WorkLog handle(CreateWorkLogCommand cmd) {
|
||||
return tx.inWrite(
|
||||
() -> {
|
||||
WorkLog saved = repository.save(...);
|
||||
appendReservedEvent(saved);
|
||||
return saved;
|
||||
});
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
```java
|
||||
// 출처: [[wiki/projects/ca-tmpl/transaction-boundary-abstraction]]
|
||||
// 실제 파일: app-bootstrap/.../CleanArchitectureTest.java, ca-tmpl @f6fbd4e196b4
|
||||
@ArchTest
|
||||
static final ArchRule APPLICATION_DOES_NOT_USE_SPRING_TRANSACTIONAL_ANNOTATION =
|
||||
noClasses()
|
||||
.that()
|
||||
.resideInAPackage("..application..")
|
||||
.should()
|
||||
.dependOnClassesThat()
|
||||
.haveFullyQualifiedName("org.springframework.transaction.annotation.Transactional")
|
||||
.as("application package must use TransactionPort instead of @Transactional")
|
||||
.allowEmptyShould(true);
|
||||
```
|
||||
|
||||
```java
|
||||
// 출처: [[wiki/projects/ca-tmpl/transaction-boundary-abstraction]]
|
||||
// 실제 파일: app-bootstrap/.../CleanArchitectureTest.java, ca-tmpl @f6fbd4e196b4
|
||||
@ArchTest
|
||||
static final ArchRule USE_CASE_CAPABILITY_MATCHES_TRANSACTION_PORT_BOUNDARY =
|
||||
classes()
|
||||
.that()
|
||||
.areAnnotatedWith(UseCaseCapability.class)
|
||||
.should(callTransactionPortMethodRequiredByCapability())
|
||||
.as("READ_REPOSITORY+READ_ONLY -> inRead, WRITE_REPOSITORY+WRITE -> inWrite");
|
||||
```
|
||||
|
||||
## Sources / 근거 (canonical 인용 필수, derived layer 의무)
|
||||
|
||||
- [[wiki/projects/ca-tmpl/transaction-boundary-abstraction]] — 이 글의 1차 canonical. `TransactionPort`, Spring 구현체, ArchUnit rule, unit/local verification, planned 항목과 과장 금지 경계를 따른다.
|
||||
- [[wiki/concepts/transaction-boundary-abstraction]] — 관련 개념 문서. 현재 `draft`이므로 구현 사실의 출처로 쓰지 않는다.
|
||||
|
||||
## 사실 vs 의견 / Fact vs opinion 구분
|
||||
|
||||
- 사실: ca-tmpl에는 `TransactionPort`, `TransactionMode`, `Isolation`, `UseCaseCapability`, `SpringTransactionPort`, transaction 관련 ArchUnit rule과 unit test가 존재한다. 근거: [[wiki/projects/ca-tmpl/transaction-boundary-abstraction]]
|
||||
- 사실: application package에서 Spring `@Transactional` 의존을 금지하는 ArchUnit rule이 존재한다. 근거: [[wiki/projects/ca-tmpl/transaction-boundary-abstraction]]
|
||||
- 사실: 운영 배포, 실 DB 통합 검증, `readOnly` flush-mode 측정, `inNew` connection pool 실측은 없다. 근거: [[wiki/projects/ca-tmpl/transaction-boundary-abstraction]]
|
||||
- 의견: skeleton template에서는 `@Transactional` 직접 부착보다 port 기반 경계가 학습과 검증에 유리할 수 있다.
|
||||
- 알지 못하는 것: production lock/contention behavior, 실제 DB vendor별 성능 차이.
|
||||
|
||||
## 답할 수 있는 범위 / Answer boundary
|
||||
|
||||
- 자신 있게 답할 수 있는 후속 질문:
|
||||
- 왜 application core에 `@Transactional`을 직접 두지 않았는가?
|
||||
- `TransactionPort.inWrite`/`inRead`/`inNew`는 각각 어떤 의도를 표현하는가?
|
||||
- `SpringTransactionPort`가 mode별 `TransactionTemplate`을 미리 만드는 이유는 무엇인가?
|
||||
- ArchUnit은 transaction boundary를 어디까지 강제하는가?
|
||||
- 다음 글로 넘길 부분:
|
||||
- vendor-specific isolation tuning.
|
||||
- 실 DB/Testcontainers 기반 `readOnly`/`REQUIRES_NEW` 동작 검증.
|
||||
- outbox와 transaction boundary의 통합 검증.
|
||||
|
||||
## 게시 체크리스트 / Publish checklist
|
||||
|
||||
- [x] 모든 사실 주장에 canonical 링크 있음
|
||||
- [x] 사실 vs 의견 분리 명시됨
|
||||
- [x] 금지 마케팅 표현 없음
|
||||
- [x] 코드 예제 출처 명시
|
||||
- [x] 타깃 독자 가정과 톤 일치
|
||||
- [x] `/lint` 통과
|
||||
- [ ] 게시 URL 기록 (게시 후):
|
||||
|
||||
## Related / 관련
|
||||
|
||||
- 후속 글 후보: [[wiki/blog/ca-tmpl-transactional-outbox-pattern-2026-07-02]]
|
||||
- 관련 개념 문서: [[wiki/concepts/transaction-boundary-abstraction]]
|
||||
@@ -1 +0,0 @@
|
||||
../../vault/40-publish/blog/ca-tmpl-transactional-outbox-pattern-2026-07-02.md
|
||||
@@ -0,0 +1,177 @@
|
||||
---
|
||||
title: Transactional Outbox를 Polling 계약으로 구현하기
|
||||
source_type: blog
|
||||
status: verified
|
||||
confidence: high
|
||||
tags: [blog, ca-tmpl, outbox, event-driven, transaction]
|
||||
related_projects: [ca-tmpl]
|
||||
last_reviewed: 2026-07-02
|
||||
canonical_sources:
|
||||
- wiki/projects/ca-tmpl/transactional-outbox-pattern
|
||||
audience: backend-engineer
|
||||
target_publish:
|
||||
status_label: ready
|
||||
---
|
||||
|
||||
# Transactional Outbox를 Polling 계약으로 구현하기
|
||||
|
||||
## Parent / 부모 (필수)
|
||||
|
||||
- 핵심 canonical: [[wiki/projects/ca-tmpl/transactional-outbox-pattern]]
|
||||
- 관련 개념 문서: [[wiki/concepts/transactional-outbox-pattern]] — 일반 outbox 개념. 현재 이 글의 구현 사실 근거는 verified project canonical에 둔다.
|
||||
|
||||
## 타깃 독자 / Target reader
|
||||
|
||||
- 독자 profile: DB transaction과 message publish 사이의 원자성 문제를 skeleton에서 다루려는 백엔드 엔지니어.
|
||||
- 이미 안다고 가정하는 것: transaction, message broker, retry.
|
||||
- 처음 듣는다고 가정하는 것: SKIP LOCKED polling과 per-aggregate FIFO gate를 계약으로 다루는 방식.
|
||||
|
||||
## 도입 / Hook
|
||||
|
||||
- 문제 / 궁금증: DB commit과 broker publish를 한 번에 성공시키는 것은 생각보다 어렵다.
|
||||
- 이 글이 답하는 것: ca-tmpl이 transactional outbox를 어떤 구현과 검증 범위로 잡았는지.
|
||||
- 이 글이 답하지 않는 것: production broker throughput과 장애 복구 실측.
|
||||
|
||||
## 본문 outline / Body outline
|
||||
|
||||
1. dual write 문제와 outbox의 목적.
|
||||
2. outbox table과 polling worker.
|
||||
3. SKIP LOCKED와 per-aggregate FIFO gate.
|
||||
4. idempotency/retry/DLQ와의 경계.
|
||||
5. local verification과 운영 검증 없음.
|
||||
|
||||
## 본문 / Body
|
||||
|
||||
DB 저장과 message publish를 한 use case에서 함께 처리하면 dual-write 문제가 생깁니다. 예를 들어 주문을 DB에 저장한 직후 broker로 이벤트를 보내야 한다고 해보겠습니다. DB commit은 성공했는데 publish 직전에 프로세스가 죽으면, DB에는 상태가 남지만 외부 시스템은 그 사실을 모릅니다. 반대로 publish는 성공했는데 DB transaction이 rollback되면, 외부 시스템은 존재하지 않는 변경을 본 셈이 됩니다.
|
||||
|
||||
Transactional outbox는 이 틈을 줄이는 패턴입니다. business table을 수정하는 같은 DB transaction 안에서 outbox table에도 이벤트 row를 저장합니다. 그리고 별도의 relay가 outbox row를 읽어 broker로 publish합니다. 여기서 중요한 점은 “DB와 broker를 한 transaction으로 묶는다”가 아닙니다. broker publish는 여전히 바깥 작업입니다. 대신 DB 안에 “나중에 반드시 publish해야 할 사실”을 남겨서, 프로세스 실패 후에도 다시 이어갈 수 있게 만듭니다.
|
||||
|
||||
ca-tmpl은 outbox를 문서상의 패턴으로만 두지 않고, application port와 persistence adapter, PostgreSQL migration, relay use case, scheduler/metrics까지 구현했습니다. `OutboxAppendPort`는 business operation이 여는 `TransactionPort.inWrite(...)` 안에서 호출되어야 합니다. 구현체가 자기 transaction을 새로 열지 않는다는 계약도 중요합니다. 같은 write transaction에 aggregate save와 event append가 함께 있어야 dual-write를 줄이는 의미가 생기기 때문입니다.
|
||||
|
||||
outbox row는 상태 머신을 가집니다. 처음에는 `PENDING`이고 relay가 claim하면 `IN_FLIGHT`가 됩니다. publish가 성공하면 `PUBLISHED`, 일시 실패하면 `FAILED`, retry를 모두 소진하면 `DEAD`가 됩니다. `DEAD`는 단순한 로그가 아니라 운영자가 봐야 하는 terminal failure입니다. ca-tmpl 문서와 코드 모두 이 상태를 manual intervention이 필요한 상태로 둡니다.
|
||||
|
||||
claim 단계는 PostgreSQL의 `FOR UPDATE SKIP LOCKED`를 사용합니다. 여러 relay가 동시에 row를 읽을 때, 이미 다른 transaction이 잠근 row를 기다리지 않고 건너뛰게 하는 방식입니다. 이것은 multi-instance relay에서 같은 row를 동시에 claim하는 경합을 줄입니다. 다만 `SKIP LOCKED`가 순서 보존까지 해결하지는 않습니다. 그래서 ca-tmpl query에는 같은 aggregate의 더 이른 미게시 row가 있으면 뒤 row를 claim하지 않는 `NOT EXISTS` gate가 같이 들어갑니다.
|
||||
|
||||
relay use case의 흐름도 의도적으로 짧은 transaction과 바깥 publish를 나눕니다. 먼저 짧은 write transaction에서 batch를 claim합니다. 그다음 publish는 transaction 밖에서 수행합니다. 성공한 row는 다시 짧은 write transaction으로 `PUBLISHED` 처리합니다. publish 실패는 잡아서 `FAILED` 또는 `DEAD`로 바꾸고 error log를 남깁니다. 반대로 publish 성공 후 `markPublished`가 실패하면 예외를 삼키지 않습니다. row가 `IN_FLIGHT`로 남고 timeout 이후 재claim될 수 있기 때문입니다.
|
||||
|
||||
이 구조는 exactly-once delivery를 약속하지 않습니다. outbox relay가 publish 성공 후 상태 갱신에 실패하면 같은 event가 다시 publish될 수 있습니다. 따라서 consumer는 `eventId`나 `idempotencyKey`로 dedupe해야 합니다. ca-tmpl project canonical도 이 지점을 명확히 나눕니다. outbox는 at-least-once delivery를 제공하고, 최종 정합성은 idempotent consumer와 함께 닫힙니다.
|
||||
|
||||
Debezium CDC나 Kafka Connect Outbox SMT도 대안입니다. 하지만 ca-tmpl은 skeleton baseline에서 Kafka Connect cluster, connector, WAL slot 운영을 기본 요구로 두지 않았습니다. 수 초 수준 lag를 허용하는 전제에서는 DB table + polling이 더 작은 운영 단위입니다. 대신 lag SLO가 sub-second로 내려가거나 polling query가 DB load를 만들면 CDC 전환을 검토하는 migration trigger를 문서에 남겼습니다.
|
||||
|
||||
검증 범위는 local/dev입니다. `./gradlew check`가 통과했고, application relay logic, RDBMS adapter, PostgreSQL Testcontainers 기반 row lifecycle과 SKIP LOCKED claim, publish adapter가 테스트되었습니다. 운영 배포, production lag 측정, DLQ 재처리 운영 경험은 없습니다. 따라서 이 글은 “outbox를 운영에서 검증했다”가 아니라 “ca-tmpl skeleton에 outbox polling 계약을 구현하고 로컬 검증했다”까지 말합니다.
|
||||
|
||||
## 코드 예제 / Code samples (있다면)
|
||||
|
||||
```java
|
||||
// 출처: [[wiki/projects/ca-tmpl/transactional-outbox-pattern]]
|
||||
// 실제 파일: application-core/.../OutboxAppendPort.java, ca-tmpl @f6fbd4e196b4
|
||||
public interface OutboxAppendPort {
|
||||
/**
|
||||
* Appends event to the outbox table, participating in the caller's existing
|
||||
* write transaction. Calling outside TransactionPort.inWrite(...) is a
|
||||
* contract violation.
|
||||
*/
|
||||
void append(NewOutboxEvent event);
|
||||
}
|
||||
```
|
||||
|
||||
```java
|
||||
// 출처: [[wiki/projects/ca-tmpl/transactional-outbox-pattern]]
|
||||
// 실제 파일: application-core/.../OutboxEventStatus.java, ca-tmpl @f6fbd4e196b4
|
||||
public enum OutboxEventStatus {
|
||||
PENDING,
|
||||
IN_FLIGHT,
|
||||
PUBLISHED,
|
||||
FAILED,
|
||||
DEAD
|
||||
}
|
||||
```
|
||||
|
||||
```java
|
||||
// 출처: [[wiki/projects/ca-tmpl/transactional-outbox-pattern]]
|
||||
// 실제 파일: adapter-persistence-postgresql/.../PostgreSqlOutboxClaimRepository.java
|
||||
private static final String CLAIM_SQL =
|
||||
"""
|
||||
SELECT * FROM outbox_event o
|
||||
WHERE o.next_attempt_at <= :now
|
||||
AND o.status IN ('PENDING', 'FAILED', 'IN_FLIGHT')
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM outbox_event p
|
||||
WHERE p.aggregate_id = o.aggregate_id
|
||||
AND p.occurred_at < o.occurred_at
|
||||
AND p.status <> 'PUBLISHED'
|
||||
)
|
||||
ORDER BY o.occurred_at ASC
|
||||
LIMIT :limit
|
||||
FOR UPDATE SKIP LOCKED
|
||||
""";
|
||||
```
|
||||
|
||||
```java
|
||||
// 출처: [[wiki/projects/ca-tmpl/transactional-outbox-pattern]]
|
||||
// 실제 파일: application-core/.../PublishPendingOutboxEventsUseCase.java
|
||||
List<OutboxEvent> claimed =
|
||||
tx.inWrite(() -> store.claimBatch(batchSize, now, inFlightTimeout));
|
||||
|
||||
for (OutboxEvent event : sorted) {
|
||||
publishPort.publish(event); // outside transaction
|
||||
tx.inWrite(() -> store.markPublished(event.eventId()));
|
||||
}
|
||||
```
|
||||
|
||||
```sql
|
||||
-- 출처: [[wiki/projects/ca-tmpl/transactional-outbox-pattern]]
|
||||
-- 실제 파일: adapter-persistence-postgresql/.../V3__outbox_event.sql
|
||||
CREATE TABLE outbox_event (
|
||||
event_id varchar(64) NOT NULL,
|
||||
aggregate_id varchar(256) NOT NULL,
|
||||
event_type varchar(256) NOT NULL,
|
||||
payload text NOT NULL,
|
||||
occurred_at timestamptz NOT NULL,
|
||||
status varchar(16) NOT NULL,
|
||||
attempt_count integer NOT NULL DEFAULT 0,
|
||||
next_attempt_at timestamptz NOT NULL,
|
||||
correlation_id varchar(64) NOT NULL,
|
||||
idempotency_key varchar(256) NOT NULL,
|
||||
CONSTRAINT pk_outbox_event PRIMARY KEY (event_id)
|
||||
);
|
||||
```
|
||||
|
||||
## Sources / 근거 (canonical 인용 필수, derived layer 의무)
|
||||
|
||||
- [[wiki/projects/ca-tmpl/transactional-outbox-pattern]] — 이 글의 1차 canonical. outbox 구현, SKIP LOCKED polling, Testcontainers/local verification, prod 미검증 경계를 따른다.
|
||||
- [[wiki/concepts/transactional-outbox-pattern]] — 관련 개념 문서. 구현 사실 출처로 쓰지 않는다.
|
||||
|
||||
## 사실 vs 의견 / Fact vs opinion 구분
|
||||
|
||||
- 사실: ca-tmpl에는 `OutboxAppendPort`, `OutboxStorePort`, `OutboxEventStatus`, `PublishPendingOutboxEventsUseCase`, PostgreSQL `FOR UPDATE SKIP LOCKED` claim repository, outbox migration, publish adapter가 존재한다. 근거: [[wiki/projects/ca-tmpl/transactional-outbox-pattern]]
|
||||
- 사실: `./gradlew check`, application unit test, RDBMS adapter test, PostgreSQL Testcontainers 기반 row lifecycle/claim 검증이 로컬 범위에 포함된다. 근거: [[wiki/projects/ca-tmpl/transactional-outbox-pattern]]
|
||||
- 사실: 운영 배포, production lag 측정, DLQ 재처리 운영 경험은 없다. 근거: [[wiki/projects/ca-tmpl/transactional-outbox-pattern]]
|
||||
- 의견: ca-tmpl 같은 skeleton에서는 Debezium CDC보다 polling outbox가 더 작은 baseline일 수 있다.
|
||||
- 알지 못하는 것: production lag, throughput, broker 장애 상황의 DLQ 운영 결과.
|
||||
|
||||
## 답할 수 있는 범위 / Answer boundary
|
||||
|
||||
- 자신 있게 답할 수 있는 후속 질문:
|
||||
- transactional outbox가 dual-write 문제를 어떻게 줄이는가?
|
||||
- `FOR UPDATE SKIP LOCKED`는 claim 경합에서 무엇을 해결하는가?
|
||||
- per-aggregate FIFO gate가 왜 별도로 필요한가?
|
||||
- 왜 outbox가 exactly-once가 아니라 at-least-once + consumer dedupe인가?
|
||||
- 다음 글로 넘길 부분:
|
||||
- broker-specific scaling.
|
||||
- Debezium CDC/Kafka Connect Outbox SMT 전환.
|
||||
- production lag/DLQ 운영 측정.
|
||||
|
||||
## 게시 체크리스트 / Publish checklist
|
||||
|
||||
- [x] 모든 사실 주장에 canonical 링크 있음
|
||||
- [x] 사실 vs 의견 분리 명시됨
|
||||
- [x] 금지 마케팅 표현 없음
|
||||
- [x] 코드 예제 출처 명시
|
||||
- [x] 타깃 독자 가정과 톤 일치
|
||||
- [x] `/lint` 통과
|
||||
- [ ] 게시 URL 기록 (게시 후):
|
||||
|
||||
## Related / 관련
|
||||
|
||||
- 후속 글 후보: [[wiki/blog/ca-tmpl-idempotency-key-design-2026-07-02]]
|
||||
Reference in New Issue
Block a user