init: llm-wiki-haness 하네스 설계
This commit is contained in:
@@ -0,0 +1 @@
|
||||
# Reserved for the transactional vault migration.
|
||||
@@ -0,0 +1,151 @@
|
||||
---
|
||||
title: API Error Envelope 설계 (custom vs ProblemDetail vs rpc.Status)
|
||||
source_type: llm-generated
|
||||
status: draft
|
||||
confidence: medium
|
||||
tags: [api-design, error-handling, http]
|
||||
related_projects: [ca-skeleton]
|
||||
last_reviewed: 2026-05-22
|
||||
---
|
||||
|
||||
# API Error Envelope 설계 (custom vs ProblemDetail vs rpc.Status)
|
||||
|
||||
> Layer: `wiki/concepts/` — 일반 개념. 특정 프로젝트의 결정/구현 사실은 `wiki/projects/`에서 다룬다.
|
||||
|
||||
## Summary
|
||||
|
||||
API error envelope은 실패 응답의 구조 계약이다. 표준 후보는 RFC 7807 ProblemDetail, Google `rpc.Status`, JSON:API errors, GraphQL errors가 있고, 그 외 대형 서비스의 custom envelope (Stripe / GitHub / 토스페이먼츠 등)이 사실상 진영별 컨벤션으로 자리잡았다. 설계 결정의 핵심 축은 (a) 성공/실패 응답의 대칭 여부, (b) `code` · `category` · `retryable` 같은 운영 메타데이터의 1급 필드 승격 여부, (c) 표준 lock-in과 client SDK 호환성의 trade-off다.
|
||||
|
||||
## Standard (공식 정의)
|
||||
|
||||
### RFC 7807 ProblemDetail (실패 전용 평면)
|
||||
|
||||
IETF 표준. `application/problem+json` media type. 필드: `type` (URI), `title`, `status`, `detail`, `instance`. 모든 필드 optional이고 확장은 top-level에 임의 필드 추가로 한다. RFC 9457로 obsolete되었지만 의미상 호환이며, Spring 6+는 `ProblemDetail` 클래스로 기본 지원한다. 성공 응답에는 적용되지 않고 실패 전용 평면 shape이다.
|
||||
|
||||
### Google `rpc.Status` (gRPC, typed details)
|
||||
|
||||
Google AIP-193. `code` (정수, `google.rpc.Code` enum), `message`, `details: Any[]`. `details`는 `google.protobuf.Any`로 packing되며 표준 detail 타입(`ErrorInfo`, `LocalizedMessage`, `Help`, `RetryInfo`, `QuotaFailure`, `BadRequest`)을 포함한다. `RetryInfo`로 retryable + delay까지 표준화되어 있다. REST/gRPC 양쪽에 동일 모델로 매핑된다.
|
||||
|
||||
### JSON:API errors (배열)
|
||||
|
||||
JSON:API v1.1 spec. top-level에 `errors: []` array 필수. 각 error 객체는 `id`, `links`, `status`, `code`, `title`, `detail`, `source.pointer` (JSON Pointer), `meta` 중 하나 이상을 가진다. `source.pointer`로 form 필드 단위 오류를 가리킨다.
|
||||
|
||||
### GraphQL errors (HTTP 200 + errors field)
|
||||
|
||||
GraphQL Specification (October 2021) §7.1.2. 응답은 `data`와 `errors`를 모두 가질 수 있고, error 객체는 `message` (required), `locations`, `path`, `extensions`를 가진다. transport는 보통 HTTP 200이고 4xx/5xx는 transport-level 실패에만 사용한다.
|
||||
|
||||
### 진영별 custom envelope (표준 아님)
|
||||
|
||||
- **Stripe**: `{ error.{ type, code, decline_code, message, param, doc_url, ... } }`. `type` enum이 사실상 category 역할.
|
||||
- **GitHub**: `{ message, documentation_url, errors[].{ resource, field, code } }`. validation 항목별 풀이가 명시적.
|
||||
- **토스페이먼츠**: `{ code, message }`. 가장 얇은 envelope. retryable/category는 `code` semantic으로 추론.
|
||||
|
||||
이 세 사례는 어떤 IETF/W3C 표준도 따르지 않으며, 각 회사 SDK가 envelope을 흡수하는 전제로 동작한다.
|
||||
|
||||
## 한계 / 주의점
|
||||
|
||||
### Custom envelope
|
||||
|
||||
- 외부 표준이 존재하지 않으므로 client SDK를 직접 작성하거나 envelope 처리 규칙을 client에게 명시적으로 전달해야 한다.
|
||||
- 성공/실패 대칭, `retryable` 1급 같은 운영 친화 결정을 자유롭게 둘 수 있지만 그 비용은 "표준 client 라이브러리 0개"다.
|
||||
|
||||
### RFC 7807 ProblemDetail
|
||||
|
||||
- 실패 전용 평면 shape이므로 "성공도 envelope으로 감싸 `success: true/false`로 분기하고 싶다"는 요구와 구조적으로 충돌한다.
|
||||
- `code` 필드가 표준에 없다 — `type` URI가 식별자다. 짧은 머신리더블 코드를 원하면 확장 필드를 강제해야 하고, 결국 "표준 위에 사실상 custom 레이어"가 된다.
|
||||
- Spring 6+는 기본 활성이므로, custom envelope을 채택한다는 것은 의식적으로 표준 인프라를 비활성화하는 선택이다.
|
||||
- `application/problem+json`을 content-negotiation으로 처리하는 client는 흔하지 않다 — 실질 호환성 이득은 명목 수준에 가깝다.
|
||||
|
||||
### Google `rpc.Status`
|
||||
|
||||
- 본질적으로 gRPC/protobuf 생태계 결합이다. HTTP REST 전용 서비스에 강제하면 `Any` 디코딩 부담이 client에 mismatch로 전가된다.
|
||||
- 표준 detail 타입 카탈로그를 알아야 효용이 발휘되어 학습 곡선이 높다.
|
||||
- 가벼운 CRUD API에는 과한 표현력이다.
|
||||
|
||||
### JSON:API errors
|
||||
|
||||
- `errors[]` array와 `source.pointer`는 항목 단위 오류 표현에 강하지만, `category`/`retryable`이 1급 필드가 아니라 `meta`로 빠진다.
|
||||
- 부분 채택 시 표준성이 사라진다. 완전 채택 시 success response 리소스 객체 구조, sparse fieldsets 등 spec 전체에 lock-in된다.
|
||||
|
||||
### GraphQL errors
|
||||
|
||||
- HTTP 200 + `errors` field가 transport 규약이라 CDN / proxy / observability 도구의 4xx/5xx 기반 알람·캐시·라우팅과 부조화한다.
|
||||
- partial success가 1급 개념이라 REST envelope과 패러다임 자체가 다르다 — REST 컨텍스트에서 직접 비교해 "GraphQL이 옳다/그르다"라고 말할 수 없다.
|
||||
|
||||
### 흔한 오해
|
||||
|
||||
- "Stripe / GitHub / 토스페이먼츠가 그렇게 하니까 industry standard다" — 표준이 아니라 진영별 컨벤션이다. SDK 없이 직접 다루는 client는 거의 없다는 전제 위에서 동작한다.
|
||||
- "ProblemDetail은 잘못된 설계다" — 실패 전용 use case (예: 외부 노출 API, RFC 9457 client 생태계 활용)에서는 유효한 선택이다.
|
||||
|
||||
## Project Application
|
||||
|
||||
- [[wiki/projects/ca-tmpl/api-error-envelope-design]] — ca-tmpl 의사결정 기록 (`verified` — envelope record/handler 코드 구현 + `./gradlew check` 로컬 통과). 실제 구현 범위·검증 수준은 project 문서 참조.
|
||||
- [[raw/project-notes/ca-skeleton-operational-contract]] — §3 Structured API Response Contract / §5 Exception Ownership Contract / §6 Operational Error Category / §29 Topic 4 (custom envelope 결정 라인업)
|
||||
- [[raw/branch-notes/feature-operational-error-observability-foundation]] — envelope schema SSOT
|
||||
- [[raw/branch-notes/feature-boundary-validation-mapping-contract]] — validation error → `error.details` 매핑
|
||||
- [[raw/branch-notes/feature-business-rule-validation-contract]] — business invariant → category 매핑
|
||||
|
||||
위 branch-note들은 success / error 대칭, `error.code` · `error.category` · `error.retryable` · `error.details` 분리, `meta.requestId` / `meta.traceId` / `meta.correlationId` 1급 노출, raw exception / SQL / token / body의 응답 leak 금지를 계약으로 둔다.
|
||||
|
||||
## Claim-backed Knowledge
|
||||
|
||||
> 이 개념 문서의 핵심 설명은 raw source claim 으로 뒷받침되어야 한다.
|
||||
> 공식 문서 claim, 회사 사례 claim, 내 프로젝트 decision 을 분리한다.
|
||||
|
||||
| Knowledge Point | Supporting Claims | Confidence | Notes |
|
||||
|---|---|---|---|
|
||||
| RFC 7807 ProblemDetail은 `application/problem+json` 기반 실패 전용 평면 shape이며 `type` URI가 식별자다 (`code` 필드 없음) | [[raw/official-docs/problem-detail-rfc-7807]], [[raw/official-docs/spring-problem-detail]] | `high` | 공식 표준 (IETF / Spring) — success/error 대칭·머신리더블 `code` 요구와 구조적으로 충돌 |
|
||||
| Google `rpc.Status`는 `RetryInfo` 등 typed detail로 retryable + delay까지 표준화 (REST/gRPC 공통 모델) | [[raw/official-docs/google-api-error-format]] | `high` | 공식 vendor 문서(AIP-193) — 단 protobuf/`Any` 결합이라 HTTP REST 전용에는 과한 표현력 |
|
||||
| JSON:API는 `errors[]` + `source.pointer`(JSON Pointer)로 항목 단위 오류를 가리키지만 `category`/`retryable`이 1급 필드가 아니다 | [[raw/official-docs/json-api-errors-spec]] | `high` | 공식 표준 — 부분 채택 시 표준성 상실, 완전 채택 시 spec 전체 lock-in |
|
||||
| Stripe/GitHub/토스페이먼츠 envelope은 IETF/W3C 표준이 아니라 진영별 컨벤션이며 각 사 SDK가 envelope을 흡수하는 전제로 동작한다 | [[raw/company-tech-blogs/stripe-error-format]], [[raw/company-tech-blogs/github-api-error-format]], [[raw/company-tech-blogs/toss-payments-error-format]] | `medium` | company-case-study — 공식 best practice로 일반화 금지. SDK 부재 client는 거의 없다는 전제 |
|
||||
|
||||
## 내가 설명할 수 있어야 하는 것
|
||||
|
||||
- API error envelope의 후보 표준(RFC 7807 / Google `rpc.Status` / JSON:API / GraphQL errors)의 공식 정의와 각자의 식별자 표현 방식은?
|
||||
- 어떤 문제를 해결하는가 — client가 실패를 어떻게 분기·재시도·관측 가능하게 만드는 구조 계약인가?
|
||||
- 어떤 상황에서는 custom envelope을 쓰면 안 되는가(표준 client 생태계 활용이 우선인 외부 노출 API 등)?
|
||||
- 공식 표준이 말하지 않는 부분(success/error 대칭, `retryable`·`category` 1급화)은 무엇이고 그 비용("표준 client 라이브러리 0개")은 무엇인가?
|
||||
- Stripe/GitHub/토스 사례를 industry standard처럼 일반화하면 안 되는 지점은?
|
||||
- 내 프로젝트에서는 어떤 branch decision(custom envelope 채택 + ProblemDetail 거부)으로 연결됐는가?
|
||||
- 이 개념을 코드/운영에서 검증하려면 무엇을 확인해야 하는가(envelope 직렬화, leak 금지, ProblemDetail 비활성 build-time 강제 등)?
|
||||
|
||||
## Interview Questions
|
||||
|
||||
- 왜 RFC 7807 ProblemDetail을 채택하지 않았는지? 표준을 우회한 비용은 무엇이고, 그 대신 무엇을 얻는지?
|
||||
- `retryable`을 1급 필드로 둔 이유는? client는 `retryable: true`를 받았을 때 어떻게 다르게 동작해야 하는지?
|
||||
- validation error를 `error.details`에 담을 때 GitHub `errors[].{resource, field, code}` 또는 JSON:API `source.pointer`와 비교하면 어떤 형식을 택했고, 왜 그렇게 택했는지?
|
||||
- `error.code`와 `error.category`를 분리한 이유는? client 분기는 어느 쪽으로 하라고 가이드하는지?
|
||||
- 응답에 절대 leak하면 안 되는 항목은? exception class name, stack trace, SQL, token, raw body, upstream raw error body 각각이 왜 금지인지 설명할 수 있는지?
|
||||
|
||||
## Do Not Overclaim
|
||||
|
||||
- "내 envelope이 표준이다" / "ca-tmpl envelope이 IETF 표준 envelope이다"라고 말하면 안 된다. 어떤 표준도 success/error 대칭 + `retryable` 1급 + `category` 1급을 동시에 강제하지 않는다 — 자체 결정일 뿐이다.
|
||||
- "ProblemDetail은 잘못된 설계다"라고 단정하면 안 된다. 실패 전용 평면이라는 그 자체가 결함이 아니며, 외부 표준 client 호환을 우선하는 use case에서는 합리적이다.
|
||||
- "Stripe / GitHub / 토스가 다 custom이니까 표준은 의미 없다"라고 말하면 안 된다. 그들은 SDK가 envelope을 흡수하는 전제 위에 동작하며, 표준 미준수가 정당화되는 것이 아니라 trade-off가 다른 것뿐이다.
|
||||
- Google `rpc.Status`의 `RetryInfo.retry_delay`보다 `retryable: boolean`이 우월하다고 주장하면 안 된다 — 후자는 단순하지만 actionable한 delay 정보를 잃는다.
|
||||
|
||||
## Sources
|
||||
|
||||
### 공식 표준
|
||||
|
||||
- [[raw/official-docs/problem-detail-rfc-7807]] — RFC 7807 (Problem Details for HTTP APIs)
|
||||
- [[raw/official-docs/spring-problem-detail]] — Spring Framework `ProblemDetail` (RFC 9457 기본 지원)
|
||||
- [[raw/official-docs/google-api-error-format]] — Google AIP-193, `google.rpc.Status`
|
||||
- [[raw/official-docs/json-api-errors-spec]] — JSON:API v1.1 Errors
|
||||
- [[raw/official-docs/graphql-errors-spec]] — GraphQL Specification (October 2021) Errors
|
||||
|
||||
### 진영별 사례 (표준 아님)
|
||||
|
||||
- [[raw/company-tech-blogs/stripe-error-format]] — Stripe custom envelope
|
||||
- [[raw/company-tech-blogs/github-api-error-format]] — GitHub REST API error format
|
||||
- [[raw/company-tech-blogs/toss-payments-error-format]] — 토스페이먼츠 `{code, message}`
|
||||
|
||||
### Canonical (프로젝트 결정 사실)
|
||||
|
||||
- [[raw/project-notes/ca-skeleton-operational-contract]] §3 / §5 / §6 / §29 Topic 4
|
||||
|
||||
## Cluster / 묶음
|
||||
|
||||
<!-- GENERATED: derived-blogs:start -->
|
||||
- [[wiki/blog/ca-tmpl-api-error-envelope-design-2026-07-02]]
|
||||
<!-- GENERATED: derived-blogs:end -->
|
||||
@@ -0,0 +1,177 @@
|
||||
---
|
||||
title: API Evolution & Schema (compatibility + serialization + HTTP contract surface)
|
||||
source_type: llm-generated
|
||||
status: reviewed
|
||||
confidence: medium
|
||||
tags: [api-design, versioning, schema, deprecation, pagination, conditional-request, http-cache]
|
||||
related_projects: [ca-skeleton]
|
||||
last_reviewed: 2026-06-04
|
||||
---
|
||||
|
||||
# API Evolution & Schema (compatibility + serialization)
|
||||
|
||||
> Layer: `wiki/concepts/` — 일반 개념. 특정 프로젝트의 결정/구현 사실은 `wiki/projects/`에서 다룬다.
|
||||
|
||||
## Summary
|
||||
|
||||
API evolution은 두 축으로 나뉜다. (1) **compatibility / deprecation** — 응답 필드 제거나 의미 변화를 막기 위해 breaking change를 분류하고 migration window 동안 deprecated marker와 Sunset 헤더로 client에게 신호를 보낸다. (2) **schema / serialization** — date·money·enum·null·unknown field의 의미를 framework default에 맡기지 않고 명시 계약으로 고정한다. 대표 결정 라인업은 `90d public + 30d internal migration window`, RFC 8594 `Sunset` 헤더, ISO-8601 offset datetime (UTC default), `BigDecimal` scale 2 + `HALF_UP`, **strict inbound / tolerant outbound** 정책이다.
|
||||
|
||||
## Standard (공식 정의)
|
||||
|
||||
### Compatibility / deprecation 표준 후보
|
||||
|
||||
- **RFC 8594 Sunset header (IETF)**: 응답 헤더로 자원이 응답 불가가 될 시점을 HTTP-date로 알린다. `Sunset` 단독은 *언제* 사라지는지 신호일 뿐이고, deprecation 자체는 별도 `Deprecation` 헤더(IETF draft)로 표시하는 것이 표준 의도다.
|
||||
- **Microsoft REST API versioning policy**: `api-version` query/header를 정식 권고. major version 단위 breaking change 허용, minor/preview는 additive only. preview API는 별도 lifecycle.
|
||||
- **GitHub REST API**: 2022년부터 `X-GitHub-Api-Version: YYYY-MM-DD` 날짜 헤더. 새 버전 release 후 **24개월 EOL** 정책, EOL된 버전 호출은 `410 Gone` 응답. preview API는 `Accept` 헤더 `application/vnd.github.<name>-preview+json`로 옵트인.
|
||||
- **Stripe date-based versioning**: account마다 첫 호출 시 version pin. 이후 새 version이 나와도 client가 명시적으로 upgrade하지 않으면 **freeze forever** (Stripe가 영구적으로 구버전 응답을 유지). 외부 컨슈머 규모가 큰 결제 도메인 특화.
|
||||
- **Google AIP-180 (Backwards compatibility)**: enum value 제거 / 의미 변경 / 응답 필드 제거 / 기본값 변경 / required request field 추가 모두 breaking으로 분류. additive (optional response field 추가)만 minor에 허용.
|
||||
- **Twitter tier-based**: legacy / current / beta 트랙 병렬 운영.
|
||||
- **Spring HATEOAS**: 응답에 `_links`로 다음 자원 URI를 동봉해 client가 version이 아닌 link relation에 결합하게 한다.
|
||||
|
||||
### Schema / serialization 표준 후보
|
||||
|
||||
- **ISO-8601**: date·time·datetime·duration의 wire 표현 표준. offset datetime(`2026-05-22T11:30:00+09:00` 또는 `Z`)이 timezone ambiguity 회피의 정석.
|
||||
- **JSON Schema** (draft 2020-12): JSON payload의 shape 검증 spec. `additionalProperties: false`로 unknown field strict, `nullable` / `required` / `enum`으로 의미 분리.
|
||||
- **OpenAPI 3.1**: JSON Schema 2020-12 정합. response shape SSOT 후보. `deprecated: true` 플래그를 schema/operation 양쪽에 둘 수 있어 deprecation marker 표준 위치가 된다.
|
||||
- **Avro schema evolution**: backward / forward / full compatibility를 schema registry가 자동 검사. 필드 추가/삭제 시 default 의무, alias로 rename. event/outbox 환경에 우위.
|
||||
- **Protobuf**: `reserved` 키워드로 field number와 name 재사용을 영구 차단. wire-format 기반 strict typing.
|
||||
- **Jackson** (Java): `DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES`는 default `true`. 단, `FAIL_ON_NULL_FOR_PRIMITIVES`는 default `false`라 null/missing primitive가 묵시적으로 0이 된다. 출력측은 `SerializationFeature.WRITE_DATES_AS_TIMESTAMPS`(default `false` → `JavaTimeModule` 경유 ISO-8601 문자열, `true` 면 epoch/배열)와 `JsonGenerator.Feature.WRITE_BIGDECIMAL_AS_PLAIN`(default `false` → 큰 값이 지수 표기 `1.23E+10`)이 wire 형식을 좌우한다. 이 둘은 *프레임워크 기본값*이라 버전 업그레이드로 flip 될 수 있으므로 계약을 명시 핀하고 effective bean 동작 테스트로 회귀를 잡는 것이 안전하다.
|
||||
- **Property naming strategy**: Jackson `PropertyNamingStrategies`(camelCase default / `SNAKE_CASE` / `KEBAB_CASE`)는 wire 의 field 이름 컨벤션을 결정한다. 한 번 정하면 client 가 그 이름에 결합하므로 *변경 자체가 breaking* — 전역 strategy 변경은 모든 응답 field rename 과 동치다.
|
||||
- **Null vs absent (`@JsonInclude`)**: `JsonInclude.Include.NON_NULL`/`NON_ABSENT`/`NON_EMPTY` 는 null 또는 빈 값을 출력에서 *생략* 한다. 생략(absent)과 명시적 `null` 은 client 에게 다른 의미(부재 vs 값이 null) 일 수 있어, JSON Merge Patch 같은 부분 갱신 의미가 필요하면 `JsonNullable<T>` 로 3-상태(present-null / present-value / absent)를 구분한다.
|
||||
- **Java BigDecimal**: 금액 계산 표준. `new BigDecimal(double)` 함정 (`0.1` → `0.1000000000000000055511151231257827021181583404541015625`), `setScale(2, RoundingMode.HALF_UP)` 패턴, JSON에서는 string 직렬화로 client 부동소수 손실 회피가 표준 권고.
|
||||
- **Smithy**: AWS의 API modeling DSL. SDK 코드 생성 친화적, 단 외부 ecosystem에서는 OpenAPI보다 미성숙.
|
||||
|
||||
### HTTP contract surface 표준 (conditional request / cache / pagination)
|
||||
|
||||
versioning·schema 와 별개로, HTTP API surface 자체의 일반 계약 표준. (RFC 9110/9111 은 IETF official-standard, AIP 는 Google community guideline)
|
||||
|
||||
- **Conditional request (RFC 9110 §13)**: `ETag` 는 representation 의 opaque validator (weak `W/"..."` 또는 strong). write 는 `If-Match` 로 optimistic concurrency 검증 — condition 이 false 면 **412 Precondition Failed**. read 는 `If-None-Match` 로 cache validation — match 면 **304 Not Modified** (body 없음, client 저장본 사용). RFC 9110 은 `If-Match` 에 *strong comparison* 을 MUST 로 요구한다.
|
||||
- **HTTP caching (RFC 9111 §5.2)**: `Cache-Control` directive — `no-store` (저장 금지, 인증 API 안전 default), `private` (shared cache 저장 금지), `public` (Authorization 있어도 shared cache 허용), `max-age=N` (stale 판정 초). 협상/인증 응답은 `Vary` (RFC 9110 §12.5.5) 로 어떤 request 부분이 content 선택에 영향을 줬는지 명시해 proxy/CDN cache poisoning 을 막는다.
|
||||
- **Pagination (Google AIP-158, JSON:API)**: offset (`page`/`size`) vs cursor (opaque token). AIP-158 은 page token 이 opaque + URL-safe MUST, server-side size cap SHOULD coerce, empty next-token = end-of-collection 을 규정. JSON:API 는 `links` object 안의 `first`/`last`/`prev`/`next` key 위치를 정의. 구체 숫자(size cap, TTL)는 표준이 아닌 구현 trade-off.
|
||||
- **Transport error 의미 구분 (RFC 9110 §15)**: 413 Content Too Large, 406 Not Acceptable (응답 표현 협상 실패) vs 415 Unsupported Media Type (요청 본문 format), 405 Method Not Allowed (+ `Allow` header MUST). 같은 code 로 뭉개면 표준 의미가 손실된다.
|
||||
- **Long-running operation (Google AIP-151 + RFC 9110)**: 비동기 처리는 **202 Accepted** + `Location` polling URL + Operation 객체(`done`/`response`/`error`). `Retry-After` 로 polling interval 권고.
|
||||
|
||||
## 한계 / 주의점
|
||||
|
||||
### Compatibility / deprecation 측
|
||||
|
||||
- **Stripe freeze-forever**: 무기한 구버전 유지 비용이 외부 결제 컨슈머 규모에서만 정당화된다. internal API에 그대로 차용하면 server 코드에 N개 버전 분기를 영구 운반하게 된다.
|
||||
- **GitHub 24개월 EOL + `410 Gone`**: 길어 보이는 EOL window지만 catalog에 EOL 응답 코드(410)를 명시하지 않으면 client 입장에서 *어느 날 갑자기 410*과 다를 바 없다. EOL 응답 코드 자체를 contract에 박는 것이 필요하다.
|
||||
- **Twitter tier-based (legacy/current/beta)**: 트랙별 행위 분기가 server-side 복잡도와 운영 비용을 곱한다. 단일 팀 / internal-first 환경에 과하다.
|
||||
- **Spring HATEOAS (links over versions)**: 이론적으로 우아하지만 실제 client가 `_links`를 dynamic하게 따라가는 경우는 드물고, 학습 곡선과 client 구현 강제 비용이 크다.
|
||||
- **Google AIP-180 `enum value 제거 = breaking`**: client switch/case 누락을 유발하므로 strict 분류가 맞지만, enum value 추가 또한 client 입장에서 unknown enum 처리 정책이 없으면 깨진다 — server-side enum addition을 "additive"로만 분류하는 단순화는 위험하다.
|
||||
- **`Sunset` 단독 사용**: RFC 8594는 *언제 사라지는지*만 알린다. 같은 자원이 *이미 deprecated인지*는 `Deprecation` 헤더로 함께 보내야 정합이다. Sunset만 보내면 "사라질 날짜는 알지만 지금 권장 여부는 모름" 상태가 된다.
|
||||
- **`Sunset` 헤더 단독 사용 금지 — `Deprecation` draft와 paired**: IETF httpapi WG 권고에 따르면 `Sunset` 헤더는 `Deprecation` 헤더(draft-ietf-httpapi-deprecation-header, RFC 9745 진행)와 paired로 송신해야 client tooling이 deprecation 상태를 감지할 수 있다. paired invariant는 "Sunset 시점 ≥ Deprecation 시점". 추가로 `Link: <url>; rel="deprecation"` / `rel="sunset"`을 함께 보내 사람-가독 가이드를 연결한다. ca-tmpl처럼 marker만 OpenAPI에 박고 응답 헤더 paired 송신을 누락하면 외부 client interceptor가 deprecation을 자동 인지하지 못한다.
|
||||
|
||||
### Schema / serialization 측
|
||||
|
||||
- **Avro / Protobuf strict typing**: schema registry가 backward/forward 자동 검사로 강력하나, 외부 REST API가 JSON인 환경에서는 outbox / event 한정 도입이 현실적이다.
|
||||
- **Smithy**: AWS SDK 친화적이지만 외부 ecosystem(예: third-party tooling, doc generator) 성숙도가 OpenAPI 대비 낮다.
|
||||
- **Jackson default**: `FAIL_ON_UNKNOWN_PROPERTIES=true`는 strict inbound와 정합하나, `FAIL_ON_NULL_FOR_PRIMITIVES=false`는 null/empty/missing 분리 정책과 **불일치**다 — 명시적으로 override하지 않으면 contract가 깨진 줄도 모르고 0이 흘러간다.
|
||||
- **"Jackson은 unknown field tolerant가 default"라는 오해**: 보안/계약 측면에서 unknown inbound를 silently 허용하면 typo로 인한 데이터 손실 + payload smuggling 모두 위험. strict inbound가 안전 default.
|
||||
- **JSON 환경의 Protobuf `reserved` 흉내**: Protobuf는 field number / name 재사용을 wire-format 수준에서 영구 차단한다(`reserved 3, 5;` / `reserved "foo";`). OpenAPI 3.1 / JSON Schema 2020-12에는 동등 시맨틱이 없다 — `deprecated: true`는 *비권장* 신호일 뿐 재사용 차단이 아니고, field가 사라지면 schema에서도 사라져 미래 재사용 방지 불가. 현실적 대안은 두 가지: (1) **OpenAPI `x-removed-fields` 같은 Specification Extension**으로 schema SSOT에 catalog를 통합하고 자체 lint로 재사용 검출, (2) **별도 markdown catalog**(예: `docs/removed-fields-catalog.md`)에 제거된 이름/번호/일자 기록 후 CI에서 OpenAPI diff와 cross-check. 둘 다 표준 검증 도구가 없어 자체 도구 작성이 따라온다. (needs-confirmation)
|
||||
- **`new BigDecimal(double)` 함정**: 같은 `0.1`이 `BigDecimal.valueOf(0.1)` (정확)과 `new BigDecimal(0.1)` (부동소수 잔차)으로 갈린다. 코드 review 규칙으로 차단하지 않으면 unit test 통과 + 운영에서 1원 차이 인시던트가 흔하다.
|
||||
- **ISO-8601 offset 없는 datetime**: `2026-05-22T11:30:00`는 표준상 valid이지만 timezone이 누락된다. 서버 timezone에 따라 의미가 달라지므로 contract에서는 offset 필수로 강제해야 한다. 직렬화 형식을 `WRITE_DATES_AS_TIMESTAMPS=false`로만 핀해도 `JavaTimeModule`(`jackson-datatype-jsr310`)이 등록되지 않으면 `LocalDateTime`이 `[2026,5,22,...]` 배열로 직렬화되므로, module 등록 + effective 직렬화 동작 테스트가 함께 필요하다.
|
||||
- **naming strategy 변경 = 전역 breaking change**: snake_case ↔ camelCase 같은 `PropertyNamingStrategy` 전역 변경은 모든 응답 field 이름이 바뀌는 것과 같아 deprecation window 없이 적용하면 client 가 일제히 깨진다. naming 은 초기에 고정하고 이후 변경을 breaking change catalog 대상으로 다뤄야 한다.
|
||||
- **`@JsonInclude(NON_NULL)` 의 의미 손실**: null 생략은 payload 를 줄이지만 "값이 null" 과 "field 부재" 를 구분 불가하게 만든다. 부분 갱신(PATCH/merge-patch) contract 에서는 이 구분이 의미를 가지므로 3-상태(`JsonNullable`/`Optional`) 표현을 별도로 둬야 하고, 무분별한 NON_NULL 전역 적용은 이 의미 분리를 무너뜨린다.
|
||||
|
||||
### 흔한 오해
|
||||
|
||||
- "Stripe 방식이 표준이다" — IETF/W3C 표준이 아니고 진영별 사례다. 외부 결제 컨슈머 규모를 가정한 trade-off의 결과다.
|
||||
- "`Sunset` 헤더만 보내면 deprecation은 끝이다" — 잘못. `Deprecation` 헤더(현재 진행 중인지)와 `Sunset` 헤더(언제 사라지는지)는 함께 사용해야 정합이다.
|
||||
- "Jackson은 unknown field tolerant가 안전한 default다" — 잘못. inbound strict가 보안/계약 안전 default이고, outbound는 schema에 없는 field가 노출되지 않도록 controlled해야 한다(소위 **strict inbound / tolerant outbound**가 아니라 "strict inbound / schema-controlled outbound"가 정확).
|
||||
- "enum 값 추가는 무조건 additive다" — server-side 입장에서는 additive지만 client 입장에서는 unknown enum 처리 정책이 없으면 깨진다. client side에 unknown enum fallback이 contract로 명시되어야 비로소 additive다.
|
||||
|
||||
## Project Application
|
||||
|
||||
- [[wiki/projects/ca-tmpl/api-evolution-and-schema]] — ca-tmpl 의사결정 기록 (현재 `documented-only`, Phase C2 미진입). 실제 구현 여부는 project 문서 참조.
|
||||
- [[raw/project-notes/ca-skeleton-operational-contract]] §13 API Contract Surface / §16 Schema / Serialization Contract / §18 API Compatibility / Deprecation / §29 G-F (외부 근거 인덱스)
|
||||
- [[raw/branch-notes/feature-api-compatibility-deprecation-contract]] — breaking change catalog(7행), 90d/30d migration window, OpenAPI `deprecated: true` marker, Sunset 헤더 채택
|
||||
- [[raw/branch-notes/feature-schema-serialization-contract]] — ISO-8601 offset/UTC, BigDecimal scale 2 + HALF_UP, unknown field strict inbound, null/empty/missing 의미 분리
|
||||
|
||||
위 branch-note들이 (a) breaking change 7 분류 + migration window + deprecation marker 위치, (b) serialization producer 책임(date/time/money/enum/null/unknown)을 계약으로 둔다. canonical 승급 여부와 검증 등급은 해당 project 문서가 판정한다.
|
||||
|
||||
ca-tmpl 의 **HTTP contract surface (versioning/pagination/conditional/cache/OpenAPI)** 는 위 두 축과 달리 실제 코드로 구현·로컬 검증됐다 — 구현 사실과 검증 등급은 [[wiki/projects/ca-tmpl/api-evolution-and-schema]] 의 "API contract baseline 구현" 절 참조.
|
||||
|
||||
## Claim-backed Knowledge
|
||||
|
||||
> 각 Knowledge Point 는 이미 §Sources 에 인용된 자료로만 뒷받침된다. company-tech-blog 출처는 사례일 뿐 official best practice 로 격상하지 않는다.
|
||||
|
||||
| Knowledge Point | Supporting Claims | Confidence | Notes |
|
||||
|---|---|---|---|
|
||||
| `Sunset` 헤더는 자원이 응답 불가가 될 시점을 HTTP-date 로 알리며 `Deprecation` 헤더와 paired 송신해야 client tooling 이 deprecation 상태를 감지 | [[raw/official-docs/compat-rfc-8594-sunset-header]], [[raw/official-docs/sunset-deprecation-headers-paired-usage]] | high | `official-standard`(RFC 8594) + IETF httpapi draft. "Sunset 단독 충분" 금지. invariant: Sunset 시점 ≥ Deprecation 시점 |
|
||||
| Google AIP-180 은 enum 제거/의미변경, 응답 필드 제거, 기본값 변경, required request field 추가를 breaking 으로 분류 | [[raw/official-docs/api-versioning-google-aip-180]] | high | `official-reference` (Google community guideline, IETF/W3C 표준 아님). additive 만 minor 허용 |
|
||||
| Jackson `FAIL_ON_UNKNOWN_PROPERTIES` default `true` (strict inbound) 이나 `FAIL_ON_NULL_FOR_PRIMITIVES` default `false` (null/missing primitive → 묵시적 0) | [[raw/official-docs/schema-jackson-unknown-field-handling]] | high | `official-vendor-doc`. "Jackson default 가 안전" 금지 — 후자는 명시 override 필요 |
|
||||
| `new BigDecimal(double)` 은 부동소수 잔차를 남기므로 `BigDecimal.valueOf` + `setScale(2, HALF_UP)` + JSON string 직렬화 권고 | [[raw/official-docs/schema-bigdecimal-money-serialization-java]] | high | `official-vendor-doc`. client 부동소수 손실 회피 |
|
||||
| ISO-8601 offset datetime 이 timezone ambiguity 회피의 정석, offset 없는 표현은 서버 timezone 의존 | [[raw/official-docs/schema-jackson-unknown-field-handling]] | medium | wire 계약에서 offset 강제 근거 (ISO-8601 일반 상식 + Jackson 직렬화 자료) |
|
||||
| OpenAPI 3.1 은 JSON Schema 2020-12 정합의 machine-readable HTTP API contract 이며 `deprecated: true` marker 를 schema/operation 양쪽에 둘 수 있음 | [[raw/official-docs/openapi-spec-3-1-0]] | high | `official-standard`(OAS/Linux Foundation). "marker 만으로 client 가 알아서 migrate" 금지 |
|
||||
| Protobuf `reserved` 는 field number/name 재사용을 wire-format 수준에서 영구 차단하나 OpenAPI/JSON Schema 에는 동등 시맨틱이 없음 | [[raw/official-docs/schema-protobuf-vs-json-evolution]], [[raw/official-docs/protobuf-reserved-vs-json-openapi-extension]] | medium | `official-reference`. "JSON 에서 완벽 흉내" 금지 — `x-` extension + 자체 lint 필요, needs-confirmation |
|
||||
| RFC 9110 conditional request: `ETag` validator + `If-Match`(write, strong comparison MUST)→412 + `If-None-Match`(read)→304; RFC 9111 cache directive(`no-store`/`private`/`public`/`max-age`) + `Vary` 로 cache poisoning 방지 | [[raw/official-docs/rfc9110-http-semantics]], [[raw/official-docs/rfc9111-http-caching]] | high | `official-standard`(IETF). ca-tmpl 의 weak/lenient `If-Match` 비교는 skeleton 단순화 — project 문서 참조 |
|
||||
| Pagination: AIP-158 은 page token opaque+URL-safe MUST, server-side size cap SHOULD coerce, empty next-token = EoC. JSON:API 는 `links` 의 first/last/prev/next 위치 정의 | [[raw/official-docs/spring-data-pageable-defaults]] (offset/zero-indexed) | medium | `official-vendor-doc`(Spring). size cap 숫자/TTL 은 표준 아닌 구현 trade-off |
|
||||
|
||||
## 내가 설명할 수 있어야 하는 것
|
||||
|
||||
- **API evolution 의 세 영역 분리**: compatibility/deprecation vs schema/serialization vs HTTP contract surface (versioning/pagination/conditional/cache). 세 영역이 framework default 가 아니라 명시 계약이어야 하는 이유.
|
||||
- **`Sunset` vs `Deprecation` 헤더의 역할 분리**와 paired 송신 이유, paired invariant.
|
||||
- **breaking change 분류 기준** (enum 축소/제거, 응답 필드 제거, 기본값 변경, required request field 추가) 과 "internal API 니까 그냥 한다" 가 위험한 이유 (client deploy lag).
|
||||
- **strict inbound / schema-controlled outbound** 의 정확한 의미와 Jackson 의 두 feature default 차이.
|
||||
- **money 직렬화**에서 `double` 위험 / `BigDecimal.valueOf` / HALF_UP / JSON string 직렬화 근거.
|
||||
- **conditional request** 가 DB optimistic lock 과 같은 충돌의 HTTP 표현이라는 점 (ETag → If-Match → 412, If-None-Match → 304), strong vs weak comparison 차이.
|
||||
- **인증 API 의 안전한 cache default = `no-store`** + `Vary` 가 cache poisoning 을 막는 원리.
|
||||
- **offset vs cursor pagination** trade-off, size cap 이 DoS 방어인 이유, page token opacity 의 의미.
|
||||
- **transport error 의미 구분** (406 vs 415, 405 + `Allow`, 413/414) 을 같은 code 로 뭉개면 안 되는 이유.
|
||||
|
||||
## Interview Questions
|
||||
|
||||
- **90d public + 30d internal migration window**의 근거는? 더 짧게/길게 잡으면 어떤 비용이 생기는지? Stripe(freeze forever)나 GitHub(24mo EOL)와 비교했을 때 internal-first 환경에서 90d가 합리적인 이유는?
|
||||
- **`Sunset` 헤더와 `Deprecation` 헤더의 차이**는? 둘 중 하나만 보내면 client 입장에서 어떤 정보가 빠지는지?
|
||||
- **enum value 추가/제거가 breaking change**가 되는 이유는? client side에 unknown enum fallback이 있을 때와 없을 때 분류가 어떻게 달라지는지?
|
||||
- **strict inbound / tolerant outbound**가 무슨 의미인지? Jackson `FAIL_ON_UNKNOWN_PROPERTIES`와 `FAIL_ON_NULL_FOR_PRIMITIVES`는 default가 어떻게 잡혀 있고, 어느 쪽을 override해야 하는지?
|
||||
- **money 직렬화에서 `BigDecimal` scale 2 + HALF_UP**을 택한 이유는? `double`이 위험한 이유, `new BigDecimal(double)` 함정, JSON string 직렬화로 client 부동소수 손실을 회피하는 이유를 설명할 수 있는지?
|
||||
|
||||
## Do Not Overclaim
|
||||
|
||||
- "Stripe 방식이 API versioning의 표준이다"라고 말하면 안 된다 — 진영별 사례이며 외부 결제 컨슈머 규모에 특화된 trade-off다.
|
||||
- "`Sunset` 헤더만 보내면 deprecation 정책으로 충분하다"라고 말하면 안 된다 — `Deprecation` 헤더와 함께 사용해야 정합이다.
|
||||
- "OpenAPI `deprecated: true`로 표시했으니 client가 알아서 migration한다"라고 단정하면 안 된다 — schema marker는 신호일 뿐이고 실제 cutover는 migration window + contract test + compatibility fixture가 함께 강제해야 한다.
|
||||
- "Jackson default가 안전하다"고 단정하면 안 된다 — `FAIL_ON_UNKNOWN_PROPERTIES`는 strict default이지만 `FAIL_ON_NULL_FOR_PRIMITIVES`는 lenient라 null/missing primitive가 묵시적으로 0이 된다.
|
||||
- "Avro / Protobuf로 가면 schema evolution이 자동 검사된다"라고 일반화하면 안 된다 — registry 인프라(예: Confluent Schema Registry)와 wire format 변경 비용이 따라온다. 외부 REST가 JSON인 환경에서는 outbox/event 한정 도입이 현실적이다.
|
||||
- "narrow enum / 응답 필드 제거 / 필드 rename"을 "internal API니까 그냥 한다"라고 정당화하면 안 된다 — client가 deploy lag을 가지면 internal에서도 breaking이다.
|
||||
|
||||
## Sources
|
||||
|
||||
### 공식 표준 / 표준 후보
|
||||
|
||||
- [[raw/official-docs/compat-rfc-8594-sunset-header]] — IETF RFC 8594 (HTTP `Sunset` header)
|
||||
- [[raw/official-docs/sunset-deprecation-headers-paired-usage]] — IETF RFC 8594 + Deprecation draft paired 사용 권고 (Sunset 단독 금지)
|
||||
- [[raw/official-docs/api-versioning-google-aip-180]] — Google AIP-180 (Backwards compatibility 분류)
|
||||
- [[raw/official-docs/schema-jackson-unknown-field-handling]] — Jackson DeserializationFeature default
|
||||
- [[raw/official-docs/schema-bigdecimal-money-serialization-java]] — Java BigDecimal scale/HALF_UP + JSON string 직렬화
|
||||
- [[raw/official-docs/schema-avro-evolution-rules]] — Avro backward/forward/full compatibility
|
||||
- [[raw/official-docs/schema-protobuf-vs-json-evolution]] — Protobuf `reserved` field semantics
|
||||
- [[raw/official-docs/protobuf-reserved-vs-json-openapi-extension]] — Protobuf `reserved` 시맨틱의 JSON/OpenAPI 환경 흉내 대안 비교 (G-F follow-up, needs-confirmation)
|
||||
- [[raw/official-docs/rfc9110-http-semantics]] — IETF RFC 9110 (HTTP Semantics): conditional request(ETag/If-Match/If-None-Match/304/412), transport error(406/413/414/415/405+Allow), HEAD/OPTIONS, 202+Retry-After, Vary
|
||||
- [[raw/official-docs/rfc9111-http-caching]] — IETF RFC 9111 (HTTP Caching): `no-store`/`private`/`public`/`max-age` directive
|
||||
- [[raw/official-docs/openapi-spec-3-1-0]] — OpenAPI 3.1.0 (machine-readable HTTP API contract, JSON Schema 2020-12 정합)
|
||||
- [[raw/official-docs/google-aip-185-resource-versioning]] — Google AIP-185 (major-only `/v1` path versioning)
|
||||
- [[raw/official-docs/google-aip-158-pagination]] — Google AIP-158 (page token opacity + size cap + EoC)
|
||||
- [[raw/official-docs/jsonapi-pagination-format]] — JSON:API pagination link key/위치
|
||||
- [[raw/official-docs/google-aip-151-long-running-operations]] — Google AIP-151 (LRO Operation shape + polling)
|
||||
- [[raw/official-docs/spring-data-pageable-defaults]] — Spring Data `Pageable` zero-indexed + size default + `DEFAULT_MAX_PAGE_SIZE` 2000
|
||||
|
||||
### 진영별 사례 (표준 아님)
|
||||
|
||||
- [[raw/company-tech-blogs/api-versioning-stripe-date-based]] — Stripe date-based versioning (account pin + freeze)
|
||||
- [[raw/company-tech-blogs/api-versioning-github-rest-date-header]] — GitHub `X-GitHub-Api-Version` + 24mo EOL + `410 Gone`
|
||||
|
||||
### Canonical (프로젝트 결정 사실)
|
||||
|
||||
- [[raw/project-notes/ca-skeleton-operational-contract]] §13 / §16 / §18 API Compatibility / Deprecation / §29 G-F
|
||||
- [[raw/branch-notes/feature-api-compatibility-deprecation-contract]]
|
||||
- [[raw/branch-notes/feature-schema-serialization-contract]]
|
||||
|
||||
## Cluster / 묶음
|
||||
|
||||
<!-- GENERATED: derived-blogs:start -->
|
||||
- [[wiki/blog/ca-tmpl-api-evolution-and-schema-2026-07-02]]
|
||||
<!-- GENERATED: derived-blogs:end -->
|
||||
@@ -0,0 +1,81 @@
|
||||
---
|
||||
title: ArchUnit 분석 scope — import scope(어떤 클래스가 검사되는가) vs classpath 의존성(어떻게 검사하는가)
|
||||
source_type: llm-generated
|
||||
status: draft
|
||||
confidence: medium
|
||||
tags: [archunit, clean-architecture, testing, static-analysis, jvm]
|
||||
related_projects: [ca-skeleton]
|
||||
last_reviewed: 2026-06-04
|
||||
---
|
||||
|
||||
# ArchUnit 분석 scope — import scope(어떤 클래스가 검사되는가) vs classpath 의존성(어떻게 검사하는가)
|
||||
|
||||
> Layer: `wiki/concepts/` — 일반 개념. 내 프로젝트 사실(`wiki/projects/`)은 `wiki-project-template` 사용.
|
||||
|
||||
## Summary
|
||||
|
||||
ArchUnit rule의 결과는 두 개의 독립적인 축에 의해 결정된다. (1) **import scope** — `ClassFileImporter`/`@AnalyzeClasses`가 어떤 class를 분석 대상 집합(`JavaClasses`)으로 끌어왔는가. (2) **classpath 의존성** — 그 class를 분석할 때 ArchUnit이 JVM classpath(reflection)에 의존하는가, 아니면 bytecode만 읽는가. 첫 번째 축을 잘못 잡으면 검사하려던 class가 아예 집합에 없어 rule이 *vacuous하게* 통과한다(false-negative). 두 번째 축은 대부분의 default rule에서 무관하지만 strongly-typed annotation 접근 같은 일부 ergonomics에만 영향을 준다.
|
||||
|
||||
## Standard (공식 정의)
|
||||
|
||||
- **Import 진입점**: class import의 표준 진입점은 `new ClassFileImporter().importPackages("<base-package>")`이며, JUnit 통합에서는 `@AnalyzeClasses(packages = ...)`가 같은 역할을 한다. `importPackages(...)`는 varargs라 다중 package를 받을 수 있고, "단일 root package만 가능"하다는 의미가 아니다. 출처: [[raw/official-docs/archunit-user-guide]] (ARCHUNIT-UG-C2).
|
||||
- **import은 classpath와 무관**: ArchUnit은 classpath/JAR/folder 어디서 import했는지와 무관하게 `JavaClasses`를 구성할 수 있다. 즉 import scope는 "어떤 `.class` 파일을 읽었는가"의 문제이지 "그 class가 현재 test의 classpath에 있는가"와 자동으로 같지 않다. 출처: [[raw/official-docs/archunit-conditional-on-property-3-layer-pattern]] (AUCP-C4).
|
||||
- **rule 평가는 classpath에 의존하지 않음**: ArchUnit 자체의 rule API와 default rule + syntax 조합 평가는 classpath에 의존하지 않는다. 출처: [[raw/official-docs/archunit-conditional-on-property-3-layer-pattern]] (AUCP-C4).
|
||||
- **classpath가 영향을 주는 곳**: classpath가 있으면 annotation을 `javaClass.getAnnotationOfType(CustomAnnotation.class).value()`처럼 strongly-typed로 접근할 수 있고, 없으면 `JavaAnnotation<?>` + `Object value = annotation.get("value")` 같은 untyped 접근을 써야 한다. 출처: [[raw/official-docs/archunit-conditional-on-property-3-layer-pattern]] (AUCP-C2, AUCP-C3).
|
||||
- **rule 평가 흐름**: rule은 `ArchRule` 객체로 표현되고 `myRule.check(importedClasses)` 또는 `@ArchTest`로 평가된다. `@ArchTest`가 붙은 rule은 지정된 class를 자동 import(또는 재사용)해 평가한다. 출처: [[raw/official-docs/archunit-user-guide]] (ARCHUNIT-UG-C3, ARCHUNIT-UG-C6).
|
||||
|
||||
## 한계 / 주의점
|
||||
|
||||
- **package filter가 import scope를 보장하지 않는다**: rule의 `that().resideInAPackage("..application..")`는 *이미 import된 집합 안에서* 필터링할 뿐이다. 해당 package의 class가 import scope(`@AnalyzeClasses(packages=...)` 또는 test classpath)에 애초에 없으면, 위반 코드가 존재해도 매칭 대상이 0개가 되어 rule이 통과한다. 즉 "package glob을 썼으니 그 package를 다 본다"는 착각이 가장 흔한 실패 모드다.
|
||||
- **두 가지 빈-집합 동작이 다르다**: (a) `that()` 결과가 비면 ArchUnit은 기본적으로 `failed to check any classes` 에러를 낸다 — 이때는 *눈에 보이는* 실패다. 빈 anchor module이 의도된 상태라면 `allowEmptyShould(true)`로 명시적으로 허용해야 한다. (b) 그러나 검사 대상 class가 *import scope 자체에 빠져* 있으면 ArchUnit은 그것을 "정상 평가했고 위반 0건"으로 인식해 `failed to check any classes` 에러조차 내지 않고 `BUILD SUCCESSFUL`로 통과한다 — 이 vacuous pass가 더 위험하다(에러 신호가 없으므로).
|
||||
- **`allowEmptyShould(true)`는 양날의 검**: 빈 anchor를 합법화하지만, 동시에 import scope 누락으로 인한 vacuous pass도 똑같이 통과시켜 버린다. 따라서 빈-집합 허용 정책만으로는 rule이 *실제로* 위반을 잡는지 보증할 수 없다.
|
||||
- **권장 보완**: (1) 검사 대상이 될 수 있는 module/package(예: sample·fixture)를 test의 import scope에 명시적으로 포함시킨다(예: Gradle `testImplementation project(':<sample>')`). (2) "위반을 데이터로 보는(violations-as-data)" negative fixture를 두고, 의도된 위반 class에 대해 `rule.evaluate(fixtureClasses).hasViolation() == true`를 별도 test로 assert해 rule이 진짜 catch하는지 commit으로 보증한다.
|
||||
- **classpath 의존성과 import scope를 혼동하지 말 것**: "classpath에 없어서 못 잡았다"와 "import scope에 안 넣어서 못 잡았다"는 다른 문제다. 전자는 주로 annotation ergonomics(typed accessor)에만 영향을 주고, false-negative의 실제 원인은 거의 항상 후자(import scope 누락)다. 두 축을 섞어 진단하면 엉뚱한 곳을 고친다. (이 구분의 정밀한 경계는 ArchUnit 버전·import 옵션에 따라 달라질 수 있어 `needs-confirmation`)
|
||||
|
||||
## Project Application
|
||||
|
||||
이 개념과 관련된 내 프로젝트 사실·검증 등급은 아래 project 문서에서 판정한다(concept 문서는 등급을 직접 매기지 않는다).
|
||||
|
||||
- [[wiki/projects/ca-tmpl/clean-architecture-package-layout]] — ca-tmpl의 `CleanArchitectureTest`가 `@AnalyzeClasses(packages = "dev.caskeleton", importOptions = DoNotIncludeTests.class)`로 import scope를 잡고, `allowEmptyShould(true)`로 빈 anchor를 허용하며, `ArchitectureViolationFixtureTest`(violations-as-data)로 각 rule의 catch 동작을 보증하는 실제 적용.
|
||||
- [[wiki/concepts/clean-architecture-package-layout]] — 경계 강제(enforcement)의 두 축(build-graph 검사 vs source/bytecode import 검사) 일반 지식.
|
||||
|
||||
## Claim-backed Knowledge
|
||||
|
||||
> 이 개념 문서의 핵심 설명은 raw source claim으로 뒷받침되어야 한다. 공식 문서 claim과 내 프로젝트 트러블슈팅 사실을 분리한다.
|
||||
|
||||
| Knowledge Point | Supporting Claims | Confidence | Notes |
|
||||
|---|---|---|---|
|
||||
| import 진입점은 `ClassFileImporter().importPackages(...)`이며 varargs로 다중 package 가능(단일 root 강제 아님) | `raw/official-docs/archunit-user-guide.md#ARCHUNIT-UG-C2` | high | 공식 vendor doc |
|
||||
| ArchUnit rule API/default rule 평가는 classpath(reflection)에 의존하지 않으며, classpath/JAR/folder 어디서 import했는지와 무관 | `raw/official-docs/archunit-conditional-on-property-3-layer-pattern.md#AUCP-C4` | high | 공식 vendor doc. "import scope ≠ classpath presence"의 근거 |
|
||||
| annotation 접근 ergonomics만 classpath에 의존(있으면 typed `.value()`, 없으면 untyped `JavaAnnotation.get("value")`) | `raw/official-docs/archunit-conditional-on-property-3-layer-pattern.md#AUCP-C2`, `#AUCP-C3` | high | classpath가 영향을 주는 *유일한* 좁은 지점 |
|
||||
| rule은 `ArchRule.check(classes)` / `@ArchTest`로 평가되고, `@ArchTest`는 지정 class를 자동 import해 평가 | `raw/official-docs/archunit-user-guide.md#ARCHUNIT-UG-C3`, `#ARCHUNIT-UG-C6` | high | 공식 vendor doc |
|
||||
| `that()` 매칭 결과가 비면 기본적으로 `failed to check any classes` 실패 — 빈 anchor가 의도면 `allowEmptyShould(true)` 필요 | `raw/errors/archunit-empty-should-anchor-2026-05-27.md` | medium | 프로젝트 트러블슈팅 사실(`error-note`). 빈 *should* 동작 |
|
||||
| 검사 대상 class가 import scope에 빠지면 위반이 있어도 vacuous pass(`BUILD SUCCESSFUL`, 에러 신호 없음) — sample/fixture를 test import scope에 포함 + negative fixture로 보완 | `raw/errors/archunit-test-scope-sample-ticket-inclusion-2026-05-28.md` | medium | 프로젝트 트러블슈팅 사실(`error-note`). 빈 *that* / import-scope 누락 동작 |
|
||||
|
||||
## 내가 설명할 수 있어야 하는 것
|
||||
|
||||
- ArchUnit의 import scope와 classpath 의존성은 각각 무엇을 결정하는가?
|
||||
- package glob(`..application..`)을 썼는데도 위반을 놓치는 경우는 왜 생기는가?
|
||||
- `failed to check any classes` 에러가 *나는* 경우와 *나지 않고 통과해 버리는* 경우의 차이는 무엇인가?
|
||||
- `allowEmptyShould(true)`는 무엇을 허용하고, 무엇을 *못* 막는가?
|
||||
- vacuous pass를 어떻게 commit 수준에서 막는가(violations-as-data)?
|
||||
|
||||
## Interview Questions
|
||||
|
||||
- ArchUnit rule이 통과했는데도 실제로는 boundary가 깨져 있을 수 있는 시나리오는? 어떻게 방지하는가?
|
||||
- ArchUnit의 분석이 JVM classpath에 의존하는 부분과 의존하지 않는 부분은 각각 무엇인가?
|
||||
- 빈 anchor package가 많은 skeleton에서 architecture test를 신뢰 가능하게 유지하려면 무엇이 필요한가?
|
||||
|
||||
## Do Not Overclaim
|
||||
|
||||
- "package glob을 쓰면 그 package의 모든 class를 검사한다"는 단정 금지. 검사 대상은 *import scope ∩ glob*이며, scope에 없으면 검사되지 않는다.
|
||||
- "ArchUnit은 classpath가 필요하다/필요 없다"는 단정 금지. default rule 평가는 classpath 독립이지만 typed annotation 접근 같은 ergonomics는 classpath에 의존한다 — 부분적이다.
|
||||
- "`allowEmptyShould(true)`를 켜면 안전하다"는 단정 금지. 빈 should를 허용할 뿐, import scope 누락으로 인한 vacuous pass는 막지 못한다.
|
||||
- 위 빈-집합/scope 동작의 정밀한 경계는 ArchUnit 버전·import 옵션에 따라 달라질 수 있어 일부는 `needs-confirmation`이다.
|
||||
|
||||
## Sources
|
||||
|
||||
- [[raw/official-docs/archunit-user-guide]] — ArchUnit User Guide (import 진입점, rule 평가, JUnit 통합)
|
||||
- [[raw/official-docs/archunit-conditional-on-property-3-layer-pattern]] — classpath 유무에 따른 annotation 접근 + rule API의 classpath 독립성
|
||||
- [[raw/errors/archunit-empty-should-anchor-2026-05-27]] — 빈 anchor에서의 `failed to check any classes` + `allowEmptyShould` 해결(프로젝트 사실)
|
||||
- [[raw/errors/archunit-test-scope-sample-ticket-inclusion-2026-05-28]] — import scope 누락으로 인한 vacuous pass + sample module을 test scope에 포함해 해결(프로젝트 사실)
|
||||
@@ -0,0 +1,86 @@
|
||||
---
|
||||
title: 경계 검증과 DTO↔도메인 매핑 (Bean Validation · MapStruct vs 수기 mapper · Patch partial-update)
|
||||
source_type: llm-generated
|
||||
status: draft
|
||||
confidence: medium
|
||||
tags: [backend, validation, mapper, dto, bean-validation, boundary]
|
||||
related_projects: [ca-skeleton, ca-tmpl]
|
||||
last_reviewed: 2026-06-04
|
||||
---
|
||||
|
||||
# 경계 검증과 DTO↔도메인 매핑
|
||||
|
||||
> Layer: `wiki/concepts/` — 일반 개념. 내 프로젝트 사실은 [[wiki/projects/ca-tmpl/boundary-validation-mapping]] 참조.
|
||||
|
||||
## Summary
|
||||
|
||||
웹 애플리케이션의 **입력 경계**(request boundary)에서는 두 가지 책임이 동시에 생긴다: (1) 들어온 데이터가 형식적으로 올바른지 **검증**하고, (2) 외부 표현(DTO)을 내부 모델(domain / command)로 **변환(mapping)** 하는 것이다.
|
||||
|
||||
- **Bean Validation (Jakarta Validation, JSR 380 / 3.0)**: `@NotNull`, `@Size`, `@Valid` 같은 선언적 제약을 DTO 필드/메서드에 붙여 프레임워크가 자동 검증하게 하는 표준. Spring MVC 는 컨트롤러 파라미터에 `@Valid`/`@Validated` 가 붙으면 본문 바인딩 직후 검증을 수행하고, 실패 시 `MethodArgumentNotValidException` 을 던진다.
|
||||
- **validation-at-boundary 원칙**: 검증은 가능한 한 *입력 경계 한 곳* 에서 fail-fast 로 끝내고, 안쪽 레이어(application/domain)는 이미 검증된 값만 받는다는 설계. 단, 형식(syntax) 검증과 도메인 불변식(invariant) 검증은 책임이 다르므로 같은 어노테이션 한 줄로 뭉뚱그리지 않는다.
|
||||
- **DTO↔domain mapping**: 외부에 노출되는 DTO 와 내부 도메인 객체를 분리하고 그 사이를 변환하는 코드. 변환 도구는 **수기(manual) mapper** 와 **MapStruct 같은 코드 생성기(generator)** 두 갈래가 있다.
|
||||
- **partial-update (PATCH) semantics**: PATCH 요청에서 "필드 없음(absent) / 명시적 null / 값 있음" 세 상태를 구분해야 silent overwrite 를 막을 수 있다.
|
||||
|
||||
## Standard (공식 정의)
|
||||
|
||||
- **Jakarta Bean Validation 3.0** (official-standard): class-level constraint 는 "한 클래스의 여러 property 를 동시에 보는 상태 검증"을 위한 것이고(JBV-3.0-C1), `ConstraintValidator` 는 클래스 인스턴스를 받아 여러 필드에 동시 접근할 수 있다(JBV-3.0-C2). `@GroupSequence` 를 쓰면 group 을 순서대로 실행하다 한 group 이 실패하면 **다음 group 을 건너뛴다(short-circuit)** — syntax 검증을 먼저 통과해야 invariant 검증이 돈다는 패턴의 normative 근거(JBV-3.0-C3). `@Valid` 는 중첩 객체로 검증을 **cascade(전파)** 시킨다(JBV-3.0-C4). 단, Bean Validation 자체는 syntax/invariant 라는 **레이어 이름을 정의하지 않는다** — 그 분류는 애플리케이션 설계 결정이다.
|
||||
- **Spring MVC REST exception handling** (official-vendor-doc): `HttpMessageNotReadableException`(JSON 파싱 실패) 과 `MethodArgumentNotValidException`(Bean Validation 실패) 은 모두 Spring 내장 `ErrorResponse` 구현체이고 `ResponseEntityExceptionHandler` 가 normative 하게 처리한다(SPRING-MVC-EXC-C1/C2/C4/C5). 즉 이 두 예외는 표준적으로 검증 실패(400) 카테고리로 분류된다.
|
||||
- **RFC 7396 (JSON Merge Patch)** (official-standard): merge patch 에서 `null` 값은 "해당 필드 삭제"를 의미한다(RFC7396-C2). 따라서 "명시적 null" 을 다른 의미로 쓰려는 API 는 RFC 7396 merge patch 를 그대로 채택하면 충돌한다(RFC7396-C3). 배열 부분 수정도 불가하다(RFC7396-C4).
|
||||
- **MapStruct** (도구): 컴파일 타임에 mapper 구현 코드를 생성하는 어노테이션 프로세서. 리플렉션 없이 동작하지만, 생성된 코드가 architecture 규칙(예: 도메인 직접 접근 금지)을 우회할 수 있어 별도 exemption 관리가 필요하다. (※ MapStruct 도구 선택 자체는 공식 표준이 권고하는 사항이 아니라 프로젝트 trade-off 결정이다.)
|
||||
|
||||
## 한계 / 주의점
|
||||
|
||||
- **4-layer validation 분류(syntax / policy / invariant / persistence integrity)는 표준이 아니다.** Bean Validation spec 은 이런 taxonomy 를 정의하지 않는다. 레이어를 나누는 것은 설계 결정이며, 잘못 나누면 같은 검증이 두 곳에서 중복되거나 빠진다.
|
||||
- **MapStruct vs 수기 mapper 는 정답이 없는 trade-off.** 수기 mapper 는 boilerplate 가 많지만 동작이 투명하다. MapStruct 는 코드량을 줄이지만 generated code 가 architecture 경계를 silent 하게 leak 할 수 있고, 매핑 누락이 컴파일 시점에 드러나지 않을 수 있다.
|
||||
- **PATCH 의 null/absent 혼동**은 흔한 버그다. Java record 의 기본 매핑으로 PATCH 를 구현하면 요청에 없던 필드가 `null` 로 들어와 기존 값을 덮어쓰는 silent overwrite 가 발생한다. `Optional<T>` 또는 `JsonNullable<T>`(openapi-generator) 같은 3-state wrapper 가 필요하다.
|
||||
- **검증을 경계에서만 한다고 도메인 불변식이 보장되지는 않는다.** 형식 검증(DTO)과 도메인 불변식(application/domain)은 별개다. DTO 검증만으로 "도메인이 안전하다"고 말하면 안 된다.
|
||||
- **`@Valid` cascade 의 무한/깊은 재귀**는 DoS 표면이 될 수 있다. 중첩 깊이에 상한을 두는 것은 spec 이 아니라 운영적 방어 결정이다.
|
||||
|
||||
## Project Application
|
||||
|
||||
- ca-tmpl 은 입력 경계의 검증/매핑 책임을 명시적으로 고정하고, 일부 정책을 ArchUnit fitness function 으로 정적 강제했다. 구체적 구현 사실·검증 등급은 [[wiki/projects/ca-tmpl/boundary-validation-mapping]] 참조.
|
||||
- 관련 트랜잭션 경계 추상화는 [[wiki/concepts/transaction-boundary-abstraction]] / [[wiki/projects/ca-tmpl/transaction-boundary-abstraction]].
|
||||
|
||||
## Claim-backed Knowledge
|
||||
|
||||
> 아래는 본 개념을 뒷받침하는 raw official-doc claim 인용. company-tech-blog 는 사례일 뿐 공식 best practice 로 격상하지 않는다.
|
||||
|
||||
| Knowledge Point | Supporting Claims | Confidence | Notes |
|
||||
|---|---|---|---|
|
||||
| class-level constraint 는 한 클래스의 여러 property 상태를 함께 검증한다 | [[raw/official-docs/validation-jakarta-bean-validation-3.0-spec]] JBV-3.0-C1 | high | constraint 의 *목적* 근거. syntax/invariant 레이어 이름은 spec 미규정 |
|
||||
| `@GroupSequence` 는 group 을 순차 실행하다 실패 시 후속 group 을 short-circuit 한다 | [[raw/official-docs/validation-jakarta-bean-validation-3.0-spec]] JBV-3.0-C3 | high | syntax→invariant 단계 분리 패턴의 normative 근거 |
|
||||
| `@Valid` 는 중첩 객체로 검증을 cascade 한다 | [[raw/official-docs/validation-jakarta-bean-validation-3.0-spec]] JBV-3.0-C4 | high | cascade *메커니즘* 근거. depth 상한은 설계 결정 (spec 미규정) |
|
||||
| `HttpMessageNotReadableException` 은 Spring 이 normative 하게 처리하는 내장 예외 | [[raw/official-docs/spring-mvc-rest-exception-handling]] SPRING-MVC-EXC-C4 | high | JSON 파싱 실패 → 검증(400) 분류 근거 |
|
||||
| `MethodArgumentNotValidException` 은 field error 를 담아 normative 처리된다 | [[raw/official-docs/spring-mvc-rest-exception-handling]] SPRING-MVC-EXC-C5 | high | Bean Validation 실패 → 검증(400) + field error shape 근거 |
|
||||
| JSON Merge Patch 의 `null` 은 필드 삭제를 의미한다 | [[raw/official-docs/patch-json-merge-rfc7396]] RFC7396-C2 | high | PATCH 에서 null/absent 구분이 필요한 이유. ca-tmpl 은 merge patch *미채택* |
|
||||
|
||||
## 내가 설명할 수 있어야 하는 것
|
||||
|
||||
- Bean Validation 의 `@Valid`/`@Validated`/`@GroupSequence` 가 각각 무엇이고, syntax 검증과 도메인 invariant 검증을 왜 분리하는가.
|
||||
- `MethodArgumentNotValidException` 과 `HttpMessageNotReadableException` 이 왜 둘 다 "검증 실패(400)" 로 분류되는가, mapper 내부 예외는 왜 별도 카테고리가 필요한가.
|
||||
- DTO↔domain mapping 에서 MapStruct 와 수기 mapper 의 trade-off (boilerplate vs architecture leak / 컴파일 안전성).
|
||||
- PATCH 의 absent / explicit-null / value 3-state 를 구분하지 않으면 어떤 버그(silent overwrite)가 생기는가, `Optional`/`JsonNullable` 로 어떻게 구분하는가.
|
||||
- RFC 7396 merge patch 의 null=deletion semantics 와, 이를 채택하지 않는 API 가 왜 `application/merge-patch+json` content type 을 쓰면 안 되는가.
|
||||
|
||||
## Interview Questions
|
||||
|
||||
- "request 검증을 어디서 하나요? 컨트롤러? 서비스? 도메인?" → 형식 검증은 경계(DTO), 도메인 불변식은 application/domain. 한 줄 어노테이션으로 다 끝낸다는 답은 위험.
|
||||
- "`@Valid` 와 `@Validated` 차이는?" → `@Validated` 는 Spring 의 group 지원 + 메서드 레벨 검증, `@Valid` 는 표준 cascade.
|
||||
- "PATCH 에서 어떤 필드만 바꾸고 싶을 때 null 을 어떻게 처리하나요?" → absent vs explicit-null 구분, 3-state wrapper.
|
||||
- "DTO 와 도메인 객체를 왜 분리하나요? MapStruct 와 수기 매핑 중 무엇을 쓰나요?" → 노출 경계 분리 + 도구 trade-off.
|
||||
|
||||
## Do Not Overclaim
|
||||
|
||||
- **"Bean Validation 이 syntax/invariant 를 알아서 나눠준다" → 금지.** spec 은 레이어를 정의하지 않는다. `@GroupSequence` 로 *순서* 는 줄 수 있지만 분류는 설계자가 한다.
|
||||
- **"MapStruct 가 수기 mapper 보다 우월하다" → 금지.** generated code 의 architecture leak / 매핑 누락 trade-off 가 있다.
|
||||
- **"DTO 검증을 했으니 도메인이 안전하다" → 금지.** 형식 검증과 도메인 불변식은 별개.
|
||||
- **"PATCH 의 null 은 항상 삭제다(RFC 7396)" → 단정 금지.** RFC 7396 의 정의일 뿐, 이를 채택하지 않는 API 도 많다.
|
||||
|
||||
## Sources
|
||||
|
||||
- [[raw/official-docs/validation-jakarta-bean-validation-3.0-spec]] — Jakarta Bean Validation 3.0 normative (class-level constraint, group sequence, `@Valid` cascade)
|
||||
- [[raw/official-docs/spring-mvc-rest-exception-handling]] — Spring MVC `ResponseEntityExceptionHandler` 처리 예외 목록 (`HttpMessageNotReadableException` / `MethodArgumentNotValidException`)
|
||||
- [[raw/official-docs/patch-json-merge-rfc7396]] — RFC 7396 JSON Merge Patch (null=deletion semantics)
|
||||
- [[raw/official-docs/schema-jackson-polymorphic-deserialization]] — Jackson polymorphic deserialization 보안 지침 (allowlist, CVE-2019-14379) — 경계 역직렬화 보안 맥락
|
||||
- [[raw/branch-notes/feature-boundary-validation-mapping-contract]] — 본 개념을 도출한 ca-tmpl 경계 검증/매핑 계약 branch
|
||||
- [[wiki/projects/ca-tmpl/boundary-validation-mapping]] — 내 프로젝트 적용 사실
|
||||
@@ -0,0 +1,64 @@
|
||||
---
|
||||
title: concept / Circuit Breaker
|
||||
source_type: llm-generated
|
||||
status: reviewed
|
||||
confidence: high
|
||||
tags: [concept, ca-tmpl, architecture, spring-boot, circuit-breaker]
|
||||
related_projects: [ca-tmpl]
|
||||
last_reviewed: 2026-06-15
|
||||
---
|
||||
|
||||
# concept / Circuit Breaker
|
||||
|
||||
## Summary
|
||||
|
||||
외부 서비스(의존성) 호출의 실패율을 감시하여, 실패율이 임계치를 초과하면 연동을 즉시 차단(OPEN)함으로써 시스템 전체로 장애가 전파되는 것을 차단하고 빠른 실패(Fail-Fast)를 유도하는 리질리언스 패턴.
|
||||
|
||||
## Standard (공식 정의)
|
||||
|
||||
서킷 브레이커는 크게 세 가지 상태를 가지며, 유한 상태 머신(FSM)으로 동작한다.
|
||||
- **CLOSED**: 정상 상태. 모든 요청을 외부 서비스로 통과시킨다. 최근 N개 호출(Count-Based) 또는 T초간 호출(Time-Based)의 실패율을 측정한다.
|
||||
- **OPEN**: 차단 상태. 외부 서비스로 요청을 보내지 않고 즉시 예외(CallNotPermittedException)를 던져 빠른 실패를 유도한다. 특정 대기 시간(Wait Duration)이 지나면 HALF_OPEN 상태로 전이한다.
|
||||
- **HALF_OPEN**: 감시 통과 상태. 설정된 횟수만큼 제한된 요청을 외부로 전송하여 성공 여부를 측정한다. 만약 재발한 실패율이 임계치 이하면 CLOSED로 복귀하고, 또다시 임계치를 초과하면 OPEN으로 회귀한다.
|
||||
|
||||
## 한계 / 주의점
|
||||
|
||||
- **지표 누수(Metric Cardinality Explosion)**: Resilience4j 등 라이브러리는 기본적으로 매우 세부적인 게이지와 카운터 지표(예: slow call rate, buffered calls 등)를 대량 방출한다. 이를 모니터링 시스템(Prometheus 등)에 그대로 전송하면 시계열 데이터 개수가 급증하여 저장소 과부하를 초래한다. 실무에서는 엄격히 합의된 저카디널리티(low-cardinality) 필수 지표만 필터링하여 통과시켜야 한다.
|
||||
- **Retry와의 충돌**: 서킷 브레이커와 리트라이를 무작정 함께 배치하면, 하나의 외부 요청 실패가 리트라이 3회로 증폭되어 서킷 브레이커가 오작동하거나 윈도우 슬라이딩의 실패율이 왜곡될 수 있다.
|
||||
|
||||
## Project Application
|
||||
|
||||
- [[wiki/explainer/adapter-outbound.md]]
|
||||
- `OutboundHttpResilience`에서 각 의존성별로 독립된 `CircuitBreaker`와 `Retry`를 구성함.
|
||||
- `OutboundHttpResilienceConfig`에서 D3/D4 가이드라인을 강제하여:
|
||||
- 리질리언스를 켤 때 지표 수집기(`MeterRegistry`)가 없으면 애플리케이션 기동을 에러로 즉시 차단(Activation Guard).
|
||||
- Prometheus 지표 수집을 위해 `resilience4j.retry.calls`, `resilience4j.circuitbreaker.calls`, `resilience4j.circuitbreaker.state` 딱 3가지 필수 지표만 허용하고 나머지는 강제 차단(Deny Filter)함.
|
||||
- 가시성을 높이기 위해 벤더 사양의 태그를 `outcome` (SUCCESS/FAILURE) 및 대문자 `state` (CLOSED, OPEN, HALF_OPEN)로 정형화(Metric Normalisation)하여 바인딩함.
|
||||
|
||||
## Claim-backed Knowledge
|
||||
|
||||
| Knowledge Point | Supporting Claims | Confidence | Notes |
|
||||
|---|---|---|---|
|
||||
| 서킷 브레이커의 표준 구조 및 Resilience4j 사양 | `raw/official-docs/outbound-resilience4j-vs-spring-retry.md` | `high` | Resilience4j 공식 사양 |
|
||||
| 지표 카디널리티 폭발 문제 및 모니터링 필터링 규칙 | `raw/official-docs/resilience4j-micrometer-module.md` | `high` | Micrometer 통합 모범 사례 |
|
||||
|
||||
## 내가 설명할 수 있어야 하는 것
|
||||
|
||||
- 서킷 브레이커의 세 가지 상태와 그 전이 조건은 무엇인가?
|
||||
- 왜 리트라이와 서킷 브레이커를 결합할 때 데코레이팅 순서가 중요한가? (CB가 Retry의 바깥쪽에 위치해야 각 재시도 실패가 개별적으로 서킷 실패율에 반영되지 않고 전체 실패로 깔끔하게 묶이거나, 혹은 구조에 따라 왜곡이 발생할 수 있음을 알아야 한다.)
|
||||
- 카디널리티 폭발(Metric Cardinality Explosion)이란 무엇이며, 우리 프로젝트는 이를 어떻게 대처했는가?
|
||||
|
||||
## Interview Questions
|
||||
|
||||
- 마이크로서비스 환경에서 서킷 브레이커의 필요성과 작동 방식(FSM)을 설명하십시오.
|
||||
- 서킷 브레이커를 적용한 후 모니터링 시스템의 시계열 부하(Cardinality)가 급증하는 문제를 해결하기 위해 구체적으로 어떤 조치를 취할 수 있습니까?
|
||||
|
||||
## Do Not Overclaim
|
||||
|
||||
- "서킷 브레이커가 동작하면 분산 시스템의 네트워크 순단에 대비해 무조건 가용성이 높아진다"고 단정하면 안 된다. 서킷이 열려 있는(OPEN) 동안은 정상 요청조차 즉시 거절되므로, 가용성은 일시적으로 0이 된다. 서킷 브레이커의 목표는 가용성 향상뿐 아니라 **호출 측의 스레드 고갈 방지 및 업스트림 서버 보호**임을 명시해야 한다.
|
||||
|
||||
## Sources
|
||||
|
||||
- [Resilience4j CircuitBreaker Core Guide](https://resilience4j.readme.io/docs/circuitbreaker)
|
||||
- [[raw/official-docs/outbound-resilience4j-vs-spring-retry.md]]
|
||||
- [[raw/official-docs/resilience4j-micrometer-module.md]]
|
||||
@@ -0,0 +1,124 @@
|
||||
---
|
||||
title: Clean Architecture 패키지 레이아웃 (feature-first vs layer-first vs hexagonal vs modulith vs onion)
|
||||
source_type: llm-generated
|
||||
status: draft
|
||||
confidence: medium
|
||||
tags: [clean-architecture, package-layout, hexagonal, modulith]
|
||||
related_projects: [ca-skeleton]
|
||||
last_reviewed: 2026-05-22
|
||||
---
|
||||
|
||||
# Clean Architecture 패키지 레이아웃 (feature-first vs layer-first vs hexagonal vs modulith vs onion)
|
||||
|
||||
> Layer: `wiki/concepts/` — 일반 개념. 내 프로젝트 사실은 `project-template` 사용.
|
||||
|
||||
## Summary
|
||||
|
||||
feature-first 패키지 레이아웃은 최상위를 도메인 feature(`features/{name}/`)로 자르고 그 내부에 `presentation/application/domain/infrastructure`를 두는 구조로, 각 feature가 자체 inbound/outbound adapter와 application core를 갖는다는 점에서 본질적으로 "feature 단위로 잘린 mini-Hexagonal"과 동형이다. layer-first는 최상위가 기술 계층이고 도메인이 그 안에 흩어지는 점에서 응집도 축이 정반대다.
|
||||
|
||||
## Standard (공식 정의)
|
||||
|
||||
- **Uncle Bob, Screaming Architecture (2011)**: 시스템의 최상위 디렉터리는 사용된 framework이 아니라 시스템이 "외치는" use case / business 영역이어야 한다고 주장. controller/service/repository로 자르는 layer-first는 framework가 외치는 구조라는 점을 비판한다. 출처: [[raw/official-docs/feature-first-uncle-bob-screaming-architecture-2011]].
|
||||
- **Cockburn, Hexagonal (Ports and Adapters)**: 응용 코어(application + domain)를 inbound adapter(driving)와 outbound adapter(driven)로부터 port interface로 격리. driving/driven adapter 분리가 본질이며 패키지 형태 자체는 비강제. 출처: [[raw/official-docs/hexagonal-cockburn-wikipedia-summary]].
|
||||
- **Thombergs, BuckPal reference**: Cockburn Hexagonal을 자바/스프링 부트로 구현한 reference. 최상위가 feature이고 내부에 `domain/application/adapter(in|out)` 3-tier로 잘려 feature-first + Hexagonal이 같은 구조에서 만난다는 점을 보여줌. 출처: [[raw/official-docs/hexagonal-thombergs-buckpal-github]].
|
||||
- **Palermo, Onion Architecture (2008)**: 의존성은 외부 layer(infrastructure/UI)에서 내부 layer(domain model)로만 향하며, 안쪽이 바깥쪽 interface를 알지 않는다는 의존성 역전 규칙. layer를 동심원으로 표현. 출처: [[raw/official-docs/onion-palermo-original-2008]].
|
||||
- **Spring Modulith (공식 문서)**: Spring Boot 위에서 패키지 자체가 모듈 경계가 되며 `@ApplicationModule`/named-interface로 cross-module 접근을 강제. JPA event SPI 위에서 transactional event publication 등 운영 contract를 framework가 제공. 출처: [[raw/official-docs/modulith-spring-official-doc]].
|
||||
|
||||
## 한계 / 주의점
|
||||
|
||||
각 레이아웃은 다른 트레이드오프를 가진다.
|
||||
|
||||
- **feature-first**
|
||||
- cross-feature shared kernel(공통 value object, 공통 정책)을 어디에 둘지가 모호. `common/`을 두되 business concept가 새지 않도록 별도 규칙이 필요.
|
||||
- 도메인 인접성이 강한 feature 사이에서 model 중복 위험(같은 개념을 두 feature가 따로 정의).
|
||||
- feature 사이 호출은 직접 import보다는 port 또는 명시적 application API를 통해 통제해야 함 (그렇지 않으면 사실상 layer-first로 회귀).
|
||||
|
||||
- **layer-first**
|
||||
- 도메인 수가 늘어나면 같은 도메인의 코드가 `controller/`, `service/`, `repository/`에 흩어져 응집도가 폭락. 한 도메인을 수정할 때 패키지 3~4곳을 동시에 건드림. Sahibinden 기술블로그는 이를 "패키지가 도메인을 외치지 않는다"로 비판함. 출처: [[raw/company-tech-blogs/feature-first-sahibinden-package-by-layer-vs-feature]].
|
||||
- Baeldung식 Clean Architecture Spring Boot 가이드는 입문 학습 비용이 가장 낮지만 결과적으로 도메인 응집을 보장하지 않음. 출처: [[raw/official-docs/layer-first-baeldung-clean-architecture-spring-boot]], [[raw/company-tech-blogs/layer-first-kamilmazurek-github-template]].
|
||||
|
||||
- **hexagonal pure (feature 슬라이스 없음)**
|
||||
- 최상위가 `application/domain/adapter`로만 잘리고 feature 슬라이스가 없으면 도메인이 늘어날수록 `application`과 `domain` 패키지가 비대해짐.
|
||||
- inbound/outbound 분리는 명확하지만 도메인 간 boundary가 약함. 우아한형제들 기술블로그의 Hexagonal 적용도 결국 도메인별 module로 분리하는 방향으로 진화. 출처: [[raw/company-tech-blogs/hexagonal-woowahan-techblog-2023]].
|
||||
|
||||
- **Spring Modulith**
|
||||
- Spring Framework / Spring Boot 종속. framework-neutral 도메인을 외부 강제로 보호하기 어려움 (도메인까지 Spring scan에 들어옴).
|
||||
- transactional event publication은 JPA event SPI에 의존하는 구현체가 다수라 persistence 선택에 영향. 카카오뱅크 수신상품 사례는 Modulith가 "느슨한 modular monolith"의 좋은 진화 경로임을 보여주지만 framework lock-in 비용을 수반. 출처: [[raw/company-tech-blogs/modulith-kakaobank-techblog-2025]], [[raw/company-tech-blogs/modulith-arawn-github-modular-monoliths-spring]].
|
||||
- Spring Modulith 공식 문서는 module boundary 위반을 verification API로 잡지만 빌드 실패 강제 여부는 적용 프로젝트의 CI 설정에 의존. 출처: [[raw/official-docs/modulith-spring-official-doc]].
|
||||
|
||||
- **onion**
|
||||
- 의존성 방향 규칙은 Hexagonal과 동등 (안쪽으로만 의존).
|
||||
- 그러나 boundary verification 도구가 framework 자체로는 제공되지 않음. ArchUnit 같은 별도 정적 분석 없이는 layer 우회를 build-time에 잡기 어려움. Allegro 기술블로그도 onion의 이상은 인정하면서 실제 강제는 별도 도구가 필요하다고 명시. 출처: [[raw/company-tech-blogs/onion-allegro-tech-blog-2023]].
|
||||
|
||||
5종 모두 "의존성은 안쪽으로만"이라는 동일한 핵심 원칙을 공유하며, 차이는 (a) 최상위 자름의 기준(feature vs layer) (b) framework가 boundary를 강제하는지 (c) inbound/outbound adapter 명시 여부에 있다.
|
||||
|
||||
### 경계를 *강제*하는 방법 (enforcement)
|
||||
|
||||
레이아웃을 고른 것만으로 경계가 지켜지지 않는다. 어느 레이아웃이든 boundary drift를 막으려면 별도의 강제 수단이 필요하며, 일반적으로 두 축으로 나뉜다.
|
||||
|
||||
- **Build-graph 검사**: multi-module 빌드에서 module 간 허용 dependency를 화이트리스트로 두고, 허용 외 module dependency 선언 시 빌드를 실패시킨다(예: Gradle custom verification task). module 경계 자체가 1차 방어선이 된다.
|
||||
- **Source/bytecode import 검사**: ArchUnit 같은 정적 분석 도구로 package/class 레벨 import·call·annotation을 검사한다. "`..domain..`은 `org.springframework..`에 의존 금지", "특정 class(예: `ApplicationContext`) 의존 금지(banned-class)", "특정 annotation 사용 금지", "DTO는 web adapter 안에서만 접근" 같은 fitness function을 test로 강제한다.
|
||||
|
||||
정적 분석의 한계는 분명하다. import/call/annotation은 bytecode에 남지만, runtime container lookup(`ApplicationContext.getBean(String)` 같은 string-key 조회), `Class.forName(String)` reflection, classloader 우회는 bytecode가 *문자열 내용*을 노출하지 않으므로 catch할 수 없다. class-literal `getBean(Class<T>)`까지는 method-call target으로 잡히지만 string-key 변종은 false-negative가 되며, 이 영역은 code review·runtime 검증(Actuator `/beans`, Modulith verifier 등)으로만 보완 가능하다. 또 ArchUnit의 `should()` 조건이 매칭 대상이 0개인 빈 module에서 vacuous하게 통과하는 empty-anchor 함정이 있어, `allowEmptyShould` 정책과 "위반을 데이터로 보는(violations-as-data)" negative fixture로 rule이 실제로 catch하는지 별도 보증하는 패턴이 쓰인다. ArchUnit 분석 scope(classpath import vs package filter)와 empty-should 함정의 일반 지식은 [[wiki/concepts/archunit-scope-classpath-vs-package-filter]] 참조.
|
||||
|
||||
## Claim-backed Knowledge
|
||||
|
||||
> 인용 가능한 출처가 직접 뒷받침하는 일반 지식만 둔다. "어느 레이아웃이 옳다"는 추론·취향은 §한계 / 주의점과 §Do Not Overclaim에서 다룬다.
|
||||
|
||||
| Knowledge Point | Supporting Claims | Confidence | Notes |
|
||||
|---|---|---|---|
|
||||
| 최상위 디렉터리는 framework가 아니라 use case / business 영역을 드러내야 한다(layer-first 비판) | [[raw/official-docs/feature-first-uncle-bob-screaming-architecture-2011]] | medium | Uncle Bob Screaming Architecture (2011), `engineering-blog` — 공식 표준이 아닌 영향력 있는 블로그 주장 |
|
||||
| Hexagonal의 본질은 응용 코어를 inbound(driving)/outbound(driven) adapter로부터 port interface로 격리하는 것이며 package 형태 자체는 비강제 | [[raw/official-docs/hexagonal-cockburn-wikipedia-summary]] | medium | Cockburn Ports & Adapters, `engineering-blog` |
|
||||
| 의존성은 외부 layer(infra/UI)→내부 layer(domain model) 방향으로만 향하고 안쪽은 바깥쪽 interface를 알지 않는다 | [[raw/official-docs/onion-palermo-original-2008]] | medium | Palermo Onion (2008), `engineering-blog` |
|
||||
| Spring Modulith는 package를 module 경계로 삼고 `@ApplicationModule`/named-interface로 접근을 강제하나, 위반의 build 실패 강제 여부는 적용 프로젝트 CI 설정에 의존(framework는 verification API만 제공) | [[raw/official-docs/modulith-spring-official-doc]] | high | 공식 문서. build 실패는 자동이 아님 |
|
||||
| onion/hexagonal 의존성 방향 규칙은 framework 자체로 build-time 강제되지 않으며, ArchUnit 등 별도 정적 분석 없이는 layer 우회를 빌드 시점에 잡기 어렵다 | [[raw/company-tech-blogs/onion-allegro-tech-blog-2023]] | medium | `company-tech-blog` 관점 — 공식 best practice로 승격 금지 |
|
||||
| 도메인 수가 늘면 layer-first에서 한 도메인 코드가 controller/·service/·repository/에 흩어져 응집도가 떨어진다 | [[raw/company-tech-blogs/feature-first-sahibinden-package-by-layer-vs-feature]] | medium | `company-tech-blog` 사례 |
|
||||
|
||||
## Project Application
|
||||
|
||||
- [[wiki/projects/ca-tmpl/clean-architecture-package-layout]] — ca-tmpl package/module blueprint + enforcement-rules 적용 기록. Gradle multi-module boundary(8 module, production root `dev.caskeleton`)와 ArchUnit/Gradle guardrail은 `locally-verified`(2026-06-04 ground-truth 대조). enforcement dimension: `domain_is_pure`(Lombok ban 포함), application↔adapter 격리, `ApplicationContext` banned-class rule(D11, string-key bypass는 한계), `verifyCleanArchitectureDependencies` build-graph 검사, violations-as-data negative fixture를 기록. `sample-portfolio` fixture business flow와 Spring Modulith verifier는 범위 밖.
|
||||
- [[raw/branch-notes/feature-architecture-enforcement-rules]] — 경계 의존성 규칙과 forbidden annotation/import의 ArchUnit 강제 기준.
|
||||
- [[raw/branch-notes/feature-skeleton-package-blueprint-contract]] — Gradle multi-module Clean Architecture / Hexagonal module blueprint SSOT.
|
||||
- [[raw/branch-notes/feature-domain-feature-onboarding-contract]] — 새 도메인 추가 시 New Domain Module Slice + Read/Write Difference Table 기준.
|
||||
- [[raw/project-notes/ca-skeleton-operational-contract]] §20 Skeleton Blueprint Contract — 위 3개 branch-note를 통합한 canonical SSOT.
|
||||
|
||||
## 내가 설명할 수 있어야 하는 것
|
||||
|
||||
- feature-first / layer-first / hexagonal / onion / modulith 5종의 공식 정의와 공통 핵심 원칙("의존성은 안쪽으로만")은 무엇인가?
|
||||
- 각 레이아웃이 어떤 문제를 해결하고, 어떤 상황에서는 무너지는가(특히 layer-first의 응집도 붕괴 시점)?
|
||||
- 레이아웃 선택만으로 경계가 지켜지지 않는 이유와, build-graph 검사 / 정적 분석(ArchUnit) 두 축의 enforcement가 각각 무엇을 막는가?
|
||||
- 공식 문서가 말하지 않는 부분(예: Spring Modulith가 위반의 build 실패를 자동 강제하지 않음)은 무엇인가?
|
||||
- 회사 기술 블로그 사례(우아한형제들·카카오뱅크·Allegro 등)를 일반 법칙처럼 말하면 안 되는 지점은?
|
||||
- 내 프로젝트(ca-tmpl)에서는 어떤 branch decision과 ArchUnit/Gradle rule로 연결됐는가?
|
||||
- 정적 분석으로 잡히지 않는 우회(runtime lookup, reflection)는 코드/운영에서 어떻게 검증·보완하는가?
|
||||
|
||||
## Interview Questions
|
||||
|
||||
- feature-first 패키지 레이아웃과 layer-first(controller/service/repository) 레이아웃의 차이는 무엇인가? 어느 시점에 후자가 무너지는가?
|
||||
- feature-first 레이아웃이 Hexagonal Architecture와 "동형"이라는 표현은 무슨 뜻인가? buckpal 예시로 설명하라.
|
||||
- 도메인 수가 늘어났을 때 layer-first가 응집도 면에서 무너지는 이유는 무엇인가? 어떤 운영 신호로 그것을 감지하는가?
|
||||
- Spring Modulith를 즉시 도입하지 않고 Gradle multi-module + ArchUnit/Gradle guardrail로 시작하는 트레이드오프는 무엇인가? 향후 Modulith로 이행할 수 있는 조건은?
|
||||
- 패키지 규약을 문서로만 두지 않고 ArchUnit 같은 architecture test로 boundary를 강제하는 이유는 무엇인가? 정적 분석으로 잡히지 않는 우회(runtime lookup 등)는 어떻게 보완하는가?
|
||||
|
||||
## Do Not Overclaim
|
||||
|
||||
- "feature-first가 항상 layer-first보다 우월하다"는 금지. 학습 비용은 layer-first가 가장 낮고, 도메인 수가 적은 초기 단계에서는 layer-first도 합리적인 선택이다.
|
||||
- "ca-tmpl이 Hexagonal Architecture다"는 단정 금지. ca-tmpl은 Gradle module boundary로 application/domain과 adapter를 물리 분리한 Clean Architecture / Hexagonal-inspired template이다. 현재 구현 어휘는 inbound = `adapter-web`, outbound = `adapter-persistence` / `adapter-outbound`이며, Cockburn 원전의 모든 어휘를 그대로 차용한 구현은 아님.
|
||||
- "Spring Modulith를 곧 도입할 것"이라는 단정 금지. Modulith는 framework가 boundary를 강제하는 자연스러운 진화 경로이지만, 도입은 framework lock-in과 JPA 의존 비용을 수반하며 ca-tmpl의 framework-neutral 도메인 원칙과 일부 충돌한다. 향후 검토 대안 중 하나일 뿐 도입 결정이 아니다.
|
||||
- "ArchUnit이 모든 경계 위반을 잡아낸다"는 단정 금지. 정적 분석은 ApplicationContext lookup, `@Lazy` reflection, runtime classloader 우회를 감지할 수 없으며 별도 코드 리뷰/SonarQube 보완이 필요하다.
|
||||
|
||||
## Sources
|
||||
|
||||
- [[raw/official-docs/feature-first-uncle-bob-screaming-architecture-2011]] — Uncle Bob Screaming Architecture (2011)
|
||||
- [[raw/official-docs/hexagonal-cockburn-wikipedia-summary]] — Cockburn Hexagonal Architecture (Ports & Adapters)
|
||||
- [[raw/official-docs/hexagonal-thombergs-buckpal-github]] — Thombergs BuckPal reference (feature 단위로 잘린 Hexagonal)
|
||||
- [[raw/official-docs/layer-first-baeldung-clean-architecture-spring-boot]] — Baeldung Clean Architecture Spring Boot
|
||||
- [[raw/official-docs/onion-palermo-original-2008]] — Palermo Onion Architecture (2008)
|
||||
- [[raw/official-docs/modulith-spring-official-doc]] — Spring Modulith 공식 문서
|
||||
- [[raw/company-tech-blogs/feature-first-sahibinden-package-by-layer-vs-feature]] — Sahibinden: feature vs layer 응집도 비교
|
||||
- [[raw/company-tech-blogs/layer-first-kamilmazurek-github-template]] — layer-first Spring Boot template
|
||||
- [[raw/company-tech-blogs/hexagonal-woowahan-techblog-2023]] — 우아한형제들 Hexagonal 적용 사례
|
||||
- [[raw/company-tech-blogs/modulith-kakaobank-techblog-2025]] — 카카오뱅크 수신상품 Modulith 적용
|
||||
- [[raw/company-tech-blogs/modulith-arawn-github-modular-monoliths-spring]] — Modular Monoliths with Spring 참조 구현
|
||||
- [[raw/company-tech-blogs/onion-allegro-tech-blog-2023]] — Allegro Onion Architecture 적용기
|
||||
- [[raw/project-notes/ca-skeleton-operational-contract]] — canonical operational contract (§20 Skeleton Blueprint Contract, §29 Topic 1)
|
||||
@@ -0,0 +1,96 @@
|
||||
---
|
||||
title: Config & Adapter Templates (env-driven + optional module)
|
||||
source_type: llm-generated
|
||||
status: draft
|
||||
confidence: medium
|
||||
tags: [12-factor, config, spring-boot, adapter, conditional-on-property]
|
||||
related_projects: [ca-skeleton]
|
||||
last_reviewed: 2026-05-22
|
||||
---
|
||||
|
||||
# Config & Adapter Templates (env-driven + optional module)
|
||||
|
||||
> Layer: `wiki/concepts/` — env 기반 runtime configuration과 optional adapter template를 동시에 다루는 일반 개념 문서. 구체적인 프로젝트 결정은 [[raw/project-notes/ca-skeleton-operational-contract]] §9 및 [[raw/branch-notes/feature-env-driven-runtime-configuration]], [[raw/branch-notes/feature-integration-adapter-templates]] 참조.
|
||||
|
||||
## Summary
|
||||
|
||||
**Env config**: 12-factor §III. Config 원칙을 따라 application-owned env에 `APP_` prefix, Duration은 `30s` 형식 1택, boolean은 `true/false` only, runtime reload는 기본 금지, `.env.example` drift 검증 도구로 누락 감지를 강제하는 설계.
|
||||
|
||||
**Adapter templates**: 선택형 adapter(Kafka/Redis/Slack/Email)는 기본 dependency가 아닌 optional module로 두고, `@ConditionalOnProperty` 3-layer(Layer 1 Spring bean 등록 조건, Layer 2 ArchUnit static dependency 검사, Layer 3 runtime `AdapterDisabledException` fail-fast)로 disabled adapter가 use case path에 새지 않게 막는 설계.
|
||||
|
||||
## Standard (공식 정의)
|
||||
|
||||
### Env-driven runtime configuration
|
||||
|
||||
- **12-factor §III. Config** — config는 코드와 분리된 환경 변수에 두고, 배포 환경별로 달라지는 값(자격 증명, hostname, profile)은 모두 env로 주입. config dump가 가능하면 안 됨.
|
||||
- **Spring Boot externalized configuration** — `@ConfigurationProperties + @Validated`로 env 바인딩, `application.yml` profile-specific override, Spring `Duration` (`30s`/`PT30S`) / `DataSize` (`10MB`) 타입 지원.
|
||||
- **검토된 대안**:
|
||||
- **Spring Cloud Config Server** — 중앙 git-backed config + `@RefreshScope`로 runtime reload. config server 자체가 인프라 SPOF가 되고 bootstrap에 의존.
|
||||
- **k8s ConfigMap + Spring Cloud Kubernetes auto-reload** — 3-level reload (`refresh` / `restart_context` / `shutdown`).
|
||||
- **HashiCorp Consul KV** — KV store + watch.
|
||||
- **AWS Parameter Store / AppConfig** — managed validator + CloudWatch auto-rollback + deployment strategy.
|
||||
- **LaunchDarkly / Unleash** — feature flag SaaS. A/B/canary, user-targeting, percentage rollout 등 product-grade 기능 제공.
|
||||
|
||||
### Adapter templates (optional module)
|
||||
|
||||
- **Spring `@ConditionalOnProperty`** — `name`/`havingValue` 조건이 일치할 때만 bean 등록. Spring Boot 3.5.0+에서 `@ConditionalOnBooleanProperty` 도입.
|
||||
- **Spring Boot AutoConfiguration** — `META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports`에 등록된 `@AutoConfiguration` 클래스가 조건부 bean을 제공. custom starter의 표준 방식.
|
||||
- **검토된 대안**:
|
||||
- **Java SPI / `ServiceLoader`** — `META-INF/services/<interface>`에 구현체 등록, classpath에서 발견된 모든 provider를 load.
|
||||
- **Spring `@Profile` 기반** — profile 활성화로 bean 선택.
|
||||
- **OSGi plugin architecture** — runtime module 동적 load/unload.
|
||||
- **Feature flag library (FF4J / Togglz)** — runtime flag로 코드 path 분기.
|
||||
|
||||
## 한계 / 주의점
|
||||
|
||||
### Env config
|
||||
|
||||
- **12-factor env (process env 노출)** — secret이 process env에 남아 `/proc/<pid>/environ`, container metadata API, `env` actuator endpoint로 leak 가능. secret manager 별도 필요.
|
||||
- **Spring Cloud Config Server** — 인프라 SPOF. config server 장애 시 client startup 차단 (bootstrap 의존).
|
||||
- **k8s ConfigMap auto-reload** — pod별로 reload 타이밍이 다르면 partial-state가 생겨 디버깅 어려움. k8s lock-in 발생.
|
||||
- **AWS AppConfig** — AWS lock-in + per-call billing.
|
||||
- **LaunchDarkly / Unleash** — 외부 SaaS 의존, flag debt(제거되지 않은 flag 누적), cost. product-grade A/B/canary 요구가 발생하기 전에는 over-engineering.
|
||||
|
||||
### Adapter templates
|
||||
|
||||
- **Spring `@ConditionalOnProperty` Layer 1** — Spring 공식이 cover하는 영역은 bean 등록 조건뿐. application code가 disabled adapter package를 import해도 Spring 자체는 막지 못함.
|
||||
- **ArchUnit Layer 2** — 별도 source가 필요한 미흡 영역. `noClasses().that().resideInAPackage("..application..").should().dependOnClassesThat().resideInAPackage("..adapters.{disabled}..")` 같은 정적 rule을 작성해야 하며, ca-tmpl 자체 contract로 G-I 후속 보강 대상.
|
||||
- **ArchUnit Layer 2 정적 검사 한계** (2026-05-22 보강) — ArchUnit User Guide의 `DescribedPredicate` / `ArchCondition` API와 `JavaClass.getAnnotationOfType(...)`로 정적 추출 가능한 것은 (a) adapter 후보 class가 `@ConditionalOnProperty`를 부착했는지, (b) `name`/`havingValue` parameter 값이 `app.adapter.<name>.enabled` 패턴을 따르는지, (c) application layer가 adapter package를 직접 import하지 않는지(CA 경계)까지. **"현재 빌드/배포 환경에서 어떤 adapter가 실제 disabled인지"는 runtime config 평가이므로 ArchUnit 능력 밖**이며, Layer 3 (`AdapterDisabledException` runtime fail-fast)에 위임해야 함. 즉 Layer 2는 "annotation 존재 + naming pattern 강제" fitness function까지가 실효 범위. 자세한 평가는 [[raw/official-docs/archunit-conditional-on-property-3-layer-pattern]] 참조. status `needs-confirmation`.
|
||||
- **`AdapterDisabledException` Layer 3** — branch 자체 contract. 표준 라이브러리가 제공하지 않으며 직접 구현.
|
||||
- **Java SPI** — on/off boolean 표현 불가(classpath 존재 = enable), default constructor 강제, Spring DI 미통합. ca-tmpl의 `APP_ADAPTER_*_ENABLED` 결정과 정면 충돌.
|
||||
- **Togglz / FF4J** — runtime branching tool로, startup-time adapter on/off와 시맨틱이 다름. ca-tmpl `@ConditionalOnProperty`(startup 결정)와 feature flag service(runtime 결정)는 분리 영역으로 취급해야 함.
|
||||
|
||||
## Project Application
|
||||
|
||||
- [[wiki/projects/ca-tmpl/config-and-adapter-templates]] — ca-tmpl 의사결정 기록 (현재 `documented-only`, Phase C2 미진입). 실제 구현 여부는 project 문서 참조.
|
||||
- [[raw/branch-notes/feature-env-driven-runtime-configuration]] — `APP_` prefix, Duration `30s`, boolean `true/false`, no-runtime-reload, `.env.example` drift 검증 결정.
|
||||
- [[raw/branch-notes/feature-integration-adapter-templates]] — optional module + `@ConditionalOnProperty` 3-layer detection + `AdapterDisabledException` fail-fast 결정.
|
||||
- [[raw/project-notes/ca-skeleton-operational-contract]] — §9 Env-driven Runtime Configuration, §11 Adapter Failure Contract, §29 Group G-I.
|
||||
|
||||
## Interview Questions
|
||||
|
||||
- 12-factor §III. Config가 의미하는 "config와 코드 분리"는 구체적으로 무엇을 강제하는지 설명해 주세요.
|
||||
- runtime config reload를 기본 금지(no-runtime-reload)로 결정한 근거와, 그 결정이 운영에서 갖는 trade-off는 무엇인가요?
|
||||
- `@ConditionalOnProperty` 3-layer 검출(Spring bean 조건 + ArchUnit static + runtime fail-fast)이 각각 어떤 실패 시나리오를 잡아내려는 것인지 설명해 주세요.
|
||||
- Java SPI `ServiceLoader`와 Spring `@ConditionalOnProperty`는 adapter on/off 표현에서 어떤 차이가 있나요?
|
||||
- LaunchDarkly 같은 feature flag SaaS와 `@ConditionalOnProperty` 기반 startup toggle은 어떤 운영 요구가 생겼을 때 갈라지는지 설명해 주세요.
|
||||
|
||||
## Do Not Overclaim
|
||||
|
||||
- "`@RefreshScope`만 도입하면 dynamic config가 된다" 같은 단정은 피해야 함. ca-tmpl은 runtime reload를 기본 금지로 두며, reload가 필요한 경우는 secret manager + startup validation을 별도 branch로 분리하는 것이 결정 사항.
|
||||
- "`@ConditionalOnProperty` 3-layer가 disabled adapter 호출을 완전 검증한다"고 단정하면 안 됨. Layer 1만 Spring 공식 cover이고, Layer 2(ArchUnit)는 source 부재로 G-I 후속 보강 대상, Layer 3(`AdapterDisabledException`)는 branch 자체 contract.
|
||||
- "12-factor env가 secret 관리까지 책임진다"는 표현은 과장. process env 노출 위험은 12-factor 자체가 해결하지 않으며 secret manager가 별도 책임.
|
||||
- "ca-tmpl이 LaunchDarkly/Togglz를 거부했다"가 아니라 "ca-tmpl scope에서 위임한 영역"이라는 표현이 정확.
|
||||
|
||||
## Sources
|
||||
|
||||
- [The Twelve-Factor App — III. Config](https://12factor.net/config) — [[raw/official-docs/config-12-factor-app-config]]
|
||||
- [Spring Cloud Config (official)](https://docs.spring.io/spring-cloud-config/reference/) — [[raw/official-docs/config-spring-cloud-config-server-official]]
|
||||
- [Spring Cloud Kubernetes — ConfigMap auto-reload](https://docs.spring.io/spring-cloud-kubernetes/reference/) — [[raw/official-docs/config-spring-cloud-kubernetes-configmap-reload]]
|
||||
- [AWS AppConfig — Feature flag & deployment strategy](https://docs.aws.amazon.com/appconfig/) — [[raw/official-docs/config-aws-appconfig-feature-flag-deployment]]
|
||||
- [LaunchDarkly — Feature flag best practice](https://launchdarkly.com/) — [[raw/company-tech-blogs/config-launchdarkly-feature-flag-best-practice]]
|
||||
- [Spring Boot — Custom AutoConfiguration / starter](https://docs.spring.io/spring-boot/reference/features/developing-auto-configuration.html) — [[raw/official-docs/adapter-spring-boot-autoconfig-custom-starter]]
|
||||
- [Java SPI — `java.util.ServiceLoader`](https://docs.oracle.com/en/java/javase/21/docs/api/java.base/java/util/ServiceLoader.html) — [[raw/official-docs/adapter-java-spi-serviceloader]]
|
||||
- [Togglz / FF4J — Feature toggle library](https://www.togglz.org/) — [[raw/company-tech-blogs/adapter-togglz-ff4j-feature-toggle-library]]
|
||||
- [ArchUnit — Writing Custom Rules / Accessing Annotation](https://www.archunit.org/userguide/html/000_Index.html) — [[raw/official-docs/archunit-conditional-on-property-3-layer-pattern]] (Layer 2 정적 검사 가능 범위 평가, needs-confirmation)
|
||||
- Canonical: [[raw/project-notes/ca-skeleton-operational-contract]] (§9, §11, §29 Group G-I)
|
||||
@@ -0,0 +1,128 @@
|
||||
---
|
||||
title: Data Layer Baseline (Persistence + Cache + Outbound HTTP)
|
||||
source_type: llm-generated
|
||||
status: draft
|
||||
confidence: medium
|
||||
tags: [persistence, jpa, cache, http-client, resilience]
|
||||
related_projects: [ca-skeleton]
|
||||
last_reviewed: 2026-05-22
|
||||
---
|
||||
|
||||
# Data Layer Baseline (Persistence + Cache + Outbound HTTP)
|
||||
|
||||
> Layer: `wiki/concepts/` — Phase E Group G-C 합성. 3개 sub-topic(Persistence failure / Cache consistency / Outbound HTTP)을 하나의 baseline canonical로 묶음. 프로젝트 적용 사실은 `wiki/projects/`에 별도 작성하고 본 문서에서는 링크만 둠.
|
||||
|
||||
## Summary
|
||||
|
||||
Data layer baseline은 세 가지 축으로 구성된다.
|
||||
|
||||
- **Persistence**: SQLState 매트릭스로 DB 실패를 분류하고, Spring `DataAccessException` 계층 위에 매핑하여 `PERSISTENCE / CONFLICT / TRANSIENT_DEPENDENCY` 카테고리를 만든다. OSIV는 off가 기본.
|
||||
- **Cache**: cache-aside default + Caffeine local lock(single-instance) + Redisson `RLock` distributed mutex(multi-instance HPA) + after-commit invalidation + eventual consistency window 5초.
|
||||
- **Outbound HTTP**: Spring RestClient를 baseline으로 두고, retry/circuit breaker는 Resilience4j로 일원화. timeout default = connect 2s / read 5s / global 10s.
|
||||
|
||||
## Standard (공식 정의)
|
||||
|
||||
### Persistence — SQLState + Spring DAO hierarchy
|
||||
|
||||
- **SQLState** (ISO/IEC 9075): 5-char code로 DB 오류를 표준 분류. `08*` = connection exception, `40001` = serialization failure, `40P01` = deadlock(Postgres), `23xxx` = integrity constraint, `57014` = query canceled.
|
||||
- **Spring `DataAccessException` hierarchy**: `TransientDataAccessException` / `NonTransientDataAccessException` / `RecoverableDataAccessException`로 retryable/non-retryable 1차 분리. JPA `PersistenceException`은 `JpaSystemException`으로 흡수.
|
||||
- **OSIV (Open Session In View)**: Hibernate session을 view rendering까지 열어두는 패턴. Vlad Mihalcea가 anti-pattern으로 명시했고 Spring Boot는 활성화 시 startup WARN 로그를 출력. 운영 baseline은 off.
|
||||
- **HikariCP pool sizing**: 공식 wiki는 `connections = ((core_count * 2) + effective_spindle_count)` 공식과 단일 small pool 권장. pool wait p99 / pool exhaustion이 1차 alert 지표.
|
||||
|
||||
### Cache — cache-aside + stampede control
|
||||
|
||||
- **Cache-aside** (Microsoft Cloud Design Patterns / AWS ElastiCache): application이 cache miss 시 DB 조회 → cache 채움. invalidation도 application 책임. write-through는 cache layer가 sync 책임, write-behind는 async, read-through는 cache layer가 loader를 안다. 책임 위치가 다름.
|
||||
- **Caffeine `AsyncLoadingCache` / `@Cacheable(sync = true)`**: 동일 key 동시 miss를 단일 loader 호출로 직렬화 (in-process stampede 방지).
|
||||
- **Redisson `RLock`**: Redis 기반 reentrant lock + watchdog lease extension. Kleppmann의 Redlock 비판을 회피하기 위해 단일 master 기반 RLock + fence token 사용.
|
||||
- **after-commit invalidation**: Spring `TransactionSynchronizationManager.registerSynchronization`의 `afterCommit()` hook에서만 cache mutation 수행. tx rollback 시 stale write 차단.
|
||||
|
||||
### Outbound HTTP — RestClient + Resilience4j
|
||||
|
||||
- **Spring RestClient** (6.1+): `RestTemplate`의 fluent 후속 API. RestTemplate은 Spring 공식 maintenance-only 상태로 신규 기능 추가 없음.
|
||||
- **Resilience4j**: Netflix Hystrix의 사실상 후속. Hystrix는 2018년 maintenance mode 진입. Retry / CircuitBreaker / TimeLimiter / Bulkhead / RateLimiter를 functional decorator로 제공.
|
||||
- **Circuit breaker 상태**: `CLOSED` → `OPEN` (failure rate threshold 초과) → `HALF_OPEN` (probe) → `CLOSED` 복귀. Micrometer로 state transition을 metric으로 노출.
|
||||
- **Timeout 계층**: connect timeout(소켓 연결) < read timeout(응답 첫 바이트 대기) < global call timeout(전체 호출). 셋 중 하나라도 미설정이면 무한 대기 위험.
|
||||
|
||||
## 한계 / 주의점
|
||||
|
||||
### Persistence
|
||||
|
||||
- SQLState 9-row matrix의 vendor-specific row(PostgreSQL `23505`, `40P01` 등)는 DB 변경 시 재검증 필요. MySQL은 `40001`만 공유하고 `40P01` 대신 다른 코드를 사용.
|
||||
- OSIV off는 lazy loading exception을 presentation까지 새지 않게 막아주지만, application 경계에서 명시적 fetch 전략(`@EntityGraph`, fetch join, DTO projection)을 강제한다. 익숙하지 않은 팀은 운영 부담이 늘 수 있음.
|
||||
- R2DBC reactive는 throughput 우위가 있으나 JPA tooling을 포기해야 한다. baseline은 JPA blocking으로 고정한 trade-off의 반대편.
|
||||
|
||||
### Cache
|
||||
|
||||
- cache-aside의 eventual consistency window가 5초로 잡혀 있어 **strict consistency가 요구되는 use case(잔액, 인증, idempotency 검증)에는 부적합**. 해당 use case는 cache bypass를 명시.
|
||||
- Caffeine local cache + Redisson 분산 mutex 조합은 노드 간 sync lag이 존재. 한 노드가 invalidation을 발행한 뒤 다른 노드의 local cache가 비워질 때까지 lag 발생.
|
||||
- Redisson `RLock`도 Kleppmann의 분산 lock 비판에서 완전히 자유롭지 않다. 정확한 fencing을 요구하는 경우 token + DB-level optimistic lock 병행이 필요.
|
||||
- negative cache(존재하지 않는 row, TTL 60s)는 invalidation 채널 적용 대상에서 제외 — 의도된 분리이지만 row가 실제로 생성된 직후 60초간 stale empty 응답이 나갈 수 있음.
|
||||
|
||||
### Outbound HTTP
|
||||
|
||||
- RestClient는 Spring 6.1+ 한정. 기존 RestTemplate 코드는 마이그레이션 비용이 따른다.
|
||||
- WebClient는 reactor event-loop 위에서 동작하므로 MVC(servlet) baseline에 강제 도입하면 blocking risk가 있다. baseline에서는 extension 문서로 분리.
|
||||
- OpenFeign은 declarative interface로 편리하지만 Spring Cloud 의존이 붙는다. Spring 6.1+ `@HttpExchange`가 framework-level 대안.
|
||||
- Stripe engineering blog는 retry default-on을 옹호하지만 **이는 idempotency-key 헤더 보장이 전제**. 일반 API에 default-on retry를 적용하면 비-idempotent endpoint의 중복 write 위험이 생긴다.
|
||||
- Resilience4j는 Spring Boot starter 통합이 매끄럽지만, Spring 외 환경(plain Java, Vert.x 등)에서는 verbose한 functional decorator 작성이 필요. "vendor-neutral"로 단언하기에는 일부 마찰이 있음.
|
||||
|
||||
## Project Application
|
||||
|
||||
- [[wiki/projects/ca-tmpl/data-layer-persistence-cache-outbound]] — ca-tmpl 의사결정 기록 (현재 `documented-only`, Phase C2 미진입). 실제 구현 여부는 project 문서 참조.
|
||||
ca-skeleton operational contract와 owning branch-notes:
|
||||
|
||||
- [[raw/branch-notes/feature-persistence-failure-baseline]] — SQLState 9-row matrix, Hikari alert threshold, OSIV off 결정
|
||||
- [[raw/branch-notes/feature-cache-consistency-contract]] — cache-aside default, Caffeine + Redisson, after-commit invalidation, 5s window
|
||||
- [[raw/branch-notes/feature-outbound-http-client-baseline]] — RestClient baseline, Resilience4j, timeout 2s/5s/10s, shutdown retry suppression
|
||||
- [[raw/project-notes/ca-skeleton-operational-contract]] — §6 Operational Error Category, §11 Adapter Failure Contract, §29 G-C 외부 근거
|
||||
|
||||
## Interview Questions
|
||||
|
||||
- SQLState 코드를 어떻게 retryable / non-retryable로 매핑했고 그 분류가 Spring `DataAccessException` hierarchy와 어떻게 정합한가?
|
||||
- OSIV가 anti-pattern으로 평가되는 이유는 무엇이고 off로 두었을 때 lazy loading은 어떻게 해결하는가?
|
||||
- cache-aside의 eventual consistency window 5초가 의미하는 바와, 그 안에서 stale read가 허용되지 않는 use case는 어떻게 분리하는가?
|
||||
- Resilience4j를 Hystrix 대신 선택한 이유와 두 라이브러리의 차이는?
|
||||
- outbound HTTP timeout을 connect 2s / read 5s / global 10s로 둔 의도와 셋 중 어떤 게 빠지면 어떤 위험이 생기는가?
|
||||
- after-commit invalidation을 강제하는 이유와, transaction rollback 시 cache 일관성이 어떻게 보장되는가?
|
||||
|
||||
## Do Not Overclaim
|
||||
|
||||
- "cache-aside면 항상 안전하다" — strict consistency가 요구되는 use case에서는 cache bypass가 필요하다. cache-aside는 eventual consistency 모델이다.
|
||||
- "Resilience4j는 vendor-neutral이라 어디서나 동일하게 동작" — Spring Boot starter 통합 외 환경에서는 functional decorator를 직접 조립해야 하고 boilerplate가 늘어난다.
|
||||
- "RestClient가 RestTemplate를 완전히 대체했다" — Spring 6.1+ 한정이고 기존 코드 마이그레이션 비용이 있다.
|
||||
- "Redisson RLock이면 분산 lock 문제 해결" — Kleppmann 비판은 완화되었지만 fencing token / DB optimistic lock 병행이 필요한 경우가 있다.
|
||||
- "Stripe처럼 retry default-on이 좋은 패턴이다" — Stripe는 idempotency-key 보장이 전제. 일반 API에 그대로 적용하면 위험하다.
|
||||
|
||||
## Sources
|
||||
|
||||
### 공식 근거 (Persistence)
|
||||
|
||||
- [[raw/official-docs/persistence-spring-dataaccessexception-hierarchy]] — Spring `DataAccessException` 계층 (SQLState 분류의 framework-level anchor)
|
||||
- [[raw/official-docs/persistence-osiv-antipattern-hibernate-vladmihalcea]] — Hibernate 권위자의 OSIV anti-pattern 명시 + Spring Boot WARN
|
||||
- [[raw/official-docs/persistence-hikaricp-pool-sizing-wiki]] — pool sizing 공식과 alert threshold 출처
|
||||
- [[raw/official-docs/persistence-r2dbc-reactive-spring]] — JPA blocking baseline의 trade-off 반대편(R2DBC reactive)
|
||||
|
||||
### 공식 근거 (Cache)
|
||||
|
||||
- [[raw/official-docs/cache-aside-vs-write-through-aws]] — cache-aside / write-through / write-behind / read-through trade-off 공식 분류
|
||||
- [[raw/official-docs/cache-caffeine-asyncloadingcache-readme]] — single-instance stampede 방지(`@Cacheable(sync = true)`, `AsyncLoadingCache`) 공식 매핑
|
||||
- [[raw/official-docs/cache-redisson-rlock-vs-setnx]] — multi-instance HPA에서 RLock 채택 + SETNX/Redlock 배제 (Kleppmann 비판 포함)
|
||||
|
||||
### 사례 (Cache)
|
||||
|
||||
- [[raw/company-tech-blogs/cache-woowahan-after-commit-invalidation]] — after-commit invalidation의 한국 사례 + Spring `TransactionSynchronizationManager` 강제 근거 (회사 기술블로그 — 사례 취급)
|
||||
|
||||
### 공식 근거 (Outbound HTTP)
|
||||
|
||||
- [[raw/official-docs/outbound-spring-restclient-baseline]] — RestClient baseline + RestTemplate maintenance-only 명시
|
||||
- [[raw/official-docs/outbound-resilience4j-vs-spring-retry]] — Resilience4j 채택 + Spring Retry 좁은 예외 허용 + Hystrix 배제
|
||||
- [[raw/official-docs/outbound-webclient-vs-restclient-spring]] — WebClient baseline 배제 이유(reactor event-loop blocking risk)
|
||||
- [[raw/official-docs/outbound-openfeign-declarative-client]] — Feign declarative 대안 + maintenance status + Spring 6.1+ `@HttpExchange`
|
||||
|
||||
### 사례 (Outbound HTTP)
|
||||
|
||||
- [[raw/company-tech-blogs/outbound-stripe-rate-limit-retry-engineering]] — retry + idempotency-key 결합, full-jitter backoff. ca-tmpl default-disabled의 보수성 대비 (회사 기술블로그 — 사례 취급)
|
||||
|
||||
### Canonical contract
|
||||
|
||||
- [[raw/project-notes/ca-skeleton-operational-contract]] — §6 Operational Error Category, §11 Adapter Failure Contract, §29 Group G-C 외부 근거 인덱스
|
||||
@@ -0,0 +1,130 @@
|
||||
---
|
||||
title: DevOps Baseline (CI + Supply chain + DX)
|
||||
source_type: llm-generated
|
||||
status: draft
|
||||
confidence: medium
|
||||
tags: [devops, ci-cd, supply-chain, sigstore, developer-experience]
|
||||
related_projects: [ca-skeleton]
|
||||
last_reviewed: 2026-05-22
|
||||
---
|
||||
|
||||
# DevOps Baseline (CI + Supply chain + DX)
|
||||
|
||||
> Layer: `wiki/concepts/` — 일반 개념. 내 프로젝트 사실은 `project-template` / project 문서 사용.
|
||||
|
||||
## Summary
|
||||
|
||||
운영 가능한 백엔드 skeleton의 DevOps baseline은 세 축으로 구성된다.
|
||||
**(1) CI quality gate** — GitHub Actions `needs:` + `if: success()`로 contract test ↔ release-blocking 의존성을 단일 yaml에서 강제하고, flaky test는 14일 sunset 기한이 붙은 quarantine bucket으로 분리한다.
|
||||
**(2) Build / release supply chain** — Cosign keyless signing (Sigstore Fulcio + Rekor transparency log)으로 artifact를 서명하고, SLSA provenance attestation으로 build 출처를 검증하며, Gradle dependency-locking으로 transitive 버전 drift를 차단한다.
|
||||
**(3) Developer experience** — `./gradlew bootstrap` 같은 단일 진입점 + Testcontainers `@ServiceConnection` 기반 integration test + `.tool-versions`로 핀된 JDK LTS로 새 개발자가 clean clone 직후 smoke까지 5단계로 도달한다.
|
||||
|
||||
## Standard (공식 정의)
|
||||
|
||||
### CI quality gate
|
||||
|
||||
- **GitHub Actions** (`docs.github.com/en/actions/`): YAML workflow의 `jobs.<id>.needs` 의존성과 `if: success() | failure()` 조건으로 단계별 gate를 표현. job status가 `failure`이면 workflow status도 `failure`.
|
||||
- **GitLab CI/CD** (`docs.gitlab.com/ee/ci/pipelines/`): `stages` + `jobs` + `needs:` + `rules:` 키워드로 같은 모델을 구성. `parallel: matrix:` 키워드로 matrix job.
|
||||
- **Jenkins Declarative Pipeline** (`jenkins.io/doc/book/pipeline/syntax/`): `agent` 디렉티브 + `post { failure { ... } }` block으로 실패 처리.
|
||||
- **CircleCI configuration reference** (`circleci.com/docs/configuration-reference/`): orbs + workflow + job 모델.
|
||||
- **Tekton Pipelines** (`tekton.dev/docs/pipelines/`): `Pipeline` = `Tasks`의 모음, 각 `Task`는 Kubernetes Pod로 실행.
|
||||
|
||||
### Supply chain
|
||||
|
||||
- **Sigstore Cosign** (`docs.sigstore.dev/cosign/signing/overview/`): OIDC identity token으로 Fulcio가 단명(10분) 서명 cert 발급, 서명 직후 private key 파기. 서명 이벤트는 **Rekor transparency log**에 immutable 기록. 검증 측은 `cosign verify --certificate-identity=... --certificate-oidc-issuer=...`로 issuer와 identity를 함께 강제.
|
||||
- **SLSA v1.0 spec** (`slsa.dev/spec/v1.0/`): "Supply-chain Levels for Software Artifacts". provenance는 build platform, top-level build invocation, materials(sources + dependencies)를 최소 식별. Build L1 = provenance 존재, L2 = hosted build platform, L3 = hardened/hermetic build.
|
||||
- **in-toto attestation** (`github.com/in-toto/attestation`): 인증된 statement = subject(artifact digest 목록) + predicate(예: SLSA Provenance). DSSE envelope으로 서명되며 Cosign이 같은 envelope을 서명한다.
|
||||
- **Gradle dependency locking** (`docs.gradle.org/current/userguide/dependency_locking.html`): `dependencyLocking { lockAllConfigurations() }` + `--write-locks`로 lockfile 생성. `lockMode = STRICT`일 때 lock state와 다른 해석은 build fail.
|
||||
- **Maven Enforcer Plugin** `dependencyConvergence` 룰: transitive lockfile은 부재. 부분 대응만 가능.
|
||||
|
||||
### Developer experience
|
||||
|
||||
- **Testcontainers for Java** (`java.testcontainers.org/`): Docker container 기반 throwaway dependency. Spring Boot 3.1+ `@ServiceConnection` annotation으로 JDBC URL, credentials, host, port가 ApplicationContext에 자동 주입. reuse 옵션은 CI 금지, 로컬만.
|
||||
- **Devcontainer spec** (`containers.dev/implementors/spec/`): `.devcontainer/devcontainer.json`이 VSCode/Codespaces용 dev container 정의. tool version과 OS-level dep을 통일하지만 첫 진입점/smoke/migration 순서는 별도 필요.
|
||||
- **mise / asdf** (`mise.jdx.dev/`, `asdf-vm.com/`) — `.tool-versions` 형식이 사실상 표준. **SDKMAN!** (`sdkman.io/`)은 별도 `.sdkmanrc` 사용.
|
||||
- **Eclipse Temurin 21 LTS** (`adoptium.net/temurin/releases/?version=21`): 2028-09까지 무료 LTS 보안 패치.
|
||||
|
||||
## 한계 / 주의점
|
||||
|
||||
### CI
|
||||
|
||||
- **GitHub Actions**는 vendor lock-in(workflow yaml 문법, OIDC issuer URL, marketplace action 등)과 hosted runner 비용 모델이 다른 provider와 다르다. provider-agnostic하게 gate를 정의하지 않으면 이식 비용이 크다.
|
||||
- **Jenkins / Tekton**은 인프라(k8s cluster, plugin ecosystem)에 대한 의존도가 커서 skeleton 단계에서는 과한 선택일 수 있다.
|
||||
- **Flaky test quarantine**은 Spotify/Google/Microsoft가 운영 도구로 인정한 반면 Martin Fowler는 *"Eradicating Non-Determinism in Tests"*에서 quarantine 자체를 anti-pattern으로 본다. "Spotify가 한다 = 공식 best practice"로 표현 금지. 14일 sunset 같은 절충은 *어느 한쪽도 공식이 아니라는 인정*이다.
|
||||
- **OpenAPI snapshot diff** (springdoc + openapi-diff/oasdiff)는 controller annotation을 정적 추출하므로 dynamic routing(예: webflux functional routes)이 있으면 누락된다. "ground truth"는 이 범위 안에서만 참.
|
||||
|
||||
### Supply chain
|
||||
|
||||
- **Cosign keyless**의 "signature 누락 시 deploy block"만으로는 부족하다. `--certificate-identity` + `--certificate-oidc-issuer`로 **identity 매칭 정책**을 별도로 명시해야 임의의 OIDC identity가 만든 서명도 통과되는 사고를 막을 수 있다. Sigstore 공식은 키리스 모드에서 두 flag를 **검증 진입 전제 조건**으로 강제하며(`--certificate-identity ... is required for verification in keyless mode`), GitHub Actions OIDC 환경의 expected identity는 `https://github.com/<ORG>/<REPO>/.github/workflows/<file>@refs/heads/<branch>` 형식, issuer는 `https://token.actions.githubusercontent.com`이다. 클러스터 측 강제는 policy-controller / Kyverno `verifyImages` 등 admission controller에서 expected identity/issuer를 정책으로 선언. — `needs-confirmation`: 정책 표현 형식은 조직별로 다름.
|
||||
- **SLSA v1.0 spec**의 실제 필드명은 두 최상위 객체로 구성된다. `buildDefinition.{buildType, externalParameters, internalParameters, resolvedDependencies}` + `runDetails.{builder.id, builder.version, builder.builderDependencies, metadata.invocationId, metadata.startedOn, metadata.finishedOn, byproducts}`. in-toto Statement 래퍼는 `_type`(`https://in-toto.io/Statement/v1`) + `subject[*].digest` + `predicateType`(`https://slsa.dev/provenance/v1`) + `predicate`. 약식 표현(`build.config.source`, `build.invocation`, `materials`)은 spec 필드명과 다르므로 slsa-verifier가 `--builder-id` ↔ `runDetails.builder.id` 등의 필드를 찾지 못해 검증이 실패한다. provenance 생성 단계에서 spec 필드명을 그대로 사용해야 한다. — 출처: [[raw/official-docs/slsa-v1-provenance-schema]].
|
||||
- **SLSA Build L3** (hardened build, hermetic, tamper-resistant builder)는 GitHub Actions hosted runner만으로는 도달 불가. 실무적으로는 L2(hosted build platform)가 현실적 목표지점.
|
||||
- **Gradle dependency-locking**이 있어도 plugin 버전과 toolchain(JDK)은 별도 핀이 필요. `.tool-versions` / `gradle/wrapper/gradle-wrapper.properties` 핀과 함께 봐야 reproducible build가 완성된다.
|
||||
- **Maven**에는 transitive lockfile이 1급 시민으로 존재하지 않는다. Maven 기반 프로젝트에서 같은 수준의 reproducibility를 요구하면 추가 도구가 필요.
|
||||
|
||||
### Developer experience
|
||||
|
||||
- **`.tool-versions`(asdf/mise) vs `.sdkmanrc`(SDKMAN)** 포맷 차이. 두 파일을 동시에 두면 drift 위험. 단일 source로 좁히는 편이 안전하다.
|
||||
- **Devcontainer**는 VSCode/Codespaces에 의존한다. IntelliJ + 로컬 JDK 사용자에게는 중복 환경이 되며 bootstrap 단일 진입점/smoke는 devcontainer 안에서도 별도로 정의되어야 한다.
|
||||
- **Testcontainers**는 Apple Silicon(arm64) 환경에서 일부 image가 emulation(amd64) 위에서 동작해 bootstrap 시간이 늘어날 수 있다.
|
||||
- **Testcontainers reuse 옵션**은 CI에서는 반드시 비활성화. test 간 isolation을 깬다.
|
||||
- **Bootstrap 한 줄 명령**은 ergonomic 강점이 있으나 단계가 합쳐져 있어 *어느 단계에서 실패했는지* 추적이 어려울 수 있다. 실패 단계별 exit code 또는 step 출력 분리가 필요.
|
||||
|
||||
## Project Application
|
||||
|
||||
- [[wiki/projects/ca-tmpl/devops-ci-supply-chain-dx]] — ca-tmpl 의사결정 기록 (현재 `documented-only`, Phase C2 미진입). 실제 구현 여부는 project 문서 참조.
|
||||
내 프로젝트(ca-skeleton)에서 이 개념과 관련된 문서로 **링크**. 실제 구현 여부·검증 등급은 해당 project / branch 문서에서 판정 (concept 문서는 등급을 직접 매기지 않음).
|
||||
|
||||
- [[raw/project-notes/ca-skeleton-operational-contract]] — §29 G-E (외부 근거 / 대안 조사 인덱스, DevOps / CI).
|
||||
- [[raw/branch-notes/feature-ci-quality-gates-contract]] — Gate ↔ Branch Contract Test 소유권 매트릭스 20행, flaky quarantine 14d sunset SSOT, OpenAPI snapshot diff.
|
||||
- [[raw/branch-notes/feature-build-release-supply-chain-contract]] — Cosign keyless 의무, SLSA provenance attestation 의무, Gradle dependency-locking, SemVer + git sha suffix, reproducibility.
|
||||
- [[raw/branch-notes/feature-developer-experience-contract]] — `./gradlew bootstrap` 5단계, Temurin 21 LTS, Testcontainers integration, markdown-link-check.
|
||||
|
||||
## Interview Questions
|
||||
|
||||
- "CI에서 Gate ↔ Branch Contract Test 소유권 매트릭스란 무엇이고 왜 필요한가? 누가 어떤 gate를 깨질 때 책임지는지 어떻게 표현하는가?"
|
||||
- "Flaky test quarantine bucket에 sunset deadline을 14일로 두는 근거는 무엇인가? quarantine 자체를 반대하는 입장(Fowler)과 어떻게 절충하는가?"
|
||||
- "Cosign keyless signing이 GPG signing과 비교해 어떤 운영 비용을 제거하고, 어떤 새 의존성(OIDC IdP, Rekor 가용성)을 추가하는가?"
|
||||
- "SLSA build level L1/L2/L3가 각각 무엇을 보장하는가? skeleton 단계에서 현실적으로 도달 가능한 level은 어디까지인가?"
|
||||
- "Gradle dependency-locking이 필요한 이유는 무엇이고, Maven에는 왜 같은 수준의 lockfile이 없으며 어떻게 대체하는가?"
|
||||
- "Integration test backend로 Testcontainers를 H2 같은 in-memory DB 대신 선택하는 이유는 무엇인가? 그 비용은 무엇인가?"
|
||||
|
||||
## Do Not Overclaim
|
||||
|
||||
- "Cosign signature 누락만 차단하면 supply chain이 안전하다"고 단정 금지. **identity 매칭 정책**(`--certificate-identity` + `--certificate-oidc-issuer`)이 없으면 임의 OIDC identity가 만든 서명도 통과될 수 있다.
|
||||
- "SLSA Build L3를 달성했다"고 단정 금지. ca-skeleton 단계에서 L3는 hermetic build / tamper-resistant builder를 요구하며 GitHub Actions hosted runner만으로는 도달 어렵다. branch note의 약식 매핑(`build.config.source` 등)은 spec 실제 필드명(`buildDefinition.externalParameters`)과 다르므로 정정 필요.
|
||||
- "Google/Spotify/Microsoft가 flaky test quarantine을 운영하므로 공식 best practice다"라고 표현 금지. 이들은 *company-tech-blog* 등급이며 Fowler의 반대 입장이 함께 존재한다.
|
||||
- "GitHub Actions가 CI provider의 정답이다"로 단정 금지. ca-skeleton은 `needs:` + `if: success()` 모델이 contract gate에 맞물려 채택된 것이며, gate 정의 자체는 provider-agnostic하게 작성되어야 이식 가능하다.
|
||||
- "`./gradlew bootstrap` 한 줄이 끝났다 = 모든 게 정상이다"로 표현 금지. 5단계(compileTestJava → docker compose up → Flyway migrate → sample profile seed → smoke) 중 어디서 실패했는지 step 단위 검증이 필요.
|
||||
- "Devcontainer가 있으면 bootstrap이 필요 없다"로 표현 금지. devcontainer는 tool version과 OS-level dep만 통일하며, 진입점/smoke/migration 순서는 별도로 정의되어야 한다.
|
||||
- LLM 생성 문서이므로 본 concept 문서의 모든 진술은 `confidence: medium`. 검증 전 high confidence로 분류 금지.
|
||||
|
||||
## Sources
|
||||
|
||||
### 공식 문서 / spec
|
||||
|
||||
- [GitHub Actions — Migrating from GitLab CI/CD](https://docs.github.com/en/actions/learn-github-actions/migrating-from-gitlab-cicd-to-github-actions) / [GitLab CI/CD pipelines](https://docs.gitlab.com/ee/ci/pipelines/) / [Jenkins Declarative Pipeline](https://www.jenkins.io/doc/book/pipeline/syntax/) / [CircleCI configuration reference](https://circleci.com/docs/configuration-reference/) / [Tekton Pipelines overview](https://tekton.dev/docs/pipelines/) — CI provider 모델 비교.
|
||||
- [Sigstore Cosign overview](https://docs.sigstore.dev/cosign/signing/overview/) + [Fulcio](https://docs.sigstore.dev/certificate_authority/overview/) + [Rekor](https://docs.sigstore.dev/logging/overview/) — keyless signing 체인.
|
||||
- [SLSA v1.0 spec](https://slsa.dev/spec/v1.0/) + [Build levels](https://slsa.dev/spec/v1.0/levels) + [Provenance schema](https://slsa.dev/spec/v1.0/provenance) + [in-toto attestation](https://github.com/in-toto/attestation) — supply chain provenance.
|
||||
- [Gradle dependency locking](https://docs.gradle.org/current/userguide/dependency_locking.html) + [Maven Enforcer dependencyConvergence](https://maven.apache.org/enforcer/enforcer-rules/dependencyConvergence.html) — dependency lockfile 정책.
|
||||
- [Testcontainers for Java](https://java.testcontainers.org/) + [reuse](https://java.testcontainers.org/features/reuse/) + [Spring Boot Testcontainers support](https://docs.spring.io/spring-boot/docs/current/reference/htmlsingle/#features.testing.testcontainers) — integration test backend.
|
||||
- [Devcontainer spec](https://containers.dev/implementors/spec/) + [VS Code Dev Containers](https://code.visualstudio.com/docs/devcontainers/containers) + [GitHub Codespaces](https://docs.github.com/en/codespaces/overview) — dev environment 통일.
|
||||
- [mise](https://mise.jdx.dev/) + [asdf](https://asdf-vm.com/) + [SDKMAN!](https://sdkman.io/usage#env) + [Adoptium Temurin 21](https://adoptium.net/temurin/releases/?version=21) — tool versioning + JDK LTS.
|
||||
- [springdoc-openapi](https://springdoc.org/) + [OpenAPITools/openapi-diff](https://github.com/OpenAPITools/openapi-diff) + [Tufin/oasdiff](https://github.com/Tufin/oasdiff) + [OpenAPI 3.1](https://spec.openapis.org/oas/v3.1.0) — OpenAPI snapshot diff.
|
||||
|
||||
### Raw 원본 (저장소 내 발췌)
|
||||
|
||||
- [[raw/official-docs/ci-github-actions-vs-gitlab-comparison]] — GitHub Actions `needs:` + `if: success()`가 contract gate 매트릭스에 맞물리는 근거, Jenkins/Tekton의 k8s 인프라 부담.
|
||||
- [[raw/official-docs/ci-openapi-snapshot-diff-tooling]] — springdoc 런타임 추출 + openapi-diff/oasdiff CI 실패 조건, dynamic routing 함정.
|
||||
- [[raw/company-tech-blogs/ci-flaky-test-quarantine-spotify-google]] — Spotify/Google/MS quarantine 인정 vs Fowler 반대 양립, 14d sunset은 절충.
|
||||
- [[raw/official-docs/supply-chain-cosign-keyless-sigstore]] — Fulcio 단명 cert + Rekor transparency log + identity 매칭 정책 필요성.
|
||||
- [[raw/official-docs/cosign-keyless-identity-verification-policy]] — `--certificate-identity` + `--certificate-oidc-issuer` 키리스 검증 강제 (Sigstore docs / cosign issue #3671), GitHub Actions OIDC identity 포맷.
|
||||
- [[raw/official-docs/supply-chain-slsa-provenance-framework]] — SLSA v1.0 build levels, provenance 최소 필드, in-toto attestation, 약식 매핑 정정 필요.
|
||||
- [[raw/official-docs/slsa-v1-provenance-schema]] — SLSA v1.0 provenance 실제 필드명 표(`buildDefinition.*` / `runDetails.*`) + in-toto Statement v1 래퍼 + slsa-verifier 검사 동작. ca-tmpl 약식 명명 정정 근거.
|
||||
- [[raw/official-docs/supply-chain-gradle-vs-maven-dependency-locking]] — Gradle `lockMode = STRICT`, Maven transitive lockfile 부재.
|
||||
- [[raw/official-docs/dx-testcontainers-java-best-practices]] — Spring Boot 3.1+ `@ServiceConnection`, singleton 패턴, CI에서 reuse 금지.
|
||||
- [[raw/official-docs/dx-mise-asdf-tool-versioning]] — `.tool-versions` 사실상 표준, `.sdkmanrc`와의 drift 위험, Temurin 21 LTS.
|
||||
- [[raw/official-docs/dx-devcontainer-spring-boot]] — devcontainer가 보장하는 것/보장하지 않는 것, IDE 종속성.
|
||||
|
||||
### Canonical 참조
|
||||
|
||||
- [[raw/project-notes/ca-skeleton-operational-contract]] §29 Group G-E — DevOps / CI / Supply chain / DX 대안 조사 인덱스.
|
||||
@@ -0,0 +1,64 @@
|
||||
---
|
||||
title: concept / Distributed Tracing & Baggage
|
||||
source_type: llm-generated
|
||||
status: reviewed
|
||||
confidence: high
|
||||
tags: [concept, ca-tmpl, observability, mdc, span-event]
|
||||
related_projects: [ca-tmpl]
|
||||
last_reviewed: 2026-06-15
|
||||
---
|
||||
|
||||
# concept / Distributed Tracing & Baggage
|
||||
|
||||
## Summary
|
||||
|
||||
여러 마이크로서비스를 거쳐 흐르는 단일 요청의 실행 흐름을 시각화하고 진단할 수 있도록 트레이스 ID와 스팬 ID 등의 메타데이터(TraceContext)를 전파하고, 전체 트레이스 수명 주기 동안 요청 전반에 걸쳐 데이터를 전달(Baggage)하는 기술.
|
||||
|
||||
## Standard (공식 정의)
|
||||
|
||||
W3C Distributed Tracing 및 OpenTelemetry 표준 명세에 따른 정의는 다음과 같다.
|
||||
- **traceparent**: 실행 중인 분산 요청의 컨텍스트를 규격화한 W3C 공식 헤더.
|
||||
- 형식: `version-traceId-parentId-traceFlags` (예: `00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01`)
|
||||
- `traceFlags`의 마지막 비트가 `01`이면 샘플링됨(Sampled), `00`이면 샘플링되지 않음(Not-Sampled)을 나타낸다.
|
||||
- **baggage**: 분산 트레이스 경계 전반에 걸쳐 임의의 키-값 쌍 메타데이터를 전파하기 위한 W3C 헤더 규격. 클라이언트 요청 처리 중 하위 모든 마이크로서비스 호출 시에 함께 흘러간다.
|
||||
- 형식: `key1=value1,key2=value2`
|
||||
|
||||
## 한계 / 주의점
|
||||
|
||||
- **보안 경계 허점 (Security Boundary Risk)**: Baggage는 하위 시스템과 외부 네트워크 경계까지 쉽게 유실/전파될 수 있으므로, 민감 정보(자격증명, 개인정보(PII), 비밀 토큰)가 포함될 경우 데이터 유출의 주요 통로가 된다. 따라서 반드시 어댑터 송출 단계에서 엄격한 허용 목록(Allowlist) 필터링을 거치거나 원천 차단해야 한다.
|
||||
- **샘플링 불일치 (Sampling Mismatch)**: 마이크로서비스 상위 계층에서 샘플링되지 않은(`00`) 트레이스 헤더가 다운스트림으로 내려가면 하위 서비스들은 해당 요청에 대한 상세 스팬 지표를 수집하지 않고 드랍할 수 있어, 트레이스 경로가 끊어지는 현상이 발생할 수 있다.
|
||||
|
||||
## Project Application
|
||||
|
||||
- [[wiki/explainer/adapter-outbound.md]]
|
||||
- `TraceContextPropagationInterceptor`가 RestClient 요청 송출 시 MDC(Mapped Diagnostic Context)에 저장된 트레이스 및 배기지 컨텍스트를 가로채 전파함.
|
||||
- `traceparent`는 MDC `trace_id`와 `span_id`를 기반으로 동적으로 조립되어 전송됨 (현재는 추적 서버로 전송하지 않는 기본 뼈대이므로 샘플 플래그는 `00`으로 고정함).
|
||||
- `baggage`의 경우 보안 누출 방지를 위해 오직 **`request_id`**와 **`tenant_id`** 두 가지만 통과시키는 허용 목록 필터링(`BaggageAllowlist.filter`)을 적용함.
|
||||
|
||||
## Claim-backed Knowledge
|
||||
|
||||
| Knowledge Point | Supporting Claims | Confidence | Notes |
|
||||
|---|---|---|---|
|
||||
| W3C traceparent 헤더 포맷 및 전파 규격 | `raw/official-docs/trace-context-w3c-recommendation.md` | `high` | W3C 공식 권고안 |
|
||||
| Baggage API 스펙 및 데이터 필터링 필요성 | `raw/official-docs/baggage-w3c-baggage-spec.md` | `high` | W3C Baggage 사양 |
|
||||
|
||||
## 내가 설명할 수 있어야 하는 것
|
||||
|
||||
- `traceparent` 헤더의 구성 요소와 샘플링 플래그(`01`/`00`)의 역할은 무엇인가?
|
||||
- 왜 Baggage 전파 시 Allowlist 기반의 보안 필터링이 필수적으로 수반되어야 하는가?
|
||||
- 우리 아웃바운드 HTTP 클라이언트의 트레이싱 전파 시 뼈대 코드(Skeleton)의 한계는 무엇이며, 향후 실무 OTel SDK 연동 시 어떻게 대응해야 하는가? (하드코딩된 `00` 샘플링 해제 및 OTel RestClient Interceptor로의 전환)
|
||||
|
||||
## Interview Questions
|
||||
|
||||
- 마이크로서비스 간 분산 트레이싱을 구현할 때 HTTP 헤더 전파(Propagation) 과정과 Baggage 활용 시 주의해야 할 보안 위협에 대해 설명해 주세요.
|
||||
- MDC 기반 트레이싱 컨텍스트와 실제 OpenTelemetry / Micrometer Tracing API의 생명 주기를 멀티스레드 환경에서 어떻게 안전하게 바인딩할 수 있습니까?
|
||||
|
||||
## Do Not Overclaim
|
||||
|
||||
- "MDC 정보가 자동으로 헤더로 전파되므로 어떤 환경에서든 분산 트레이싱이 정상 작동한다"고 과장해서는 안 된다. 멀티스레드 비동기 작업(TaskExecutor 사용 시)이나 리액티브 환경에서는 MDC가 유실되므로 별도의 Context Propagator를 직접 정의하여 스레드 경계를 가로지르는 전파 설계를 갖춰야만 보장된다.
|
||||
|
||||
## Sources
|
||||
|
||||
- [W3C Recommendation for Trace Context](https://www.w3c.org/TR/trace-context/)
|
||||
- [[raw/official-docs/trace-context-w3c-recommendation.md]]
|
||||
- [[raw/official-docs/baggage-w3c-baggage-spec.md]]
|
||||
@@ -0,0 +1,61 @@
|
||||
---
|
||||
title: concept / Fail-Open & Fail-Closed
|
||||
source_type: llm-generated
|
||||
status: reviewed
|
||||
confidence: high
|
||||
tags: [concept, ca-tmpl, architecture, spring-boot, circuit-breaker]
|
||||
related_projects: [ca-tmpl]
|
||||
last_reviewed: 2026-06-15
|
||||
---
|
||||
|
||||
# concept / Fail-Open & Fail-Closed
|
||||
|
||||
## Summary
|
||||
|
||||
장애가 발생했을 때 시스템이 취하는 두 가지 상반된 처리 모델.
|
||||
- **Fail-Open (실패 개방)**: 외부 시스템/인프라 장애 시 요청을 통과시키거나 대체 수단(Cache-Miss 등)으로 우회하여 핵심 비즈니스 기능을 계속 수행한다.
|
||||
- **Fail-Closed (실패 폐쇄)**: 외부 시스템/인프라 장애 발생 시 즉시 시스템 전체 또는 해당 기능을 중단하고 예외를 전파하여 불완전한 상태에서의 처리를 강력히 차단한다.
|
||||
|
||||
## Standard (공식 정의)
|
||||
|
||||
공식적인 소프트웨어 및 인프라 설계 기법(SRE 및 분산 아키텍처)에 따르면 두 모델의 정의는 다음과 같다.
|
||||
- **Fail-Open**: 보안 게이트웨이나 캐시 계층 같은 비핵심 인프라가 먹통이 되었을 때, 인프라 부재 상태를 '허용'하여 전체 서비스 가용성을 최대화하는 모델. 예컨대 캐시 서버가 죽으면 DB를 조회(Cache-miss로 취급)하도록 하여 기능 정지를 막는다.
|
||||
- **Fail-Closed**: 원격 트랜잭션, 아웃박스 발행기 등 데이터 정합성이 극도로 중요한 구간에서 하위 시스템이 오류를 뱉으면 호출자에게 오류를 전파하고 전체 처리를 롤백하는 모델.
|
||||
|
||||
## 한계 / 주의점
|
||||
|
||||
- **Fail-Open의 함정**: 가용성은 유지되나 백엔드 DB에 트래픽이 폭증(Cache Stampede)하거나, 장애가 전파되어 전체 시스템이 도미노처럼 무너질 위험이 있다. 따라서 반드시 서킷 브레이커, Rate Limiter 같은 보호막이 함께 작동해야 한다.
|
||||
- **Fail-Closed의 함정**: 가용성이 급격히 떨어진다. 단 하나의 마이크로서비스나 인프라 장애로 인해 전체 서비스가 5xx 에러를 뿜으며 중단될 수 있다.
|
||||
|
||||
## Project Application
|
||||
|
||||
- [[wiki/explainer/adapter-outbound.md]]
|
||||
- `FailOpenCacheStore`에서는 캐시 인프라 장애 시 예외를 삼키고 캐시 미스로 처리하는 Fail-Open을 적용함.
|
||||
- `KafkaOutboxMessagePublishAdapter`는 아웃박스 이벤트 유실 방지를 위해 Fail-Closed를 적용하여 예외를 반드시 상위로 전파함.
|
||||
|
||||
## Claim-backed Knowledge
|
||||
|
||||
| Knowledge Point | Supporting Claims | Confidence | Notes |
|
||||
|---|---|---|---|
|
||||
| 캐시 붕괴 시 DB 조회 등으로 가용성을 지키는 것 | `raw/official-docs/cache-aside-vs-write-through-aws.md` | `high` | AWS 캐시 아키텍처 가이드라인 |
|
||||
| Fail-Open 구조에서 유실되지 않아야 할 이벤트 처리 | `raw/official-docs/event-sourcing-vs-outbox-microservices-io.md` | `high` | 마이크로서비스 트랜잭션 보장 기법 |
|
||||
|
||||
## 내가 설명할 수 있어야 하는 것
|
||||
|
||||
- Fail-Open과 Fail-Closed의 극명한 결정 기준은 무엇인가? (가용성 우선 vs 정합성/안전성 우선)
|
||||
- 우리 프로젝트의 캐시 스토어와 아웃박스 발행기는 각각 어떤 모델을 따르며 그 이유는 무엇인가?
|
||||
- Fail-Open 적용 시 백엔드 DB 보호를 위해 어떤 추가 장치가 필요한가?
|
||||
|
||||
## Interview Questions
|
||||
|
||||
- Redis 캐시 서버가 갑자기 중단되었을 때, 귀하의 시스템은 어떻게 동작하며 이를 위해 어떤 resilience 패턴을 적용했습니까?
|
||||
- 메시지 발행 실패 시 예외를 상위로 전파하는 구조(Fail-Closed)와 삼켜버리는 구조(Fail-Open)의 아키텍처적 트레이드오프를 설명하십시오.
|
||||
|
||||
## Do Not Overclaim
|
||||
|
||||
- "Fail-Open을 적용했으므로 인프라가 죽어도 시스템에 아무런 영향이 없다"고 과장해서는 안 된다. 캐시가 없으면 DB 부하가 치솟으므로 성능 저하와 2차 장애 위험이 상존함을 인정해야 한다.
|
||||
|
||||
## Sources
|
||||
|
||||
- [AWS Cache-Aside caching strategy](https://aws.amazon.com/caching/)
|
||||
- [[raw/official-docs/cache-aside-vs-write-through-aws.md]]
|
||||
@@ -0,0 +1,141 @@
|
||||
---
|
||||
title: Idempotency Key 설계 (triple scope vs Stripe/Square/Toss)
|
||||
source_type: llm-generated
|
||||
status: draft
|
||||
confidence: medium
|
||||
tags: [idempotency, api-design, distributed-systems]
|
||||
related_projects: [ca-skeleton]
|
||||
last_reviewed: 2026-05-22
|
||||
---
|
||||
|
||||
# Idempotency Key 설계 (triple scope vs Stripe/Square/Toss)
|
||||
|
||||
> Layer: `wiki/concepts/` — 일반 개념. 내 프로젝트 사실은 [[raw/branch-notes/feature-rate-limit-idempotency-contract]] / [[raw/project-notes/ca-skeleton-operational-contract]] §29 Topic 5 참조.
|
||||
|
||||
## Summary
|
||||
|
||||
Idempotency key는 동일한 mutating request의 재시도를 서버가 인식하도록 클라이언트가 생성하는 고유 값입니다. ca-tmpl은 key shape를 `(authenticatedPrincipal, idempotencyKey, useCaseName)` triple + DB table + 24h TTL + 200ms in-flight wait + fingerprint mismatch 시 HTTP 422로 정의합니다. 이 설계는 (a) triple scope로 endpoint dimension을 명시해 cross-use-case 충돌을 방지하고, (b) 24h TTL로 스토리지·키 추측 공격면을 최소화하며, (c) 200ms wait로 IETF draft의 즉시 409보다 retry 친화적인 hybrid를 채택하고, (d) body fingerprint mismatch를 409(in-flight)와 분리해 422로 표현한 점이 특징입니다.
|
||||
|
||||
## Standard (공식 정의)
|
||||
|
||||
### IETF draft (`draft-ietf-httpapi-idempotency-key-header`, draft-07, 2025-10)
|
||||
|
||||
- `Idempotency-Key` HTTP request header를 정의 — Stripe / PayPal / Square / Adyen이 공통 참조하는 사실상의 헤더 표준 초안 (정식 RFC 아님).
|
||||
- 인용: *"Uniqueness of the key MUST be defined by the resource owner and MUST be implemented by the clients."* — key scope 정의는 **resource owner의 책임**으로 위임.
|
||||
- 인용: *"If there is an attempt to reuse an idempotency key with a different request payload, the resource SHOULD reply with a HTTP `422` status code."*
|
||||
- 인용: *"The request was retried before the original request completed. The resource SHOULD respond with a resource conflict error"* (HTTP `409`).
|
||||
- TTL은 시간을 명시하지 않고 "정책을 정해 문서화하라"만 강제.
|
||||
|
||||
### Stripe v1 pair → v2 triple
|
||||
|
||||
- v1: `(account, Idempotency-Key)` pair. TTL 24h minimum. 5xx 응답까지 그대로 replay됨(결정적 응답).
|
||||
- v2: *"idempotent request replay occurs when requests use the same idempotency key, are made to the same API, occur within the scope of the same account or sandbox, and occur within 30 days of each other."* → `(account/sandbox, API, key)` triple. TTL 30일.
|
||||
- fingerprint mismatch: *"The idempotency layer compares incoming parameters to those of the original request and errors if they're not the same."* (status code는 명시 안 함).
|
||||
|
||||
### Square (Common API patterns)
|
||||
|
||||
- `idempotency_key`를 **body 필드**로 받음 (header 표준 미준수). endpoint별 dedup → `(merchant_account, endpoint, idempotency_key)` 사실상 triple.
|
||||
- fingerprint mismatch: *"If you use the same idempotency key but change the `CreatePayment` request ... you get an error indicating that you used the idempotency key previously."*
|
||||
- TTL 미공개, in-flight 동작 미정의.
|
||||
- 특수 디자인: `cancel-payment-by-idempotency-key` — 키 자체를 resource handle로 사용.
|
||||
|
||||
### PayPal (Idempotency-Replay / `PayPal-Request-Id`)
|
||||
|
||||
- header 이름이 `Idempotency-Key`가 아닌 `PayPal-Request-Id` (Stripe·IETF와 다름).
|
||||
- scope: `(request-id, API call type)`. TTL **45일** — 조사된 reference 중 최장.
|
||||
|
||||
### Toss Payments (기술블로그)
|
||||
|
||||
- 4-tuple `(account, key, URL, method)` + TTL **15일**. ca-tmpl보다 dimension 1개 많고 TTL 더 김.
|
||||
- header 이름은 `Idempotency-Key`로 IETF/Stripe와 동일.
|
||||
|
||||
### AWS Lambda Powertools (idempotency utility)
|
||||
|
||||
- key를 **server-derived content-hash** `(function_name, payload_hash)`로 도출 → 클라이언트가 header를 보낼 필요 없음.
|
||||
- 동일 payload면 동일 hash → 자동 dedup. body 변경 = 서로 다른 operation으로 취급.
|
||||
|
||||
### GitHub REST API
|
||||
|
||||
- API-level idempotency dedup을 제공하지 않음. 클라이언트 측 retry 정책에만 의존.
|
||||
|
||||
### Brandur (Stripe 엔지니어 글) — Postgres locked_at lock
|
||||
|
||||
- Postgres 테이블 + atomic phase 모델 + `locked_at` column으로 in-flight를 표현. abandoned key 회수는 별도 정책 필요.
|
||||
- Stripe 내부 구현의 가장 자세한 reference 문서.
|
||||
|
||||
## 한계 / 주의점
|
||||
|
||||
| 옵션 | 한계 / 주의점 |
|
||||
|------|-----------|
|
||||
| **Stripe v1 pair `(account, key)`** | endpoint dimension 부재 → API 추가 시 같은 키가 의도하지 않은 use case에 재사용될 위험. v2에서 API dimension 추가로 직접 보강. |
|
||||
| **Stripe v2 triple `(account, API, key)`** | IETF "resource owner가 정의" 범위 내에서 가장 엄격한 reference. TTL 30일은 보안 surface와 비용에 부담. |
|
||||
| **Square endpoint-scoped (body field)** | header 표준 미준수 → 미들웨어/게이트웨이 레벨에서 dedup 불가. URL path 변경 시 endpoint dimension 매핑이 깨질 수 있음. TTL 미공개로 클라이언트가 retry window를 가늠 못 함. |
|
||||
| **PayPal 45일 TTL** | 스토리지 비용 크고 키 추측 공격면이 가장 넓음. header 이름이 표준과 달라 멀티 PG 통합 비용 발생. |
|
||||
| **Toss 4-tuple `(account, key, URL, method)`** | URL/method가 scope에 들어가 HTTP path 변경(예: `/v1/payments` → `/v2/payments`) 시 같은 의미의 재시도가 다른 키로 인식. version migration에 취약. |
|
||||
| **AWS Powertools content-hash** | 클라이언트가 키를 누락해도 동작하는 장점이 있으나, body의 사소한 변경(여백/필드 순서)이 다른 operation으로 분류 — JSON canonicalization 정책 필수. |
|
||||
| **Brandur Postgres lock (`locked_at`)** | `locked_at`만으로는 process crash 후 stale lock이 남을 수 있음 → abandoned key 회수(timeout-based release) 정책이 별도로 필요. |
|
||||
| **IETF draft 자체** | draft 단계로 정식 RFC 아님. TTL / 저장 layer / lock 정책 등 운영 핵심을 표준이 다루지 않아 구현체별 동작이 제각각. |
|
||||
| **No API-level dedup (GitHub)** | 인프라/미들웨어 부담은 없으나 클라이언트가 모든 중복 위험을 책임 → 결제·금융 도메인에는 부적합. |
|
||||
|
||||
### 흔한 오해
|
||||
|
||||
- "Stripe pair보다 ca-tmpl이 무조건 안전" — **v1 한정** 비교. Stripe v2 triple과는 사실상 동등.
|
||||
- "IETF draft 422는 fingerprint mismatch의 표준" — draft는 `SHOULD`이지 `MUST` 아님. 구현체별로 400/409/422가 혼재.
|
||||
- "TTL은 길수록 안전하다" — 길수록 클라이언트 retry window는 늘지만 스토리지 비용과 키 추측 공격면도 함께 증가.
|
||||
|
||||
## Project Application
|
||||
|
||||
- [[wiki/projects/ca-tmpl/idempotency-key-design]] — ca-tmpl 의사결정 기록 (현재 `documented-only`, Phase C2 미진입). 실제 구현 여부는 project 문서 참조.
|
||||
- [[raw/branch-notes/feature-rate-limit-idempotency-contract]] — key shape / TTL / 저장소 SSOT (triple scope + DB table + 24h TTL + 200ms wait + 422 fingerprint mismatch + 409 in-flight 결정의 owning branch).
|
||||
- [[raw/branch-notes/feature-api-contract-baseline]] — `Idempotency-Key` HTTP header 표준 (consume only, shape은 위 branch가 owns).
|
||||
- [[raw/project-notes/ca-skeleton-operational-contract]] §29 Topic 5 — 비교표·결정 라인.
|
||||
|
||||
## Interview Questions
|
||||
|
||||
- **Q1.** `useCaseName` (또는 endpoint) dimension을 scope에 포함시키는 이유는? Stripe v1 pair에서 어떤 충돌이 발생할 수 있는가?
|
||||
- **Q2.** TTL을 24h로 잡은 trade-off는? PayPal 45일·Stripe v2 30일과 비교했을 때 어떤 비용·위험을 줄이고, 어떤 use case(예: 결제·송금 long-running)에서는 부족한가?
|
||||
- **Q3.** 동시 도착 요청에 대해 200ms wait를 둔 의미는? IETF draft의 즉시 409와 비교했을 때 client retry 동작이 어떻게 달라지는가?
|
||||
- **Q4.** 같은 key + 다른 body를 422로, in-flight 충돌을 409로 분리한 이유는? 두 상황을 같은 코드로 합치면 어떤 클라이언트 버그가 가려지는가?
|
||||
- **Q5.** key가 클라이언트 생성 unique value라면 추측 공격면은 어떻게 평가해야 하는가? TTL이 길수록 공격면이 어떻게 변하고, AWS Powertools content-hash 방식은 이 문제를 어떻게 우회하는가?
|
||||
|
||||
## Do Not Overclaim
|
||||
|
||||
- "ca-tmpl triple이 Stripe pair보다 무조건 안전하다"고 말하지 않습니다. **v1 pair 한정** 비교이며, Stripe v2 triple과는 사실상 동급.
|
||||
- "ca-tmpl이 IETF Idempotency-Key spec을 완전히 준수한다"고 단정하지 않습니다. **draft 단계**이고, 422 fingerprint mismatch는 `SHOULD`이며, ca-tmpl의 200ms wait는 draft의 "즉시 409" 권고와 다른 선택입니다.
|
||||
- "Square가 표준 미준수라서 열등하다"고 단정하지 않습니다. body 필드 방식은 `cancel-by-idempotency-key`처럼 키를 resource handle로 쓰는 API 디자인의 장점이 있습니다.
|
||||
- "AWS Powertools content-hash가 header 방식의 상위 호환"이라고 말하지 않습니다. body의 사소한 변경(여백/필드 순서/timestamp)이 다른 operation으로 분류되므로 canonicalization 정책이 함께 가야 동작합니다.
|
||||
- "Brandur lock 패턴을 그대로 채택했다"고 말하지 않습니다. ca-tmpl은 200ms wait + unique constraint hybrid이며 Brandur `locked_at` lock의 변형입니다.
|
||||
- ca-tmpl 24h TTL이 "업계 표준"이라고 표현하지 않습니다. Stripe v1 최소값과 일치할 뿐이고, 다른 도메인 reference는 모두 더 길게 잡습니다.
|
||||
|
||||
## Sources
|
||||
|
||||
### 공식 / 표준
|
||||
|
||||
- [IETF draft — The Idempotency-Key HTTP Header Field](https://datatracker.ietf.org/doc/draft-ietf-httpapi-idempotency-key-header/) — 422/409 status code 근거, "resource owner가 scope 정의" 권한 위임.
|
||||
- [Stripe API Reference — Idempotent requests](https://docs.stripe.com/api/idempotent_requests) — v1 pair / v2 triple scope, 24h–30d TTL, 5xx replay.
|
||||
- [Square API — Idempotency (Common API patterns)](https://developer.squareup.com/docs/build-basics/common-api-patterns/idempotency) — body 필드 방식, fingerprint mismatch error.
|
||||
- [PayPal — Idempotency](https://developer.paypal.com/api/rest/reference/idempotency/) — `PayPal-Request-Id`, 45일 TTL.
|
||||
- [AWS Lambda Powertools — Idempotency utility](https://docs.powertools.aws.dev/lambda/python/latest/utilities/idempotency/) — content-hash 기반.
|
||||
- [GitHub REST API](https://docs.github.com/en/rest) — API-level dedup 없음.
|
||||
|
||||
### 구현 reference
|
||||
|
||||
- [Brandur Leach — Implementing Stripe-like Idempotency Keys in Postgres](https://brandur.org/idempotency-keys) — atomic phase + `locked_at` lock.
|
||||
|
||||
### raw 보존본
|
||||
|
||||
- [[raw/official-docs/idempotency-ietf-draft]]
|
||||
- [[raw/official-docs/idempotency-stripe-api-ref]]
|
||||
- [[raw/official-docs/idempotency-square-api]]
|
||||
- [[raw/official-docs/idempotency-paypal-docs]]
|
||||
- [[raw/official-docs/idempotency-aws-lambda-powertools]]
|
||||
- [[raw/official-docs/idempotency-no-api-level-github-rest]]
|
||||
- [[raw/company-tech-blogs/idempotency-brandur-stripe-postgres]]
|
||||
- [[raw/company-tech-blogs/idempotency-toss-payments-techblog]]
|
||||
- [[raw/company-tech-blogs/idempotency-redis-vs-db-storage]]
|
||||
|
||||
### canonical 참조
|
||||
|
||||
- [[raw/project-notes/ca-skeleton-operational-contract]] §29 Topic 5
|
||||
- [[raw/branch-notes/feature-rate-limit-idempotency-contract]]
|
||||
- [[raw/branch-notes/feature-api-contract-baseline]]
|
||||
@@ -0,0 +1,60 @@
|
||||
---
|
||||
title: concept / Idempotency
|
||||
source_type: llm-generated
|
||||
status: reviewed
|
||||
confidence: high
|
||||
tags: [concept, ca-tmpl, api-design, spring-boot, idempotency]
|
||||
related_projects: [ca-tmpl]
|
||||
last_reviewed: 2026-06-15
|
||||
---
|
||||
|
||||
# concept / Idempotency
|
||||
|
||||
## Summary
|
||||
|
||||
동일한 요청을 한 번 보내는 것과 여러 번 연속해서 보내는 것이 서버의 상태에 미치는 영향이 동일한 성질.
|
||||
- 안전한 메서드(Safe Methods) 및 멱등한 메서드(Idempotent Methods)를 구분하여 HTTP 클라이언트의 재시도 안전성을 보장하는 기반이 된다.
|
||||
|
||||
## Standard (공식 정의)
|
||||
|
||||
RFC 9110 HTTP Semantics 규격에 따른 정의는 다음과 같다.
|
||||
- **Idempotent Methods**: `GET`, `HEAD`, `PUT`, `DELETE`, `OPTIONS`, `TRACE`는 여러 번 수행해도 리소스의 최종 상태가 동일하다. 따라서 transient network failure 발생 시 클라이언트가 안전하게 재시도할 수 있다.
|
||||
- **Non-Idempotent Methods**: `POST`와 `PATCH`는 호출할 때마다 새로운 리소스가 생성되거나 상태 변경이 누적될 수 있어, 재시도가 안전하지 않다. 중복 처리를 방지하려면 별도의 `Idempotency-Key` 헤더와 같은 고유 분산 락/식별 메커니즘이 합의되어야 한다.
|
||||
|
||||
## 한계 / 주의점
|
||||
|
||||
- **멱등성은 서버가 보장해야 하는 계약이다**: 클라이언트 입장에서 단순히 `GET`을 보낸다고 해서 서버가 내부적으로 멱등하게 처리하지 않고 사이드 이펙트(예: 조회수 1 증가 등)를 누적한다면 엄격한 의미의 멱등성은 깨질 수 있다. 그러나 HTTP 명세상 클라이언트는 RFC 규격을 신뢰하고 재시도를 감행하게 된다.
|
||||
- **Idempotency-Key 계약의 부재**: 아웃바운드 연동 시 상대방 서버가 `Idempotency-Key` 사양을 구현하지 않았다면, `POST`나 `PATCH` 호출 실패 시 클라이언트는 네트워크 지연 등의 원인으로 인해 요청이 이미 처리되었는지 알 수 없어 재시도가 불가능하다.
|
||||
|
||||
## Project Application
|
||||
|
||||
- [[wiki/explainer/adapter-outbound.md]]
|
||||
- `OutboundRetryPolicy`는 RFC 9110 규격에 정의된 멱등한 메서드(`GET`, `HEAD`, `PUT`, `DELETE`)에 대해서만 `shouldRetry`가 `true`를 반환하도록 설계되어 있음. `POST`/`PATCH`는 부작용 방지를 위해 즉시 `false`를 뱉고 재시도를 전면 금지함.
|
||||
|
||||
## Claim-backed Knowledge
|
||||
|
||||
| Knowledge Point | Supporting Claims | Confidence | Notes |
|
||||
|---|---|---|---|
|
||||
| RFC 9110 기반 멱등 메서드 리스트 및 재시도 타당성 | `raw/official-docs/rfc9110-http-semantics.md` | `high` | RFC 9110 표준 명세 |
|
||||
| non-idempotent API 재시도를 위한 Idempotency-Key 계약 | `raw/official-docs/idempotency-stripe-api-ref.md` | `high` | Stripe의 실무 멱등 키 처리 패턴 |
|
||||
|
||||
## 내가 설명할 수 있어야 하는 것
|
||||
|
||||
- `GET`과 `PUT`은 왜 멱등하고 `POST`와 `PATCH`는 왜 비멱등한가?
|
||||
- 왜 우리 아웃바운드 HTTP 클라이언트는 `POST`/`PATCH` 요청에 대해 재시도를 원천 차단하는가? (Idempotency-Key 계약 미정의에 따른 사이드 이펙트 방지)
|
||||
- 비멱등 메서드를 꼭 재시도해야 할 경우, 인프라 및 애플리케이션 계층에서 어떤 설계를 보완해야 하는가?
|
||||
|
||||
## Interview Questions
|
||||
|
||||
- HTTP 메서드 중 멱등성을 보장하는 메서드와 그렇지 않은 메서드를 구분하고, 네트워크 타임아웃 발생 시 각각에 대한 재시도 전략을 설명해 주세요.
|
||||
- 아웃바운드 호출 시 POST 요청의 재시도를 제한하는 시스템에서, 일시적인 네트워크 순단 상황을 어떻게 극복할 수 있겠습니까?
|
||||
|
||||
## Do Not Overclaim
|
||||
|
||||
- "멱등한 메서드만 재시도하므로 어떠한 데이터 정합성 문제도 발생하지 않는다"고 확언해서는 안 된다. 업스트림(상대방 서버)이 표준을 무시하고 내부 구현을 비멱등하게 작성했을 경우 여전히 사이드 이펙트가 발생할 수 있음을 인지해야 한다.
|
||||
|
||||
## Sources
|
||||
|
||||
- [RFC 9110 Section 9.3: Idempotent Methods](https://www.rfc-editor.org/rfc/rfc9110.html)
|
||||
- [[raw/official-docs/rfc9110-http-semantics.md]]
|
||||
- [[raw/official-docs/idempotency-stripe-api-ref.md]]
|
||||
@@ -0,0 +1,142 @@
|
||||
---
|
||||
title: Multi-tenancy Isolation 패턴 (Pool vs Silo vs Bridge)
|
||||
source_type: llm-generated
|
||||
status: draft
|
||||
confidence: medium
|
||||
tags: [multi-tenancy, saas, isolation]
|
||||
related_projects: [ca-skeleton]
|
||||
last_reviewed: 2026-05-22
|
||||
---
|
||||
|
||||
# Multi-tenancy Isolation 패턴 (Pool vs Silo vs Bridge)
|
||||
|
||||
> Layer: `wiki/concepts/` — 일반 개념. 실제 적용은 `wiki/projects/` 또는 raw 브랜치 노트 참조.
|
||||
|
||||
## Summary
|
||||
|
||||
Multi-tenancy isolation은 "여러 tenant가 같은 소프트웨어 인스턴스를 어느 수준까지 공유하는가"의 스펙트럼이다. AWS SaaS Lens는 이를 **Silo / Pool / Bridge** 3분류로 정리하고, Hibernate는 ORM 레벨에서 **DATABASE / SCHEMA / DISCRIMINATOR** 3 strategy로 공식 지원하며, Azure는 **Deployment Stamps** 패턴으로 hybrid를 다룬다. ca-tmpl은 **opt-in(`APP_TENANT_ENABLED=true` 시만 활성) + shared DB + `tenant_id` column(ULID) + JWT claim 우선 resolution** 조합을 baseline으로 채택한다. 이는 AWS Pool 모델 + Hibernate DISCRIMINATOR 전략에 해당하며, B2B 초기 단계(tenant 수 수십~수백 단위)에 isolation 비용 대비 운영 단순성을 우선한 의도적 선택이다. opt-in 설계의 의의는 single-tenant deployment에서는 tenant 로직 자체를 비활성화하여 skeleton의 적용 범위를 넓힌 점에 있다. **Migration trigger 3가지**는 (a) 규제(금융·의료) isolation 강제, (b) tenant 수 수백~수천 + 단일 row 수 수억 도달, (c) enterprise tier 등장으로 isolation을 가격에 반영해야 할 때다.
|
||||
|
||||
## Standard (공식 정의)
|
||||
|
||||
### AWS SaaS Tenant Isolation Strategies (Whitepaper) — Silo / Pool / Bridge
|
||||
|
||||
- **Silo**: tenant마다 별도 stack(compute/DB/network까지 분리). isolation 최강, 비용 최대.
|
||||
- **Pool**: 모든 tenant가 동일 infra와 schema를 공유, `tenant_id` 컬럼으로 row-level 구분.
|
||||
- **Bridge**: 일부 리소스는 silo, 일부는 pool. 예) DB는 silo, app server는 pool.
|
||||
- AWS는 "Authentication is not isolation. You must enforce isolation at the resource layer"라고 명시한다.
|
||||
|
||||
### Hibernate ORM Multi-tenancy — DATABASE / SCHEMA / DISCRIMINATOR
|
||||
|
||||
- **DATABASE**: tenant별 별도 데이터베이스.
|
||||
- **SCHEMA**: 동일 DB, tenant별 별도 schema.
|
||||
- **DISCRIMINATOR**: 동일 schema, `tenant_id` 컬럼. Hibernate 6부터 native 지원(이전엔 Filter로 우회).
|
||||
- 활성화는 `hibernate.tenant_identifier_resolver` + `hibernate.multi_tenant_connection_provider` 설정으로 수행. `CurrentTenantIdentifierResolver`가 ThreadLocal/SecurityContext에서 tenant를 결정.
|
||||
|
||||
### Azure Architecture Center — Deployment Stamps (Hybrid)
|
||||
|
||||
- Tenancy를 "fully shared → shared compute, isolated DB → isolated stamp → isolated subscription" **스펙트럼**으로 정의.
|
||||
- **Deployment Stamps**: 동일한 스택을 단위(stamp)로 복제하고, stamp 안에 N개 tenant를 pool. tier별로 stamp 크기와 isolation 수준을 다르게 둘 수 있음.
|
||||
- Microsoft는 "There's no single right approach to multitenancy"라고 명시 — 비즈니스 모델·규제·확장성·비용에 따라 모델이 달라진다.
|
||||
|
||||
### Tenant Resolution 방식 (isolation과 직교)
|
||||
|
||||
- **JWT claim**: token 서명 검증으로 위변조 방지. 가장 안전.
|
||||
- **Subdomain (`{tenant}.app.com`)**: UX 친화적, 단 wildcard DNS/TLS 필요.
|
||||
- **Custom header (`X-Tenant-Id`)**: 단순하나 외부 trust boundary에서 단독 신뢰 금지.
|
||||
- **Path (`/t/{tenant}/...`)**: routing 자연스럽지만 모든 client URL 변경.
|
||||
|
||||
## 한계 / 주의점
|
||||
|
||||
각 대안의 한계는 다음과 같다.
|
||||
|
||||
### shared DB + tenant_id (Pool / Hibernate DISCRIMINATOR)
|
||||
|
||||
- **Noisy neighbor**: hot tenant가 같은 인스턴스 전체에 영향.
|
||||
- **규제 isolation 불가**: application bug 한 줄로 cross-tenant leak 가능. HIPAA·FedRAMP·금융권은 storage 레벨 분리를 요구하는 경우가 있어 Pool로 충족 어려움.
|
||||
- **Index 비용**: tenant로 filter하는 모든 index에 `tenant_id`를 leading column으로 포함해야 plan이 효율적.
|
||||
- **Native query/JDBC bypass 위험**: JPQL 경로 외에서 tenant filter 누락 시 leak.
|
||||
|
||||
### Subdomain-based resolution
|
||||
|
||||
- **Wildcard DNS와 wildcard TLS 인증서** 필요. custom domain 지원 시 per-domain 인증서 자동화 추가.
|
||||
- Let's Encrypt rate limit은 "registered domain당 주 50개 인증서"로 보고되나 — 정확 수치와 적용 범위는 `needs-confirmation` (raw 발췌 기준).
|
||||
- **DNS propagation 지연**, **subdomain takeover 위험**(tenant 삭제 후 DNS record 미정리), **CORS/cookie domain 설정 복잡성**.
|
||||
- Local dev는 `lvh.me`/`nip.io`/hosts 수정 필요.
|
||||
|
||||
### JWT claim only
|
||||
|
||||
- claim 검증을 한 곳이라도 빠뜨리면 cross-tenant 위험.
|
||||
- token 재발급 없이 tenant 전환 불가 → admin/support 운영 동선 제약.
|
||||
- IdP와 강결합 → tenant 정보 변경 시 token rotation 정책 필요.
|
||||
|
||||
### Schema-per-tenant (Hibernate SCHEMA)
|
||||
|
||||
- Postgres metadata(`pg_class`, `pg_attribute`) overhead가 tenant 수 증가에 따라 누적.
|
||||
- Stripe/Citus 자료에 따르면 "수백~수천 tenant"에서 catalog bloat·autovacuum·plan cache miss가 문제로 보고됨 — 다만 정확한 임계 수치 인용은 `needs-confirmation`.
|
||||
- **Connection pooling 난이도**: `search_path` 전환이 plan cache를 무효화. HikariCP per tenant vs single pool 설계 선택 필요.
|
||||
- 마이그레이션이 tenant 수만큼 반복(Flyway `schemas` 옵션으로 일괄 처리 가능하나 추가/삭제 자동화 필요).
|
||||
|
||||
### Database-per-tenant (Silo)
|
||||
|
||||
- Isolation 가장 강함, **운영 비용 폭증**: 마이그레이션·백업·모니터링이 모두 tenant 수에 비례.
|
||||
- Connection pool이 (tenant 수 × pool size)로 폭발 → connection multiplexing(예: PgBouncer) 필수.
|
||||
- AWS 계정·서비스 limit에 부딪힐 수 있음.
|
||||
- 비용은 silo > bridge > pool 순.
|
||||
|
||||
### Hybrid (Azure Deployment Stamps / AWS Bridge)
|
||||
|
||||
- 두 가지 이상 모델을 동시 운영 → **운영 복잡도 최고**.
|
||||
- Tier 승급(pool → silo) 시 **데이터 이동 절차** 필요.
|
||||
- Routing layer + tenant catalog가 사실상 control plane이 되어, 가용성 single point가 되지 않도록 분산 필요.
|
||||
- 작은 팀에서 도입하면 ROI 부정. 일반적으로 product-market fit 이후 단계에서 검토.
|
||||
|
||||
### 공통 오해
|
||||
|
||||
- "Pool이면 무조건 싸다"는 거짓 — 노이즈/검증 비용이 일정 규모 이상에선 silo와 역전될 수 있음.
|
||||
- "Subdomain이면 자동 isolation" 거짓 — resolution과 isolation은 직교. subdomain은 routing일 뿐 storage 분리를 보장하지 않음.
|
||||
- "JWT claim만 있으면 안전" 거짓 — repository·query 레이어에서 tenant filter를 강제하지 않으면 claim의 의미가 없음.
|
||||
|
||||
## Project Application
|
||||
|
||||
- [[wiki/projects/ca-tmpl/multi-tenancy-isolation-patterns]] — ca-tmpl 의사결정 기록 (현재 `documented-only`, Phase C2 미진입). 실제 구현 여부는 project 문서 참조.
|
||||
ca-tmpl은 본 개념을 다음 위치에서 적용·문서화한다. concept 문서는 등급을 매기지 않으며, 검증 수준은 각 프로젝트/브랜치 노트에서 판정한다.
|
||||
|
||||
- [[raw/branch-notes/feature-tenant-context-policy]] — tenant resolution(JWT > header admin only > subdomain fallback) + isolation SSOT
|
||||
- [[raw/branch-notes/feature-repository-access-permission-contract]] — `CROSS_TENANT_ADMIN` capability, repository 레벨 tenant filter 강제 contract
|
||||
- [[raw/project-notes/ca-skeleton-operational-contract]] — §10 Repository Access Permission Contract, §29 Topic 6 Multi-tenancy Isolation
|
||||
|
||||
## Interview Questions
|
||||
|
||||
- AWS SaaS Lens의 Pool/Silo/Bridge는 무엇이 다르고, 어떤 상황에서 어떤 모델을 선택하나?
|
||||
- Tenant ID를 JWT claim과 HTTP header 중 어디서 읽어야 하며, 둘을 동시에 허용한다면 어떤 trust 기준을 두는가?
|
||||
- shared DB + tenant_id에서 schema-per-tenant 또는 db-per-tenant로 마이그레이션을 트리거하는 조건은 무엇인가?
|
||||
- Cross-tenant 침해를 막기 위해 어느 레이어(JWT 검증 / SecurityContext / repository / DB)에 어떤 방어가 필요한가?
|
||||
- Tenant 식별자에 ULID와 UUID 중 어느 쪽을 쓰는 게 적합하며, 각 선택의 trade-off는 무엇인가?
|
||||
|
||||
## Do Not Overclaim
|
||||
|
||||
- "shared DB + tenant_id가 항상 우월하다"고 말하지 말 것 — 규제 산업·data residency 요구가 있는 도메인에서는 Silo가 필수 또는 사실상 강제다.
|
||||
- Stripe/Citus의 schema-per-tenant 한계치(예: "정확히 N tenant에서 한계")는 **정확 인용 wording이 미완**이며 raw 자료는 `needs-confirmation` 상태다. 면접/이력서에서는 "수백~수천 단위에서 catalog overhead가 보고된다" 정도로 출처(Citus blog)와 함께만 언급할 것.
|
||||
- "Atlassian이 그렇게 하니까 best practice"라고 말하지 말 것 — company-tech-blog 사례는 관점·증거이지 공식 기준이 아니다.
|
||||
- "JWT claim만 검증하면 multi-tenant가 안전하다"는 단정 금지 — claim은 입구일 뿐 storage layer 강제가 별도로 필요하다.
|
||||
- ca-tmpl 적용 사실(예: ULID 채택 이유, capability 설계)은 본 concept 문서가 아니라 `wiki/projects/` 또는 branch-notes에서 검증 등급과 함께 진술할 것. "내가 했다"는 표현은 concept 레이어에 두지 않는다.
|
||||
|
||||
## Sources
|
||||
|
||||
- [AWS Whitepaper — SaaS Tenant Isolation Strategies](https://docs.aws.amazon.com/whitepapers/latest/saas-tenant-isolation-strategies/saas-tenant-isolation-strategies.html) — Silo/Pool/Bridge 분류 baseline
|
||||
- [Hibernate ORM User Guide — Multi-tenancy](https://docs.jboss.org/hibernate/orm/current/userguide/html_single/Hibernate_User_Guide.html#multitenacy) — DATABASE/SCHEMA/DISCRIMINATOR 공식 strategy
|
||||
- [Azure Architecture Center — Multitenant SaaS](https://learn.microsoft.com/en-us/azure/architecture/guide/multitenant/overview) — Deployment Stamps / hybrid spectrum
|
||||
- [Citus — Designing your SaaS DB for High Scalability](https://www.citusdata.com/blog/2016/10/03/designing-your-saas-database-for-high-scalability/) — schema vs shared schema 한계치 (company-tech-blog, needs-confirmation)
|
||||
- [Auth0 — Multi-tenant applications](https://auth0.com/docs/get-started/auth0-overview/create-tenants/multiple-tenants) — tenant resolution(subdomain/JWT/header) 비교
|
||||
- [Vercel — Multi-tenant Next.js Guide](https://vercel.com/guides/nextjs-multi-tenant-application) — subdomain routing 실무
|
||||
- [AWS APN Blog — Hybrid Tenant Isolation](https://aws.amazon.com/blogs/apn/) — tier-based hybrid 사례
|
||||
- [Atlassian Engineering — Cloud Architecture Guidelines](https://www.atlassian.com/engineering/cloud-architecture-and-guidelines) — shard 단위 isolation + tenant context propagation 사례
|
||||
- [[raw/official-docs/multitenancy-aws-saas-tenant-isolation-whitepaper]]
|
||||
- [[raw/official-docs/multitenancy-hibernate-user-guide]]
|
||||
- [[raw/official-docs/multitenancy-azure-architecture-patterns]]
|
||||
- [[raw/company-tech-blogs/multitenancy-stripe-citus-schema-per-tenant]]
|
||||
- [[raw/company-tech-blogs/multitenancy-auth0-tenant-resolution]]
|
||||
- [[raw/company-tech-blogs/multitenancy-subdomain-resolution-patterns]]
|
||||
- [[raw/company-tech-blogs/multitenancy-hybrid-pooled-siloed-mix]]
|
||||
- [[raw/company-tech-blogs/multitenancy-atlassian-tenant-context]]
|
||||
- [[raw/project-notes/ca-skeleton-operational-contract]] — §10, §29 Topic 6
|
||||
@@ -0,0 +1,155 @@
|
||||
---
|
||||
title: Observability Baseline (Log + Metric + Trace + Runbook)
|
||||
source_type: llm-generated
|
||||
status: draft
|
||||
confidence: medium
|
||||
tags: [observability, logging, metrics, tracing, runbook, sre]
|
||||
related_projects: [ca-skeleton]
|
||||
last_reviewed: 2026-05-22
|
||||
---
|
||||
|
||||
# Observability Baseline (Log + Metric + Trace + Runbook)
|
||||
|
||||
> Layer: `wiki/concepts/` — 일반 개념. 내 프로젝트 사실은 project 문서에서 다룬다.
|
||||
|
||||
## Summary
|
||||
|
||||
Observability는 세 가지 신호(structured log, metric, distributed trace)와 이를 운영 행위로 잇는 runbook이 결합될 때 성립한다. ca-tmpl은 **JSON Logback + Micrometer dot.case 이름 규칙 + W3C tracecontext 전파 + `runbook://` URI 스킴**을 기본선으로 잡아 네 축을 하나의 운영 계약으로 묶는다. 어느 한 축만 갖추면 인시던트 시 "왜·어디서·어떻게 대응할지"를 답할 수 없다.
|
||||
|
||||
## Standard (공식 정의)
|
||||
|
||||
### Log
|
||||
|
||||
- **ECS (Elastic Common Schema)**: `@timestamp`, `log.level`, `service.name`, `trace.id`, `event.dataset` 등 필드명을 표준화. Elastic이 정의한 공개 스키마지만 OTel·Loki·Datadog도 부분 호환.
|
||||
- **OpenTelemetry Log Data Model**: log record를 trace/metric과 동일 SDK로 다루는 신호. `SeverityNumber`, `Body`, `Attributes`, `TraceId`/`SpanId` correlation을 정의.
|
||||
- **Structured logging best practice**: 자유 텍스트가 아닌 key-value JSON. PII는 발신 측에서 마스킹 (Logback `ch.qos.logback.classic.pattern` 또는 `MaskingPatternLayout`).
|
||||
|
||||
### Metric
|
||||
|
||||
- **Micrometer**: JVM 표준 facade. 이름은 `dot.case` (`http.server.requests`), `meterRegistry`가 backend별 변환을 담당.
|
||||
- **Prometheus**: pull-based, label cardinality bound 권장. exporter가 dot을 `_`로 변환 (`http_server_requests_seconds_count`).
|
||||
- **OpenTelemetry Metrics Data Model**: counter / gauge / histogram / exponential histogram을 정의. instrument 종류와 aggregation을 분리.
|
||||
- **RED method (Tom Wilkie)**: Request rate / Error rate / Duration. request-driven 서비스 표준.
|
||||
- **USE method (Brendan Gregg)**: Utilization / Saturation / Errors. 리소스 관점.
|
||||
- **SLO burn-rate alert (Google SRE Workbook)**: error budget 소진 속도를 multi-window multi-burn-rate로 측정 (예: 1h 14.4× burn AND 5m 14.4× burn).
|
||||
|
||||
### Trace
|
||||
|
||||
- **W3C Trace Context (W3C TR)**: `traceparent` 헤더 — `version-trace-id-parent-id-trace-flags`. 128-bit trace-id, 64-bit span-id, vendor-neutral.
|
||||
- **Micrometer Tracing**: Spring 진영의 facade. Brave(Zipkin) 또는 OpenTelemetry bridge로 backend 교체 가능.
|
||||
- **B3 propagation (Zipkin legacy)**: `X-B3-TraceId`(64 or 128-bit), `X-B3-SpanId`, `X-B3-Sampled`. 일부 레거시 서비스 호환용.
|
||||
- **Sampling**: head-based (요청 시점 결정, 저비용) vs tail-based (span 완료 후 결정, 고비용·고정밀). OTel Collector가 tail processor 제공.
|
||||
|
||||
### Runbook
|
||||
|
||||
- **Google SRE Workbook**: incident response·postmortem·error budget을 한 묶음으로 본다. runbook은 "on-call이 새벽 3시에 따라할 수 있어야" 한다.
|
||||
- **PagerDuty Incident Response**: severity(SEV-1~5), incident commander, scribe, communication template을 표준화.
|
||||
- **PagerDuty Runbook Automation (구 Rundeck)**: runbook을 코드/스크립트로 실행. drift 감소.
|
||||
- **ITIL**: 광의의 service operation 프로세스 (incident / problem / change). runbook은 ITIL의 procedure에 해당.
|
||||
- **Runbook-as-code (GitOps)**: markdown runbook을 git에 두고 alert payload에 URL을 박는다. `runbook://` 같은 내부 스킴은 ca-tmpl 관례.
|
||||
|
||||
## 한계 / 주의점
|
||||
|
||||
### Log
|
||||
|
||||
| 항목 | 한계 |
|
||||
|------|------|
|
||||
| ECS schema | Elastic이 사실상 owner — Loki/Datadog 채택은 부분적, **vendor lock-in 위험**. |
|
||||
| OTel log signal | 2024년 기준 GA 진입했지만 ecosystem maturity는 metric/trace 대비 낮음. SDK·Collector 버전 호환에 주의. |
|
||||
| SaaS 백엔드 (Loki/Datadog/Splunk) | 필드 매핑·인덱싱 정책이 제품마다 달라 schema drift 발생. 마이그레이션 비용 큼. |
|
||||
| Masking | Logback `MaskingPatternLayout`은 정규식 기반 — false negative (놓침)·false positive (과다 마스킹) 모두 가능. 정책은 발신지에서. |
|
||||
|
||||
### Metric
|
||||
|
||||
| 항목 | 한계 |
|
||||
|------|------|
|
||||
| Naming drift | Micrometer dot.case → Prometheus exporter underscore 변환은 자동이지만, 대시보드·alert rule은 backend 표기를 직접 참조 → 코드와 alert 사이 표기 분리. |
|
||||
| Cardinality | `userId`·`requestId`처럼 unbounded label을 metric에 박으면 시계열 폭증. trace/log로 보내야 함. |
|
||||
| SLO burn-rate | 식이 직관적이지 않음. SLO 자체가 없는 단계에선 traffic-based threshold가 더 합리적. |
|
||||
| Histogram | exponential histogram은 OTel·Prometheus 양쪽에서 채택 중이나 client/server 호환 매트릭스 확인 필요. |
|
||||
|
||||
### Trace
|
||||
|
||||
| 항목 | 한계 |
|
||||
|------|------|
|
||||
| Sampling | head-based 1% sampling은 rare-error 누락 위험. tail-based는 Collector 메모리·CPU 비용 큼. |
|
||||
| Adaptive sampling | "에러는 100%, 정상은 N%" 같은 정책 — 검증·재현이 어렵고 비교 분석을 깨뜨릴 수 있음. |
|
||||
| B3 non-호환 | B3 64-bit trace-id는 W3C 128-bit와 1:1 호환 안 됨. 게이트웨이에서 변환 정책 필요. |
|
||||
| Backend lock-in | Datadog APM·New Relic의 auto-instrumentation은 강력하지만 OTel exporter로 동등하게 옮기기 어려움. |
|
||||
| 비용 | full-trace 보관은 비싸다. 보존 기간·sampling rate가 곧 비용. |
|
||||
|
||||
### Runbook
|
||||
|
||||
| 항목 | 한계 |
|
||||
|------|------|
|
||||
| Drift | Confluence·Notion runbook은 코드와 따로 움직여 stale 되기 쉽다. |
|
||||
| Automation lock-in | PagerDuty Runbook Automation·Rundeck 같은 도구는 ops 표면을 그 제품에 묶는다. |
|
||||
| `runbook://` scheme | git markdown 링크는 repo 이동·이름 변경 시 link rot. CI에서 link check 필요. |
|
||||
| 적용 한계 | runbook은 "이미 알려진 장애"에 강하다. novel incident에는 framework(SEV·comm·IC)만 도움이 되고 절차 자체는 비워둬야 한다. |
|
||||
|
||||
## Project Application
|
||||
|
||||
- [[wiki/projects/ca-tmpl/observability-log-metric-trace-runbook]] — ca-tmpl 의사결정 기록 (`verified` — foundation observability 토대 slice는 MDC snake_case 표준 + 응답-로그 상관 + 헤더 sanitization으로 코드 구현·로컬 검증됨; 4축 full 기능은 여전히 `documented-only`). 실제 구현 범위·검증 수준은 project 문서 참조.
|
||||
- [[raw/project-notes/ca-skeleton-operational-contract]] — ca-tmpl 운영 계약 SSOT (§8 Structured Log, §6 Operational Error, §29 G-A).
|
||||
- [[raw/branch-notes/feature-log-management-contract]] — JSON Logback + masking + trace 상관관계 계약.
|
||||
- [[raw/branch-notes/feature-metrics-alerting-contract]] — Micrometer dot.case + SLO burn-rate alert 계약.
|
||||
- [[raw/branch-notes/feature-distributed-tracing-contract]] — W3C tracecontext 전파 + sampling 계약.
|
||||
- [[raw/branch-notes/feature-operational-runbook-contract]] — `runbook://` scheme · alert payload 연동 계약.
|
||||
- [[raw/branch-notes/feature-operational-error-observability-foundation]] — error code · severity · 3 pillars 연계 토대.
|
||||
|
||||
## Claim-backed Knowledge
|
||||
|
||||
> 이 개념 문서의 핵심 설명은 raw source claim 으로 뒷받침되어야 한다.
|
||||
> 공식 문서 claim, 회사 사례 claim, 내 프로젝트 decision 을 분리한다.
|
||||
|
||||
| Knowledge Point | Supporting Claims | Confidence | Notes |
|
||||
|---|---|---|---|
|
||||
| ECS는 `@timestamp`/`log.level`/`service.name`/`trace.id` 등 로그 필드명을 표준화한 공개 스키마다 | [[raw/official-docs/log-ecs-schema-elastic-official]] | `high` | 공식(Elastic) — 사실상 Elastic이 owner라 Loki/Datadog 채택은 부분적, vendor lock-in 위험 |
|
||||
| OpenTelemetry는 log를 trace/metric과 동일 SDK 신호로 다루며 `TraceId`/`SpanId` correlation을 정의한다 | [[raw/official-docs/log-otel-log-data-model-spec]], [[raw/official-docs/metric-otel-metrics-data-model-spec]] | `high` | 공식 spec — log signal은 metric/trace 대비 ecosystem maturity 낮음 |
|
||||
| Micrometer는 `dot.case` 이름 규칙을 쓰고 Prometheus exporter가 `_`로 변환한다 (`http.server.requests` → `http_server_requests_seconds_count`) | [[raw/official-docs/metric-micrometer-naming-convention-official]] | `high` | 공식 — 대시보드·alert rule은 backend 표기를 직접 참조해 코드/alert 표기 분리 발생 |
|
||||
| W3C Trace Context `traceparent`는 128-bit trace-id·64-bit span-id의 vendor-neutral 표준이며 B3(64-bit)와 1:1 lossless 변환이 안 된다 | [[raw/official-docs/tracing-w3c-trace-context-spec]], [[raw/official-docs/tracing-b3-propagation-zipkin-spec]] | `high` | 공식 — hybrid 환경에서 게이트웨이 변환 정책 필요 |
|
||||
| trace sampling은 head-based(저비용, rare-error 누락 위험) vs tail-based(고정밀, Collector 메모리/CPU 비용)의 trade-off다 | [[raw/official-docs/tracing-otel-sampling-tail-vs-head-spec]] | `high` | 공식 — full-trace 보관 비용이 곧 보존기간·sampling rate |
|
||||
| SLO burn-rate alert는 error budget 소진 속도를 multi-window multi-burn-rate로 측정한다 | [[raw/official-docs/metric-google-sre-slo-burn-rate]] | `high` | 공식(Google SRE Workbook) — SLO 미합의 단계에선 traffic-based threshold가 더 운영 가능 |
|
||||
| PagerDuty는 severity·incident commander·comm template로 incident response를 표준화하며 runbook은 "on-call이 새벽 3시에 따라할 수 있어야" 한다 | [[raw/official-docs/runbook-pagerduty-incident-response-doc]] | `high` | 공식 — runbook은 알려진 장애에 강하고 novel incident엔 framework만 유효 |
|
||||
|
||||
## 내가 설명할 수 있어야 하는 것
|
||||
|
||||
- Observability 3 pillars(log/metric/trace)의 공식 정의와 각 신호가 서로 대체 불가능한 이유는?
|
||||
- 각 축의 공개 표준(ECS / OTel data model / Micrometer / W3C Trace Context / SLO burn-rate)은 무엇을 규정하는가?
|
||||
- 어떤 상황에서는 특정 선택을 쓰면 안 되는가(SLO 미합의 시 burn-rate alert, unbounded label을 metric에 박기 등)?
|
||||
- 공식 표준이 말하지 않는 부분(backend lock-in, schema drift, masking false negative/positive)은 무엇인가?
|
||||
- Datadog APM vs OTel 같은 tech-blog 비교를 공식 best practice처럼 일반화하면 안 되는 지점은?
|
||||
- 내 프로젝트에서는 어떤 branch decision(MDC snake_case 표준, W3C traceparent 채택, `runbook://` scheme 등)으로 연결됐는가?
|
||||
- 이 개념을 코드/운영에서 검증하려면 무엇을 확인해야 하는가(MDC 키 일관성, 응답-로그 상관, 헤더 sanitization, alert 발화 등)?
|
||||
|
||||
## Interview Questions
|
||||
|
||||
- Observability **3 pillars**(log/metric/trace)를 정의하고, 각각이 다른 신호로 대체될 수 없는 이유는?
|
||||
- **SLO burn-rate alert**의 원리와 단순 threshold alert 대비 장점은?
|
||||
- **W3C tracecontext와 B3 propagation**의 차이, 그리고 hybrid 환경에서 변환 전략은?
|
||||
- **trace sampling rate 1%**를 선택할 때의 근거와 rare-error 누락 위험을 어떻게 보완하는가?
|
||||
- **log masking**은 어디서(발신/수신) 수행해야 하며, false negative를 어떻게 줄이는가?
|
||||
- **runbook drift**(코드와 문서 불일치)를 방지하는 운영적 장치는?
|
||||
|
||||
## Do Not Overclaim
|
||||
|
||||
- "OpenTelemetry만 쓰면 vendor-neutral이다"라고 단정하지 말 것. instrument 표준은 중립이지만 **backend (Datadog/New Relic/Tempo/Jaeger)** 선택 시점에 다시 lock-in이 발생한다.
|
||||
- "SLO burn-rate alert가 정답이다"라고 단정하지 말 것. SLO·error budget이 합의되지 않은 단계에선 traffic-based threshold (RPS·5xx rate)가 더 운영 가능하다.
|
||||
- "structured logging만 하면 PII는 안전하다"고 단정하지 말 것. 필드 단위 마스킹 정책과 sink(Elastic/Loki/Datadog)별 접근 통제가 함께 있어야 한다.
|
||||
- "B3과 W3C는 호환된다"고 단정하지 말 것. 64-bit B3 trace-id는 128-bit W3C로 lossless 변환되지 않는다.
|
||||
- "runbook이 있으면 incident가 빨라진다"고 단정하지 말 것. drift된 runbook은 오히려 잘못된 행동을 유도한다.
|
||||
|
||||
## Sources
|
||||
|
||||
- [[raw/official-docs/log-ecs-schema-elastic-official]] — ECS schema 공식 정의.
|
||||
- [[raw/official-docs/log-otel-log-data-model-spec]] — OpenTelemetry log data model spec.
|
||||
- [[raw/official-docs/log-logback-mask-pattern-converter-official]] — Logback masking pattern 공식.
|
||||
- [[raw/official-docs/metric-micrometer-naming-convention-official]] — Micrometer dot.case 이름 규칙.
|
||||
- [[raw/official-docs/metric-otel-metrics-data-model-spec]] — OTel metrics data model spec.
|
||||
- [[raw/official-docs/metric-google-sre-slo-burn-rate]] — Google SRE Workbook burn-rate alert.
|
||||
- [[raw/official-docs/tracing-w3c-trace-context-spec]] — W3C Trace Context spec.
|
||||
- [[raw/official-docs/tracing-b3-propagation-zipkin-spec]] — Zipkin B3 propagation spec.
|
||||
- [[raw/official-docs/tracing-otel-sampling-tail-vs-head-spec]] — OTel sampling head/tail 비교.
|
||||
- [[raw/official-docs/runbook-pagerduty-incident-response-doc]] — PagerDuty incident response 공식 문서.
|
||||
- [[raw/company-tech-blogs/tracing-datadog-apm-vs-opentelemetry]] — Datadog APM vs OTel 비교 (tech blog 관점).
|
||||
- [[raw/project-notes/ca-skeleton-operational-contract]] — ca-tmpl 운영 계약 canonical SSOT.
|
||||
@@ -0,0 +1,65 @@
|
||||
---
|
||||
title: concept / Transactional Outbox Pattern
|
||||
source_type: llm-generated
|
||||
status: reviewed
|
||||
confidence: high
|
||||
tags: [concept, ca-tmpl, messaging, kafka, outbox-pattern]
|
||||
related_projects: [ca-tmpl]
|
||||
last_reviewed: 2026-06-15
|
||||
---
|
||||
|
||||
# concept / Transactional Outbox Pattern
|
||||
|
||||
## Summary
|
||||
|
||||
로컬 트랜잭션의 일부로 비즈니스 상태 변경과 이벤트를 동일한 데이터베이스(Outbox 테이블)에 저장한 후, 독립적인 프로세스(Outbox Relay)가 이 이벤트를 비동기적으로 메시지 브로커(Kafka 등)로 발행하는 디자인 패턴.
|
||||
- 이를 통해 분산 환경에서 비즈니스 로직 성공과 메시지 발행 간의 원자성(Atomicity)을 보장하고, 이중 쓰기(Dual-Write) 안티패턴을 방지한다.
|
||||
|
||||
## Standard (공식 정의)
|
||||
|
||||
- **Dual-Write Anti-Pattern**: 하나의 비즈니스 유스케이스 내에서 데이터베이스 업데이트와 외부 메시지 발행을 동시에 시도하는 방식. 데이터베이스 트랜잭션은 커밋되었으나 브로커 연결 실패로 메시지가 유실되거나, 반대로 메시지는 발행되었으나 데이터베이스 커밋이 롤백되는 불일치 문제가 상존한다.
|
||||
- **Transactional Outbox**:
|
||||
1. 비즈니스 원장 데이터 수정과 함께, 발행할 메시지를 동일 트랜잭션 하에서 `Outbox` 테이블에 인서트한다. (DB 로컬 트랜잭션의 원자성으로 인해 메시지 저장도 100% 보장된다.)
|
||||
2. 별도의 백그라운드 워커(Outbox Relay)가 Outbox 테이블을 주기적으로 폴링(또는 CDC를 활용)하여 `PENDING` 상태의 이벤트를 읽어온다.
|
||||
3. 릴레이 워커가 메시지를 브로커로 발행(Publish)한 뒤, 데이터베이스에 해당 Outbox 레코드를 `COMPLETED` 등으로 상태를 업데이트하거나 삭제한다.
|
||||
|
||||
## 한계 / 주의점
|
||||
|
||||
- **중복 메시지 발행 (At-Least-Once Delivery)**: 릴레이가 브로커에 메시지를 정상적으로 보냈으나, DB에 상태를 `COMPLETED`로 업데이트하기 직전에 시스템이 다운되면 동일한 메시지가 재전송될 수 있다. 따라서 소비처(Consumer)는 반드시 **멱등적 메시지 처리(Idempotent Consumer)** 구조를 갖춰야 한다.
|
||||
- **순서 보장 (Ordering)**: 멀티 스레드로 릴레이를 돌릴 때 동일 Aggregate의 이벤트가 뒤집혀서 발행되지 않도록 Aggregate ID 기반의 분산 락이나 시퀀스 제어가 필요할 수 있다.
|
||||
|
||||
## Project Application
|
||||
|
||||
- [[wiki/explainer/adapter-outbound.md]]
|
||||
- 우리 프로젝트에서는 메시지 발행 시 직접 발행과 아웃복스 릴레이 발행의 결합을 지원함.
|
||||
- **직접 발행 (`KafkaMessagePublisher`)**: 비즈니스 트랜잭션 흐름 중 메시지를 즉시 발행함. 이미 로컬 DB 트랜잭션에 아웃복스가 커밋되므로, 실시간 발행은 **Fail-Open** 계약을 맺어 예외가 발생하더라도 사용자 API를 중단시키지 않고 백그라운드 릴레이에 유실 복구를 위임함.
|
||||
- **릴레이 발행 (`KafkaOutboxMessagePublishAdapter`)**: 백그라운드에서 Outbox 레코드를 전달받아 브로커에 실제 전달하는 역할. 브로커가 장애를 내면 반드시 예외를 다시 던지는 **Fail-Closed** 계약을 가짐. 예외가 전파되어야 릴레이 트랜잭션이 롤백되어 해당 레코드가 `IN_FLIGHT`에 고립되지 않고 재시도(Retry) 루프를 타거나 운영 경보(Runbook)가 정상 작동하기 때문임.
|
||||
|
||||
## Claim-backed Knowledge
|
||||
|
||||
| Knowledge Point | Supporting Claims | Confidence | Notes |
|
||||
|---|---|---|---|
|
||||
| 이중 쓰기(Dual-write)의 근본적 문제점과 일관성 결여 | `raw/official-docs/dual-write-antipattern-microservices-io.md` | `high` | 마이크로서비스 데이터 패턴 |
|
||||
| 트랜잭셔널 아웃복스 패턴의 기본 구성 요소 | `raw/official-docs/transactional-outbox-aws-prescriptive-guidance.md` | `high` | AWS 마이크로서비스 설계 패턴 |
|
||||
| Outbox 데이터 상태 변경 및 중복 처리 주의점 | `raw/official-docs/microservices-io-transactional-outbox.md` | `high` | Microservices.io 패턴 정의 |
|
||||
|
||||
## 내가 설명할 수 있어야 하는 것
|
||||
|
||||
- 이중 쓰기(Dual-Write)의 위험성과 이를 아웃복스 패턴이 어떻게 해결하는지 메커니즘을 상세히 설명할 수 있어야 함.
|
||||
- 실시간 API 단의 메시지 발행기와 백그라운드 릴레이 단의 메시지 발행기가 예외 처리 정책(Fail-Open vs Fail-Closed)을 다르게 맺는 이유는 무엇인가?
|
||||
- 카프카 외에 다른 메시징 시스템(RabbitMQ, AWS SQS)으로 아웃복스 발행기를 대체하려면 어떻게 설계해야 하는가? (Port-Adapter 인터페이스 구현을 통해 어댑터만 교체)
|
||||
|
||||
## Interview Questions
|
||||
|
||||
- 메시지 큐와 RDB를 동시에 업데이트할 때 발생할 수 있는 데이터 정합성 문제와 이를 해결하기 위한 Transactional Outbox Pattern에 대해 설명해 주세요.
|
||||
- 아웃복스 릴레이 컴포넌트의 실패 상황 시 가용성과 정합성 설계 관점에서 어떻게 실패 복구를 처리해야 하는지 설명하십시오.
|
||||
|
||||
## Do Not Overclaim
|
||||
|
||||
- "아웃복스 패턴을 도입했으므로 분산 트레이싱 환경에서 완벽한 1회성 전송(Exactly-Once)을 달성할 수 있다"고 장담하면 안 된다. 분산 네트워크 상에서 릴레이 DB 업데이트 실패 시 중복 메시지가 무조건 나갈 수 있으므로, 최종 소비자의 멱등 수신 설계가 반드시 동반되어야 보장된다.
|
||||
|
||||
## Sources
|
||||
|
||||
- [Microservices.io - Transactional Outbox](https://microservices.io/patterns/data/transactional-outbox.html)
|
||||
- [[raw/official-docs/dual-write-antipattern-microservices-io.md]]
|
||||
- [[raw/official-docs/transactional-outbox-aws-prescriptive-guidance.md]]
|
||||
@@ -0,0 +1,116 @@
|
||||
---
|
||||
title: Privacy / File / Domain Modeling (GDPR + ICAP + Vernon)
|
||||
source_type: llm-generated
|
||||
status: draft
|
||||
confidence: medium
|
||||
tags: [privacy, gdpr, file-upload, ddd, domain-modeling]
|
||||
related_projects: [ca-skeleton]
|
||||
last_reviewed: 2026-05-22
|
||||
---
|
||||
|
||||
# Privacy / File / Domain Modeling (GDPR + ICAP + Vernon)
|
||||
|
||||
> Layer: `wiki/concepts/` — 일반 개념. Phase E Group G-J(3 branch / 10 raw) 통합. 내 프로젝트 사실 판정은 [[raw/project-notes/ca-skeleton-operational-contract]] §19, §29 G-J 와 각 branch-note에서 별도로 다룸.
|
||||
|
||||
## Summary
|
||||
|
||||
서비스가 도메인을 얹기 전에도 (1) **개인정보·로그의 보존/삭제 계약**, (2) **파일 업로드/다운로드의 안전성 계약**, (3) **도메인 모델의 프레임워크 격리 계약** 세 축이 사전에 정의되어야 한다. 본 문서는 이 세 축의 공식 기준과 그 한계를 묶어서 다룬다. 대표 결정값(예: 30/180/365일 retention, HMAC-SHA-256 + 90일 salt rotation, DSR SLA 30/14일, 3-layer file size limit, content-type allowlist 6종, VO private constructor, aggregate root mutator non-public)은 모두 개별 branch-note의 결정 사항을 따른다.
|
||||
|
||||
## Standard (공식 정의)
|
||||
|
||||
### Privacy / Retention
|
||||
|
||||
- **GDPR Art.25 — Data protection by design and by default**: 처리 시작 시점부터 "최소한의 데이터, 가능한 짧은 보존, 가능한 적은 노출"이 기본값이어야 한다. Art.17(Right to erasure)은 controller가 합리적 조치로 backup·복제본을 포함해 삭제하도록 요구한다.
|
||||
- **NIST SP 800-88 Rev.1 — Cryptographic Erase (CE)**: 키를 안전하게 폐기함으로써 데이터 자체를 sanitize한 것으로 인정하는 공식 방법. backup·offline media의 GDPR Art.17 대응 수단으로 사용 가능.
|
||||
- **ENISA / IAPP — Pseudonymization techniques**: HMAC-with-secret-key, tokenization, encryption 등을 pseudonymization 기법으로 분류. salt rotation, lookup table 분리 보관, brute-force input space 등을 비교 기준으로 제시.
|
||||
- **DSR (Data Subject Request) 운영 패턴**: intake → identity verification → scope classification(export/delete) → execution → audit evidence. GDPR Art.12는 응답을 "원칙적으로 1개월(연장 시 +2개월)" 내로 요구.
|
||||
|
||||
### File / Resource Handling
|
||||
|
||||
- **ICAP / RFC 3507 — Internet Content Adaptation Protocol**: HTTP proxy/gateway가 antivirus engine(예: ClamAV)에 payload를 위임 검사하는 표준 프로토콜. 업로드 단의 외부 콘텐츠 검사를 app 외부에서 수행하는 정석.
|
||||
- **AWS S3 — Presigned URL upload**: 서버가 서명된 PUT URL을 발급하면 클라이언트가 직접 S3에 업로드. app/gateway의 대역폭/CPU 부담 없이 large object 처리 가능.
|
||||
- **tus.io — Resumable upload protocol (v1.0.0)**: HTTP `PATCH` 기반 resumable upload. 대용량/장시간 업로드를 chunk 단위 재개 가능하도록 표준화.
|
||||
- **multipart/form-data + size limit**: Spring `spring.servlet.multipart.max-file-size` 등 framework 단의 1차 enforcement는 envelope error 변환의 책임을 진다. gateway/WAF는 raw 차단 보조.
|
||||
|
||||
### Domain Modeling
|
||||
|
||||
- **Vaughn Vernon — Effective Aggregate Design (IDDD)**: 4 rules — (1) protect true invariants in consistency boundary, (2) design small aggregates, (3) reference other aggregates by identity, (4) update other aggregates eventually. ORM-friendly constructor / package-private setter를 통해 ORM과 도메인 모델의 분리를 권장(이하 "Option A: ORM 외부 매핑").
|
||||
- **Martin Fowler — Anemic Domain Model**: 데이터만 있는 entity + 모든 로직이 service에 모이는 구조를 anti-pattern으로 정의. rich model(state + behavior + invariant 동소화)을 기본으로 제시.
|
||||
- **Greg Young — CQRS / Event Sourcing**: domain event는 transport-free fact, command와 query 모델 분리, event stream을 source of truth로 두는 패턴. event sourcing과 CQRS는 동일 개념이 아님(Young 본인이 구분).
|
||||
|
||||
## 한계 / 주의점
|
||||
|
||||
### Privacy
|
||||
|
||||
- **HMAC + salt rotation을 anonymization으로 단정 금지**: ENISA·IAPP 기준으로도 HMAC은 pseudonymization이지 anonymization이 아니다. brute-force 가능한 input space(예: 한국 휴대폰 11자리, 주민번호 일부 자리)에서는 attacker가 가능한 모든 입력을 미리 HMAC 계산할 수 있으므로 tokenization(랜덤 토큰 + 별도 lookup table)이 우위인 구간이 존재한다. 또한 HMAC + salt rotation은 **forward security만** 제공한다 — 새로 기록되는 식별자에 한해 rotation 이전 hash가 무효화될 뿐, 이미 작성된 backup 안의 hash는 그대로 잔존한다. 따라서 HMAC을 backup erasure 수단으로 오해하면 안 된다.
|
||||
- **salt rotation interval (예: 90일)** 자체로 안전성이 증명되지 않음. 회전 주기 동안의 collision/lookup 정책, 옛 salt 보관 기간(예: 90일 retain), 키 저장소의 안전성이 별도로 요구된다.
|
||||
- **GDPR Art.17 + backup → envelope key 필요**: backup·snapshot에서의 erasure는 단건 삭제가 어렵다. NIST SP 800-88 Rev.1 § 2.5 Cryptographic Erase (CE)는 인정되는 방법이나, **per-principal envelope key** 구조(주체별 DEK를 master CMK로 wrap, 삭제 요청 시 해당 principal의 DEK 폐기 → 모든 backup ciphertext가 동시에 unreadable)가 사전에 설계되어 있어야 한다. HMAC + salt rotation은 이 단건 erasure를 제공하지 **못한다**. 비용 trade-off에 따라 (a) per-principal CMK / (b) per-principal DEK + master CMK envelope (AWS KMS·GCP KMS 권장) / (c) tenant-level CMK (Stripe·Twilio·Shopify 류 SaaS 일반 패턴) 중 선택이 필요하다. 일반적 대량 KEK 폐기로는 Art.17 단건 요청을 만족하기 어렵다.
|
||||
- **PII detection SaaS(AWS Macie / OneTrust / TrustArc)** 채택은 vendor 종속을 만든다. skeleton 단계의 기본값으로 두는 것은 부적절.
|
||||
|
||||
### File / Resource
|
||||
|
||||
- **ICAP gateway가 모든 위협을 막는다고 단정 금지**: HTTPS end-to-end TLS 환경에서는 gateway가 payload를 평문으로 보지 못해 ICAP 검사가 어려운 구간이 있다. 그 경우 post-upload async scan(예: quarantine bucket + worker)이 대안.
|
||||
- **Direct S3 presigned URL**: 앱이 payload를 보지 못하므로 in-app validation(예: content-type 재검증, watermark, business rule)이 부재한다. content-type/size 검증은 S3 측 정책 + 후행 worker로 분산되어야 한다.
|
||||
- **tus resumable upload**: session 식별자와 orphan temp file이 충돌한다. ca-tmpl 류의 "temp file > 1h not closed = orphan, sweeper가 삭제" 정책은 tus의 정상 long session을 잘못 삭제할 수 있어 threshold 분리가 필요하다.
|
||||
- **in-app ClamAV daemon**: 앱 인스턴스마다 daemon dependency가 늘고, scaling/CPU 비용이 함께 증가한다. skeleton 단계의 기본값으로는 부적절.
|
||||
- **content-type "sniffing 금지" vs "allowlist"**: client-supplied Content-Type 신뢰는 위험하나, 동시에 서버측 sniffing(magic byte 추론)도 우회 가능. allowlist + endpoint별 검증이 현실적 절충.
|
||||
- **size limit 3-layer (예: app 10MB / global 12MB / gateway 20MB)**: 의도된 defense-in-depth지만, gateway 단의 raw 413은 envelope을 우회한다는 점이 trade-off다. 어느 layer에서 어떤 응답 형태를 보장할지 사전에 정해야 한다.
|
||||
|
||||
### Domain Modeling
|
||||
|
||||
- **Functional domain modeling (Scala / F#)**: 패러다임은 매력적이나 JVM Java 중심 팀의 학습 비용이 크다. skeleton 기본 채택은 부적절.
|
||||
- **Anemic model**: 로직이 service로 흩어져 invariant 위치가 불명확해진다. Fowler가 anti-pattern으로 명시.
|
||||
- **Pure DDD aggregates**: 작은 도메인에 과한 학습 비용 / 코드량을 강제할 수 있다. Vernon 본인도 "small aggregate"를 강조.
|
||||
- **Event sourcing**: event store, snapshot, projection 등 운영 비용이 크다. 도메인 event = transport-free fact라는 정의만 차용하고 event sourcing은 채택하지 않는 절충이 일반적.
|
||||
- **JPA direct annotation in domain (Vernon Option B / 우아한형제들 초기 글 스타일)**: `@Entity` / `@Column` 등을 domain class에 직접 두는 방식. 도메인이 persistence를 "안다"는 점에서 framework 격리 규칙과 충돌. forbidden import 규칙을 둔 코드베이스에서는 채택 불가.
|
||||
- **`@Entity` / `@Service` / Logger / HTTP type을 도메인이 import**: 도메인의 framework neutrality가 깨진다. ArchUnit 등의 forbidden-import 테스트로 강제할 수 있다.
|
||||
|
||||
## Project Application
|
||||
|
||||
- [[wiki/projects/ca-tmpl/privacy-file-domain-modeling]] — ca-tmpl 의사결정 기록 (현재 `documented-only`, Phase C2 미진입). 실제 구현 여부는 project 문서 참조.
|
||||
- [[raw/branch-notes/feature-data-retention-privacy-contract]] — log retention by profile, HMAC pseudonymization, DSR SLA, backup retention 결정
|
||||
- [[raw/branch-notes/feature-file-resource-handling-contract]] — upload size 3-layer, content-type allowlist, temp file cleanup, antivirus position 결정
|
||||
- [[raw/branch-notes/feature-domain-modeling-guardrails]] — VO private constructor, aggregate mutator non-public, domain forbidden import 결정
|
||||
- [[raw/project-notes/ca-skeleton-operational-contract]] §19 Domain Application Readiness Contract, §29 G-J 외부 근거 / 대안 조사
|
||||
|
||||
## Interview Questions
|
||||
|
||||
- GDPR Art.17 erasure 요청이 들어왔을 때, backup·snapshot까지 어떻게 처리하는가? Cryptographic erase와 per-principal envelope key 구조가 왜 필요한가?
|
||||
- HMAC + salt rotation을 pseudonymization으로 채택할 때 salt rotation 주기(예: 90일)는 어떤 의미를 갖는가? brute-force 가능한 input space에서는 왜 tokenization이 더 안전할 수 있는가?
|
||||
- 파일 업로드 size limit을 app(예: 10MB) / global(예: 12MB) / gateway(예: 20MB) 3-layer로 두는 이유는? 각 layer가 어떤 실패 모드를 책임지는가?
|
||||
- ICAP / RFC 3507 기반 gateway antivirus의 한계는? HTTPS end-to-end TLS 환경과 in-app ClamAV daemon은 각각 어떤 trade-off를 만드는가?
|
||||
- Value Object의 생성자를 private/factory only로 두는 이유는? aggregate root의 mutator를 package-private/protected로 강제하는 이유는?
|
||||
- ORM 매핑을 도메인 외부에서 수행(Vernon Option A)하는 방식과, JPA annotation을 도메인에 직접 다는 방식(Option B / 우아한형제들 초기 글 스타일)의 trade-off는?
|
||||
|
||||
## Do Not Overclaim
|
||||
|
||||
- **"HMAC + salt = anonymization"으로 단정 금지**. ENISA·IAPP 기준 pseudonymization. brute-force 가능 input(휴대폰·주민번호 일부 등)에서는 tokenization이 우위인 구간이 존재.
|
||||
- **"backup도 GDPR Art.17로 완전 삭제했다"고 단정 금지**. cryptographic erase + per-principal envelope key 구조가 실제로 설계되어 있어야 가능한 진술이다. 단순 backup 보존만으로는 단건 삭제 불가.
|
||||
- **"DSR SLA 30/14일은 GDPR 요구치"라고 단정 금지**. GDPR Art.12는 "원칙적으로 1개월(연장 시 +2개월)"이며, 30/14일은 내부 운영 결정값이다.
|
||||
- **"ICAP gateway antivirus가 모든 위협을 막는다"고 단정 금지**. HTTPS E2E TLS 환경 한계와 post-upload async scan 필요성이 있다.
|
||||
- **"Direct S3 presigned URL이 가장 안전하다"고 단정 금지**. in-app validation 부재 → quarantine bucket + 후행 worker 분리가 추가로 필요.
|
||||
- **"우리는 pure DDD 기반"이라고 단정 금지**. Vernon Option A(ORM 외부 매핑) 차용이며, CQRS / event sourcing은 채택하지 않은 절충이다. "transport-free domain event 정의만 차용했다"가 더 정확한 표현.
|
||||
- **"Vernon Option B(JPA direct annotation)도 DDD이니 동일하다"고 단정 금지**. domain의 framework 격리 규칙을 두는 코드베이스에서는 양립 불가.
|
||||
- **"functional domain modeling(Scala/F#) 도입했다"고 단정 금지**(JVM Java 기준 코드베이스에서). 패러다임 학습 비용과 팀 적합성이 별도로 필요.
|
||||
|
||||
## Sources
|
||||
|
||||
### Privacy
|
||||
- [GDPR Article 25 — Data protection by design and by default](https://gdpr-info.eu/art-25-gdpr/) — [[raw/official-docs/privacy-gdpr-article-25-design]]
|
||||
- [NIST SP 800-88 Rev.1 — Cryptographic Erase](https://nvlpubs.nist.gov/nistpubs/SpecialPublications/NIST.SP.800-88r1.pdf) — [[raw/official-docs/privacy-cryptographic-erasure-nist-sp800-88]]
|
||||
- [Per-Principal Envelope Key for GDPR Art.17 (NIST SP 800-88 + AWS/GCP KMS envelope)](https://csrc.nist.gov/publications/detail/sp/800-88/rev-1/final) — [[raw/official-docs/gdpr-cryptographic-erasure-envelope-key-pattern]]
|
||||
- [ENISA / IAPP — Pseudonymization techniques](https://www.enisa.europa.eu/publications/pseudonymisation-techniques-and-best-practices) — [[raw/company-tech-blogs/privacy-pseudonymization-hmac-vs-tokenization-iapp]]
|
||||
|
||||
### File / Resource
|
||||
- [ClamAV / ICAP — Gateway antivirus scan](https://docs.clamav.net/manual/Usage/Scanning.html) — [[raw/company-tech-blogs/file-clamav-icap-gateway-scan]]
|
||||
- [AWS S3 — Presigned URL upload](https://docs.aws.amazon.com/AmazonS3/latest/userguide/PresignedUrlUploadObject.html) — [[raw/official-docs/file-s3-presigned-url-upload]]
|
||||
- [tus.io — Resumable upload protocol v1.0.0](https://tus.io/protocols/resumable-upload) — [[raw/official-docs/file-tus-resumable-upload-protocol]]
|
||||
|
||||
### Domain Modeling
|
||||
- [Vaughn Vernon — Aggregate root rules (IDDD)](https://www.dddcommunity.org/library/vernon_2011/) — [[raw/official-docs/domain-vaughn-vernon-aggregate-root]]
|
||||
- [Martin Fowler — Anemic Domain Model](https://martinfowler.com/bliki/AnemicDomainModel.html) — [[raw/official-docs/domain-fowler-anemic-vs-rich-model]]
|
||||
- [우아한형제들 — DDD Aggregate 구현](https://techblog.woowahan.com/2711/) — [[raw/company-tech-blogs/domain-woowahan-ddd-aggregate-techblog]]
|
||||
- [Greg Young — CQRS Documents (Event sourcing vs CQRS 구분)](https://cqrs.files.wordpress.com/2010/11/cqrs_documents.pdf) — [[raw/company-tech-blogs/domain-event-sourcing-vs-cqrs-greg-young]]
|
||||
|
||||
### Canonical
|
||||
- [[raw/project-notes/ca-skeleton-operational-contract]] §19, §29 G-J
|
||||
@@ -0,0 +1,135 @@
|
||||
---
|
||||
title: Resource Identifier Format (ULID vs UUIDv7 vs UUIDv4 vs Snowflake)
|
||||
source_type: llm-generated
|
||||
status: draft
|
||||
confidence: medium
|
||||
tags: [resource-identifier, ulid, uuid, backend]
|
||||
related_projects: [ca-skeleton]
|
||||
last_reviewed: 2026-06-04
|
||||
---
|
||||
|
||||
# Resource Identifier Format (ULID vs UUIDv7 vs UUIDv4 vs Snowflake)
|
||||
|
||||
> Layer: `wiki/concepts/` — 일반 개념. 특정 프로젝트(ca-tmpl)의 적용 사실은 [[wiki/projects/ca-tmpl/resource-identifier-format]] 로 분리.
|
||||
|
||||
## Summary
|
||||
|
||||
Resource identifier format 결정은 API resource 를 가리키는 public ID 의 *형식*(random vs time-ordered, charset, 길이, prefix)을 고르는 일이다. 후보는 크게 random 계열(UUID v4, NanoID)과 time-ordered 계열(UUID v7, ULID, KSUID, Snowflake, TSID)로 갈린다. 핵심 trade-off 축은 **(1) 정렬성/DB index locality, (2) timestamp leak(privacy), (3) URL 길이/charset, (4) 조율 부담, (5) 표준 여부**다. ID 는 URL·log·DB PK·cache key·FK 에 한 번 박히면 변경이 breaking 이므로, 형식 선택은 되돌리기 어려운 결정이다.
|
||||
|
||||
## Standard (공식 정의)
|
||||
|
||||
### UUID (RFC 9562, 2024)
|
||||
|
||||
IETF RFC 9562 는 UUID 의 128-bit 구조와 버전을 정의한다. v4 는 순수 random, v7 은 48-bit Unix millisecond timestamp 를 앞에 두는 **time-ordered** 변형이며, 같은 timestamp 내 단조성을 위한 monotonicity 메커니즘을 규정한다. RFC 는 새 ID 가 필요할 때 time-ordered 변형(v6/v7)을 SHOULD 로 권고한다. §8 은 timestamp 노출의 attack surface 를 "very small" 로 기술한다.
|
||||
|
||||
출처: [[raw/official-docs/rfc9562-uuid]] (RFC9562-C1~C5).
|
||||
|
||||
### ULID (공식 spec)
|
||||
|
||||
ULID 는 128-bit 를 **26-char Crockford base32** 로 인코딩한 형식이다. 앞 48-bit 가 millisecond timestamp(정렬 가능), 뒤 80-bit 가 random. `getMonotonicUlid()` 류의 monotonic factory 는 동일 ms 내 단조 증가를 보장한다. 128-bit 이므로 UUID 와 binary 호환(상호 변환 가능)이다.
|
||||
|
||||
출처: [[raw/official-docs/ulid-spec.md]] (ULID-C1~C6).
|
||||
|
||||
### Crockford base32 / RFC 3986
|
||||
|
||||
- **Crockford base32**: 32-char alphabet 에서 사람이 혼동하는 **I / L / O / U 를 제외**한다. 디코딩 시 `I`/`L` → `1`, `O` → `0` 으로 정규화하고 대소문자를 구분하지 않는다(case-insensitive). 출처: [[raw/official-docs/crockford-base32-spec.md]] (CROCKFORD-C1~C4).
|
||||
- **RFC 3986 (URI generic syntax)**: `unreserved` charset 은 `ALPHA / DIGIT / "-" / "." / "_" / "~"`. path component 는 case-sensitive 로 취급되며 §6.2.2.1 의 case normalization 규칙은 scheme/host 에만 적용된다. ULID 의 `0-9A-Z` 는 `unreserved` 의 진부분집합이라 percent-encoding 없이 URL path 에 안전하다. 출처: [[raw/official-docs/rfc3986-uri-generic-syntax]] (RFC3986-C1/C3/C4).
|
||||
|
||||
### 식별자 관례 (벤더 표준 — best practice 아님)
|
||||
|
||||
- **Google AIP-148**: `name`(server-assigned), `uid`(system-assigned opaque, non-PII), `display_name`(mutable), `parent`(계층 resource name) 표준 필드. 출처: [[raw/official-docs/google-aip-148-standard-fields]] (AIP148-C1~C5).
|
||||
- **Stripe**: typed prefix opaque ID(`ch_`, `cus_`, `pi_`). 단 Stripe 스스로 prefix 변경을 *backward-compatible* 로 분류 → prefix 영구 불변 보장이 아니므로 prefix 의존 코드는 lock-in 위험. Idempotency-Key 는 client-generated 로 resource ID 와 별개. 출처: [[raw/official-docs/stripe-resource-id-convention]] (STRIPE-C1~C5).
|
||||
|
||||
> AIP-148·Stripe 는 `official-vendor-doc`/벤더 관례다. RFC 9562·RFC 3986·ULID spec 같은 `official-standard` 와 달리 "공식 best practice" 로 일반화하면 안 된다.
|
||||
|
||||
## 한계 / 주의점
|
||||
|
||||
후보별 trade-off:
|
||||
|
||||
| 형식 | 정렬성(DB index) | timestamp leak | URL 길이 | 조율 부담 | 표준 |
|
||||
| --- | --- | --- | --- | --- | --- |
|
||||
| Sequential integer | 최상 | 없음(but enumeration/count leak) | 짧음 | 없음 | — |
|
||||
| UUID v4 | 나쁨(random → B-tree 단편화) | 없음 | 36자(dashed) | 없음 | RFC 9562 |
|
||||
| UUID v7 | 좋음(time-ordered) | **48-bit ms 노출** | 36자 | 없음 | RFC 9562 |
|
||||
| ULID | 좋음(time-ordered) | **48-bit ms 노출** | 26자 | 없음 | ULID spec(비-IETF) |
|
||||
| NanoID | 나쁨(random) | 없음 | 21자(default) | 없음 | 라이브러리 |
|
||||
| KSUID | 좋음 | 초 단위 노출 | 27자(base62) | 없음 | 라이브러리 |
|
||||
| Snowflake | 좋음(k-sorted) | ms 노출 + machine ID | ~19자(64-bit) | **worker/datacenter id 조율** | 라이브러리 |
|
||||
| TSID | 좋음 | ms 노출 | BIGINT fit | 일부 | 라이브러리 |
|
||||
| CUID2 | 없음(보안 우선) | **없음(저자 주장)** | 24자(base36) | 없음 | 라이브러리 |
|
||||
|
||||
주요 함정:
|
||||
|
||||
- **Sequential ID**: enumeration attack + count leak + tenant 격리 위반. public ID 로 부적합.
|
||||
- **random UUID v4 의 DB 비용**: time-ordered 가 아니라 B-tree index 에 random insert → page split + WAL/디스크 증가. Percona 의 MySQL InnoDB 25M-row 벤치마크에서 random UUID PK 가 ordered UUID 대비 +50% 디스크, ordered UUID ≈ BIGINT 성능. 단 이는 MySQL InnoDB clustered index 기준 — PostgreSQL HEAP/MVCC 등 다른 엔진에는 *parallel evidence* 로만 적용된다. 출처: [[raw/company-tech-blogs/percona-uuid-storage-mysql]] (PERCONA-UUID-C2~C5).
|
||||
- **timestamp leak**: UUID v7 / ULID 는 48-bit ms timestamp 가 평문 노출 → 작성 시각·가입 순서·활동 패턴 추론 가능. *user-facing* ID 에서 실질 문제. 완화책은 수용 / random scramble / CUID2 채택. CUID2 의 timestamp 비노출은 *저자 주장*이며 독립 감사로 확인된 것은 아니다. 출처: [[raw/official-docs/cuid2-spec.md]] (CUID2-C1).
|
||||
- **Snowflake 의 조율 부담**: worker_id / datacenter_id 를 노드마다 사전 할당해야 함 → 단일 generator 환경에는 과한 운영 부담. 출처: [[raw/company-tech-blogs/snowflake-twitter-id]] (SNOWFLAKE-C1~C5).
|
||||
- **case-insensitive charset 의 함정**: Crockford base32(ULID)는 입력이 case-insensitive 라 서버가 URL boundary 에서 canonical uppercase 로 normalize 하지 않으면 cache key miss 가 발생한다.
|
||||
- **typed prefix lock-in**: Stripe 자신이 prefix 변경을 backward-compatible 로 본다 → prefix 를 파싱·의존하는 코드는 깨질 수 있다.
|
||||
- **public ID vs internal sequence**: external-only(ULID 하나가 public ID = PK, Stripe)는 단순하지만, dual column(internal BIGINT + external ULID, Shopify/Linear/PlanetScale)은 audit/JOIN 성능을 회수한다. 후자는 cache key/FK 를 어느 쪽으로 둘지 추가 결정을 부른다. 출처: [[raw/company-tech-blogs/planetscale-nanoid-api]] (PLANETSCALE-NANOID-C4).
|
||||
|
||||
## Project Application
|
||||
|
||||
- ca-tmpl(Clean Architecture skeleton)에서의 실제 ULID 채택 + `adapter-identifier` 모듈 구현 사실은 [[wiki/projects/ca-tmpl/resource-identifier-format]] 참조. (본 개념 문서는 일반론만 다룬다.)
|
||||
|
||||
## Claim-backed Knowledge
|
||||
|
||||
> 인용된 raw source 의 claim 만. 출처 없는 일반화 금지.
|
||||
|
||||
| Knowledge Point | Supporting Claims | Confidence | Notes |
|
||||
| --- | --- | --- | --- |
|
||||
| RFC 9562 가 UUID v7 = time-ordered(48-bit Unix ms) 를 정의하고 새 ID 에 time-ordered 를 SHOULD 권고 | [[raw/official-docs/rfc9562-uuid]] RFC9562-C1/C3 | high | `official-standard` |
|
||||
| RFC 9562 §8 이 timestamp 노출 attack surface 를 "very small" 로 기술 | [[raw/official-docs/rfc9562-uuid]] RFC9562-C5 | high | `official-standard` |
|
||||
| ULID = 26-char Crockford base32, 48-bit ms timestamp + 80-bit random, monotonic 정렬 | [[raw/official-docs/ulid-spec.md]] ULID-C1~C5 | high | `official-reference`(비-IETF spec) |
|
||||
| Crockford base32 가 I/L/O/U 제외 + 디코딩 시 정규화(case-insensitive) | [[raw/official-docs/crockford-base32-spec.md]] CROCKFORD-C1~C3 | high | `official-reference` |
|
||||
| RFC 3986 `unreserved` = `ALPHA / DIGIT / "-" / "." / "_" / "~"`, path case-sensitive | [[raw/official-docs/rfc3986-uri-generic-syntax]] RFC3986-C1/C3 | high | `official-standard` |
|
||||
| Google AIP-148 의 uid = system-assigned opaque(non-PII), display_name 과 분리 | [[raw/official-docs/google-aip-148-standard-fields]] AIP148-C2/C3 | medium | `official-vendor-doc` (벤더 관례, 공식 표준 아님) |
|
||||
| Stripe 가 typed prefix 변경을 backward-compatible 로 분류(영구 불변 보장 아님) | [[raw/official-docs/stripe-resource-id-convention]] STRIPE-C2 | medium | `official-vendor-doc` |
|
||||
| Percona: MySQL InnoDB 에서 random UUID PK 가 ordered UUID 대비 +50% 디스크, ordered UUID ≈ BIGINT (25M-row) | [[raw/company-tech-blogs/percona-uuid-storage-mysql]] PERCONA-UUID-C2/C5 | medium | `company-case-study` (MySQL 5.x, 타 엔진엔 parallel evidence) |
|
||||
| CUID2 가 timestamp leak 없음 | [[raw/official-docs/cuid2-spec.md]] CUID2-C1 | low | `official-reference` (저자 주장, 독립 감사 미확인) |
|
||||
| Snowflake 가 worker/datacenter id 사전 조율을 요구 | [[raw/company-tech-blogs/snowflake-twitter-id]] SNOWFLAKE-C1 | medium | `company-case-study` |
|
||||
| NanoID 21자 default + URL-safe alphabet `A-Za-z0-9_-` + crypto-strong random | [[raw/official-docs/nanoid-spec]] NANOID-C1/C2/C4 | high | `official-reference` |
|
||||
| Brandur(전 Stripe): Idempotency-Key 는 client-generated, ~24h TTL, request fingerprint 비교 | [[raw/company-tech-blogs/brandur-stripe-idempotency-keys]] BRANDUR-IDEMP-C8~C12 | medium | `engineering-blog` |
|
||||
|
||||
## 내가 설명할 수 있어야 하는 것
|
||||
|
||||
- time-ordered ID(UUID v7 / ULID)가 random UUID v4 대비 DB index locality 에 유리한 *원리*(B-tree 에 정렬된 키가 append 우세).
|
||||
- timestamp leak 가 왜 *user-facing* ID 에서만 실질 문제인지, 완화책(수용 / scramble / CUID2)의 trade-off.
|
||||
- Crockford base32 가 I/L/O/U 를 제외하는 이유 + 그래서 생기는 canonical uppercase 출력 + case-insensitive 입력 정규화 의무.
|
||||
- public ID vs internal sequence(external-only vs dual column)의 trade-off.
|
||||
- Idempotency-Key(client-generated, ephemeral) 와 resource ID(server-assigned, persistent)가 왜 별개 형식인지.
|
||||
- "Netflix/Stripe 가 X 를 쓰니까 공식이다" 가 아니라, RFC(official-standard) 와 벤더 관례(vendor-doc)·사례(case-study)를 구분해 말하는 것.
|
||||
|
||||
## Interview Questions
|
||||
|
||||
- ULID 와 UUID v7 은 둘 다 time-ordered 인데 왜 ULID 를 고를 수 있는가? (URL 길이 26 vs 36, Crockford base32 의 human-friendliness, Java 21 `java.util.UUID` 의 v7 native 미지원.)
|
||||
- random UUID v4 를 DB PK 로 쓰면 어떤 비용이 있는가? 어느 엔진 기준 벤치마크인가?
|
||||
- ULID/UUID v7 의 timestamp leak 가 실제로 어떤 정보를 노출하는가? 언제 문제이고 어떻게 완화하나?
|
||||
- typed prefix(`tk_`)를 쓰는 것의 장단점은? Stripe 가 prefix 변경을 어떻게 분류하는가?
|
||||
- public ID 와 internal sequence 를 분리(dual column)하는 동기와 비용은?
|
||||
|
||||
## Do Not Overclaim
|
||||
|
||||
- **"ULID 가 UUID 보다 항상 우월하다" → 금지.** timestamp leak(privacy), 비-IETF 표준, 라이브러리 의존이라는 trade-off 존재.
|
||||
- **"random UUID 는 PostgreSQL 에서도 느리다" → 단정 금지.** 인용 벤치마크는 MySQL InnoDB clustered index 기준 — 다른 엔진에는 parallel evidence 일 뿐.
|
||||
- **"CUID2 는 timestamp 가 절대 안 샌다" → 단정 금지.** spec 저자 주장이며 독립 감사로 확인된 것은 아니다.
|
||||
- **"Google AIP / Stripe 관례 = 업계 공식 표준" → 금지.** 벤더 관례·사례이지 RFC 같은 official-standard 가 아니다.
|
||||
- **"sequential ID 는 무조건 나쁘다" → 맥락 의존.** internal-only(외부 비노출) 라면 합리적일 수 있고, dual column 의 internal PK 가 그 예다.
|
||||
|
||||
## Sources
|
||||
|
||||
- [[raw/official-docs/rfc9562-uuid]] — IETF RFC 9562 (UUID v4/v6/v7/v8, monotonicity, §8 attack surface).
|
||||
- [[raw/official-docs/ulid-spec.md]] — ULID 공식 spec (26-char Crockford base32, monotonic).
|
||||
- [[raw/official-docs/crockford-base32-spec.md]] — Crockford base32 (I/L/O/U 제외, case-insensitive 디코딩).
|
||||
- [[raw/official-docs/rfc3986-uri-generic-syntax]] — URI generic syntax (`unreserved` charset, case normalization).
|
||||
- [[raw/official-docs/cuid2-spec.md]] — CUID2 (timestamp-leak-free 저자 주장).
|
||||
- [[raw/official-docs/nanoid-spec]] — NanoID (21자 URL-safe, crypto random).
|
||||
- [[raw/official-docs/google-aip-148-standard-fields]] — Google AIP-148 standard fields.
|
||||
- [[raw/official-docs/stripe-resource-id-convention]] — Stripe typed prefix opaque ID 관례.
|
||||
- [[raw/company-tech-blogs/percona-uuid-storage-mysql]] — Percona MySQL InnoDB UUID PK 벤치마크.
|
||||
- [[raw/company-tech-blogs/snowflake-twitter-id]] — Twitter Snowflake (조율 부담).
|
||||
- [[raw/company-tech-blogs/planetscale-nanoid-api]] — PlanetScale NanoID + dual column 사례.
|
||||
- [[raw/company-tech-blogs/brandur-stripe-idempotency-keys]] — Brandur: Idempotency-Key vs resource ID.
|
||||
- [[raw/company-tech-blogs/segment-ksuid]] — Segment KSUID (base62, 초 단위 timestamp).
|
||||
- [[raw/company-tech-blogs/github-graphql-global-node-id]] — GitHub global node ID (base64 type-encoded).
|
||||
- [[raw/company-tech-blogs/aws-iam-arn-format]] — AWS ARN 계층 prefix.
|
||||
@@ -0,0 +1,158 @@
|
||||
---
|
||||
title: Runtime / Container / Health / Migration Baseline
|
||||
source_type: llm-generated
|
||||
status: draft
|
||||
confidence: medium
|
||||
tags: [runtime, container, kubernetes, health, migration, flyway]
|
||||
related_projects: [ca-skeleton]
|
||||
last_reviewed: 2026-05-22
|
||||
---
|
||||
|
||||
# Runtime / Container / Health / Migration Baseline
|
||||
|
||||
> Layer: `wiki/concepts/` — JVM 서비스의 container runtime · runtime health · migration startup 세 sub-topic을 한 문서로 통합한 baseline. 내 프로젝트 사실은 `project-template` 사용.
|
||||
|
||||
## Summary
|
||||
|
||||
JVM 서비스의 **runtime baseline**은 세 축으로 구성된다.
|
||||
|
||||
1. **Container**: Eclipse Temurin (Adoptium) JRE slim + JVM ergonomics (`-XX:MaxRAMPercentage=75`, `-XX:+UseContainerSupport`).
|
||||
2. **Health**: Kubernetes Probes (liveness/readiness/startup)를 **세 endpoint로 분리** + Spring Boot Actuator Health Groups로 dependency 범위를 명시.
|
||||
3. **Migration**: Flyway forward-only migration을 **readiness gated**로 실행 + 표준 startup exit code (sysexits 계열 78/70/71/72).
|
||||
|
||||
세 축은 **graceful shutdown 35s budget** (app 20s + preStop 5s + grace 10s margin)으로 묶인다.
|
||||
|
||||
## Standard (공식 정의)
|
||||
|
||||
### Container
|
||||
|
||||
- **Eclipse Temurin (Adoptium)** — JEP/JCK 인증 OpenJDK 빌드. JRE slim 이미지는 JDK 대비 footprint 작고 production runtime에 권장.
|
||||
- **OCI Image spec** — base image, layer, label 표준. Dockerfile은 OCI 호환 image를 산출.
|
||||
- **JVM container ergonomics**:
|
||||
- `-XX:+UseContainerSupport` — JDK 10+ default. cgroup memory/cpu limit을 JVM이 인식.
|
||||
- `-XX:MaxRAMPercentage=<N>` — container memory limit의 N%를 max heap으로 사용. 절대값 `-Xmx`보다 container 환경에서 안전.
|
||||
- `-XX:+ExitOnOutOfMemoryError` — JVM `OutOfMemoryError` 발생 시 즉시 process exit (137).
|
||||
- `-XX:HeapDumpPath=...` — OOM 진단용 heap dump.
|
||||
|
||||
### Health
|
||||
|
||||
- **Kubernetes Probes** (kubelet 공식 모델):
|
||||
- **liveness** — process가 살아있는가. 실패 시 container restart.
|
||||
- **readiness** — traffic을 받을 수 있는가. 실패 시 Service endpoint 제거 (drain).
|
||||
- **startup** — startup이 끝났는가. startup probe가 success할 때까지 liveness/readiness 비활성. 긴 migration/warmup 시 liveness 오판 방지.
|
||||
- probe 분리는 K8s 공식 권장. single `/health`로 묶지 않는다.
|
||||
- **Spring Boot Actuator Health Groups** — `management.endpoint.health.group.liveness.include`, `.readiness.include`로 endpoint별 HealthIndicator set을 분리.
|
||||
- Spring default readiness는 외부 dependency 미포함이므로 DB/broker 등 required dependency는 명시적 group 등록 필요.
|
||||
|
||||
### Migration
|
||||
|
||||
- **Flyway 공식**:
|
||||
- forward-only versioned migration이 기본 model.
|
||||
- `flyway.repair` — checksum/state 수정 도구. **prod 사용은 공식이 직접 위험성 경고** (실제 schema 변경 없이 metadata만 수정).
|
||||
- `flyway.baselineOnMigrate` — 기존 DB에 처음 Flyway 적용 시. 잘못 켜면 누락 migration이 skip된 채 baseline.
|
||||
- `flyway.outOfOrder` — version 순서 외 migration 허용. 협업 환경에서 일관성 깨짐.
|
||||
- **sysexits.h** (BSD `sysexits.h`, 1990s) — Unix 관례적 exit code 의미.
|
||||
- `64` — usage error
|
||||
- `70` — internal software error
|
||||
- `71` — OS error
|
||||
- `72` — critical OS file missing
|
||||
- `78` — config error
|
||||
- 표준이 강제하는 enum은 아니지만 ops/CI 진단에 관례적으로 사용.
|
||||
|
||||
## 한계 / 주의점
|
||||
|
||||
### Container 선택 트레이드오프
|
||||
|
||||
- **Temurin JRE slim (base)**:
|
||||
- 운영/디버깅 친숙도 우위 (shell, JDK tools 가용).
|
||||
- security surface는 distroless보다 크다 (apt, libc 등 OS 패키지 포함).
|
||||
- **Distroless (Google)**:
|
||||
- OS 패키지 제거 → 보안 surface 축소 + image 크기 감소.
|
||||
- shell·debug tool 없음 → in-container 디버깅 손실. 별도 sidecar/ephemeral container 필요.
|
||||
- **Alpine + musl libc**:
|
||||
- image 크기 작음.
|
||||
- musl libc는 glibc 호환성 risk (DNS resolver 차이, native lib 미지원 등). Java 일부 native lib는 alpine에서 동작 미보장.
|
||||
- **GraalVM Native Image / Spring Boot Native**:
|
||||
- cold start/메모리 우위 (수십 MB heap, ms 단위 startup).
|
||||
- reflection·dynamic proxy는 build-time metadata 필요. peak throughput은 HotSpot JIT보다 손실.
|
||||
- Spring Boot Native는 Spring 6+ + Spring Boot 3+ AOT compile 의존.
|
||||
- 우아한형제들 도입기는 전체 native 전환이 아닌 **hybrid 채택** 결론.
|
||||
|
||||
### Health 분리의 한계
|
||||
|
||||
- **Single `/health` endpoint (legacy)**:
|
||||
- liveness/readiness 구분 불가.
|
||||
- K8s rolling update 시 dependency 일시 outage가 container restart loop 유발 가능. traffic 유실 risk.
|
||||
- **Custom HealthIndicator만 사용**:
|
||||
- Spring default readiness는 외부 dependency 미포함. DB/broker 등은 명시적으로 readiness group에 묶지 않으면 readiness가 traffic 가능 여부를 반영하지 않음.
|
||||
- **Service mesh-based health (Istio sidecar)**:
|
||||
- mTLS 환경에서 편의성. 단 sidecar 살아있음 / app 살아있음 구분이 mesh layer에서 불명확.
|
||||
- 추가 infra 의존 (sidecar 주입, mesh control plane).
|
||||
|
||||
### Migration tool 트레이드오프
|
||||
|
||||
- **Liquibase (XML/YAML changelog)**:
|
||||
- DB-agnostic + rollback 기능.
|
||||
- XML/YAML 기반은 SQL 대비 verbose. migration speed Flyway 대비 느림 (changelog parser 오버헤드).
|
||||
- rollback 안전 보장 없음 (rollback script 사람이 작성).
|
||||
- **Hibernate `hbm2ddl=update` 등**:
|
||||
- 공식 anti-pattern. prod 사용 금지가 일반 권고. schema drift 추적 불가.
|
||||
- **Atlas / Tern (schema-as-code)**:
|
||||
- declarative + integrity hash 강점.
|
||||
- Java/Spring 생태계 성숙도 부족. JVM 외부 CLI tool.
|
||||
- **K8s Init Container 패턴**:
|
||||
- replica마다 init container 실행 → multi-instance migration race.
|
||||
- **K8s Job + migration lock**이 race 회피에 구조적 우월.
|
||||
- **Flyway 자체 한계**:
|
||||
- `repair` / `baselineOnMigrate` / `outOfOrder`는 잘못 쓰면 schema state corruption. 공식이 직접 위험 경고.
|
||||
- forward-only 모델이라 rollback은 별도 forward migration으로 처리.
|
||||
|
||||
### Exit code 한계
|
||||
|
||||
- sysexits.h는 관례. POSIX 강제 표준 아님. 조직 표준으로 명시적 enum 필요.
|
||||
|
||||
## Project Application
|
||||
|
||||
- [[wiki/projects/ca-tmpl/runtime-container-health-migration]] — ca-tmpl 의사결정 기록 (현재 `documented-only`, Phase C2 미진입). 실제 구현 여부는 project 문서 참조.
|
||||
- [[raw/branch-notes/feature-container-runtime-contract]] — container runtime 결정 (Temurin JRE slim, `MaxRAMPercentage=75`, UTC/UTF-8, graceful shutdown 35s).
|
||||
- [[raw/branch-notes/feature-runtime-health-lifecycle-contract]] — liveness/readiness/startup 3-endpoint 분리, Required vs Optional Dependency Matrix.
|
||||
- [[raw/branch-notes/feature-migration-startup-contract]] — Flyway baseline + readiness gated + exit code 78/70/71/72.
|
||||
- [[raw/project-notes/ca-skeleton-operational-contract]] (§15 Runtime / Lifecycle Contract).
|
||||
|
||||
## Interview Questions
|
||||
|
||||
- JRE slim과 distroless 중 어떤 base image를 선택하고, 그 근거는 무엇인가?
|
||||
- `-XX:MaxRAMPercentage=75`로 설정한 이유는 무엇이고, 절대값 `-Xmx`와 어떤 차이가 있는가?
|
||||
- liveness / readiness / startup 세 probe를 분리하는 이유는 무엇인가? single `/health`로 묶으면 어떤 운영 문제가 생기는가?
|
||||
- graceful shutdown을 app 20s + preStop 5s + terminationGracePeriodSeconds 35s로 잡았다면 각 단계가 어떤 의미를 가지는가?
|
||||
- Flyway `repair`가 prod에서 위험하다고 보는 근거는? 어떤 대안 경로가 있는가?
|
||||
- startup exit code 78 / 70 / 71 / 72로 분리하면 어떤 진단상 이점이 생기는가? (config error / internal error / OS error / critical OS file missing)
|
||||
|
||||
## Do Not Overclaim
|
||||
|
||||
- "GraalVM native-image가 곧 standard"라고 단정하지 말 것. reflection-heavy 코드와 peak throughput 손실은 실측 trade-off. 우아한형제들 사례도 hybrid 채택.
|
||||
- "Flyway가 항상 우월"이라고 단정하지 말 것. 조직이 XML/YAML 기반 schema-as-doc을 요구하거나 DB-agnostic이 강제일 때는 Liquibase가 합리.
|
||||
- "distroless가 보안상 무조건 정답"이라고 단정하지 말 것. in-container 디버깅 손실은 incident 대응 시간을 늘릴 수 있다.
|
||||
- "K8s probe만 있으면 graceful shutdown은 자동"이라고 말하지 말 것. app shutdown timeout과 manifest grace period가 sync되지 않으면 SIGKILL로 inflight 요청 유실.
|
||||
- "exit code 70/78은 표준"이라고 말하지 말 것. sysexits.h는 관례이고 조직 enum 명시가 필요.
|
||||
|
||||
## Sources
|
||||
|
||||
- [Eclipse Temurin / Adoptium project](https://adoptium.net/) — 공식 OpenJDK 배포.
|
||||
- [Kubernetes — Configure Liveness, Readiness and Startup Probes (공식)](https://kubernetes.io/docs/tasks/configure-pod-container/configure-liveness-readiness-startup-probes/)
|
||||
- [Spring Boot Actuator — Health (공식)](https://docs.spring.io/spring-boot/docs/current/reference/html/actuator.html#actuator.endpoints.health)
|
||||
- [Flyway — Concepts / Repair (공식)](https://documentation.red-gate.com/flyway/) — repair / baseline_on_migrate / out_of_order 위험성 경고 명시.
|
||||
- [sysexits.h — BSD man page](https://man.freebsd.org/cgi/man.cgi?sysexits) — 64/70/71/72/78 등 관례적 exit code.
|
||||
- [[raw/official-docs/container-distroless-google-github]] — Distroless 보안 surface vs 디버깅 손실.
|
||||
- [[raw/official-docs/container-alpine-java-musl-tradeoffs]] — Alpine + musl libc 호환성 risk.
|
||||
- [[raw/official-docs/container-graalvm-native-image-spring-boot]] — GraalVM native-image / Spring Boot Native AOT 비용·이득.
|
||||
- [[raw/company-tech-blogs/container-woowahan-spring-native-tradeoffs]] — 우아한형제들 Spring Native 도입기 (hybrid 채택).
|
||||
- [[raw/official-docs/runtime-health-k8s-probes-official]] — K8s liveness/readiness/startup 공식.
|
||||
- [[raw/official-docs/runtime-health-spring-actuator-groups]] — Spring Boot Actuator Health Groups.
|
||||
- [[raw/official-docs/runtime-health-istio-mesh-health-check]] — Istio mesh health 대안과 한계.
|
||||
- [[raw/company-tech-blogs/runtime-health-datadog-engineering-graceful-shutdown]] — Datadog graceful shutdown preStop/drain/grace 비율 사례.
|
||||
- [[raw/official-docs/migration-flyway-official-concepts-and-repair]] — Flyway 공식 repair/baseline_on_migrate/out_of_order 위험성.
|
||||
- [[raw/official-docs/migration-liquibase-official-changelog-xml-yaml]] — Liquibase XML/YAML changelog.
|
||||
- [[raw/official-docs/migration-atlas-schema-as-code]] — Atlas schema-as-code 대안.
|
||||
- [[raw/official-docs/migration-k8s-init-container-job-pattern]] — K8s Init Container vs Job 패턴 비교.
|
||||
- [[raw/project-notes/ca-skeleton-operational-contract]] — §15 Runtime / Lifecycle Contract.
|
||||
@@ -0,0 +1,83 @@
|
||||
---
|
||||
title: Sample Fixture & Adoption (skeleton template lifecycle)
|
||||
source_type: llm-generated
|
||||
status: draft
|
||||
confidence: medium
|
||||
tags: [skeleton, sample-fixture, template, adoption]
|
||||
related_projects: [ca-skeleton]
|
||||
last_reviewed: 2026-05-22
|
||||
---
|
||||
|
||||
# Sample Fixture & Adoption (skeleton template lifecycle)
|
||||
|
||||
> Layer: `wiki/concepts/` — skeleton/template lifecycle 일반 개념. 구체 결정과 검증 등급은 `wiki/projects/` 또는 `raw/branch-notes/`에서 판정.
|
||||
|
||||
## Summary
|
||||
|
||||
skeleton/template repository 라이프사이클은 두 축으로 분해된다. 첫째, **sample fixture**는 비즈니스 기능이 아니라 skeleton 계약(envelope/error/capability/transaction/idempotency)을 트리거하는 contract 검증 도구이다. 둘째, **sample-off/adoption**은 실제 도메인을 얹을 때 sample을 production runtime에서 비활성화하면서도 운영 계약이 함께 사라지지 않도록 보장하는 절차이다. 두 영역의 대표 안: sample-ticket 12 scenario matrix + 6-field minimum model + `OPEN→IN_PROGRESS→CLOSED` state machine + optimistic lock + idempotency key, 그리고 sample-off profile + production dependency 차단 + dual-mode CI matrix(sample-on / sample-off 둘 다 release-blocking) + multi-module adoption checklist.
|
||||
|
||||
## Standard (공식 정의 / 업계 사례)
|
||||
|
||||
### Sample fixture 계열
|
||||
|
||||
- **Spring Petclinic**: Spring Framework 공식 데모. README에 "demo지 best-practice 아님" 본인 선언. 학습/시연 목적, contract 검증 매트릭스는 부재.
|
||||
- **RealWorld (gothinkster Conduit)**: cross-stack spec (Article/Comment/User/Follow/Favorite). 백엔드 언어/프레임워크 호환성을 검증하는 reference. spec은 풍부하지만 minimum이 아니고, envelope/idempotency/optimistic lock 같은 contract scenario는 정의 범위 밖.
|
||||
- **Spring Cloud Microservices sample**: microservices 변형 (config server, eureka, gateway). fixture 수준을 초과해 인프라 다수 component를 함께 보여줌.
|
||||
- **Stripe testmode**: SaaS sandbox. payment 도메인에 한정된 sandbox key/카드 번호.
|
||||
|
||||
### Removal / adoption 계열 (template scaffolding)
|
||||
|
||||
- **Yeoman / Maven archetype**: generator 시점에 sample 제외 옵션을 노출하는 전통적 generator 모델. 생성 후에는 sample 자취가 남지 않음.
|
||||
- **Cookiecutter (Python)**: `{{cookiecutter.*}}` 변수 치환 기반 generator. 생성 시점 sample-off가 기본.
|
||||
- **degit (Svelte)**: git history 없이 repo를 clone하는 경량 도구. 생성 후에도 원본 sample 그대로 존재.
|
||||
- **Spring Initializr**: Spring Boot 공식 generator. dependency / build tool / language / Java version 선택 기반이며 contract sample은 포함되지 않음.
|
||||
- **GitHub Template Repository**: GitHub 공식 기능. 한 번의 클릭으로 코드뿐 아니라 CI/Actions workflow 파일까지 그대로 복제됨. friction이 가장 낮은 reference scaffolding 모델.
|
||||
- **Backstage golden path (Spotify IDP)**: Spotify가 발표한 internal developer platform. service template / scorecard / catalog를 묶어 조직 차원에서 표준 stack 진입점을 제공.
|
||||
|
||||
## 한계 / 주의점
|
||||
|
||||
- **Spring Petclinic**: README가 "demo"라고 자기 부정. best-practice baseline으로 사용하기에는 contract enforcement test/registry/profile isolation이 없어 부족.
|
||||
- **RealWorld**: domain spec은 풍부하나 "minimum"이 아니며, validation/conflict/optimistic lock/idempotency를 trigger하는 contract 시나리오 매트릭스는 정의되지 않음. backend cross-stack 호환성 reference로는 적합.
|
||||
- **Stripe testmode**: SaaS-side sandbox. OSS skeleton repo가 채택할 수 있는 모델은 아니며 payment 도메인에 한정.
|
||||
- **No fixture (unit test only)**: contract test를 트리거할 도메인 흐름 자체가 없어 envelope/capability/transaction 일관성을 행위로 검증할 수단이 없음.
|
||||
- **Yeoman / Maven archetype**: generator 시점에 sample을 제거하므로, "sample-on / sample-off 두 mode를 CI에서 동시에 green으로 유지"하는 운영 모델과는 시맨틱이 다름.
|
||||
- **Cookiecutter**: Python ecosystem에 정착. JVM/Spring 환경에서는 직접 도구로 들이기 어렵고, 동일하게 generator 시점 sample-off 모델.
|
||||
- **degit**: 단일 repo 단순 clone에 최적화. monorepo / multi-module 구조나 CI/Actions 동반 복제에는 친화적이지 않음.
|
||||
- **Spring Initializr**: dependency-only generator. operational contract / sample fixture / contract test 같은 운영 계약 묶음은 제공하지 않음.
|
||||
- **GitHub Template Repository**: CI/Actions 파일까지 그대로 복제되어 friction이 낮다. skeleton repo 모델의 reference 1순위로 평가되지만, 그 자체로 sample-off profile이나 adoption 절차를 보장하지는 않음. 별도 sample-off/adoption 절차가 함께 정의되어야 함.
|
||||
- **Backstage**: 조직 규모가 service template / scorecard / catalog를 따로 운영할 수준에 도달한 이후 적합. 1인 / 소규모 단계에서는 IDP 도입 자체가 과투자.
|
||||
|
||||
## Project Application
|
||||
|
||||
- [[wiki/projects/ca-tmpl/sample-fixture-and-adoption]] — ca-tmpl 의사결정 기록 (현재 `documented-only`, Phase C2 미진입). 실제 구현 여부는 project 문서 참조.
|
||||
- [[raw/branch-notes/feature-sample-domain-contract-fixture]] — sample-ticket 12 scenario matrix + 6-field minimum model + state machine + optimistic lock + idempotency key 결정 SSOT branch.
|
||||
- [[raw/branch-notes/feature-sample-removal-adoption-contract]] — `sample-ticket` fixture module 유지 + sample-off runtime isolation + dual-mode CI matrix 결정 SSOT branch.
|
||||
- [[raw/project-notes/ca-skeleton-operational-contract]] — canonical operational contract (§17 Sample Domain Fixture, §22 Sample-ticket Contract Matrix, §29 G-H Sample / adoption).
|
||||
|
||||
## Interview Questions
|
||||
|
||||
- sample-ticket 12 scenario matrix는 어떤 의미를 갖나요? 왜 단순한 CRUD 예제가 아니어야 하나요?
|
||||
- sample-ticket이 6개 필드(`TicketId`, `TicketTitle`, `TicketStatus`, `TicketVersion`, `TicketOwner`, `IdempotencyKey`)만 가지는 근거는 무엇인가요?
|
||||
- "dual-mode CI matrix(sample-on / sample-off 둘 다 release-blocking)"는 어떤 문제를 막기 위한 장치인가요?
|
||||
- sample-off first adoption이 즉시 코드 삭제보다 좋은 이유는 무엇인가요?
|
||||
- Spring Petclinic이나 RealWorld 같은 기존 sample 대신 자체 fixture(sample-ticket)를 둔 이유는 무엇인가요?
|
||||
|
||||
## Do Not Overclaim
|
||||
|
||||
- sample-ticket을 "도메인 모델"로 단정하면 안 된다. sample은 skeleton 계약을 트리거하기 위한 **contract 검증 도구(fixture)**이며 production feature가 아니다.
|
||||
- Spring Initializr / Cookiecutter를 "ca-tmpl과 동급 alternative"로 단정하면 안 된다. 두 도구 모두 **generator 시점에 sample을 빼는 모델**이라 sample-on / sample-off 두 mode를 동시에 release-blocking으로 검증하는 운영 모델과 시맨틱이 다르다.
|
||||
- "GitHub Template Repository가 reference 1순위"라는 평가는 friction(=초기 복제 단계의 마찰) 기준일 뿐이다. sample-off 절차, adoption checklist, operational contract 보존은 별도로 정의되어야 한다.
|
||||
- Backstage는 조직 규모 임계점 이후의 IDP 진입점이며, 일반적인 skeleton repo와 동일 레이어가 아니다.
|
||||
- 위 비교는 외부 raw 자료 발췌와 ca-skeleton operational contract canonical을 기반으로 한 정리이며, 본 문서는 status `draft` / confidence `medium`이다. 실제 채택 / 검증 등급은 관련 `wiki/projects/` 문서에서 판정한다.
|
||||
|
||||
## Sources
|
||||
|
||||
- [[raw/official-docs/sample-spring-petclinic-github]] — Spring Petclinic README (demo 선언)
|
||||
- [[raw/official-docs/sample-realworld-gothinkster-github]] — RealWorld (Conduit) spec
|
||||
- [[raw/official-docs/sample-microservices-spring-cloud-github]] — Spring Cloud microservices sample
|
||||
- [[raw/official-docs/scaffolding-spring-initializr]] — Spring Initializr generator
|
||||
- [[raw/official-docs/scaffolding-cookiecutter-official]] — Cookiecutter (Python)
|
||||
- [[raw/official-docs/scaffolding-degit-svelte-github]] — degit (Svelte)
|
||||
- [[raw/official-docs/scaffolding-github-template-repository]] — GitHub Template Repository
|
||||
- [[raw/company-tech-blogs/scaffolding-backstage-golden-path-spotify]] — Backstage golden path (Spotify IDP)
|
||||
- [[raw/project-notes/ca-skeleton-operational-contract]] — §17 Sample Domain Fixture, §22 Sample-ticket Contract Matrix, §29 Group G-H
|
||||
@@ -0,0 +1,138 @@
|
||||
---
|
||||
title: Security Baseline (JWT Resource Server + Actuator + Secrets)
|
||||
source_type: llm-generated
|
||||
status: draft
|
||||
confidence: medium
|
||||
tags: [security, jwt, oauth2, actuator, secrets]
|
||||
related_projects: [ca-skeleton]
|
||||
last_reviewed: 2026-05-22
|
||||
---
|
||||
|
||||
# Security Baseline (JWT Resource Server + Actuator + Secrets)
|
||||
|
||||
> Layer: `wiki/concepts/` — JWT Resource Server 기반 인증/인가, Actuator 관리면 보안, secret 소스/rotation 세 가지를 한 묶음으로 다루는 백엔드 보안 baseline 개념 문서. 실무 적용은 `wiki/projects/` 문서로 분리.
|
||||
|
||||
## Summary
|
||||
|
||||
운영 가능한 백엔드 보안 baseline은 **세 축**으로 구성된다. ① 데이터면 인증/인가는 **JWT Resource Server**(RFC 7519/8725, OAuth2 Resource Server) 기준으로 표준화하고, 토큰 실패를 `missing / malformed / expired / invalid signature / issuer / audience / unknown kid / claim mapping` 등으로 분류한다. JWKS는 주기 refresh(예: 10분 + unknown kid 시 on-demand)로 키 회전을 흡수하고, JWT 시간 검증은 **clock skew tolerance 60s** 정도를 둔다. ② 제어면(Actuator)은 **management port 분리**(예: 9001) + prod allowlist(health / prometheus / info) + heapdump/threaddump/env/configprops/shutdown forbidden을 default로 한다. ③ Secret은 **prod = secret manager 또는 mounted secret**, local만 `.env` 허용, runtime reload 금지, rotation은 restart 또는 명시적 dual-bind/overlap window로만 한다.
|
||||
|
||||
## Standard (공식 정의)
|
||||
|
||||
### JWT / OAuth2 / 인가
|
||||
|
||||
- **RFC 7519 (JSON Web Token)**: JWT 구조와 `iss`, `aud`, `exp`, `nbf`, `iat`, `jti`, `sub` 등 표준 claim, 서명/검증 의무를 규정. `exp`/`nbf` 검증 시 "a few minutes leeway"가 일반적이며 구현은 명시된 허용치를 설정해야 한다.
|
||||
- **RFC 8725 (JWT Best Current Practices)**: algorithm confusion 회피(`alg: none` 금지, `HS256`↔`RS256` 혼용 금지), `kid` 사용, audience/issuer 명시 검증, `typ: JWT` 검증 등 운영상 함정 정리.
|
||||
- **RFC 6749/6750 + OAuth2 Resource Server**: bearer token으로 보호된 리소스에서 token validation 책임을 resource server에 두는 모델. Spring Security 6의 `spring-boot-starter-oauth2-resource-server`가 표준 구현 경로.
|
||||
- **RFC 8252 (OAuth 2.0 for Native Apps) + PKCE**: public client(SPA, mobile)의 authorization code flow에서 code interception 방어. **issuance flow** 영역으로 resource server JWT 검증과는 보완재.
|
||||
- **RFC 8705 (Mutual-TLS Client Authentication and Certificate-Bound Access Tokens)**: mTLS 또는 sender-constrained token. JWT보다 강한 보장이나 PKI 운영 비용이 큼.
|
||||
- **OWASP Authorization Cheatsheet**: deny-by-default, least privilege, server-side enforcement, ABAC/RBAC 혼합, audit logging 등 인가 설계 원칙.
|
||||
|
||||
### Actuator / 관리면
|
||||
|
||||
- **Spring Boot Actuator 공식 문서**: 기본적으로 `health`, `info`만 web exposure, 그 외(`env`, `configprops`, `heapdump`, `threaddump`, `loggers`, `shutdown`)는 default disabled. `management.endpoints.web.exposure.include`로 명시 허용 + `SecurityFilterChain`으로 별도 보호 권고.
|
||||
- **`management.server.port`**: app port(8080)와 별도의 management port(예: 9001)로 분리 가능. 네트워크 ACL/Ingress에서 외부 노출 차단을 단순화하는 것이 분리 권고의 핵심.
|
||||
- **Istio sidecar / service mesh**: mTLS, AuthorizationPolicy로 management endpoint 보호 가능. mesh 가정이 강하므로 framework-neutral skeleton에서는 대안.
|
||||
|
||||
### Secrets / Config
|
||||
|
||||
- **12-factor App §III. Config**: 환경 사이에서 변하는 값은 **환경변수**로 외부화, 코드와 분리. config dump 금지의 이론 근거.
|
||||
- **AWS Secrets Manager (auto-rotation)**: Lambda 기반 rotation function 표준. dual-binding window 동안 old/new credential을 둘 다 유효하게 두어 connection pool/검증자 캐시가 흡수하도록 설계.
|
||||
- **HashiCorp Vault (dynamic secrets)**: lease 기반 짧은 수명 credential 발급. lease renewal 책임을 클라이언트가 짊.
|
||||
- **K8s Secret + External Secrets Operator (ESO)**: 외부 secret manager → K8s Secret → 컨테이너 mount/env 경로. etcd 암호화 미설정 시 평문 저장 한계.
|
||||
- **NIST SP 800-57 (Recommendation for Key Management)**: cryptoperiod, key rotation, key destruction의 표준. HMAC salt/JWT signing key rotation 주기 결정의 reference.
|
||||
|
||||
## 한계 / 주의점
|
||||
|
||||
### JWT Resource Server
|
||||
|
||||
- **Revocation 한계**: 표준 JWT는 stateless 검증이므로 발급 후 강제 무효화가 어렵다. 회수 수단은 ① short expiry + refresh token, ② JWKS rotation + 작은 key overlap, ③ deny-list cache(상태 부활), ④ token introspection(stateless 포기) 중 trade-off. "JWT라 안전하다"는 단정 금지.
|
||||
- **algorithm confusion**: RFC 8725가 명시적으로 경고. 구현 단에서 server-side로 허용 알고리즘을 fix해야 함(`alg: none`/HS↔RS 혼용 금지).
|
||||
- **clock skew**: 너무 작게 잡으면 서버 시계 drift로 false negative, 너무 크면 expired token 수용 창 확대. 일반적으로 30~60s 권고.
|
||||
- **JWKS endpoint outage**: cache miss + IdP 장애 시 모든 인증이 막힘. 캐시 TTL + on-demand refresh + 명시적 outage status 분류가 필요.
|
||||
|
||||
### Session + Cookie
|
||||
|
||||
- stateless 확장성 손실(서버 측 session store 필요).
|
||||
- CSRF 방어, SameSite/HttpOnly/Secure cookie 운영 복잡도.
|
||||
- revocation은 session 삭제로 즉시 가능 — 보안상 강점이지만 비용은 분산 session store.
|
||||
|
||||
### mTLS
|
||||
|
||||
- sender-constrained로 token theft 위협에 강함.
|
||||
- 단점: PKI(발급/갱신/폐기) 운영 비용, public client(브라우저 SPA, 모바일 일반 사용자) 사용 어려움.
|
||||
|
||||
### OPA (Open Policy Engine)
|
||||
|
||||
- 정책-코드 분리, 외부에서 정책 변경/감사 가능.
|
||||
- 단점: 외부 호출 latency, sidecar/agent 운영, in-process 인가 2~3종에는 과한 인프라.
|
||||
|
||||
### Actuator
|
||||
|
||||
- **single-port + path ACL**: cloud ingress가 path 기반 차단을 강하게 보장할 때만 안전. 잘못된 filter ordering, regex 매칭 우회 risk.
|
||||
- **mTLS for management**: 강하지만 cert 운영 부담.
|
||||
- **mesh sidecar (Istio)**: mesh 도입을 전제 → skeleton/framework-neutral 가정과 충돌.
|
||||
- **info endpoint**: build info 외에 commit hash/branch만 노출해도 attack surface가 될 수 있음 — 무엇이 들어가는지 명시 필요.
|
||||
- **한국 사례 (토스/우아한형제들 등) 일부 참조 가능 (G-B 후속 보강 결과).** Actuator 노출 보안에 대한 한국 도메인 사례가 존재하며, JWT/secret 관리 직접 사례는 follow-up 후보로 남음.
|
||||
|
||||
### Secrets
|
||||
|
||||
- **Vault dynamic secrets**: 짧은 lease가 보안 우위이나, **Spring `@RefreshScope` + bean 재생성** 흐름을 강제 → connection pool/캐시 lifecycle과 충돌. ca-tmpl처럼 `@RefreshScope` 금지 환경에서는 정면 충돌.
|
||||
- **AWS Secrets Manager auto-rotation**: dual-binding window 60s 패턴과 정합하지만, rotation Lambda 자체가 운영/감사 대상.
|
||||
- **ESO**: K8s native이지만 etcd 평문 저장은 cluster operator의 별도 책임.
|
||||
- **Doppler / 1Password SDK**: dev 머신까지 reference 보호 강점이지만 SaaS 외부 의존.
|
||||
- **plain env**: prod에서 ps/dump/log 노출 가능성 — 단독 baseline으로는 거부 대상.
|
||||
|
||||
## Project Application
|
||||
|
||||
- [[wiki/projects/ca-tmpl/security-baseline-jwt-actuator-secrets]] — ca-tmpl 의사결정 기록 (현재 `documented-only`, Phase C2 미진입). 실제 구현 여부는 project 문서 참조.
|
||||
이 baseline은 ca-skeleton 운영 계약과 세 개의 branch-note 결정에 적용된다(검증 등급은 각 branch/project 문서가 판정한다 — 이 concept 문서는 등급을 매기지 않는다).
|
||||
|
||||
- [[raw/project-notes/ca-skeleton-operational-contract]] — §18 Control Plane Contract (Secrets / Config Source, Management / Actuator Security)
|
||||
- [[raw/branch-notes/feature-security-operational-baseline]] — JWT Resource Server + AuthN/AuthZ Matrix 12행 + JWKS 10min refresh + clock skew 60s + rotation overlap 24h + public path snapshot diff
|
||||
- [[raw/branch-notes/feature-management-actuator-security-contract]] — management port 9001 + prod allowlist + heapdump/threaddump prod forbidden + loggers prod read-only + metrics network ACL default
|
||||
- [[raw/branch-notes/feature-secrets-config-source-contract]] — prod = secret manager OR mounted env + `no-runtime-reload` default + `__LOCAL_DEV_` sentinel + JWT key 24h overlap / DB credential dual-bind 60s / API key restart-reload
|
||||
|
||||
## Interview Questions
|
||||
|
||||
- JWT vs Session 기반 인증을 어떤 기준으로 선택하는가? (stateless 확장성 / revocation 용이성 / cookie 운영 비용 / 클라이언트 타입)
|
||||
- JWKS rotation 주기와 unknown `kid` 처리 정책을 어떻게 설계하는가? (refresh 주기, on-demand refresh, overlap window)
|
||||
- Spring Boot Actuator를 운영에서 노출할 때 management port를 분리하는 이유는? (network 경계 단순화, ingress 정책, single-port + path ACL 위험)
|
||||
- secret rotation을 zero-downtime으로 만들 때 어떤 패턴을 쓰는가? (dual-bind window, JWT key overlap, restart-only vs runtime reload)
|
||||
- HMAC salt rotation을 90일 등으로 두는 근거는? (NIST cryptoperiod 권고, 누적 노출량 한도, downstream re-hash 비용)
|
||||
- JWT 검증의 `clock skew tolerance`를 어떻게 정하는가? (NTP drift 가정, 발급자/검증자 분산도, expired vs replay trade-off)
|
||||
|
||||
## Do Not Overclaim
|
||||
|
||||
- **"JWT는 안전하다"는 단정 금지.** 토큰 탈취 시 revocation이 어렵다는 한계가 있다. JWT의 보안성은 발급/저장/전송/회수 전 과정 설계에 좌우된다.
|
||||
- **"HashiCorp Vault가 secret 관리의 표준"이라는 단정 금지.** dynamic secrets는 강력하지만 `@RefreshScope`/bean refresh 패턴을 전제로 하며, 이를 금지하는 운영 계약(예: ca-skeleton)과는 충돌한다. AWS Secrets Manager, K8s + ESO, 1Password 등은 각자 다른 운영 상충점을 갖는다.
|
||||
- **"actuator를 켜두는 것은 항상 안전하다"는 단정 금지.** default exposure가 `health`/`info`로 좁아도 `env`, `configprops`, `heapdump`, `threaddump`, `shutdown`이 잘못 열리면 그대로 공격 표면이 된다. allowlist + 네트워크 경계 + 인증의 다층 방어가 필요하다.
|
||||
- **"company tech blog가 JWT/secret를 이렇게 쓴다 = 공식 best practice"** 로 격상 금지. 사례는 참고일 뿐 RFC/OWASP/공식 문서 기준과 구분해야 한다.
|
||||
|
||||
## Sources
|
||||
|
||||
### Canonical project SSOT
|
||||
|
||||
- [[raw/project-notes/ca-skeleton-operational-contract]] — §18 Control Plane Contract, §29 Group G-B 외부 근거 인덱스
|
||||
|
||||
### JWT / OAuth2 / 인가 (raw)
|
||||
|
||||
- [[raw/official-docs/security-jwt-rfc-7519-validation]] — RFC 7519 JWT claim 검증 표준
|
||||
- [[raw/official-docs/security-oauth2-pkce-rfc-8252]] — OAuth2 PKCE (RFC 8252) issuance flow 표준
|
||||
- [[raw/official-docs/security-mtls-rfc-8705]] — mTLS sender-constrained token (RFC 8705)
|
||||
- [[raw/official-docs/security-aws-sigv4-hmac-signing]] — AWS SigV4 HMAC signing (webhook/외부 호출 인증 영역)
|
||||
- [[raw/official-docs/security-authorization-cheatsheet-owasp]] — OWASP Authorization Cheatsheet (deny-by-default)
|
||||
|
||||
### Actuator / 관리면 (raw)
|
||||
|
||||
- [[raw/official-docs/actuator-endpoint-exposure-spring-official]] — Spring 공식 actuator default exposure 정책
|
||||
- [[raw/official-docs/actuator-management-port-spring-official]] — Spring 공식 separate management port 권고
|
||||
- [[raw/official-docs/actuator-istio-sidecar-management-alt]] — Istio sidecar 기반 management 보호 (대안)
|
||||
- [[raw/company-tech-blogs/security-woowahan-actuator-safe-usage]] — 우아한형제들 SOC팀 Actuator 안전 사용 사례 (한국 도메인)
|
||||
- [[raw/company-tech-blogs/security-toss-actuator-healthcheck]] — 토스 Spring Boot Actuator 헬스체크 (health detail 민감성, 한국 도메인)
|
||||
|
||||
### Secrets / Config (raw)
|
||||
|
||||
- [[raw/official-docs/secrets-aws-secrets-manager-rotation]] — AWS Secrets Manager + auto-rotation (dual-bind 패턴 정합)
|
||||
- [[raw/official-docs/secrets-vault-dynamic-secrets-hashicorp]] — HashiCorp Vault dynamic secrets (short lease)
|
||||
- [[raw/official-docs/secrets-k8s-secret-external-secrets-operator]] — K8s Secret + External Secrets Operator
|
||||
- [[raw/company-tech-blogs/secrets-1password-developer-secret-references]] — 1Password developer secret references (dev 머신 보호 사례)
|
||||
+153
@@ -0,0 +1,153 @@
|
||||
---
|
||||
title: Skeleton Governance (Registry + Verification + Test taxonomy + Scorecard)
|
||||
source_type: llm-generated
|
||||
status: draft
|
||||
confidence: medium
|
||||
tags: [skeleton, governance, archunit, testcontainers, scorecard]
|
||||
related_projects: [ca-skeleton]
|
||||
last_reviewed: 2026-05-22
|
||||
---
|
||||
|
||||
# Skeleton Governance (Registry + Verification + Test taxonomy + Scorecard)
|
||||
|
||||
> Layer: `wiki/concepts/` — 일반 개념. 내 프로젝트 사실은 `project-template` 사용.
|
||||
|
||||
## Summary
|
||||
|
||||
스켈레톤 거버넌스는 네 축으로 구성된다. (1) **Contract registry** — markdown SSOT(canonical 운영 계약) + YAML 파생을 단일 진실 원천으로 두고 ADR/스키마 레지스트리 같은 외부 대안을 트레이드오프 관점에서 선택, (2) **Verification suite** — Pact CDC · Spring Cloud Contract · Spring REST Docs · WireMock/Hoverfly 등으로 계약-구현 일치를 자동 검증, (3) **Test taxonomy** — 단위/얇은 슬라이스/통합/E2E/계약/성능의 6 레벨로 피라미드와 트로피의 절충을 명시, (4) **Readiness scorecard** — 11개 릴리즈 차단 게이트의 binary pass/fail로 채택 가능 여부를 판정. 네 축은 서로 참조 관계이며 어느 하나가 빠지면 거버넌스가 깨진다.
|
||||
|
||||
## Standard (공식 정의)
|
||||
|
||||
### Contract registry
|
||||
|
||||
- **Architecture Decision Records (ADR)**: Michael Nygard이 제안한 결정 단위 markdown 문서. 컨텍스트·결정·결과를 명시하며 한번 채택된 ADR은 변경 대신 새 ADR로 교체. branch-note의 "결정/근거/측정값" 패턴과 구조가 유사하다.
|
||||
- **Schema/Protobuf/Smithy registry**: 데이터/인터페이스 계약을 IDL로 선언하고 빌드 산출물(jar, 코드)로 분배. 멀티 언어·멀티 팀에서 단일 출처를 강제하는 방식.
|
||||
- **Markdown SSOT + YAML 파생**: 운영 계약을 사람이 읽는 markdown 한 곳에만 두고, machine-readable 형식은 빌드 시점에 파생. drift는 빌드 스크립트가 검사.
|
||||
- **Code-only registry (enum/annotation)**: ArchUnit·custom annotation에 메타정보를 박는 방식. verifier 가깝지만 사람이 읽기 어려움.
|
||||
|
||||
### Verification suite
|
||||
|
||||
- **Pact (Consumer-Driven Contract)**: consumer가 기대를 pact 파일로 선언 → provider가 pact broker에서 받아 검증. 외부 consumer가 많을 때 효과.
|
||||
- **Spring Cloud Contract**: provider 쪽 DSL/YAML로 계약 정의 → consumer stub 자동 생성. JVM 단일 생태계에 최적.
|
||||
- **Spring REST Docs**: 테스트 통과 시점에 asciidoc 스니펫을 자동 추출. 문서-구현 일치 보장 강하지만 "계약 위반 시 빌드 실패" 강제력은 약함.
|
||||
- **ApprovalTests / JSON snapshot**: 출력 스냅샷을 파일로 저장, diff로 회귀 감지. 단일 팀에서 가장 가볍다.
|
||||
- **WireMock / Hoverfly**: 외부 의존성 mock/record-replay. 통합 테스트에서 외부 시스템을 격리.
|
||||
- **ArchUnit**: 패키지 의존 방향·네이밍·어노테이션 규칙을 JUnit 테스트로 표현해 빌드 차단.
|
||||
|
||||
### Test taxonomy
|
||||
|
||||
- **Test pyramid (Mike Cohn, *Succeeding with Agile*)**: 단위 다수 → 서비스 일부 → UI 소수. 비용/속도 기반.
|
||||
- **Test trophy (Kent C. Dodds)**: 정적 분석 + 단위 + 통합(가장 두꺼움) + E2E. 통합이 ROI가 높다는 주장.
|
||||
- **Honeycomb (Spotify)**: 마이크로서비스에서는 통합 중심이 현실적이라는 변형.
|
||||
- **Fitness functions (*Building Evolutionary Architectures*, Ford et al.)**: 아키텍처 특성(레이어 의존성, 성능 SLO, 보안 룰)을 실행 가능한 테스트로 표현.
|
||||
- **Testcontainers**: real DB/Kafka/Redis를 Docker로 띄워 통합 테스트. mock의 false confidence를 줄인다는 입장.
|
||||
|
||||
### Readiness scorecard
|
||||
|
||||
- **AWS Well-Architected Framework**: 6 pillar(운영·보안·신뢰성·성능·비용·지속가능성)에 대한 review 질문. 점진적 maturity.
|
||||
- **CIS Benchmark**: 구성 항목별 pass/fail. 보안 baseline에 가까움.
|
||||
- **SLSA (Supply-chain Levels for Software Artifacts)**: build 단계의 무결성을 1~4 레벨로 나눔.
|
||||
- **CMMI**: 조직 프로세스 성숙도 1~5.
|
||||
- **OpenTelemetry Maturity Model**: observability 도입 단계.
|
||||
|
||||
스켈레톤은 이 중 **CIS/Well-Architected의 binary pass/fail** 접근에 가깝다. "릴리즈 가능한가"만 판정.
|
||||
|
||||
## 한계 / 주의점
|
||||
|
||||
### Registry 축
|
||||
|
||||
- **Markdown SSOT + YAML 파생**: drift 검증 도구를 **자체 작성**해야 함. CI에 통합되지 않으면 SSOT가 깨져도 모름.
|
||||
- **Code-only enum/annotation**: SSOT가 코드 곳곳에 분산. 사람이 한눈에 보기 어렵고 외부 리뷰어가 접근 못 함.
|
||||
- **Protobuf/Smithy registry**: IDL 학습·빌드 파이프라인 추가·breaking change 정책까지 필요. 단일 팀 스켈레톤에는 도입 비용이 효익을 초과할 수 있음.
|
||||
- **ArchUnit annotations as registry**: verifier 한정. "왜 이 규칙인지"를 표현하지 못함 — registry라기보다 enforcement. (2026-05-22 후속 평가: framework-neutral 부재 / git diff review 약함 / 외부 도구 호환 불가로 ca-tmpl에서 채택 보류, markdown SSOT 유지. [[raw/official-docs/archunit-annotation-as-registry-evaluation]])
|
||||
- **DB-stored registry (config service)**: 런타임 의존성·운영 부담. 빌드 타임 결정에는 부적합.
|
||||
|
||||
### Verification 축
|
||||
|
||||
- **Pact CDC**: 외부 consumer가 다수일 때 강점. **single-team / single-repo 환경에선 JSON snapshot이 우위** — broker 운영 비용, consumer-provider 협업 오버헤드가 효익을 초과.
|
||||
- **Spring Cloud Contract**: JVM 외 consumer가 있으면 stub 활용도 떨어짐.
|
||||
- **Spring REST Docs**: 문서 자동 생성에는 좋지만 "계약을 깨면 빌드가 실패"하는 강제력은 약함 — 문서가 코드와 같이 갱신될 뿐, 변경 자체는 막지 않음.
|
||||
- **WireMock/Hoverfly**: real system과 mock의 차이로 false green 가능. Testcontainers와 병행 필요.
|
||||
- **ArchUnit**: 규칙이 많아지면 테스트 시간·유지보수 부담. annotation 기반 규칙은 어노테이션 누락 시 silently pass.
|
||||
|
||||
### Test taxonomy 축
|
||||
|
||||
- **6 level (unit / slice / integration / e2e / contract / performance)**: 전체 budget 5분 등 시간 제약을 두면 레벨이 늘수록 budget 준수가 어려움. **레벨 분리 + 병렬화 + nightly 분리**가 필요.
|
||||
- **Testcontainers integration**: real DB/Redis로 mock보다 정확하지만 CI 시간 증가. cache layer warm-up 비용 큼.
|
||||
- **Trophy/Honeycomb 모델**: "통합이 ROI 높다"는 주장은 도메인 의존적. 순수 라이브러리·CLI에는 과한 권고.
|
||||
- **Fitness functions**: 빌드 차단력은 강하지만 룰을 잘못 짜면 false positive로 개발 흐름을 막음.
|
||||
|
||||
### Scorecard 축
|
||||
|
||||
- **Binary pass/fail**: **adoption gate 판단에 적합**. "이 스켈레톤으로 신규 프로젝트를 시작해도 되는가" 같은 컷오프 결정에 단순·명확.
|
||||
- 그러나 **점진적 개선이 필요한 기존 시스템 평가**에는 부적합 — "50% 만족"을 표현 못 함. 한 게이트를 못 넘으면 전체가 not-ready로 표시되어, 개선 우선순위를 가리기 어려움.
|
||||
- **AWS Well-Architected / CIS**: 운영 중 시스템의 점진적 개선·우선순위 매기기에 적합. 새 스켈레톤 평가엔 항목이 너무 많아 noise.
|
||||
- **SLSA**: 공급망에 한정. registry/test 영역은 다루지 않음.
|
||||
- **CMMI / OpenTelemetry maturity**: 조직·도메인 단위 평가. 단일 skeleton repo 단위에는 과대.
|
||||
|
||||
### 4축의 결합 한계
|
||||
|
||||
- 네 축이 서로 참조되도록 강제하지 않으면 거버넌스가 깨짐. 예: scorecard가 verification suite를 "통과" 표시했는데 실제로는 일부 contract만 검증된 경우. **메타 검증(scorecard ↔ verification ↔ registry 교차 확인)이 별도로 필요**.
|
||||
- branch-note ≈ mini-ADR로 운용하면 결정 이력은 보존되나, 시간이 지나며 ADR이 누락된 결정이 코드에 생길 수 있음 — registry 정기 audit 필요.
|
||||
|
||||
## Project Application
|
||||
|
||||
- [[wiki/projects/ca-tmpl/skeleton-governance-registry-verification-test-scorecard]] — ca-tmpl 의사결정 기록 (현재 `documented-only`, Phase C2 미진입). 실제 구현 여부는 project 문서 참조.
|
||||
- [[raw/project-notes/ca-skeleton-operational-contract]] — §12 Test Contract, §21 Contract Registry, §27 Readiness Scorecard, §29 Group G-G
|
||||
- [[raw/branch-notes/feature-contract-registry-governance]]
|
||||
- [[raw/branch-notes/feature-contract-verification-test-suite]]
|
||||
- [[raw/branch-notes/feature-test-taxonomy-fixture-contract]]
|
||||
- [[raw/branch-notes/feature-implementation-readiness-scorecard]]
|
||||
|
||||
(실제 구현 여부·검증 등급은 위 project / branch 문서에서 판정. 본 concept 문서는 등급을 직접 매기지 않음.)
|
||||
|
||||
## Interview Questions
|
||||
|
||||
- Contract registry의 SSOT 위치를 markdown SSOT vs code-only(enum/annotation) vs IDL(Protobuf/Smithy) 중 어떻게 선택했고, 각 선택의 트레이드오프는 무엇인가?
|
||||
- Consumer-Driven Contract(Pact)와 단순 JSON snapshot(ApprovalTests) 중 single-team skeleton에 어느 쪽을 택해야 하고 이유는?
|
||||
- Testcontainers를 통합 테스트에 강제하는 이유와, 대신 mock으로 갈 때 잃는 보장은 무엇인가?
|
||||
- 단위/슬라이스/통합/E2E/계약/성능의 6 test level이 각각 무엇을 보장하며, budget 5분을 어떻게 지키는가?
|
||||
- Readiness scorecard에서 binary pass/fail vs maturity score(AWS WAF·CMMI 류) 중 binary를 택하는 상황은 언제인가?
|
||||
- branch-note를 mini-ADR처럼 사용한다는 것은 구체적으로 무엇을 의미하며, ADR과 어떤 부분이 같고 어떤 부분이 다른가?
|
||||
|
||||
## Do Not Overclaim
|
||||
|
||||
- "Pact CDC가 항상 우월하다"고 말하지 말 것. **외부 consumer가 다수일 때만 효익이 비용을 넘는다**. single-team 환경에서는 over-engineering이 되며, JSON snapshot이 더 적합할 수 있다.
|
||||
- "Binary pass/fail이 절대적 기준"이라고 말하지 말 것. **adoption gate(채택 가능 여부) 한정**이다. 운영 중 시스템의 점진적 개선 평가에는 AWS Well-Architected / CIS 형태가 적합하다.
|
||||
- "ArchUnit으로 모든 거버넌스를 강제할 수 있다"고 말하지 말 것. 어노테이션 누락 시 silently pass하는 등 enforcement 한계가 있다.
|
||||
- "Spring REST Docs가 계약을 강제한다"고 말하지 말 것. 문서-구현 일치를 자동화할 뿐, 계약 위반 자체를 막는 강제력은 약하다.
|
||||
- "Markdown SSOT + YAML 파생이 다른 registry보다 우월하다"고 말하지 말 것. **drift 검증 도구를 자체 작성·CI 통합**해야 비로소 신뢰 가능하다.
|
||||
- "Test taxonomy 6 level이면 항상 5분 budget을 지킬 수 있다"고 말하지 말 것. 병렬화·nightly 분리·캐시 전략이 같이 가야 한다.
|
||||
|
||||
## Sources
|
||||
|
||||
### Canonical (내 프로젝트 운영 계약)
|
||||
|
||||
- [[raw/project-notes/ca-skeleton-operational-contract]] — §12 Test Contract, §21 Contract Registry, §27 Readiness Scorecard, §29 Group G-G
|
||||
|
||||
### Registry
|
||||
|
||||
- [[raw/official-docs/registry-adr-official]] — Architecture Decision Records
|
||||
- [[raw/official-docs/schema-protobuf-vs-json-evolution]] — IDL registry / 호환성
|
||||
- [[raw/official-docs/governance-archunit-official]] — code-only enforcement registry
|
||||
- [[raw/official-docs/archunit-annotation-as-registry-evaluation]] — annotation-as-registry 대안 평가 (2026-05-22, ca-tmpl 채택 보류)
|
||||
|
||||
### Verification
|
||||
|
||||
- [[raw/official-docs/verification-pact-cdc-official]] — Consumer-Driven Contract
|
||||
- [[raw/official-docs/verification-spring-cloud-contract-official]] — provider-side contract
|
||||
- [[raw/official-docs/verification-spring-restdocs-official]] — 문서-구현 일치
|
||||
- [[raw/official-docs/verification-approvaltests-snapshot-official]] — JSON snapshot 대안
|
||||
|
||||
### Test taxonomy
|
||||
|
||||
- [[raw/official-docs/test-taxonomy-practical-pyramid-fowler]] — Practical Test Pyramid
|
||||
- [[raw/official-docs/test-taxonomy-testcontainers-official]] — Testcontainers
|
||||
- [[raw/official-docs/dx-testcontainers-java-best-practices]] — Testcontainers Java DX
|
||||
- [[raw/company-tech-blogs/test-pyramid-vs-trophy-kent-dodds]] — Trophy 모델 (회사 블로그 — 공식 기준 아님)
|
||||
|
||||
### Scorecard
|
||||
|
||||
- [[raw/official-docs/scorecard-aws-well-architected]] — Well-Architected Framework
|
||||
- [[raw/official-docs/scorecard-cis-benchmarks-slsa]] — CIS / SLSA
|
||||
- [[raw/official-docs/scorecard-opentelemetry-maturity]] — OTel Maturity Model
|
||||
@@ -0,0 +1,66 @@
|
||||
---
|
||||
title: concept / Spring SmartLifecycle
|
||||
source_type: llm-generated
|
||||
status: reviewed
|
||||
confidence: high
|
||||
tags: [concept, ca-tmpl, runtime, spring-framework, graceful-shutdown]
|
||||
related_projects: [ca-tmpl]
|
||||
last_reviewed: 2026-06-15
|
||||
---
|
||||
|
||||
# concept / Spring SmartLifecycle
|
||||
|
||||
## Summary
|
||||
|
||||
Spring 컨텍스트의 생명 주기(start / stop)에 통합되어, 빈의 시작 및 종료 순서를 결정론적으로(Deterministic) 제어할 수 있게 해주는 인터페이스.
|
||||
- 애플리케이션 종료 시점에 리소스 반납 및 진행 중인 트랜잭션/재시도의 중단을 순서대로 조율하여 우아한 종료(Graceful Shutdown)를 돕는다.
|
||||
|
||||
## Standard (공식 정의)
|
||||
|
||||
Spring Framework 공식 명세에 따른 정의는 다음과 같다.
|
||||
- **SmartLifecycle**: `Lifecycle` 및 `Phased` 인터페이스의 확장판.
|
||||
- **isAutoStartup()**: 컨텍스트 리프레시 시점에 `start()`가 자동으로 실행될지 여부를 결정한다.
|
||||
- **getPhase()**: 생명 주기 상의 실행 단계를 나타낸다.
|
||||
- **시작(Start) 순서**: `getPhase()`가 **작은 순**에서 **큰 순**으로 기동된다.
|
||||
- **종료(Stop) 순서**: `getPhase()`가 **큰 순**에서 **작은 순**(내림차순)으로 정지된다.
|
||||
- 따라서, phase가 `Integer.MAX_VALUE`인 빈은 가장 마지막에 기동되고, **종료 시점에는 가장 먼저** 멈춘다.
|
||||
|
||||
## 한계 / 주의점
|
||||
|
||||
- **ContextClosedEvent 와의 차이**: Spring의 `ContextClosedEvent` 리스너는 애플리케이션 컨텍스트가 닫히기 시작했다는 신호만 전달할 뿐, 빈의 소멸(destroy) 순서와 비결정론적으로 얽혀 있다. 예컨대 어떤 DB 소스 빈이 이미 소멸된 후에 커넥션을 수립하려는 리스너 코드가 호출되면 NPE나 의존성 부재 예외가 터진다.
|
||||
- **SmartLifecycle은 비동기 셧다운을 차단할 수 있다**: `stop(Runnable callback)` 메서드가 호출되면 종료 작업을 수행하고 반드시 callback을 호출해 주어야 한다. 그렇지 않으면 Spring이 설정된 셧다운 타임아웃까지 대기하여 기동 종료 과정이 지연될 수 있다.
|
||||
|
||||
## Project Application
|
||||
|
||||
- [[wiki/explainer/adapter-outbound.md]]
|
||||
- `OutboundHttpShutdownGuard`가 `SmartLifecycle`을 구현하고 `getPhase()`에서 `Integer.MAX_VALUE`를 반환함.
|
||||
- 이로 인해 Spring 컨텍스트가 종료 과정을 개시할 때, 다른 어떤 데이터베이스 빈이나 아웃바운드 의존성 어댑터가 종료되기 전에 **가장 먼저** 셧다운 가드의 `stop()`이 실행되어 `shuttingDown` 플래그를 세우게 됨.
|
||||
- 리트라이 정책(`OutboundRetryPolicy`)은 루프 도중 이 플래그를 관찰하여 즉시 중단(short-circuit)하며, 신규 요청 또한 `OutboundHttpClient` 단에서 즉시 거부(`DEPENDENCY_CIRCUIT_OPEN` 예외)함으로써, 애플리케이션 종료 시 불필요한 HTTP 커넥션 맺기나 타임아웃 예산 낭비를 미연에 방지함.
|
||||
|
||||
## Claim-backed Knowledge
|
||||
|
||||
| Knowledge Point | Supporting Claims | Confidence | Notes |
|
||||
|---|---|---|---|
|
||||
| Spring SmartLifecycle 생명 주기 제어 및 phase 결정 규칙 | `raw/official-docs/spring-smartlifecycle-reference.md` | `high` | Spring Framework 공식 참조 |
|
||||
| Spring Boot Graceful Shutdown 시그널 수신 및 정리 과정 | `raw/official-docs/spring-boot-graceful-shutdown-reference.md` | `high` | Spring Boot Reference Guide |
|
||||
|
||||
## 내가 설명할 수 있어야 하는 것
|
||||
|
||||
- `Lifecycle`과 `SmartLifecycle` 인터페이스의 근본적인 차이는 무엇인가?
|
||||
- 왜 Graceful Shutdown 구현 시 `ContextClosedEvent` 리스너를 사용하는 대신 `SmartLifecycle` phase를 활용하는 것이 안전한가?
|
||||
- `getPhase()` 반환값이 `Integer.MAX_VALUE`일 때, 종료 시점의 제어 순서는 어떻게 보장되는가?
|
||||
|
||||
## Interview Questions
|
||||
|
||||
- Spring Framework에서 애플리케이션이 안전하게 종료(Graceful Shutdown)되도록 빈의 소멸 순서를 조율하는 방법에 대해 설명하고, `SmartLifecycle` 인터페이스의 동작 방식을 설명하십시오.
|
||||
- Kubernetes 환경에서 Pod가 종료 신호(SIGTERM)를 받았을 때 Spring Boot 애플리케이션이 수신 중인 API 및 아웃바운드 재시도 요청을 처리하는 우아한 종료 흐름을 설계해 보십시오.
|
||||
|
||||
## Do Not Overclaim
|
||||
|
||||
- "SmartLifecycle을 적용했기 때문에 종료 과정에서 어떠한 데이터 유실도 물리적으로 발생하지 않는다"고 보장해서는 안 된다. 컨테이너 셧다운 유예 기간(Kubernetes `terminationGracePeriodSeconds`)을 넘어가면 강제 종료(SIGKILL)가 발생하므로, 애플리케이션의 우아한 정리 시간이 유예 기간보다 짧도록 세심히 설정해야만 보장된다.
|
||||
|
||||
## Sources
|
||||
|
||||
- [Spring Framework Reference - SmartLifecycle](https://docs.spring.org/spring-framework/reference/core/beans/factory-nature.html#beans-factory-lifecycle)
|
||||
- [[raw/official-docs/spring-smartlifecycle-reference.md]]
|
||||
- [[raw/official-docs/spring-boot-graceful-shutdown-reference.md]]
|
||||
@@ -0,0 +1,86 @@
|
||||
---
|
||||
title: Streaming Response Patterns (SSE vs WebSocket vs Long-Polling vs Chunked)
|
||||
source_type: llm-generated
|
||||
status: draft
|
||||
confidence: medium
|
||||
tags: [streaming, sse, websocket, http, backend]
|
||||
related_projects: [ca-skeleton]
|
||||
last_reviewed: 2026-06-04
|
||||
---
|
||||
|
||||
# Streaming Response Patterns (SSE vs WebSocket vs Long-Polling vs Chunked)
|
||||
|
||||
> Layer: `wiki/concepts/` — 일반 개념. ca-skeleton 이 이 개념을 *미지원으로 결정하고 ArchUnit 으로 차단한* 사실은 [[wiki/projects/ca-tmpl/streaming-response-support]] 참조.
|
||||
|
||||
## Summary
|
||||
|
||||
HTTP 의 기본 통신 모델은 *request-response*(클라이언트가 묻고 서버가 한 번 답함)다. 이를 넘어 서버가 클라이언트로 데이터를 *지속적으로/능동적으로* 보내려면 별도 메커니즘이 필요하다 — 대표적으로 **SSE**(서버→클라이언트 단방향 push), **WebSocket**(양방향 full-duplex), **long-polling**(요청을 응답 없이 오래 붙잡아 둠), **chunked transfer encoding**(크기 미상 응답을 조각으로 흘려보냄)이 있다. 핵심 구분 축은 *통신 방향(단/양방향)* 과 *통신 모델이 request-response 를 유지하는가, server-push 로 바뀌는가* 다.
|
||||
|
||||
## Standard (공식 정의)
|
||||
|
||||
- **SSE (Server-Sent Events)**: MIME type `text/event-stream`, UTF-8 인코딩 필수. `data:` / `event:` / `id:` / `retry:` 필드를 가진 line-based text protocol. 클라이언트 측 API 는 `EventSource`(브라우저 `Window`/`Worker` context 전용 — 서버는 직접 `text/event-stream` 응답을 구현해야 함). 재연결 시 `Last-Event-ID` 헤더로 마지막 수신 event 를 서버에 전달. (WHATWG HTML §9.2)
|
||||
- **WebSocket**: 단일 TCP 연결 위의 *full-duplex*(양방향) 통신 — 각 side 가 독립적으로 언제든 송신 가능. HTTP Upgrade handshake(`GET` + `Upgrade: websocket` → `101 Switching Protocols`)로 연결을 수립하고, handshake 이후 TCP 는 HTTP 가 아닌 WebSocket 프레임 전송에 쓰인다. HTTP 와의 *유일한* 관계는 handshake 가 HTTP Upgrade 로 해석되는 것뿐인 독립 프로토콜. (IETF RFC 6455 §1.2, §1.7)
|
||||
- **Chunked transfer encoding**: *크기를 알 수 없는* content stream 을 length-delimited buffer 의 연속으로 전송 — 전체 크기 없이 connection 을 유지하며 메시지 완료를 수신자가 알 수 있게 함(`Transfer-Encoding: chunked`, last-chunk = size 0). HTTP/1.1 한정 (HTTP/2 는 DATA frame 으로 별도 framing, `Transfer-Encoding` 자체 금지). (IETF RFC 9112 §7.1)
|
||||
- **Long-polling**: 클라이언트가 요청을 보내고 서버가 *이벤트가 생길 때까지* 응답을 지연시키는 패턴 — RFC 6455 는 WebSocket 의 탄생 배경으로 "HTTP polling/long-polling 은 HTTP 의 남용(abuse)이며 서버가 클라이언트마다 여러 TCP 연결을 유지해야 했다"고 기술한다. (RFC 6455 §1.1)
|
||||
- **Spring MVC(servlet) 매핑**: `request.startAsync()` 로 Servlet/filter 는 exit 하고 response 만 열어 둠. 응답 타입별로 — `StreamingResponseBody`(message conversion 우회, `OutputStream` 직접 write, *파일 다운로드* 용), `ResponseBodyEmitter`(객체 stream emit, 각 객체를 `HttpMessageConverter` 로 직렬화), `SseEmitter`(`ResponseBodyEmitter` 의 subclass, W3C SSE 포맷). (Spring MVC vendor doc)
|
||||
|
||||
## 한계 / 주의점
|
||||
|
||||
- **"streaming" 이라는 단어가 두 개의 다른 것을 가리킨다**: ① *통신 모델 자체* 가 server-push 로 바뀌는 것(SSE/WebSocket) ② request-response 모델을 유지한 채 *응답 body 만 조각 전송* 하는 것(`StreamingResponseBody` / chunked 다운로드). 둘은 운영 부담·계약이 전혀 다르므로 묶어서 다루면 안 된다.
|
||||
- **SSE 는 단방향**: 서버→클라이언트만. 클라이언트→서버 메시지는 별도 일반 HTTP 요청으로. 양방향이 필요하면 WebSocket.
|
||||
- **WebSocket 은 기존 HTTP 인프라와 자동 호환되지 않는다**: HTTP 와 독립 프로토콜이라 reverse proxy(Nginx 등)에 Upgrade 처리 설정이 별도로 필요. envelope/필터/미들웨어 같은 기존 request-response 자산도 그대로 못 씀.
|
||||
- **server-push 는 운영 비용을 키운다**: connection 수 관리, 서버 재시작 시 동시 재연결(thundering herd), 멀티 서버 fan-out, timeout/heartbeat/reconnect, load balancer sticky session 등. 단발 request-response 에는 없던 부담.
|
||||
- **chunked 는 HTTP/1.1 전용**: HTTP/2·HTTP/3 에서 `Transfer-Encoding: chunked` 는 금지(별도 framing). 브라우저의 trailer section 지원도 일반화 보장 안 됨.
|
||||
- **YAGNI 경계**: 실제 server-push use case 가 없으면 스트리밍 도입은 speculative generality — request-response + 비동기 우회(LRO polling, webhook)로 대부분 충분.
|
||||
|
||||
## Project Application
|
||||
|
||||
- [[wiki/projects/ca-tmpl/streaming-response-support]] — ca-skeleton 이 이벤트/server-push 스트리밍을 *미지원으로 결정* 하고 ArchUnit import-ban 3개(`no_sse_emitter` / `no_response_body_emitter` / `no_websocket_handler`)로 강제. `StreamingResponseBody`(다운로드)는 차단 제외.
|
||||
|
||||
## Claim-backed Knowledge
|
||||
|
||||
> 이 개념 문서의 핵심 설명은 raw source claim 으로 뒷받침된다. 공식 standard / vendor doc / 회사 사례를 분리한다.
|
||||
|
||||
| Knowledge Point | Supporting Claims | Confidence | Notes |
|
||||
|---|---|---|---|
|
||||
| SSE 는 `text/event-stream`(UTF-8) line-based protocol, `data:/event:/id:/retry:` 필드 | `raw/official-docs/whatwg-html-server-sent-events.md#WHATWG-SSE-C1`, `#WHATWG-SSE-C2` | `high` | WHATWG HTML (official-standard) |
|
||||
| SSE 재연결은 `Last-Event-ID` 헤더로 마지막 event 전달, `retry:` 로 대기시간 설정 | `#WHATWG-SSE-C3`, `#WHATWG-SSE-C4` | `high` | 서버 활용은 구현 책임 (MAY 수준) |
|
||||
| `EventSource` 는 브라우저 클라이언트 API — 서버는 `text/event-stream` 을 직접 구현 | `#WHATWG-SSE-C5` | `high` | Spring 서버 측에 EventSource 직접 적용 불가 |
|
||||
| WebSocket 은 단일 TCP 위 full-duplex, 양 side 독립 송신 | `raw/official-docs/rfc6455-websocket.md#RFC6455-C1` | `high` | RFC 6455 (official-standard) |
|
||||
| WebSocket 은 HTTP Upgrade handshake(101) 이후 HTTP 와 독립 프로토콜 | `#RFC6455-C3`, `#RFC6455-C5` | `high` | reverse proxy 자동 호환 아님 — 별도 설정 필요 |
|
||||
| WebSocket 탄생 배경 = HTTP polling/long-polling 의 "HTTP 남용" + 클라이언트당 다중 TCP | `#RFC6455-C2` | `high` | "항상 polling 보다 우수" 는 아님 — 희소 업데이트엔 SSE/polling 적합 |
|
||||
| chunked = 크기 미상 stream 을 length-delimited buffer 로, HTTP/1.1 한정 | `raw/official-docs/rfc9112-http-1-1-chunked-transfer.md#RFC9112-CHUNK-C1` | `high` | HTTP/2 에선 `Transfer-Encoding` 금지 |
|
||||
| `SseEmitter` = `ResponseBodyEmitter` subclass, W3C SSE 포맷 / `StreamingResponseBody` = 파일 다운로드용 | `raw/official-docs/spring-mvc-async-streaming.md#SPRING-ASYNC-C4`, `#SPRING-ASYNC-C2`, `#SPRING-ASYNC-C3` | `high` | Spring vendor doc — server-push(SSE) vs 다운로드(StreamingResponseBody) 구분 |
|
||||
| SSE 멀티서버 운영 시 thundering herd(재시작 시 동시 재연결 CPU spike), 해결로 random jitter | `raw/company-tech-blogs/sse-realtime-notification-woowahan.md#WOOWA-SSE-C2`, `#WOOWA-SSE-C3` | `medium` | 우아한형제들 사례 (company-case-study) — 공식 best practice 아님, 규모별 심각도 다름 |
|
||||
|
||||
## 내가 설명할 수 있어야 하는 것
|
||||
|
||||
- SSE / WebSocket / long-polling / chunked 각각의 공식 정의와 통신 방향(단/양방향).
|
||||
- "streaming" 이 *통신 모델 변경(server-push)* 과 *응답 body 청크 전송(다운로드)* 두 개를 가리킨다는 점, 그리고 왜 둘을 구분해야 하는지.
|
||||
- WebSocket 이 왜 기존 HTTP 인프라(envelope, proxy)와 자동 호환되지 않는가.
|
||||
- 언제 스트리밍이 가치 있고(실시간 push, LLM token streaming), 언제 request-response + 비동기 우회(LRO polling, webhook)로 충분한가.
|
||||
- 우아한형제들 SSE/WebSocket 운영 부담 사례를 *일반 법칙처럼* 말하면 안 되는 지점.
|
||||
|
||||
## Interview Questions
|
||||
|
||||
- SSE 와 WebSocket 의 차이는? 어떤 상황에 각각을 고르나?
|
||||
- 서버가 클라이언트에 능동적으로 데이터를 보내야 할 때, 스트리밍 없이 해결하는 방법은? (LRO polling, webhook)
|
||||
- `StreamingResponseBody` 와 `SseEmitter` 는 둘 다 "스트리밍" 인데 무엇이 다른가?
|
||||
- WebSocket 을 도입하면 reverse proxy/load balancer 설정이 왜 달라지나?
|
||||
- 스트리밍을 *도입하지 않기로* 결정한다면, 그 결정을 코드 레벨에서 어떻게 강제할 수 있나?
|
||||
|
||||
## Do Not Overclaim
|
||||
|
||||
- **회사 기술 블로그(우아한형제들) 사례 = 공식 best practice 아님.** thundering herd / jitter / fan-out 은 *그 회사 규모·스택*(WebFlux + Coroutine + Kafka 등) 특화이며 일반 법칙으로 단정 금지.
|
||||
- **"WebSocket 이 polling 보다 항상 우월" → 금지.** RFC 6455 자체가 희소 업데이트엔 다른 선택이 적합할 수 있다고 시사.
|
||||
- **"SseEmitter 가 Last-Event-ID replay 를 자동 지원" → 금지.** 서버 측 event store 를 별도 구현해야 함 (vendor doc 주의).
|
||||
- **개념 문서는 구현 등급을 매기지 않는다.** 실제 구현/검증 여부는 [[wiki/projects/ca-tmpl/streaming-response-support]] 에서 판정.
|
||||
|
||||
## Sources
|
||||
|
||||
- [[raw/official-docs/whatwg-html-server-sent-events]] — WHATWG HTML SSE spec (`text/event-stream`, EventSource, Last-Event-ID, retry). official-standard.
|
||||
- [[raw/official-docs/rfc6455-websocket]] — IETF RFC 6455 WebSocket (full-duplex, HTTP Upgrade handshake, masking). official-standard.
|
||||
- [[raw/official-docs/rfc9112-http-1-1-chunked-transfer]] — HTTP/1.1 chunked transfer encoding (§7.1 framing). official-standard.
|
||||
- [[raw/official-docs/spring-mvc-async-streaming]] — Spring MVC `SseEmitter` / `ResponseBodyEmitter` / `StreamingResponseBody`. official-vendor-doc.
|
||||
- [[raw/company-tech-blogs/sse-realtime-notification-woowahan]] — 우아한형제들 SSE 운영 사례 (thundering herd, jitter, Kafka fan-out). company-case-study — 공식 best practice 아님.
|
||||
- [[raw/company-tech-blogs/realtime-service-experience-woowahan-websocket]] — 우아한형제들 WebSocket 운영 사례 (이벤트 유실, 모바일 네트워크, 클러스터링). company-case-study.
|
||||
@@ -0,0 +1,172 @@
|
||||
---
|
||||
title: Transaction Boundary Abstraction (TransactionPort vs @Transactional)
|
||||
source_type: llm-generated
|
||||
status: draft
|
||||
confidence: medium
|
||||
tags: [transaction, clean-architecture, spring]
|
||||
related_projects: [ca-skeleton]
|
||||
last_reviewed: 2026-05-22
|
||||
---
|
||||
|
||||
# Transaction Boundary Abstraction (TransactionPort vs @Transactional)
|
||||
|
||||
> Layer: `wiki/concepts/` — 일반 개념. 특정 프로젝트의 적용 사실은 `wiki/projects/`로 분리.
|
||||
|
||||
## Summary
|
||||
|
||||
Transaction boundary abstraction은 application layer가 Spring transaction API(`@Transactional`, `PlatformTransactionManager`)를 직접 의존하지 않고, `TransactionPort` 또는 `TransactionalUseCaseRunner` 같은 port abstraction을 통해 트랜잭션 경계를 선언하는 패턴이다. Clean Architecture / Hexagonal에서 "application은 framework를 모른다"는 원칙을 트랜잭션 경계까지 일관되게 적용하기 위한 선택지 중 하나이며, 다수파인 `@Transactional` 직접 부착의 대안으로 testability와 framework lock-in 완화를 노린다.
|
||||
|
||||
## Standard (공식 정의)
|
||||
|
||||
Spring Framework는 트랜잭션 경계 선언을 위해 세 가지 표준 메커니즘을 제공한다.
|
||||
|
||||
- **`PlatformTransactionManager`**: 모든 트랜잭션 추상화의 SPI. JDBC, JPA, JTA 구현체가 존재.
|
||||
- **선언적 트랜잭션 (`@Transactional`)**: AOP proxy 기반. method/class 단위 attribute로 propagation, isolation, timeout, rollbackFor, readOnly 등을 선언.
|
||||
- **프로그래매틱 트랜잭션 (`TransactionTemplate`, `TransactionManager`)**: 명시적 코드로 트랜잭션 범위를 둘러쌈.
|
||||
|
||||
### Propagation 7종 (Spring `Propagation` enum)
|
||||
|
||||
| 값 | 의미 |
|
||||
| --- | --- |
|
||||
| `REQUIRED` (default) | 기존 트랜잭션 참여, 없으면 새로 생성 |
|
||||
| `SUPPORTS` | 있으면 참여, 없으면 non-transactional |
|
||||
| `MANDATORY` | 반드시 존재해야 함, 없으면 예외 |
|
||||
| `REQUIRES_NEW` | 항상 새 물리 트랜잭션 (기존은 suspend) |
|
||||
| `NOT_SUPPORTED` | non-transactional로 실행 (기존은 suspend) |
|
||||
| `NEVER` | 트랜잭션 존재 시 예외 |
|
||||
| `NESTED` | savepoint 기반 nested 트랜잭션 (JDBC 한정, JPA는 일반적으로 미지원) |
|
||||
|
||||
### Isolation 5종 (Spring `Isolation` enum)
|
||||
|
||||
`DEFAULT`, `READ_UNCOMMITTED`, `READ_COMMITTED`, `REPEATABLE_READ`, `SERIALIZABLE`. PostgreSQL은 `READ_COMMITTED`가 default, MySQL InnoDB는 `REPEATABLE_READ`가 default라서 vendor default 묵시 사용은 의미 차이를 만든다.
|
||||
|
||||
출처: [[raw/official-docs/at-transactional-spring-official]], [[raw/official-docs/transaction-template-spring-official]].
|
||||
|
||||
## 한계 / 주의점
|
||||
|
||||
트랜잭션 경계를 어떻게 선언할지에 대한 5가지 대안과 그 한계.
|
||||
|
||||
### 대안 1: `@Transactional` direct (다수파)
|
||||
|
||||
- **장점**: boilerplate 최저, Spring/Hexagonal 표준 다수파, IDE 가시성 좋음.
|
||||
- **한계**:
|
||||
- **AOP self-invocation 문제**: 같은 클래스 내부 메서드 호출은 proxy를 거치지 않아 `@Transactional`이 무시됨. self-injection이나 별도 bean 분리 같은 우회가 필요.
|
||||
- **Testability 낮음**: application use case 단위 테스트에서 트랜잭션 경계를 검증하려면 Spring context 또는 `@DataJpaTest` 등 통합 환경이 필요.
|
||||
- **Framework lock-in**: application package가 `org.springframework.transaction.annotation.Transactional`을 직접 import → Clean Architecture 의존성 규칙 위반 (application은 framework를 모른다).
|
||||
- **선언과 실행 분리**: annotation은 attribute 선언일 뿐 실제 실행은 proxy/interceptor가 담당. 디버깅 시 호출 경로 추적이 간접적.
|
||||
- 출처: [[raw/official-docs/at-transactional-spring-official]], [[raw/company-tech-blogs/hexagonal-reflectoring-transactional-placement]].
|
||||
|
||||
### 대안 2: `TransactionTemplate` programmatic
|
||||
|
||||
- **장점**: 명시적 코드, self-invocation 문제 없음, propagation/isolation을 객체로 다룸.
|
||||
- **한계**:
|
||||
- Boilerplate 증가 — 매 use case마다 `template.execute(status -> { ... })` 작성.
|
||||
- 여전히 `org.springframework.transaction.support.TransactionTemplate`를 application이 직접 import → framework lock-in은 그대로.
|
||||
- 출처: [[raw/official-docs/transaction-template-spring-official]].
|
||||
|
||||
### 대안 3: Functional Resource monad (예: Arrow Kt `Resource`, `transaction { }`)
|
||||
|
||||
- **장점**: testability 최고 (순수 함수 합성으로 검증 가능), 명시적 effect, type-level 보장.
|
||||
- **한계**:
|
||||
- 팀 학습 비용 큼 — Kotlin/함수형 코드 스타일에 익숙하지 않은 팀에선 채택 장벽이 높다.
|
||||
- Java 위주 Spring 팀에선 패턴 매칭 / monad 사용이 자연스럽지 않음.
|
||||
- Spring의 propagation/isolation 기본 의미를 monad 위에 재구현해야 하는 경우 있음.
|
||||
- 출처: [[raw/official-docs/functional-tx-arrow-kt-resource-docs]].
|
||||
|
||||
### 대안 4: Custom `TransactionInterceptor` (AOP)
|
||||
|
||||
- **장점**: 자체 annotation 정의 가능, 커스텀 정책 주입(예: capability 검증과 결합) 가능.
|
||||
- **한계**:
|
||||
- AOP 자체의 self-invocation 문제 동일하게 잔존.
|
||||
- interceptor 구현 자체가 Spring AOP 의존을 가짐.
|
||||
- 표준 `@Transactional` 도구(`@TransactionalEventListener` 등) 호환성 추가 검증 필요.
|
||||
- 출처: [[raw/company-tech-blogs/custom-transaction-interceptor-catnipcoder]].
|
||||
|
||||
### 대안 5: TransactionPort / TransactionalUseCaseRunner abstraction (소수파)
|
||||
|
||||
- **장점**:
|
||||
- Application package가 Spring transaction import 없이 트랜잭션 경계를 선언.
|
||||
- Test에서는 in-memory fake port로 트랜잭션 경계 검증 가능 → use case 단위 테스트가 Spring context 없이 성립.
|
||||
- Framework 교체(예: Spring → Micronaut) 시 application 코드 변경 최소화.
|
||||
- **한계**:
|
||||
- 소수파 — 일반적 hexagonal 사례에서도 `@Transactional`을 application service에 부착하는 경우가 다수.
|
||||
- Port interface 추가, infrastructure 구현체 추가, propagation/isolation을 port 시그니처로 어떻게 표현할지 결정 비용.
|
||||
- Spring 도구(`@TransactionalEventListener`, JPA OSIV, AOP 기반 audit 등)와의 호환을 직접 챙겨야 함.
|
||||
- 단순 CRUD 위주 프로젝트에서는 over-engineering이 될 수 있음.
|
||||
- 출처: [[raw/company-tech-blogs/transaction-port-clean-ddd-spring-medium]], [[raw/company-tech-blogs/transaction-port-vassilis-soum-github-readme]].
|
||||
|
||||
### 공통 주의점
|
||||
|
||||
- **묵시적 vendor default isolation**: `Isolation.DEFAULT`로 두면 PostgreSQL은 `READ_COMMITTED`, MySQL InnoDB는 `REPEATABLE_READ`로 달라진다. multi-vendor 환경에서는 명시 선언이 안전.
|
||||
- **`NESTED`는 JDBC savepoint 기반**: JPA EntityManager는 일반적으로 nested 트랜잭션을 지원하지 않음 (provider 의존).
|
||||
- **`REQUIRES_NEW`는 비싸다**: 기존 트랜잭션을 suspend하고 새 connection을 잡는 비용이 있음. outbox/audit 같은 명시적 케이스에만 사용.
|
||||
|
||||
## Claim-backed Knowledge
|
||||
|
||||
> 이 표는 일반 개념 지식이 어떤 raw 근거로 뒷받침되는지 명시한다. 프로젝트 구현 주장은 여기에 넣지 않는다 (project 문서 참조).
|
||||
|
||||
| Knowledge Point | Supporting Claims | Confidence | Notes |
|
||||
|---|---|---|---|
|
||||
| Spring 은 트랜잭션 경계 선언에 declarative(`@Transactional`) / programmatic(`TransactionTemplate`) / SPI(`PlatformTransactionManager`) 메커니즘을 제공 | [[raw/official-docs/at-transactional-spring-official]], [[raw/official-docs/transaction-template-spring-official]] | high | `official-vendor-doc` (Spring 공식) |
|
||||
| `@Transactional` 은 AOP proxy 기반이라 self-invocation 시 무시될 수 있음 | [[raw/official-docs/at-transactional-spring-official]]#AT-TX-C5 | high | 표준 우회(self-injection 등) 존재 — 치명적 결함 아님 |
|
||||
| Propagation 기본값은 `REQUIRED`, `readOnly` 는 REQUIRED/REQUIRES_NEW 한정 적용 | [[raw/official-docs/spring-tx-management-reference]]#SPRING-TX-MGR-C3, #SPRING-TX-MGR-C6 | high | `official-vendor-doc` |
|
||||
| `REQUIRES_NEW` 는 독립 physical transaction + 새 connection → pool 소모, exhaustion/deadlock 위험 | [[raw/official-docs/spring-tx-propagation-required-new-nested-official]]#SPRING-PROP-C1~C4 | high | `official-vendor-doc` |
|
||||
| closure-based transaction abstraction 은 enterprise OSS 선례 존재(Axon `executeInTransaction`/`fetchInTransaction`) | [[raw/company-tech-blogs/axonframework-transactionmanager-spring-adapter]]#AXON-TX-C1~C3 | medium | `company-case-study` — 공식 best practice 아님 |
|
||||
| 다수파 hexagonal 사례는 오히려 application service 에 `@Transactional` 직접 부착(abstraction 없음) | [[raw/company-tech-blogs/buckpal-archunit-lombok-allowlist-direct-transactional]]#BUCKPAL-TX-C1~C2, [[raw/company-tech-blogs/hexagonal-reflectoring-transactional-placement]]#HEX-REFL-C1 | medium | `engineering-blog`/`company-case-study` — TransactionPort 가 소수파임을 보여주는 contrary evidence |
|
||||
| Spring 공식 incubator(Modulith)는 `@ApplicationModuleListener` 로 `@Transactional(REQUIRES_NEW)` 를 meta-annotation 재노출 | [[raw/company-tech-blogs/spring-modulith-archunit-generated-exemption-and-violations-as-data]]#SPRING-MOD-TX-C1 | medium | abstraction-only forbidden 정책과 반대 방향 |
|
||||
|
||||
## Project Application
|
||||
|
||||
- [[wiki/projects/ca-tmpl/transaction-boundary-abstraction]] — ca-tmpl 의사결정 + 구현 기록 (`TransactionPort` + `SpringTransactionPort` + ArchUnit 강제, 로컬 검증까지 완료). 실제 구현·검증 범위는 project 문서 참조 — 이 개념 문서에는 프로젝트 구현 주장을 넣지 않는다.
|
||||
|
||||
- [[raw/project-notes/ca-skeleton-operational-contract]] (§14 Transaction/Concurrency, §19 Domain Application Readiness, §29 Topic 2)
|
||||
- [[raw/branch-notes/feature-application-port-usecase-contract]] — TransactionPort interface spec, forbidden import 규칙
|
||||
- [[raw/branch-notes/feature-transaction-concurrency-contract]] — isolation default, propagation default, idempotency / lock 분류
|
||||
|
||||
## 내가 설명할 수 있어야 하는 것
|
||||
|
||||
- transaction boundary abstraction 의 공식 정의 — Spring 의 declarative / programmatic / SPI 메커니즘과의 관계.
|
||||
- 어떤 문제를 해결하는가 — application 패키지의 framework lock-in 차단 + use case 단위 테스트의 Spring context 분리(testability).
|
||||
- 어떤 상황에서는 쓰면 안 되는가 — 단순 CRUD 위주 + framework 교체 계획 없음 + Spring 숙련 팀이면 `@Transactional` 직접 부착이 합리적. abstraction 은 over-engineering 이 될 수 있다.
|
||||
- 공식 문서가 말하지 않는 부분 — Spring 공식은 `@Transactional`/`TransactionTemplate` 을 권장하지 abstraction port 를 권장하지 않는다. port 화는 자체 taste.
|
||||
- 회사 기술 블로그 사례를 일반 법칙처럼 말하면 안 되는 지점 — UNIL / Axon / Buckpal / Modulith 는 case-study/engineering-blog 등급. 특히 Buckpal·Modulith 는 오히려 `@Transactional` 직접/meta 부착이라 abstraction-only 가 다수파라고 말하면 안 된다.
|
||||
- 내 프로젝트에서는 어떤 branch decision 으로 연결됐는가 — [[raw/branch-notes/feature-application-port-usecase-contract]] D3(TransactionPort 채택) / D11(callback 시그니처) / D12(`inNew` pool 비용). 구현 사실은 [[wiki/projects/ca-tmpl/transaction-boundary-abstraction]].
|
||||
- 코드/운영에서 검증하려면 — ArchUnit 으로 application 패키지의 `@Transactional` import 차단을 확인, `readOnly` flush-mode 는 Hibernate session statistics 로 측정, `REQUIRES_NEW` 는 connection pool 사용량을 통합 테스트로 확인.
|
||||
|
||||
## Interview Questions
|
||||
|
||||
- 왜 application layer에서 Spring `@Transactional` 직접 import를 금지할 수 있는가? 어떤 trade-off가 있는가?
|
||||
- AOP self-invocation 문제는 무엇이고, TransactionPort abstraction은 이 문제를 어떻게 회피하는가?
|
||||
- `REQUIRES_NEW`와 `NESTED`의 차이는 무엇이며, 왜 `NESTED`는 JPA에서 일반적으로 권장되지 않는가?
|
||||
- Isolation level 4단계(READ_UNCOMMITTED, READ_COMMITTED, REPEATABLE_READ, SERIALIZABLE)와 phantom read / non-repeatable read / dirty read의 관계를 설명할 수 있는가?
|
||||
- TransactionPort 도입의 trade-off를 단순 CRUD 프로젝트와 도메인 복잡도가 큰 프로젝트로 나눠 어떻게 다르게 평가하는가?
|
||||
|
||||
## Do Not Overclaim
|
||||
|
||||
- **"TransactionPort가 무조건 우월하다"고 말하지 않는다.** 단순 CRUD가 대부분이고 framework 교체 계획이 없으며 팀이 Spring에 익숙하다면, `@Transactional` 직접 부착이 boilerplate / 가시성 / 표준 도구 호환성 측면에서 합리적인 선택이다. Hexagonal/Clean Architecture 사례 다수도 application service에 `@Transactional`을 부착한다.
|
||||
- **UNIL 팀 사례를 "ca-tmpl이 영감을 받았다"고 단정하지 않는다.** [[raw/company-tech-blogs/transaction-port-clean-ddd-spring-medium]](UNIL, 2024-05)와 ca-tmpl은 동일한 evolution path(@Transactional → AOP → TransactionPort)를 거친 별개 사례로 다루며, 인용은 "동일한 결론에 도달한 외부 사례" 수준에서만 한다.
|
||||
- **"AOP 기반 transaction은 항상 self-invocation 문제 때문에 깨진다"고 말하지 않는다.** self-injection, public method 분리, 별도 bean 분리 같은 표준 우회가 존재하며, 다수 프로덕션에서 잘 동작한다. self-invocation은 "주의해야 할 함정"이지 "치명적 결함"이 아니다.
|
||||
- **"Functional monad가 testability에서 항상 우월하다"고 말하지 않는다.** test 친화성은 높지만 팀 역량 / 언어 / 기존 코드베이스에 따라 실제 도입 비용이 매우 크다.
|
||||
- 본 문서의 5종 비교는 **외부 source를 기반으로 정리한 trade-off 표**이며, 모든 항목이 자체 측정 결과는 아니다. status `draft` / confidence `medium`로 둔다.
|
||||
|
||||
## Sources
|
||||
|
||||
### 공식 문서
|
||||
|
||||
- [[raw/official-docs/at-transactional-spring-official]] — Spring `@Transactional` 선언적 트랜잭션 공식 정의
|
||||
- [[raw/official-docs/transaction-template-spring-official]] — Spring `TransactionTemplate` 프로그래매틱 API
|
||||
- [[raw/official-docs/functional-tx-arrow-kt-resource-docs]] — Arrow Kt Resource / Functional transaction
|
||||
|
||||
### 사례 / 블로그 (공식 best practice 아님)
|
||||
|
||||
- [[raw/company-tech-blogs/transaction-port-clean-ddd-spring-medium]] — UNIL (2024-05), 동일 진화 경로 사례
|
||||
- [[raw/company-tech-blogs/transaction-port-vassilis-soum-github-readme]] — TransactionPort 참고 구현
|
||||
- [[raw/company-tech-blogs/hexagonal-reflectoring-transactional-placement]] — Hexagonal에서 `@Transactional` 부착 위치 (다수파)
|
||||
- [[raw/company-tech-blogs/custom-transaction-interceptor-catnipcoder]] — Custom TransactionInterceptor (AOP) 사례
|
||||
- [[raw/company-tech-blogs/woowahan-hexagonal-multimodule]] — 보완(대체 아님): hexagonal multi-module 분리
|
||||
|
||||
### 프로젝트 canonical / branch-notes
|
||||
|
||||
- [[raw/project-notes/ca-skeleton-operational-contract]] — §14, §19, §29
|
||||
- [[raw/branch-notes/feature-application-port-usecase-contract]]
|
||||
- [[raw/branch-notes/feature-transaction-concurrency-contract]]
|
||||
@@ -0,0 +1,112 @@
|
||||
---
|
||||
title: Transactional Outbox Pattern (SKIP LOCKED polling vs CDC)
|
||||
source_type: llm-generated
|
||||
status: draft
|
||||
confidence: medium
|
||||
tags: [outbox, event-driven, distributed-systems]
|
||||
related_projects: [ca-skeleton]
|
||||
last_reviewed: 2026-05-22
|
||||
---
|
||||
|
||||
# Transactional Outbox Pattern (SKIP LOCKED polling vs CDC)
|
||||
|
||||
> Layer: `wiki/concepts/` — 일반 개념. 프로젝트 적용 사실은 [[raw/project-notes/ca-skeleton-operational-contract]] 등 project 문서 참조.
|
||||
|
||||
## Summary
|
||||
|
||||
Transactional outbox는 "DB write + 외부 메시지 publish"라는 두 시스템에 걸친 원자성 요구를 **단일 RDB 트랜잭션 + 비동기 publisher**로 우회하는 패턴입니다. 도메인 변경과 같은 트랜잭션에서 `outbox` 테이블에 이벤트 row를 INSERT하고, 별도 publisher가 그 row를 polling(또는 CDC)으로 읽어 broker에 발행함으로써 dual-write 문제(두 시스템 중 하나만 성공)를 제거합니다. polling 구현체에서는 PostgreSQL/MySQL의 `FOR UPDATE SKIP LOCKED`로 다중 publisher 간 row 경합을 해소합니다.
|
||||
|
||||
## Standard (공식 정의)
|
||||
|
||||
- **microservices.io / Chris Richardson**: outbox 패턴의 원형 정의. 서비스가 DB 트랜잭션 내에 `OUTBOX` 테이블에 이벤트를 기록하고, 별도 message relay가 이 테이블을 읽어 broker로 publish. dual-write를 명시적 anti-pattern으로 두고 outbox/event sourcing을 두 정식 대안으로 제시.
|
||||
- **PostgreSQL `FOR UPDATE SKIP LOCKED`**: 9.5+. `SELECT ... FOR UPDATE` 대상 row 중 다른 트랜잭션이 이미 잠근 row를 **차단 없이 skip**. queue 형태의 워크로드(outbox claim, job queue)에 사용 권장. 잠금은 row 단위, 트랜잭션 종료 시 해제.
|
||||
- **MySQL 8.0+ `SKIP LOCKED`**: PostgreSQL과 동일한 의미. 8.0 이전 버전은 미지원 — advisory lock으로 fallback.
|
||||
- **Debezium**: 오픈소스 CDC 플랫폼. DB write-ahead log(Postgres logical replication / MySQL binlog)을 읽어 변경 이벤트를 Kafka 등 broker로 전달. outbox 테이블도 다른 테이블과 동일하게 WAL/binlog로 캡처.
|
||||
- **Kafka Connect Outbox Event Router (Debezium SMT)**: Debezium이 캡처한 outbox row를 Single Message Transform 단계에서 Kafka topic/key/headers로 라우팅. outbox row schema 규약(`aggregatetype`, `aggregateid`, `type`, `payload`)을 요구.
|
||||
- **delivery semantic**: outbox + 비동기 publish는 **at-least-once**가 기본이며 exactly-once가 아님. consumer 측에서 `eventId` 또는 `idempotencyKey` 기반 dedupe가 필수.
|
||||
|
||||
## 한계 / 주의점
|
||||
|
||||
각 구현 옵션별 trade-off.
|
||||
|
||||
### SKIP LOCKED polling
|
||||
|
||||
- publish lag = polling interval + claim transaction + broker publish. 일반적으로 **수 초~수 분** 수준이며 sub-second lag 요구에는 부적합.
|
||||
- outbox 테이블이 단조 증가 → archived/published row cleanup 정책 필수 (TTL 삭제 또는 partition rotation). 누락 시 인덱스 비대 및 vacuum 비용 증가.
|
||||
- 단일 DB가 SSOT여야 함. 멀티 DB에 도메인 write가 분산되면 outbox 1개로 해소 불가.
|
||||
- multi-instance publisher 운영 시 동일 row 중복 claim 방지는 SKIP LOCKED 자체가 보장하지만, publish 후 commit 실패 시 재시도로 인한 중복 publish 가능 → consumer dedupe가 정합성의 일부.
|
||||
|
||||
### Debezium CDC
|
||||
|
||||
- WAL/binlog 기반이므로 publish lag이 polling보다 짧음(밀리초~초 단위).
|
||||
- 단, Kafka Connect 클러스터, connector 설정/스키마, replica slot 관리, snapshot 운영 인력이 추가로 필요. **인프라 비용·운영 학습 비용이 폴링 대비 크게 큼**.
|
||||
- Postgres에서는 logical replication slot이 누적되면 WAL 디스크가 증가하는 운영 risk가 있음(slot lag 모니터링 필수).
|
||||
- 마이그레이션 트리거는 보통 "polling lag SLO 위반" 또는 "DB load가 polling 쿼리로 포화"이며, 그 가정이 깨지지 않으면 도입 정당화 어려움.
|
||||
|
||||
### Kafka Connect Outbox SMT (Debezium event router)
|
||||
|
||||
- payload 변환·라우팅 로직이 connector 설정 + SMT 규약에 묶임. 복잡한 payload 가공이나 multi-topic fan-out은 SMT 표현력의 한계가 있음.
|
||||
- outbox row schema가 Debezium event router 규약에 종속 → 자유로운 컬럼 설계가 어려움.
|
||||
|
||||
### Dual-write (anti-pattern, negative reference)
|
||||
|
||||
- 애플리케이션 코드에서 DB commit과 broker publish를 **순차로 직접 호출**하는 형태. 둘 사이에 프로세스 종료/장애가 끼면 정합성이 깨짐.
|
||||
- outbox 도입의 근거 그 자체이므로, "왜 outbox인가"의 답은 항상 dual-write 실패 시나리오에서 출발.
|
||||
- 외부 publish 없이 in-process consumer만 있는 경우라면 트랜잭션 commit 후 in-process dispatch도 허용 가능 — 하지만 외부 transport가 끼는 순간 outbox가 기본값.
|
||||
|
||||
### Event sourcing
|
||||
|
||||
- 흔히 "outbox 대안"으로 묶이지만 실제로는 **도메인 모델 자체를 이벤트 스트림으로 교체**하는 결정이며, 단순 publish 정합성 문제 해결이 아님.
|
||||
- 도메인 재설계, 스냅샷·재구성 운영, 쿼리 모델(CQRS) 분리 비용 동반. 단지 "이벤트 발행이 필요해서" event sourcing으로 가는 것은 trade-off 오판.
|
||||
|
||||
### Spring `@TransactionalEventListener`
|
||||
|
||||
- `AFTER_COMMIT` phase에서 in-process bean으로 이벤트 dispatch. **JVM 프로세스 내부에서만 동작**.
|
||||
- commit 직후 publish 실패(예: 외부 broker 호출 예외, 프로세스 강제 종료)에 대한 영속 큐가 없음 → **재시작 시 유실**. 외부 broker로 가는 integration event 발행에는 부적합.
|
||||
- 도메인 이벤트의 in-process side effect 트리거 용도로만 안전.
|
||||
|
||||
### Netflix DBLog 류 자체 CDC
|
||||
|
||||
- Debezium보다 더 큰 자체 인프라 투자. 일반 백엔드 팀이 도입할 baseline 아님. 비교 시 "왜 Debezium도 부담이라 polling을 골랐는가"의 대조군으로만 사용.
|
||||
|
||||
## Project Application
|
||||
|
||||
- [[wiki/projects/ca-tmpl/transactional-outbox-pattern]] — ca-tmpl 의사결정 기록 (현재 `documented-only`, Phase C2 미진입). 실제 구현 여부는 project 문서 참조.
|
||||
- [[raw/branch-notes/feature-domain-event-outbox-contract]] — outbox publisher SSOT, row status(`PENDING/IN_FLIGHT/PUBLISHED/FAILED/DEAD`), per-aggregate FIFO, claim transaction(`READ_COMMITTED` + `FOR UPDATE SKIP LOCKED`), at-least-once + consumer dedupe 결정.
|
||||
- [[raw/branch-notes/feature-background-job-async-contract]] — outbox publisher가 consume하는 retry/DLQ vocabulary(exp backoff with jitter, max 3, DLQ exhausted) SSOT.
|
||||
- [[raw/project-notes/ca-skeleton-operational-contract]] (§11 Adapter Failure, §14 Transaction/Concurrency, §29 Topic 3) — outbox 패턴이 어떤 운영 계약 안에서 어떤 위치를 차지하는지의 canonical map.
|
||||
|
||||
## Interview Questions
|
||||
|
||||
- 왜 dual-write는 안 되는가? outbox는 dual-write의 어떤 실패 모드를 어떻게 제거하는가?
|
||||
- SKIP LOCKED polling은 publish lag과 어떤 trade-off를 가지는가? lag을 줄이려면 polling interval만 줄이면 되는가?
|
||||
- Debezium CDC로 마이그레이션을 결정하는 트리거는 무엇인가? (어떤 가정이 깨졌을 때?)
|
||||
- outbox 테이블 cleanup(archived row 삭제/파티셔닝)을 누락하면 어떤 문제가 생기는가?
|
||||
- outbox가 exactly-once를 보장하지 않는 이유와, 그 위에서 consumer가 정합성을 유지하는 메커니즘(idempotency key)을 설명할 수 있는가?
|
||||
|
||||
## Do Not Overclaim
|
||||
|
||||
- "outbox = exactly-once delivery"라고 말하지 않기. 정확한 표현은 **at-least-once delivery + idempotent consumer**.
|
||||
- "Debezium을 곧 도입할 것"이라고 말하지 않기. CDC migration은 polling lag SLO나 DB 부하 가정이 깨질 때만 정당화되며, 현 시점에는 가정이 유지된다고만 말할 것.
|
||||
- "outbox만 있으면 정합성이 보장된다"고 말하지 않기. publisher 측의 retry/DLQ, consumer 측의 dedupe, outbox row cleanup 정책이 함께 있어야 운영 가능.
|
||||
- "SKIP LOCKED가 race condition을 다 막아준다"고 말하지 않기. SKIP LOCKED는 **claim 단계의 row 경합**만 해소하며, publish 후 commit 실패로 인한 재발행은 별개의 문제.
|
||||
- "event sourcing이 outbox의 상위 호환이다"라고 말하지 않기. 둘은 해결하려는 문제의 층위가 다름(전달 정합성 vs 도메인 모델링).
|
||||
- 본인이 polling publisher를 운영해 본 측정값이 없다면 lag 수치를 단정적으로 말하지 않기.
|
||||
|
||||
## Sources
|
||||
|
||||
- [Pattern: Transactional outbox (microservices.io)](https://microservices.io/patterns/data/transactional-outbox.html) — outbox 원형 정의 / Chris Richardson
|
||||
- [PostgreSQL: SELECT — The Locking Clause](https://www.postgresql.org/docs/current/sql-select.html#SQL-FOR-UPDATE-SHARE) — `FOR UPDATE SKIP LOCKED` 의미론
|
||||
- [Debezium documentation — Outbox Event Router](https://debezium.io/documentation/reference/stable/transformations/outbox-event-router.html) — Kafka Connect SMT
|
||||
- [Spring Framework — `@TransactionalEventListener`](https://docs.spring.io/spring-framework/reference/data-access/transaction/event.html) — in-process only 한계
|
||||
- [[raw/official-docs/outbox-skip-locked-microservices-io]] — outbox 원형 raw 발췌
|
||||
- [[raw/official-docs/skip-locked-postgres-docs]] — Postgres SKIP LOCKED 원리
|
||||
- [[raw/official-docs/outbox-debezium-official-docs]] — Debezium 공식 문서
|
||||
- [[raw/official-docs/spring-transactional-event-listener]] — Spring 공식 문서
|
||||
- [[raw/official-docs/event-sourcing-vs-outbox-microservices-io]] — outbox vs event sourcing
|
||||
- [[raw/official-docs/dual-write-antipattern-microservices-io]] — dual-write negative reference
|
||||
- [[raw/company-tech-blogs/outbox-woowahan-techblog-pattern]] — 우아한형제들 polling 사례
|
||||
- [[raw/company-tech-blogs/outbox-wix-engineering-debezium]] — Wix Debezium migration 사례
|
||||
- [[raw/company-tech-blogs/outbox-confluent-kafka-connect-smt]] — Confluent Kafka Connect outbox SMT
|
||||
- [[raw/company-tech-blogs/outbox-netflix-domain-events-cdc]] — Netflix DBLog 자체 CDC
|
||||
- [[raw/project-notes/ca-skeleton-operational-contract]] — §11 / §14 / §29 Topic 3 canonical map
|
||||
@@ -0,0 +1,18 @@
|
||||
---
|
||||
title: (강사 설명) adapter-identifier 모듈
|
||||
source_type: explainer
|
||||
status: raw
|
||||
confidence: unknown
|
||||
tags: [explainer, ca-tmpl, resource-identifier]
|
||||
related_projects: [ca-tmpl]
|
||||
last_reviewed:
|
||||
---
|
||||
|
||||
# (강사 설명) adapter-identifier 모듈
|
||||
|
||||
> Layer: `wiki/explainer/` — **derived(파생) 교육 문서.** 개인 이해용이며 외부 공개 대상이 아니다.
|
||||
> 사실·근거·검증 등급은 여기서 만들지 않고 canonical 에서 가져온다.
|
||||
|
||||
**아직 작성되지 않은 스텁입니다.** `[[wiki/explainer/adapter-outbound]]` 와 같은 ca-tmpl 모듈별
|
||||
설명 시리즈의 자리만 잡아둔 상태이며, 본문은 canonical(`[[wiki/projects/ca-tmpl/resource-identifier-format]]`)을
|
||||
경유해 작성해야 합니다.
|
||||
@@ -0,0 +1,923 @@
|
||||
---
|
||||
title: (강사 설명) adapter-outbound 모듈의 아웃바운드 연동 및 리질리언스 설계 구조
|
||||
source_type: explainer
|
||||
status: reviewed
|
||||
confidence: high
|
||||
tags: [explainer, ca-tmpl, architecture, spring-boot, integration]
|
||||
related_projects: [ca-tmpl]
|
||||
last_reviewed: 2026-06-15
|
||||
---
|
||||
|
||||
# (강사 설명) adapter-outbound — Outbound HTTP 클라이언트 완전 정복
|
||||
|
||||
> Layer: `wiki/explainer/` — **derived(파생) 교육 문서.** "나의 진짜 이해" 를 위한 1타강사 칠판이다.
|
||||
> 정확한 사실·근거·검증 등급은 여기서 만들지 않는다. 전부 canonical 에서 가져온다:
|
||||
> - 개념·대안·근거: [[wiki/concepts/fail-open-fail-closed.md]], [[wiki/concepts/idempotency.md]], [[wiki/concepts/circuit-breaker.md]], [[wiki/concepts/outbox-pattern.md]], [[wiki/concepts/distributed-tracing-baggage.md]], [[wiki/concepts/spring-smart-lifecycle.md]]
|
||||
> - 내 프로젝트 실제 구현·검증 범위: [[wiki/projects/ca-tmpl/data-layer-persistence-cache-outbound.md]] (§Outbound HTTP Client — 코드 사실 SSOT, `locally-verified`), [[wiki/projects/ca-tmpl/config-and-adapter-templates.md]] (adapter on/off 게이팅)
|
||||
>
|
||||
> 이 문서의 **비유는 의도적으로 부정확**하다 (이해를 위한 단순화). 비유를 사실로 인용하지 마라. 면접에서 말할 땐 canonical 의 표현을 써라.
|
||||
>
|
||||
> 📁 코드 경로 기준(본문 캡션은 파일명만 표기): `ca-tmpl/src/adapter-outbound/src/main/java/dev/caskeleton/adapter/outbound/httpclient/`
|
||||
> 📌 본문의 `D5`·`D8`·`I5`·`B7` 같은 코드는 ca-tmpl 의 **설계 결정/규칙 번호**다. 흐름 이해엔 무시해도 된다(추적용 꼬리표).
|
||||
|
||||
---
|
||||
|
||||
## §0. 학습 계약 — 시작 전에 꼭 읽기
|
||||
|
||||
🟢 **[신입 필수]** — 이 섹션은 먼저 읽는다.
|
||||
|
||||
이 수업은 **하나의 클래스(`OutboundHttpClient`)가 외부 API 호출의 위험을 어떻게 가두는가**를 *코드 레벨*로 가르친다. 다 읽으면 면접에서 이 주제로 "깊이 있게" 답할 수 있는 게 목표다.
|
||||
|
||||
### 이 수업을 마치면 — 수료 역량 (이 질문들에 이 깊이로 답하게 된다)
|
||||
|
||||
| # | 질문 | 답에 *반드시* 들어가야 할 키워드 |
|
||||
|---|---|---|
|
||||
| E1 | 외부 API 를 그냥 `RestClient` 한 줄로 부르면 뭐가 문제인가? | 타임아웃 부재→스레드 고갈 / 무지성 재시도→이중결제 / 무한버퍼→OOM / 종료 중 호출 (4개 중 3개 + 인과) |
|
||||
| E2 | POST 는 왜 재시도 안 하나? | 비멱등 → 중복 부작용. RFC 9110 멱등 메서드(GET/HEAD/PUT/DELETE)만. Idempotency-Key 미보장 |
|
||||
| E3 | 서킷 브레이커 상태와 전이를 설명하라 | CLOSED/OPEN/HALF_OPEN + 각 전이 트리거 + 설정값(임계 50%·대기 60s·시험 10건) |
|
||||
| E4 | "서킷 쓰면 가용성 올라가?" (함정) | **틀림** → OPEN 동안 정상 요청도 거부(가용성 일시 0). 목적 = 내 스레드·업스트림 보호 |
|
||||
| E5 | 외부가 5xx + 본문에 토큰을 줬다. 클라이언트는 뭘 받나? | `{"error":{"code":"DEPENDENCY_5XX_SERVER"}}`만. 진단메시지=status+클래스명, body 비유출(2중 방어) |
|
||||
| E6 | CB 와 Retry 의 감싸는 순서가 왜 중요한가? | CB 바깥 → 재시도 전체가 CB 에 **1건**으로 집계(트레이드오프 설명) |
|
||||
| E7 | 100MB 응답은 어떻게 받나? | `exchange()`(buffered, 10MB 초과 예외) 대신 `stream()`(raw stream, 재시도 없음 — 스트림은 되감기 불가) |
|
||||
| E8 | 배포 종료 중 새 호출이 오면? | `SmartLifecycle` phase=MAX_VALUE → 가드가 먼저 stop → 플래그 → `exchange()` Step1 fail-fast |
|
||||
|
||||
### 시작 전 알아야 할 것 — 선행 지식 (self-check 통과하면 OK)
|
||||
|
||||
| 알아야 할 것 | self-check (한 줄로 답되면 통과) | 모르면 |
|
||||
|---|---|---|
|
||||
| HTTP 메서드·상태코드 | "GET·POST 의 부작용 차이? 404 와 503 중 '내 잘못'은?" | MDN HTTP |
|
||||
| 스레드 / 스레드 풀 | "요청 1개가 스레드 1개를 점유한다는 게 무슨 뜻?" | (§1 #1 에서 직관 보충) |
|
||||
| 자바 제네릭 `<T>` / `Class<T>` | "`get(uri, User.class)` 가 어떻게 `User` 를 돌려주나?" | Oracle Generics |
|
||||
| 자바 람다 / `Supplier<T>` | "`() -> x` 는 *언제* 실행되나(즉시? 나중?)" | Oracle Lambda |
|
||||
| 예외 / cause chain | "`new RuntimeException(e)` 에서 `e` 는 어디로?" | Throwable.getCause() |
|
||||
| Spring Bean / `@Bean` / DI | "'빈을 등록한다'가 무슨 뜻?" | Spring IoC Container |
|
||||
|
||||
> 람다·제네릭이 약하면 §5 의 "코드 읽기 전 5단어" 박스를 먼저 봐라.
|
||||
|
||||
### 난이도 레인 & 최소 완주 경로
|
||||
|
||||
각 섹션 제목에 라벨이 있다: **[신입 필수]** / **[심화]** / **[참조]**.
|
||||
- **신입은 §1~§11(필수)까지만 읽어도** E1~E5·E7·E8 을 답할 수 있다.
|
||||
- **[심화]**(§12~§14)는 E6 + 면접 압박 질문(라이브러리 내부)을 위한 것. 1회독 후 와도 된다.
|
||||
- **[참조]**(§15~§16)는 학습용이 아니라 *복습/치트시트*다.
|
||||
|
||||
### 이 수업을 관통하는 한 줄기 🧵
|
||||
|
||||
처음부터 끝까지 **"결제 호출 1건(`POST /v1/payments`, 주문 `ord-1001`)의 생애"** 를 따라간다. 이 한 건이 정상일 때 어떻게 흐르고, 각 안전장치를 만날 때 어떻게 갈리는지를 *순서대로* 본다. (결제 도메인은 이해를 위한 **가상 예시** — ca-tmpl skeleton 엔 결제 코드가 없다.)
|
||||
|
||||
---
|
||||
|
||||
## §1. 한 장면 — 5초 만에 고통 느끼기
|
||||
|
||||
🟢 **[신입 필수]**
|
||||
|
||||
외부 결제사에 `POST /payments` 를 보내다 네트워크가 순간 튀었다. 개발자가 재시도를 걸었다 → **고객에게 이중 결제**가 청구돼 민원 폭탄.
|
||||
또는 Redis 캐시가 죽자 그 여파로 홈 화면 API 전체가 500 으로 마비.
|
||||
또는 배포 종료(SIGTERM) 신호가 왔는데 진행 중 재시도가 커넥션을 안 놓고 버티다 강제 종료(SIGKILL), 데이터가 반쯤 처리된 채 꼬임.
|
||||
또는 외부에서 수 GB 응답을 무작정 버퍼에 담다 JVM 힙이 가득 차 **OOM** 사망.
|
||||
|
||||
> 💡 **왜 타임아웃이 "생사 문제"인가(스레드 풀 보충):** 톰캣 같은 서버는 요청 하나당 스레드 하나를 배정한다. 스레드 수는 유한(풀, 예: 200개). 외부가 응답을 안 주는데 타임아웃이 없으면 그 스레드는 *영원히* 그 요청에 묶인다. 이런 요청이 200개 쌓이면 *새 요청을 받을 스레드가 없어* 서버 전체가 멈춘다. 이게 "스레드 고갈"이다.
|
||||
|
||||
**그래서 진짜 고민 한 줄: 외부 인프라·네트워크 장애로부터 우리 시스템 리소스를 어떻게 격리하고, 사이드 이펙트 없이 우아하게 방어할 것인가?**
|
||||
|
||||
---
|
||||
|
||||
## §2. 단 하나의 축
|
||||
|
||||
🟢 **[신입 필수] — 이 주제의 축: 정합성(Consistency) ↔ 가용성(Availability)**
|
||||
|
||||
아웃바운드 설계의 모든 결정은 **데이터 정합성(Consistency) ↔ 시스템 가용성(Availability)** 이라는 하나의 축 위에서 갈린다.
|
||||
|
||||
```text
|
||||
[안전제일 / Fail-Closed (정합성 최우선)] ◄──────────────────────► [가용성 / Fail-Open (가용성 최우선)]
|
||||
- Outbox Relay (Kafka) 연동 - 캐시 스토어 (Redis) 연동
|
||||
- 비멱등(POST/PATCH) HTTP 재시도 차단 - 멱등(GET/PUT/DELETE) HTTP 재시도 허용
|
||||
- 셧다운 가드 (즉시 신규 요청 거절) - 직접 알림 발행 (Slack/Email)
|
||||
```
|
||||
|
||||
어떤 의존성은 장애 시 즉각 멈춰야 정합성을 지키고(Fail-Closed), 어떤 의존성은 장애를 삼키고 우회해야 가용성을 지킨다(Fail-Open). HTTP 클라이언트는 이 축 위에서 "**장애를 분류해 예외로 전달**"하는 중간 전략을 쓴다 — 무엇을 재시도/차단할지 메서드와 예외 종류로 가른다.
|
||||
|
||||
---
|
||||
|
||||
## §3. 큰 그림
|
||||
|
||||
🟢 **[신입 필수] — 관통 줄기: 결제 호출 1건(`ord-1001`)의 정상 항해**
|
||||
|
||||
### 레이어 경계 — Port & Adapter
|
||||
|
||||
> **새 용어 — Port & Adapter(육각형/클린 아키텍처):** Port = application(비즈니스 로직)이 "이런 기능이 필요해"라고 선언한 **인터페이스(구멍)**. Adapter = 그 구멍을 실제 기술(HTTP/Redis/Kafka)로 **메우는 구현체**. 비즈니스 로직이 "외부가 HTTP 인지 Redis 인지" 몰라도 되게 분리하는 게 목적. 의존성 화살표는 항상 바깥(adapter)에서 안쪽(core)을 향한다.
|
||||
|
||||

|
||||
|
||||
`OutboundHttpClient` 는 그 그림에서 **HTTP adapter 가 외부 세계로 나가는 출구**다. 사용자의 "결제하기" 클릭 한 번이 이렇게 흐른다:
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
autonumber
|
||||
participant Web as 🌫️ web (미지의 영역)<br>Controller
|
||||
participant App as 🌫️ application (미지의 영역)<br>PayUseCase
|
||||
participant Port as PaymentPort<br>(인터페이스)
|
||||
participant Adapter as PaymentHttpAdapter<br>(outbound adapter)
|
||||
participant Client as OutboundHttpClient
|
||||
participant Ext as 외부 결제사 서버
|
||||
|
||||
Web->>App: PaymentCommand(orderId, amount, currency)
|
||||
App->>Port: pay(command)
|
||||
Note over Port,Adapter: Port 는 application 이 정의한 "구멍",<br>Adapter 가 그 구멍을 HTTP 로 메운다
|
||||
Adapter->>Client: exchange(POST, "/v1/payments", reqObj, PaymentResult.class)
|
||||
Client->>Ext: POST /v1/payments (객체 → JSON 직렬화)
|
||||
Ext-->>Client: 200 OK (JSON 본문)
|
||||
Client-->>Adapter: PaymentResult (JSON → 객체 역직렬화)
|
||||
Adapter-->>App: 도메인 결과
|
||||
```
|
||||
|
||||
### 경계를 넘는 실제 값 (JSON 입·출력)
|
||||
|
||||
> **새 용어 — 직렬화/역직렬화:** 자바 객체 ↔ JSON 문자열 변환. 나갈 때 객체→JSON(직렬화), 들어올 때 JSON→객체(역직렬화). 내가 짜지 않고 RestClient 가 한다(자세히는 §13).
|
||||
|
||||
1. **application → adapter (자바 객체):** application 은 외부가 HTTP 인지 모른다. 객체만 넘긴다.
|
||||
`PaymentCommand(orderId="ord-1001", amount=15000, currency="KRW")`
|
||||
2. **adapter → `exchange()` 호출:**
|
||||
```java
|
||||
PaymentResult result = paymentClient.exchange(
|
||||
HttpMethod.POST, "/v1/payments",
|
||||
new PaymentRequest("ord-1001", 15000, "KRW"), // ← requestBody (자바 객체)
|
||||
PaymentResult.class); // ← 응답을 이 타입으로 받겠다
|
||||
```
|
||||
3. **`exchange()` 가 실제로 내보내는 HTTP (객체 → JSON):**
|
||||
```http
|
||||
POST /v1/payments HTTP/1.1
|
||||
Host: payment
|
||||
traceparent: 00-4bf9...-00f0...-00 ← 인터셉터가 자동 주입 (§11)
|
||||
Content-Type: application/json
|
||||
|
||||
{"orderId":"ord-1001","amount":15000,"currency":"KRW"}
|
||||
```
|
||||
4. **외부 성공 응답(JSON):** `{"paymentId":"pay_abc","status":"APPROVED","approvedAt":"2026-06-15T09:00:00Z"}`
|
||||
5. **반환값:** RestClient 가 JSON 을 `PaymentResult` 로 역직렬화 → adapter 는 `PaymentResult(paymentId="pay_abc", status=APPROVED, …)` 객체를 받음.
|
||||
6. **실패(500)면?** `exchange()` 는 `DependencyFailureException`(코드 `DEPENDENCY_5XX_SERVER`)를 **던진다** → §10 에서 추적.
|
||||
|
||||
➡️ 한 줄 요약: `exchange()` 입력 = **(메서드 + 경로 + 요청객체 + 응답타입)**, 출력 = **역직렬화된 응답객체** 또는 **던져진 `DependencyFailureException`**.
|
||||
|
||||
---
|
||||
|
||||
## §4. 클래스의 모양 — 공개 메서드 4개 [신입 필수]
|
||||
|
||||
> **새 용어 — 정적 팩토리(static factory):** `new` 대신 `static` 메서드로 객체를 만드는 방식. 여기선 아키텍처 규칙(ArchUnit "B7": 어댑터 타입을 반환하는 *일반* 메서드 금지)을 `static` 으로 우회하는 합법 통로(seam). · **제네릭 `<T>`:** "호출자가 정한 타입". `get(uri, PaymentResult.class)` 면 `T=PaymentResult`.
|
||||
|
||||
진입 클래스 `OutboundHttpClient` 는 **외부 의존성 1개당 인스턴스 1개**(결제용 1개, 재고용 1개 …). 공개 메서드 4개:
|
||||
|
||||
| 부르는 법 | 코드 위치 | 넣는 것 | 나오는 것 |
|
||||
|---|---|---|---|
|
||||
| `baseline(name, baseUrl, …협력자 8개)` | `OutboundHttpClient.java:140` | 의존성 이름 + 협력 빈 | 그 의존성 전용 클라이언트 |
|
||||
| `get(uri, Class<T>)` | `:166` | URI + 응답 타입 | 역직렬화된 `T` |
|
||||
| `exchange(method, uri, body, Class<T>)` | `:184` | 메서드 + URI + 요청 바디 + 응답 타입 | 역직렬화된 `T` |
|
||||
| `stream(method, uri, reader)` | `:281` | 메서드 + URI + 스트림 리더(함수) | 리더가 만든 `T` (대용량 전용, 재시도 X) |
|
||||
|
||||
```java
|
||||
// 📄 OutboundHttpClient.java:166-168 — get 은 exchange 의 GET 단축
|
||||
public <T> T get(String uri, Class<T> responseType) {
|
||||
return exchange(HttpMethod.GET, uri, null, responseType);
|
||||
}
|
||||
```
|
||||
|
||||
<details><summary>✅ 이해 점검 (펼쳐서 스스로 답해보기)</summary>
|
||||
|
||||
1. `exchange()` 와 `stream()` 의 *출력 형태* 차이는? (정답: exchange=역직렬화된 객체 / stream=리더 함수가 만든 값. stream 은 재시도 없음)
|
||||
2. `baseline(...)` 이 `static` 인 이유 한 줄? (ArchUnit B7 우회 seam)
|
||||
</details>
|
||||
|
||||
---
|
||||
|
||||
## §5. `exchange()` 한 줄씩 — 정상 골격 [신입 필수]
|
||||
|
||||
> 🔑 **이 코드 읽기 전 5단어** (이거만 알면 아래가 읽힌다):
|
||||
> - **supplier** = "값을 주는 함수"(`Supplier<T>`). *호출(`.get()`)해야* 실제로 실행된다(준비 ≠ 실행).
|
||||
> - **람다 `() -> {...}`** = 이름 없는 함수 한 덩어리. `() ->` 는 "인자 없이 {…} 를 실행".
|
||||
> - **`<T>`** = 호출자가 받고 싶은 응답 타입(예: `PaymentResult`).
|
||||
> - **ThreadLocal** = "스레드 전용 변수칸"(다른 스레드와 안 섞임).
|
||||
> - **데코레이션(decorate)** = 함수를 *한 겹 감싸* 새 능력(재시도·차단)을 입히는 것.
|
||||
|
||||
```java
|
||||
// 📄 OutboundHttpClient.java:184-258 — exchange() (주석 축약)
|
||||
public <T> T exchange(HttpMethod method, String uri, Object requestBody, Class<T> responseType) {
|
||||
// ── Step 1. 셧다운 fail-fast: 종료 중이면 네트워크를 맺지도 않고 즉시 거부 (§6)
|
||||
if (shutdownGuard.isShuttingDown()) {
|
||||
DependencyFailureException rejected = new DependencyFailureException(
|
||||
OperationalError.DEPENDENCY_CIRCUIT_OPEN, // ← 나가는 예외 "값"
|
||||
dependencyName,
|
||||
"shutdown in progress — outbound call rejected fail-fast (D8)", null);
|
||||
logger.logFailure(dependencyName, "REJECTED", 0L, 0, rejected);
|
||||
throw rejected; // ← 여기서 나간다
|
||||
}
|
||||
|
||||
// ── Step 2. 마감시한 산정 + 스레드에 적재 (재시도 루프가 이 시각을 본다) (§7)
|
||||
Instant deadline = Instant.now().plus(settings.globalCallTimeout());
|
||||
retryPolicy.beginCall(method, deadline);
|
||||
|
||||
int[] attemptCount = {0}; // 시도 횟수(람다가 고치려고 1칸 배열 — §14)
|
||||
long startNs = System.nanoTime();
|
||||
try {
|
||||
// ── Step 3. 실제 호출(buffered)을 supplier 로 "준비"만 한다 (아직 실행 X)
|
||||
Supplier<T> supplier = buildSupplier(method, uri, requestBody, responseType);
|
||||
|
||||
// ── Step 4. CB·Retry 로 감싼다 (감싸는 순서의 의미는 §12 [심화])
|
||||
Optional<CircuitBreaker> cb = resilience.circuitBreakerFor(dependencyName);
|
||||
Optional<Retry> retry = resilience.retryFor(dependencyName);
|
||||
Supplier<T> countingSupplier = () -> { attemptCount[0]++; return supplier.get(); };
|
||||
Supplier<T> decorated = countingSupplier;
|
||||
if (retry.isPresent()) decorated = Retry.decorateSupplier(retry.get(), decorated);
|
||||
if (cb.isPresent()) decorated = CircuitBreaker.decorateSupplier(cb.get(), decorated);
|
||||
|
||||
T result = decorated.get(); // ← 여기서 비로소 실제 네트워크 호출이 일어난다
|
||||
|
||||
// ── Step 5. 성공 로그 (소요시간 + 재시도 횟수)
|
||||
long durationMs = (System.nanoTime() - startNs) / 1_000_000;
|
||||
logger.logSuccess(dependencyName, durationMs, Math.max(0, attemptCount[0] - 1));
|
||||
return result;
|
||||
|
||||
} catch (OutboundResponseSizeExceededException sizeEx) {
|
||||
throw sizeEx; // ── Step 6a. 응답 과대 = "API 오용" → 분류 없이 그대로 (§9)
|
||||
|
||||
} catch (Throwable t) { // Throwable = 자바 모든 예외의 최상위 = 사실상 전부
|
||||
// ── Step 6b. 그 외 모든 실패 → 하나의 DependencyFailureException 으로 "번역" (§10)
|
||||
long durationMs = (System.nanoTime() - startNs) / 1_000_000;
|
||||
DependencyFailureException dfe = errorMapper.classify(dependencyName, t);
|
||||
logger.logFailure(dependencyName, outcomeFor(dfe), durationMs,
|
||||
Math.max(0, attemptCount[0] - 1), dfe);
|
||||
throw dfe; // ← 호출자는 항상 이 분류된 예외만 본다
|
||||
|
||||
} finally {
|
||||
retryPolicy.endCall(); // 성공·예외 무관 *반드시* 실행 → ThreadLocal 정리(누수 방지 §14)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
골격 5줄 요약: ① 종료 중이면 즉시 거부 → ② 마감시한 적재 → ③ 호출을 *준비* → ④ 감싸서 `decorated.get()` 으로 *실행* → ⑤/⑥ 성공 로그 또는 예외 번역. **`supplier` 는 레시피일 뿐, `.get()` 을 불러야 요리된다**(지연 실행). 각 안전장치의 *내부*는 §6~§11 에서 하나씩 연다.
|
||||
|
||||
<details><summary>✅ 이해 점검</summary>
|
||||
|
||||
1. *네트워크가 실제로 일어나는* 코드 한 줄은? (정답: `decorated.get()`)
|
||||
2. `finally` 의 `endCall()` 을 빼면? (ThreadLocal 누수 → 풀 스레드 재사용 시 이전 요청 컨텍스트 오염 — §14)
|
||||
3. 응답 과대(Step 6a)만 분류 없이 그대로 던지는 이유? (업스트림 장애가 아니라 "버퍼 API 오용")
|
||||
</details>
|
||||
|
||||
---
|
||||
|
||||
## §5.5. 안전장치 ⓪ 타임아웃 3종 — connect·read·global [신입 필수]
|
||||
|
||||
> **새 용어:** **connect timeout** = TCP 연결(핸드셰이크) 맺기까지의 제한. **read timeout** = 연결 후 *한 번의* 응답 바이트를 기다리는 제한. **global-call timeout** = 재시도까지 포함한 *전체* 마감(= §7 의 deadline 예산).
|
||||
|
||||
타임아웃은 가장 기본 안전장치다 — 셧다운·재시도·서킷보다 먼저, **모든 호출에 무조건** 적용된다. 하나라도 빠지면 §1 #1 의 "무한 대기 → 스레드 고갈"이 그 구멍으로 샌다. 세 개가 *서로 다른 단계*를 끊는다:
|
||||
|
||||
```text
|
||||
[연결 시도] ──connect timeout(예 2s)──▶ [연결됨] ──read timeout(예 5s)──▶ [응답 한 번 도착]
|
||||
└──────────────── global-call timeout(예 10s): 재시도 다 합쳐 여기까지 ────────────────┘
|
||||
```
|
||||
|
||||
설정/배선 (생성자에서):
|
||||
|
||||
```java
|
||||
// 📄 OutboundHttpClient.java:95-99 — 타임아웃 2원화
|
||||
HttpClient httpClient = HttpClient.newBuilder()
|
||||
.connectTimeout(settings.connectTimeout()).build(); // ← connect 는 JDK HttpClient 가
|
||||
JdkClientHttpRequestFactory requestFactory = new JdkClientHttpRequestFactory(httpClient);
|
||||
requestFactory.setReadTimeout(settings.readTimeout()); // ← read 는 factory 가
|
||||
// global 은 타임아웃 객체가 아니라 exchange() 의 deadline 예산으로 강제 (§7)
|
||||
```
|
||||
|
||||
> 🤔 **왜 connect 와 read 가 다른 객체에?** JDK `HttpClient.Builder` 엔 connectTimeout API 만 있고 *per-request read timeout 이 없다*. 그래서 Spring 의 `JdkClientHttpRequestFactory.setReadTimeout` 이 그 공백을 메운다(라이브러리 API 한계). global 은 라이브러리가 안 주니 우리가 deadline 으로 직접 만든다.
|
||||
|
||||
#### 타임아웃 설정값과 역할 (`app.outbound.http.*`)
|
||||
|
||||
| 설정 | 역할 (무엇을 끊나) | 기본 | 없거나 0/음수면 |
|
||||
|---|---|---|---|
|
||||
| `connect-timeout` | TCP 연결(핸드셰이크)까지 | **필수(기본 없음)** | 죽은/방화벽 막힌 호스트에 무한 대기 |
|
||||
| `read-timeout` | 연결 후 응답 한 번까지 | **필수** | 응답을 질질 끄는 서버에 스레드 묶임 |
|
||||
| `global-call-timeout` | 재시도 포함 전체 마감(=deadline) | **필수** | 재시도 루프가 끝없이 늘어짐 |
|
||||
|
||||
셋 다 **필수 입력**이라, 하나라도 비거나 잘못되면 §13① 의 `@ConfigurationProperties` 검증이 `IllegalArgumentException` 으로 **앱 기동을 막는다** — 무한 대기 구멍을 *기동 시점에* 봉쇄한다.
|
||||
|
||||
> 🧑🏫 **한마디:** connect/read 는 *한 단계*를, global 은 *전체*를 끊는다. 보통 connect ≤ read ≤ global 로 잡아 어느 단계에서 멈춰도 새는 곳이 없게 한다. 단 §7 에서 봤듯 global(deadline)은 *진행 중 read 를 강제로 못 끊어* 하드컷이 아니다 — 진행 중 호출의 상한은 결국 read timeout 이 책임진다.
|
||||
|
||||
<details><summary>✅ 이해 점검</summary>
|
||||
|
||||
1. 상대가 TCP 연결은 받아주는데 응답 바이트를 영영 안 주면, 어느 타임아웃이 끊나? (read)
|
||||
2. connect/read 가 왜 두 객체(JDK HttpClient / factory)에 나뉘나? (JDK 에 per-request read API 부재)
|
||||
</details>
|
||||
|
||||
---
|
||||
|
||||
## §6. 안전장치 ① 셧다운 fail-fast — `SmartLifecycle` [신입 필수]
|
||||
|
||||
배포로 서버가 종료 중일 때 새 외부 호출이 들어오면, 반쯤 죽은 빈을 건드려 NPE·자원 누수가 난다. 그래서 **종료가 시작되면 가장 먼저 깃발을 올려** 신규 호출을 즉시 끊는다.
|
||||
|
||||
```java
|
||||
// 📄 OutboundHttpShutdownGuard.java:41-77 (발췌) — SmartLifecycle 구현
|
||||
@Override public void stop() { shuttingDown.set(true); running.set(false); } // 종료 시 호출됨
|
||||
@Override public int getPhase(){ return Integer.MAX_VALUE; } // ← phase 최대 = 내림차순에서 1순위로 stop
|
||||
public boolean isShuttingDown() { return shuttingDown.get(); } // exchange Step1 / shouldRetry 가 조회
|
||||
```
|
||||
|
||||
**어떻게 동작하나:** Spring 컨테이너는 종료 시 `SmartLifecycle` 빈들의 `stop()` 을 **phase 큰 것부터(내림차순)** 호출한다. phase 를 `Integer.MAX_VALUE` 로 둬서 이 가드의 `stop()` 이 *맨 먼저* 불리고 `shuttingDown` 깃발이 켜진다 → 외부 호출하는 다른 빈이 아직 살아있을 때 이미 신규 호출을 막는다.
|
||||
|
||||
> 🤔 **왜 `ContextClosedEvent` 가 아니라 `SmartLifecycle`?** (자가점검 단골) `ContextClosedEvent` 리스너는 *컨테이너가 이미 닫히기 시작한 뒤* + 리스너 간 순서 보장 없이 불린다 → 그 사이 다른 빈이 먼저 죽어버릴 수 있다. `SmartLifecycle` 의 phase 순서는 *결정론적*이라 "내가 1순위"를 보장한다.
|
||||
|
||||
이 깃발은 두 곳이 읽는다: `exchange()` Step 1(신규 호출 즉시 `DEPENDENCY_CIRCUIT_OPEN`) + `shouldRetry()` 관문1(진행 중 재시도 중단).
|
||||
|
||||
---
|
||||
|
||||
## §7. 안전장치 ② 재시도 — *할지*(4-관문) + *어떻게*(루프·백오프) [신입 필수]
|
||||
|
||||
> **새 용어 — 멱등(idempotent):** 같은 요청을 여러 번 보내도 결과가 한 번과 같음. GET/PUT/DELETE 는 멱등(안전하게 재시도 가능), **POST 는 비멱등**(보낼 때마다 새 결제가 생김 → 재시도 금지). · **데드라인 예산:** "늦어도 이 시각까지"라는 전체 마감. · **백오프/지터:** 재시도 간 대기를 점점 늘리고(backoff) 거기에 무작위를 섞어(jitter) 모두가 동시에 재시도(thundering herd)하는 걸 막음.
|
||||
|
||||
#### A. 재시도를 *할지* 결정 — 4-관문 (`shouldRetry`)
|
||||
|
||||
재시도는 무조건 하면 위험하다(이중 결제). 그래서 **4-관문을 전부 통과해야만** 재시도한다:
|
||||
|
||||
```java
|
||||
// 📄 OutboundRetryPolicy.java:103-133 — shouldRetry() (반환문 압축)
|
||||
public boolean shouldRetry(Throwable failure) {
|
||||
if (guard.isShuttingDown()) return false; // 관문1: 종료 중이면 끝
|
||||
CallContext ctx = callContextHolder.get();
|
||||
if (ctx == null) return false; // beginCall 안 됐으면 끝
|
||||
if (!IDEMPOTENT_METHODS.contains(ctx.method())) return false; // 관문2: POST/PATCH 차단
|
||||
boolean retryable;
|
||||
if (failure instanceof DependencyFailureException dfe)
|
||||
retryable = dfe.errorCode().retryable(); // 이미 번역됨 → 그 코드의 플래그
|
||||
else
|
||||
retryable = mapper.classify("_retry-check_", failure).errorCode().retryable();
|
||||
if (!retryable) return false; // 관문3: 재시도 가능 코드만 (§10 표)
|
||||
return Instant.now().isBefore(ctx.deadline()); // 관문4: 마감시한 예산 남았나
|
||||
}
|
||||
// IDEMPOTENT_METHODS = Set.of(GET, HEAD, PUT, DELETE) ← :52-53 (POST/PATCH 의도적 제외)
|
||||
```
|
||||
|
||||
순서대로: ① **종료 중 아님** → ② **멱등 메서드** → ③ **재시도 가능 코드**(§10 의 "재시도?" 칸) → ④ **마감시한 남음**. (코드상으론 `ctx==null` 까지 5개의 조기 반환이지만, 논리적으론 4-관문.)
|
||||
|
||||
> ⚠️ **[심화] deadline 은 "하드 데드라인"이 아니다.** 관문4 는 *재시도를 시작하기 전*에만 검사한다(`shouldRetry` 안). 즉 **이미 시작된 read 는 강제로 못 끊는다** → 마지막 시도가 read-timeout 만큼 deadline 을 *초과*해 끝날 수 있다. "deadline=다음 재시도 차단선"이지 "30s 면 무조건 30s 에 끊김"이 아니다. 진짜 하드 컷이 필요하면 Resilience4j `TimeLimiter`(+별도 스레드)가 필요한데, 동기 클라이언트엔 스레드 낭비라 *의도적으로* deadline 예산만 택했다(결정 I3).
|
||||
|
||||
#### B. 재시도가 *어떻게* 도나 — 루프·횟수·백오프·지터
|
||||
|
||||
게이트(A)가 "해도 된다"고 하면, Resilience4j `Retry` 가 *실제 루프*를 돈다. 그 설정을 만드는 코드:
|
||||
|
||||
```java
|
||||
// 📄 OutboundHttpResilience.java:82-90 — retryFor(): 재시도 설정 빌드
|
||||
RetryConfig config = RetryConfig.custom()
|
||||
.maxAttempts(r.maxAttempts()) // 총 시도 횟수 (기본 3)
|
||||
.intervalFunction(IntervalFunction.ofExponentialRandomBackoff( // 지수 백오프 + 지터
|
||||
r.initialBackoff(), r.backoffMultiplier())) // 기본 100ms, ×2.0
|
||||
.retryOnException(retryPolicy::shouldRetry) // ← 4-관문(A)이 여기 꽂힌다
|
||||
.build();
|
||||
```
|
||||
|
||||
- **`retryOnException(shouldRetry)`** — 매 실패마다 Retry 가 4-관문을 *다시* 물어본다. true 면 한 번 더, false 면 즉시 포기. 즉 **게이트(A)는 루프 안에서 매 회 호출**된다.
|
||||
- **`maxAttempts=3`** — 첫 시도 1 + 재시도 2 = **총 3번**. (재시도 켠 채 매번 500 주는 GET 은 서버를 *정확히 3번* 친다 — 테스트 검증.)
|
||||
- **백오프 = 지수 + 지터** — 시도 사이 *대기 시간*. nominal = `initial-backoff × multiplier^(n-1)` → 기본값이면 100ms, 200ms … 거기에 **±50% 무작위(지터)** 를 섞는다(Resilience4j 기본 randomizationFactor 0.5).
|
||||
|
||||
타임라인 (기본값, GET 이 매번 timeout):
|
||||
|
||||
```text
|
||||
시도1 ─실패→ 대기 ~100ms(지터 [50,150]) → 시도2 ─실패→ 대기 ~200ms(지터 [100,300]) → 시도3 ─실패→ 포기(예외 전파)
|
||||
└─────────────────── 매 대기 직전 4-관문④(deadline)을 다시 확인 ───────────────────┘
|
||||
```
|
||||
|
||||
> **왜 지터?** 장애 순간 수백 개 요청이 *똑같이* 100ms 뒤 동시에 재시도하면 회복 중인 상대를 또 무너뜨린다(thundering herd). ±무작위로 시점을 흩뜨려 막는다.
|
||||
|
||||
#### 재시도 설정값과 역할 (`app.outbound.http.retry.*`)
|
||||
|
||||
| 설정 | 역할 | 기본 | 바꾸면 |
|
||||
|---|---|---|---|
|
||||
| `retry-enabled` | 재시도 기능 on/off (off 면 `retryFor`→`Optional.empty()` = 데코 안 함) | `false` | `true` 라야 위 루프가 생김 |
|
||||
| `retry.max-attempts` | **총** 시도 횟수(첫 시도 포함) | `3` | `5` → 최대 4번 재시도 |
|
||||
| `retry.initial-backoff` | 첫 재시도 전 nominal 대기 | `100ms` | 키우면 첫 대기 ↑ |
|
||||
| `retry.backoff-multiplier` | 매 재시도마다 대기 ×배수 | `2.0` | `3.0` → 100→300→900ms |
|
||||
|
||||
> 🧑🏫 **한마디:** 게이트(A)=*할지*, 루프(B)=*어떻게*. 재시도가 실제로 일어나려면 **`retry-enabled=true`** + **4-관문 통과** 둘 다 필요하다. (서킷 §8 과 합쳐지는 순서·집계는 §12 [심화].)
|
||||
|
||||
<details><summary>✅ 이해 점검</summary>
|
||||
|
||||
1. `POST /orders` 가 `SocketTimeoutException` → 재시도되나? 어느 관문에서 탈락? (관문2)
|
||||
2. `GET /products/1` 가 404 → 재시도되나? 왜? (관문3 — 4xx 는 retryable=false, §10)
|
||||
3. `max-attempts=3` 이고 매번 실패면 서버를 몇 번 치고, 대기는 몇 번 하나? (정답: 3번 호출 / 2번 대기)
|
||||
4. `initial-backoff=100ms`, `backoff-multiplier=2.0` 면 *두 번째* 재시도 전 nominal 대기는? (200ms)
|
||||
</details>
|
||||
|
||||
---
|
||||
|
||||
## §8. 안전장치 ③ 서킷 브레이커 — 0부터 [신입 필수]
|
||||
|
||||
> 근거: 개념 [[wiki/concepts/circuit-breaker.md]], 설정 수치는 canonical project 문서. 라이브러리는 Resilience4j.
|
||||
|
||||
**서킷 브레이커가 뭔데?** 집 누전차단기(두꺼비집)다. 과부하/누전 시 차단기가 *탁* 내려가 집 전체 화재를 막고, 잠시 뒤 다시 올려본다. **단 — 차단기가 내려간 동안은 멀쩡한 가전도 못 쓴다.** 소프트웨어도 똑같다: 어떤 외부 의존성이 계속 실패하면 그쪽 호출을 한동안 *아예 끊는다*. 죽은 서버를 계속 두들겨봐야 ① 내 스레드만 묶이고 ② 아픈 상대를 더 괴롭히기 때문.
|
||||
|
||||
**무엇을 감시?** 그 의존성으로 나간 **최근 100건의 실패 비율**(= 슬라이딩 윈도우).
|
||||
|
||||
```mermaid
|
||||
stateDiagram-v2
|
||||
[*] --> CLOSED
|
||||
CLOSED --> OPEN: 최근 100건 실패율 ≥ 50%
|
||||
OPEN --> HALF_OPEN: 60초 경과
|
||||
HALF_OPEN --> CLOSED: 시험 10건 실패율 < 50% (복구)
|
||||
HALF_OPEN --> OPEN: 시험 10건 실패율 ≥ 50% (아직 아픔)
|
||||
note right of CLOSED
|
||||
정상. 통과시키며 실패율만 측정
|
||||
end note
|
||||
note right of OPEN
|
||||
차단. 네트워크 안 감.
|
||||
즉시 CallNotPermittedException
|
||||
end note
|
||||
note right of HALF_OPEN
|
||||
간 보기. 동시 10건만 통과
|
||||
end note
|
||||
```
|
||||
|
||||
- **CLOSED(정상):** 다 통과시키며 실패율을 잰다.
|
||||
- **OPEN(차단):** 외부로 **안 보내고** 즉시 `CallNotPermittedException` 을 던진다(= §10 표의 `DEPENDENCY_CIRCUIT_OPEN`). ms 단위로 빠르게 실패(fail-fast).
|
||||
- **HALF_OPEN(간 보기):** 대기 후 "살아났나?" 확인하려 **동시 10건만** 통과시키고 나머진 거부. 그 10건 결과가 다 모이면 CLOSED 복귀냐 OPEN 회귀냐 결정.
|
||||
|
||||
**각 전이를 어떤 설정값이 정하나:**
|
||||
|
||||
| 전이 | 트리거 | 설정 (`app.outbound.http.circuit-breaker.*`) | 기본 |
|
||||
|---|---|---|---|
|
||||
| CLOSED → OPEN | 윈도우가 차고 실패율 임계 이상 | `sliding-window-size` / `minimum-number-of-calls` / `failure-rate-threshold` | 100 / 100 / 50% |
|
||||
| OPEN → HALF_OPEN | 대기시간 경과 | `wait-duration-in-open-state` | 60s |
|
||||
| HALF_OPEN → CLOSED/OPEN | 시험 호출 실패율 < / ≥ 임계 | `permitted-calls-in-half-open` (+ 임계) | 10 |
|
||||
|
||||
> 🧩 **두 설정이 헷갈린다 — `sliding-window-size` vs `minimum-number-of-calls`:** 우연히 둘 다 100이지만 *다른 손잡이*다. 윈도우 크기 = "실패율을 *재는 표본 범위*", min-calls = "실패율을 *계산하기 시작하는 최소 건수*". 100건이 안 모이면 한두 번 실패해도 서킷을 안 연다(통계 노이즈 방지).
|
||||
>
|
||||
> 🔬 **[심화] HALF_OPEN 의 윈도우는 따로다.** HALF_OPEN 에 들어가면 100짜리 윈도우를 비우고 `permitted-calls-in-half-open`(10) 크기의 *별도 시험 윈도우*로 평가한다. 그 10건의 실패율로 CLOSED/OPEN 을 가른다. (이 윈도우는 시간이 아니라 *건수* 기준 = `slidingWindowType` 이 COUNT_BASED. 코드엔 노출 안 됨 = Resilience4j 기본값. TIME_BASED 로 바꾸려면 코드 수정 필요.)
|
||||
|
||||
**타임라인(기본값):** ① payment 가 100건 중 60건 실패 → 실패율 60% → **OPEN.** ② 60초간 모든 payment 호출 즉시 거절(내 스레드 보호, 상대 숨 돌림). ③ 60초 후 **HALF_OPEN**, 10건 떠봄 → 1건만 실패(10%) → **CLOSED 복귀.** ④ 만약 6건 실패면 → **다시 OPEN.**
|
||||
|
||||
**코드에서 어디?** `OutboundHttpResilience.circuitBreakerFor("payment")` 가 의존성 이름별 인스턴스를 캐시 → payment 와 inventory 는 *독립된* 두꺼비집(한쪽이 열려도 다른 쪽 멀쩡).
|
||||
|
||||
> ⚠️ **과장 금지(면접용):** "서킷 쓰면 가용성 올라간다"는 **틀린 말**이다. OPEN 동안은 멀쩡한 요청도 거절돼 *그 의존성 가용성은 일시적으로 0*. 서킷의 진짜 목적은 가용성이 아니라 **내 스레드 보호 + 아픈 업스트림 보호**다.
|
||||
|
||||
<details><summary>✅ 이해 점검</summary>
|
||||
|
||||
1. 빈 종이에 3상태 + 전이 4개 + 트리거를 직접 그려보라. (E3)
|
||||
2. `minimum-number-of-calls=100` 이 없으면 서버 켜자마자 무슨 일? (1~2건 실패로 서킷 오픈 — 노이즈)
|
||||
3. "서킷 쓰면 가용성 ↑?" O/X + 한 줄 교정. (E4)
|
||||
</details>
|
||||
|
||||
---
|
||||
|
||||
## §9. 안전장치 ④ 응답 크기 & 스트리밍 [신입 필수]
|
||||
|
||||
상대가 2GB 응답을 주는데 버퍼에 다 담으면 OOM. 그래서 **buffered 경로엔 크기 상한(기본 10MB)**, 대용량은 **streaming 경로**로 분리한다.
|
||||
|
||||
`ResponseSizeBoundingInterceptor` 는 **2단 방어**다:
|
||||
1. **Content-Length 빠른 차단:** 응답 헤더의 선언 크기가 상한을 넘으면 본문을 *한 바이트도 안 읽고* `OutboundResponseSizeExceededException`.
|
||||
2. **스트림 카운팅:** 헤더가 없거나 *거짓말*하면, `BoundedInputStream` 이 읽는 바이트를 세다 상한 초과 시 throw.
|
||||
|
||||
대용량은 buffered 가 아니라 streaming:
|
||||
|
||||
```java
|
||||
// 📄 OutboundHttpClient.java:295-298 — 버퍼 없이 raw InputStream 을 reader 에게 직접
|
||||
T result = streamingClient.method(method).uri(uri)
|
||||
.exchange((req, res) -> reader.apply(res.getBody()));
|
||||
```
|
||||
|
||||
`exchange()` 콜백은 응답을 메모리에 다 담지 않고 `InputStream` 을 그대로 넘긴다 → 100MB CSV OK. 단 **재시도 없음** — 한 번 흘려보낸 스트림은 (수도꼭지에서 이미 흘러간 물처럼) 되감을 수 없어 다시 보낼 수 없다(자가점검 Q5 답).
|
||||
|
||||
---
|
||||
|
||||
## §10. 실패의 번역 — `classify()` + 예외 운반 [신입 필수]
|
||||
|
||||
> **새 용어 — cause chain(원인 사슬):** 예외 A 가 예외 B 때문에 났을 때 `A.getCause()==B` 로 줄줄이 연결된 것. 진짜 원인은 사슬 아래에 숨어 있곤 한다.
|
||||
|
||||
`exchange()` 가 잡은 raw 예외(`Throwable`)는 **하나의 `DependencyFailureException` 으로 번역**된다. `classify()` 가 cause chain 을 훑어 첫 매치를 채택:
|
||||
|
||||
```java
|
||||
// 📄 OutboundHttpErrorMapper.java:63-169 — classify() (메시지 인자 …로 생략)
|
||||
public DependencyFailureException classify(String dependencyName, Throwable failure) {
|
||||
Throwable current = failure;
|
||||
while (current != null) { // 원인 사슬을 위에서부터 한 칸씩
|
||||
if (current instanceof CallNotPermittedException) // 규칙1: 서킷 OPEN (§8)
|
||||
return new DependencyFailureException(OperationalError.DEPENDENCY_CIRCUIT_OPEN, …);
|
||||
if (current instanceof UnknownHostException || current instanceof UnresolvedAddressException)
|
||||
return new DependencyFailureException(OperationalError.DEPENDENCY_DNS_FAILED, …); // 규칙2: DNS
|
||||
if (current instanceof HttpConnectTimeoutException) // 규칙3: 연결 (먼저!)
|
||||
return new DependencyFailureException(OperationalError.DEPENDENCY_CONNECT_FAILED, …);
|
||||
if (current instanceof ConnectException) {
|
||||
if (hasDnsCauseInChain(current.getCause())) // 연결예외가 사실 DNS 를 감쌌으면 DNS 로
|
||||
return new DependencyFailureException(OperationalError.DEPENDENCY_DNS_FAILED, …);
|
||||
return new DependencyFailureException(OperationalError.DEPENDENCY_CONNECT_FAILED, …);
|
||||
}
|
||||
if (current instanceof HttpTimeoutException || current instanceof SocketTimeoutException
|
||||
|| current instanceof TimeoutException) // 규칙4: 시간 초과
|
||||
return new DependencyFailureException(OperationalError.DEPENDENCY_TIMEOUT, …);
|
||||
if (current instanceof RestClientResponseException responseEx) { // 규칙5/6: HTTP 상태
|
||||
int status = responseEx.getStatusCode().value();
|
||||
if (status >= 400 && status < 500) // I5: 모든 4xx = 비재시도
|
||||
return new DependencyFailureException(OperationalError.DEPENDENCY_4XX_CLIENT, …);
|
||||
if (status >= 500)
|
||||
return new DependencyFailureException(OperationalError.DEPENDENCY_5XX_SERVER, …);
|
||||
}
|
||||
current = current.getCause(); // 다음 원인으로
|
||||
}
|
||||
return new DependencyFailureException(OperationalError.DEPENDENCY_CONNECT_FAILED, …); // 규칙7: fallback
|
||||
}
|
||||
```
|
||||
|
||||
> 🤔 **왜 `HttpConnectTimeoutException` 을 먼저 검사하나?** 자바는 부모 타입으로 `instanceof` 하면 자식도 다 걸린다. `HttpConnectTimeoutException` 은 `HttpTimeoutException`(규칙4)의 *자식*이라, 규칙4 를 먼저 두면 connect-timeout 이 일반 timeout 으로 *오분류*된다 → 그래서 규칙3(connect)이 위. · **ConnectException 의 DNS 재탐색:** JDK 가 DNS 실패를 `ConnectException(cause=UnresolvedAddressException)` 로 감싸는 패턴이 있어, 그 *하위 사슬*을 한 번 더 훑어 DNS 면 `DNS_FAILED` 로 승격한다(분류 충실도).
|
||||
|
||||
번역 결과(코드·HTTP·재시도 여부)는 `OperationalError` enum 에 못박혀 있다:
|
||||
|
||||
| 실제로 터진 예외 | 번역된 코드 | HTTP / 재시도? |
|
||||
|---|---|---|
|
||||
| `CallNotPermittedException`(서킷 OPEN) | `DEPENDENCY_CIRCUIT_OPEN` | 503 / ✅ |
|
||||
| `UnknownHostException`(DNS) | `DEPENDENCY_DNS_FAILED` | 503 / ✅ |
|
||||
| `ConnectException`(연결) | `DEPENDENCY_CONNECT_FAILED` | 503 / ✅ |
|
||||
| `SocketTimeoutException` 등(시간초과) | `DEPENDENCY_TIMEOUT` | 504 / ✅ |
|
||||
| 상대 4xx | `DEPENDENCY_4XX_CLIENT` | 502 / ❌ (401→자격증명, 403→권한 힌트) |
|
||||
| 상대 5xx | `DEPENDENCY_5XX_SERVER` | 502 / ✅ |
|
||||
|
||||
> 4xx 가 ❌ 인 이유: 잘못 보낸 요청을 똑같이 다시 보내봐야 또 거절. **알려진 한계:** 408(타임아웃)·429(과다요청)는 원래 재시도 가치가 있는데 "모든 4xx=비재시도"라 함께 막힌다.
|
||||
|
||||
### 예외 객체는 어떻게 "담겨서" 위로 가나
|
||||
|
||||
```java
|
||||
// 📄 shared/error/DependencyFailureException.java (발췌) — 분류된 실패 운반체
|
||||
public class DependencyFailureException extends RuntimeException {
|
||||
private final ApiErrorCode errorCode; // ① 클라이언트에 줄 코드 (DEPENDENCY_5XX_SERVER)
|
||||
private final String dependencyName; // ② 누가 실패했나 ("payment")
|
||||
public DependencyFailureException(ApiErrorCode errorCode, String dependencyName,
|
||||
String diagnosticMessage, Throwable cause) {
|
||||
super(diagnosticMessage, cause); // ③ diagnosticMessage = 서버 로그 전용, ④ cause = 원본 예외
|
||||
...
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
5xx 메시지 조립(`:151-154`): `"Upstream 5xx from dependency: payment status=500 (HttpServerErrorException)"` — **status + 예외 클래스명만.**
|
||||
|
||||
> 🔒 **비밀(토큰/PII)이 안 새는 2중 방어:** (1) `classify()` 가 메시지에 `getResponseBodyAsString()`(외부 응답 본문)을 *안 넣는다* + (2) 구조화 로거(`OutboundHttpDependencyLogger`)는 애초에 **응답 body·URI 를 받는 파라미터가 없다**(시그니처 차원 봉쇄, "by construction"). 그래서 외부가 `{"token":"sk_live_secret"}` 를 줘도:
|
||||
> - 서버 로그 메시지: `…status=500 (HttpServerErrorException)` (secret 없음)
|
||||
> - 클라이언트 응답: `{"error":{"code":"DEPENDENCY_5XX_SERVER"}}` (코드만)
|
||||
> - 원본 예외는 `cause` 로 서버 스택트레이스에만. (이 비유출을 단위 테스트가 검증.)
|
||||
|
||||
**전달 경로:** `exchange()` 가 throw → adapter·application 은 안 잡음 → 🌫️ web 의 `GlobalExceptionHandler` 가 잡아 `errorCode()` 만 읽어 클라이언트용 봉투로 변환(이 계약은 `DependencyFailureException` javadoc 에 명시). web 변환부 *세부*는 미지의 영역.
|
||||
|
||||
<details><summary>✅ 이해 점검</summary>
|
||||
|
||||
1. 외부 500 + body `{"token":"…"}` → ① 서버 로그 메시지 ② 클라이언트 응답을 각각 써보라. (E5)
|
||||
2. `HttpConnectTimeoutException` 을 `HttpTimeoutException` 보다 먼저 검사하는 이유? (상속 + instanceof 순서)
|
||||
</details>
|
||||
|
||||
---
|
||||
|
||||
## §11. 횡단 관심사 — trace / baggage 인터셉터 [신입 필수]
|
||||
|
||||
> **새 용어 — MDC:** 로그·추적용 "스레드별 메모장". **traceparent:** W3C 표준 분산추적 헤더(`00-traceid-spanid-flag`). **baggage:** 서비스 간 따라다니는 키-값. **allowlist:** 허용 목록(나머지는 차단).
|
||||
|
||||
외부로 나가는 모든 요청에 `TraceContextPropagationInterceptor` 가 *먼저* 끼어들어 MDC 의 추적 정보를 헤더로 붙인다 — 여러 서버를 관통하는 한 요청을 추적하려고. 단 **baggage 는 allowlist(`tenant_id`·`request_id`)만** 통과시키고 나머지(이메일·토큰 등)는 전송 전 박멸한다(보안 경계).
|
||||
|
||||
```text
|
||||
입력 MDC: trace_id, span_id, tenant_id, user_email(민감)
|
||||
출력 헤더: traceparent: 00-<trace_id>-<span_id>-00
|
||||
baggage: tenant_id=... (user_email 은 자동 탈락)
|
||||
```
|
||||
|
||||
> ⚠️ **[한계]** 현재 traceparent 의 샘플링 비트가 `00`(Not-Sampled)으로 하드코딩이다. 실제 운영 분산추적엔 OpenTelemetry SDK / Micrometer Tracing 연동으로 교체해야 한다(스켈레톤 한계).
|
||||
|
||||
---
|
||||
|
||||
## §12. [심화] 데코레이션 순서의 진실 — CB 는 retry 의 *바깥*
|
||||
|
||||
§5 Step 4 에서 `Retry.decorateSupplier` 로 감싼 뒤 `CircuitBreaker.decorateSupplier` 로 또 감쌌다. **마지막에 감싼 게 가장 바깥 껍질**이므로 최종 구조는:
|
||||
|
||||
```text
|
||||
CircuitBreaker( Retry( countingSupplier → 실제 호출 ) )
|
||||
└ 바깥 ─────────┘ └ 안쪽 ┘
|
||||
실행: cb.executeSupplier( () -> retry.executeSupplier( counting ) )
|
||||
```
|
||||
|
||||
**이게 무슨 뜻인가(★중요):** CB 가 가장 바깥이라, **한 논리적 호출(재시도 N번 포함)이 CB 에는 단 1건으로 기록된다.**
|
||||
- 일시 실패가 재시도로 복구되면 → CB 는 그 흔들림을 *안 보고* 성공 1건만 기록(블립 흡수).
|
||||
- 재시도까지 다 실패하면 → CB 에 실패 1건.
|
||||
- 즉 **재시도 각각이 따로 카운트되지 않는다.**
|
||||
|
||||
트레이드오프:
|
||||
- **CB-바깥(현재 코드):** CB 가 "이 논리적 호출이 최종 실패했나"만 본다. 재시도로 흡수된 일시 장애가 윈도우를 오염시키지 않음(장점). 대신 시도별 실패 *빈도*는 CB 가 못 봄.
|
||||
- **CB-안쪽(반대 배치):** 시도마다 CB 에 기록 → 한 번 실패한 호출이 윈도우를 재시도 횟수만큼 부풀림 + CB 가 OPEN 되면 남은 재시도가 `CallNotPermitted` 로 즉시 끊김.
|
||||
|
||||
> 🐞 **반드시 알아야 할 코드 모순(내 코드의 결함):** 실제 코드 주석(`OutboundHttpClient.java:212-216`)은 "Retry is OUTSIDE the CB so each retry attempt is independently CB-counted"(재시도가 따로 카운트됨)라고 적었지만, 바로 아래 `:225-231` 의 데코 순서는 **CB 를 바깥**에 둔다 → 주석의 주장과 정반대로 동작한다(재시도는 1건으로 묶임). 이 문서의 *이전 버전도 그 틀린 주석을 베껴* "재시도가 따로 잡힌다"고 잘못 썼었다. **➡️ 코드 소유 브랜치(`feature-outbound-http-client-baseline`)에서 주석을 고치거나, 의도가 "시도별 집계"였다면 데코 순서를 바꿔야 한다.** (면접에서 "CB 바깥이라 재시도가 따로 잡힌다"고 말하면 Resilience4j 아는 면접관이 바로 반박한다 — 1순위 위험.)
|
||||
|
||||
<details><summary>✅ 이해 점검 (E6)</summary>
|
||||
|
||||
같은 호출이 3번 재시도 끝에 실패했다. CB 슬라이딩 윈도우엔 실패가 몇 건 기록되나? (정답: 1건 — CB 가 바깥이라.)
|
||||
</details>
|
||||
|
||||
---
|
||||
|
||||
## §13. [심화] 배선 & Spring 메커니즘 — "그게 어떻게 가능한가"
|
||||
|
||||
**① `@ConfigurationProperties` + `@ConstructorBinding`** (`OutboundHttpSettings.java:36, 60`)
|
||||
|
||||
```java
|
||||
@ConfigurationProperties(prefix = "app.outbound.http") // 이 prefix 설정만 모음
|
||||
public record OutboundHttpSettings(
|
||||
@ConstructorBinding // setter 없이 "생성자로만" 주입
|
||||
Duration connectTimeout, Duration readTimeout, Duration globalCallTimeout, ...) {
|
||||
public OutboundHttpSettings { // compact 생성자 = 값이 들어오는 길목에서 검증
|
||||
if (connectTimeout == null || connectTimeout.isZero() || connectTimeout.isNegative())
|
||||
throw new IllegalArgumentException("APP_OUTBOUND_HTTP_CONNECT_TIMEOUT ... (D5)");
|
||||
}
|
||||
}
|
||||
```
|
||||
어떻게 가능한가: ① Spring Boot 의 **`Binder`** 가 `Environment`(env+yaml+프로퍼티)에서 prefix 키를 긁고 → ② **느슨한 바인딩**(`connect-timeout` ≡ `APP_OUTBOUND_HTTP_CONNECT_TIMEOUT` ≡ `connectTimeout`) → ③ 타입 변환(`"30s"`→`Duration`, `"10MB"`→`DataSize`) → ④ `@ConstructorBinding` 이라 생성자로만 주입 → 불변 → ⑤ compact 생성자 검증에서 `throw` 하면 빈 생성 실패 → `BeanCreationException` → **앱이 아예 안 뜸**(런타임 아님). 핵심: Binder 가 리플렉션으로 record 파라미터↔키를 자동 매칭하므로 내가 파싱 코드를 안 짠다.
|
||||
|
||||
**② `BeanPostProcessor` 타임아웃 강제기** (`OutboundHttpTimeoutEnforcer.java:39`) — 모든 빈 생성 직후 끼어드는 콜백. raw `RestClient`/`Builder` 빈을 발견하면 `BeanCreationException` 으로 기동 차단(타임아웃 없는 클라 봉쇄). `static @Bean` 인 이유: 다른 빈보다 먼저 만들어져야 검사 가능. **잔여 위험:** 메서드 *본문 안*의 인라인 `RestClient.create()` 는 빈이 아니라 못 잡는다 → 코드리뷰/import 게이트가 그 방어선. (그래서 §1 의 "원천 차단"은 정확히는 *빈으로 등록된* raw 클라 차단.)
|
||||
|
||||
**③ 타임아웃 2원화** (`OutboundHttpClient.java:95-99`) — connect timeout 은 JDK `HttpClient` 가, read timeout 은 `JdkClientHttpRequestFactory` 가 맡는다. *왜 두 군데?* JDK `HttpClient.Builder` 엔 connectTimeout 만 있고 *per-request read timeout API 가 없어서*, Spring factory 가 그 공백을 메운다(라이브러리 API 한계).
|
||||
|
||||
**④ RestClient 의 객체↔JSON 은 Jackson "만"이 아니다** — `.body(obj)` / `.body(responseType)` 는 RestClient 의 **`HttpMessageConverter` 체인**을 돌며 타입 + `Content-Type` 협상으로 컨버터를 고른다. JSON 이면 `MappingJackson2HttpMessageConverter` 가 담당할 뿐, XML/폼/String 도 같은 메커니즘. "RestClient=무조건 Jackson"으로 일반화하면 안 된다.
|
||||
|
||||
**⑤ Resilience4j `decorateSupplier`** — `Supplier` 를 감싸 능력 부여(§12). 의존성 이름별 인스턴스를 Registry 가 캐시.
|
||||
**⑥ Micrometer `MeterFilter`** (`OutboundHttpResilienceConfig.java`) — 지표 등록 *전*에 끼어들어 핵심 3종만 남기고 `DENY` + 태그 정규화(§16 "카디널리티").
|
||||
|
||||
---
|
||||
|
||||
## §14. [심화] 자료구조 & 배선 함정
|
||||
|
||||
| 쓴 것 | 코드 위치 | 왜 (한 겹 더) |
|
||||
|---|---|---|
|
||||
| `AtomicBoolean` | `OutboundHttpShutdownGuard.java:28-29` | 종료 스레드의 write 를 요청 스레드가 *즉시* 보게(가시성). JMM 상 plain boolean 은 다른 스레드가 캐시된 옛 값을 영원히 볼 수 있다 → `AtomicBoolean` 은 내부가 `volatile`+CAS 라 **happens-before** 로 가시화. (여기선 CAS 안 쓰니 `volatile boolean` 으로도 충분 — 표현 명시성 때문에 Atomic 선택) |
|
||||
| `ThreadLocal<CallContext>` | `OutboundRetryPolicy.java:59` | 호출이 한 스레드를 타고 가니 마감시한·메서드를 스레드별 격리. **누수 위험:** 톰캣 풀 스레드는 재사용되므로 `endCall()`(`remove()`)을 안 하면 다음 요청이 *이전 컨텍스트*를 봄(오판) + GC 안 됨 → `exchange()` `finally` 가 필수 |
|
||||
| `Set.of(GET,HEAD,PUT,DELETE)` | `:52-53` | 불변 + O(1) 멱등 판정 |
|
||||
| `int[] attemptCount = {0}` | `OutboundHttpClient.java:206` | 람다는 바깥 지역변수를 못 바꿈 → 1칸 배열의 *안*을 고침 |
|
||||
| `record CallContext` | `:136` | per-call 불변 컨텍스트 |
|
||||
| `Optional<Retry>/<CircuitBreaker>` | `:217-218` | "기능 off → 데코 없음"을 호출자가 반드시 처리하게 |
|
||||
|
||||
```mermaid
|
||||
classDiagram
|
||||
class OutboundHttpClient {
|
||||
+baseline(...)$ OutboundHttpClient
|
||||
+exchange(method, uri, body, type) T
|
||||
+stream(method, uri, reader) T
|
||||
}
|
||||
class OutboundHttpShutdownGuard { +isShuttingDown() boolean }
|
||||
class OutboundRetryPolicy { +beginCall() +shouldRetry() boolean +endCall() }
|
||||
class OutboundHttpResilience { +circuitBreakerFor(name) Optional +retryFor(name) Optional }
|
||||
class OutboundHttpErrorMapper { +classify(name, failure) DependencyFailureException }
|
||||
class OutboundHttpSettings
|
||||
class SmartLifecycle { <<interface>> }
|
||||
|
||||
OutboundHttpClient --> OutboundHttpSettings : 설정
|
||||
OutboundHttpClient --> OutboundHttpShutdownGuard : 종료 검문
|
||||
OutboundHttpClient --> OutboundRetryPolicy : beginCall/endCall
|
||||
OutboundHttpClient --> OutboundHttpResilience : CB·Retry 공급
|
||||
OutboundHttpClient --> OutboundHttpErrorMapper : 예외 번역
|
||||
OutboundHttpResilience --> OutboundRetryPolicy : shouldRetry 를 재시도 조건으로
|
||||
OutboundRetryPolicy --> OutboundHttpShutdownGuard : 종료 시 중단
|
||||
OutboundHttpShutdownGuard ..|> SmartLifecycle : 구현
|
||||
```
|
||||
|
||||
생성/배선: `OutboundHttpClientConfig` 가 협력 빈들을 `@Bean` 등록(단 `OutboundHttpClient` 자체는 의존성마다 `baseline(...)` 으로 직접 생성), `OutboundHttpResilienceConfig` 가 `OutboundHttpResilience` 빈 + MeterFilter.
|
||||
|
||||
> ⚠️ **함정:** `OutboundRetryPolicy` 를 `OutboundHttpResilience`(판정)와 `OutboundHttpClient`(`beginCall` 적재)가 *다른 객체*로 들면, 판정 측 `callContextHolder.get()` 이 항상 `null` → **영영 재시도 안 함**(관문2 탈락). 반드시 **같은 빈 공유**.
|
||||
|
||||
---
|
||||
|
||||
## §15. [참조] 설정값 레퍼런스 (전부 `OutboundHttpSettings.java`)
|
||||
|
||||
| 키 (`app.outbound.http.*`) | 기본값 | 효과 |
|
||||
|---|---|---|
|
||||
| `connect-timeout` / `read-timeout` / `global-call-timeout` | **없음(필수)** | TCP 연결 / 한 번 읽기 / 재시도 포함 전체. 누락 시 기동 실패 |
|
||||
| `retry-enabled` / `circuit-breaker-enabled` | `false` / `false` | 재시도 / 서킷 활성. 하나라도 켜면 `MeterRegistry` 필수 |
|
||||
| `response-size-limit` | `10MB` | buffered 응답 메모리 상한 |
|
||||
| `retry.max-attempts` / `initial-backoff` / `backoff-multiplier` | `3` / `100ms` / `2.0` | 시도 횟수 / 첫 대기 / 지수 배수 |
|
||||
| `circuit-breaker.failure-rate-threshold` | `50%` | 이 실패율 넘으면 OPEN |
|
||||
| `…sliding-window-size` / `…minimum-number-of-calls` | `100` / `100` | 표본 범위 / 계산 최소 건수 |
|
||||
| `…wait-duration-in-open-state` / `…permitted-calls-in-half-open` | `60s` / `10` | OPEN 유지 / HALF_OPEN 시험 호출 수 |
|
||||
|
||||
---
|
||||
|
||||
## §16. [참조] 용어집 — 치트시트 (복습용)
|
||||
|
||||
> 학습용이 아니라 *남 앞에서 설명하기 직전* 빠르게 훑는 카드. 각 항목: 정의 → 한 줄로 말하면.
|
||||
|
||||
- **Port & Adapter** — application 이 선언한 인터페이스(Port)를 어댑터가 실제 기술로 구현. → *"핵심 로직은 인터페이스에만 의존하고 외부 기술은 어댑터가 갈아끼웁니다."*
|
||||
- **직렬화/역직렬화** — 객체 ↔ JSON. RestClient 가 메시지 컨버터로 처리. → *"객체를 넣으면 JSON 으로 바꿔 보내고 응답 JSON 을 객체로 돌려줍니다."*
|
||||
- **타임아웃 3종** — connect/read/global. → *"어디서 멈춰도 스레드가 안 묶이게 셋 다 끊습니다."*
|
||||
- **데드라인 예산** — 재시도 누적 시간의 절대 마감. *재시도 차단선*이지 하드컷 아님(§7). → *"재시도가 전체 마감을 못 넘게 하는 예산입니다."*
|
||||
- **멱등(idempotent)** — 여러 번 = 한 번(GET/PUT/DELETE). POST 는 비멱등. → *"멱등 메서드만 재시도해 이중 결제를 막습니다."*
|
||||
- **재시도/백오프/지터** — 다시 시도 / 대기 점증 / 무작위 섞기. → *"지수 백오프에 지터를 섞어 재시도 쏠림(thundering herd)을 막습니다."*
|
||||
- **서킷 브레이커 / CLOSED·OPEN·HALF_OPEN / 슬라이딩 윈도우** — 실패 잦은 의존성을 잠시 끊는 두꺼비집. → *"세 상태 FSM 으로 아픈 서버를 잠시 끊어 내 스레드·상대를 보호(가용성 목적 아님)."*
|
||||
- **fail-fast / fail-open / fail-closed** — 즉시 실패 / 삼키고 통과 / 막고 멈춤. → *"의존성 성격에 따라 정합성↔가용성 중 무엇을 지킬지 고릅니다."*
|
||||
- **`@ConfigurationProperties`+`@ConstructorBinding`** — 설정→불변 record + 생성자 검증 → *"값이 틀리면 앱이 아예 안 뜨게 합니다."*
|
||||
- **`SmartLifecycle`/phase** — 순서 보장 시작/종료. → *"가드를 phase 최대로 둬 가장 먼저 멈춰 신규 호출을 선차단합니다."*
|
||||
- **`BeanPostProcessor`** — 빈 생성 직후 후크. raw 클라 적발에 사용.
|
||||
- **데코레이터/`decorateSupplier`** — 함수를 감싸 능력 추가. CB 는 retry 의 *바깥*(§12).
|
||||
- **`ThreadLocal`** — 스레드 전용 칸. 풀 스레드면 `remove()` 안 하면 누수.
|
||||
- **`AtomicBoolean` / 가시성** — 스레드 간 즉시 보이는 boolean(volatile+CAS).
|
||||
- **MDC / traceparent / baggage / allowlist** — 추적 메모장 / W3C 추적 헤더 / 따라다니는 키값 / 허용 목록.
|
||||
- **시계열 DB / 카디널리티** — 시각별 측정값 수열 저장소 / 라벨 조합 가짓수. 무한값 라벨은 폭발 → 핵심 지표만(저카디널리티).
|
||||
- **cause chain** — `getCause()` 로 줄줄이 연결된 원인. → *"래퍼에 가려진 진짜 원인을 따라가며 분류합니다."*
|
||||
|
||||
---
|
||||
|
||||
## §17. 캐시 모듈 — Fail-Open 데코레이터 + 라우터 [신입 필수]
|
||||
|
||||
> §2 축의 *가용성 끝*. "캐시가 죽어도 본점은 정상 영업." HTTP 와 달리 캐시는 장애를 **삼켜서 미스인 척** 한다. 근거: [[wiki/concepts/fail-open-fail-closed.md]]
|
||||
|
||||
**한 줄 그림:** `CacheStore`(Port: get/put) ← `CacheBackend`(+backendId) ← {`RedisCacheStore`→`RedisClient`(프로젝트 구현), `FailOpenCacheStore`(데코레이터)}. `CacheStoreRouter` 가 logicalName→backendId→backend 로 라우팅.
|
||||
|
||||
> ⚠️ **HTTP 와 타입이 다르다:** 캐시 값은 전부 `String`. `get(key)` → `Optional<String>`, `put(key, value)`. **`Class<T>`·TTL·직렬화가 이 모듈엔 없다**(있다면 프로젝트의 `RedisClient` 구현 쪽). 흔한 오해: `get(key, Class)` 형태가 *아니다*.
|
||||
|
||||
**① Fail-Open 데코레이터 — 장애를 미스로 바꾸는 곳:**
|
||||
|
||||
```java
|
||||
// 📄 cache/FailOpenCacheStore.java:42-64 — 모든 백엔드를 감싸는 데코레이터
|
||||
@Override public Optional<String> get(String key) {
|
||||
try {
|
||||
Optional<String> value = delegate.get(key);
|
||||
dependencyLogger.logSuccess(delegate.backendId(), "cache", "get");
|
||||
return value;
|
||||
} catch (Exception ex) { // ← 백엔드가 던지는 모든 예외를 잡아
|
||||
dependencyLogger.logFailure(delegate.backendId(), "cache", "get", ex); // WARN(+correlation_id)
|
||||
return Optional.empty(); // ← 미스인 척 → 호출자는 DB 로 fallback
|
||||
}
|
||||
}
|
||||
@Override public void put(String key, String value) {
|
||||
try { delegate.put(key, value); /* logSuccess */ }
|
||||
catch (Exception ex) { dependencyLogger.logFailure(...); } // ← put 실패는 조용히 삼킴(no-op)
|
||||
}
|
||||
```
|
||||
→ Redis 가 죽어도 컨트롤러는 **예외를 안 받는다.** `get` 은 빈 Optional(미스), `put` 은 무시. 실패는 WARN 로그로만 *관측*된다(5xx 아님). 단 대량 동시 미스 → DB 쏠림(캐시 스탬피드) 위험은 서킷 병행으로 보완.
|
||||
|
||||
**② 백엔드는 안 삼킨다 — "장애"를 "미스"로 오인하지 않게:**
|
||||
|
||||
```java
|
||||
// 📄 cache/redis/RedisCacheStore.java:34-40 — 얇은 Redis 바인딩
|
||||
@Override public Optional<String> get(String key) {
|
||||
try { return client.read(key); } // RedisClient = 프로젝트가 구현하는 seam
|
||||
catch (Exception ex) { throw new CacheBackendException(BACKEND_ID, ex); } // 감싸서 *전파*
|
||||
}
|
||||
```
|
||||
`CacheBackendException` 메시지 = `"cache backend 'redis' access failed"`. **백엔드는 전파, 데코레이터(①)는 삼킴** — 이 2단 분리 덕에 "진짜 장애"와 "그냥 미스(키 없음)"가 안 섞인다.
|
||||
|
||||
**③ 라우터 = 설정 오류엔 fail-fast (fail-open 과 정반대 층):**
|
||||
|
||||
```java
|
||||
// 📄 cache/CacheStoreRouter.java:77-87 — 바인딩 안 된 logical 이름 접근
|
||||
private CacheStore resolve(String logicalName) {
|
||||
String backendId = bindings.get(logicalName);
|
||||
if (backendId == null)
|
||||
throw new AdapterDisabledException("cache",
|
||||
"no cache backend bound for logical cache '" + logicalName + "' — set app.cache.bindings...");
|
||||
return backends.get(backendId);
|
||||
}
|
||||
```
|
||||
생성 시엔 **중복 backendId / 없는 backend 바인딩 → `IllegalStateException`**(기동 차단). 즉 *런타임 장애*는 fail-open(①), *설정 실수*는 fail-fast(③) — 같은 모듈 안 두 정책.
|
||||
|
||||
**설정값과 역할:**
|
||||
|
||||
| 설정 | 역할 | 기본 |
|
||||
|---|---|---|
|
||||
| `app.cache.redis.enabled` | Redis 백엔드 빈 등록 여부(`@ConditionalOnProperty`) | `false` |
|
||||
| `app.cache.bindings.<논리명>=<backendId>` | 논리 캐시명 → 실제 백엔드 매핑 | 빈 맵 |
|
||||
|
||||
<details><summary>✅ 이해 점검</summary>
|
||||
|
||||
1. Redis 가 완전히 죽었다. `Router.get("worklog","k")` 의 반환과 컨트롤러가 받는 예외는? (Optional.empty / 예외 없음 → DB fallback)
|
||||
2. `RedisCacheStore` 는 왜 예외를 안 삼키고 `CacheBackendException` 으로 던지나? (장애를 "미스"로 오인 못 하게 — 삼킴은 데코레이터 책임)
|
||||
3. `app.cache.bindings.worklog=redis` 인데 `redis.enabled=false` 면? (기동 시 IllegalStateException — fail-fast)
|
||||
</details>
|
||||
|
||||
---
|
||||
|
||||
## §18. 메시징·아웃박스 모듈 — Fail-Open vs Fail-Closed (한 줄 차이) [신입 필수]
|
||||
|
||||
> §2 축의 *양쪽을 한 모듈에서 동시에* 보여주는 곳. 같은 Kafka 인데 **실시간 발행은 fail-open, 백그라운드 릴레이는 fail-closed**. 차이는 catch 블록이 `throw` 로 끝나느냐뿐. 근거: [[wiki/concepts/outbox-pattern.md]]
|
||||
|
||||
**① 실시간 발행 — Fail-Open (삼킴):**
|
||||
|
||||
```java
|
||||
// 📄 messaging/kafka/KafkaMessagePublisher.java:39-48
|
||||
@Override public void publish(OutboundMessage message) {
|
||||
try { sender.send(message); dependencyLogger.logSuccess("kafka","messaging","publish"); }
|
||||
catch (Exception ex) {
|
||||
dependencyLogger.logFailure("kafka","messaging","publish", ex); // 로깅만
|
||||
// ← throw 없음. 브로커가 죽어도 사용자 API 는 200.
|
||||
}
|
||||
}
|
||||
```
|
||||
왜 삼켜도 되나? 이미 같은 트랜잭션에서 **Outbox 테이블에 메시지가 영속화**됐기 때문(전달은 릴레이가 책임). 브로커 장애가 사용자 응답을 5xx 로 만들지 않는다.
|
||||
|
||||
**② 백그라운드 릴레이 — Fail-Closed (전파):**
|
||||
|
||||
```java
|
||||
// 📄 messaging/outbox/KafkaOutboxMessagePublishAdapter.java:56-71
|
||||
@Override public void publish(OutboxEvent event) {
|
||||
String envelope = OutboxEnvelopeJson.toJson(event);
|
||||
OutboundMessage message = new OutboundMessage(event.eventType(), event.aggregateId(), envelope);
|
||||
try { sender.send(message); dependencyLogger.logSuccess(...); }
|
||||
catch (RuntimeException ex) { dependencyLogger.logFailure(...); throw ex; } // ← 그대로 던짐
|
||||
catch (Exception ex) { dependencyLogger.logFailure(...);
|
||||
throw new RuntimeException("Kafka outbox publish failed", ex); } // checked 는 감싸 던짐
|
||||
}
|
||||
```
|
||||
왜 던져야 하나? 릴레이가 예외를 **봐야** 그 Outbox 레코드를 `FAILED`/`DEAD` 로 전이하고 트랜잭션을 롤백해 *재시도 루프*에 남긴다. 삼키면 레코드가 `IN_FLIGHT` 로 영영 박혀 큐가 조용히 막힌다(지표 이상도 없음).
|
||||
|
||||
> 🎯 **단 한 줄의 차이:** 둘 다 `logFailure` 를 부른다. 실시간(①)의 catch 는 *그냥 끝*나고, 릴레이(②)의 catch 는 *`throw` 로 끝*난다. 이게 fail-open ↔ fail-closed 의 전부다.
|
||||
|
||||
**③ 봉투 직렬화 — Jackson 없이 손으로:**
|
||||
|
||||
```java
|
||||
// 📄 messaging/outbox/OutboxEnvelopeJson.java:49-58 — D12 wire format
|
||||
public static String toJson(OutboxEvent event) {
|
||||
return "{"
|
||||
+ "\"eventId\":\"" + escape(event.eventId()) + "\","
|
||||
+ /* eventType, aggregateId, occurredAt, correlationId, idempotencyKey — 모두 escape */
|
||||
+ "\"payload\":" + event.payload() // ← payload 는 *이미 JSON* 이라 escape 없이 raw 삽입
|
||||
+ "}";
|
||||
}
|
||||
```
|
||||
`payload` 는 이미 직렬화된 JSON 이라 그대로(이중 인코딩 방지), 나머지 문자열은 `escape()`(RFC 8259 제어문자). 스켈레톤은 `jackson-databind` 를 안 싣는다.
|
||||
|
||||
**④ on/off 게이팅 — 같은 플래그가 real ↔ Disabled 빈을 교체:**
|
||||
|
||||
```java
|
||||
// 📄 messaging/kafka/KafkaAdapterConfig.java:30-41 — @ConditionalOnProperty 한 쌍
|
||||
@Bean @ConditionalOnProperty(name="app.messaging.kafka.enabled", havingValue="true", matchIfMissing=false)
|
||||
public MessagePublisher kafkaMessagePublisher(...) { return new KafkaMessagePublisher(...); }
|
||||
|
||||
@Bean @ConditionalOnProperty(name="app.messaging.kafka.enabled", havingValue="false", matchIfMissing=true)
|
||||
public MessagePublisher disabledMessagePublisher() { return new DisabledMessagePublisher(); }
|
||||
```
|
||||
플래그 하나로 *정확히 하나*의 빈만 등록된다. 꺼지면(기본) `DisabledMessagePublisher` 가 올라가, 누가 실수로 호출하면 `AdapterDisabledException("kafka")` 를 던진다(Layer 3 — Layer 1 게이팅이 뚫렸을 때의 최후 방어선).
|
||||
|
||||
**설정값:** `app.messaging.kafka.enabled`(false) — 실시간/릴레이 두 포트를 *한 플래그*로 동시 제어. `app.messaging.kafka.brokers`(켜면 CSV `host:port` 필수, regex 검증).
|
||||
|
||||
> 🧑🏫 **한마디:** "같은 Kafka 인데 왜 한쪽은 삼키고 한쪽은 던지나?"는 단골 질문. 답: **누가 그 실패를 책임지느냐**. 실시간은 Outbox 가 책임지니 삼켜도 되고, 릴레이는 *자기가* 마지막 책임자라 던져 재시도/경보로 이어가야 한다.
|
||||
|
||||
<details><summary>✅ 이해 점검</summary>
|
||||
|
||||
1. 브로커 순단 시 두 발행자의 동작 차이를 *코드 한 줄*로? (catch 가 `throw` 로 끝나는가)
|
||||
2. `app.messaging.kafka.enabled` 미설정 시 어떤 빈이 등록되고 호출하면? (Disabled* → AdapterDisabledException)
|
||||
3. `OutboxEnvelopeJson` 이 `payload` 만 escape 안 하는 이유? (이미 JSON → 이중 인코딩 방지)
|
||||
</details>
|
||||
|
||||
---
|
||||
|
||||
## §19. [심화] 더 깊이 — 이 코드 *밖*의 5가지 (면접 천장 뚫기)
|
||||
|
||||
> 여기부터는 ca-tmpl 에 **구현되어 있지 않은** 주제다(skeleton 범위 밖). 면접에서 "그 다음은?"으로 꼬리를 물 때 막히지 않도록 *왜 이 코드엔 없고, 있으면 어떻게 되는지*만 정리한다. (canonical 프로젝트 사실 아님 — 일반 지식 + 이 설계와의 연결.)
|
||||
|
||||
**1. Bulkhead(동시성 격리) — 지금 빠진 가장 큰 구멍.** 서킷·타임아웃은 있지만 *동시 호출 수 제한*이 없다. 동기 RestClient 는 호출당 스레드를 점유하므로, 한 의존성이 느려지면 서킷이 *열리기 전까지* 호출 스레드가 무더기로 묶인다. Resilience4j `Bulkhead`(세마포어/스레드풀)로 "이 의존성엔 동시 N개까지"를 막아야 완전하다. → *"타임아웃+서킷은 '오래 걸리는 것'을, Bulkhead 는 '한꺼번에 많은 것'을 막습니다."*
|
||||
|
||||
**2. Idempotency-Key 프로토콜 — POST 재시도의 진짜 해법.** 지금은 "POST 재시도 전면 금지"로 *회피*한다(§7 관문2). 진짜는: 클라이언트가 요청마다 고유 키(UUID)를 만들어 *재전송 시 동일 키 유지* → 서버가 그 키로 중복을 제거. 그러면 POST 도 안전하게 재시도 가능. → *"멱등 키 계약이 서면 비멱등 메서드도 재시도할 수 있습니다. 지금은 그 계약이 없어 보수적으로 막은 겁니다."*
|
||||
|
||||
**3. 재시도 예산(retry budget) — retry storm 방지.** per-call deadline(§7)은 *한 요청*의 재시도만 제한한다. *서비스 전역*으로 "전체 요청의 N%만 재시도 허용"하는 상한(Google SRE retry budget)은 없다. 장애 시 모두가 재시도하면 트래픽이 증폭돼 상대를 더 무너뜨린다(retry storm). → *"deadline 은 한 건을, retry budget 은 전체를 지킵니다."*
|
||||
|
||||
**4. 분산추적 샘플링 — traceparent `00` 의 실체.** §11 에서 샘플링 비트가 `00`(not-sampled) 하드코딩이라 했다. 실무는 head-based(시작 시 결정) vs tail-based(끝나고 느린 것만) 샘플링 + 부모 결정 전파(ParentBased)가 일관돼야 한다. 지금은 그게 없어 *수집이 안 된다.* OpenTelemetry SDK 로 교체 필요. → *"추적 헤더는 붙지만 샘플링 결정이 죽어 있어, 실제 백엔드 연동 전엔 트레이스가 안 모입니다."*
|
||||
|
||||
**5. 커넥션 풀 / HTTP/2 멀티플렉싱.** JDK `HttpClient` 의 connection pool·executor 를 *명시 설정하지 않아* 기본값에 의존한다(skeleton 한계). HTTP/2 면 한 커넥션에 다중 스트림이 흐르는데, 이때 head-of-line blocking 과 read-timeout 의 상호작용이 미묘하다. 동시성 상한은 결국 timeout+서킷으로 *간접* 보호될 뿐 명시적 풀 튜닝은 없다. → *"커넥션 재사용·풀 사이즈는 아직 기본값이라 고부하에선 별도 튜닝이 필요합니다."*
|
||||
|
||||
> 🧑🏫 **한마디:** 1~3 은 "구현된 것의 *다음 단계*", 4~5 는 "스켈레톤이라 *기본값에 맡긴* 부분". 면접에서 이걸 *먼저* "여기까진 했고, 다음은 Bulkhead/멱등키/재시도예산입니다"로 말하면 천장이 아니라 로드맵이 된다.
|
||||
|
||||
---
|
||||
|
||||
## 그래서 어떤 문제로 "정의" 했나
|
||||
|
||||
`ca-tmpl` 은 연동 문제를 **"외부 시스템의 장애·지연이 우리 서버의 스레드 잠식이나 데이터 정합성 훼손으로 전이되지 않도록 강력한 완충 경계(Isolation Boundary)를 강제"** 로 정의했다. 그래서:
|
||||
|
||||
- **물리 리소스 제약:** `bufferedClient`/`streamingClient` 격리 + `ResponseSizeBoundingInterceptor` 로 힙 통제.
|
||||
- **시간 예산:** connect/read 외에 deadline 예산으로 재시도 무한 대기 차단.
|
||||
- **Optional 빈 게이팅 & 센티넬:** 비활성 의존성 호출 시 `AdapterDisabledException` 으로 기동 거부(Layer 3).
|
||||
|
||||
### 실제 구현·검증 범위 (`locally-verified`)
|
||||
|
||||
`adapter-outbound` 모듈에 구현되어 있고 단위 테스트로 검증됨(prod 배포·측정 없음): `OutboundHttpClientTest`(재시도/CB/셧다운/사이즈), `OutboundHttpErrorMapperTest`(예외 매핑), `OutboundHttpResilienceTest/ConfigTest`, `OutboundRetryPolicyTest`, `OutboundHttpShutdownGuardTest`, `OutboundHttpSettingsTest`, `TraceContextPropagationInterceptorTest` 등.
|
||||
(상세는 canonical [[wiki/projects/ca-tmpl/data-layer-persistence-cache-outbound.md]] §Outbound HTTP Client.)
|
||||
|
||||
> [!WARNING]
|
||||
> traceparent 샘플링 비트 `00` 하드코딩(§11). 실제 분산추적은 OpenTelemetry/Micrometer Tracing 으로 교체 필요.
|
||||
> §12 의 CB 데코 주석 모순은 *코드* 결함 — 소유 브랜치에서 정정 필요.
|
||||
|
||||
---
|
||||
|
||||
## 자가 점검 — 다시 처음 장면으로
|
||||
|
||||
1. **[멱등성]** `Idempotency-Key` 명세가 없는 `POST /payments` 에 재시도를 켜두면, 어느 안전장치(어느 관문)가 거부하나? (§7 관문2)
|
||||
2. **[캐시 Fail-Open]** Redis 완전 다운 시 홈 API 호출 → `FailOpenCacheStore` 내부에서 무슨 일이? 컨트롤러가 받는 최종 예외는? (예외 없음 → DB fallback, §17)
|
||||
3. **[우아한 종료]** SIGTERM 시 `SmartLifecycle` 대신 `ContextClosedEvent` 로 깃발을 세우면 어떤 비결정 순서 오류가? (§6)
|
||||
4. **[아웃박스]** 브로커 순단 시 `KafkaMessagePublisher`(실시간) vs `KafkaOutboxMessagePublishAdapter`(릴레이) 가 각각 왜 삼킴/전파를 택하나? (§18)
|
||||
5. **[대용량]** 100MB CSV 를 `get(uri, Class)` 로 받으면 무슨 에러? 우회 API 는? (size 예외 → `stream()`, §9)
|
||||
6. **[서킷-재시도]** 한 호출이 3번 재시도 끝에 실패했다. CB 윈도우엔 실패 몇 건? (1건 — §12)
|
||||
|
||||
---
|
||||
|
||||
## Sources (이 설명의 출처 — 모두 canonical)
|
||||
|
||||
- [[wiki/concepts/fail-open-fail-closed.md]] — 실패 처리 설계 철학 및 트레이드오프
|
||||
- [[wiki/concepts/idempotency.md]] — RFC 9110 HTTP 멱등성 및 재시도 게이트
|
||||
- [[wiki/concepts/circuit-breaker.md]] — 서킷 브레이커 FSM 상태 전이 및 저카디널리티 지표 필터링
|
||||
- [[wiki/concepts/outbox-pattern.md]] — 트랜잭셔널 아웃복스 및 릴레이의 정합성 보장
|
||||
- [[wiki/concepts/distributed-tracing-baggage.md]] — MDC 트레이싱 전파와 배기지 보안 필터
|
||||
- [[wiki/concepts/spring-smart-lifecycle.md]] — SmartLifecycle 을 통한 Graceful Shutdown
|
||||
- [[wiki/projects/ca-tmpl/data-layer-persistence-cache-outbound.md]] — **§Outbound HTTP Client: 코드 사실 SSOT** (입출력·예외 매핑·자료구조·Spring 메커니즘·설정·검증, `locally-verified`)
|
||||
- [[wiki/projects/ca-tmpl/config-and-adapter-templates.md]] — adapter on/off 게이팅 결정
|
||||
@@ -0,0 +1,18 @@
|
||||
---
|
||||
title: (강사 설명) adapter-persistence 모듈
|
||||
source_type: explainer
|
||||
status: raw
|
||||
confidence: unknown
|
||||
tags: [explainer, ca-tmpl, persistence]
|
||||
related_projects: [ca-tmpl]
|
||||
last_reviewed:
|
||||
---
|
||||
|
||||
# (강사 설명) adapter-persistence 모듈
|
||||
|
||||
> Layer: `wiki/explainer/` — **derived(파생) 교육 문서.** 개인 이해용이며 외부 공개 대상이 아니다.
|
||||
> 사실·근거·검증 등급은 여기서 만들지 않고 canonical 에서 가져온다.
|
||||
|
||||
**아직 작성되지 않은 스텁입니다.** `[[wiki/explainer/adapter-outbound]]` 와 같은 ca-tmpl 모듈별
|
||||
설명 시리즈의 자리만 잡아둔 상태이며, 본문은 canonical(`[[wiki/projects/ca-tmpl]]`)을 경유해
|
||||
작성해야 합니다.
|
||||
@@ -0,0 +1,18 @@
|
||||
---
|
||||
title: (강사 설명) adapter-web 모듈
|
||||
source_type: explainer
|
||||
status: raw
|
||||
confidence: unknown
|
||||
tags: [explainer, ca-tmpl, api-design]
|
||||
related_projects: [ca-tmpl]
|
||||
last_reviewed:
|
||||
---
|
||||
|
||||
# (강사 설명) adapter-web 모듈
|
||||
|
||||
> Layer: `wiki/explainer/` — **derived(파생) 교육 문서.** 개인 이해용이며 외부 공개 대상이 아니다.
|
||||
> 사실·근거·검증 등급은 여기서 만들지 않고 canonical 에서 가져온다.
|
||||
|
||||
**아직 작성되지 않은 스텁입니다.** `[[wiki/explainer/adapter-outbound]]` 와 같은 ca-tmpl 모듈별
|
||||
설명 시리즈의 자리만 잡아둔 상태이며, 본문은 canonical(`[[wiki/projects/ca-tmpl]]`)을 경유해
|
||||
작성해야 합니다.
|
||||
@@ -0,0 +1,18 @@
|
||||
---
|
||||
title: (강사 설명) application-core 모듈
|
||||
source_type: explainer
|
||||
status: raw
|
||||
confidence: unknown
|
||||
tags: [explainer, ca-tmpl, application]
|
||||
related_projects: [ca-tmpl]
|
||||
last_reviewed:
|
||||
---
|
||||
|
||||
# (강사 설명) application-core 모듈
|
||||
|
||||
> Layer: `wiki/explainer/` — **derived(파생) 교육 문서.** 개인 이해용이며 외부 공개 대상이 아니다.
|
||||
> 사실·근거·검증 등급은 여기서 만들지 않고 canonical 에서 가져온다.
|
||||
|
||||
**아직 작성되지 않은 스텁입니다.** `[[wiki/explainer/adapter-outbound]]` 와 같은 ca-tmpl 모듈별
|
||||
설명 시리즈의 자리만 잡아둔 상태이며, 본문은 canonical(`[[wiki/projects/ca-tmpl]]`)을 경유해
|
||||
작성해야 합니다.
|
||||
@@ -0,0 +1,18 @@
|
||||
---
|
||||
title: (강사 설명) domain-core 모듈
|
||||
source_type: explainer
|
||||
status: raw
|
||||
confidence: unknown
|
||||
tags: [explainer, ca-tmpl, architecture]
|
||||
related_projects: [ca-tmpl]
|
||||
last_reviewed:
|
||||
---
|
||||
|
||||
# (강사 설명) domain-core 모듈
|
||||
|
||||
> Layer: `wiki/explainer/` — **derived(파생) 교육 문서.** 개인 이해용이며 외부 공개 대상이 아니다.
|
||||
> 사실·근거·검증 등급은 여기서 만들지 않고 canonical 에서 가져온다.
|
||||
|
||||
**아직 작성되지 않은 스텁입니다.** `[[wiki/explainer/adapter-outbound]]` 와 같은 ca-tmpl 모듈별
|
||||
설명 시리즈의 자리만 잡아둔 상태이며, 본문은 canonical(`[[wiki/projects/ca-tmpl]]`)을 경유해
|
||||
작성해야 합니다.
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 672 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 606 KiB |
@@ -0,0 +1,18 @@
|
||||
---
|
||||
title: (강사 설명) shared-contract 모듈
|
||||
source_type: explainer
|
||||
status: raw
|
||||
confidence: unknown
|
||||
tags: [explainer, ca-tmpl, architecture]
|
||||
related_projects: [ca-tmpl]
|
||||
last_reviewed:
|
||||
---
|
||||
|
||||
# (강사 설명) shared-contract 모듈
|
||||
|
||||
> Layer: `wiki/explainer/` — **derived(파생) 교육 문서.** 개인 이해용이며 외부 공개 대상이 아니다.
|
||||
> 사실·근거·검증 등급은 여기서 만들지 않고 canonical 에서 가져온다.
|
||||
|
||||
**아직 작성되지 않은 스텁입니다.** `[[wiki/explainer/adapter-outbound]]` 와 같은 ca-tmpl 모듈별
|
||||
설명 시리즈의 자리만 잡아둔 상태이며, 본문은 canonical(`[[wiki/projects/ca-tmpl]]`)을 경유해
|
||||
작성해야 합니다.
|
||||
@@ -0,0 +1,220 @@
|
||||
---
|
||||
title: (강사 설명) 트랜잭션 경계를 어디에 둘 것인가 — 5가지 답이 갈리는 진짜 이유
|
||||
source_type: explainer
|
||||
status: draft
|
||||
confidence: medium
|
||||
tags: [transaction, clean-architecture, spring]
|
||||
related_projects: [ca-skeleton]
|
||||
last_reviewed: 2026-06-04
|
||||
---
|
||||
|
||||
# (강사 설명) 트랜잭션 경계를 어디에 둘 것인가 — 5가지 답이 갈리는 진짜 이유
|
||||
|
||||
> Layer: `wiki/explainer/` — **derived(파생) 교육 문서.** "나의 진짜 이해" 를 위한 1타강사 칠판이다.
|
||||
> 정확한 사실·근거·검증 등급은 여기서 만들지 않는다. 전부 아래 canonical 에서 가져온다:
|
||||
> - 개념·대안·근거: [[wiki/concepts/transaction-boundary-abstraction]]
|
||||
> - 내 프로젝트 실제 구현·검증 범위: [[wiki/projects/ca-tmpl/transaction-boundary-abstraction]]
|
||||
>
|
||||
> 이 문서의 **비유는 의도적으로 부정확**하다 (이해를 위한 단순화). 비유를 사실로 인용하지 마라. 면접에서 말할 땐 위 canonical 의 표현을 써라.
|
||||
|
||||
---
|
||||
|
||||
## 0. 한 장면 — 5초 만에 고통 느끼기
|
||||
|
||||
`PostService.createPost()` 안에서 DB 에 두 번 쓴다.
|
||||
|
||||
```text
|
||||
1) posts 테이블에 글 한 줄 INSERT ← 성공
|
||||
2) tags 테이블에 태그 세 줄 INSERT ← 여기서 예외 펑!
|
||||
```
|
||||
|
||||
자, 1번은 이미 커밋됐고 2번은 터졌다. 결과는? **태그 없는 반쪽짜리 글**이 DB 에 영원히 남는다. 누구도 지워주지 않는다.
|
||||
|
||||
이걸 막는 게 트랜잭션이다. "1번과 2번은 **한 묶음**. 둘 다 되든가, 둘 다 없던 일이 되든가." 은행 송금이랑 똑같다 — 내 계좌 -1만 원, 상대 계좌 +1만 원, 중간에 멈추면 돈이 증발한다. 그래서 "다 되거나 다 취소(rollback)" 로 묶는다.
|
||||
|
||||
**여기까진 아무도 이견이 없다.** 진짜 싸움은 다음 한 줄에서 시작된다:
|
||||
|
||||
> "그래서 이 '한 묶음' 의 시작과 끝을, **코드 어디에, 누가, 어떻게** 선언하지?"
|
||||
|
||||
5가지 답이 있다. 그리고 답이 갈리는 이유는 — 곧 보겠지만 — 사람마다 **무엇이 문제인지 자체가 다르기 때문**이다.
|
||||
|
||||
---
|
||||
|
||||
## 1. 진짜 문제는 무엇인가 — 모든 대안이 싸우는 단 하나의 축
|
||||
|
||||
트랜잭션은 비즈니스 로직이 아니다. "글을 쓴다" 는 비즈니스고, "이걸 한 묶음으로 처리해라" 는 **인프라 관심사(infrastructure concern)** 다. DB 라는 기계를 다루는 기술적 약속이지, 도메인 규칙이 아니다.
|
||||
|
||||
그래서 모든 대안이 답하려는 질문은 결국 **하나의 축** 위에 있다:
|
||||
|
||||
> **인프라 관심사인 '트랜잭션 경계' 를, 비즈니스 핵심 코드에서 얼마나 떼어낼 것인가?**
|
||||
|
||||
```text
|
||||
분리 0% ─────────────────────────────────────────────► 분리 100%
|
||||
"핵심 코드에 그냥 붙여" "핵심 코드는 트랜잭션을 몰라야 해"
|
||||
|
||||
대안1 대안2 대안4 대안3 대안5
|
||||
@Transactional Template Interceptor Functional TransactionPort
|
||||
직접 부착 명령형 커스텀 AOP monad (port 추상화)
|
||||
```
|
||||
|
||||
축의 **왼쪽 끝** 신념: "분리? 그거 다 오버엔지니어링이야. 트랜잭션 경계가 코드에 **눈으로 보이는 게** 제일 중요해."
|
||||
축의 **오른쪽 끝** 신념: "비즈니스 핵심은 Spring 이든 뭐든 **프레임워크를 몰라야 해**. 그래야 갈아끼우고 테스트하기 좋아."
|
||||
|
||||
5개 대안은 이 축 위 서로 다른 지점에 점을 찍은 것뿐이다. **누가 맞고 틀린 게 아니라, 무엇을 더 두려워하는지가 다른 것이다.** 이제 한 명씩 그 사람 입장이 되어보자.
|
||||
|
||||
---
|
||||
|
||||
## 2. 대안들 = 문제를 "다르게 정의한" 답 ★이 문서의 심장★
|
||||
|
||||
각 대안을 똑같은 5단으로 본다:
|
||||
**(a) 이 사람이 본 문제 → (b) 핵심 직관 → (c) 왜 이게 그 문제를 푸는가(끝까지) → (d) 언제 맞고 어디서 깨지나 → (e) 근거.**
|
||||
|
||||
---
|
||||
|
||||
### 대안 1. `@Transactional` 직접 부착 — "경계는 *눈에 보이는 곳*에 둬" (다수파)
|
||||
|
||||
**(a) 이 사람이 본 문제:**
|
||||
"트랜잭션이 어디서 시작하고 끝나는지, 코드를 열었을 때 **그 자리에서 바로** 보여야 한다. 한 겹 추상화를 끼우면 그 가시성이 사라진다. 추상화는 비용이고, 나는 그 비용을 낼 이유가 없다."
|
||||
|
||||
**(b) 핵심 직관:**
|
||||
메서드 위에 붙인 `@Transactional` 은 **형광펜**이다. "여기부터 여기까지 한 묶음" 이라고 코드에 직접 칠해두는 표시.
|
||||
|
||||
**(c) 왜 이게 문제를 푸는가 (끝까지):**
|
||||
Spring 이 이 형광펜을 발견하면, 네 객체를 그대로 안 쓰고 **대역(proxy) 객체**를 하나 만든다. 대역은 네 메서드를 부르기 *직전*에 몰래 `BEGIN`(트랜잭션 시작) 을 끼우고, 무사히 끝나면 `COMMIT`, 예외가 터지면 `ROLLBACK` 을 대신 해준다. 그래서 너는 트랜잭션 코드를 **한 줄도 안 쓴다.** 형광펜만 칠하면 끝.
|
||||
→ **그런데** 이 마법은 "대역을 거쳐야만" 작동한다. 만약 같은 클래스 안에서 `this.otherMethod()` 처럼 내 메서드를 *직접* 부르면? 대역을 안 거치고 진짜 객체를 바로 부른다 → 형광펜이 **그냥 무시된다(self-invocation 함정).** 분명 `@Transactional` 을 붙였는데 트랜잭션이 안 걸리는 미스터리가 여기서 나온다.
|
||||
|
||||
> **강사의 한마디:** 이 함정은 "치명적 결함" 이 아니라 "알면 피하는 함정" 이다. self-injection, public 메서드 분리, 별도 bean 으로 빼기 — 표준 우회가 여러 개 있고 수많은 프로덕션이 이걸로 잘 돌아간다. "AOP 는 self-invocation 때문에 깨진다" 고 *단정하면 과장*이다.
|
||||
|
||||
**(d) 언제 맞나 / 어디서 깨지나:**
|
||||
- ✅ 맞다: 단순 CRUD 위주, 프레임워크 바꿀 계획 없음, 팀이 Spring 에 익숙. → 형광펜의 가시성이 추상화 비용보다 명백히 이득.
|
||||
- ❌ 깨진다: "비즈니스 핵심 코드는 프레임워크를 import 하면 안 된다" 는 규칙(Clean Architecture)을 세운 순간. 형광펜을 칠하려면 `org.springframework...Transactional` 을 import 해야 하는데, **그 import 자체가 규칙 위반**이 된다. (대안 5 의 출발점이 바로 여기다.)
|
||||
|
||||
**(e) 근거:** [[wiki/concepts/transaction-boundary-abstraction]] 대안 1 · self-invocation = claim `AT-TX-C5`. 다수파라는 증거(Buckpal·Reflectoring) 도 같은 문서 Claim-backed 표 참조.
|
||||
|
||||
---
|
||||
|
||||
### 대안 2. `TransactionTemplate` 명령형 — "마법 말고 *내 손으로* 묶을게"
|
||||
|
||||
**(a) 이 사람이 본 문제:**
|
||||
"형광펜(annotation)은 *선언*일 뿐, 실제 실행은 보이지 않는 대역(proxy)이 한다. 그 보이지 않는 마법과 self-invocation 함정이 싫다. 트랜잭션 시작·끝을 **내가 쓴 코드로 명시적으로** 보고 싶다."
|
||||
|
||||
**(b) 핵심 직관:**
|
||||
형광펜 대신 **직접 괄호를 친다.** `template.execute(status -> { ...여기 안이 한 묶음... })`. 묶음의 시작과 끝이 중괄호로 눈에 보인다.
|
||||
|
||||
**(c) 왜 이게 문제를 푸는가 (끝까지):**
|
||||
`execute(...)` 를 부르는 순간 그 자리에서 진짜로 `BEGIN` 이 실행되고, 람다가 끝나면 `COMMIT`, 예외면 `ROLLBACK`. **프록시 대역이 없다.** 내가 직접 부른 메서드 안에서 시작하므로 self-invocation 함정도 원천적으로 없다. 경계가 "선언" 이 아니라 "실행되는 코드 한 줄" 이 됐다.
|
||||
→ **그런데** 대가가 있다. 묶고 싶은 use case 마다 `template.execute(...)` 보일러플레이트를 반복해서 써야 한다. 그리고 결정적으로 — 이 `TransactionTemplate` 클래스 역시 `org.springframework...` 소속이다. 즉 **비즈니스 코드가 여전히 Spring 을 직접 안고 있다.** 가시성·함정 문제는 풀었지만 "프레임워크 분리" 축에서는 대안 1 과 같은 자리다.
|
||||
|
||||
**(d) 언제 맞나 / 어디서 깨지나:**
|
||||
- ✅ 맞다: self-invocation 같은 AOP 함정을 확실히 피하고 싶고, 트랜잭션 경계를 코드로 또렷이 보고 싶을 때.
|
||||
- ❌ 깨진다: 보일러플레이트가 늘어나는 게 싫을 때 / "프레임워크 import 금지" 규칙이 있을 때 (여전히 Spring 클래스 import).
|
||||
|
||||
**(e) 근거:** [[wiki/concepts/transaction-boundary-abstraction]] 대안 2 · Spring 공식 programmatic API.
|
||||
|
||||
---
|
||||
|
||||
### 대안 3. 함수형 Resource/monad (예: Arrow Kt) — "트랜잭션을 *값* 으로 만들어"
|
||||
|
||||
**(a) 이 사람이 본 문제:**
|
||||
"트랜잭션은 '효과(effect)' 다. 효과를 숨겨진 마법(proxy)이나 명령형 괄호로 다루지 말고, **타입으로 드러내서 합성** 하고 싶다. 그래야 컴파일러가 검증해주고, 순수 함수처럼 테스트할 수 있다."
|
||||
|
||||
**(b) 핵심 직관:**
|
||||
트랜잭션을 *행동* 이 아니라 **레시피(값)** 로 본다. "이 작업은 트랜잭션이 필요함" 이라는 사실이 **타입에 적혀** 따라다닌다. 레시피들을 레고처럼 합쳐서 마지막에 한 번 실행한다.
|
||||
|
||||
**(c) 왜 이게 문제를 푸는가 (끝까지):**
|
||||
효과가 타입에 드러나면, "이 함수는 트랜잭션 안에서 돌아야 한다" 를 **컴파일 타임에** 강제할 수 있다 → 실행은 순수 함수 합성이라, Spring context 같은 무거운 환경 없이 검증 가능 → testability 가 최고로 올라간다.
|
||||
→ **그런데** 이건 사고방식 자체가 다르다. Java 위주 Spring 팀에게 monad/패턴 매칭/함수 합성은 **학습 절벽**이다. 게다가 Spring 이 공짜로 주던 propagation·isolation 의미를 monad 위에 직접 다시 구현해야 할 때도 있다. 강력하지만 비싸다.
|
||||
|
||||
**(d) 언제 맞나 / 어디서 깨지나:**
|
||||
- ✅ 맞다: 팀이 이미 함수형(Kotlin/Arrow 등)에 능하고, 효과를 타입으로 다루는 가치를 아는 경우.
|
||||
- ❌ 깨진다: 평범한 Java/Spring 팀. 도입 비용이 이득을 압도한다. "함수형이 테스트에 항상 우월" 은 *과장* — 팀 역량/언어/기존 코드가 비용을 결정한다.
|
||||
|
||||
**(e) 근거:** [[wiki/concepts/transaction-boundary-abstraction]] 대안 3 (Arrow Kt Resource).
|
||||
|
||||
---
|
||||
|
||||
### 대안 4. 커스텀 `TransactionInterceptor` (AOP) — "마법은 좋아, 근데 *내 마법*으로"
|
||||
|
||||
**(a) 이 사람이 본 문제:**
|
||||
"annotation 기반 마법(대안 1)의 편리함은 좋다. 하지만 표준 `@Transactional` 은 트랜잭션만 한다. 나는 트랜잭션 *경계에서* 추가 정책 — 예를 들어 권한(capability) 검증 — 을 같이 끼우고 싶다."
|
||||
|
||||
**(b) 핵심 직관:**
|
||||
대안 1 의 형광펜을 **내가 직접 만든 형광펜**으로 바꾼다. 내 annotation, 내 interceptor → 묶음의 시작·끝에 내가 원하는 로직을 추가로 끼워넣는다.
|
||||
|
||||
**(c) 왜 이게 문제를 푸는가 (끝까지):**
|
||||
내 interceptor 가 메서드 호출을 가로채니, `BEGIN`/`COMMIT` 사이에 커스텀 정책을 자유롭게 주입할 수 있다 → 트랜잭션 + 정책을 한 곳에서 다룬다.
|
||||
→ **그런데** 이건 결국 대안 1 과 같은 AOP 기반이다. **self-invocation 함정 그대로 상속**한다. interceptor 구현 자체가 Spring AOP 에 의존하고, `@TransactionalEventListener` 같은 표준 도구와의 호환을 내가 직접 챙겨야 한다. "마법을 커스터마이즈" 한 대가로 표준이 주던 보장을 일부 떠안는다.
|
||||
|
||||
**(d) 언제 맞나 / 어디서 깨지나:**
|
||||
- ✅ 맞다: 트랜잭션 경계에 *정말로* 횡단 정책을 묶어야 하는 특수 요구가 있을 때.
|
||||
- ❌ 깨진다: 그냥 트랜잭션만 필요한데 이걸 쓰면 — AOP 함정 + 호환성 부담만 떠안는 오버엔지니어링.
|
||||
|
||||
**(e) 근거:** [[wiki/concepts/transaction-boundary-abstraction]] 대안 4 (custom interceptor 사례).
|
||||
|
||||
---
|
||||
|
||||
### 대안 5. `TransactionPort` 추상화 — "핵심 코드는 *트랜잭션이 뭔지도 몰라야 해*" (소수파, ca-tmpl 채택)
|
||||
|
||||
**(a) 이 사람이 본 문제:**
|
||||
"내 비즈니스 핵심(application layer)은 **Spring 의 존재 자체를 몰라야 한다.** 그래야 (1) 프레임워크를 갈아끼워도 핵심이 안 흔들리고, (2) use case 를 Spring context 없이 가볍게 단위 테스트할 수 있다. 트랜잭션이라는 인프라 관심사도 예외 없이 이 규칙을 따라야 한다."
|
||||
|
||||
**(b) 핵심 직관:**
|
||||
핵심 코드에는 **콘센트 구멍(interface)** 만 뚫어둔다 — `tx.inWrite(() -> { ... })`. 이 구멍은 "트랜잭션으로 묶어줘" 라고 *요청* 만 할 뿐, **어떻게** 묶는지는 모른다. 진짜 Spring 플러그(`SpringTransactionPort`)는 바깥 어댑터 계층에서 꽂는다. 핵심은 콘센트 규격만 알고, 전기 회사가 한전인지 아닌지는 모른다.
|
||||
|
||||
**(c) 왜 이게 문제를 푸는가 (끝까지):**
|
||||
application 은 자기가 만든 `TransactionPort` 인터페이스만 import 한다 → `org.springframework...` 가 비즈니스 코드에서 **완전히 사라진다** → Clean Architecture 의 "의존성은 안쪽(핵심)으로만" 규칙을 트랜잭션 경계까지 지킨다 → 테스트에선 진짜 Spring 대신 **가짜(fake) port** 를 꽂아 "경계가 제대로 선언됐나" 를 Spring context 없이 검증한다.
|
||||
→ **그런데** 공짜가 아니다. port 인터페이스 추가 + 어댑터 구현체 추가 + "propagation/isolation 을 port 시그니처에 어떻게 드러낼까" 라는 설계 결정 비용이 든다. 그리고 이건 **소수파**다 — 유명한 hexagonal 예제(Buckpal)나 Spring 공식 incubator(Modulith)조차 오히려 `@Transactional` 을 직접/메타로 부착한다. 즉 "추상화만이 정답" 이라고 말하면 *과장*이다.
|
||||
|
||||
**(d) 언제 맞나 / 어디서 깨지나:**
|
||||
- ✅ 맞다: "핵심은 프레임워크를 모른다" 를 **진짜 규칙으로 강제** 하려는 프로젝트 (skeleton/템플릿처럼 규율이 자산인 경우). 도메인 복잡도가 크고 testability 가 중요할 때.
|
||||
- ❌ 깨진다: 단순 CRUD 가 대부분이고 프레임워크 교체 계획도 없는데 이걸 쓰면 — 그냥 **오버엔지니어링.** 콘센트 한 겹이 가시성만 깎아먹는다.
|
||||
|
||||
**(e) 근거:** [[wiki/concepts/transaction-boundary-abstraction]] 대안 5 · 소수파 증거(Buckpal/Modulith는 반대 방향) Claim-backed 표 참조.
|
||||
|
||||
---
|
||||
|
||||
## 3. 그래서 나는 어떤 문제로 "정의" 했나
|
||||
|
||||
**ca-tmpl 이 대안 5(`TransactionPort`)를 고른 이유.**
|
||||
|
||||
여기가 핵심이다. ca-tmpl 이 `TransactionPort` 를 고른 건 "그게 제일 우월해서" 가 **아니다.** **내가 문제를 그렇게 정의했기 때문**이다.
|
||||
|
||||
ca-tmpl 은 **Clean Architecture skeleton 템플릿**이다. 이 프로젝트의 존재 이유 자체가 "규율(discipline)을 코드로 강제해서 남에게 물려주는 것" 이다. 그래서 나는 가장 먼저 이 규칙을 세웠다:
|
||||
|
||||
> **"application layer 는 Spring 을 import 하지 않는다."**
|
||||
|
||||
이 규칙을 세운 *순간*, 답은 거의 정해졌다. 대안 1·2·4 는 전부 `org.springframework...` import 를 요구하니 **규칙 위반**이다. 대안 3 은 팀 언어(Java)에 안 맞는다. 남는 건 대안 5. → **문제 정의가 답을 결정했다.**
|
||||
|
||||
내가 실제로 한 것 (검증된 사실만 — 자세히는 [[wiki/projects/ca-tmpl/transaction-boundary-abstraction]]):
|
||||
|
||||
- `TransactionPort` 인터페이스: `inWrite` / `inRead` / `inNew` 3개. 인자를 `Supplier`/`Runnable` 로만 받는다 → checked exception 을 시그니처에 노출 안 함(설계 결정 D11).
|
||||
- `inNew` = `REQUIRES_NEW` = **새 물리 connection** 을 잡는다 → pool 을 소모하므로 loop 안에서 부르면 안 됨(D12). 비싼 도구라 명시적 케이스(outbox/audit)에만.
|
||||
- `Isolation` 은 `READ_COMMITTED` **한 값만** 노출 (나머지는 다른 브랜치로 위임 — 범위를 좁혀 결정 비용을 미룸).
|
||||
- 진짜 강제 장치: **ArchUnit fitness function** 이 application 패키지에서 `@Transactional` import 를 발견하면 **테스트를 깨뜨린다.** 규칙이 문서가 아니라 빌드 게이트가 됐다.
|
||||
|
||||
**검증은 어디까지?** JVM 단위 테스트 + 정적 분석(ArchUnit)까지. **실 DB 통합 테스트도, 운영 배포도 없다.** 그러니 면접에서 "운영에서 검증했다", "실 DB 로 전파를 측정했다" 고 말하면 **거짓말**이다. (과장 금지 전체 목록: project 문서 §과장 금지)
|
||||
|
||||
> **강사의 결론:** 누가 "왜 그냥 `@Transactional` 안 썼어요? 그게 표준인데" 라고 물으면, 정답은 "추상화가 우월해서" 가 **아니라** 이렇게 답해야 한다 —
|
||||
> *"제 프로젝트의 문제 정의가 '핵심은 프레임워크를 모른다' 였습니다. 그 규칙을 세운 순간 `@Transactional` import 는 위반이 됩니다. 만약 단순 CRUD 서비스였다면 저도 `@Transactional` 을 직접 붙였을 겁니다. 문제 정의가 다르면 답도 다릅니다."*
|
||||
> 이게 "대안을 안다" 의 진짜 의미다.
|
||||
|
||||
---
|
||||
|
||||
## 4. 다시 처음 장면으로 — 원리를 곱씹는 자가 점검
|
||||
|
||||
0번의 그 장면(반쪽짜리 글)으로 돌아가자. 이제 너는 단순히 "트랜잭션 걸면 됨" 이 아니라, **어디에 점을 찍을지** 를 물을 수 있어야 한다. 답을 보지 말고 스스로 재구성해봐라:
|
||||
|
||||
1. 만약 네가 지금 만드는 게 **사내 단순 게시판 CRUD** 라면, 위 축에서 어느 점을 찍겠는가? 그 이유를 "두려워하는 것" 의 언어로 한 문장으로 말해봐라.
|
||||
2. 누가 "AOP `@Transactional` 은 self-invocation 때문에 깨지니까 쓰지 마세요" 라고 단정한다. 어디가 과장인가? 표준 우회를 하나라도 말할 수 있는가?
|
||||
3. 대안 2(`TransactionTemplate`)와 대안 5(`TransactionPort`)는 **둘 다 명시적 호출**이다. 그런데 분리 축에서 자리가 다르다. **무엇 하나** 때문에 갈리는가? (힌트: import 하는 클래스가 누구 소속인가)
|
||||
4. ca-tmpl 이 `Isolation` 을 `READ_COMMITTED` 한 값만 노출한 건 "결정을 미룬 것" 이다. 이게 왜 *나쁜 게으름이 아니라* 좋은 설계 판단일 수 있는가? (힌트: skeleton 의 목적 + 결정 비용)
|
||||
5. 한 단계 더: `inNew`(REQUIRES_NEW)를 for-loop 안에서 100번 부르면 무슨 일이 일어나는가? 왜 그게 D12 에서 금지됐는가? (힌트: 비유에서 콘센트가 아니라 "새 전선을 매번 새로 까는" 비용)
|
||||
|
||||
이 5개를 막힘없이 말로 설명할 수 있으면, 너는 이 주제를 "외운" 게 아니라 "이해한" 거다.
|
||||
|
||||
---
|
||||
|
||||
## Sources (이 설명의 출처 — 모두 canonical)
|
||||
|
||||
- [[wiki/concepts/transaction-boundary-abstraction]] — 5개 대안 정의 / Claim-backed 근거 / 과장 금지 (사실의 금고)
|
||||
- [[wiki/projects/ca-tmpl/transaction-boundary-abstraction]] — ca-tmpl 실제 구현 · 검증 범위 · 면접 가능 범위 (내 프로젝트 사실)
|
||||
@@ -0,0 +1,65 @@
|
||||
---
|
||||
title: 자동차·부품 / Auto
|
||||
source_type: invest-concept
|
||||
status: draft
|
||||
confidence: low
|
||||
tags: [invest-concept, field-card, sector]
|
||||
last_reviewed: 2026-06-08
|
||||
---
|
||||
|
||||
# 자동차·부품 / Auto
|
||||
|
||||
> Layer: `wiki/invest-concepts/` — 분야 카드. 모든 관계 행에 `[검증]/[가설]` 라벨 필수. `[가설]`은 외부 산출물 금지. 세무·투자 자문 아님 — [[wiki/invest-strategy/strategy]] §고지.
|
||||
|
||||
## Parent
|
||||
|
||||
- [[wiki/invest-concepts/field-map]]
|
||||
|
||||
## 한 줄 정의 / What it is
|
||||
|
||||
> 완성차·부품. 글로벌 판매·환율(수출)·전기차 전환에 민감. 원화 약세가 수출 마진에 우호적(가설).
|
||||
|
||||
## 무엇이 이걸 움직이나 / Drivers
|
||||
|
||||
| 요인 | 방향 | 메커니즘 | 검증/가설 | 근거 |
|
||||
|---|---|---|---|---|
|
||||
| 원화 약세 | 자동차 ↑ | 수출 마진 개선 | `[가설]` | 관찰 누적중 (→ `field-dollar`) |
|
||||
| 글로벌 판매 ↑ | 자동차 ↑ | 물량·실적 | `[가설]` | 관찰 누적중 |
|
||||
|
||||
## 연결 / Linkages
|
||||
|
||||
| 이게 ↑하면 | → 따라 | 메커니즘 | 검증/가설 | 근거 |
|
||||
|---|---|---|---|---|
|
||||
| 완성차 ↑ | 부품 소형주 ↑ | 공급망 낙수 | `[가설]` | 관찰 누적중 |
|
||||
|
||||
## 대장주 / 추종주 (Leaders & Followers)
|
||||
|
||||
> 대장주와 추종주. **종목 선정·동조 관계는 전부 `[가설]`** — `/invest-research`로 검증.
|
||||
|
||||
| 종목(티커) | 역할 | 왜(동조 근거) | 검증/가설 |
|
||||
|---|---|---|---|
|
||||
| 현대차 (005380) | 대장주 | 완성차 1위 | `[가설]` |
|
||||
| 기아 (000270) | 대장주 | 완성차 | `[가설]` |
|
||||
| 현대모비스 (012330) | 추종 | 핵심 부품·모듈 | `[가설]` |
|
||||
| 한온시스템 (018880) | 추종 | 공조 부품 | `[가설]` |
|
||||
| HL만도 (204320) | 추종 | 섀시·전장 | `[가설]` |
|
||||
|
||||
## 관찰 지표 / What to watch
|
||||
|
||||
- 현대차·기아 주가, USD/KRW, 글로벌 자동차 판매, 미국 금리(할부수요)
|
||||
|
||||
## 경기 사이클 위치 / Cycle position
|
||||
|
||||
- 원화 약세·글로벌 수요 회복기 강
|
||||
|
||||
## 검증 상태 / Verification
|
||||
|
||||
- `[검증]` 0개 · `[가설]` 8개
|
||||
|
||||
## Sources
|
||||
|
||||
- [[wiki/invest-strategy/strategy]]
|
||||
|
||||
## Related
|
||||
|
||||
- [[wiki/invest-concepts/field-dollar]]
|
||||
@@ -0,0 +1,56 @@
|
||||
---
|
||||
title: 빅테크 / AI (Big Tech)
|
||||
source_type: invest-concept
|
||||
status: draft
|
||||
confidence: low
|
||||
tags: [invest-concept, field-card, sector]
|
||||
last_reviewed: 2026-06-08
|
||||
---
|
||||
|
||||
# 빅테크 / AI (Big Tech)
|
||||
|
||||
> Layer: `wiki/invest-concepts/` — 분야 카드. 모든 관계 행에 `[검증]/[가설]` 라벨 필수. `[가설]`은 외부 산출물 금지. 세무·투자 자문 아님 — [[wiki/invest-strategy/strategy]] §고지.
|
||||
|
||||
## Parent
|
||||
|
||||
- [[wiki/invest-concepts/field-map]]
|
||||
|
||||
## 한 줄 정의 / What it is
|
||||
|
||||
> 빅테크/AI = S&P500 시총 상위를 차지하는 성장주군. 금리 민감 + AI 투자 사이클의 중심.
|
||||
|
||||
## 무엇이 이걸 움직이나 / Drivers
|
||||
|
||||
| 요인 | 방향 | 메커니즘 | 검증/가설 | 근거 |
|
||||
|---|---|---|---|---|
|
||||
| 금리 ↓ | 빅테크 ↑ | 먼 미래 현금흐름 할인 완화 | `[가설]` | 관찰 누적중 (→ `field-us-rates`) |
|
||||
| AI capex ↑ | 빅테크·반도체 ↑ | 투자 사이클 | `[가설]` | 관찰 누적중 (→ `field-semiconductors`) |
|
||||
|
||||
## 연결 / Linkages
|
||||
|
||||
| 이게 ↑하면 | → 따라 | 메커니즘 | 검증/가설 | 근거 |
|
||||
|---|---|---|---|---|
|
||||
| 빅테크 ↑ | S&P500 ↑ | 시총 비중 큼 | `[가설]` | 관찰 누적중 (→ `field-us-equity`) |
|
||||
| 빅테크 ↑ | 반도체 수요 ↑ | AI 인프라 | `[가설]` | 관찰 누적중 (→ `field-semiconductors`) |
|
||||
|
||||
## 관찰 지표 / What to watch
|
||||
|
||||
- 나스닥100, 매그니피센트7, AI capex 가이던스
|
||||
|
||||
## 경기 사이클 위치 / Cycle position
|
||||
|
||||
- 완화·확장기 강 / 금리 급등기 약
|
||||
|
||||
## 검증 상태 / Verification
|
||||
|
||||
- `[검증]` 0개 · `[가설]` 4개
|
||||
|
||||
## Sources
|
||||
|
||||
- [[wiki/invest-strategy/strategy]]
|
||||
|
||||
## Related
|
||||
|
||||
- [[wiki/invest-concepts/field-us-rates]]
|
||||
- [[wiki/invest-concepts/field-semiconductors]]
|
||||
- [[wiki/invest-concepts/field-us-equity]]
|
||||
@@ -0,0 +1,65 @@
|
||||
---
|
||||
title: 바이오·제약 / Bio & Pharma
|
||||
source_type: invest-concept
|
||||
status: draft
|
||||
confidence: low
|
||||
tags: [invest-concept, field-card, sector]
|
||||
last_reviewed: 2026-06-08
|
||||
---
|
||||
|
||||
# 바이오·제약 / Bio & Pharma
|
||||
|
||||
> Layer: `wiki/invest-concepts/` — 분야 카드. 모든 관계 행에 `[검증]/[가설]` 라벨 필수. `[가설]`은 외부 산출물 금지. 세무·투자 자문 아님 — [[wiki/invest-strategy/strategy]] §고지.
|
||||
|
||||
## Parent
|
||||
|
||||
- [[wiki/invest-concepts/field-map]]
|
||||
|
||||
## 한 줄 정의 / What it is
|
||||
|
||||
> 바이오/제약(CDMO·바이오시밀러·신약). 금리 민감 성장 섹터 + 임상 결과·기술수출 같은 종목 고유 이벤트가 큰 변동 요인.
|
||||
|
||||
## 무엇이 이걸 움직이나 / Drivers
|
||||
|
||||
| 요인 | 방향 | 메커니즘 | 검증/가설 | 근거 |
|
||||
|---|---|---|---|---|
|
||||
| 금리 ↓ | 바이오 ↑ | 성장주 할인 완화 | `[가설]` | 관찰 누적중 (→ `field-us-rates`) |
|
||||
| 임상 성공/FDA 승인 | 해당 종목 ↑ | 파이프라인 가치 | `[가설]` | 관찰 누적중 |
|
||||
| 기술수출(L/O) | 해당 종목 ↑ | 마일스톤 | `[가설]` | 관찰 누적중 |
|
||||
|
||||
## 연결 / Linkages
|
||||
|
||||
| 이게 ↑하면 | → 따라 | 메커니즘 | 검증/가설 | 근거 |
|
||||
|---|---|---|---|---|
|
||||
| 대형 바이오 ↑ | 신약 소형주 ↑ | 섹터 센티먼트 | `[가설]` | 관찰 누적중 |
|
||||
|
||||
## 대장주 / 추종주 (Leaders & Followers)
|
||||
|
||||
> 대장주와 추종주. **종목 선정·동조 관계는 전부 `[가설]`** — `/invest-research`로 검증. ⚠️ 바이오는 종목 고유 임상 리스크가 커서 동조성이 약할 수 있음(가설).
|
||||
|
||||
| 종목(티커) | 역할 | 왜(동조 근거) | 검증/가설 |
|
||||
|---|---|---|---|
|
||||
| 삼성바이오로직스 (207940) | 대장주 | CDMO 시총 1위 | `[가설]` |
|
||||
| 셀트리온 (068270) | 대장주 | 바이오시밀러 | `[가설]` |
|
||||
| 유한양행 (000100) | 추종 | 신약(렉라자) | `[가설]` |
|
||||
| 알테오젠 (196170) | 추종 | 플랫폼 기술수출 | `[가설]` |
|
||||
|
||||
## 관찰 지표 / What to watch
|
||||
|
||||
- 바이오 ETF, 美 바이오지수(XBI), 미 10Y 금리, 임상/FDA 뉴스
|
||||
|
||||
## 경기 사이클 위치 / Cycle position
|
||||
|
||||
- 저금리·위험선호기 강 / 금리 급등기 약
|
||||
|
||||
## 검증 상태 / Verification
|
||||
|
||||
- `[검증]` 0개 · `[가설]` 8개 (관찰 누적 → `/invest-research`로 승격)
|
||||
|
||||
## Sources
|
||||
|
||||
- [[wiki/invest-strategy/strategy]]
|
||||
|
||||
## Related
|
||||
|
||||
- [[wiki/invest-concepts/field-us-rates]]
|
||||
@@ -0,0 +1,54 @@
|
||||
---
|
||||
title: 비트코인 / BTC
|
||||
source_type: invest-concept
|
||||
status: draft
|
||||
confidence: low
|
||||
tags: [invest-concept, field-card, macro-asset]
|
||||
last_reviewed: 2026-06-08
|
||||
---
|
||||
|
||||
# 비트코인 / BTC
|
||||
|
||||
> Layer: `wiki/invest-concepts/` — 분야 카드. 모든 관계 행에 `[검증]/[가설]` 라벨 필수. `[가설]`은 외부 산출물 금지. 세무·투자 자문 아님 — [[wiki/invest-strategy/strategy]] §고지.
|
||||
|
||||
## Parent
|
||||
|
||||
- [[wiki/invest-concepts/field-map]]
|
||||
|
||||
## 한 줄 정의 / What it is
|
||||
|
||||
> 비트코인 = 고변동 위험자산. 유동성·위험선호의 선행 바로미터로 자주 거론(미검증 가설).
|
||||
|
||||
## 무엇이 이걸 움직이나 / Drivers
|
||||
|
||||
| 요인 | 방향 | 메커니즘 | 검증/가설 | 근거 |
|
||||
|---|---|---|---|---|
|
||||
| 글로벌 유동성 ↑ | BTC ↑ | 위험자산 자금 유입 | `[가설]` | 관찰 누적중 |
|
||||
| 금리 ↓ / 완화 | BTC ↑ | risk-on | `[가설]` | 관찰 누적중 (→ `field-us-rates`) |
|
||||
|
||||
## 연결 / Linkages
|
||||
|
||||
| 이게 ↑하면 | → 따라 | 메커니즘 | 검증/가설 | 근거 |
|
||||
|---|---|---|---|---|
|
||||
| BTC ↑ | 위험선호 신호(주식과 동조 경향) | risk-on 방증 | `[가설]` | 관찰 누적중 (→ `field-us-equity`) |
|
||||
|
||||
## 관찰 지표 / What to watch
|
||||
|
||||
- BTC 가격, ETH, 글로벌 유동성 지표
|
||||
|
||||
## 경기 사이클 위치 / Cycle position
|
||||
|
||||
- 유동성 확장기 강 / 긴축·위험회피기 급락
|
||||
|
||||
## 검증 상태 / Verification
|
||||
|
||||
- `[검증]` 0개 · `[가설]` 3개
|
||||
|
||||
## Sources
|
||||
|
||||
- [[wiki/invest-strategy/strategy]]
|
||||
|
||||
## Related
|
||||
|
||||
- [[wiki/invest-concepts/field-us-rates]]
|
||||
- [[wiki/invest-concepts/field-us-equity]]
|
||||
@@ -0,0 +1,66 @@
|
||||
---
|
||||
title: 화학·정유 / Chemicals & Refining
|
||||
source_type: invest-concept
|
||||
status: draft
|
||||
confidence: low
|
||||
tags: [invest-concept, field-card, sector]
|
||||
last_reviewed: 2026-06-08
|
||||
---
|
||||
|
||||
# 화학·정유 / Chemicals & Refining
|
||||
|
||||
> Layer: `wiki/invest-concepts/` — 분야 카드. 모든 관계 행에 `[검증]/[가설]` 라벨 필수. `[가설]`은 외부 산출물 금지. 세무·투자 자문 아님 — [[wiki/invest-strategy/strategy]] §고지.
|
||||
|
||||
## Parent
|
||||
|
||||
- [[wiki/invest-concepts/field-map]]
|
||||
|
||||
## 한 줄 정의 / What it is
|
||||
|
||||
> 석유화학·정유. 유가(원가)·정제마진·중국 수요에 민감한 경기민감 소재. 일부 화학사는 2차전지로 사업 확장.
|
||||
|
||||
## 무엇이 이걸 움직이나 / Drivers
|
||||
|
||||
| 요인 | 방향 | 메커니즘 | 검증/가설 | 근거 |
|
||||
|---|---|---|---|---|
|
||||
| 유가 ↑ | 정유 마진 양방향 | 정제마진·재고효과 | `[가설]` | 관찰 누적중 (→ `field-oil`) |
|
||||
| 중국 수요·증설 | 화학 양방향 | 공급과잉 변수 | `[가설]` | 관찰 누적중 (→ `field-em-china`) |
|
||||
|
||||
## 연결 / Linkages
|
||||
|
||||
| 이게 ↑하면 | → 따라 | 메커니즘 | 검증/가설 | 근거 |
|
||||
|---|---|---|---|---|
|
||||
| 정제마진 ↑ | 정유주 ↑ | 실적 모멘텀 | `[가설]` | 관찰 누적중 |
|
||||
|
||||
## 대장주 / 추종주 (Leaders & Followers)
|
||||
|
||||
> 대장주와 추종주. **종목 선정·동조 관계는 전부 `[가설]`** — `/invest-research`로 검증.
|
||||
|
||||
| 종목(티커) | 역할 | 왜(동조 근거) | 검증/가설 |
|
||||
|---|---|---|---|
|
||||
| LG화학 (051910) | 대장주(화학) | 화학 + 2차전지 모회사 | `[가설]` |
|
||||
| S-Oil (010950) | 대장주(정유) | 정유 | `[가설]` |
|
||||
| SK이노베이션 (096770) | 추종 | 정유·배터리 | `[가설]` |
|
||||
| 롯데케미칼 (011170) | 추종 | 석유화학 | `[가설]` |
|
||||
| 금호석유 (011780) | 추종 | 합성고무·화학 | `[가설]` |
|
||||
|
||||
## 관찰 지표 / What to watch
|
||||
|
||||
- WTI·정제마진(싱가포르 복합), 중국 화학 가동률, LG화학·S-Oil 주가
|
||||
|
||||
## 경기 사이클 위치 / Cycle position
|
||||
|
||||
- 수요 회복·공급 타이트기 강 / 중국 증설·둔화기 약
|
||||
|
||||
## 검증 상태 / Verification
|
||||
|
||||
- `[검증]` 0개 · `[가설]` 8개
|
||||
|
||||
## Sources
|
||||
|
||||
- [[wiki/invest-strategy/strategy]]
|
||||
|
||||
## Related
|
||||
|
||||
- [[wiki/invest-concepts/field-oil]]
|
||||
- [[wiki/invest-concepts/field-em-china]]
|
||||
@@ -0,0 +1,65 @@
|
||||
---
|
||||
title: 화장품·소비재 / Cosmetics & Consumer
|
||||
source_type: invest-concept
|
||||
status: draft
|
||||
confidence: low
|
||||
tags: [invest-concept, field-card, sector]
|
||||
last_reviewed: 2026-06-08
|
||||
---
|
||||
|
||||
# 화장품·소비재 / Cosmetics & Consumer
|
||||
|
||||
> Layer: `wiki/invest-concepts/` — 분야 카드. 모든 관계 행에 `[검증]/[가설]` 라벨 필수. `[가설]`은 외부 산출물 금지. 세무·투자 자문 아님 — [[wiki/invest-strategy/strategy]] §고지.
|
||||
|
||||
## Parent
|
||||
|
||||
- [[wiki/invest-concepts/field-map]]
|
||||
|
||||
## 한 줄 정의 / What it is
|
||||
|
||||
> 화장품·필수소비재. 중국·면세·미국 등 수출 수요 + K-뷰티 인디 브랜드 모멘텀. ODM/유통이 추종.
|
||||
|
||||
## 무엇이 이걸 움직이나 / Drivers
|
||||
|
||||
| 요인 | 방향 | 메커니즘 | 검증/가설 | 근거 |
|
||||
|---|---|---|---|---|
|
||||
| 중국·면세 수요 ↑ | 화장품 ↑ | 수출·면세 실적 | `[가설]` | 관찰 누적중 (→ `field-em-china`) |
|
||||
| 미국·인디 브랜드 수출 ↑ | 화장품 ↑ | 신시장 | `[가설]` | 관찰 누적중 |
|
||||
|
||||
## 연결 / Linkages
|
||||
|
||||
| 이게 ↑하면 | → 따라 | 메커니즘 | 검증/가설 | 근거 |
|
||||
|---|---|---|---|---|
|
||||
| 브랜드 수요 ↑ | ODM·부자재 소형주 ↑ | 생산 밸류체인 | `[가설]` | 관찰 누적중 |
|
||||
|
||||
## 대장주 / 추종주 (Leaders & Followers)
|
||||
|
||||
> 대장주와 추종주. **종목 선정·동조 관계는 전부 `[가설]`** — `/invest-research`로 검증.
|
||||
|
||||
| 종목(티커) | 역할 | 왜(동조 근거) | 검증/가설 |
|
||||
|---|---|---|---|
|
||||
| 아모레퍼시픽 (090430) | 대장주(브랜드) | 화장품 대형 | `[가설]` |
|
||||
| LG생활건강 (051900) | 대장주(브랜드) | 화장품·생활용품 | `[가설]` |
|
||||
| 코스맥스 (192820) | 추종(ODM) | 제조자개발생산 | `[가설]` |
|
||||
| 한국콜마 (161890) | 추종(ODM) | ODM | `[가설]` |
|
||||
| 실리콘투 (257720) | 추종(유통) | K-뷰티 수출 유통 | `[가설]` |
|
||||
|
||||
## 관찰 지표 / What to watch
|
||||
|
||||
- 아모레·LG생건 주가, 중국 소비·면세 데이터, 미국 K-뷰티 수출, 인디 브랜드 동향
|
||||
|
||||
## 경기 사이클 위치 / Cycle position
|
||||
|
||||
- 중국·수출 소비 회복기 강
|
||||
|
||||
## 검증 상태 / Verification
|
||||
|
||||
- `[검증]` 0개 · `[가설]` 8개
|
||||
|
||||
## Sources
|
||||
|
||||
- [[wiki/invest-strategy/strategy]]
|
||||
|
||||
## Related
|
||||
|
||||
- [[wiki/invest-concepts/field-em-china]]
|
||||
@@ -0,0 +1,64 @@
|
||||
---
|
||||
title: 방산 / Defense
|
||||
source_type: invest-concept
|
||||
status: draft
|
||||
confidence: low
|
||||
tags: [invest-concept, field-card, sector]
|
||||
last_reviewed: 2026-06-08
|
||||
---
|
||||
|
||||
# 방산 / Defense
|
||||
|
||||
> Layer: `wiki/invest-concepts/` — 분야 카드. 모든 관계 행에 `[검증]/[가설]` 라벨 필수. `[가설]`은 외부 산출물 금지. 세무·투자 자문 아님 — [[wiki/invest-strategy/strategy]] §고지.
|
||||
|
||||
## Parent
|
||||
|
||||
- [[wiki/invest-concepts/field-map]]
|
||||
|
||||
## 한 줄 정의 / What it is
|
||||
|
||||
> 방산(국방). 지정학 긴장·해외 수출 수주에 민감. 거시 경기 사이클과 다소 독립적으로 움직이는 경향(가설).
|
||||
|
||||
## 무엇이 이걸 움직이나 / Drivers
|
||||
|
||||
| 요인 | 방향 | 메커니즘 | 검증/가설 | 근거 |
|
||||
|---|---|---|---|---|
|
||||
| 지정학 위험 ↑ | 방산 ↑ | 국방예산·수요 기대 | `[가설]` | 관찰 누적중 |
|
||||
| 해외 수출 수주 | 방산 ↑ | 실적 모멘텀 | `[가설]` | 관찰 누적중 |
|
||||
|
||||
## 연결 / Linkages
|
||||
|
||||
| 이게 ↑하면 | → 따라 | 메커니즘 | 검증/가설 | 근거 |
|
||||
|---|---|---|---|---|
|
||||
| 대장주 수주 ↑ | 부품·협력 소형주 ↑ | 밸류체인 낙수 | `[가설]` | 관찰 누적중 |
|
||||
|
||||
## 대장주 / 추종주 (Leaders & Followers)
|
||||
|
||||
> 대장주와 추종주. **종목 선정·동조 관계는 전부 `[가설]`** — `/invest-research`로 검증.
|
||||
|
||||
| 종목(티커) | 역할 | 왜(동조 근거) | 검증/가설 |
|
||||
|---|---|---|---|
|
||||
| 한화에어로스페이스 (012450) | 대장주 | 엔진·발사체·해외 수출 주도 | `[가설]` |
|
||||
| LIG넥스원 (079550) | 대장주(유도무기) | 미사일·유도무기 | `[가설]` |
|
||||
| 현대로템 (064350) | 추종 | 지상장비(K2 전차) | `[가설]` |
|
||||
| 한국항공우주 (047810) | 추종 | 항공(KAI) | `[가설]` |
|
||||
|
||||
## 관찰 지표 / What to watch
|
||||
|
||||
- 방산 ETF, 해외 수출 수주 뉴스, 한화에어로스페이스 주가, 지정학 이슈
|
||||
|
||||
## 경기 사이클 위치 / Cycle position
|
||||
|
||||
- 지정학 긴장기 강 (거시 금리/경기 사이클과 약한 상관 — 가설)
|
||||
|
||||
## 검증 상태 / Verification
|
||||
|
||||
- `[검증]` 0개 · `[가설]` 8개 (관찰 누적 → `/invest-research`로 승격)
|
||||
|
||||
## Sources
|
||||
|
||||
- [[wiki/invest-strategy/strategy]]
|
||||
|
||||
## Related
|
||||
|
||||
- [[wiki/invest-concepts/field-us-equity]]
|
||||
@@ -0,0 +1,57 @@
|
||||
---
|
||||
title: 미 달러 / USD
|
||||
source_type: invest-concept
|
||||
status: draft
|
||||
confidence: low
|
||||
tags: [invest-concept, field-card, macro-asset]
|
||||
last_reviewed: 2026-06-08
|
||||
---
|
||||
|
||||
# 미 달러 / USD
|
||||
|
||||
> Layer: `wiki/invest-concepts/` — 분야 카드. 모든 관계 행에 `[검증]/[가설]` 라벨 필수. `[가설]`은 외부 산출물 금지. 세무·투자 자문 아님 — [[wiki/invest-strategy/strategy]] §고지.
|
||||
|
||||
## Parent
|
||||
|
||||
- [[wiki/invest-concepts/field-map]]
|
||||
|
||||
## 한 줄 정의 / What it is
|
||||
|
||||
> 달러 = 글로벌 기축통화. 위험회피(risk-off) 국면에 강해지고, 글로벌 자산 가격의 분모 역할.
|
||||
|
||||
## 무엇이 이걸 움직이나 / Drivers
|
||||
|
||||
| 요인 | 방향 | 메커니즘 | 검증/가설 | 근거 |
|
||||
|---|---|---|---|---|
|
||||
| 미 기준금리/10Y ↑ | 달러 ↑ | 금리 높으면 달러 표시 자산 수요↑ | `[가설]` | 관찰 누적중 |
|
||||
| 글로벌 위험회피 | 달러 ↑ | 안전자산 선호 | `[가설]` | 관찰 누적중 |
|
||||
|
||||
## 연결 / Linkages
|
||||
|
||||
| 이게 ↑하면 | → 따라 | 메커니즘 | 검증/가설 | 근거 |
|
||||
|---|---|---|---|---|
|
||||
| 달러 ↑ | 금 ↓ | 무이자 금의 상대매력↓ | `[가설]` | 관찰 누적중 (→ `field-gold`) |
|
||||
| 달러 ↑ | 원유 ↓ | 달러표시 원자재 역상관 | `[가설]` | 관찰 누적중 (→ `field-oil`) |
|
||||
| 달러 ↑ | 신흥국/위험자산 ↓ | 자금 미국 회귀 | `[가설]` | 관찰 누적중 |
|
||||
|
||||
## 관찰 지표 / What to watch
|
||||
|
||||
- DXY 달러인덱스, USD/KRW 환율, 미 10Y 금리
|
||||
|
||||
## 경기 사이클 위치 / Cycle position
|
||||
|
||||
- 침체·위험회피 국면에 강 / 위험선호(확장) 국면에 약
|
||||
|
||||
## 검증 상태 / Verification
|
||||
|
||||
- `[검증]` 0개 · `[가설]` 5개 (관찰 누적 → `/invest-research`로 승격 예정)
|
||||
|
||||
## Sources
|
||||
|
||||
- [[wiki/invest-strategy/strategy]]
|
||||
|
||||
## Related
|
||||
|
||||
- [[wiki/invest-concepts/field-gold]]
|
||||
- [[wiki/invest-concepts/field-oil]]
|
||||
- [[wiki/invest-concepts/field-us-rates]]
|
||||
@@ -0,0 +1,55 @@
|
||||
---
|
||||
title: 신흥국·중국 / EM & China
|
||||
source_type: invest-concept
|
||||
status: draft
|
||||
confidence: low
|
||||
tags: [invest-concept, field-card, macro-asset]
|
||||
last_reviewed: 2026-06-08
|
||||
---
|
||||
|
||||
# 신흥국·중국 / EM & China
|
||||
|
||||
> Layer: `wiki/invest-concepts/` — 분야 카드. 모든 관계 행에 `[검증]/[가설]` 라벨 필수. `[가설]`은 외부 산출물 금지. 세무·투자 자문 아님 — [[wiki/invest-strategy/strategy]] §고지.
|
||||
|
||||
## Parent
|
||||
|
||||
- [[wiki/invest-concepts/field-map]]
|
||||
|
||||
## 한 줄 정의 / What it is
|
||||
|
||||
> 신흥국(특히 중국) 증시. 글로벌 위험선호·달러·중국 경기의 바로미터. 한국 증시와 동조 경향(가설).
|
||||
|
||||
## 무엇이 이걸 움직이나 / Drivers
|
||||
|
||||
| 요인 | 방향 | 메커니즘 | 검증/가설 | 근거 |
|
||||
|---|---|---|---|---|
|
||||
| 달러 ↑ | 신흥국 ↓ | 자금 미국 회귀 | `[가설]` | 관찰 누적중 (→ `field-dollar`) |
|
||||
| 중국 경기 부양 | 신흥국 ↑ | 수요·심리 | `[가설]` | 관찰 누적중 |
|
||||
| 글로벌 위험선호 | 신흥국 ↑ | risk-on 자금 | `[가설]` | 관찰 누적중 |
|
||||
|
||||
## 연결 / Linkages
|
||||
|
||||
| 이게 ↑하면 | → 따라 | 메커니즘 | 검증/가설 | 근거 |
|
||||
|---|---|---|---|---|
|
||||
| 신흥국 ↑ | 한국 증시·원자재 ↑ | 위험선호 동조 | `[가설]` | 관찰 누적중 (→ `field-oil`) |
|
||||
|
||||
## 관찰 지표 / What to watch
|
||||
|
||||
- MSCI EM, 상해종합·항셍, 위안화(USD/CNY), 외국인 코스피 순매수
|
||||
|
||||
## 경기 사이클 위치 / Cycle position
|
||||
|
||||
- 위험선호·달러 약세기 강 / 위험회피·달러 강세기 약
|
||||
|
||||
## 검증 상태 / Verification
|
||||
|
||||
- `[검증]` 0개 · `[가설]` 4개
|
||||
|
||||
## Sources
|
||||
|
||||
- [[wiki/invest-strategy/strategy]]
|
||||
|
||||
## Related
|
||||
|
||||
- [[wiki/invest-concepts/field-dollar]]
|
||||
- [[wiki/invest-concepts/field-oil]]
|
||||
@@ -0,0 +1,65 @@
|
||||
---
|
||||
title: 엔터·미디어 / Entertainment
|
||||
source_type: invest-concept
|
||||
status: draft
|
||||
confidence: low
|
||||
tags: [invest-concept, field-card, sector]
|
||||
last_reviewed: 2026-06-08
|
||||
---
|
||||
|
||||
# 엔터·미디어 / Entertainment
|
||||
|
||||
> Layer: `wiki/invest-concepts/` — 분야 카드. 모든 관계 행에 `[검증]/[가설]` 라벨 필수. `[가설]`은 외부 산출물 금지. 세무·투자 자문 아님 — [[wiki/invest-strategy/strategy]] §고지.
|
||||
|
||||
## Parent
|
||||
|
||||
- [[wiki/invest-concepts/field-map]]
|
||||
|
||||
## 한 줄 정의 / What it is
|
||||
|
||||
> 엔터·미디어·콘텐츠(K-POP·드라마). 앨범·투어·아티스트 컴백 같은 이벤트 + 중국·일본 등 해외 K-콘텐츠 수요가 모멘텀.
|
||||
|
||||
## 무엇이 이걸 움직이나 / Drivers
|
||||
|
||||
| 요인 | 방향 | 메커니즘 | 검증/가설 | 근거 |
|
||||
|---|---|---|---|---|
|
||||
| 앨범 판매·월드투어 | 해당 종목 ↑ | 실적 모멘텀 | `[가설]` | 관찰 누적중 |
|
||||
| 해외 K-콘텐츠 수요 ↑ | 엔터 ↑ | 글로벌 팬덤 | `[가설]` | 관찰 누적중 (→ `field-em-china`) |
|
||||
|
||||
## 연결 / Linkages
|
||||
|
||||
| 이게 ↑하면 | → 따라 | 메커니즘 | 검증/가설 | 근거 |
|
||||
|---|---|---|---|---|
|
||||
| 대장 엔터주 ↑ | 중소 기획사·콘텐츠 ↑ | 섹터 센티먼트 | `[가설]` | 관찰 누적중 |
|
||||
|
||||
## 대장주 / 추종주 (Leaders & Followers)
|
||||
|
||||
> 대장주와 추종주. **종목 선정·동조 관계는 전부 `[가설]`** — `/invest-research`로 검증.
|
||||
|
||||
| 종목(티커) | 역할 | 왜(동조 근거) | 검증/가설 |
|
||||
|---|---|---|---|
|
||||
| 하이브 (352820) | 대장주 | BTS 등 시총 1위 | `[가설]` |
|
||||
| JYP Ent. (035900) | 추종 | 멀티 IP | `[가설]` |
|
||||
| 에스엠 (041510) | 추종 | 멀티 IP | `[가설]` |
|
||||
| 와이지엔터 (122870) | 추종(소형) | 블랙핑크 등 | `[가설]` |
|
||||
|
||||
## 관찰 지표 / What to watch
|
||||
|
||||
- 하이브 주가, 앨범 초동 판매, 월드투어·컴백 일정, 중국/일본 K-콘텐츠 정책
|
||||
|
||||
## 경기 사이클 위치 / Cycle position
|
||||
|
||||
- 컴백·투어 사이클·해외 수요기 강 (거시 사이클과 약한 상관 — 가설)
|
||||
|
||||
## 검증 상태 / Verification
|
||||
|
||||
- `[검증]` 0개 · `[가설]` 7개
|
||||
|
||||
## Sources
|
||||
|
||||
- [[wiki/invest-strategy/strategy]]
|
||||
|
||||
## Related
|
||||
|
||||
- [[wiki/invest-concepts/field-em-china]]
|
||||
- [[wiki/invest-concepts/field-internet-platform]]
|
||||
@@ -0,0 +1,65 @@
|
||||
---
|
||||
title: 금융 / Financials
|
||||
source_type: invest-concept
|
||||
status: draft
|
||||
confidence: low
|
||||
tags: [invest-concept, field-card, sector]
|
||||
last_reviewed: 2026-06-08
|
||||
---
|
||||
|
||||
# 금융 / Financials
|
||||
|
||||
> Layer: `wiki/invest-concepts/` — 분야 카드. 모든 관계 행에 `[검증]/[가설]` 라벨 필수. `[가설]`은 외부 산출물 금지. 세무·투자 자문 아님 — [[wiki/invest-strategy/strategy]] §고지.
|
||||
|
||||
## Parent
|
||||
|
||||
- [[wiki/invest-concepts/field-map]]
|
||||
|
||||
## 한 줄 정의 / What it is
|
||||
|
||||
> 은행·증권·보험. 금리(예대마진·이자수익)에 민감 — 성장주와 반대로 금리↑에 우호적(가설). 배당·밸류업 테마.
|
||||
|
||||
## 무엇이 이걸 움직이나 / Drivers
|
||||
|
||||
| 요인 | 방향 | 메커니즘 | 검증/가설 | 근거 |
|
||||
|---|---|---|---|---|
|
||||
| 금리 ↑ | 은행 ↑ | 예대마진 확대 | `[가설]` | 관찰 누적중 (→ `field-us-rates`) |
|
||||
| 밸류업·배당 정책 | 금융 ↑ | 주주환원 | `[가설]` | 관찰 누적중 |
|
||||
|
||||
## 연결 / Linkages
|
||||
|
||||
| 이게 ↑하면 | → 따라 | 메커니즘 | 검증/가설 | 근거 |
|
||||
|---|---|---|---|---|
|
||||
| 금리 ↑ | 금융 ↑ / 성장주 ↓ | 로테이션(가치↔성장) | `[가설]` | 관찰 누적중 |
|
||||
|
||||
## 대장주 / 추종주 (Leaders & Followers)
|
||||
|
||||
> 대장주와 추종주. **종목 선정·동조 관계는 전부 `[가설]`** — `/invest-research`로 검증.
|
||||
|
||||
| 종목(티커) | 역할 | 왜(동조 근거) | 검증/가설 |
|
||||
|---|---|---|---|
|
||||
| KB금융 (105560) | 대장주(은행) | 금융지주 시총 상위 | `[가설]` |
|
||||
| 신한지주 (055550) | 대장주(은행) | 금융지주 | `[가설]` |
|
||||
| 하나금융지주 (086790) | 추종 | 은행지주 | `[가설]` |
|
||||
| 메리츠금융지주 (138040) | 추종 | 보험·증권 | `[가설]` |
|
||||
| 삼성생명 (032830) | 추종(보험) | 생보 1위 | `[가설]` |
|
||||
|
||||
## 관찰 지표 / What to watch
|
||||
|
||||
- 은행지주 주가, 국고채/기준금리, 밸류업 정책 뉴스
|
||||
|
||||
## 경기 사이클 위치 / Cycle position
|
||||
|
||||
- 금리 상승·정상화기 강 / 급격한 금리인하·경기침체기 약
|
||||
|
||||
## 검증 상태 / Verification
|
||||
|
||||
- `[검증]` 0개 · `[가설]` 8개
|
||||
|
||||
## Sources
|
||||
|
||||
- [[wiki/invest-strategy/strategy]]
|
||||
|
||||
## Related
|
||||
|
||||
- [[wiki/invest-concepts/field-us-rates]]
|
||||
@@ -0,0 +1,67 @@
|
||||
---
|
||||
title: 게임 / Game
|
||||
source_type: invest-concept
|
||||
status: draft
|
||||
confidence: low
|
||||
tags: [invest-concept, field-card, sector]
|
||||
last_reviewed: 2026-06-08
|
||||
---
|
||||
|
||||
# 게임 / Game
|
||||
|
||||
> Layer: `wiki/invest-concepts/` — 분야 카드. 모든 관계 행에 `[검증]/[가설]` 라벨 필수. `[가설]`은 외부 산출물 금지. 세무·투자 자문 아님 — [[wiki/invest-strategy/strategy]] §고지.
|
||||
|
||||
## Parent
|
||||
|
||||
- [[wiki/invest-concepts/field-map]]
|
||||
|
||||
## 한 줄 정의 / What it is
|
||||
|
||||
> 게임(모바일·PC·콘솔). 신작 흥행·중국 판호 같은 종목 고유 이벤트가 큰 변동. 성장주라 금리 민감.
|
||||
|
||||
## 무엇이 이걸 움직이나 / Drivers
|
||||
|
||||
| 요인 | 방향 | 메커니즘 | 검증/가설 | 근거 |
|
||||
|---|---|---|---|---|
|
||||
| 신작 흥행 | 해당 종목 ↑ | 매출 모멘텀 | `[가설]` | 관찰 누적중 |
|
||||
| 중국 판호 발급 | 게임 ↑ | 시장 개방 기대 | `[가설]` | 관찰 누적중 (→ `field-em-china`) |
|
||||
| 금리 ↓ | 게임 ↑ | 성장주 할인 완화 | `[가설]` | 관찰 누적중 (→ `field-us-rates`) |
|
||||
|
||||
## 연결 / Linkages
|
||||
|
||||
| 이게 ↑하면 | → 따라 | 메커니즘 | 검증/가설 | 근거 |
|
||||
|---|---|---|---|---|
|
||||
| 대형 게임주 ↑ | 중소 게임주 ↑ | 섹터 센티먼트 | `[가설]` | 관찰 누적중 |
|
||||
|
||||
## 대장주 / 추종주 (Leaders & Followers)
|
||||
|
||||
> 대장주와 추종주. **종목 선정·동조 관계는 전부 `[가설]`** — `/invest-research`로 검증. ⚠️ 게임은 신작 고유 리스크가 커서 동조성 약할 수 있음(가설).
|
||||
|
||||
| 종목(티커) | 역할 | 왜(동조 근거) | 검증/가설 |
|
||||
|---|---|---|---|
|
||||
| 크래프톤 (259960) | 대장주 | 배그·시총 1위급 | `[가설]` |
|
||||
| 엔씨소프트 (036570) | 대장주 | MMORPG | `[가설]` |
|
||||
| 넷마블 (251270) | 추종 | 모바일 퍼블리셔 | `[가설]` |
|
||||
| 펄어비스 (263750) | 추종 | 검은사막·붉은사막 | `[가설]` |
|
||||
| 위메이드 (112040) | 추종(소형) | 블록체인 게임 | `[가설]` |
|
||||
|
||||
## 관찰 지표 / What to watch
|
||||
|
||||
- 크래프톤·엔씨 주가, 신작 출시 일정, 중국 판호 뉴스, 금리
|
||||
|
||||
## 경기 사이클 위치 / Cycle position
|
||||
|
||||
- 저금리·신작 사이클 강 (종목별 편차 큼)
|
||||
|
||||
## 검증 상태 / Verification
|
||||
|
||||
- `[검증]` 0개 · `[가설]` 9개
|
||||
|
||||
## Sources
|
||||
|
||||
- [[wiki/invest-strategy/strategy]]
|
||||
|
||||
## Related
|
||||
|
||||
- [[wiki/invest-concepts/field-us-rates]]
|
||||
- [[wiki/invest-concepts/field-internet-platform]]
|
||||
@@ -0,0 +1,55 @@
|
||||
---
|
||||
title: 금 / Gold
|
||||
source_type: invest-concept
|
||||
status: draft
|
||||
confidence: low
|
||||
tags: [invest-concept, field-card, macro-asset]
|
||||
last_reviewed: 2026-06-08
|
||||
---
|
||||
|
||||
# 금 / Gold
|
||||
|
||||
> Layer: `wiki/invest-concepts/` — 분야 카드. 모든 관계 행에 `[검증]/[가설]` 라벨 필수. `[가설]`은 외부 산출물 금지. 세무·투자 자문 아님 — [[wiki/invest-strategy/strategy]] §고지.
|
||||
|
||||
## Parent
|
||||
|
||||
- [[wiki/invest-concepts/field-map]]
|
||||
|
||||
## 한 줄 정의 / What it is
|
||||
|
||||
> 금 = 무이자 안전자산·실질금리/달러의 거울. 위험회피·인플레이션 헤지 수단.
|
||||
|
||||
## 무엇이 이걸 움직이나 / Drivers
|
||||
|
||||
| 요인 | 방향 | 메커니즘 | 검증/가설 | 근거 |
|
||||
|---|---|---|---|---|
|
||||
| 실질금리 ↓ | 금 ↑ | 무이자 금의 기회비용↓ | `[가설]` | 관찰 누적중 (→ `field-us-rates`) |
|
||||
| 달러 ↓ | 금 ↑ | 달러표시 역상관 | `[가설]` | 관찰 누적중 (→ `field-dollar`) |
|
||||
| 지정학 위험 | 금 ↑ | 안전자산 수요 | `[가설]` | 관찰 누적중 |
|
||||
|
||||
## 연결 / Linkages
|
||||
|
||||
| 이게 ↑하면 | → 따라 | 메커니즘 | 검증/가설 | 근거 |
|
||||
|---|---|---|---|---|
|
||||
| 금 ↑ | 위험회피 신호(주식 경계) | 안전자산 쏠림 방증 | `[가설]` | 관찰 누적중 |
|
||||
|
||||
## 관찰 지표 / What to watch
|
||||
|
||||
- 금 현물가격, 미 실질금리(TIPS), DXY
|
||||
|
||||
## 경기 사이클 위치 / Cycle position
|
||||
|
||||
- 침체·완화 기대 국면에 강
|
||||
|
||||
## 검증 상태 / Verification
|
||||
|
||||
- `[검증]` 0개 · `[가설]` 4개
|
||||
|
||||
## Sources
|
||||
|
||||
- [[wiki/invest-strategy/strategy]]
|
||||
|
||||
## Related
|
||||
|
||||
- [[wiki/invest-concepts/field-dollar]]
|
||||
- [[wiki/invest-concepts/field-us-rates]]
|
||||
@@ -0,0 +1,67 @@
|
||||
---
|
||||
title: 인터넷·플랫폼 / Internet & Platform
|
||||
source_type: invest-concept
|
||||
status: draft
|
||||
confidence: low
|
||||
tags: [invest-concept, field-card, sector]
|
||||
last_reviewed: 2026-06-08
|
||||
---
|
||||
|
||||
# 인터넷·플랫폼 / Internet & Platform
|
||||
|
||||
> Layer: `wiki/invest-concepts/` — 분야 카드. 모든 관계 행에 `[검증]/[가설]` 라벨 필수. `[가설]`은 외부 산출물 금지. 세무·투자 자문 아님 — [[wiki/invest-strategy/strategy]] §고지.
|
||||
|
||||
## Parent
|
||||
|
||||
- [[wiki/invest-concepts/field-map]]
|
||||
|
||||
## 한 줄 정의 / What it is
|
||||
|
||||
> 인터넷/플랫폼(검색·메신저·커머스·핀테크·콘텐츠). 성장주라 금리 민감 + 광고·커머스 경기 + AI 적용 기대에 좌우.
|
||||
|
||||
## 무엇이 이걸 움직이나 / Drivers
|
||||
|
||||
| 요인 | 방향 | 메커니즘 | 검증/가설 | 근거 |
|
||||
|---|---|---|---|---|
|
||||
| 금리 ↓ | 플랫폼 ↑ | 성장주 할인 완화 | `[가설]` | 관찰 누적중 (→ `field-us-rates`) |
|
||||
| 광고·커머스 경기 ↑ | 플랫폼 ↑ | 매출 회복 | `[가설]` | 관찰 누적중 |
|
||||
| AI 적용 기대 | 플랫폼 ↑ | 빅테크 테마 동조 | `[가설]` | 관찰 누적중 (→ `field-bigtech-ai`) |
|
||||
|
||||
## 연결 / Linkages
|
||||
|
||||
| 이게 ↑하면 | → 따라 | 메커니즘 | 검증/가설 | 근거 |
|
||||
|---|---|---|---|---|
|
||||
| 네이버·카카오 ↑ | 핀테크·콘텐츠 소형주 ↑ | 생태계 동조 | `[가설]` | 관찰 누적중 |
|
||||
|
||||
## 대장주 / 추종주 (Leaders & Followers)
|
||||
|
||||
> 대장주와 추종주. **종목 선정·동조 관계는 전부 `[가설]`** — `/invest-research`로 검증.
|
||||
|
||||
| 종목(티커) | 역할 | 왜(동조 근거) | 검증/가설 |
|
||||
|---|---|---|---|
|
||||
| 네이버 (035420) | 대장주 | 검색·커머스·AI | `[가설]` |
|
||||
| 카카오 (035720) | 대장주 | 메신저·핀테크 플랫폼 | `[가설]` |
|
||||
| 카카오페이 (377300) | 추종 | 핀테크 | `[가설]` |
|
||||
| 카카오뱅크 (323410) | 추종 | 인터넷은행 | `[가설]` |
|
||||
| 콘텐츠·웹툰 소형주 | 추종 | 플랫폼 생태계 | `[가설]` |
|
||||
|
||||
## 관찰 지표 / What to watch
|
||||
|
||||
- 네이버·카카오 주가, 나스닥(美 빅테크), 미 10Y 금리, 광고 시장 지표
|
||||
|
||||
## 경기 사이클 위치 / Cycle position
|
||||
|
||||
- 저금리·성장 선호기 강 / 금리 급등기 약
|
||||
|
||||
## 검증 상태 / Verification
|
||||
|
||||
- `[검증]` 0개 · `[가설]` 9개 (관찰 누적 → `/invest-research`로 승격)
|
||||
|
||||
## Sources
|
||||
|
||||
- [[wiki/invest-strategy/strategy]]
|
||||
|
||||
## Related
|
||||
|
||||
- [[wiki/invest-concepts/field-us-rates]]
|
||||
- [[wiki/invest-concepts/field-bigtech-ai]]
|
||||
@@ -0,0 +1,55 @@
|
||||
---
|
||||
title: 한국 금리·원화 / KRW Rates
|
||||
source_type: invest-concept
|
||||
status: draft
|
||||
confidence: low
|
||||
tags: [invest-concept, field-card, macro-asset]
|
||||
last_reviewed: 2026-06-08
|
||||
---
|
||||
|
||||
# 한국 금리·원화 / KRW Rates
|
||||
|
||||
> Layer: `wiki/invest-concepts/` — 분야 카드. 모든 관계 행에 `[검증]/[가설]` 라벨 필수. `[가설]`은 외부 산출물 금지. 세무·투자 자문 아님 — [[wiki/invest-strategy/strategy]] §고지.
|
||||
|
||||
## Parent
|
||||
|
||||
- [[wiki/invest-concepts/field-map]]
|
||||
|
||||
## 한 줄 정의 / What it is
|
||||
|
||||
> 한국 기준금리·국고채 금리·원화. 국내 자산 할인율 + 외국인 자금 유출입의 핵심 변수.
|
||||
|
||||
## 무엇이 이걸 움직이나 / Drivers
|
||||
|
||||
| 요인 | 방향 | 메커니즘 | 검증/가설 | 근거 |
|
||||
|---|---|---|---|---|
|
||||
| 미 금리 ↑ | 한국 금리 ↑ 압력 | 한미 금리차·자본유출 방어 | `[가설]` | 관찰 누적중 (→ `field-us-rates`) |
|
||||
| 한은 긴축 | 금리 ↑ | 물가·환율 방어 | `[가설]` | 관찰 누적중 |
|
||||
|
||||
## 연결 / Linkages
|
||||
|
||||
| 이게 ↑하면 | → 따라 | 메커니즘 | 검증/가설 | 근거 |
|
||||
|---|---|---|---|---|
|
||||
| 한국 금리 ↑ | 국내 성장주 ↓ | 할인율 상승 | `[가설]` | 관찰 누적중 |
|
||||
| 한미 금리차 역전 ↑ | 원화 약세 | 자본 미국 회귀 | `[가설]` | 관찰 누적중 (→ `field-dollar`) |
|
||||
|
||||
## 관찰 지표 / What to watch
|
||||
|
||||
- 한은 기준금리, 국고채 3년·10년, USD/KRW, 한미 금리차
|
||||
|
||||
## 경기 사이클 위치 / Cycle position
|
||||
|
||||
- 긴축기 금리↑·원화변동↑ / 완화기 금리↓
|
||||
|
||||
## 검증 상태 / Verification
|
||||
|
||||
- `[검증]` 0개 · `[가설]` 4개
|
||||
|
||||
## Sources
|
||||
|
||||
- [[wiki/invest-strategy/strategy]]
|
||||
|
||||
## Related
|
||||
|
||||
- [[wiki/invest-concepts/field-us-rates]]
|
||||
- [[wiki/invest-concepts/field-dollar]]
|
||||
@@ -0,0 +1,63 @@
|
||||
---
|
||||
title: 투자 분야 지도 (Field Map)
|
||||
source_type: invest-concept
|
||||
status: draft
|
||||
confidence: low
|
||||
tags: [invest-concept, field-card, hub]
|
||||
last_reviewed: 2026-06-08
|
||||
---
|
||||
|
||||
# 투자 분야 지도 (Field Map)
|
||||
|
||||
> Layer: `wiki/invest-concepts/` — 분야 카드(노드)의 허브. 2층(거시 자산군 / 산업 섹터)으로 분야를 나열한다. Obsidian 그래프뷰에서 이 허브를 중심으로 카드가 연결되면 그게 곧 자금흐름 지도. 세무·투자 자문 아님 — [[wiki/invest-strategy/strategy]] §고지.
|
||||
|
||||
## Parent
|
||||
|
||||
- [[wiki/invest/invest-hub]]
|
||||
|
||||
## 거시 자산군 / Macro Assets
|
||||
|
||||
- [[wiki/invest-concepts/field-dollar]] — 달러 (DXY/USD-KRW)
|
||||
- [[wiki/invest-concepts/field-us-rates]] — 미 10Y 금리
|
||||
- [[wiki/invest-concepts/field-oil]] — 원유 (WTI)
|
||||
- [[wiki/invest-concepts/field-gold]] — 금
|
||||
- [[wiki/invest-concepts/field-us-equity]] — 미국 주식 (S&P500)
|
||||
- [[wiki/invest-concepts/field-bitcoin]] — 비트코인
|
||||
- [[wiki/invest-concepts/field-krw-rates]] — 한국 금리·원화
|
||||
- [[wiki/invest-concepts/field-em-china]] — 신흥국·중국
|
||||
|
||||
## 산업 섹터 / Sectors
|
||||
|
||||
> 거시 자산군 아래 층. 한국 섹터/테마는 각 카드에 **대장주/추종주**(종목 서열)를 담는다.
|
||||
|
||||
- [[wiki/invest-concepts/field-semiconductors]] — 반도체 (대장주: 엔비디아·SK하이닉스)
|
||||
- [[wiki/invest-concepts/field-bigtech-ai]] — 빅테크/AI
|
||||
- [[wiki/invest-concepts/field-secondary-battery]] — 2차전지 (대장주: LG엔솔·에코프로비엠)
|
||||
- [[wiki/invest-concepts/field-defense]] — 방산 (대장주: 한화에어로스페이스)
|
||||
- [[wiki/invest-concepts/field-shipbuilding]] — 조선 (대장주: HD현대중공업·한화오션)
|
||||
- [[wiki/invest-concepts/field-bio-pharma]] — 바이오·제약 (대장주: 삼성바이오·셀트리온)
|
||||
- [[wiki/invest-concepts/field-internet-platform]] — 인터넷·플랫폼 (대장주: 네이버·카카오)
|
||||
- [[wiki/invest-concepts/field-auto]] — 자동차·부품 (대장주: 현대차·기아)
|
||||
- [[wiki/invest-concepts/field-financials]] — 금융 (대장주: KB·신한)
|
||||
- [[wiki/invest-concepts/field-steel-materials]] — 철강·소재 (대장주: POSCO홀딩스)
|
||||
- [[wiki/invest-concepts/field-chem-refining]] — 화학·정유 (대장주: LG화학·S-Oil)
|
||||
- [[wiki/invest-concepts/field-nuclear-power]] — 원자력·전력설비 (대장주: 두산에너빌리티)
|
||||
- [[wiki/invest-concepts/field-robotics]] — 로봇·자동화 (대장주: 두산로보틱스·레인보우)
|
||||
- [[wiki/invest-concepts/field-game]] — 게임 (대장주: 크래프톤·엔씨)
|
||||
- [[wiki/invest-concepts/field-entertainment]] — 엔터·미디어 (대장주: 하이브)
|
||||
- [[wiki/invest-concepts/field-cosmetics-consumer]] — 화장품·소비재 (대장주: 아모레·LG생건)
|
||||
- [[wiki/invest-concepts/field-telecom-utility]] — 통신·유틸리티 (대장주: SKT·한국전력)
|
||||
|
||||
## 분야간 로테이션 / Rotation
|
||||
|
||||
> 분야 *사이*의 돈 흐름 — "어디서 빠져 어디로". 위험선호·금리·달러·경기 4축.
|
||||
|
||||
- [[wiki/invest-concepts/field-rotation]] — 분야간 로테이션 지도 (4축 연쇄)
|
||||
|
||||
## 작동 루프
|
||||
|
||||
> 매일 `/invest-daily`의 "분야 관찰"로 카드 예측 vs 실측 대조 → 패턴은 `/invest-research`로 검증 → `/invest-ingest`로 카드 연결표에 `[검증]` 반영.
|
||||
|
||||
## Sources
|
||||
|
||||
- [[wiki/invest-strategy/strategy]]
|
||||
@@ -0,0 +1,64 @@
|
||||
---
|
||||
title: 원자력·전력설비 / Nuclear & Power
|
||||
source_type: invest-concept
|
||||
status: draft
|
||||
confidence: low
|
||||
tags: [invest-concept, field-card, sector]
|
||||
last_reviewed: 2026-06-08
|
||||
---
|
||||
|
||||
# 원자력·전력설비 / Nuclear & Power
|
||||
|
||||
> Layer: `wiki/invest-concepts/` — 분야 카드. 모든 관계 행에 `[검증]/[가설]` 라벨 필수. `[가설]`은 외부 산출물 금지. 세무·투자 자문 아님 — [[wiki/invest-strategy/strategy]] §고지.
|
||||
|
||||
## Parent
|
||||
|
||||
- [[wiki/invest-concepts/field-map]]
|
||||
|
||||
## 한 줄 정의 / What it is
|
||||
|
||||
> 원자력·전력설비(SMR·송배전). AI 데이터센터 전력수요 급증 + 원전 정책·해외 수주가 모멘텀(가설).
|
||||
|
||||
## 무엇이 이걸 움직이나 / Drivers
|
||||
|
||||
| 요인 | 방향 | 메커니즘 | 검증/가설 | 근거 |
|
||||
|---|---|---|---|---|
|
||||
| AI 데이터센터 전력수요 ↑ | 전력설비 ↑ | 전력 인프라 투자 | `[가설]` | 관찰 누적중 (→ `field-bigtech-ai`) |
|
||||
| 원전 정책·해외 수주 | 원자력 ↑ | 수주 모멘텀 | `[가설]` | 관찰 누적중 |
|
||||
|
||||
## 연결 / Linkages
|
||||
|
||||
| 이게 ↑하면 | → 따라 | 메커니즘 | 검증/가설 | 근거 |
|
||||
|---|---|---|---|---|
|
||||
| 대장 수주 ↑ | 기자재 소형주 ↑ | 공급망 낙수 | `[가설]` | 관찰 누적중 |
|
||||
|
||||
## 대장주 / 추종주 (Leaders & Followers)
|
||||
|
||||
> 대장주와 추종주. **종목 선정·동조 관계는 전부 `[가설]`** — `/invest-research`로 검증.
|
||||
|
||||
| 종목(티커) | 역할 | 왜(동조 근거) | 검증/가설 |
|
||||
|---|---|---|---|
|
||||
| 두산에너빌리티 (034020) | 대장주 | 원전 주기기·SMR | `[가설]` |
|
||||
| 한전기술 (052690) | 추종 | 원전 설계 | `[가설]` |
|
||||
| 비에이치아이 (083650) | 추종(소형) | 발전 기자재 | `[가설]` |
|
||||
| 우진 (105840) | 추종(소형) | 원전 계측 | `[가설]` |
|
||||
|
||||
## 관찰 지표 / What to watch
|
||||
|
||||
- 두산에너빌리티 주가, AI 데이터센터 capex, 원전 수출 뉴스, 전력 수요
|
||||
|
||||
## 경기 사이클 위치 / Cycle position
|
||||
|
||||
- AI·전력 투자 사이클·정책 우호기 강
|
||||
|
||||
## 검증 상태 / Verification
|
||||
|
||||
- `[검증]` 0개 · `[가설]` 7개
|
||||
|
||||
## Sources
|
||||
|
||||
- [[wiki/invest-strategy/strategy]]
|
||||
|
||||
## Related
|
||||
|
||||
- [[wiki/invest-concepts/field-bigtech-ai]]
|
||||
@@ -0,0 +1,56 @@
|
||||
---
|
||||
title: 원유 / WTI
|
||||
source_type: invest-concept
|
||||
status: draft
|
||||
confidence: low
|
||||
tags: [invest-concept, field-card, macro-asset]
|
||||
last_reviewed: 2026-06-08
|
||||
---
|
||||
|
||||
# 원유 / WTI
|
||||
|
||||
> Layer: `wiki/invest-concepts/` — 분야 카드. 모든 관계 행에 `[검증]/[가설]` 라벨 필수. `[가설]`은 외부 산출물 금지. 세무·투자 자문 아님 — [[wiki/invest-strategy/strategy]] §고지.
|
||||
|
||||
## Parent
|
||||
|
||||
- [[wiki/invest-concepts/field-map]]
|
||||
|
||||
## 한 줄 정의 / What it is
|
||||
|
||||
> 원유(WTI) = 핵심 원자재이자 인플레이션·경기 수요의 바로미터.
|
||||
|
||||
## 무엇이 이걸 움직이나 / Drivers
|
||||
|
||||
| 요인 | 방향 | 메커니즘 | 검증/가설 | 근거 |
|
||||
|---|---|---|---|---|
|
||||
| 글로벌 경기 수요 ↑ | 원유 ↑ | 산업 수요 | `[가설]` | 관찰 누적중 |
|
||||
| 공급 충격(OPEC/지정학) | 원유 ↑ | 공급 제약 | `[가설]` | 관찰 누적중 |
|
||||
| 달러 ↑ | 원유 ↓ | 달러표시 역상관 | `[가설]` | 관찰 누적중 (→ `field-dollar`) |
|
||||
|
||||
## 연결 / Linkages
|
||||
|
||||
| 이게 ↑하면 | → 따라 | 메커니즘 | 검증/가설 | 근거 |
|
||||
|---|---|---|---|---|
|
||||
| 원유 ↑ | 인플레이션 기대 ↑ → 금리 ↑ | 에너지발 물가 | `[가설]` | 관찰 누적중 (→ `field-us-rates`) |
|
||||
| 원유 ↑ | 에너지 섹터 주가 ↑ | 정유·E&P 이익 | `[가설]` | 관찰 누적중 |
|
||||
|
||||
## 관찰 지표 / What to watch
|
||||
|
||||
- WTI/Brent 유가, 미 원유재고, OPEC+ 결정
|
||||
|
||||
## 경기 사이클 위치 / Cycle position
|
||||
|
||||
- 확장 후반 강 / 침체 진입 시 수요붕괴로 급락
|
||||
|
||||
## 검증 상태 / Verification
|
||||
|
||||
- `[검증]` 0개 · `[가설]` 5개
|
||||
|
||||
## Sources
|
||||
|
||||
- [[wiki/invest-strategy/strategy]]
|
||||
|
||||
## Related
|
||||
|
||||
- [[wiki/invest-concepts/field-dollar]]
|
||||
- [[wiki/invest-concepts/field-us-rates]]
|
||||
@@ -0,0 +1,65 @@
|
||||
---
|
||||
title: 로봇·자동화 / Robotics
|
||||
source_type: invest-concept
|
||||
status: draft
|
||||
confidence: low
|
||||
tags: [invest-concept, field-card, sector]
|
||||
last_reviewed: 2026-06-08
|
||||
---
|
||||
|
||||
# 로봇·자동화 / Robotics
|
||||
|
||||
> Layer: `wiki/invest-concepts/` — 분야 카드. 모든 관계 행에 `[검증]/[가설]` 라벨 필수. `[가설]`은 외부 산출물 금지. 세무·투자 자문 아님 — [[wiki/invest-strategy/strategy]] §고지.
|
||||
|
||||
## Parent
|
||||
|
||||
- [[wiki/invest-concepts/field-map]]
|
||||
|
||||
## 한 줄 정의 / What it is
|
||||
|
||||
> 로봇·자동화(협동로봇·휴머노이드·FA). AI·인건비 상승·리쇼어링 테마. 성장주라 금리 민감 + 글로벌 빅테크 로봇 모멘텀에 동조(가설).
|
||||
|
||||
## 무엇이 이걸 움직이나 / Drivers
|
||||
|
||||
| 요인 | 방향 | 메커니즘 | 검증/가설 | 근거 |
|
||||
|---|---|---|---|---|
|
||||
| AI·휴머노이드 모멘텀 | 로봇 ↑ | 테마 자금 유입 | `[가설]` | 관찰 누적중 (→ `field-bigtech-ai`) |
|
||||
| 금리 ↓ | 로봇 ↑ | 성장주 할인 완화 | `[가설]` | 관찰 누적중 (→ `field-us-rates`) |
|
||||
|
||||
## 연결 / Linkages
|
||||
|
||||
| 이게 ↑하면 | → 따라 | 메커니즘 | 검증/가설 | 근거 |
|
||||
|---|---|---|---|---|
|
||||
| 대장 로봇주 ↑ | 부품·감속기 소형주 ↑ | 밸류체인 | `[가설]` | 관찰 누적중 |
|
||||
|
||||
## 대장주 / 추종주 (Leaders & Followers)
|
||||
|
||||
> 대장주와 추종주. **종목 선정·동조 관계는 전부 `[가설]`** — `/invest-research`로 검증.
|
||||
|
||||
| 종목(티커) | 역할 | 왜(동조 근거) | 검증/가설 |
|
||||
|---|---|---|---|
|
||||
| 두산로보틱스 (454910) | 대장주 | 협동로봇 | `[가설]` |
|
||||
| 레인보우로보틱스 (277810) | 대장주 | 휴머노이드(삼성 지분) | `[가설]` |
|
||||
| 에스피지 (058610) | 추종(소형) | 감속기·모터 | `[가설]` |
|
||||
| 로보스타 (090360) | 추종(소형) | 산업용 로봇 | `[가설]` |
|
||||
|
||||
## 관찰 지표 / What to watch
|
||||
|
||||
- 두산로보틱스·레인보우 주가, 글로벌 로봇 테마(테슬라 옵티머스), 금리
|
||||
|
||||
## 경기 사이클 위치 / Cycle position
|
||||
|
||||
- 저금리·AI 테마 우호기 강 (고변동 테마주)
|
||||
|
||||
## 검증 상태 / Verification
|
||||
|
||||
- `[검증]` 0개 · `[가설]` 7개
|
||||
|
||||
## Sources
|
||||
|
||||
- [[wiki/invest-strategy/strategy]]
|
||||
|
||||
## Related
|
||||
|
||||
- [[wiki/invest-concepts/field-bigtech-ai]]
|
||||
- [[wiki/invest-concepts/field-us-rates]]
|
||||
@@ -0,0 +1,74 @@
|
||||
---
|
||||
title: 분야간 로테이션 지도 / Sector Rotation Map
|
||||
source_type: invest-concept
|
||||
status: draft
|
||||
confidence: low
|
||||
tags: [invest-concept, field-card, rotation]
|
||||
last_reviewed: 2026-06-08
|
||||
---
|
||||
|
||||
# 분야간 로테이션 지도 / Sector Rotation Map
|
||||
|
||||
> Layer: `wiki/invest-concepts/` — 분야 *사이*의 돈 흐름(로테이션). "어디서 빠져 어디로 가나"의 연쇄. 모든 행 `[검증]/[가설]` 라벨. `[가설]`은 외부 산출물 금지. 세무·투자 자문 아님 — [[wiki/invest-strategy/strategy]] §고지.
|
||||
> ⚠️ **이 지도는 "예측"이 아니라 "관측 가설표"다.** 매일 `/invest-daily`가 "오늘 이 로테이션이 실제로 일어났나"를 채점해 `[가설]`→`[검증]`으로 익힌다. 어떤 로테이션도 *반드시 일어난다*고 보장하지 않는다.
|
||||
|
||||
## Parent
|
||||
|
||||
- [[wiki/invest-concepts/field-map]]
|
||||
|
||||
## 한 줄 정의 / What it is
|
||||
|
||||
> 돈은 한곳에 머물지 않고 *조건*(위험선호·금리·달러·경기)에 따라 분야 사이를 옮겨다닌다. 이 카드는 그 *이동 연쇄*를 4개 축으로 정리한다.
|
||||
|
||||
## 로테이션 축 / Rotation Axes
|
||||
|
||||
> 각 행: 조건 → 빠지는 쪽(↓) / 들어가는 쪽(↑). 관련 카드로 wikilink.
|
||||
|
||||
### ① 위험선호 / Risk Sentiment
|
||||
|
||||
| 조건 | 빠지는 쪽 ↓ | 들어가는 쪽 ↑ | 검증/가설 |
|
||||
|---|---|---|---|
|
||||
| risk-on (위험선호) | 금 · 달러 · 방어주(통신·유틸) | 반도체 · 2차전지 · 코인 · 성장주(게임·로봇·바이오) | `[가설]` |
|
||||
| risk-off (위험회피) | 성장주 · 코인 · 신흥국 | 금 · 달러 · 방산 · [[wiki/invest-concepts/field-telecom-utility]] | `[가설]` |
|
||||
|
||||
### ② 금리 / Rates
|
||||
|
||||
| 조건 | 빠지는 쪽 ↓ | 들어가는 쪽 ↑ | 검증/가설 |
|
||||
|---|---|---|---|
|
||||
| 금리 ↑ ([[wiki/invest-concepts/field-us-rates]]) | 성장주([[wiki/invest-concepts/field-bio-pharma]]·[[wiki/invest-concepts/field-internet-platform]]·2차전지) | [[wiki/invest-concepts/field-financials]](은행) · 가치·경기방어 | `[가설]` |
|
||||
| 금리 ↓ | 금융 | 성장주 · 바이오 · 로봇 | `[가설]` |
|
||||
|
||||
### ③ 달러 / Dollar
|
||||
|
||||
| 조건 | 빠지는 쪽 ↓ | 들어가는 쪽 ↑ | 검증/가설 |
|
||||
|---|---|---|---|
|
||||
| 달러 ↑ ([[wiki/invest-concepts/field-dollar]]) | [[wiki/invest-concepts/field-em-china]] · 원자재 · 금 | 미국 자산 | `[가설]` |
|
||||
| 달러 ↓ / 원화 약세 | — | 금 · 신흥국 · 수출주([[wiki/invest-concepts/field-auto]]) | `[가설]` |
|
||||
|
||||
### ④ 경기 사이클 / Cycle
|
||||
|
||||
| 조건 | 빠지는 쪽 ↓ | 들어가는 쪽 ↑ | 검증/가설 |
|
||||
|---|---|---|---|
|
||||
| 회복 초입 | 방어주 | 경기민감([[wiki/invest-concepts/field-semiconductors]]·[[wiki/invest-concepts/field-shipbuilding]]·[[wiki/invest-concepts/field-steel-materials]]·[[wiki/invest-concepts/field-chem-refining]]) | `[가설]` |
|
||||
| 둔화 | 경기민감 | 통신·유틸·필수소비([[wiki/invest-concepts/field-cosmetics-consumer]]) | `[가설]` |
|
||||
|
||||
## 관찰법 / How to observe
|
||||
|
||||
> 매일 `/invest-daily` "분야 관찰"에서: 오늘 어떤 *조건*(달러·금리·위험선호)이 움직였나 → 이 표가 예측한 *로테이션*이 실제로 일어났나(예: 금리↑인데 정말 금융↑·성장주↓?) 확인/반증.
|
||||
|
||||
## 검증 상태 / Verification
|
||||
|
||||
- `[검증]` 0개 · `[가설]` 8행 (관찰 누적 → `/invest-research`로 승급. 로테이션은 *항상* 성립하지 않으므로 "언제 성립/실패하는지"까지 봐야 함)
|
||||
|
||||
## Sources
|
||||
|
||||
- [[wiki/invest-strategy/strategy]]
|
||||
- [[raw/invest-research/2026-06-05-passive-diversification-behavior]]
|
||||
|
||||
## Related
|
||||
|
||||
- [[wiki/invest-concepts/field-map]]
|
||||
- [[wiki/invest-concepts/field-dollar]]
|
||||
- [[wiki/invest-concepts/field-us-rates]]
|
||||
- [[wiki/invest-concepts/field-financials]]
|
||||
- [[wiki/invest-concepts/field-telecom-utility]]
|
||||
@@ -0,0 +1,67 @@
|
||||
---
|
||||
title: 2차전지 / Secondary Battery
|
||||
source_type: invest-concept
|
||||
status: draft
|
||||
confidence: low
|
||||
tags: [invest-concept, field-card, sector]
|
||||
last_reviewed: 2026-06-08
|
||||
---
|
||||
|
||||
# 2차전지 / Secondary Battery
|
||||
|
||||
> Layer: `wiki/invest-concepts/` — 분야 카드. 모든 관계 행에 `[검증]/[가설]` 라벨 필수. `[가설]`은 외부 산출물 금지. 세무·투자 자문 아님 — [[wiki/invest-strategy/strategy]] §고지.
|
||||
|
||||
## Parent
|
||||
|
||||
- [[wiki/invest-concepts/field-map]]
|
||||
|
||||
## 한 줄 정의 / What it is
|
||||
|
||||
> 2차전지/전기차 밸류체인(셀·양극재·소재·장비). 전기차 수요 + 금리에 민감한 성장 테마.
|
||||
|
||||
## 무엇이 이걸 움직이나 / Drivers
|
||||
|
||||
| 요인 | 방향 | 메커니즘 | 검증/가설 | 근거 |
|
||||
|---|---|---|---|---|
|
||||
| 전기차 판매 ↑ | 2차전지 ↑ | 셀·소재 수요 | `[가설]` | 관찰 누적중 |
|
||||
| 금리 ↑ | 2차전지 ↓ | 성장주 할인 심화 | `[가설]` | 관찰 누적중 (→ `field-us-rates`) |
|
||||
| 리튬/니켈 가격 | 양방향 | 원가·마진 | `[가설]` | 관찰 누적중 |
|
||||
|
||||
## 연결 / Linkages
|
||||
|
||||
| 이게 ↑하면 | → 따라 | 메커니즘 | 검증/가설 | 근거 |
|
||||
|---|---|---|---|---|
|
||||
| 셀 대장주 ↑ | 소재·장비 소형주 ↑ | 밸류체인 동조 | `[가설]` | 관찰 누적중 |
|
||||
|
||||
## 대장주 / 추종주 (Leaders & Followers)
|
||||
|
||||
> 대장주(선행·시총/거래 주도)와 따라 움직이는 추종주. **종목 선정·동조 관계는 전부 `[가설]`** — `/invest-research`로 검증.
|
||||
|
||||
| 종목(티커) | 역할 | 왜(동조 근거) | 검증/가설 |
|
||||
|---|---|---|---|
|
||||
| LG에너지솔루션 (373220) | 대장주(셀) | 글로벌 배터리 셀 1위급 | `[가설]` |
|
||||
| 삼성SDI (006400) | 대장주(셀) | 각형·ESS | `[가설]` |
|
||||
| 에코프로비엠 (247540) | 대장주(소재) | 양극재 | `[가설]` |
|
||||
| 포스코퓨처엠 (003670) | 추종 | 양극재·음극재 | `[가설]` |
|
||||
| 엘앤에프 (066970) | 추종(소형) | 양극재 | `[가설]` |
|
||||
|
||||
## 관찰 지표 / What to watch
|
||||
|
||||
- 2차전지 ETF, 리튬·니켈 가격, 전기차 판매량, LG에너지솔루션 주가
|
||||
|
||||
## 경기 사이클 위치 / Cycle position
|
||||
|
||||
- 저금리·성장 선호기 강 / 금리 급등·전기차 수요둔화기 약
|
||||
|
||||
## 검증 상태 / Verification
|
||||
|
||||
- `[검증]` 0개 · `[가설]` 9개 (대장주/추종주 포함, 관찰 누적 → `/invest-research`로 승격)
|
||||
|
||||
## Sources
|
||||
|
||||
- [[wiki/invest-strategy/strategy]]
|
||||
|
||||
## Related
|
||||
|
||||
- [[wiki/invest-concepts/field-us-rates]]
|
||||
- [[wiki/invest-concepts/field-oil]]
|
||||
@@ -0,0 +1,67 @@
|
||||
---
|
||||
title: 반도체 / Semiconductors
|
||||
source_type: invest-concept
|
||||
status: draft
|
||||
confidence: low
|
||||
tags: [invest-concept, field-card, sector]
|
||||
last_reviewed: 2026-06-08
|
||||
---
|
||||
|
||||
# 반도체 / Semiconductors
|
||||
|
||||
> Layer: `wiki/invest-concepts/` — 분야 카드. 모든 관계 행에 `[검증]/[가설]` 라벨 필수. `[가설]`은 외부 산출물 금지. 세무·투자 자문 아님 — [[wiki/invest-strategy/strategy]] §고지.
|
||||
|
||||
## Parent
|
||||
|
||||
- [[wiki/invest-concepts/field-map]]
|
||||
|
||||
## 한 줄 정의 / What it is
|
||||
|
||||
> 반도체 = 경기·기술 사이클의 선행 지표로 자주 거론되는 핵심 산업(메모리·파운드리·설계).
|
||||
|
||||
## 무엇이 이걸 움직이나 / Drivers
|
||||
|
||||
| 요인 | 방향 | 메커니즘 | 검증/가설 | 근거 |
|
||||
|---|---|---|---|---|
|
||||
| AI/데이터센터 투자 ↑ | 반도체 ↑ | 수요 견인 | `[가설]` | 관찰 누적중 (→ `field-bigtech-ai`) |
|
||||
| 메모리 사이클(재고) | 양방향 | 공급과잉↔부족 주기 | `[가설]` | 관찰 누적중 |
|
||||
|
||||
## 연결 / Linkages
|
||||
|
||||
| 이게 ↑하면 | → 따라 | 메커니즘 | 검증/가설 | 근거 |
|
||||
|---|---|---|---|---|
|
||||
| 반도체 ↑ | 빅테크/지수 ↑ | 시총 비중·공급망 | `[가설]` | 관찰 누적중 (→ `field-us-equity`) |
|
||||
| 반도체 ↑ | 경기 선행 신호 | 수요 회복 방증 | `[가설]` | 관찰 누적중 |
|
||||
|
||||
## 대장주 / 추종주 (Leaders & Followers)
|
||||
|
||||
> 대장주와 추종주. **종목 선정·동조 관계는 전부 `[가설]`** — `/invest-research`로 검증. 글로벌 대장(엔비디아)이 국내 추종주(하이닉스·소부장)를 끄는 구조(가설).
|
||||
|
||||
| 종목(티커) | 역할 | 왜(동조 근거) | 검증/가설 |
|
||||
|---|---|---|---|
|
||||
| 엔비디아 (NVDA, 미국) | 글로벌 대장주 | AI 반도체 수요 선행 | `[가설]` |
|
||||
| SK하이닉스 (000660) | 국내 대장주 | HBM·메모리 사이클 선행 | `[가설]` |
|
||||
| 삼성전자 (005930) | 국내 대장주(메모리) | 메모리 양대 | `[가설]` |
|
||||
| 한미반도체 (042700) | 추종 | HBM 본더 장비 | `[가설]` |
|
||||
| HPSP (403870) | 추종(소형) | 고압어닐링 장비 | `[가설]` |
|
||||
|
||||
## 관찰 지표 / What to watch
|
||||
|
||||
- SOX(필라델피아 반도체지수), 엔비디아/TSMC/삼성전자, 메모리 현물가
|
||||
|
||||
## 경기 사이클 위치 / Cycle position
|
||||
|
||||
- 사이클 선행(회복 초입 강) / 과잉 국면 급락
|
||||
|
||||
## 검증 상태 / Verification
|
||||
|
||||
- `[검증]` 0개 · `[가설]` 4개
|
||||
|
||||
## Sources
|
||||
|
||||
- [[wiki/invest-strategy/strategy]]
|
||||
|
||||
## Related
|
||||
|
||||
- [[wiki/invest-concepts/field-bigtech-ai]]
|
||||
- [[wiki/invest-concepts/field-us-equity]]
|
||||
@@ -0,0 +1,66 @@
|
||||
---
|
||||
title: 조선 / Shipbuilding
|
||||
source_type: invest-concept
|
||||
status: draft
|
||||
confidence: low
|
||||
tags: [invest-concept, field-card, sector]
|
||||
last_reviewed: 2026-06-08
|
||||
---
|
||||
|
||||
# 조선 / Shipbuilding
|
||||
|
||||
> Layer: `wiki/invest-concepts/` — 분야 카드. 모든 관계 행에 `[검증]/[가설]` 라벨 필수. `[가설]`은 외부 산출물 금지. 세무·투자 자문 아님 — [[wiki/invest-strategy/strategy]] §고지.
|
||||
|
||||
## Parent
|
||||
|
||||
- [[wiki/invest-concepts/field-map]]
|
||||
|
||||
## 한 줄 정의 / What it is
|
||||
|
||||
> 조선(선박 건조). 글로벌 해운 사이클·LNG/탱커 발주·환경규제(친환경 선박) 수요에 민감. 후판(철강) 원가가 마진 변수.
|
||||
|
||||
## 무엇이 이걸 움직이나 / Drivers
|
||||
|
||||
| 요인 | 방향 | 메커니즘 | 검증/가설 | 근거 |
|
||||
|---|---|---|---|---|
|
||||
| 신조선가 ↑ | 조선 ↑ | 수주 단가·마진 | `[가설]` | 관찰 누적중 |
|
||||
| LNG·탱커 발주 ↑ | 조선 ↑ | 수주잔고 | `[가설]` | 관찰 누적중 |
|
||||
| 후판 가격 ↑ | 조선 마진 ↓ | 원가 부담 | `[가설]` | 관찰 누적중 |
|
||||
|
||||
## 연결 / Linkages
|
||||
|
||||
| 이게 ↑하면 | → 따라 | 메커니즘 | 검증/가설 | 근거 |
|
||||
|---|---|---|---|---|
|
||||
| 대형 조선 ↑ | 기자재·엔진 소형주 ↑ | 공급망 낙수 | `[가설]` | 관찰 누적중 |
|
||||
|
||||
## 대장주 / 추종주 (Leaders & Followers)
|
||||
|
||||
> 대장주와 추종주. **종목 선정·동조 관계는 전부 `[가설]`** — `/invest-research`로 검증.
|
||||
|
||||
| 종목(티커) | 역할 | 왜(동조 근거) | 검증/가설 |
|
||||
|---|---|---|---|
|
||||
| HD현대중공업 (329180) | 대장주 | 세계 1위급 조선 | `[가설]` |
|
||||
| 한화오션 (042660) | 대장주 | 옛 대우조선·특수선 | `[가설]` |
|
||||
| 삼성중공업 (010140) | 대장주 | LNG선 강점 | `[가설]` |
|
||||
| HD현대미포 (010620) | 추종 | 중형선 | `[가설]` |
|
||||
| HD현대마린엔진/기자재 소형주 | 추종 | 엔진·의장 공급망 | `[가설]` |
|
||||
|
||||
## 관찰 지표 / What to watch
|
||||
|
||||
- 신조선가지수(Clarksons), 후판 가격, 조선 3사 주가, LNG선 발주 뉴스
|
||||
|
||||
## 경기 사이클 위치 / Cycle position
|
||||
|
||||
- 해운 호황·발주 사이클 상승기 강
|
||||
|
||||
## 검증 상태 / Verification
|
||||
|
||||
- `[검증]` 0개 · `[가설]` 9개 (관찰 누적 → `/invest-research`로 승격)
|
||||
|
||||
## Sources
|
||||
|
||||
- [[wiki/invest-strategy/strategy]]
|
||||
|
||||
## Related
|
||||
|
||||
- [[wiki/invest-concepts/field-oil]]
|
||||
@@ -0,0 +1,65 @@
|
||||
---
|
||||
title: 철강·소재 / Steel & Materials
|
||||
source_type: invest-concept
|
||||
status: draft
|
||||
confidence: low
|
||||
tags: [invest-concept, field-card, sector]
|
||||
last_reviewed: 2026-06-08
|
||||
---
|
||||
|
||||
# 철강·소재 / Steel & Materials
|
||||
|
||||
> Layer: `wiki/invest-concepts/` — 분야 카드. 모든 관계 행에 `[검증]/[가설]` 라벨 필수. `[가설]`은 외부 산출물 금지. 세무·투자 자문 아님 — [[wiki/invest-strategy/strategy]] §고지.
|
||||
|
||||
## Parent
|
||||
|
||||
- [[wiki/invest-concepts/field-map]]
|
||||
|
||||
## 한 줄 정의 / What it is
|
||||
|
||||
> 철강·비철금속 소재. 경기민감(중국 수요·인프라)·원자재 가격에 좌우. 일부는 2차전지 소재(리튬·니켈)로 테마 겹침.
|
||||
|
||||
## 무엇이 이걸 움직이나 / Drivers
|
||||
|
||||
| 요인 | 방향 | 메커니즘 | 검증/가설 | 근거 |
|
||||
|---|---|---|---|---|
|
||||
| 중국 경기·인프라 ↑ | 철강 ↑ | 수요 견인 | `[가설]` | 관찰 누적중 (→ `field-em-china`) |
|
||||
| 원자재(철광석·니켈) 가격 | 양방향 | 원가·판가 | `[가설]` | 관찰 누적중 |
|
||||
|
||||
## 연결 / Linkages
|
||||
|
||||
| 이게 ↑하면 | → 따라 | 메커니즘 | 검증/가설 | 근거 |
|
||||
|---|---|---|---|---|
|
||||
| 철강 ↑ | 경기민감 신호(조선·건설 동조) | 전방 산업 | `[가설]` | 관찰 누적중 (→ `field-shipbuilding`) |
|
||||
|
||||
## 대장주 / 추종주 (Leaders & Followers)
|
||||
|
||||
> 대장주와 추종주. **종목 선정·동조 관계는 전부 `[가설]`** — `/invest-research`로 검증.
|
||||
|
||||
| 종목(티커) | 역할 | 왜(동조 근거) | 검증/가설 |
|
||||
|---|---|---|---|
|
||||
| POSCO홀딩스 (005490) | 대장주 | 철강 1위 + 2차전지 소재 | `[가설]` |
|
||||
| 현대제철 (004020) | 추종 | 철강(전기로) | `[가설]` |
|
||||
| 고려아연 (010130) | 추종 | 비철(아연·니켈) | `[가설]` |
|
||||
| 풍산 (103140) | 추종(소형) | 구리·방산소재 | `[가설]` |
|
||||
|
||||
## 관찰 지표 / What to watch
|
||||
|
||||
- POSCO홀딩스 주가, 철광석·니켈 가격, 중국 PMI, 조선·건설 수주
|
||||
|
||||
## 경기 사이클 위치 / Cycle position
|
||||
|
||||
- 경기 회복·인프라 투자기 강 / 둔화기 약
|
||||
|
||||
## 검증 상태 / Verification
|
||||
|
||||
- `[검증]` 0개 · `[가설]` 7개
|
||||
|
||||
## Sources
|
||||
|
||||
- [[wiki/invest-strategy/strategy]]
|
||||
|
||||
## Related
|
||||
|
||||
- [[wiki/invest-concepts/field-em-china]]
|
||||
- [[wiki/invest-concepts/field-shipbuilding]]
|
||||
@@ -0,0 +1,66 @@
|
||||
---
|
||||
title: 통신·유틸리티 / Telecom & Utility
|
||||
source_type: invest-concept
|
||||
status: draft
|
||||
confidence: low
|
||||
tags: [invest-concept, field-card, sector]
|
||||
last_reviewed: 2026-06-08
|
||||
---
|
||||
|
||||
# 통신·유틸리티 / Telecom & Utility
|
||||
|
||||
> Layer: `wiki/invest-concepts/` — 분야 카드. 모든 관계 행에 `[검증]/[가설]` 라벨 필수. `[가설]`은 외부 산출물 금지. 세무·투자 자문 아님 — [[wiki/invest-strategy/strategy]] §고지.
|
||||
|
||||
## Parent
|
||||
|
||||
- [[wiki/invest-concepts/field-map]]
|
||||
|
||||
## 한 줄 정의 / What it is
|
||||
|
||||
> 통신·전력(유틸리티). **방어주** — 경기·금리 둔감하고 배당 매력. 위험회피(risk-off) 국면에 상대적으로 강(가설).
|
||||
|
||||
## 무엇이 이걸 움직이나 / Drivers
|
||||
|
||||
| 요인 | 방향 | 메커니즘 | 검증/가설 | 근거 |
|
||||
|---|---|---|---|---|
|
||||
| 금리 ↑ | 배당주 ↓(상대) | 배당 매력 상대 하락 | `[가설]` | 관찰 누적중 (→ `field-us-rates`) |
|
||||
| 위험회피 | 통신·유틸 ↑(상대) | 방어 자금 이동 | `[가설]` | 관찰 누적중 |
|
||||
| 전기요금 인상 | 한전 ↑ | 적자 해소 | `[가설]` | 관찰 누적중 |
|
||||
|
||||
## 연결 / Linkages
|
||||
|
||||
| 이게 ↑하면 | → 따라 | 메커니즘 | 검증/가설 | 근거 |
|
||||
|---|---|---|---|---|
|
||||
| 위험회피 ↑ | 방어주(통신·유틸) ↑ / 성장주 ↓ | 로테이션 | `[가설]` | 관찰 누적중 |
|
||||
|
||||
## 대장주 / 추종주 (Leaders & Followers)
|
||||
|
||||
> 대장주와 추종주. **종목 선정·동조 관계는 전부 `[가설]`** — `/invest-research`로 검증.
|
||||
|
||||
| 종목(티커) | 역할 | 왜(동조 근거) | 검증/가설 |
|
||||
|---|---|---|---|
|
||||
| SK텔레콤 (017670) | 대장주(통신) | 통신·배당 | `[가설]` |
|
||||
| 한국전력 (015760) | 대장주(유틸) | 전력 독점 | `[가설]` |
|
||||
| KT (030200) | 추종 | 통신·AI | `[가설]` |
|
||||
| LG유플러스 (032640) | 추종 | 통신 | `[가설]` |
|
||||
| 한국가스공사 (036460) | 추종 | 가스 유틸 | `[가설]` |
|
||||
|
||||
## 관찰 지표 / What to watch
|
||||
|
||||
- SKT·한전 주가, 금리(배당 스프레드), 전기·가스 요금 정책, 시장 변동성(VIX)
|
||||
|
||||
## 경기 사이클 위치 / Cycle position
|
||||
|
||||
- 위험회피·둔화기 상대 강 / 위험선호·성장 랠리기 상대 약
|
||||
|
||||
## 검증 상태 / Verification
|
||||
|
||||
- `[검증]` 0개 · `[가설]` 9개
|
||||
|
||||
## Sources
|
||||
|
||||
- [[wiki/invest-strategy/strategy]]
|
||||
|
||||
## Related
|
||||
|
||||
- [[wiki/invest-concepts/field-us-rates]]
|
||||
@@ -0,0 +1,59 @@
|
||||
---
|
||||
title: 미국 주식 / US Equity (S&P500)
|
||||
source_type: invest-concept
|
||||
status: draft
|
||||
confidence: medium
|
||||
tags: [invest-concept, field-card, macro-asset]
|
||||
last_reviewed: 2026-06-08
|
||||
---
|
||||
|
||||
# 미국 주식 / US Equity (S&P500)
|
||||
|
||||
> Layer: `wiki/invest-concepts/` — 분야 카드. 모든 관계 행에 `[검증]/[가설]` 라벨 필수. `[가설]`은 외부 산출물 금지. 세무·투자 자문 아님 — [[wiki/invest-strategy/strategy]] §고지.
|
||||
|
||||
## Parent
|
||||
|
||||
- [[wiki/invest-concepts/field-map]]
|
||||
|
||||
## 한 줄 정의 / What it is
|
||||
|
||||
> 미국 주식(S&P500) = 500대 대형주. 사용자 코어 보유 자산(TIGER 미국S&P500 360750)의 기초지수.
|
||||
|
||||
## 무엇이 이걸 움직이나 / Drivers
|
||||
|
||||
| 요인 | 방향 | 메커니즘 | 검증/가설 | 근거 |
|
||||
|---|---|---|---|---|
|
||||
| 금리 ↑ | 주가 ↓(특히 성장주) | 할인율 상승 | `[가설]` | 관찰 누적중 (→ `field-us-rates`) |
|
||||
| 기업이익 기대 ↑ | 주가 ↑ | 펀더멘털 | `[가설]` | 관찰 누적중 |
|
||||
|
||||
## 연결 / Linkages
|
||||
|
||||
| 이게 ↑하면 | → 따라 | 메커니즘 | 검증/가설 | 근거 |
|
||||
|---|---|---|---|---|
|
||||
| S&P500 ↑ | 위험선호 → 신흥국·코인 동반 | risk-on 쏠림 | `[가설]` | 관찰 누적중 (→ `field-bitcoin`) |
|
||||
| 반도체/빅테크 ↑ | 지수 ↑(비중 큼) | 시총 가중 | `[가설]` | 관찰 누적중 (→ `field-semiconductors`) |
|
||||
|
||||
## 관찰 지표 / What to watch
|
||||
|
||||
- S&P500 지수, VIX(공포지수), TIGER 미국S&P500(360750) NAV
|
||||
|
||||
## 경기 사이클 위치 / Cycle position
|
||||
|
||||
- 확장기 강 / 침체·긴축기 약. **역사적 최대낙폭 -40~-57%** (단일 연도 -40% 사례)
|
||||
|
||||
## 검증 상태 / Verification
|
||||
|
||||
- `[검증]` 1개(드로다운 위험) · `[가설]` 4개
|
||||
|
||||
## Sources
|
||||
|
||||
- [[wiki/invest-strategy/strategy]]
|
||||
- [[raw/invest-research/2026-06-08-broad-equity-etf-100man-candidates]]
|
||||
- [[raw/invest-research/2026-06-08-korean-broad-etf-ticker-comparison]]
|
||||
|
||||
## Related
|
||||
|
||||
- [[wiki/invest-concepts/field-us-rates]]
|
||||
- [[wiki/invest-concepts/field-bitcoin]]
|
||||
- [[wiki/invest-concepts/field-semiconductors]]
|
||||
- [[wiki/invest-concepts/field-bigtech-ai]]
|
||||
@@ -0,0 +1,58 @@
|
||||
---
|
||||
title: 미 10년물 금리 / US 10Y
|
||||
source_type: invest-concept
|
||||
status: draft
|
||||
confidence: low
|
||||
tags: [invest-concept, field-card, macro-asset]
|
||||
last_reviewed: 2026-06-08
|
||||
---
|
||||
|
||||
# 미 10년물 금리 / US 10Y
|
||||
|
||||
> Layer: `wiki/invest-concepts/` — 분야 카드. 모든 관계 행에 `[검증]/[가설]` 라벨 필수. `[가설]`은 외부 산출물 금지. 세무·투자 자문 아님 — [[wiki/invest-strategy/strategy]] §고지.
|
||||
|
||||
## Parent
|
||||
|
||||
- [[wiki/invest-concepts/field-map]]
|
||||
|
||||
## 한 줄 정의 / What it is
|
||||
|
||||
> 미 10년물 국채금리 = 글로벌 자산 할인율의 기준. 오르면 미래 현금흐름의 현재가치↓.
|
||||
|
||||
## 무엇이 이걸 움직이나 / Drivers
|
||||
|
||||
| 요인 | 방향 | 메커니즘 | 검증/가설 | 근거 |
|
||||
|---|---|---|---|---|
|
||||
| 인플레이션 기대 ↑ | 금리 ↑ | 채권 실질수익 방어 요구 | `[가설]` | 관찰 누적중 |
|
||||
| 연준 긴축 | 금리 ↑ | 정책금리·QT | `[가설]` | 관찰 누적중 |
|
||||
|
||||
## 연결 / Linkages
|
||||
|
||||
| 이게 ↑하면 | → 따라 | 메커니즘 | 검증/가설 | 근거 |
|
||||
|---|---|---|---|---|
|
||||
| 금리 ↑ | 성장주/빅테크 ↓ | 먼 미래 현금흐름 할인 심화 | `[가설]` | 관찰 누적중 (→ `field-bigtech-ai`) |
|
||||
| 금리 ↑ | 달러 ↑ | 금리차 매력 | `[가설]` | 관찰 누적중 (→ `field-dollar`) |
|
||||
| 금리 ↑ | 금 ↓ | 무이자 자산 불리 | `[가설]` | 관찰 누적중 (→ `field-gold`) |
|
||||
|
||||
## 관찰 지표 / What to watch
|
||||
|
||||
- 미 10Y 국채금리, 2Y-10Y 스프레드(장단기 역전), 한미 금리차
|
||||
|
||||
## 경기 사이클 위치 / Cycle position
|
||||
|
||||
- 확장기 상승 / 침체 진입 시 급락(완화 기대)
|
||||
|
||||
## 검증 상태 / Verification
|
||||
|
||||
- `[검증]` 0개 · `[가설]` 5개
|
||||
|
||||
## Sources
|
||||
|
||||
- [[wiki/invest-strategy/strategy]]
|
||||
|
||||
## Related
|
||||
|
||||
- [[wiki/invest-concepts/field-bigtech-ai]]
|
||||
- [[wiki/invest-concepts/field-dollar]]
|
||||
- [[wiki/invest-concepts/field-gold]]
|
||||
- [[wiki/invest-concepts/field-us-equity]]
|
||||
@@ -0,0 +1,109 @@
|
||||
---
|
||||
title: 활성 투자 계획 (Active Plan)
|
||||
source_type: invest-plan
|
||||
status: draft
|
||||
confidence: medium
|
||||
tags: [invest-plan, personal-invest, finance]
|
||||
last_reviewed: 2026-06-08
|
||||
---
|
||||
|
||||
# 활성 투자 계획 (Active Plan)
|
||||
|
||||
> Layer: `wiki/invest-plan/` — 현재 활성 투자 계획. `wiki/invest-strategy/` 규칙 + 최근 `raw/invest-research/`·`raw/invest-daily/` 조사에서 도출. **모든 항목은 canonical/증거 근거 링크 필수.**
|
||||
> ⚠️ 면허 자문 아님 — [[wiki/invest-strategy/strategy]] §고지 참조.
|
||||
|
||||
## Parent
|
||||
|
||||
- [[wiki/invest/invest-hub]]
|
||||
|
||||
## 현재 자본·목표·계좌
|
||||
|
||||
- 가용 자본: **100만 원** (여유자금, 1년+ 미사용 — [[wiki/invest-strategy/strategy]] 프로필 2026-06-08)
|
||||
- **현재 자본 구간**: **~200만 이하** → 기본 전략 = **광범위 ETF 1~2개**(strategy ①). 개별주 분산·집중 베팅 ❌.
|
||||
- MDD 수용 / 주식 비중: **~-40% / 주식 90~100%** (폭락장에서 안 판다 확인, 전략 프로필 2026-06-08)
|
||||
- 계좌: **일반 위탁계좌** ([[raw/invest-research/2026-06-08-isa-vs-general-account-no-income]] — 무소득·소액·단기엔 ISA 실익 없음; 연금/IRP 기각)
|
||||
- 매수 방식: **일시매수 (100만 1회)** — 금액이 작고 장기보유 확신 + 일시매수가 역사적 평균 ~2/3 우세(strategy ⑤, [[raw/invest-research/2026-06-05-passive-diversification-behavior]] #C5). 직후 하락해도 안 판다는 전제.
|
||||
- 이번 분기 목표: **장기 시장수익 추종. 고정 목표금액 없음** — 3달은 동전던지기라 "정산" 아니라 "점검".
|
||||
|
||||
## 목표 자산 배분 / Target Allocation
|
||||
|
||||
> 자본 100만 = 전략 ① "~200만 이하" 구간. 감내력 확인(-40% 버팀) → 주식 비중 높게.
|
||||
|
||||
| 자산 | 분류 | 목표 비중% | 근거(링크) |
|
||||
|---|---|---|---|
|
||||
| **TIGER 미국S&P500 (360750)** 국내상장·언헤지 | 코어 | **90~100%** | [[wiki/invest-strategy/strategy]] ① / [[raw/invest-research/2026-06-08-broad-equity-etf-100man-candidates]] C1·C2·C3 / [[raw/invest-research/2026-06-08-korean-broad-etf-ticker-comparison]] C1·C7 |
|
||||
| 현금 완충 (예금/CMA) | 완충 | **0~10%** | 급매수 충동 방지. `UNSUPPORTED_IMPL_DECISION` — 정확 비율은 심리 재량 |
|
||||
| 개별주 / 테마 베팅 | 베팅 | **0%** | 전략 ① (소액 집중 베팅 비권장) |
|
||||
|
||||
## 보유 종목 / Holdings
|
||||
|
||||
> [[raw/invest-ledger/ledger]]와 동기화(원장이 사실 SSOT). 종목 확정(TIGER 360750), **매수 전이라 보유 0** — 매수 후 `/invest-decide`로 수량·평단 채움.
|
||||
|
||||
| 종목/티커 | 분류 | 보유수량 | 평단 | 현재 비중% | 목표 비중% | 근거(링크) |
|
||||
|---|---|---|---|---|---|---|
|
||||
| **TIGER 미국S&P500 (360750)** | 코어 | 0 (매수 전) | — | 0% | 90~100% | [[raw/invest-research/2026-06-08-korean-broad-etf-ticker-comparison]] C1 |
|
||||
|
||||
## 매수 실행 / Execution Plan
|
||||
|
||||
> **무엇을·얼마를·언제·어느 계좌에서.**
|
||||
|
||||
- **무엇을 (종목)**: ✅ **TIGER 미국S&P500 (종목코드 360750), 언헤지** — 실부담(TER) 최저 0.1387% + AUM 최대 19.4조(안정·유동성) + 패시브 ([[raw/invest-research/2026-06-08-korean-broad-etf-ticker-comparison]] C1·C7). 대안: RISE 미국S&P500(379780).
|
||||
- **얼마를 (금액)**: **100만 원 전액** (현금 완충 0~10% 둘 거면 90만 매수 + 10만 예비). 매수 직전 주당 가격 확인 후 가능한 주수 매수(소액 잔돈은 완충).
|
||||
- **언제·어떻게 (스케줄)**: **일시매수 1회** — 일반 위탁계좌 개설(있으면 생략) 직후. 타이밍 노림 ❌, "좋은 날" 기다리지 않음(strategy ③).
|
||||
- **다음 매수 트리거**: 현재 추가납입 없음 → **소득 발생 시** 월 추가납입 개시(아래 로드맵). 그 전까지 100만 단일 원금 보유.
|
||||
- **매수 직전 재확인**: 보수율·AUM·NAV는 스냅샷 → 미래에셋 TIGER 공식 페이지에서 매수 당일 재확인([[raw/invest-research/2026-06-08-korean-broad-etf-ticker-comparison]] S1).
|
||||
- **매수 후**: `/invest-decide "매수 TIGER미국S&P500 [수량] [단가]"` 로 [[raw/invest-ledger/ledger]] 기록.
|
||||
|
||||
## 자본 성장 로드맵 / Capital Growth Ladder
|
||||
|
||||
> "100만으로 시작해 키운다"의 체계. strategy ① 자본 구간 + ④ 절세계좌 조건을 단계로. **전환은 자본 임계치 / 소득 발생 트리거로.**
|
||||
|
||||
| 단계 | 자본 구간 | 전략 (strategy ①) | 계좌·절세 (strategy ④) | 전환 트리거 |
|
||||
|---|---|---|---|---|
|
||||
| **▶ 현재** | ~200만 이하 | 광범위 ETF **1개** 일시매수 후 보유 | 일반 위탁계좌, 절세계좌 보류 | — |
|
||||
| 다음 | 200만~1,000만 | ETF 코어 + 위성 1~2 자산군(채권 등) | **소득 발생 시 ISA/연금 재검토** | 자본 200만 돌파 **또는** 소득 발생 |
|
||||
| 그다음 | 1,000만~ | 자산군 배분(주식·채권·원자재) 본격화 | ISA 손익통산 가치 발현 가능 | 자본 1,000만 돌파 |
|
||||
|
||||
- **🔑 소득 발생 트리거 (가장 중요한 전환점)**: 취업·소득 생기면 → ① 월 추가납입 시작(매수 실행 § 갱신), ② **절세계좌 재검토** — 결정세액이 생기면 ISA 손익통산·연금 세액공제 가치가 발생해 *무소득 시 "일반계좌" 결론이 뒤집힐 수 있음*([[raw/invest-research/2026-06-08-isa-vs-general-account-no-income]]), ③ MDD·목표·주식비중 재설정 가능. → 그때 `/invest-research` + `/invest-plan` 재실행.
|
||||
|
||||
## 리밸런싱·점검 규칙 / Review Cadence
|
||||
|
||||
- **점검 주기**: **분기(3개월) 1회**. 분기말 하락장이어도 강제매도 ❌ (strategy ②).
|
||||
- **리밸런싱 밴드**: 목표 배분 **±5%p** 이탈 시 검토 (strategy ①, 재량 — 잦은 매매 경계).
|
||||
- **점검 체크리스트**: ① 비중 drift(주식 vs 현금) ② stale 조사(90일+ 재조사) ③ 규칙 위반 매매 ④ **자본 구간/소득 전환 도달 여부**(로드맵).
|
||||
- 실행: `/invest-review`.
|
||||
|
||||
## 워치리스트 / Watchlist
|
||||
|
||||
> 상장지·종목 확정 완료. 아래는 선택 기록 + 향후 확장 후보.
|
||||
|
||||
| 종목/티커(유형) | 왜 후보인가 | 검증 상태(invest-research 링크) | 상태 |
|
||||
|---|---|---|---|
|
||||
| ✅ **TIGER 미국S&P500 (360750)** | 실부담 최저·AUM 최대·패시브·언헤지 | 운용사 공식 3-0 (`...ticker-comparison` C1·C7) | **선택 — 첫 종목** |
|
||||
| RISE 미국S&P500 (379780) | 헤드라인 보수 최저, 단 실부담 약간↑·규모↓ | 운용사 공식 3-0 (C3) | 대안 |
|
||||
| 국내상장 패시브 **전세계 ETF** | 미국집중 넘어 전세계 분산 | ⏳ 저비용 패시브 종목 **미확인**(TIGER 토탈월드는 액티브) | 자본 커지면 추가 조사 |
|
||||
|
||||
## 리스크·한계
|
||||
|
||||
- 이 계획이 틀릴 수 있는 지점:
|
||||
- **-40% 드로다운을 실제로 보면 못 버틸 위험** — 전체 계획의 단일 최대 전제. 못 버티면 주식 비중 낮춰 재설계(strategy ②·③).
|
||||
- **종목 집중**: TIGER 미국S&P500은 미국 1개국 집중(전세계 분산 아님). 미국 장기 우위는 귀납적이며 보장 아님 — 분산을 더 원하면 향후 전세계 ETF 추가.
|
||||
- 국내상장 언헤지 ETF도 **환율 변동** 노출([[raw/invest-daily/2026-06-06]] 환율 1,550원대) — 환헤지(H) 선택 시 환위험↓·헤지비용↑.
|
||||
- 말하면 안 되는 범위: 특정 ETF "좋다" 단정, 국내상장 구체 보수율(미검증), 무소득 비교과세 임계치(기각), 2026 ISA 확대안(미확정 입법).
|
||||
|
||||
## 규칙 사전 점검 (Rule Pre-check)
|
||||
|
||||
- ① 포지션 크기: 광범위 ETF 90~100% 코어 / 베팅 0% → **준수 ✅**
|
||||
- ② 손절/익절: 코어 ETF 무손절·장기보유, -40% 수용 → **준수 ✅**
|
||||
- ③ 행동 가드레일: 타이밍 노림 금지·일시매수 후 보유 → **준수 ✅**
|
||||
- ④ 절세계좌: 무소득 → 연금/ISA 기각, 일반 위탁계좌 확정 → **준수 ✅**
|
||||
- 위반: **없음.** 자본·MDD·계좌·상장지·매수방식·**종목까지 전부 확정.** 남은 건 실제 행동 — 일반 위탁계좌 개설 + TIGER 360750 100만 일시매수 → `/invest-decide` 기록.
|
||||
|
||||
## Sources
|
||||
|
||||
- [[wiki/invest-strategy/strategy]]
|
||||
- [[raw/invest-research/2026-06-08-broad-equity-etf-100man-candidates]]
|
||||
- [[raw/invest-research/2026-06-08-isa-vs-general-account-no-income]]
|
||||
- [[raw/invest-research/2026-06-08-korean-broad-etf-ticker-comparison]]
|
||||
- [[raw/invest-research/2026-06-05-passive-diversification-behavior]]
|
||||
- [[raw/invest-daily/2026-06-06]]
|
||||
@@ -0,0 +1,106 @@
|
||||
---
|
||||
title: 개인 투자 전략 규칙 (Personal Invest Strategy)
|
||||
source_type: invest-strategy
|
||||
status: draft
|
||||
confidence: medium
|
||||
tags: [invest-strategy, personal-invest, finance]
|
||||
last_reviewed: 2026-06-08
|
||||
---
|
||||
|
||||
# 개인 투자 전략 규칙 (Personal Invest Strategy)
|
||||
|
||||
> Layer: `wiki/invest-strategy/` — 내 투자 전략 규칙. `/invest-decide`가 이 문서의 고정 규칙과 매매를 대조한다.
|
||||
|
||||
## ⚠️ 고지 (Disclaimer)
|
||||
|
||||
> **이 시스템은 면허 있는 투자자문(PB)이 아니다.** Claude는 환각으로 틀릴 수 있고, 손실에 책임지지 않으며, 주문 체결·자산 보관도 하지 않는다. 정확한 성격은 **"규율을 강제하는 투자 의사결정 저널 + 리서치 보조"**다(진짜 PB와 비교하면 — 어디까지나 주관적 추정으로 — 실행·수탁·세금 인프라가 없어 한참 못 미치고, 따라 할 수 있는 건 프로세스·규율 층뿐이다). 모든 수치는 **조사 시점 기준**이며, 사용자가 반드시 출처 링크로 교차검증해야 한다. 최종 손실 책임은 본인에게 있다.
|
||||
|
||||
## Parent
|
||||
|
||||
- [[wiki/invest/invest-hub]]
|
||||
|
||||
## 내 프로필 (규칙 기준점)
|
||||
|
||||
> 입력: 2026-06-08 사용자 확정.
|
||||
|
||||
- 시작 자본: **100만 원** (여유자금 — 1년+ 안 써도 되는 돈으로 확인. 곧 쓸 돈 아님 → 주식 ETF 투자 적격.)
|
||||
- 목표 금액 / 기간: **분기(3개월) 주기로 점검·갱신. 목표 = "장기 시장수익 추종(주식 비중 높게)".**
|
||||
- ⚠️ 사용자 최초 요청은 "3달간 *벌 수 있는 최대 금액*"이었으나, **최대수익과 손실상한은 양립 불가** — 수익 천장과 손실 바닥은 한 몸이다. "최대"가 아니라 **장기 시장수익 추종**으로 고정.
|
||||
- 현실적 기대치: 광범위 주식 ETF의 **장기 연 기대수익 ≈ +7~8%**(귀납적, 보장 아님). 단일 분기 결과는 **-15% ~ +15%** 어디든 정상(고변동). 음(-)의 분기는 실패가 아님. **3달은 주식엔 너무 짧아 동전던지기** — 수익은 수년 묵혀야 평균 수렴.
|
||||
- **3개월 주기 = 점검·리밸런스 주기이지 강제 정산이 아니다.** 분기말이 하락장이면 규칙 ②(코어 ETF 무손절·장기보유)에 따라 **보유 유지** — 하락장 한복판 강제매도 금지.
|
||||
- 월 추가납입: **없음** (별도 납입 계획 없음. 100만 단일 원금으로 운용.)
|
||||
- 최대 감내손실(MDD): **현실 인정 ~-40% (광범위 주식 ETF의 역사적 최대낙폭 수준) / 주식 비중 90~100%.**
|
||||
- 2026-06-08 결정: 사용자 최초 -20% 상한은 **100% 주식 ETF와 물리적으로 충돌**(근거: [[raw/invest-research/2026-06-08-broad-equity-etf-100man-candidates]] C2 — MSCI World 금융위기 -57.82%, 2008년 -40.71%). "어느 날 -40%(100만→60만) 찍혀도 안 팔고 버틸 수 있다"를 사용자가 확인 → **수익 우선·주식 비중 높게** 선택. 따라서 MDD 상한을 -20%에서 **현실적 -40%로 정직하게 상향**(규칙이 거짓말하지 않도록).
|
||||
- **단서**: 이 결정의 유일한 전제는 *폭락장에서 안 판다*. -40%를 보고 패닉셀하면 이 전략은 무너진다(규칙 ③ 패닉셀 가드 + ② 무손절 장기보유). 여유자금·1년+ 미사용·취준생 무소득(곧 쓸 돈 아님)이라 전제 성립.
|
||||
- **현재 과세소득(결정세액): 없음 (취준생, 별도 소득 없음 — 2026-06-08 확인)** → 절세계좌(특히 연금저축·IRP) **비권장 확정**. 결정세액이 0이면 세액공제 가치가 없고 락업(중도인출 16.5% 페널티)만 남는다(규칙 ④). **ISA 또는 일반 위탁계좌가 적합.**
|
||||
|
||||
## ① 포지션 크기 규칙 (자본 구간별)
|
||||
|
||||
> 근거: [[raw/invest-research/2026-06-05-passive-diversification-behavior]] (#C1 액티브 장기열위, #C2 분산 임계). 판정 KEEP.
|
||||
|
||||
| 자본 구간 | 기본 전략 | 근거 |
|
||||
|---|---|---|
|
||||
| ~200만 이하 | **광범위 ETF 1~2개로 집중** (소액 개별주 분산 ❌) | 광범위 ETF 1개 = 수백~수천 종목 분산. 소액 개별주 분산은 비효율 [#C1, #C2] |
|
||||
| 200만~1,000만 | ETF 코어 + 위성 1~2 자산군 | 분산효과가 비용을 초과하기 시작 |
|
||||
| 1,000만~ | 자산군 배분(주식·채권·원자재) 본격화 | 진짜 자산배분 단계 |
|
||||
|
||||
> **현 60만 원의 정답은 "올인 한 종목"이 아니라 광범위 ETF 1~2개**(그 자체가 분산). 자본이 늘면 규칙이 자동 전환된다.
|
||||
|
||||
### 리밸런싱 밴드 (drift 허용폭)
|
||||
|
||||
- **목표 배분에서 ±5%p 이탈 시 리밸런싱 검토.** `/invest-review`가 이 밴드로 이탈을 플래그한다.
|
||||
- ⚠️ **±5%p 는 근거가 아니라 내 위험감내 재량 — `UNSUPPORTED_DECISION`.** 학술적 "최적 밴드"는 비용·세금·변동성에 따라 다르고 이 숫자는 임의 기본값이다. 잦은 리밸런싱은 수수료·세금·잦은 매매(③ #C3)를 늘리므로 밴드를 너무 좁히지 않는다. **사용자 조정 가능.**
|
||||
|
||||
## ② 손절 / 익절 규칙
|
||||
|
||||
> 근거: [[raw/invest-research/2026-06-05-stoploss-takeprofit-tax-accounts]] (#C1 기계적 손절=기대수익↓, #C2 기계적 익절=복리손상 REJECT, #C3 드로다운 회복 KEEP).
|
||||
|
||||
- **코어(광범위 ETF): 손절·익절 규칙 없음. 장기보유.** `[KEEP]` — 지수 드로다운은 역사적으로 회복돼 왔다(#C3). **단서**: 회복에 수년~수십 년 걸린 적 있고 귀납적이라 미래 보장은 아님.
|
||||
- **개별 베팅(선택 시): 손절/익절은 "근거 있는 규칙"이 아니라 본인의 위험감내 재량.** 손절선을 두려면 반드시 `UNSUPPORTED_DECISION` 라벨 + **"이건 근거가 아니라 내 위험감내 재량이다"** 한 줄을 함께 기록. 특정 숫자(−15%)는 임의값.
|
||||
- **기계적 익절(+20~30%)은 근거상 비권장 `[REJECT]`** — 승자를 일찍 잘라 복리를 손상(#C2, Haghani 2023 / Dybvig 1988). 특정 숫자(+20~30%)는 임의값.
|
||||
|
||||
## ③ 행동 가드레일
|
||||
|
||||
> 근거: [[raw/invest-research/2026-06-05-passive-diversification-behavior]] (#C3 잦은 매매 수익손상 Barber-Odean, #C4 행동격차 Morningstar Mind the Gap ≈ 연 1.1%p — DALBAR식 3~4% 아님). 판정 CORRECT(근거 정교화).
|
||||
|
||||
- **패닉셀 24h 쿨다운**: 급락을 본 뒤 24시간 내 매도 결정 시 빨간 플래그 + "이유 먼저 쓰라" 강제.
|
||||
- **FOMO 가드**: 단기 급등 종목 신규매수 시 경고.
|
||||
- **주간 거래상한**: 주 N회 초과 매매 시 플래그 [#C3 — 최다거래 11.4% vs 시장 17.9%].
|
||||
- **선(先)근거 원칙**: 근거 문서 링크 없는 매매는 `/invest-decide`가 기록을 거부.
|
||||
- ⚠️ **참고(가드 근거 아님)**: "최고의 날을 놓치면 망한다" 류 논리는 **약하다(대칭성 반론 — 최악의 날도 함께 놓침)**. 이 논리는 행동 가드의 근거로 쓰지 않는다.
|
||||
|
||||
## ④ 절세계좌 우선순위 (조건부)
|
||||
|
||||
> 근거: [[raw/invest-research/2026-06-05-stoploss-takeprofit-tax-accounts]] (#C4 ISA 한도, #C5 연금 세액공제·락업). 판정 CORRECT→조건부.
|
||||
|
||||
- **사전 체크 (먼저 답할 것)**: ① 낼 소득세(결정세액)가 있는가? ② 이 돈을 곧 쓰는가? → 무소득/단기자금이면 연금계좌(연금저축·IRP) **비권장**(중도인출 16.5% 페널티 = 락업). 무조건 "연금 먼저"는 ❌. 소액·저소득·단기자금이면 ISA/일반계좌가 더 적절할 수 있음.
|
||||
- **계좌별 한도 (전부 2025년 시행 기준)**:
|
||||
- ISA: 연 2,000만 / 총 1억 / 비과세 일반 200만(서민 400만) / 초과분 9.9% 분리과세 / 의무가입 3년.
|
||||
- 연금저축: 연 600만 세액공제 (총급여 5,500만 이하 16.5% / 초과 13.2%).
|
||||
- IRP: 연금저축 합산 900만 세액공제, 총 납입한도 1,800만.
|
||||
- **⚠️ 미확정**: **2026 ISA 확대안(연 4,000만·비과세 500만)은 국회 통과 전 — 확정 숫자로 인용 금지.** 현재는 사실 아님.
|
||||
|
||||
## ⑤ 목표·금액
|
||||
|
||||
- 시작자본(100만), 목표(장기 시장수익 추종·분기 점검), 월 추가납입(없음), 최대 감내손실(MDD ~-40% 현실 인정·주식 90~100%)을 위 [내 프로필](#내-프로필-규칙-기준점)에서 한 줄씩 명시 → ①~④ 규칙의 기준점. **2026-06-08 사용자 확정 완료(MDD는 연구 근거로 -20%→-40% 정직 상향).**
|
||||
- **DCA(분할)/일시매수**: 일시매수가 역사·시뮬레이션상 평균 ~2/3 우세하지만, DCA는 하락·후회 위험을 줄이는 선택 [#C5, Vanguard]. **수익 전략이 아니라 리스크/심리 전략으로 표기.**
|
||||
|
||||
## 규칙 근거 / Rule Provenance
|
||||
|
||||
> 각 규칙이 어느 증거에서 도출됐는지. 근거 없는 규칙은 `UNSUPPORTED_DECISION` 라벨.
|
||||
|
||||
| 규칙 | Supporting Claim | 판정 |
|
||||
|---|---|---|
|
||||
| ① 소액=광범위 ETF 1~2개 집중 | [[raw/invest-research/2026-06-05-passive-diversification-behavior]] #C1, #C2 | KEEP |
|
||||
| ② 코어 ETF 무손절·무익절·장기보유 | [[raw/invest-research/2026-06-05-stoploss-takeprofit-tax-accounts]] #C3 | KEEP (단서: 회복 수년~수십년) |
|
||||
| ② 개별 베팅 손절선(둘 경우) | (근거 없음 — 위험감내 재량) | `UNSUPPORTED_DECISION` |
|
||||
| ② 기계적 익절(+X%) 비권장 | [[raw/invest-research/2026-06-05-stoploss-takeprofit-tax-accounts]] #C2 | REJECT |
|
||||
| ③ 주간 거래상한 | [[raw/invest-research/2026-06-05-passive-diversification-behavior]] #C3 | CORRECT(근거 정교화) |
|
||||
| ③ 패닉셀 24h 쿨다운 / FOMO / 선근거 | [[raw/invest-research/2026-06-05-passive-diversification-behavior]] #C4 | CORRECT(근거 정교화) |
|
||||
| ④ 절세계좌 조건부 우선순위 | [[raw/invest-research/2026-06-05-stoploss-takeprofit-tax-accounts]] #C4, #C5 | CORRECT→조건부 |
|
||||
| ⑤ DCA=리스크/심리 전략 | [[raw/invest-research/2026-06-05-passive-diversification-behavior]] #C5 | CORRECT |
|
||||
|
||||
## Sources
|
||||
|
||||
- [[raw/invest-research/2026-06-05-passive-diversification-behavior]] — 패시브·분산·행동격차 근거 (SPIVA·Statman·Barber-Odean·Morningstar·Vanguard·Ibbotson-Kaplan)
|
||||
- [[raw/invest-research/2026-06-05-stoploss-takeprofit-tax-accounts]] — 손절·익절·한국 절세계좌 근거 (Kaminski-Lo·Haghani·Dybvig·국세청·금융위·KB)
|
||||
@@ -0,0 +1,50 @@
|
||||
---
|
||||
title: 개인 투자 허브 (Personal Invest Hub)
|
||||
source_type: invest-concept
|
||||
status: draft
|
||||
confidence: unknown
|
||||
tags: [invest-concept, personal-invest, finance]
|
||||
last_reviewed: 2026-06-05
|
||||
---
|
||||
|
||||
# 개인 투자 허브 (Personal Invest Hub)
|
||||
|
||||
> Layer: `wiki/invest/` — 개인 투자 cluster의 named hub(루트). 개발 프로젝트와 분리된 트리. 모든 invest 문서가 이리로 upward link.
|
||||
|
||||
## ⚠️ 고지
|
||||
|
||||
면허 자문 아님. 상세는 [[wiki/invest-strategy/strategy]] §고지 참조.
|
||||
|
||||
## 파이프라인
|
||||
|
||||
조사(`/invest-daily`·`/invest-research`) → 변환(`/invest-ingest`) → 계획(`/invest-plan`) → 결정(`/invest-decide`) → 리뷰(`/invest-review`).
|
||||
|
||||
## Cluster
|
||||
|
||||
### 증거 (raw)
|
||||
- 일일 조사: `raw/invest-daily/`
|
||||
- 심층 조사: `raw/invest-research/`
|
||||
- 매매 원장: [[raw/invest-ledger/ledger]]
|
||||
|
||||
### canonical (wiki)
|
||||
- 개념: `wiki/invest-concepts/`
|
||||
- 분야 지식 지도: [[wiki/invest-concepts/field-map]] — 거시·섹터 카드 허브(분야 간 인과·상관 + `[검증]/[가설]` 라벨)
|
||||
- 전략: [[wiki/invest-strategy/strategy]]
|
||||
- 활성 계획: [[wiki/invest-plan/active-plan]]
|
||||
|
||||
### 시스템 설계 (project-note)
|
||||
- 자금흐름 관측 시스템 hub: [[raw/project-notes/invest-money-flow-system]]
|
||||
|
||||
### 템플릿 (형식 정의)
|
||||
- [[templates/invest-daily-template]] — `raw/invest-daily/` 일일 거시 조사
|
||||
- [[templates/invest-research-template]] — `raw/invest-research/` 심층 조사 (verbatim 인용 보존)
|
||||
- [[templates/invest-ledger-template]] — `raw/invest-ledger/` 매매 원장 (사실 기록)
|
||||
- [[templates/invest-concept-template]] — `wiki/invest-concepts/` 투자 개념
|
||||
- [[templates/invest-field-card-template]] — `wiki/invest-concepts/` 분야 지식 카드 ([[wiki/invest-concepts/field-map]] 하위)
|
||||
- [[templates/invest-strategy-template]] — `wiki/invest-strategy/` 전략 규칙
|
||||
- [[templates/invest-plan-template]] — `wiki/invest-plan/` 활성 계획
|
||||
|
||||
## 현재 상태
|
||||
|
||||
- 시작 자본: 60만원
|
||||
- 다음 액션: `/invest-daily`로 첫 거시 스냅샷 수집 → `/invest-plan` 초안
|
||||
@@ -0,0 +1,70 @@
|
||||
---
|
||||
title: ca-tmpl
|
||||
source_type: project
|
||||
status: reviewed
|
||||
confidence: high
|
||||
tags: [ca-skeleton, project-hub, locally-verified]
|
||||
related_projects: [ca-skeleton]
|
||||
last_reviewed: 2026-05-27
|
||||
---
|
||||
|
||||
# ca-tmpl — Project Hub
|
||||
|
||||
> Layer: sibling `wiki/projects/ca-tmpl/` 폴더 안 의사결정 sub-doc 의 named hub (folder-note 패턴). Clean Architecture skeleton 템플릿 프로젝트. 2026-05-27 기준 package/module blueprint slice는 Phase C2 local implementation 및 verification 완료, 나머지 운영 계약 slice는 문서/설계 또는 후속 구현 대기 상태다.
|
||||
|
||||
## 프로젝트 현황
|
||||
|
||||
- **상태**: Phase A-E (canonical contract + 16 wiki/concepts/ 합성) 완료. Phase C2는 package/module blueprint slice부터 진입했고, `/home/donghyeon/workspace/ca-tmpl/`에서 local verification 완료.
|
||||
- **canonical SSOT**: [[raw/project-notes/ca-skeleton-operational-contract]] (29 섹션, 43 branch 결정 통합)
|
||||
- **운영 artifact 위치**: ca-tmpl repo `/home/donghyeon/workspace/ca-tmpl/docs/{registries,runbooks}/` (LLM Wiki 외부)
|
||||
- **공통 증거 등급**: `clean-architecture-package-layout` 중 skeleton package blueprint 범위는 `actually-implemented` + `locally-verified`. 나머지 문서는 각 문서별 상태를 따른다. `prod-verified`는 없음.
|
||||
|
||||
## 16 의사결정 문서
|
||||
|
||||
### Core 6 (T1-T6)
|
||||
|
||||
- [[wiki/projects/ca-tmpl/clean-architecture-package-layout]] — T1 Gradle multi-module Clean Architecture / Hexagonal package blueprint (`locally-verified`)
|
||||
- [[wiki/projects/ca-tmpl/transaction-boundary-abstraction]] — T2 TransactionPort abstraction
|
||||
- [[wiki/projects/ca-tmpl/transactional-outbox-pattern]] — T3 SKIP LOCKED outbox
|
||||
- [[wiki/projects/ca-tmpl/api-error-envelope-design]] — T4 custom error envelope
|
||||
- [[wiki/projects/ca-tmpl/idempotency-key-design]] — T5 triple scope idempotency
|
||||
- [[wiki/projects/ca-tmpl/multi-tenancy-isolation-patterns]] — T6 opt-in Pool model
|
||||
|
||||
### Cross-cutting 10 (G-A ~ G-J)
|
||||
|
||||
- [[wiki/projects/ca-tmpl/observability-log-metric-trace-runbook]] — G-A Log + Metric + Trace + Runbook
|
||||
- [[wiki/projects/ca-tmpl/security-baseline-jwt-actuator-secrets]] — G-B JWT + Actuator + Secrets
|
||||
- [[wiki/projects/ca-tmpl/data-layer-persistence-cache-outbound]] — G-C Persistence + Cache + Outbound
|
||||
- [[wiki/projects/ca-tmpl/runtime-container-health-migration]] — G-D Container + Health + Migration
|
||||
- [[wiki/projects/ca-tmpl/devops-ci-supply-chain-dx]] — G-E CI + Supply chain + DX
|
||||
- [[wiki/projects/ca-tmpl/api-evolution-and-schema]] — G-F Compatibility + Schema
|
||||
- [[wiki/projects/ca-tmpl/skeleton-governance-registry-verification-test-scorecard]] — G-G Registry + Verification + Test + Scorecard
|
||||
- [[wiki/projects/ca-tmpl/sample-fixture-and-adoption]] — G-H Sample fixture + Adoption
|
||||
- [[wiki/projects/ca-tmpl/config-and-adapter-templates]] — G-I Env config + Adapter
|
||||
- [[wiki/projects/ca-tmpl/privacy-file-domain-modeling]] — G-J Privacy + File + Domain
|
||||
|
||||
### Resource contract slices
|
||||
|
||||
- [[wiki/projects/ca-tmpl/resource-identifier-format]] — ULID resource identifier 결정 + 신규 `adapter-identifier` 모듈 (`actually-implemented` + `locally-verified`)
|
||||
- [[wiki/projects/ca-tmpl/boundary-validation-mapping]] — 입력 경계 검증 + DTO↔도메인 매핑 계약 (Bean Validation `@GroupSequence` · `Patch<T>` 3-state · outbound ACL · 8개 boundary ArchUnit rule) (`actually-implemented` + `locally-verified`)
|
||||
- [[wiki/projects/ca-tmpl/streaming-response-support]] — 이벤트/server-push 스트리밍 *미지원* 결정 + ArchUnit import-ban 3개(`no_sse_emitter` / `no_response_body_emitter` / `no_websocket_handler`) 정적 강제 (`StreamingResponseBody` 다운로드는 차단 제외) (`actually-implemented` + `locally-verified`)
|
||||
- [[wiki/projects/ca-tmpl/knowledge-capture-workflow]] — 구현 종료 조건에 LLM Wiki branch/error/interview/blog-topic capture를 포함하는 workflow 결정 (`documented-only`)
|
||||
|
||||
## 면접 발화 가이드 (공통)
|
||||
|
||||
- **자신 있게**: 의사결정 근거와 대안 trade-off
|
||||
- **적당히**: 마이그레이션 trigger, 표준 vs 사례 비교
|
||||
- **답하면 안 됨**: "구현했다 / 측정했다 / 운영했다" — 모두 Phase C2 진입 후에만 가능
|
||||
|
||||
## 승급 경로
|
||||
|
||||
각 문서는 Phase C2 구현 slice가 실제 코드와 검증으로 확인될 때 `actually-implemented` / `locally-verified` 섹션을 갱신한다. 2026-05-27 package blueprint slice는 이 승급을 완료했다. 외부 공개(`published-ready`)는 운영 과장 방지 검토와 파생 문서 게이트를 별도로 통과해야 한다.
|
||||
|
||||
자세한 단계 정의는 [[CLAUDE]] §15.
|
||||
|
||||
## Sources
|
||||
|
||||
> 본 문서는 hub/index 성격이며 16개 sub-document를 위 목록으로 가리킵니다. 개별 의사결정의 출처는 각 sub-document의 Sources 섹션에 있습니다.
|
||||
|
||||
- [[raw/project-notes/ca-skeleton-operational-contract]] — ca-tmpl 운영 계약 canonical SSOT (16 의사결정의 원천)
|
||||
- [[CLAUDE]] §15 — 문서 위계 및 파생 규칙 (`documented-only` → `actually-implemented` 승급 정의)
|
||||
@@ -0,0 +1,182 @@
|
||||
---
|
||||
title: ca-tmpl - API Error Envelope 결정 (custom envelope, ProblemDetail 거부)
|
||||
source_type: project
|
||||
status: verified
|
||||
confidence: high
|
||||
tags: [ca-tmpl, api-design, error-handling, actually-implemented, locally-verified]
|
||||
related_projects: [ca-tmpl]
|
||||
last_reviewed: 2026-07-02
|
||||
---
|
||||
|
||||
# ca-tmpl - API Error Envelope 결정 (custom envelope, ProblemDetail 거부)
|
||||
|
||||
> Layer: `wiki/projects/` — 내 프로젝트 사실. 일반 개념은 [[wiki/concepts/api-error-envelope-design]] 참고.
|
||||
|
||||
## 프로젝트 컨텍스트
|
||||
|
||||
ca-tmpl은 Clean Architecture 기반 백엔드 skeleton 프로젝트다. 운영 환경에서 API 실패 응답을 일관된 구조로 직렬화하고, client가 분기/재시도/관측 가능하도록 만들기 위해 자체 error envelope을 설계했다. RFC 7807 ProblemDetail이 Spring 6+ 기본 지원이지만 의식적으로 거부하고 다음 shape을 채택했다.
|
||||
|
||||
```text
|
||||
{
|
||||
success: boolean,
|
||||
data: <T> | null,
|
||||
error: {
|
||||
code: string,
|
||||
category: string,
|
||||
message: string,
|
||||
retryable: boolean,
|
||||
details: <항목별 오류 배열> | null
|
||||
} | null,
|
||||
meta: { requestId, traceId, correlationId, ... }
|
||||
}
|
||||
```
|
||||
|
||||
진행 상태: **Phase C2 (구현) 완료 (2026-06-01).** envelope record, exception handler, error response factory가 코드에 존재하고 `./gradlew check` (전 모듈 test + ArchUnit)가 통과한다. 단 `Retry-After` 헤더 발행과 span ERROR 기록은 seam/stub 상태이며 owner branch에 위임돼 있다(아래 명시).
|
||||
|
||||
> **Ground-truth 대조 (2026-06-04, ca-tmpl @0c996fc "운영 에러 관측성 foundation 계약 구현", HEAD `db61075`에서도 존재 확인):** 아래 envelope field shape·클래스·enum·ArchUnit/config는 ca-tmpl 코드 실측으로 일치 확인. 패키지 root는 `dev.caskeleton.*` (이전 stale 추출의 `com.example.blog`/`sample-ticket` 류는 발견되지 않음 — 현재 sample 모듈은 `sample-portfolio`). 모듈 경로는 `src/<module>/src/main/java/dev/caskeleton/...`.
|
||||
|
||||
## 실제 구현 내용 (`actually-implemented`)
|
||||
|
||||
`/home/donghyeon/workspace/ca-tmpl` 코드에 실재 (grep 확인):
|
||||
|
||||
- `shared-contract/response/Envelope.java` — `record Envelope<T>(boolean success, T data, ApiError error, ResponseMeta meta)`. success/failure가 한 shape 공유, `ok()`/`failure()` 팩토리. RFC 7807 거부 javadoc 명시.
|
||||
- `shared-contract/response/ApiError.java` — `record ApiError(String code, String category, String message, boolean retryable, Object details)`. `category`가 1급 필드(10-enum 이름), `retryable` 1급, `details`는 code별 polymorphic.
|
||||
- `shared-contract/response/ResponseMeta.java` — `record ResponseMeta(String requestId, String traceId, String correlationId, ...)` — 평면 `traceId`를 대체한 meta 객체(D20).
|
||||
- `shared-contract/error/Category.java` — 10-value 운영 분류 enum.
|
||||
- `shared-contract/error/OperationalError.java` + `error/ApiErrorCode.java` — code 카탈로그 + `category()` 매핑(`VALIDATION_FAILED`/`MAPPING_FAILED`/… → `VALIDATION`, `UNAUTHENTICATED`/`INVALID_TOKEN` → `AUTH`, `FORBIDDEN` → `AUTHZ`, `ROUTE_NOT_FOUND` → `NOT_FOUND`, `INTERNAL_ERROR` → `INTERNAL`). `retryable`은 per-code 유지.
|
||||
- `adapter-web/error/GlobalExceptionHandler.java` + `error/ErrorResponseFactory.java` — 예외 → envelope 변환, `code.category().name()` 주입.
|
||||
- `adapter-web/envelope/EnvelopeBodyAdvice.java` — 성공 응답 envelope 래핑.
|
||||
- `feature-api-contract-baseline` 이후 transport failure 매핑 — 413(`PAYLOAD_TOO_LARGE`), 406(`NOT_ACCEPTABLE`), 415(`UNSUPPORTED_MEDIA_TYPE`), 405(`METHOD_NOT_ALLOWED` + `Allow` header), 412(`PRECONDITION_FAILED`) 를 같은 envelope shape으로 반환하되, category/status 의미는 보존한다. Spring MVC `ResponseEntityExceptionHandler` 가 이미 다루는 umbrella exception은 `@ExceptionHandler` 중복 등록이 아니라 protected override로 처리한다.
|
||||
|
||||
ProblemDetail 거부가 **빌드 타임에 강제**된다 (코드 실측):
|
||||
|
||||
- `app-bootstrap/.../architecture/CleanArchitectureTest.java` (ArchUnit) — `org.springframework.http.ProblemDetail` import 금지 규칙(L355 "D5: RFC 7807 ProblemDetail is explicitly rejected").
|
||||
- `app-bootstrap/src/main/resources/application.yml` — `spring.mvc.problemdetails.enabled: false`로 pin.
|
||||
- `app-bootstrap/.../settings/ProblemDetailDisabledConfigTest.java` — shipped `application.yml`이 그 플래그를 literal `false`로 유지하는지 검증 (default flip 회귀 방지).
|
||||
|
||||
## 로컬/dev 검증 (`locally-verified`)
|
||||
|
||||
`./gradlew check` (전 모듈 test + `verifyCleanArchitectureDependencies` + ArchUnit `CleanArchitectureTest`) **BUILD SUCCESSFUL** (2026-06-01). 검증 테스트: `EnvelopeTest`/`ApiErrorTest`/`CategoryTest`(shared-contract), `EnvelopeMetaIntegrationTest`(adapter-web standalone MockMvc — meta/category 필드 + leak 차단). 단 운영(prod) 검증은 아직 없음.
|
||||
|
||||
`feature-api-contract-baseline` 의 transport failure envelope 범위는 `TransportErrorHandlingTest` 로 413/406/415 distinct + 405 `Allow` header를 검증했고, `WorkLogControllerWireTest` 로 `If-Match` mismatch → 412 envelope 흐름을 검증했다. 이 검증은 framework/transport failure를 domain validation과 같은 원인으로 섞는 것이 아니라, 같은 response shape 안에서 status/code/category를 보존하는 범위다.
|
||||
|
||||
## 운영 검증 (`prod-verified`)
|
||||
|
||||
없음. 운영 배포 자체가 존재하지 않는다.
|
||||
|
||||
## 설계 결정 (구현됨 — `actually-implemented` + `locally-verified`)
|
||||
|
||||
> 2026-06-01 이전에는 본 섹션 전체가 `documented-only`였으나 Phase C2로 envelope schema가 코드화·로컬 검증됨. 아래 schema 결정·leak catalog는 이제 코드에 반영돼 있다. 단 `Retry-After` 헤더 발행 / 5xx span ERROR 기록은 여전히 **seam/stub**(owner branch 위임), business rule violation → envelope 변환은 **다른 branch 책임**이다(아래 명시).
|
||||
|
||||
### Envelope schema 결정
|
||||
|
||||
- success / error 대칭 envelope: 성공도 동일한 top-level shape으로 감싸 `success: true/false` 분기를 client에 단일 규칙으로 제공.
|
||||
- `error.code` (머신리더블 식별자) 와 `error.category` (운영 분류) 를 별도 1급 필드로 분리.
|
||||
- `error.retryable: boolean`을 1급 필드로 승격. client 재시도 정책을 envelope 자체에서 가이드.
|
||||
- `error.details`로 항목 단위 오류(예: validation field error) 를 배열로 운반.
|
||||
- `meta`에 `requestId`, `traceId`, `correlationId`를 1급으로 노출 — 로그/트레이스와 응답을 join 가능.
|
||||
|
||||
출처: [[raw/project-notes/ca-skeleton-operational-contract]] §3 (Structured API Response Contract) / §5 (Exception Ownership Contract) / §6 (Operational Error Category).
|
||||
|
||||
### 5종 envelope 대안 검토 결과
|
||||
|
||||
[[raw/project-notes/ca-skeleton-operational-contract]] §29 Topic 4에서 다음 5종을 비교 후 **custom envelope** 채택.
|
||||
|
||||
| 후보 | 거부 사유 |
|
||||
|------|-----------|
|
||||
| RFC 7807 ProblemDetail | 실패 전용 평면 shape — success/error 대칭 요구와 구조적 충돌. `code`/`retryable`/`category` 표준 부재로 결국 표준 위에 사실상 custom 레이어 추가가 필요. |
|
||||
| Google `rpc.Status` | gRPC/protobuf 결합. HTTP REST 전용에서 `Any` 디코딩 부담을 client에 전가. CRUD 비중 큰 skeleton에 과한 표현력. |
|
||||
| JSON:API errors | `errors[]` + `source.pointer`는 항목 단위 강점이나 `category`/`retryable` 1급 필드 없음. 부분 채택 시 표준성 상실, 완전 채택 시 spec 전체 lock-in. |
|
||||
| GraphQL errors | HTTP 200 + `errors` 규약. REST envelope과 패러다임 자체가 다름. CDN/proxy/observability 4xx/5xx 알람과 부조화. |
|
||||
| Custom envelope (채택) | 표준 client SDK가 0개라는 비용을 감수하는 대신 success/error 대칭 + `retryable`/`category` 1급화 + observability 메타 노출이라는 운영 요구를 충족. |
|
||||
|
||||
### Exception leak 금지 항목 catalog
|
||||
|
||||
응답 envelope에 절대 노출 금지로 계약된 항목:
|
||||
|
||||
- exception class fully-qualified name
|
||||
- stack trace 전체 또는 일부
|
||||
- SQL / SQL fragment / bind parameter
|
||||
- token / credential / secret 값
|
||||
- raw request body / raw upstream response body
|
||||
|
||||
출처: [[raw/branch-notes/feature-operational-error-observability-foundation]] (envelope schema SSOT 및 leak 금지 catalog).
|
||||
|
||||
### Validation / business rule 매핑
|
||||
|
||||
- boundary validation (request DTO 단계): 항목별 오류를 `error.details[]`에 `{field, code, message}` 형태로 매핑. owner = [[raw/branch-notes/feature-boundary-validation-mapping-contract]] (`actually-implemented` — `VALIDATION_FAILED` details shape).
|
||||
- business rule violation (use case 내부 invariant): `error.category`로 분류하고 `error.details`로 세부 위반 정보를 운반. owner = [[raw/branch-notes/feature-business-rule-validation-contract]].
|
||||
|
||||
foundation 측 exception → envelope 변환 골격(`GlobalExceptionHandler`/`ErrorResponseFactory`)은 구현됨. 위 항목별 매핑 *세부*(validation field 매핑 / business invariant 분류)는 각 owner branch 책임이다.
|
||||
|
||||
### Blog-topic ingest: spring-responseentityexceptionhandler-transport-failure-envelope (2026-07-02)
|
||||
|
||||
[[raw/blog-topics/spring-responseentityexceptionhandler-transport-failure-envelope-2026-07-02]] 는 Spring MVC transport failure를 custom envelope에 태운 경험을 블로그로 풀기 위한 raw seed다. canonical 승격 기준은 다음처럼 정리했다.
|
||||
|
||||
- **locally-verified 로 말할 수 있는 부분**: 413/406/415/405(+`Allow`) transport failure와 412 precondition failure가 ca-tmpl envelope shape으로 매핑되고 테스트된다.
|
||||
- **source-backed 로 말할 수 있는 부분**: 406/415/405/412/413의 HTTP status 의미는 RFC 9110 계열 근거와 기존 `api-evolution-and-schema` project canonical에 연결된다.
|
||||
- **project-local implementation 으로 말할 부분**: Spring MVC `ResponseEntityExceptionHandler` 흐름을 깨지 않기 위해 umbrella exception은 protected override로 처리한다는 구현 선택.
|
||||
- **블로그 전 과장 방지**: Spring MVC의 모든 예외가 envelope으로 포괄된다고 쓰지 않는다. 검증된 transport failure row와 owner branch 범위로 제한한다.
|
||||
|
||||
### Blog-topic ingest: operational-error-envelope-meta-category-migration (2026-07-02)
|
||||
|
||||
[[raw/blog-topics/operational-error-envelope-meta-category-migration-2026-06-01]] 는 기존 `{success,data,error,traceId}` 응답을 `error.category`와 `meta.{requestId,traceId,correlationId}`가 있는 richer envelope로 additive migration한 경험을 블로그로 풀기 위한 raw seed다.
|
||||
|
||||
- **canonical 반영 범위**: verified error envelope 구현 문서에 meta/category migration, enum vocabulary, response meta factory 글감을 연결했다.
|
||||
- **blogify 전 가능 범위**: 이 canonical은 `verified` 이므로 blogify 후보가 될 수 있다.
|
||||
- **블로그 전 과장 방지**: 운영 배포/운영 검증이 아니라 코드 구현 + 로컬 검증 범위로 제한한다.
|
||||
- [[raw/blog-topics/spring-security-filter-layer-error-envelope-2026-06-08]]: Spring Security filter-layer 인증/인가 실패를 custom `AuthenticationEntryPoint` / `AccessDeniedHandler`에서 같은 envelope shape으로 직렬화하는 글감. 보안 adapter 구현 여부와 heuristic 분류 한계를 재확인한다.
|
||||
|
||||
## 문서/계획만 존재 (`documented-only` / `planned`)
|
||||
|
||||
- **`Retry-After` 헤더 발행** (`planned`): rate-limit owner branch 위임. GlobalExceptionHandler 내 seam/stub 상태. 실제 헤더 발행 로직은 미구현.
|
||||
- **5xx span ERROR 기록** (`planned`): distributed-tracing owner branch 위임. span 조립/에러 마킹 로직은 seam/stub 상태.
|
||||
- **business rule violation → `error.category` 매핑 세부** (`planned`): [[raw/branch-notes/feature-business-rule-validation-contract]] 담당. use case 내부 invariant 위반을 `error.category`·`error.details`로 분류하는 세부 정책은 foundation 측 골격만 존재하고 실제 분류 로직은 미구현.
|
||||
|
||||
## 면접에서 말할 수 있는 범위
|
||||
|
||||
### 자신 있게 답할 수 있는 질문
|
||||
|
||||
- ProblemDetail을 채택하지 **않은** 이유 — 실패 전용 평면 shape이라 success/error 대칭 요구와 구조적으로 충돌하고, `code`/`retryable`/`category`가 표준 부재라 결국 표준 위에 custom 레이어가 또 필요해진다.
|
||||
- `retryable`을 1급 필드로 둔 의미 — client 재시도 정책을 envelope 자체에서 가이드하기 위함. 단, `RetryInfo.retry_delay` 수준의 actionable delay 정보는 잃는다는 trade-off까지 인지.
|
||||
- validation error를 `error.details`에 매핑하는 정책의 의도 (boundary vs business rule 구분).
|
||||
- exception leak 금지 항목 catalog와 각 항목이 왜 금지인지.
|
||||
|
||||
### 적당히 답할 수 있는 질문
|
||||
|
||||
- Stripe / GitHub / 토스페이먼츠 envelope과 ca-tmpl envelope의 차이점.
|
||||
- JSON:API `source.pointer` 와 ca-tmpl `error.details[].field` 표현의 비교.
|
||||
|
||||
### 말할 수 있는 범위 (구현 사실 + 한계)
|
||||
|
||||
- "이 envelope을 코드로 구현했는가" — **답: 그렇다 (`actually-implemented` + `locally-verified`).** `Envelope`/`ApiError`/`ResponseMeta` record + `GlobalExceptionHandler`가 코드에 있고 `./gradlew check` 통과. 단 *로컬* 검증까지다.
|
||||
- "운영에서 어떻게 동작하는가 / 운영 측정값" — **답: 운영(prod) 검증은 없음.** 로컬 빌드/테스트 수준까지만.
|
||||
- "`Retry-After` 헤더·5xx span ERROR 기록도 동작하는가" — **답: seam/stub 단계.** 헤더 발행/span 조립은 rate-limit·distributed-tracing owner branch 위임.
|
||||
|
||||
## 과장 금지 지점
|
||||
|
||||
- **"ca-tmpl envelope이 표준이다"** — ❌. 어떤 IETF/W3C 표준도 success/error 대칭 + `retryable` 1급 + `category` 1급을 동시에 강제하지 않는다. 자체 결정이다.
|
||||
- **"ProblemDetail이 잘못된 설계다"** — ❌. 실패 전용 use case (외부 노출 API, RFC 9457 client 생태계 활용)에서는 유효한 선택이다. ca-tmpl의 요구 조합과 맞지 않았을 뿐이다.
|
||||
- **"envelope을 운영에서 검증했다"** — ❌. 코드 구현 + `./gradlew check` 로컬 통과까지(`locally-verified`)이며, prod 배포·측정은 없다. "구현했다"는 OK, "운영 검증했다"는 과장.
|
||||
- **"Stripe/GitHub/토스가 다 custom이니까 표준은 의미 없다"** — ❌. 그들은 SDK가 envelope을 흡수하는 전제 위에 동작한다. 표준 미준수가 정당화되는 게 아니라 trade-off가 다른 것뿐이다.
|
||||
|
||||
## 관련 개념
|
||||
|
||||
- [[wiki/concepts/api-error-envelope-design]]
|
||||
|
||||
## Sources
|
||||
|
||||
- [[raw/project-notes/ca-skeleton-operational-contract]] §3 Structured API Response Contract / §5 Exception Ownership Contract / §6 Operational Error Category / §29 Topic 4 (5종 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 매핑과 `TransportErrorHandlingTest` 검증
|
||||
- [[raw/blog-topics/spring-responseentityexceptionhandler-transport-failure-envelope-2026-07-02]] — transport failure envelope 블로그 글감 raw seed. canonical 반영 범위: verified transport rows + Spring MVC override 경계 + 과장 금지 항목.
|
||||
- [[raw/blog-topics/operational-error-envelope-meta-category-migration-2026-06-01]] — meta/category migration 블로그 글감 raw seed.
|
||||
- [[raw/blog-topics/spring-security-filter-layer-error-envelope-2026-06-08]] — Spring Security filter-layer envelope 블로그 글감 raw seed.
|
||||
- [[raw/branch-notes/feature-boundary-validation-mapping-contract]] — boundary validation → `error.details` 매핑
|
||||
- [[raw/branch-notes/feature-business-rule-validation-contract]] — business invariant → `error.category` 매핑
|
||||
|
||||
## Cluster / 묶음
|
||||
|
||||
<!-- GENERATED: derived-blogs:start -->
|
||||
- [[wiki/blog/ca-tmpl-api-error-envelope-design-2026-07-02]]
|
||||
<!-- GENERATED: derived-blogs:end -->
|
||||
@@ -0,0 +1,225 @@
|
||||
---
|
||||
title: ca-tmpl - API Evolution & Schema 결정 (versioning + contract baseline + serialization)
|
||||
source_type: project
|
||||
status: verified
|
||||
confidence: high
|
||||
tags: [ca-tmpl, api-design, versioning, pagination, conditional-request, http-cache, openapi, schema]
|
||||
related_projects: [ca-tmpl]
|
||||
last_reviewed: 2026-07-02
|
||||
---
|
||||
|
||||
# ca-tmpl - API Evolution & Schema 결정 (versioning + contract baseline + serialization)
|
||||
|
||||
> Layer: `wiki/projects/` — 내 프로젝트 사실. 일반 개념은 [[wiki/concepts/api-evolution-and-schema]] 참고.
|
||||
|
||||
## 프로젝트 컨텍스트
|
||||
|
||||
ca-tmpl은 Clean Architecture 기반 백엔드 skeleton 프로젝트다. 이 문서는 API surface 의 세 영역을 다룬다.
|
||||
|
||||
- **API contract baseline (구현됨)** — versioning (`/v1` path prefix), pagination/sort, conditional request (ETag/If-Match/304/412), HTTP cache policy, OpenAPI producer, long-running operation, batch endpoint. `feature-api-contract-baseline` branch 가 producer-소유 결정을 실제 코드(`adapter-web` + `sample-portfolio`)에 구현하고 단위/슬라이스/임베디드 테스트로 검증했다. **`locally-verified`**.
|
||||
- **Compatibility / deprecation 축 (설계만)** — `90d public + 30d internal migration window`, 7행 breaking change catalog, RFC 8594 `Sunset` + `Deprecation` 헤더 병기, OpenAPI `deprecated: true` marker. `feature-api-compatibility-deprecation-contract` branch 의 결정이며 **코드 미구현 (`documented-only`)**.
|
||||
- **Schema / serialization 축 (출력측 부분 구현)** — ISO-8601 offset datetime, `BigDecimal` scale 2 + `HALF_UP`, unknown field strict inbound, null/empty/missing 분리. `feature-schema-serialization-contract` branch 의 결정이다. **직렬화 출력측 핀 (`WRITE_DATES_AS_TIMESTAMPS=false` / `WRITE_BIGDECIMAL_AS_PLAIN=true`) + `new BigDecimal(double)` 정적 차단 ArchUnit 룰 + 직렬화 동작 테스트는 실제 코드로 구현·로컬 검증됨 (`locally-verified`)**. 단 입력측 deser switch·null/empty/missing 3-상태(`Patch<T>`)는 sibling `feature-boundary-validation-mapping-contract` 가 소유하며, OpenAPI drift release gate (D5) · 제거-field 재사용 도구 (D6) · Avro Schema Registry (D7) · money string-vs-number per-API 코드 시연은 미구현 (`documented-only` / `planned` / `needs-confirmation`).
|
||||
|
||||
> **Ground-truth 대조 (2026-06-04, ca-tmpl @b15dcf5 "API 계약 baseline 구현")**: contract baseline 축의 아래 `actually-implemented` / `locally-verified` 항목은 ca-tmpl 저장소 commit `b15dcf5` 의 실제 코드(`dev.caskeleton.*` package root)와 1:1 대조해 확인했다.
|
||||
>
|
||||
> **Ground-truth 대조 (2026-06-04, ca-tmpl @5d89766 "schema/serialization 계약 직렬화 출력측 구현")**: schema/serialization 축 *출력측* 항목 — `no_bigdecimal_double_constructor` ArchUnit 룰(`CleanArchitectureTest`), `JacksonSerializationPolicyTest`, `application.yml`/`application-test.yml`/`.env` 의 직렬화 핀 두 키 — 은 commit `5d89766` 의 실제 코드와 1:1 대조해 확인했다 (`locally-verified`). compatibility/deprecation 축 + schema 의 D5/D6/D7 + per-API money 직렬화 코드 시연은 여전히 `documented-only` / `planned` / `needs-confirmation`.
|
||||
|
||||
## 실제 구현 내용 (`actually-implemented`)
|
||||
|
||||
> **API contract baseline 축** (`feature-api-contract-baseline`) + **schema/serialization 축의 출력측** (`feature-schema-serialization-contract`) 이 구현됨. compatibility/deprecation 축 + schema 의 D5/D6/D7 은 코드 부재 (§문서/계획만 존재).
|
||||
|
||||
코드에 존재하는 클래스/필터 (테스트 유무와 무관하게 production main 소스에 존재):
|
||||
|
||||
- **D2 versioning** — `/v1` path prefix 는 설정 주도(`app-bootstrap/.../application.yml` 의 `ca-skeleton.presentation.api-base-path: ${PRESENTATION_API_BASE_PATH:/v1}`) + `adapter-web` `PresentationSettings` (env 누락/`/` 누락 시 warn + 보정). 코드 자체의 default 는 `""`, 운영 default 는 `/v1`.
|
||||
- **D18/D20 pagination/sort** — `adapter-web` `PageParams` (page≥0, size 1..100, deep-offset>10000 플래그), `SortParam` (Spring native `field,direction` 파싱 + 비-네이티브 reject), `shared-contract` `PageMeta`/`ResponseMeta.page`.
|
||||
- **D15 conditional request** — `adapter-web/conditional/ETags` (`weakFromVersion` = `W/"<version>"`, lenient `matches`), `PreconditionFailedException`.
|
||||
- **D16 cache policy** — `adapter-web/filter/CacheControlFilter` (`@Order(HIGHEST_PRECEDENCE+20)`, 모든 응답에 `Cache-Control: no-store` + `Vary: Accept, Accept-Encoding, Authorization`).
|
||||
- **D22 cursor (SEAM)** — `adapter-web/cursor/CursorCodec` (base64url(`iat:payload`) + HMAC-SHA256 + 24h TTL) + `CursorException`.
|
||||
- **D17 LRO** — `sample-portfolio` `OperationsController` (`POST /worklogs:export` → 202 + `Location` + `Operation`, `GET /operations/{id}` polling), `shared-contract` `Operation`/`OperationStatus`, `SampleOperationStore`.
|
||||
- **D8/D9/D12 transport errors** — `adapter-web/error/GlobalExceptionHandler` 가 413(`PAYLOAD_TOO_LARGE`)/406(`NOT_ACCEPTABLE`)/415(`UNSUPPORTED_MEDIA_TYPE`)/405(`METHOD_NOT_ALLOWED` + `Allow` header)/412(`PRECONDITION_FAILED`) 를 envelope 로 매핑.
|
||||
- **D23 batch** — `sample-portfolio` `WorkLogController` 의 `POST /worklogs:batchCreate` (단일 tx atomic, `@Size(max=1000)` cap) + `BatchCreateWorkLogsUseCase`.
|
||||
- **D10 OpenAPI producer** — `adapter-web/build.gradle` 에 `springdoc-openapi-starter-webmvc-api:2.8.6` 의존 추가, `/v3/api-docs` 노출.
|
||||
|
||||
### Schema / serialization 출력측 (`feature-schema-serialization-contract`, ca-tmpl @5d89766)
|
||||
|
||||
직렬화 *출력측* 계약을 코드에 핀하고 정적으로 차단했다. 입력측 deser switch(`FAIL_ON_UNKNOWN_PROPERTIES`/`FAIL_ON_NULL_FOR_PRIMITIVES`/`READ_UNKNOWN_ENUM_VALUES_AS_NULL=false`)와 null/empty/missing 3-상태(`Patch<T>`)는 sibling `feature-boundary-validation-mapping-contract` 소유이므로 본 축 *출력측* 만 여기서 다룬다.
|
||||
|
||||
- **D2 datetime 직렬화 핀** — `app-bootstrap/.../application.yml` 의 `spring.jackson.serialization.write-dates-as-timestamps=false` (env `SPRING_JACKSON_SER_WRITE_DATES_AS_TIMESTAMPS` 바인딩). `java.time` 값이 epoch/배열이 아니라 ISO-8601 문자열로 직렬화됨. `JavaTimeModule` 은 Spring Boot `starter-json` auto-config 가 classpath 의 `jackson-datatype-jsr310` 을 자동 등록 — 명시 등록 코드는 없음.
|
||||
- **D3 BigDecimal plain 직렬화 핀** — `application.yml` 의 `spring.jackson.generator.write-bigdecimal-as-plain=true` (env `SPRING_JACKSON_GEN_WRITE_BIGDECIMAL_AS_PLAIN` 바인딩). 지수 표기(`1.23E+10`) 대신 plain notation 으로 직렬화.
|
||||
- **D3 정적 차단 ArchUnit 룰** — `app-bootstrap/.../architecture/CleanArchitectureTest` 의 `no_bigdecimal_double_constructor` (`@ArchTest`). `dev.caskeleton..` production 패키지에서 `callConstructor(BigDecimal.class, double.class)` / `float.class` 호출을 build fail. (`new BigDecimal(0.1)` 의 부동소수 잔차 함정 = SBMS-C3 차단)
|
||||
- **위반 fixture** — `architecture/violations/serialization/BigDecimalDoubleConstructorFixture` (`new BigDecimal(double/float)` 사용) — 룰의 vacuous-pass 방지용 negative fixture.
|
||||
- **테스트 리소스 핀** — `application-test.yml` 에 위 두 키를 리터럴(`false`/`true`)로 박아 테스트 프로파일에서도 동일 계약 유지.
|
||||
|
||||
이 핀들은 *현재 Spring Boot 기본값과 일치*하나, future default flip 회귀를 차단하기 위해 명시했다 (rationale 은 `.env` 주석에 `spring.mvc.problemdetails.enabled=false` 와 동일 논리로 기록).
|
||||
|
||||
compatibility/deprecation 축: 없음 (version interceptor, Sunset/Deprecation header bean, OpenAPI deprecation marker 모두 부재). schema 축의 D5 OpenAPI drift release gate · D6 제거-field 재사용 도구 · D7 Avro Schema Registry · money string-vs-number per-API 코드 시연: 부재 (§문서/계획만 존재 / SEAM).
|
||||
|
||||
## 로컬/dev 검증 (`locally-verified`)
|
||||
|
||||
위 contract baseline 구현은 단위/슬라이스/임베디드-컨테이너 테스트로 동작이 확인됐다 (`./gradlew check` + ArchUnit gate PASS):
|
||||
|
||||
- `TransportErrorHandlingTest` — 413/406/415 distinct + 405 + `Allow` header.
|
||||
- `WorkLogControllerWireTest` — D15 ETag 발행 / `If-None-Match`→304 / `If-Match` mismatch→412, D7/D18 `meta.page` + size·page 경계 400 + 빈 list `[]` + deep-offset `Deprecation` 헤더, D20 sort 네이티브/비-네이티브, D21 flat filter 무시(`filter_dsl_is_ignored_not_parsed`), D13 HEAD-mirror-GET(`head_on_get_endpoint_is_supported_not_405`), D23 batch size cap(`batch_over_size_cap_is_400`, 1001→400), D3 `Idempotency-Key` POST surface(`post_accepts_idempotency_key_header`, server-tolerant).
|
||||
- `CacheControlFilterTest` — D16 default `no-store` + `Vary`.
|
||||
- `CursorCodecTest` — D22 opacity / integrity(서명 변조 탐지) / TTL 3-invariant.
|
||||
- `ETagsTest`, `PageParamsTest`, `SortParamTest` — adapter 단위 검증.
|
||||
- `OperationsControllerWireTest` — D17 202 + `Location` + `data.{operationId,statusUrl}` + polling.
|
||||
- `OpenApiSnapshotTest` — D10 임베디드 RANDOM_PORT 컨테이너에서 `/v3/api-docs` 200 응답 + `WorkLogController` 반영.
|
||||
- `VersioningPrefixTest` — D2 `/v1/probe` 200, `/probe` 404 (unversioned public endpoint 불가).
|
||||
- `DateHeaderContractTest` — D24 임베디드 Tomcat 200·404 응답에 `Date` 헤더.
|
||||
- `ErrorCodeRegistryMappingTest` — D11 405/406/412/413/414/415 row 와 controller 응답 drift FAIL (producer contract test).
|
||||
|
||||
Schema / serialization 출력측 (`feature-schema-serialization-contract`, @5d89766) 테스트:
|
||||
|
||||
- `JacksonSerializationPolicyTest` — ① `JacksonProperties` 바인딩 assert (`WRITE_DATES_AS_TIMESTAMPS=false`, `WRITE_BIGDECIMAL_AS_PLAIN=true`), ② wired `ObjectMapper` 직렬화 동작 assert: `OffsetDateTime`(UTC)→`"1985-04-12T23:20:50.52Z"`, `LocalDate`→`"2026-06-02"`, `new BigDecimal("1.10")`→`1.10` (trailing zero 보존), 대형 값(`12300000000000000000.00`)이 비-scientific notation. `ApplicationContextRunner` 로 effective bean 동작까지 검증해 `JavaTimeModule` 누락 회귀(배열 직렬화)도 잡는다.
|
||||
- `ArchitectureViolationFixtureTest.no_bigdecimal_double_constructor_catches_double_and_float_constructors` — D3 ArchUnit 룰이 fixture 의 `new BigDecimal(double/float)` 를 실제로 잡는지 검증 (vacuous-pass 방지).
|
||||
- 검증 명령: `./gradlew verifyCleanArchitectureDependencies` + `:app-bootstrap:test` + 전체 `test` 모두 BUILD SUCCESSFUL.
|
||||
|
||||
compatibility/deprecation 축 + schema 의 D5/D6/D7: 없음. Sunset+Deprecation 헤더 응답·`api-version` 헤더 라우팅·OpenAPI drift release gate·제거-field 재사용 도구·Avro compat 자동검사 어느 것도 로컬에서 실행/통합 테스트로 확인된 바 없다. per-API money string-vs-number 직렬화도 sample 도메인에 money 필드가 없어 코드 시연 없음(문서 의무만).
|
||||
|
||||
## 운영 검증 (`prod-verified`)
|
||||
|
||||
없음. ca-tmpl 은 운영 배포가 없다. contract baseline 항목은 전부 로컬/CI 검증까지이며, compatibility/schema 축은 90d/30d migration window·deprecation cutover·Sunset 시점 410 응답 같은 운영 검증 0건이다.
|
||||
|
||||
### SEAM / 계획만 존재 (`planned`) — contract baseline 축
|
||||
|
||||
형제 branch 또는 인프라에 막혀 의도적으로 seam 또는 planned 로 남긴 항목 — 면접에서 "구현했다"고 말하면 안 되는 경계:
|
||||
|
||||
- **D22 HMAC 키 회전 / 운영 key 주입** — `CursorCodec` 은 주입식 key 와 `withDevKey()` (dev/test 전용) factory 만 제공. production key wiring + rotation 은 `feature-security-operational-baseline` 소유, 미구현. encode/decode·opacity·integrity·TTL 메커니즘 자체는 구현됨.
|
||||
- **D8 414 URI Too Long end-to-end** — Tomcat/gateway 가 Spring dispatch 전에 거부하므로 code + registry row 만 존재, end-to-end 검증 없음.
|
||||
- **D3 key shape / replay semantics** — header 이름(`Idempotency-Key`)과 POST surface 수용만 구현. key shape/scope/replay 는 `feature-rate-limit-idempotency-contract` 소유.
|
||||
- **D5 / D10 drift 릴리스 게이트** — OpenAPI drift release-blocking 집행은 `feature-contract-verification-test-suite` 소유. 이 branch 는 producer(snapshot 발행 + registry mapping 정합 test)까지.
|
||||
- **D16 cache layer** — Redis/CDN 구현은 `feature-cache-consistency-contract` 소유. 이 branch 는 HTTP 응답 header 정책(`no-store`/`Vary`)만.
|
||||
- **D22 sample cursor endpoint** — `CursorCodec` 만 있고 cursor 페이징을 노출하는 sample endpoint 는 §Test Contract 미요구 (optional).
|
||||
- **D14 PATCH `merge-patch+json` 차단** — content type 정책은 이 branch 가 producer 지만 ArchUnit rule `no_merge_patch_json_media_type_string` 와 mapper 구현은 `feature-boundary-validation-mapping-contract` B2 소유 (cross-branch SSOT).
|
||||
|
||||
### 근거 미명시 구현 결정 (`UNSUPPORTED_IMPL_DECISION` 잔존)
|
||||
|
||||
표준이 *원칙* 만 권고하고 *숫자/메커니즘* 은 project-internal trade-off 인 지점 — 면접에서 "표준이라서"가 아니라 "내가 이렇게 trade-off 했다"로 말해야 함:
|
||||
|
||||
- **pagination size cap 100 / min 1 / deep-offset 10000** — Spring 기본 `DEFAULT_MAX_PAGE_SIZE` 는 2000(`PageParams` 주석에도 명시). 100 cap 은 DoS 방지용 추가 제한, 숫자는 표준 근거 없음.
|
||||
- **ETag lenient(weak) 비교** — RFC 9110 은 `If-Match` 에 *strong* comparison 을 MUST 로 규정하나(`ETags` javadoc 에 명시), skeleton 은 `W/` 마커·따옴표를 무시하는 lenient 비교로 weak-ETag 형태가 그대로 optimistic lock 을 구동하게 했다. production fork 는 strong ETag 로 교체 가능.
|
||||
- **cursor 24h TTL + HMAC-SHA256 선택** — AIP-158 은 opacity/URL-safe 만 MUST, TTL 숫자와 서명 알고리즘은 project-internal.
|
||||
- **LRO status enum 5종(PENDING/RUNNING/SUCCEEDED/FAILED/CANCELLED)** — AIP-151 은 `done`/`response`/`error` 이진 모델만 정의, 5종 어휘 매핑은 project-internal.
|
||||
|
||||
## 문서/계획만 존재 (`documented-only` / `planned`)
|
||||
|
||||
> **Compatibility / deprecation 축** (`feature-api-compatibility-deprecation-contract`) 은 결정/설계만 있고 **코드 미구현(`documented-only`)** 이다. **Schema / serialization 축** 은 *출력측* (datetime/BigDecimal 핀 + ArchUnit) 만 `locally-verified` (위 §실제 구현 내용 참조) 이고, 아래 D5/D6/D7 + per-API money 직렬화는 여전히 미구현이다. contract baseline 의 `locally-verified` 와 혼동하면 안 된다.
|
||||
|
||||
다음 항목은 모두 canonical 계약 문서와 branch-note 단계에 머물러 있다. 면접에서 "구현했다 / 운영했다"고 말하면 안 된다.
|
||||
|
||||
### Compatibility / deprecation 결정
|
||||
|
||||
- **90d public + 30d internal migration window**: 외부 client는 90일, internal client는 30일의 이중 window로 deprecated API를 계속 응답하면서 marker로 신호한다. Stripe의 freeze-forever, GitHub의 24mo EOL과 비교 검토 후 internal-first 환경 trade-off로 90d/30d를 선택.
|
||||
- **7행 breaking change catalog**: 응답 필드 제거 / 응답 필드 의미 변화 / required request field 추가 / enum value 제거 / enum value 의미 변화 / narrow enum(허용값 축소) / 기본값 변경 — 7항목을 breaking으로 분류. Google AIP-180 정의를 ca-tmpl 도메인에 맞게 행 단위로 catalog화.
|
||||
- **`Sunset` 헤더 (RFC 8594) + `Deprecation` 헤더 병기**: Sunset 단독은 *언제 사라지는지*만 알리므로 *지금 deprecated인지* 신호인 `Deprecation` 헤더를 함께 보낸다. concept §흔한 오해 항목과 정합.
|
||||
- **OpenAPI `deprecated: true` marker**: operation / schema 양쪽에 둘 수 있는 표준 marker로 deprecation을 schema SSOT에 박는다.
|
||||
- **Sunset + Deprecation 헤더 paired 전송 결정 (2026-05-22)**: API deprecation 응답은 `Sunset: <HTTP-date>` + `Deprecation: @<unix-epoch>` 헤더를 **함께** 송신한다. 단독 Sunset 금지. paired invariant는 "Sunset 시점 ≥ Deprecation 시점". 추가로 `Link: <url>; rel="deprecation"` (정책 문서), `Link: <url>; rel="sunset"` (마이그레이션 가이드)를 권장. 근거: [[raw/official-docs/sunset-deprecation-headers-paired-usage]]. 상태: `documented-only` — bean / interceptor 코드 미작성.
|
||||
|
||||
출처: [[raw/project-notes/ca-skeleton-operational-contract]] §13 API Contract Surface + §29 G-F (외부 근거 인덱스), [[raw/branch-notes/feature-api-compatibility-deprecation-contract]].
|
||||
|
||||
### Blog-topic ingest: api-deprecation-sunset-header-migration-window (2026-07-02)
|
||||
|
||||
[[raw/blog-topics/api-deprecation-sunset-header-migration-window-2026-07-02]] 는 위 compatibility/deprecation 축을 블로그로 풀기 위한 raw seed다. canonical 승격 기준은 다음처럼 정리했다.
|
||||
|
||||
- **프로젝트 사실로 보존**: D5-D8은 `feature-api-compatibility-deprecation-contract` 에 기록된 ca-tmpl 결정이다. 단 구현/운영 검증이 없으므로 등급은 `documented-only` / `needs-confirmation` 이다.
|
||||
- **source-backed 로 말할 수 있는 부분**: RFC 8594 `Sunset`, `Deprecation` header paired usage, Google AIP-180 기반 breaking-change 분류, OpenAPI `deprecated: true` marker 의 존재.
|
||||
- **project-local policy 로만 말할 부분**: `90d public + 30d internal` 숫자, release-blocking diff gate, compatibility fixture 결합 방식. 표준 요구사항처럼 쓰지 않는다.
|
||||
- **블로그 전 과장 방지**: 실제 API deprecation 운영 경험, 외부 client migration coordination, 410 cutover 실측은 없다.
|
||||
|
||||
### Schema / serialization 결정 (출력측은 위 §에서 구현, 아래는 미구현분만)
|
||||
|
||||
> 아래 항목 중 datetime/BigDecimal *출력측 핀* 과 `new BigDecimal(double)` 정적 차단은 @5d89766 에서 `locally-verified` (§실제 구현 내용 참조). unknown field strict inbound 와 null/empty/missing 분리는 sibling `feature-boundary-validation-mapping-contract` 가 `locally-verified` (입력측 deser + `Patch<T>`). 여기 남는 미구현분은 D5/D6/D7 + per-API money 직렬화 코드 시연이다.
|
||||
|
||||
- **per-API money string-vs-number 직렬화 시연** (`documented-only`): scale 2 + `HALF_UP` 기본 + plain notation 핀은 구현됐으나, 외부/금융 API = string vs 내부 API = number+plain 의 endpoint별 명시 선택은 **문서 의무**(adapter-web 계약 문서)로만 박혔다. sample 도메인(WorkLog)에 money 필드가 없어 `@JsonSerialize(ToStringSerializer)` 같은 코드 시연은 없다.
|
||||
- **Field 재사용 금지 catalog 정책 (자체 markdown 또는 OpenAPI `x-removed-fields`)** (`needs-confirmation`, D6): Protobuf `reserved` 시맨틱(field number/name 재사용 영구 차단)을 JSON 환경에서 흉내내기 위해 제거된 field 이름/번호를 catalog로 관리하고 CI에서 재사용을 검출. 두 후보 — (a) OpenAPI Specification Extension `x-removed-fields` + 자체 lint, (b) 별도 markdown catalog + CI cross-check — 중 도구 선택이 미정. 2026-05-22 needs-confirmation. 출처: [[raw/official-docs/protobuf-reserved-vs-json-openapi-extension]].
|
||||
- **OpenAPI drift release gate** (`planned`, D5): response 측 "schema 없는 field 미노출" 의 실제 강제는 verification suite 소유. springdoc producer 는 존재하나 release-blocking drift gate 는 `feature-contract-verification-test-suite` 미구현.
|
||||
- **Avro Schema Registry compat 자동검사** (`needs-confirmation`, D7): outbox/event 한정 검토 가치. 외부 REST/JSON 은 JSON 유지. Confluent compatibility level enforcement 메커니즘 미확보.
|
||||
|
||||
출처: [[raw/project-notes/ca-skeleton-operational-contract]] §16 Schema / Serialization Contract + §29 G-F, [[raw/branch-notes/feature-schema-serialization-contract]].
|
||||
|
||||
### Blog-topic ingest: spring-boot-serialization-contract-pins (2026-07-02)
|
||||
|
||||
[[raw/blog-topics/spring-boot-serialization-contract-pins-2026-07-02]] 는 schema/serialization 출력측 구현을 블로그로 풀기 위한 raw seed다. canonical 승격 기준은 다음처럼 정리했다.
|
||||
|
||||
- **locally-verified 로 말할 수 있는 부분**: `WRITE_DATES_AS_TIMESTAMPS=false`, `WRITE_BIGDECIMAL_AS_PLAIN=true` 설정 pin, wired `ObjectMapper` 직렬화 테스트, `new BigDecimal(double/float)` ArchUnit 차단과 negative fixture.
|
||||
- **source-backed 로 말할 수 있는 부분**: RFC 3339 datetime 표현, Java `BigDecimal` 생성자/scale/rounding 의미, Jackson serialization feature의 역할.
|
||||
- **project-local policy 로만 말할 부분**: 현재 Spring Boot 기본값과 같아도 future default drift를 막기 위해 명시 pin + effective-bean test를 둔 결정.
|
||||
- **블로그 전 과장 방지**: 입력측 deser switch, null/empty/missing 3-상태, per-API money string-vs-number 직렬화 예제는 이 branch의 구현 범위가 아니다. 특히 sample 도메인에는 money field 코드 시연이 없다.
|
||||
|
||||
### 5종 대안 검토 결과
|
||||
|
||||
concept 문서([[wiki/concepts/api-evolution-and-schema]]) Standard 섹션의 5개 진영 — Stripe date-based / GitHub `X-GitHub-Api-Version` + 24mo EOL / Google AIP-180 / Twitter tier-based / Spring HATEOAS — 을 비교한 결과 internal-first + 단일 팀 trade-off로 **`api-version` 헤더 + 90d/30d migration window + Sunset+Deprecation 병기**를 채택. 사유는 concept 문서 한계 / 주의점 섹션과 동일.
|
||||
|
||||
versioning/compatibility 대안 비교 자체는 문서/설계 단계 — version interceptor, Sunset header bean 미작성. (Jackson 직렬화 출력측 핀은 별개로 구현됨, §실제 구현 내용 참조.)
|
||||
|
||||
## 면접에서 말할 수 있는 범위
|
||||
|
||||
### 자신 있게 답할 수 있는 질문
|
||||
|
||||
- **90d public + 30d internal migration window 근거** — Stripe(freeze forever)는 외부 결제 컨슈머 규모에 특화된 trade-off라 internal에 그대로 차용 시 server에 N개 버전 분기를 영구 운반, GitHub 24mo EOL은 catalog에 410 응답 명시가 없으면 사실상 *어느 날 갑자기 410*과 같음. internal-first 단일 팀 환경에서는 deploy lag을 흡수할 수 있는 가장 짧은 두 layer로 90d/30d.
|
||||
- **`Sunset` vs `Deprecation` 헤더 차이 + 함께 보내는 이유** — `Sunset`(RFC 8594)은 *언제* 사라지는지의 HTTP-date 신호(ABNF: `Sunset = HTTP-date`), `Deprecation` 헤더(draft-ietf-httpapi-deprecation-header)는 *지금 deprecated인지*의 Structured Date 상태 신호. 하나만 보내면 "사라질 날짜는 아는데 권장 여부는 모름" 또는 그 반대 상태가 되므로 **paired 송신이 IETF httpapi WG 권고**. paired invariant는 "Sunset 시점 ≥ Deprecation 시점". 추가로 `Link rel="deprecation"` / `rel="sunset"`으로 사람-가독 가이드 연결. ca-tmpl도 결정 사항에 paired 전송을 명시 박음(2026-05-22).
|
||||
- **Narrow enum이 breaking인 이유** — server-side에서는 허용값 축소가 invariant 강화처럼 보이지만, 이전 enum value를 합법적으로 보내던 client 입장에서는 어제까지 통과하던 요청이 오늘 거부됨. enum value 추가도 client side에 unknown enum fallback이 contract로 없으면 breaking.
|
||||
- **Strict inbound + tolerant outbound 의미** — 요청은 unknown field를 거부해 typo/payload smuggling 방어, 응답은 schema 정의 외 field 누출을 막음. 단 concept 문서가 지적하듯 정확한 표현은 "strict inbound / schema-controlled outbound".
|
||||
- **BigDecimal `new BigDecimal(double)` 함정 + ArchUnit 정적 차단** — `new BigDecimal(0.1)`은 `0.1000...555` 잔차를 담고 `new BigDecimal("0.1")`/`BigDecimal.valueOf`는 정확하다. ca-tmpl 은 이 함정을 `no_bigdecimal_double_constructor` ArchUnit 룰(`callConstructor(BigDecimal.class, double.class)`/`float.class`)로 production 패키지에서 build fail 시키고, vacuous-pass 방지 fixture 테스트까지 둔다 (`locally-verified`, @5d89766). HALF_UP 은 금융 round-half-up 관례와 정합. JSON number 직렬화 시 JS `Number` 정밀도 손실이 있어 외부/금융 API 는 string 직렬화 권장 — 단 per-API string-vs-number 는 *문서 의무*로만 박혔고 sample 도메인에 money 필드가 없어 코드 시연은 없다.
|
||||
- **serialization 계약을 '기본값'이 아니라 '명시 핀 + effective-bean 테스트'로 고정한 이유** — `WRITE_DATES_AS_TIMESTAMPS=false`/`WRITE_BIGDECIMAL_AS_PLAIN=true`는 현재 Spring Boot 기본값과 일치하나, future default flip 회귀를 차단하려고 `application.yml`/`application-test.yml`/`.env`에 명시 핀했다 (`spring.mvc.problemdetails.enabled=false`와 동일 논리). `JacksonSerializationPolicyTest`가 `ApplicationContextRunner`로 wired `ObjectMapper`의 `OffsetDateTime`→`"...Z"`/`LocalDate`→`"YYYY-MM-DD"`/`BigDecimal`→plain 직렬화 동작까지 검증해 `JavaTimeModule` 누락 회귀(배열 직렬화)도 잡는다 (`locally-verified`, @5d89766).
|
||||
- **conditional request 가 DB optimistic lock 과 같은 충돌의 두 표현이라는 점** — read 응답이 entity `@Version` 으로부터 `W/"<version>"` ETag 를 발행하고(`ETags.weakFromVersion`), write 가 `If-Match` 로 그 버전을 제시한다. 버전이 안 맞으면 HTTP layer 에서 412 Precondition Failed (`PreconditionFailedException` → `GlobalExceptionHandler`), 같은 충돌이 persistence layer 면 serialization failure 로 표현된다. ca-tmpl 은 `WorkLogControllerWireTest` 로 ETag 발행/304/412 를 검증했다 (locally-verified).
|
||||
- **인증된 API 의 안전한 cache default = `no-store`** — `CacheControlFilter` 가 모든 응답에 `Cache-Control: no-store` + `Vary: Accept, Accept-Encoding, Authorization` 를 박아 proxy/CDN cache poisoning 을 막는다. cacheable endpoint 만 `ResponseEntity` 의 `Cache-Control` 로 opt-in. Spring Security 자체 cache-control 은 비활성화해서 이 필터를 단일 owner 로 둠.
|
||||
- **pagination 의 size cap 이 왜 DoS 방어인가 + Spring 기본값과의 관계** — `size` 를 1..100 으로 제한하고 `page<0`/`size` 범위 밖은 400 VALIDATION_FAILED (`PageParams`). Spring 의 기본 `DEFAULT_MAX_PAGE_SIZE` 는 Integer.MAX_VALUE 가 아니라 2000 이며, 100 cap 은 그 위에 얹은 project-internal 추가 제한이라는 점까지 말할 수 있다.
|
||||
- **batch endpoint 의 sync = atomic 결정** — `POST /worklogs:batchCreate` 는 AIP-136 colon-verb + 단일 트랜잭션 all-or-nothing (partial 금지), `@Size(max=1000)` cap. partial failure 는 async LRO polling 응답에서만 허용. `batch_over_size_cap_is_400` 으로 검증.
|
||||
|
||||
### 적당히 답할 수 있는 질문
|
||||
|
||||
- **Stripe date-based versioning vs ca-tmpl** — Stripe는 account 단위 version pin + freeze forever로 외부 결제 컨슈머 deploy lag을 server 측 영구 분기로 흡수, ca-tmpl은 헤더 기반 + 시한 migration window로 server 분기 부담을 한정. 다만 외부 컨슈머 규모 차이가 trade-off의 본질이라 "ca-tmpl이 더 낫다" 식의 단정은 금지.
|
||||
|
||||
### 답하면 안 되는 질문 (모른다고 해야 함)
|
||||
|
||||
- **"API deprecation을 운영해 본 경험"** — 답: 없음. ca-tmpl은 운영 배포 자체가 없다.
|
||||
- **"외부 컨슈머와 migration coordination을 해본 경험"** — 답: 없음. 외부 컨슈머가 존재하지 않는다.
|
||||
- **"compatibility/deprecation 결정을 코드로 구현했는가"** — 답: 아니다. 계약·설계 단계. (schema/serialization 출력측은 별개로 C2 에서 `locally-verified` — 위 §실제 구현 참조. compatibility 축만 미구현.)
|
||||
- **"운영 측정값 / cutover 인시던트 / 410 응답 실측"** — 답: 모두 없다.
|
||||
|
||||
## 과장 금지 지점
|
||||
|
||||
- **"Stripe 방식이 API versioning의 표준이다"** — ❌. IETF/W3C 표준이 아니고 외부 결제 컨슈머 규모에 특화된 trade-off다. ca-tmpl은 다른 trade-off를 택한 것이지 우열을 판정한 게 아니다.
|
||||
- **"`Sunset` 헤더만 보내면 deprecation 정책으로 충분하다"** — ❌. `Sunset`은 *언제* 신호이고 `Deprecation`은 *지금 상태* 신호다. 병기해야 정합.
|
||||
- **"OpenAPI `deprecated: true`로 marker만 박으면 client가 알아서 migrate한다"** — ❌. schema marker는 신호일 뿐, 실제 cutover는 migration window + contract test + compatibility fixture가 함께 강제해야 한다.
|
||||
- **"Protobuf `reserved` 시맨틱을 JSON 환경에서 동등하게 흉내낼 수 있다"** — ❌. OpenAPI에는 동등 시맨틱이 없고 `x-` extension으로 흉내내야 하는데 검증 도구 표준이 부재해 효과가 제한적이다. **needs-confirmation**.
|
||||
- **"Jackson default가 안전하다"** — ❌. `FAIL_ON_UNKNOWN_PROPERTIES=true`는 strict이지만 `FAIL_ON_NULL_FOR_PRIMITIVES=false`는 lenient라 null/missing primitive가 묵시적으로 0이 된다. ca-tmpl 은 후자(입력측 deser switch)를 sibling `feature-boundary-validation-mapping-contract` 가 명시 override 했고 (`locally-verified`), 직렬화 출력측은 본 branch 가 핀했다. "default 라서 안전"이 아니라 "명시 핀 + 테스트"로 강제했다고 말해야 한다.
|
||||
- **"90d/30d window를 운영에서 검증했다"** — ❌. 운영 배포 0건. 설계 결정의 *근거*는 말할 수 있지만 *경험*은 없다.
|
||||
- **"envelope처럼 compatibility 결정도 구현했다"** — ❌. compatibility/deprecation 축은 계약/설계 단계, 코드 미구현. schema/serialization 축은 *출력측* (datetime/BigDecimal 핀 + ArchUnit + 직렬화 테스트) 만 `locally-verified` 이고, D5 OpenAPI drift gate · D6 제거-field 도구 · D7 Avro · per-API money string-vs-number 코드 시연은 미구현이다. (contract baseline 축은 별개로 locally-verified)
|
||||
- **"BigDecimal 을 금액 string 직렬화로 구현했다"** — ❌. `WRITE_BIGDECIMAL_AS_PLAIN=true` + `new BigDecimal(double)` 정적 차단은 구현했으나, 외부 API string 직렬화(`@JsonSerialize(ToStringSerializer)`)는 sample 도메인에 money 필드가 없어 코드 시연이 없다 — per-API string-vs-number 는 *문서 의무*까지다.
|
||||
- **"OpenAPI drift 로 schema 없는 response field 노출을 차단한다"** — ❌. 직렬화 출력측 핀은 했으나 response 측 "schema 없는 field 미노출"의 release-blocking 강제(D5)는 verification suite(`feature-contract-verification-test-suite`) 소유 planned 이다.
|
||||
- **"conditional request 를 RFC 9110 대로 strong ETag 로 구현했다"** — ❌. `If-Match` 비교는 weak/lenient 다 (`ETags.matches` 가 `W/`·따옴표 무시). RFC 9110 의 strong comparison MUST 와는 다른 skeleton 단순화이며, production fork 에서 교체해야 한다.
|
||||
- **"cursor pagination 을 운영 key 로 서명해 구현했다"** — ❌. `CursorCodec` 은 dev key factory(`withDevKey()`)만 있고 운영 key 주입/회전은 security branch 소유 planned. 메커니즘(opaque base64url + HMAC + TTL)은 구현·검증됨.
|
||||
- **"414 URI Too Long 을 end-to-end 로 처리한다"** — ❌. Tomcat/gateway 가 Spring dispatch 전에 거부하므로 registry row + code 만 있고 end-to-end 검증은 없다.
|
||||
- **"Idempotency 를 구현했다"** — ❌. `Idempotency-Key` header 이름 수용(server-tolerant)만. key shape/replay 는 rate-limit branch 소유.
|
||||
- **"OpenAPI drift 를 릴리스에서 차단한다"** — ❌. 이 branch 는 snapshot producer + registry mapping 정합 test 까지. release-blocking 집행은 verification-test-suite branch 소유.
|
||||
|
||||
## 관련 개념
|
||||
|
||||
- [[wiki/concepts/api-evolution-and-schema]]
|
||||
|
||||
## Sources
|
||||
|
||||
- [[raw/project-notes/ca-skeleton-operational-contract]] §13 API Contract Surface / §16 Schema / Serialization Contract / §25 Default Decisions (API versioning) / §29 G-F (외부 근거 / 대안 조사 인덱스)
|
||||
- [[raw/branch-notes/feature-api-contract-baseline]] — versioning(`/v1`), pagination/sort, conditional request(ETag/If-Match/304/412 = D15), HTTP cache(`no-store`/`Vary`), OpenAPI producer, LRO, batch endpoint. Ground-truth @b15dcf5 로 대조해 `locally-verified` 확정.
|
||||
- [[raw/branch-notes/feature-api-compatibility-deprecation-contract]] — 90d/30d migration window, 7행 breaking change catalog, Sunset + Deprecation 헤더 병기, OpenAPI `deprecated: true` marker (documented-only)
|
||||
- [[raw/blog-topics/api-deprecation-sunset-header-migration-window-2026-07-02]] — compatibility/deprecation 블로그 글감 raw seed. canonical 반영 범위: documented-only project decision + source-backed/header-role 경계 + 과장 금지 항목.
|
||||
- [[raw/branch-notes/feature-schema-serialization-contract]] — ISO-8601 offset/UTC, BigDecimal scale 2 + HALF_UP, unknown field strict inbound, null/empty/missing 분리. 직렬화 출력측(datetime/BigDecimal 핀 + `no_bigdecimal_double_constructor` ArchUnit + `JacksonSerializationPolicyTest`)은 Ground-truth @5d89766 로 대조해 `locally-verified`; D5/D6/D7 + per-API money 코드 시연은 미구현.
|
||||
- [[raw/blog-topics/spring-boot-serialization-contract-pins-2026-07-02]] — Spring Boot serialization pin 블로그 글감 raw seed. canonical 반영 범위: output serialization pin + effective ObjectMapper test + BigDecimal constructor guard.
|
||||
- [[raw/official-docs/sunset-deprecation-headers-paired-usage]] — IETF RFC 8594 + Deprecation draft paired 사용 권고 (Sunset 단독 금지)
|
||||
- [[raw/official-docs/rfc9110-http-semantics]] — D15 conditional request(ETag/If-Match/If-None-Match/304/412), D8/D9/D12 transport error 의미론
|
||||
- [[raw/official-docs/rfc9111-http-caching]] — D16 `no-store`/`private`/`max-age` directive 정의
|
||||
- [[raw/official-docs/openapi-spec-3-1-0]] — D10 OpenAPI = machine-readable contract
|
||||
- [[raw/official-docs/google-aip-185-resource-versioning]] — D2 major-only `/v1` path versioning
|
||||
- [[raw/official-docs/spring-data-pageable-defaults]] — D18/D20 Pageable zero-indexed + size default + `DEFAULT_MAX_PAGE_SIZE` 2000
|
||||
- [[raw/official-docs/schema-jackson-unknown-field-handling]] — Jackson DeserializationFeature default (직렬화/역직렬화 정책 근거)
|
||||
- [[raw/official-docs/schema-bigdecimal-money-serialization-java]] — Java BigDecimal scale/HALF_UP + `new BigDecimal(double)` 함정 (D3 / SBMS-C1~C4)
|
||||
- [[raw/official-docs/rfc3339-datetime-utc]] — IETF RFC 3339 datetime UTC + "Z" suffix (D2 datetime 직렬화 normative 근거)
|
||||
|
||||
## Cluster / 묶음
|
||||
|
||||
<!-- GENERATED: derived-blogs:start -->
|
||||
- [[wiki/blog/ca-tmpl-api-evolution-and-schema-2026-07-02]]
|
||||
<!-- GENERATED: derived-blogs:end -->
|
||||
@@ -0,0 +1,163 @@
|
||||
---
|
||||
title: ca-tmpl - 입력 경계 검증 & DTO↔도메인 매핑 계약 (Bean Validation · Patch · ACL · 정적 강제)
|
||||
source_type: project
|
||||
status: verified
|
||||
confidence: high
|
||||
tags: [ca-tmpl, validation, mapper, boundary, dto, archunit, actually-implemented]
|
||||
related_projects: [ca-tmpl]
|
||||
last_reviewed: 2026-07-02
|
||||
---
|
||||
|
||||
# ca-tmpl - 입력 경계 검증 & DTO↔도메인 매핑 계약
|
||||
|
||||
> Layer: `wiki/projects/` — 내 프로젝트 사실. 일반 개념은 [[wiki/concepts/boundary-validation-and-dto-mapping]] 참조.
|
||||
|
||||
## 프로젝트 컨텍스트
|
||||
|
||||
- **프로젝트**: ca-tmpl — Clean Architecture 기반 백엔드 skeleton 템플릿.
|
||||
- **목표**: request → application → response 의 입력/출력 경계에서 (1) 무엇을 검증하고 (2) 어떤 mapper 를 통과해야 하는지 고정하고, 핵심 정책을 ArchUnit fitness function 으로 **정적 강제**한다. DTO·domain·persistence 모델이 서로 새어 나가는 것을 막는 것이 핵심.
|
||||
- **이유**: 경계가 흐려지면 도메인/엔티티가 응답에 silent 직렬화되거나, request DTO 가 service layer 까지 leak 되거나, PATCH 가 기존 값을 silent overwrite 하는 회귀가 코드 리뷰만으로는 반복적으로 새어 나간다. 컨벤션을 *코드*(ArchUnit + wire-level 테스트)로 묶어 다음 작업자가 무심코 깨면 build 가 빨갛게 떨어지도록 했다.
|
||||
- **진행 단계**: **Phase C2 (코드) 구현 + 로컬 검증 완료.** `feature-boundary-validation-mapping-contract` 브랜치에서 ArchUnit rule, exception handler, envelope advice, mapper, sample 도메인(WorkLog) 까지 작성되어 코드 베이스에 존재한다. 운영 배포 / 실 DB 통합 테스트 / 측정값은 없다.
|
||||
|
||||
## Ground-truth 대조 (2026-06-04, ca-tmpl @fccb033 "경계 검증 계약 추가 및 sample 모듈 교체")
|
||||
|
||||
`/home/donghyeon/workspace/ca-tmpl` 의 commit `fccb033` 코드를 직접 읽고 테스트를 재실행해 검증한 사실:
|
||||
|
||||
- 패키지 root 는 `dev.caskeleton.*`. 브랜치 노트의 이전 `com.example.blog` 는 stale.
|
||||
- fccb033 시점에 **sample 모듈은 이미 `sample-portfolio`(WorkLog 도메인)** 로 교체된 상태다. 즉 "sample 모듈 교체"(sample-ticket → sample-portfolio)는 본 커밋에 포함되어 있다. 브랜치 노트가 참조한 `BoundaryDemoControllerWireTest` 는 교체 과정에서 **`WorkLogControllerWireTest` 로 re-home** 되었고, B1/B2/B3/B8 검증은 WorkLog 엔드포인트로 이전되었다.
|
||||
- 브랜치 노트는 일부 ArchUnit rule(controller 반환 타입, `@Valid` cascade depth)을 `planned` 으로 표기했으나, **fccb033 에서는 8개 boundary ArchUnit rule 이 모두 실제 구현되어 있다** (아래 §실제 구현 내용). 본 문서는 ground truth 를 우선해 이들을 `actually-implemented` 로 기록한다.
|
||||
- `./gradlew test verifyCleanArchitectureDependencies` (fccb033 worktree) → **BUILD SUCCESSFUL, 126 tests / 0 failures** (2026-06-04 재실행, exit 0).
|
||||
- 현재 repo HEAD 는 `db61075`(sibling `feature-business-rule-validation-contract`)로 더 진행되어 `Category`/`ResponseMeta` 등이 추가됨. 본 문서는 **fccb033 기준 사실만** 기록한다.
|
||||
|
||||
## 실제 구현 내용 (`actually-implemented`)
|
||||
|
||||
ca-tmpl @fccb033 코드에서 직접 확인한 산출물:
|
||||
|
||||
**shared-contract (stdlib-only, `dev.caskeleton.shared.*`)**
|
||||
|
||||
- `request/Patch.java` — PATCH 필드의 3-state 값 객체. `absent()` / `ofNull()` / `of(value)` + `isAbsent()` / `isExplicitNull()` / `hasValue()`. 웹 어댑터가 Jackson-aware `JsonNullable<T>` 를 이 Jackson-free 타입으로 변환해 application-core 가 wire 표현을 보지 않도록 함 (B2).
|
||||
- `error/MappingException.java` — 모든 경계 mapper(request→command, response shaping, outbound ACL)가 "구조는 멀쩡하나 의미상 매핑 불가" 일 때 던지는 sentinel `RuntimeException`. shared.error 에 두어 어느 모듈이든 cross-adapter 의존 없이 던질 수 있게 함 (B3 + B7). *(주의: 브랜치 노트 errors 로그는 `application.exception` 으로 이전했다고 기록하나, fccb033 ground truth 에서는 `shared.error` 에 위치 — 이후 모듈 승격/재배치의 결과.)*
|
||||
- `error/OperationalError.java` (enum) + `error/ApiErrorCode.java` (인터페이스, `code()`/`httpStatus()` int/`retryable()`) — `VALIDATION_FAILED(400,false)`, `MAPPING_FAILED(400,false)`, `BATCH_PARTIAL_FAILURE(200,false)`, `BAD_PARAMETER(400)`, `INTERNAL_ERROR(500,true)` 등. 전송 중립을 위해 Spring `HttpStatus` 대신 plain int.
|
||||
- `response/Envelope.java` / `response/BulkEnvelope.java` / `response/ApiError.java` — skeleton-wide 응답 봉투 타입.
|
||||
|
||||
**adapter-web (`dev.caskeleton.adapter.web.*`)**
|
||||
|
||||
- `error/GlobalExceptionHandler.java` (`@RestControllerAdvice extends ResponseEntityExceptionHandler`) — `ProblemDetail` import 0 (D5: RFC 7807 거부). `MappingException`→`MAPPING_FAILED`, `ConstraintViolationException`→`VALIDATION_FAILED`(field/message 리스트), `handleMethodArgumentNotValid` override→`VALIDATION_FAILED`(field/rejectedValue/message), `handleHttpMessageNotReadable` override→`VALIDATION_FAILED`(`{cause: <Jackson exception simpleName>}`), method-not-allowed/media-type/route-not-found override, catch-all→`INTERNAL_ERROR`. 모두 `ErrorResponseFactory` 단일 지점으로 envelope 빌드.
|
||||
- `envelope/EnvelopeBodyAdvice.java` (`ResponseBodyAdvice`) — 모든 JSON 컨트롤러 응답을 `Envelope.ok(body, traceId)` 로 자동 wrap. 이미 `Envelope`/`BulkEnvelope` 면 pass-through, null/void(DELETE 204)·비-JSON skip (D5/D6).
|
||||
|
||||
**sample-portfolio (WorkLog 도메인 — 계약 실증)**
|
||||
|
||||
- `adapter/web/dto/request/CreateWorkLogRequest.java` — `@GroupSequence({Syntax.class, Invariant.class, CreateWorkLogRequest.class})` + `@NotBlank/@Size/@NotNull(groups=Syntax.class)` + `@AssertTrue(groups=Invariant.class)` periodEnd≥periodStart. syntax→invariant short-circuit 실증 (B4).
|
||||
- `adapter/web/dto/request/UpdateWorkLogRequest.java` — 필드를 `JsonNullable<T>` 로 받아 `Patch<T>` 로 변환(`titlePatch()` 등). PATCH 3-state (B2).
|
||||
- `adapter/web/dto/request/SamplePolymorphicRequest.java` — `sealed interface` + record subtypes(`Text`/`Image`) + `@JsonTypeInfo(use=NAME, property="kind")` + `@JsonSubTypes` allowlist. allowlist 외 discriminator → `InvalidTypeIdException` (B5).
|
||||
- `adapter/web/mapper/WorkLogWebMapper.java` — 수기 mapper. 잘못된 link URI 면 `MappingException` wrap (B3). domain→response DTO 변환.
|
||||
- `adapter/outbound/repostats/RepoStatsAclMapper.java` (+ package-private `RawRepoStatsResponse`) — B7 ACL: normalization(lower-case)/masking(echoedToken drop)/public-field selection 후 domain 타입만 반환. raw 누락 시 `MappingException`.
|
||||
- `adapter/persistence/mapper/WorkLogPersistenceMapper.java` — 영속 매퍼.
|
||||
|
||||
**app-bootstrap — ArchUnit fitness functions** (`architecture/CleanArchitectureTest.java`) — boundary rule **8개 모두 실 ArchRule (allowEmptyShould)**:
|
||||
|
||||
- `request_dtos_do_not_silence_unknown_fields` — `..adapter.web..dto..` 의 class-level `@JsonIgnoreProperties(ignoreUnknown=true)` 금지 (B1).
|
||||
- `no_jackson_laissez_faire_subtype_validator` + `no_jackson_enable_default_typing_call` — CVE-2019-14379 RCE 벡터 차단 (B5).
|
||||
- `no_inheritable_thread_local` — virtual thread 누설 방지 (B6).
|
||||
- `controllers_do_not_return_domain_or_entity_types` — controller public 메서드가 `..domain.entity..`/`..persistence.entity..`/`..repository..` 반환 금지 (§Forbidden). *브랜치 노트는 `planned` 였으나 fccb033 에 구현됨.*
|
||||
- `application_methods_do_not_accept_web_dtos` — application public 메서드가 `..adapter.web..dto..` 파라미터 수용 금지 (§Forbidden). *동일하게 fccb033 에 구현됨.*
|
||||
- `no_problem_detail_usage` — `org.springframework.http.ProblemDetail` import 차단 (D5).
|
||||
- `no_merge_patch_json_media_type_string` — custom ArchCondition 으로 `application/merge-patch+json` 어노테이션 참조 차단 (B2).
|
||||
- `valid_cascade_depth_at_most_three` — custom ArchCondition 으로 `@Valid` cascade depth ≤ 3 (B4 DoS 방어). *브랜치 노트는 `planned` 였으나 fccb033 에 구현됨.*
|
||||
- `outbound_adapter_method_returns_only_domain_or_primitives` — outbound public 메서드가 raw external 응답 타입 escape 금지 (B7 ACL).
|
||||
- 각 rule 은 `ArchitectureViolationFixtureTest` 의 의도된 위반 fixture(`JsonIgnoreUnknownRequestFixture`, `DefaultTypingFixture`, `InheritableThreadLocalFixture`, `DomainReturningControllerFixture`, `WebDtoAcceptingApplicationFixture`, `ProblemDetailUsingFixture`, `MergePatchJsonFixture`, `DeepCascadeRequestFixture`)로 catch 동작을 보증 (violations-as-data).
|
||||
|
||||
## 로컬/dev 검증 (`locally-verified`)
|
||||
|
||||
- **wire-level 테스트** `WorkLogControllerWireTest` (`@WebMvcTest`/`@TestPropertySource`) 9 케이스: envelope wrap, 404, blank-title validation, unknown-field 거부(B1), unmappable-link→`MAPPING_FAILED`(B3), PATCH present-only 교체 + explicit-null 수용(B2), repo-stats domain via envelope, bulk partial → `BATCH_PARTIAL_FAILURE`(B8).
|
||||
- **unit/contract 테스트**: `SamplePolymorphicRequestTest`(B5 sealed type 4 케이스), `BasicPolymorphicTypeValidatorAllowlistTest`(B5 allowlist 4 케이스), `BulkEnvelopeTest`(3 케이스), `GlobalExceptionHandlerTest`, `EnvelopeBodyAdviceTest`, `RepoStatsAclMapperTest`(B7), `WorkLogPersistenceMapperTest`, `DomainExceptionHandlerTest`, `OperationalErrorTest`.
|
||||
- **virtual-thread MDC**: `VirtualThreadMdcPropagationTest`(unit) + `VirtualThreadMdcE2ETest`(`@SpringBootTest(RANDOM_PORT)` 실 Tomcat + 실 가상스레드 + 실 `RequestLoggingFilter` + `TestRestTemplate`) — server-generated `requestId` 와 client `X-Request-Id` 두 경로가 컨트롤러까지 도달함을 wire-level pin (B6).
|
||||
- **ArchUnit + 위반 fixture**: `CleanArchitectureTest` + `ArchitectureViolationFixtureTest` 전체 green.
|
||||
- **전체 빌드**: `./gradlew test verifyCleanArchitectureDependencies` (fccb033) → **126 tests / 0 failures**, 2026-06-04 재실행 exit 0.
|
||||
- 검증 범위는 JVM 단위/슬라이스/슬라이스-wire/e2e(in-process Tomcat) + 정적 분석까지. **실 DB(Testcontainers) 통합 테스트는 없음** (persistence 매퍼는 unit 레벨).
|
||||
|
||||
## 운영 검증 (`prod-verified`)
|
||||
|
||||
**없음.** 운영 환경에 배포된 적이 없다. 트래픽·측정값·인시던트·릴리즈 노트 어느 것도 없다. 본 패스는 enforcement + reference + unit/contract + wire-level + e2e(in-process) 단계까지다.
|
||||
|
||||
## 문서/계획만 존재 (`documented-only` / `planned`)
|
||||
|
||||
다음은 설계/문서/위임 상태이며 **면접에서 "구현했다 / 검증했다"고 말하면 안 된다**.
|
||||
|
||||
- **B7-2 실 `WebClient`/`RestClient` + WireMock 통합**: `planned`. 현 패스의 outbound 는 HTTP fetch 를 추상화한 형태이고 실 외부 HTTP 왕복은 미검증.
|
||||
- **B8-2 OpenAPI response shape 분기(`oneOf`) 명시**: `planned`. OpenAPI 스펙 자체가 부재해 구현 보류.
|
||||
- **B2 RFC 7396 미채택 사실의 OpenAPI 문서화**: `planned` (OpenAPI 부재).
|
||||
- **request DTO primitive→wrapper 강제 ArchUnit rule** (B1 component-type): `planned`. Jackson 4-종 스위치 자체는 설정/테스트로 확인되나 component-type ArchUnit rule 은 미작성.
|
||||
- **실 DB 통합(@DataJpaTest / Testcontainers)**, `@Version` 낙관적 락: `planned` (후속 브랜치).
|
||||
- **MapStruct generated mapper exemption rule**: `documented-only` / `needs-confirmation`. 현 구현은 수기 mapper 만 사용하며 MapStruct 는 optional 계약으로만 존재.
|
||||
|
||||
## 면접에서 말할 수 있는 범위
|
||||
|
||||
### 자신 있게 답할 수 있는 질문
|
||||
|
||||
- 입력 경계에서 검증/매핑 책임을 어떻게 분리했는가 — Bean Validation `@GroupSequence` 로 syntax→invariant short-circuit, request DTO→command 수기 mapper, response 는 DTO 만 노출.
|
||||
- `MethodArgumentNotValidException` / `HttpMessageNotReadableException` 을 왜 `VALIDATION_FAILED` 로, mapper 내부 실패를 왜 `MappingException`→`MAPPING_FAILED` 별도 카테고리로 분류했는가 (Spring 이 전자는 자동 처리, 후자는 안 하므로).
|
||||
- PATCH 의 absent/explicit-null/value 3-state 를 `JsonNullable<T>`→`Patch<T>` 로 어떻게 구분했고, 구분 안 하면 어떤 silent overwrite 버그가 나는가.
|
||||
- CVE-2019-14379 (Jackson default typing gadget chain RCE) 를 ArchUnit 으로 `enableDefaultTyping()` 호출과 `LaissezFaireSubTypeValidator` 참조를 정적 차단하고, 안전한 `@JsonTypeInfo`+`@JsonSubTypes` / `BasicPolymorphicTypeValidator` allowlist 만 허용한 방법.
|
||||
- RFC 7807 ProblemDetail 을 왜 거부하고 custom envelope 를 썼는가, 그 결정을 `no_problem_detail_usage` ArchUnit 으로 회귀 차단한 방법.
|
||||
- B7 outbound ACL — 외부 응답 raw 타입이 domain 으로 leak 되지 않도록 mapper + ArchUnit(`outbound_adapter_method_returns_only_domain_or_primitives`)으로 강제한 방법.
|
||||
- violations-as-data — 각 ArchUnit rule 이 의도된 위반 fixture 를 실제로 잡는지 네거티브 테스트로 보증한 패턴.
|
||||
|
||||
### 적당히 답할 수 있는 질문
|
||||
|
||||
- virtual thread(`spring.threads.virtual.enabled`) 환경에서 `InheritableThreadLocal` 이 왜 위험하고 MDC/`RequestContextHolder` 로 어떻게 context 를 전파하는가 (단 실 프로덕션 트래픽 검증은 안 함).
|
||||
- MapStruct vs 수기 mapper 의 trade-off (현 구현은 수기 mapper 채택, MapStruct 는 미사용).
|
||||
|
||||
### 답하면 안 되는 질문 (모른다고 해야 함)
|
||||
|
||||
- "운영에서 이 검증/매핑 계약이 인시던트를 막은 사례가 있는가? 성능을 측정했는가?" → **운영 배포 없음, 측정 없음.**
|
||||
- "outbound ACL 을 실 외부 API + WireMock 으로 통합 검증했는가?" → **안 함. HTTP fetch 추상화 단계.**
|
||||
- "PATCH/검증을 실 DB 통합 테스트로 끝까지 돌렸는가?" → **persistence 는 unit 레벨. Testcontainers 통합 없음.**
|
||||
- "OpenAPI 로 bulk/단일 응답 shape 분기를 명시했는가?" → **OpenAPI 스펙 부재. `planned`.**
|
||||
|
||||
## 과장 금지 지점
|
||||
|
||||
- **"운영에서 검증했다 / prod 에서 돌고 있다" → 금지.** 로컬 단위/슬라이스/e2e(in-process Tomcat) + 정적 분석까지가 검증 범위.
|
||||
- **"실 DB 통합 테스트로 PATCH/매핑을 검증했다" → 금지.** persistence 매퍼는 unit 레벨, Testcontainers 없음.
|
||||
- **"4-layer validation(syntax/policy/invariant/persistence integrity)은 표준 분류다" → 금지.** Bean Validation spec 은 이 taxonomy 를 정의하지 않는다. ca-tmpl 내부 설계 결정이다([[wiki/concepts/boundary-validation-and-dto-mapping]] 참조).
|
||||
- **"controller 반환 타입/cascade depth ArchUnit 은 계획만 했다" → (옛 브랜치 노트 표현) 정정.** fccb033 ground truth 에서는 둘 다 구현되어 있다.
|
||||
- **"ArchUnit 으로 막았으니 RCE/leak 이 원천 불가능하다" → 단정 금지.** 정적 분석은 바이트코드에서 탐지 가능한 carrier(어노테이션/import/호출)만 잡는다. 메서드 본문 내 free-form 문자열 등은 한계가 있다(코드 주석에 명시됨).
|
||||
- **"`MappingException` 위치가 `application.exception` 이다" → fccb033 기준 정정.** ground truth 에서는 `shared.error` 에 있다.
|
||||
|
||||
### Blog-topic ingest: boundary-validation-mapper-responsibility-map (2026-07-02)
|
||||
|
||||
[[raw/blog-topics/boundary-validation-mapper-responsibility-map-2026-07-02]] 는 입력 syntax, application policy, domain invariant, persistence integrity, mapper normalization 책임을 한 계층에 몰지 않는 경계 설계를 블로그로 풀기 위한 raw seed다.
|
||||
|
||||
- **locally-verified 로 말할 수 있는 부분**: `Patch<T>` 3-state, `MappingException`, DTO/mapper boundary ArchUnit rule, validation/mapping wire·unit test 범위.
|
||||
- **project-local policy 로 말할 부분**: syntax/policy/invariant/persistence integrity/normalization 책임 분리는 ca-tmpl 내부 taxonomy다.
|
||||
- **블로그 전 과장 방지**: 모든 validation 책임을 해결하는 보편 구조처럼 쓰지 않고, fccb033 기준 구현·검증 범위와 미구현 OpenAPI/DB integration 범위를 분리한다.
|
||||
|
||||
### Blog-topic ingest: archunit-jackson-default-typing-cve block (2026-07-02)
|
||||
|
||||
[[raw/blog-topics/archunit-jackson-default-typing-cve-2019-14379-block-2026-05-29]] 는 Jackson default typing RCE 진입점(`enableDefaultTyping`, `LaissezFaireSubTypeValidator`)을 ArchUnit fitness function으로 차단한 글감이다.
|
||||
|
||||
- **locally-verified 로 말할 수 있는 부분**: `no_jackson_laissez_faire_subtype_validator`, `no_jackson_enable_default_typing_call`, `DefaultTypingFixture`가 boundary canonical에 이미 구현/검증 범위로 기록돼 있다.
|
||||
- **블로그 전 과장 방지**: CVE 전체를 제거했다고 쓰지 않고, ca-tmpl 코드에서 특정 위험 API 호출/참조를 정적 rule로 차단한 범위로 제한한다.
|
||||
|
||||
## 관련 개념
|
||||
|
||||
- [[wiki/concepts/boundary-validation-and-dto-mapping]]
|
||||
- [[wiki/concepts/transaction-boundary-abstraction]]
|
||||
|
||||
## Sources
|
||||
|
||||
- [[raw/branch-notes/feature-boundary-validation-mapping-contract]] — 결정(D1~D15)·Decision Evidence Map·Claims To Verify·구현 결과(5/6차 패스)·wiki 추출 대상
|
||||
- [[raw/blog-topics/boundary-validation-mapper-responsibility-map-2026-07-02]] — boundary validation/mapper 책임 분리 블로그 글감 raw seed. canonical 반영 범위: verified boundary/mapping 구현 + project-local 책임 taxonomy + 과장 금지 항목.
|
||||
- [[raw/blog-topics/archunit-jackson-default-typing-cve-2019-14379-block-2026-05-29]] — Jackson default typing CVE static block 블로그 글감 raw seed.
|
||||
- [[raw/project-notes/ca-skeleton-operational-contract]] — §6 Operational Error Category(`VALIDATION_FAILED`/`MAPPING_FAILED`/`BATCH_PARTIAL_FAILURE` 등록), §20 Skeleton Blueprint package convention
|
||||
- [[raw/official-docs/validation-jakarta-bean-validation-3.0-spec]] — `@GroupSequence` short-circuit, `@Valid` cascade
|
||||
- [[raw/official-docs/spring-mvc-rest-exception-handling]] — `HttpMessageNotReadableException`/`MethodArgumentNotValidException` → VALIDATION 분류
|
||||
- [[raw/official-docs/patch-json-merge-rfc7396]] — PATCH null=deletion (미채택 근거)
|
||||
- [[raw/official-docs/schema-jackson-polymorphic-deserialization]] — CVE-2019-14379 + allowlist API (B5)
|
||||
- ca-tmpl @fccb033 코드 (ground-truth): `src/shared-contract/.../request/Patch.java` · `.../error/{MappingException,OperationalError,ApiErrorCode}.java`, `src/adapter-web/.../error/GlobalExceptionHandler.java` · `.../envelope/EnvelopeBodyAdvice.java`, `src/sample-portfolio/.../adapter/web/{dto/request,mapper}/*.java` · `.../adapter/outbound/repostats/RepoStatsAclMapper.java`, `src/app-bootstrap/.../architecture/{CleanArchitectureTest,ArchitectureViolationFixtureTest}.java` · `.../controller/WorkLogControllerWireTest.java`
|
||||
|
||||
## Cluster / 묶음
|
||||
|
||||
<!-- GENERATED: derived-blogs:start -->
|
||||
- [[wiki/blog/ca-tmpl-boundary-validation-mapping-2026-07-02]]
|
||||
<!-- GENERATED: derived-blogs:end -->
|
||||
@@ -0,0 +1,207 @@
|
||||
---
|
||||
title: ca-tmpl - Clean Architecture 패키지 레이아웃 결정
|
||||
source_type: project
|
||||
status: verified
|
||||
confidence: high
|
||||
tags: [ca-skeleton, clean-architecture, package-layout, locally-verified, interview-candidate]
|
||||
related_projects: [ca-skeleton, ca-tmpl]
|
||||
last_reviewed: 2026-07-02
|
||||
---
|
||||
|
||||
# ca-tmpl - Clean Architecture 패키지 레이아웃 결정
|
||||
|
||||
> Layer: `wiki/projects/` — 내 프로젝트 사실. 일반 개념은 `wiki/concepts/clean-architecture-package-layout` 사용.
|
||||
|
||||
## 프로젝트 컨텍스트
|
||||
|
||||
ca-tmpl은 Java 21 + Spring Boot 3.4 + Gradle multi-module 기반 Clean Architecture skeleton template이다. `blog` 도메인은 reference implementation이며, 새 프로젝트에서는 도메인 이름과 엔티티를 교체하되 module boundary와 dependency direction은 유지한다.
|
||||
|
||||
본 문서는 `feature-skeleton-package-blueprint-contract` branch-note의 package/module blueprint가 ca-tmpl repo에 실제 반영된 상태를 기록한다. 이 slice는 `actually-implemented` + `locally-verified`이며, 운영 배포 대상이 아니므로 `prod-verified`는 없다. 2026-06-04 ca-tmpl 레포(`@5d89766`) ground-truth 대조로 아래 사실을 검증함 (§Ground-truth 대조 참조).
|
||||
|
||||
## 실제 구현 내용 (`actually-implemented`)
|
||||
|
||||
- Gradle include가 `app-bootstrap`, `domain-core`, `application-core`, `adapter-web`, `adapter-persistence`, `adapter-outbound`, `shared-contract`, `sample-portfolio` 8개 module로 전환되었다. (이후 `feature-resource-identifier-contract` branch가 9번째 module `adapter-identifier`를 추가했으나 이는 본 slice 범위 밖이다.)
|
||||
- production package root는 `dev.caskeleton`이다. 기존 reference blog code는 다음 mapping으로 이동했다.
|
||||
- `cmd` → `app-bootstrap` / `dev.caskeleton.bootstrap` (`BlogApplication` → `CaSkeletonApplication`)
|
||||
- `domain` → `domain-core` / `dev.caskeleton.domain`
|
||||
- `service` → `application-core` / `dev.caskeleton.application`
|
||||
- `presentation` → `adapter-web` / `dev.caskeleton.adapter.web`
|
||||
- `infra` → `adapter-persistence` / `dev.caskeleton.adapter.persistence`
|
||||
- `blog.*` 설정 prefix → `ca-skeleton.*`, `CmdSettings` → `BootstrapSettings`
|
||||
- 기존 reference code는 production module에서 격리되어 `sample-portfolio` 내부 `dev.caskeleton.sample.portfolio.{domain,application}.worklog` package로 이동했다.
|
||||
- `domain-core`, `application-core`, `adapter-persistence`, `adapter-outbound`, `shared-contract`는 skeleton anchor package + `package-info.java` 중심으로 유지된다. 각 module 내부의 실제 business/contract type 구현은 후속 branch slice들(`feature-operational-error-observability-foundation`, `feature-api-contract-baseline` 등)이 채운다.
|
||||
- `src/build.gradle`의 `verifyCleanArchitectureDependencies` task(root `build.gradle:53`)가 module dependency matrix를 검사한다.
|
||||
- `app-bootstrap`의 `CleanArchitectureTest`(ArchUnit)가 domain purity, application adapter isolation, adapter 간 직접 의존 금지, web DTO containment, shared-contract package scope, production → `sample-portfolio` dependency 금지를 검사한다.
|
||||
- **(D9) module 간 의존 선언 정책**: 기본 `implementation`, 소비자의 public ABI에 타 module 타입이 노출될 때만 `api`. ground-truth 확인: 9개 `build.gradle` 모두 `api` 선언 0개, 전부 `implementation` — 정책 충족. 근거 `raw/official-docs/gradle-java-library-api-vs-implementation.md`.
|
||||
- **(D10) `@SpringBootApplication` 배치**: `dev.caskeleton.bootstrap`(root package)에 두고 default package 금지. multi-module component scan을 위해 `@SpringBootApplication(scanBasePackages = "dev.caskeleton")` 명시. ground-truth 확인: `app-bootstrap/.../bootstrap/CaSkeletonApplication.java`에 일치. 근거 `raw/official-docs/spring-boot-structuring-your-code.md`.
|
||||
- `README.md`, `AGENTS.md`, root/module `CLAUDE.md`, local clean-architecture rule이 새 module vocabulary로 갱신되었다.
|
||||
|
||||
### Boundary enforcement rules (`feature-architecture-enforcement-rules` slice)
|
||||
|
||||
> 이 sub-section은 module/package *blueprint* 위에 얹는 **enforcement-rules dimension**이다. 위 blueprint가 "module 경계가 어디 있는가"라면, 아래는 "그 경계가 깨지면 build가 실패하는가"를 다룬다. ca-tmpl `@db61075` ground-truth 대조로 아래 rule 이름·개수·위치를 확인했다(§Ground-truth 대조 — enforcement 참조). ⚠️ ground-truth 파일은 이후 다른 branch slice들이 rule을 더 추가했으므로, 아래는 **본 enforcement-rules slice가 정의·구현한 항목만** 추렸다(타 slice rule은 해당 branch ingest에서 다룬다).
|
||||
|
||||
ArchUnit test(`app-bootstrap/src/test/java/dev/caskeleton/bootstrap/architecture/CleanArchitectureTest.java`, `@AnalyzeClasses(packages = "dev.caskeleton", importOptions = DoNotIncludeTests.class)`)가 정적 import/dependency graph를 검사한다. 본 slice가 정의한 rule:
|
||||
|
||||
- `domain_is_pure` — `..domain..`이 `org.springframework..` / `jakarta.persistence..` / `javax.persistence..` / `jakarta.servlet..` / `org.hibernate..` / **`lombok..`**(D3) / `..application..` / `..adapter..` / `..bootstrap..` 등에 의존하면 실패. domain을 framework-neutral POJO로 유지. (`actually-implemented`)
|
||||
- `application_does_not_depend_on_adapters_or_transport` — `..application..`이 `..adapter..` / `..bootstrap..` / `org.springframework.web..` / persistence·hibernate에 의존하면 실패. (`actually-implemented`)
|
||||
- `application_does_not_use_spring_transactional_annotation` — `..application..`이 `org.springframework.transaction.annotation.Transactional` FQN에 의존하면 실패. (코드 주석상 attribution은 `feature-application-port-usecase-contract D3`이나, 본 enforcement slice의 테스트 계약에도 포함되어 `locally-verified`로 red/green 확인됨.)
|
||||
- `application_does_not_depend_on_application_context` (**D11**, banned-class rule) — `..application..`이 `org.springframework.context.ApplicationContext` FQN에 의존하면 실패. class-literal 기반 `getBean(Class<T>)` 호출까지는 bytecode access로 catch. (`actually-implemented`) — **한계(D12)**: string-key `getBean(String)`·`Class.forName(String)`·`BeanFactory#getBeansOfType` 같은 reflection-style bypass는 ArchUnit 정적 분석으로 catch 불가. `application-core/CLAUDE.md` forbidden 섹션의 code review checklist로만 보완. ArchUnit이 모든 우회를 잡는다고 말하면 과장.
|
||||
- `web_adapter_does_not_depend_on_persistence_or_outbound_adapters` / `persistence_adapter_does_not_depend_on_web_or_outbound_adapters` / `outbound_adapter_does_not_depend_on_web_or_persistence_adapters` — adapter module 간 직접 의존 금지. (`actually-implemented`)
|
||||
- `web_dtos_stay_in_web_adapter` — `..adapter.web..dto..`는 `..adapter.web..`에서만 접근 가능(DTO containment). (`actually-implemented`)
|
||||
- `shared_contract_contains_only_operational_contract_packages` — `..shared..`는 response/request/error/operation/headers/logging/tracing/metrics/registry/annotation operational-contract package allowlist만 허용; business/domain concept 유입 시 실패. (`locally-verified` — 임시 `shared.worklog` 위반으로 red 확인)
|
||||
- `production_code_does_not_depend_on_sample_portfolio` — `..sample.portfolio..` 밖 production code가 sample package에 의존하면 실패. (`locally-verified`)
|
||||
|
||||
Gradle build-graph 검사는 `verifyCleanArchitectureDependencies` task(`src/build.gradle:53`, root)가 담당한다. `allowedProjectDependencies` matrix로 9개 module의 허용된 `project()` dependency(`api`/`implementation`/`compileOnly`/`runtimeOnly`)를 화이트리스트하고, 허용 외 `ProjectDependency`가 선언되면 `GradleException`을 던진다. ArchUnit이 *source import graph*를, 이 task가 *Gradle project dependency graph*를 막는 이중 방어다. (`actually-implemented` — task 존재 + matrix; `locally-verified` — 임시 `app-bootstrap → sample-portfolio` 선언으로 red 확인)
|
||||
|
||||
`allowEmptyShould(true)`: 대부분 rule이 빈 anchor module(아직 구현 type이 없는 module)에서 vacuous하게 통과하지 않도록 명시. 빈 should가 곧 PASS로 둔갑하는 ArchUnit empty-should anchor 문제를 다루기 위함.
|
||||
|
||||
### Negative fixture (violations-as-data)
|
||||
|
||||
`ArchitectureViolationFixtureTest`(같은 `architecture/` 패키지)가 본 slice의 각 rule이 *실제로* 위반을 catch하는지 commit된 negative test로 보증한다(Spring Modulith `example/ninvalid` 패턴 차용). 본 slice가 추가한 fixture·test(round 2, 2026-05-28): `SpringDependentDomainFixture`(domain_is_pure D3), `ApplicationContextDependentFixture`(D11), `TransactionalAnnotatedFixture`(@Transactional)를 포함한 의도된 위반 class와, 대응 `*_catches_violation` test가 `rule.evaluate(VIOLATION_CLASSES).hasViolation() == true`를 assert한다. fixture는 `src/test/...`에 위치하므로 main `@AnalyzeClasses(importOptions = DoNotIncludeTests.class)` 분석에서 제외 → main suite의 vacuous pass 위험 없음. (`actually-implemented`)
|
||||
|
||||
> 이후 branch slice들이 같은 fixture tree에 boundary-validation·streaming·serialization·resource-identifier·api-contract rule용 fixture를 추가해, 현재 ground-truth `ArchitectureViolationFixtureTest`는 본 slice 범위를 넘는 negative test를 다수 포함한다. 본 doc은 enforcement-rules slice가 만든 fixture만 위에 명시했다.
|
||||
|
||||
## 로컬/dev 검증 (`locally-verified`)
|
||||
|
||||
2026-05-27 ca-tmpl repo에서 다음 명령이 통과했다.
|
||||
|
||||
```bash
|
||||
cd src
|
||||
./gradlew verifyCleanArchitectureDependencies
|
||||
./gradlew :app-bootstrap:test --tests '*CleanArchitectureTest'
|
||||
./gradlew :adapter-web:test --tests '*SettingsTest'
|
||||
./gradlew test
|
||||
```
|
||||
|
||||
검증 의미:
|
||||
|
||||
- Gradle project dependency graph가 branch-note의 module dependency direction을 위반하지 않는다.
|
||||
- ArchUnit이 source-level forbidden dependency를 검사한다.
|
||||
- web settings binding tests가 package rename 이후에도 통과한다.
|
||||
- 전체 Gradle test suite가 새 module layout에서 통과한다.
|
||||
|
||||
enforcement-rules slice 추가 red/green 검증(2026-05-28, `feature-architecture-enforcement-rules`):
|
||||
|
||||
```bash
|
||||
cd src
|
||||
./gradlew :app-bootstrap:test verifyCleanArchitectureDependencies # ArchUnit + Gradle graph
|
||||
./gradlew check # 전체 — round 2 fixture 포함
|
||||
```
|
||||
|
||||
- 임시 위반 코드(`application @Transactional`, controller domain return, mapper → application 의존, `shared.worklog` package)를 추가했을 때 `CleanArchitectureTest`가 실패함을 확인한 뒤 임시 파일을 제거했다.
|
||||
- 임시 `app-bootstrap → sample-portfolio` project dependency 선언 시 `verifyCleanArchitectureDependencies`가 실패함을 확인한 뒤 제거했다.
|
||||
- round 2: `domain_is_pure`의 `lombok..` 추가(D3)와 `application_does_not_depend_on_application_context`(D11)가 commit된 negative fixture(`ArchitectureViolationFixtureTest`)로 catch 동작을 보증함을 확인했다.
|
||||
|
||||
## 운영 검증 (`prod-verified`)
|
||||
|
||||
없음. ca-tmpl은 template repository이며, 이번 package blueprint slice는 운영 배포/운영 로그/운영 metric으로 검증된 항목이 아니다.
|
||||
|
||||
## 문서/계획만 존재 (`documented-only` / `planned`)
|
||||
|
||||
> 이 등급은 **본 blueprint slice 시점(2026-05-27)** 기준이다. 일부 항목은 이후 별도 branch slice가 구현했을 수 있으며, 그 검증은 해당 branch의 ingest에서 갱신한다(본 slice는 module/package *경계*만 검증).
|
||||
|
||||
- `sample-portfolio` 실제 worklog domain fixture business flow는 본 slice 시점엔 anchor 중심이었다. (현재 `dev.caskeleton.sample.portfolio.{domain,application}.worklog`에 worklog 모델/테스트 존재 — 별도 sample fixture branch 산출물.)
|
||||
- `adapter-outbound` 실제 HTTP client/messaging/cache/notification adapter는 본 slice 시점에 미구현(anchor만).
|
||||
- `shared-contract` 실제 response/error/header/logging/tracing/metrics/registry/annotation type은 본 slice 시점에 미구현(anchor만). 이후 `feature-operational-error-observability-foundation`·`feature-api-contract-baseline` slice가 일부 채움.
|
||||
- `application/port/in` 및 `application/port/out` package anchor는 존재하지만, reference blog repository port는 본 slice 시점엔 `sample-portfolio/domain/repository`에 남아 있었다. production use case port 정리는 `feature-application-port-usecase-contract` branch에서 수행한다.
|
||||
- Spring Modulith verifier는 도입하지 않았다. 현재 검증은 Gradle dependency rule + ArchUnit rule이다.
|
||||
|
||||
## 면접에서 말할 수 있는 범위
|
||||
|
||||
- **자신 있게 답할 수 있는 질문**
|
||||
- 왜 Gradle multi-module을 1차 boundary로 두고 `domain-core` / `application-core` / `adapter-*`를 물리 분리했는지.
|
||||
- `shared-contract`를 business common dumping ground로 쓰지 않기 위해 어떤 package와 ArchUnit rule을 두었는지.
|
||||
- `sample-portfolio`이 presentation layer가 아니라 fixture/sample consumer module인 이유.
|
||||
- `verifyCleanArchitectureDependencies`와 ArchUnit test가 각각 build graph와 source import graph에서 무엇을 막는지.
|
||||
|
||||
- **적당히 답할 수 있는 질문**
|
||||
- 왜 Spring Modulith를 즉시 도입하지 않았는지.
|
||||
- reference blog port가 아직 `domain/repository`에 남아 있는 이유와 `feature-application-port-usecase-contract` branch에서 `application/port/out`으로 이동할 계획.
|
||||
|
||||
- **답하면 안 되는 질문**
|
||||
- “운영에서 검증했다”는 표현. 운영 배포/운영 metric 근거가 없다.
|
||||
- “sample-portfolio worklog business flow까지 이 blueprint slice에서 구현했다”는 표현. 본 slice는 module/package 경계만 검증했고, fixture 구현은 별도 slice다.
|
||||
- “ArchUnit이 모든 boundary 우회를 잡는다”는 표현. runtime lookup/reflection 우회는 별도 리뷰와 CI 보완이 필요하다.
|
||||
|
||||
## 과장 금지 지점
|
||||
|
||||
- “ca-tmpl 전체 Phase C2가 완료됐다” → 금지. package/module blueprint slice만 local verification 완료.
|
||||
- “모든 operational contract가 구현됐다” → 금지. registry/generated constants, outbox, security, runtime, privacy 등은 별도 slice다.
|
||||
- “Spring Modulith 수준 named interface 검증을 구현했다” → 금지. 현재는 Gradle + ArchUnit 최소 검증이다.
|
||||
- “prod-verified” → 금지. 운영 환경 검증 없음.
|
||||
- “`application_does_not_depend_on_application_context`(D11) rule이 모든 Spring container 우회를 잡는다” → 금지. class-literal `getBean(Class)`까지만 catch하고, string-key `getBean(String)` / `Class.forName(String)` / `BeanFactory#getBeansOfType` reflection-style bypass는 ArchUnit 정적 분석 범위 밖이다(D12). 이 부분은 code review checklist로만 보완하며 자동 강제 장치가 아니다.
|
||||
- “ArchUnit/Gradle이 enforcement-rules의 모든 항목을 자동 검증한다” → 금지. MapStruct generated mapper exemption(D9)은 `needs-confirmation`, runtime lookup false-pass 확인은 `planned`로 남아 있다.
|
||||
|
||||
## Ground-truth 대조 (2026-06-04, ca-tmpl `@5d89766`)
|
||||
|
||||
실제 레포 대조로 검증한 사실 (`locally-verified`):
|
||||
|
||||
| 검증 항목 | ca-tmpl 증거 |
|
||||
|---|---|
|
||||
| 8 module include | `settings.gradle` 일치 (+ `adapter-identifier`는 별도 branch) |
|
||||
| production root `dev.caskeleton` | `CaSkeletonApplication` @ `dev.caskeleton.bootstrap` |
|
||||
| D9 전 module `implementation` | 9개 `build.gradle` 모두 `api` 0개 |
|
||||
| D10 `scanBasePackages="dev.caskeleton"` | `CaSkeletonApplication.java` |
|
||||
| boundary guardrail | root `build.gradle:53` `verifyCleanArchitectureDependencies` + `app-bootstrap/.../architecture/CleanArchitectureTest.java` |
|
||||
| anchor `package-info.java` | domain-core·shared-contract·adapter-* 존재 |
|
||||
| sample 격리 | `dev.caskeleton.sample.portfolio.*.worklog` |
|
||||
|
||||
**대조에서 정정된 1차 추출 오류**: 기존 문서의 `com.example.blog.*`(→ `dev.caskeleton.*`), `sample-ticket`(→ `sample-portfolio`)은 1차 추출 시점의 stale 값이었고 본 ingest에서 ground-truth로 정정함.
|
||||
|
||||
### Enforcement-rules dimension 대조 (2026-06-04, ca-tmpl `@db61075`)
|
||||
|
||||
`feature-architecture-enforcement-rules` slice가 정의한 항목만 실제 레포와 대조함 (`actually-implemented` / `locally-verified`):
|
||||
|
||||
| 검증 항목 | ca-tmpl 증거 |
|
||||
|---|---|
|
||||
| ArchUnit suite 진입점 | `app-bootstrap/.../architecture/CleanArchitectureTest.java`, `@AnalyzeClasses(packages = "dev.caskeleton", importOptions = DoNotIncludeTests.class)` |
|
||||
| domain purity + Lombok ban (D3) | `domain_is_pure` rule의 forbidden package에 `lombok..` 포함 |
|
||||
| application↔adapter/transport 격리 | `application_does_not_depend_on_adapters_or_transport` |
|
||||
| @Transactional ban | `application_does_not_use_spring_transactional_annotation` (FQN `org.springframework.transaction.annotation.Transactional`) |
|
||||
| ApplicationContext banned-class (D11) | `application_does_not_depend_on_application_context` (FQN `org.springframework.context.ApplicationContext`) |
|
||||
| adapter-adapter 격리 | `web_/persistence_/outbound_adapter_does_not_depend_on_*` 3 rule |
|
||||
| web DTO containment | `web_dtos_stay_in_web_adapter` |
|
||||
| shared-contract scope | `shared_contract_contains_only_operational_contract_packages` (operational allowlist) |
|
||||
| production → sample ban | `production_code_does_not_depend_on_sample_portfolio` |
|
||||
| Gradle build-graph 검사 | `src/build.gradle:53` `verifyCleanArchitectureDependencies` + `allowedProjectDependencies` matrix(9 module) |
|
||||
| negative fixture | `ArchitectureViolationFixtureTest` + `architecture/violations/...`(SpringDependentDomainFixture·ApplicationContextDependentFixture·TransactionalAnnotatedFixture 등) |
|
||||
| D11 한계(string-key bypass) | rule 주석에 명시 — `getBean(Class)`까지만 catch, `getBean(String)`/`Class.forName` 범위 밖 |
|
||||
|
||||
> ⚠️ ground-truth `CleanArchitectureTest`는 본 slice 이후 boundary-validation / streaming / serialization / resource-identifier / api-contract slice의 rule도 다수 포함한다(현재 30+ rule). 위 표는 본 enforcement-rules slice 소유 항목만 골랐고, 나머지는 각 branch ingest에서 대조한다.
|
||||
|
||||
### Blog-topic ingest: clean-architecture-module-blueprint (2026-07-02)
|
||||
|
||||
[[raw/blog-topics/clean-architecture-module-blueprint-2026-05-28]] 는 Clean Architecture skeleton에서 Gradle module boundary를 1차 강제선으로, package 내부 책임 분류를 2차 강제선으로 둔 이유를 블로그로 풀기 위한 raw seed다.
|
||||
|
||||
- **locally-verified 로 말할 수 있는 부분**: module include, production root, `scanBasePackages`, Gradle dependency matrix, package anchor, sample isolation, enforcement-rules slice 검증 범위.
|
||||
- **project-local policy 로 말할 부분**: Spring Modulith를 즉시 도입하지 않고 Gradle + ArchUnit 최소 검증으로 시작한 선택.
|
||||
- **블로그 전 과장 방지**: 운영 검증이나 전체 Phase C2 완료처럼 쓰지 않고, module/package blueprint slice의 로컬 검증으로 제한한다.
|
||||
- [[raw/blog-topics/executable-clean-architecture-onboarding-2026-06-25]]: Clean Architecture 체크리스트를 README가 아니라 test-only dry-run slice와 negative fixture로 만들어 새 도메인 추가 경계를 CI에서 반복 검증하는 글감. local verification이며 보편 표준 증명처럼 쓰지 않는다.
|
||||
- [[raw/blog-topics/clean-architecture-boundary-enforcement-2026-05-28]]: Gradle project-dependency matrix와 ArchUnit bytecode rule을 나눠 Clean Architecture boundary drift를 막는 글감. runtime lookup / MapStruct exemption 같은 planned 항목은 구현 완료로 쓰지 않는다.
|
||||
- [[raw/blog-topics/archunit-violations-as-data-pattern-2026-05-28]]: ArchUnit rule의 vacuous pass를 막기 위해 violations-as-data fixture와 negative test로 rule 자체를 검증하는 글감. static analysis 한계를 보완하는 패턴이지 reflection bypass를 해결하는 것은 아니다.
|
||||
- [[raw/blog-topics/archunit-generic-return-type-purity-query-port-2026-06-05]]: `List<DomainType>` 같은 generic return type leak을 `getAllInvolvedRawTypes()`로 잡는 query port purity 글감. Object/downcast/reflection 우회까지 잡는다고 쓰지 않는다.
|
||||
- [[raw/blog-topics/domain-modeling-guardrails-as-archunit-fitness-functions-2026-06-05]]: DDD marker annotation과 ArchUnit rule로 value object / aggregate / domain event guardrail을 강제하는 글감. marker taxonomy는 ca-tmpl project-local rule로 제한한다.
|
||||
|
||||
## 관련 개념
|
||||
|
||||
- [[wiki/concepts/clean-architecture-package-layout]]
|
||||
- [[wiki/concepts/archunit-scope-classpath-vs-package-filter]] — `@AnalyzeClasses` 분석 scope(classpath import vs package filter)와 `allowEmptyShould` empty-anchor 함정의 일반 지식
|
||||
|
||||
## Sources
|
||||
|
||||
- [[raw/project-notes/ca-skeleton-operational-contract]] (§20 Skeleton Blueprint Contract, §29 Topic 1)
|
||||
- [[raw/branch-notes/feature-skeleton-package-blueprint-contract]]
|
||||
- [[raw/blog-topics/clean-architecture-module-blueprint-2026-05-28]] — module/package blueprint 블로그 글감 raw seed
|
||||
- [[raw/branch-notes/feature-architecture-enforcement-rules]]
|
||||
- [[raw/branch-notes/feature-domain-feature-onboarding-contract]]
|
||||
- [[raw/blog-topics/executable-clean-architecture-onboarding-2026-06-25]] — executable onboarding guardrails 블로그 글감 raw seed
|
||||
- [[raw/blog-topics/clean-architecture-boundary-enforcement-2026-05-28]] — Gradle + ArchUnit boundary enforcement 블로그 글감 raw seed
|
||||
- [[raw/blog-topics/archunit-violations-as-data-pattern-2026-05-28]] — violations-as-data ArchUnit fixture 블로그 글감 raw seed
|
||||
- [[raw/blog-topics/archunit-generic-return-type-purity-query-port-2026-06-05]] — generic return type purity guardrail 블로그 글감 raw seed
|
||||
- [[raw/blog-topics/domain-modeling-guardrails-as-archunit-fitness-functions-2026-06-05]] — domain modeling guardrail 블로그 글감 raw seed
|
||||
|
||||
## Cluster / 묶음
|
||||
|
||||
<!-- GENERATED: derived-blogs:start -->
|
||||
- [[wiki/blog/ca-tmpl-clean-architecture-package-layout-2026-07-02]]
|
||||
<!-- GENERATED: derived-blogs:end -->
|
||||
@@ -0,0 +1,127 @@
|
||||
---
|
||||
title: ca-tmpl - Config & Adapter Templates 결정 (env-driven + ConditionalOnProperty)
|
||||
source_type: project
|
||||
status: verified
|
||||
confidence: high
|
||||
tags: [ca-tmpl, 12-factor, config, conditional-on-property, actually-implemented, locally-verified]
|
||||
related_projects: [ca-tmpl]
|
||||
last_reviewed: 2026-07-02
|
||||
---
|
||||
|
||||
# ca-tmpl - Config & Adapter Templates 결정 (env-driven + ConditionalOnProperty)
|
||||
|
||||
> Layer: `wiki/projects/` — ca-tmpl skeleton 프로젝트의 Config & Adapter 영역 결정 사항. 일반 개념은 [[wiki/concepts/config-and-adapter-templates]] 참조.
|
||||
|
||||
## 프로젝트 컨텍스트
|
||||
|
||||
**ca-tmpl skeleton** — Clean Architecture 기반 Spring Boot 템플릿. 신규 백엔드 서비스를 시작할 때 use case / port / adapter 경계, env-driven config, optional adapter on/off, 운영 contract(observability / failure / supply chain 등)를 미리 fix해 두는 사내용 skeleton.
|
||||
|
||||
본 문서가 다루는 영역(canonical §9 Env-driven Runtime Configuration, §29 Group G-I Adapter Failure & Disabled):
|
||||
|
||||
- **Env config 결정**: `APP_` prefix + Duration `30s` 형식 1택 + boolean `true/false` only + no-runtime-reload + `.env.example` drift 검증.
|
||||
- **Adapter on/off 결정**: optional module + `@ConditionalOnProperty` 3-layer detection (Spring bean + ArchUnit static + `AdapterDisabledException` runtime fail-fast).
|
||||
|
||||
**진행 상황**: C2 부분 구현 + 로컬 검증 완료. `docs/registries/env-keys.yaml`, `verifyEnvKeys`, `@ConfigurationProperties` settings, startup safety validator, optional-adapter 조건부 테스트/ArchUnit guard가 존재한다. 모든 provider-specific adapter template가 구현된 것은 아니다.
|
||||
|
||||
## 실제 구현 내용 (`actually-implemented`)
|
||||
|
||||
- `docs/registries/env-keys.yaml`과 Gradle `verifyEnvKeys` gate가 존재한다.
|
||||
- `app-bootstrap`, `adapter-web`, `sample-portfolio` 등에 `@ConfigurationProperties` 기반 `*Settings` 타입이 존재한다.
|
||||
- `StartupSafetyValidator`, `RuntimeNumericBoundsValidator`, `RequiredEnvironmentValidator`, `RequiredAdapterDisabledException`이 startup fail-fast guard를 구성한다.
|
||||
- `DisabledAdapterArchitectureTest`가 optional adapter bean의 `@ConditionalOnProperty` 부착과 disabled-default boundary를 정적으로 검증한다.
|
||||
- `EnabledIfRedisCacheEnabled`, `EnabledIfHttpRetryEnabled`, `EnabledIfHttpCircuitBreakerEnabled` 등 optional adapter contract test 조건부 실행 annotation이 존재한다.
|
||||
|
||||
## 로컬/dev 검증 (`locally-verified`)
|
||||
|
||||
- `./gradlew check` 통과(2026-07-02, `BUILD SUCCESSFUL`, 114 tasks).
|
||||
- 실행 중 `verifyEnvKeys: OK — 99 env keys, 72 required placeholders covered, 85 APP_ keys registered`가 출력되었다.
|
||||
- `EnvProfileMatrixContractTest`, `StartupSafetyValidatorTest`, `RuntimeNumericBoundsValidatorTest`, `DisabledAdapterArchitectureTest`가 env/profile/optional adapter contract를 검증한다.
|
||||
|
||||
## 운영 검증 (`prod-verified`)
|
||||
|
||||
**없음.** ca-tmpl은 skeleton이며 prod 배포 이력 없음.
|
||||
|
||||
## 문서/계획만 존재 (`documented-only` / `planned`)
|
||||
|
||||
다음 결정은 구현된 gate와 아직 provider-specific adapter template로 남은 부분을 함께 기록한다.
|
||||
|
||||
### Env config (canonical §9)
|
||||
|
||||
- **`APP_` prefix** — application-owned env는 `APP_` 접두사로 통일, 외부 의존 env(`SPRING_*`, `JAVA_OPTS` 등)와 시각적 분리.
|
||||
- **Duration 1택** — Spring `Duration` 입력은 `30s` 형식으로 통일(ISO-8601 `PT30S` 금지). 동일 의미 두 표기가 공존하면 grep/diff 비용이 발생.
|
||||
- **Boolean `true/false` only** — `1/0`, `yes/no`, `on/off` 금지. Spring `Binder`가 허용하더라도 contract 수준에서 1택.
|
||||
- **No-runtime-reload** — `@RefreshScope`, Spring Cloud Config refresh endpoint, Spring Cloud Kubernetes auto-reload 모두 기본 금지. config 변경은 **재배포로만** 반영.
|
||||
- **`.env.example` drift verify** — `@ConfigurationProperties`에 선언된 모든 env가 `.env.example`에도 존재해야 함을 빌드 단계에서 강제. 누락 시 build fail.
|
||||
|
||||
### 5종 대안 검토 (concept 문서 참조)
|
||||
|
||||
[[wiki/concepts/config-and-adapter-templates]]에서 다음 5종을 검토하고 ca-tmpl scope에서는 모두 채택하지 않기로 결정:
|
||||
|
||||
- Spring Cloud Config Server — config server SPOF + bootstrap 의존
|
||||
- k8s ConfigMap + Spring Cloud Kubernetes auto-reload — pod별 partial-state + k8s lock-in
|
||||
- HashiCorp Consul KV — KV+watch 운영 비용
|
||||
- AWS Parameter Store / AppConfig — AWS lock-in + per-call billing
|
||||
- LaunchDarkly / Unleash — product-grade A/B/canary 요구가 발생하기 전에는 over-engineering, ca-tmpl scope 밖
|
||||
|
||||
### Adapter templates (canonical §29 G-I)
|
||||
|
||||
- **Layer 1 — Spring `@ConditionalOnProperty`**: `APP_ADAPTER_<NAME>_ENABLED=true`일 때만 adapter bean 등록. optional module 자체는 dependency로 두지만 disabled 시 bean 등록 X.
|
||||
- **Layer 2 — ArchUnit static detection**: `noClasses().that().resideInAPackage("..application..").should().dependOnClassesThat().resideInAPackage("..adapters.<disabled>..")` 형태의 정적 dependency rule. application code가 disabled adapter package를 import하는 것을 빌드 단계에서 차단.
|
||||
- **Layer 3 — `AdapterDisabledException` runtime fail-fast**: disabled adapter가 어떤 경로로든 호출되면 즉시 `AdapterDisabledException`을 던져 silent failure 방지.
|
||||
- **ArchUnit Layer 2 정적 검사 범위 명확화 (2026-05-22)** — annotation 존재까지만 정적 보장(`@ConditionalOnProperty` 부착 + `app.adapter.<name>.enabled` naming pattern), runtime active 여부 검사는 Layer 3 (`AdapterDisabledException`)에 위임. 자세한 평가는 [[raw/official-docs/archunit-conditional-on-property-3-layer-pattern]] (status: `needs-confirmation`).
|
||||
|
||||
Layer 1/2 일부와 startup fail-fast guard는 구현되어 있다. 다만 Kafka/Slack/Email 같은 모든 provider-specific adapter template와 runtime call path의 disabled sentinel은 범위별로 추가 확인이 필요하다.
|
||||
|
||||
## 면접에서 말할 수 있는 범위
|
||||
|
||||
### 자신 있게 답할 수 있는 질문
|
||||
|
||||
- "12-factor §III. Config가 의미하는 'config와 코드 분리'는 구체적으로 무엇을 강제하는가"
|
||||
- "ca-tmpl이 no-runtime-reload를 기본 방침으로 둔 결정의 근거는?"
|
||||
- "`@ConditionalOnProperty` 3-layer (Spring bean 조건 + ArchUnit static + runtime fail-fast)가 각각 어떤 실패 시나리오를 잡는지"
|
||||
- "Java SPI `ServiceLoader`와 `@ConditionalOnProperty`가 adapter on/off 표현에서 어떻게 다른지"
|
||||
- "LaunchDarkly 같은 feature flag SaaS와 `@ConditionalOnProperty` startup toggle은 어떤 운영 요구가 생겼을 때 갈라지는지(trade-off)"
|
||||
|
||||
### 적당히 답할 수 있는 질문
|
||||
|
||||
- "`@RefreshScope`를 금지로 둔 이유" — 결정 근거는 설명 가능. 운영 데이터/사례는 없음.
|
||||
- "Vault dynamic credential과 `@RefreshScope` 같은 runtime reload 메커니즘이 충돌하는 지점" — 개념적으로는 설명 가능. 직접 운영 경험 없음.
|
||||
|
||||
### 답하면 안 되는 질문 (모른다고 해야 함)
|
||||
|
||||
- "`@ConfigurationProperties` 검증을 운영 환경에서 어떻게 운용하는가" — 운영 경험 없음.
|
||||
- "adapter on/off를 실제 환경에서 전환한 경험" — 없음. ca-tmpl은 skeleton 단계.
|
||||
- "Layer 2 ArchUnit rule이 실제 빌드에서 어떤 위반을 잡았는가" — `DisabledAdapterArchitectureTest`와 `./gradlew check` 통과 범위까지 답할 수 있음. 모든 provider adapter runtime path 검증은 별도 확인 필요.
|
||||
|
||||
## 과장 금지 지점
|
||||
|
||||
- **"`@RefreshScope`만 도입하면 dynamic config가 된다"** — ❌. ca-tmpl은 `@RefreshScope`를 기본 금지로 두는 결정을 했고, 본인은 dynamic config를 운영한 경험이 없음. "도입 가능" 정도로만 표현해야 함.
|
||||
- **"`@ConditionalOnProperty` 3-layer가 disabled adapter 호출을 완전 검증한다"** — ❌. Layer 1/2와 startup fail-fast 일부는 검증됐지만, 모든 provider adapter runtime path까지 자동 보장한다고 쓰지 않는다.
|
||||
- **"ca-tmpl이 LaunchDarkly를 거부했다"** — ❌. "ca-tmpl scope 밖으로 위임했다" / "product-grade A/B/canary 요구가 발생하면 별도 branch로 다룬다"는 표현이 정확.
|
||||
- **"Config & Adapter 전체가 구현 완료"** — ❌. env registry/gate와 optional-adapter guard는 구현됐지만 provider별 adapter template 완성도는 범위별 확인이 필요하다.
|
||||
|
||||
### Blog-topic ingest: env/config/adapter 묶음 (2026-07-02)
|
||||
|
||||
- [[raw/blog-topics/env-example-drift-gate-gradle-2026-06-06]]: `.env.example` 중복 사본 대신 실제 `application.yml` placeholder와 tracked `.env` key surface를 대조하는 drift gate 글감. `verifyEnvKeys` 구현과 `./gradlew check` 통과를 근거로 blogify 가능하다.
|
||||
- [[raw/blog-topics/spring-conditional-on-property-optional-adapter-template-2026-06-09]]: heavy SDK를 기본 dependency로 싣지 않고 optional adapter seam, disabled default, `@ConditionalOnProperty`, ArchUnit, disabled sentinel로 계약을 만드는 글감. 구현 범위는 optional-adapter guard와 startup fail-fast 일부로 제한한다.
|
||||
- [[raw/blog-topics/spring-boot-3-configprops-record-multi-constructor-binding-2026-06-12]]: Spring Boot 3 record `@ConfigurationProperties`에 보조 생성자를 추가했을 때 constructor binding auto-detect가 깨질 수 있는 troubleshooting 글감. 공식 문서 근거 보강 전까지 일반화하지 않는다.
|
||||
|
||||
## 관련 개념
|
||||
|
||||
- [[wiki/concepts/config-and-adapter-templates]]
|
||||
|
||||
## Sources
|
||||
|
||||
- [[raw/project-notes/ca-skeleton-operational-contract]] (§9 Env-driven Runtime Configuration, §29 Group G-I Adapter Failure & Disabled)
|
||||
- [[raw/branch-notes/feature-env-driven-runtime-configuration]]
|
||||
- [[raw/branch-notes/feature-integration-adapter-templates]]
|
||||
- [[raw/blog-topics/env-example-drift-gate-gradle-2026-06-06]] — env key drift gate 블로그 글감 raw seed.
|
||||
- [[raw/blog-topics/spring-conditional-on-property-optional-adapter-template-2026-06-09]] — optional adapter template 블로그 글감 raw seed.
|
||||
- [[raw/blog-topics/spring-boot-3-configprops-record-multi-constructor-binding-2026-06-12]] — Spring Boot 3 record configuration binding 블로그 글감 raw seed.
|
||||
- [[raw/official-docs/archunit-conditional-on-property-3-layer-pattern]] — ArchUnit Layer 2 정적 검사 가능 범위 평가 (needs-confirmation)
|
||||
|
||||
## Cluster / 묶음
|
||||
|
||||
<!-- GENERATED: derived-blogs:start -->
|
||||
- [[wiki/blog/ca-tmpl-config-and-adapter-templates-2026-07-02]]
|
||||
<!-- GENERATED: derived-blogs:end -->
|
||||
@@ -0,0 +1,255 @@
|
||||
---
|
||||
title: ca-tmpl - Data Layer Baseline 결정 (Persistence + Cache + Outbound)
|
||||
source_type: project
|
||||
status: verified
|
||||
confidence: high
|
||||
tags: [ca-tmpl, persistence, jpa, cache, http-client, actually-implemented, locally-verified]
|
||||
related_projects: [ca-tmpl]
|
||||
last_reviewed: 2026-07-02
|
||||
---
|
||||
|
||||
> **UPDATE 2026-06-15 (스코프: Outbound HTTP 한정):** 본 문서가 2026-05-22 에 기록한 *"Phase C2 미진입 / 코드 없음"* 전제는 **Outbound HTTP 영역에 한해 더 이상 사실이 아니다.** `src/adapter-outbound/.../httpclient/` 에 outbound HTTP 클라이언트가 구현 + 로컬 테스트로 검증되어 있다(아래 "Outbound HTTP Client" 절). 이 절은 [[wiki/explainer/adapter-outbound]] 가 코드 사실의 근거로 인용한다.
|
||||
>
|
||||
> **UPDATE 2026-07-02:** `/home/donghyeon/workspace/ca-tmpl/src` 대조 및 `./gradlew check` 통과로 이 문서를 `verified`로 승격했다. Outbound HTTP, lower-layer cache SPI/router/fail-open, idempotency/outbox persistence, OSIV/Hikari startup guard는 구현·로컬 검증됐다. 단 본 문서의 원래 "Cache 결정"(cache-aside + Caffeine + Redisson 분산 lock + after-commit invalidation)은 일부가 lower-layer cache SPI와 다른 층이므로 구현 범위를 분리해서 읽어야 한다.
|
||||
|
||||
# ca-tmpl - Data Layer Baseline 결정 (Persistence + Cache + Outbound)
|
||||
|
||||
> Layer: `wiki/projects/` — ca-tmpl skeleton의 data layer baseline 결정 사실 기록. 일반 개념·근거는 [[wiki/concepts/data-layer-persistence-cache-outbound]]에 둠. 본 문서는 "내 프로젝트에서 무엇을 결정했고, 어디까지 진행되었는가"만 다룬다.
|
||||
|
||||
## 프로젝트 컨텍스트
|
||||
|
||||
ca-tmpl은 Clean Architecture 기반 백엔드 skeleton 템플릿이다. 본 문서는 그 안에서 **data layer baseline 3축**(Persistence / Cache / Outbound HTTP)을 어떻게 결정했는지를 기록한다.
|
||||
|
||||
- 결정한 baseline:
|
||||
- **Persistence**: SQLState 9-row classifier matrix + Hibernate **OSIV off** + HikariCP pool wait/exhaustion alert + read replica lag threshold ([[raw/project-notes/ca-skeleton-operational-contract]] §6).
|
||||
- **Cache**: cache-aside default + Caffeine local lock(single-instance) + Redisson `RLock` distributed mutex(multi-instance HPA) + after-commit invalidation + eventual consistency window **5s** (canonical §11).
|
||||
- **Outbound HTTP**: Spring **RestClient** baseline + Resilience4j CircuitBreaker · TimeLimiter · Retry + timeout **connect 2s / read 5s / global 10s** + retry **default disabled** (canonical §11, §29 G-C).
|
||||
- 진행 단계: **C2 부분 구현 + 로컬 검증 완료.** 본 문서의 범위는 구현된 outbound/cache/persistence slice와 아직 planned로 남은 cache-aside/replica-lag/운영 tuning 경계를 분리한다.
|
||||
|
||||
## 실제 구현 내용 (`actually-implemented`)
|
||||
|
||||
**Persistence / Cache / Outbound 일부 구현됨.** SQLState classifier와 read-replica lag metric은 별도 확인이 필요하지만, idempotency/outbox persistence adapter와 Flyway migration, OSIV/Hikari startup guard, lower-layer cache SPI/router/fail-open/Redis adapter, outbound HTTP baseline은 코드에 존재한다.
|
||||
|
||||
### Outbound HTTP Client (`actually-implemented`)
|
||||
|
||||
모듈 `src/adapter-outbound/.../httpclient/`, 단일 업스트림 의존성 1개당 인스턴스 1개. 진입 클래스 `OutboundHttpClient`.
|
||||
|
||||
**입출력 (공개 API)**
|
||||
|
||||
| 메서드 | 입력 | 반환 | 비고 |
|
||||
|---|---|---|---|
|
||||
| `static OutboundHttpClient baseline(name, baseUrl, settings, guard, resilience, retryPolicy, errorMapper, logger)` | 협력자 8개 | `OutboundHttpClient` | 정적 팩토리 — **빈으로 등록하지 않음**. fork 프로젝트가 의존성마다 named 인스턴스 생성. static 인 이유: ArchUnit B7(어댑터 타입 반환 public *비*static 메서드 금지) seam |
|
||||
| `<T> T get(String uri, Class<T> type)` | URI, 응답 타입 | `T` | `exchange(GET, uri, null, type)` 위임 |
|
||||
| `<T> T exchange(HttpMethod m, String uri, Object body, Class<T> type)` | 메서드/URI/요청바디/응답타입 | `T` | 전체 파이프라인(아래) |
|
||||
| `<T> T stream(HttpMethod m, String uri, Function<InputStream,T> reader)` | 메서드/URI/스트림 리더 | `T` | **리트라이 없음 · size 인터셉터 없음**. 대용량 응답 전용 |
|
||||
|
||||
**호출 파이프라인 (`exchange` 정상 경로)**
|
||||
1. **셧다운 fast-fail** — `guard.isShuttingDown()` 이면 네트워크를 맺지 않고 즉시 `DependencyFailureException(DEPENDENCY_CIRCUIT_OPEN, name, "shutdown in progress — outbound call rejected fail-fast (D8)")` throw, 로그 outcome=`REJECTED`.
|
||||
2. `deadline = Instant.now().plus(globalCallTimeout)` 산정 → `retryPolicy.beginCall(method, deadline)` (ThreadLocal 에 적재).
|
||||
3. 데코레이션 합성: `CircuitBreaker.decorateSupplier(cb, Retry.decorateSupplier(retry, countingSupplier))` → **합성 순서 = CB(바깥) → Retry(안) → 실제 호출**. 리트라이가 CB 안쪽이라 각 재시도가 독립적으로 CB 윈도우에 카운트됨.
|
||||
4. 예외 분기: `OutboundResponseSizeExceededException` 는 **분류하지 않고 그대로 재throw**(업스트림 장애가 아니라 "버퍼 API 오용" 계약 위반); 그 외 모든 `Throwable` → `errorMapper.classify(name, t)` 로 매핑 → 로그 → throw. **호출자는 항상 `DependencyFailureException`(또는 size 예외)만 본다.**
|
||||
5. `finally` 에서 `retryPolicy.endCall()` 항상 실행(ThreadLocal 누수 방지).
|
||||
|
||||
**예외 / 오류코드 매핑 (`OutboundHttpErrorMapper.classify`, cause chain 순회 → 첫 매치 채택)**
|
||||
|
||||
진단 메시지는 **server-log-only** — status code + 예외 클래스명만 담고 업스트림 raw 응답 body 는 절대 미포함(D12 PII 안전, `DependencyFailureException` javadoc 계약). 오류코드는 `OperationalError`(SSOT `docs/registries/error-codes.yaml`):
|
||||
|
||||
| 매치 (cause chain) | 코드 | HTTP / retryable |
|
||||
|---|---|---|
|
||||
| `CallNotPermittedException`(R4j) | `DEPENDENCY_CIRCUIT_OPEN` | 503 / true |
|
||||
| `UnknownHostException`·`UnresolvedAddressException` | `DEPENDENCY_DNS_FAILED` | 503 / true |
|
||||
| `HttpConnectTimeoutException` | `DEPENDENCY_CONNECT_FAILED` | 503 / true |
|
||||
| `ConnectException`(DNS cause 포함) | `DEPENDENCY_DNS_FAILED` | 503 / true |
|
||||
| `ConnectException`(그 외) | `DEPENDENCY_CONNECT_FAILED` | 503 / true |
|
||||
| `HttpTimeout`·`SocketTimeout`·`TimeoutException` | `DEPENDENCY_TIMEOUT` | 504 / true |
|
||||
| `RestClientResponseException` 4xx | `DEPENDENCY_4XX_CLIENT` | 502 / **false** (401→"check credential", 403→"check scope" 힌트) |
|
||||
| `RestClientResponseException` 5xx | `DEPENDENCY_5XX_SERVER` | 502 / true |
|
||||
| 매치 없음(fallback) | `DEPENDENCY_CONNECT_FAILED` | 503 / true |
|
||||
|
||||
> 순서 주의: `HttpConnectTimeoutException extends HttpTimeoutException` 이라 connect 를 read timeout 보다 먼저 검사. **Open Risk(D12):** 408/429 는 의미상 재시도 가능하지만 현재 모든 4xx 가 non-retryable.
|
||||
|
||||
**자료구조 + 선택 이유**
|
||||
|
||||
| 구조 | 위치 | 이유 |
|
||||
|---|---|---|
|
||||
| `AtomicBoolean running/shuttingDown` | `OutboundHttpShutdownGuard` | 셧다운 스레드 write ↔ 요청 스레드 read 간 가시성 |
|
||||
| `ThreadLocal<CallContext>` + `record CallContext(HttpMethod, Instant deadline)` | `OutboundRetryPolicy` | 동기 클라이언트라 호출이 한 스레드를 타고 가므로 deadline·method 를 스레드별 격리 |
|
||||
| `Set.of(GET,HEAD,PUT,DELETE)` | `OutboundRetryPolicy.IDEMPOTENT_METHODS` | 불변 + O(1) 멱등 판정. POST/PATCH 의도적 제외 |
|
||||
| `int[] attemptCount = {0}` | `OutboundHttpClient.exchange` | 람다가 캡처 지역변수를 못 바꾸므로 1칸 배열을 가변 closure cell 로 사용 |
|
||||
| `record OutboundHttpSettings` + 중첩 `record Retry/CircuitBreaker`(박싱 `Integer/Double/Float`) | `OutboundHttpSettings` | 불변 값 + `null` = "기본값 적용" 신호 |
|
||||
| `Optional<Retry>`/`Optional<CircuitBreaker>` | `OutboundHttpResilience` | "데코레이션 없음"(기능 off)을 호출자가 강제로 다루게 |
|
||||
|
||||
**Spring / Resilience4j / Micrometer 메커니즘**
|
||||
- `@ConfigurationProperties(prefix="app.outbound.http")` + `@ConstructorBinding` → env/yaml → record 바인딩, compact 생성자 검증 실패 시 **startup 실패**.
|
||||
- `SmartLifecycle`(`OutboundHttpShutdownGuard`): `getPhase()=Integer.MAX_VALUE` → 컨텍스트 종료 시 phase **내림차순** stop → 이 빈의 `stop()` 이 가장 먼저 호출(다른 아웃바운드 빈보다 먼저 플래그 set). `ContextClosedEvent` 는 너무 늦고 순서 미보장이라 부적합.
|
||||
- `BeanPostProcessor`(`OutboundHttpTimeoutEnforcer`, **static @Bean**): raw `RestClient`/`RestClient.Builder` 빈 발견 시 `BeanCreationException` → timeout 미설정 클라이언트 등록을 startup 차단. static 이라 다른 빈보다 일찍 생성돼 가로챔.
|
||||
- Resilience4j: `decorateSupplier` 합성, `RetryRegistry`/`CircuitBreakerRegistry` 가 dependency 이름별 인스턴스 캐시(= per-dependency 지표), `IntervalFunction.ofExponentialRandomBackoff`(지수 + jitter).
|
||||
- Micrometer `MeterFilter`(`OutboundHttpResilienceConfig`): 저카디널리티 정규화 — `kind`→`outcome` 태그 리네임, state 값 대문자화, 그 외 `resilience4j.*` 미터 전부 `DENY`. **활성화 가드(D3):** retry/CB 중 하나라도 켜졌는데 `MeterRegistry` 없으면 `IllegalStateException`.
|
||||
- `RestClient` 2개: connect timeout = `HttpClient.connectTimeout`, read timeout = `JdkClientHttpRequestFactory.setReadTimeout`. buffered(trace→size 인터셉터) / streaming(trace 만).
|
||||
|
||||
**설정 (`app.outbound.http.*`)**
|
||||
|
||||
| 키 | 기본값 | 효과 |
|
||||
|---|---|---|
|
||||
| `connect-timeout` / `read-timeout` / `global-call-timeout` | 없음(필수) | TCP 연결 / 소켓 읽기 / 리트라이 포함 전체 deadline 예산. 누락 시 startup 실패 |
|
||||
| `retry-enabled` / `circuit-breaker-enabled` | `false` / `false` | R4j retry / CB 활성. 하나라도 켜면 `MeterRegistry` 필수 |
|
||||
| `response-size-limit` | `10MB` | buffered 본문 in-memory 상한(초과 시 `OutboundResponseSizeExceededException`) |
|
||||
| `retry.max-attempts` / `initial-backoff` / `backoff-multiplier` | `3` / `100ms` / `2.0` | 시도 횟수 / 첫 백오프 / 지수 배수 |
|
||||
| `circuit-breaker.failure-rate-threshold` | `50`(%) | open 임계 |
|
||||
| `…sliding-window-size` / `…minimum-number-of-calls` | `100` / `100` | COUNT_BASED 윈도우 / rate 계산 최소 호출 |
|
||||
| `…wait-duration-in-open-state` / `…permitted-calls-in-half-open` | `60s` / `10` | open→half-open 대기 / half-open 시험 호출 수 |
|
||||
|
||||
**클래스 연관 (빈 배선)**
|
||||
- `OutboundHttpClientConfig` 가 공유 빈(`ShutdownGuard`/`TimeoutEnforcer`/`ErrorMapper`/`OutboundHttpDependencyLogger`/`RetryPolicy`)을 `@Bean @ConditionalOnMissingBean` 등록하되 **`OutboundHttpClient` 빈은 일부러 안 만든다**(의존성마다 named 인스턴스).
|
||||
- `OutboundHttpResilienceConfig` 가 `OutboundHttpResilience` 빈 생산 + MeterFilter 설치.
|
||||
- ⚠️ `retryPolicy` 인스턴스는 `resilience`(`shouldRetry` predicate)와 `OutboundHttpClient`(`beginCall`)가 **같은 것을 공유**해야 한다 — 다르면 `shouldRetry` 가 ctx=null 로 영영 재시도하지 않음.
|
||||
- 인터셉터: `TraceContextPropagationInterceptor`(MDC→`traceparent`/`baggage` 헤더, 샘플 플래그 `00` 하드코딩, allowlist=`tenant_id`·`request_id`), `ResponseSizeBoundingInterceptor`(Content-Length 또는 `BoundedInputStream` 누적이 limit 초과 시 throw, buffered 전용).
|
||||
|
||||
## 로컬/dev 검증 (`locally-verified`)
|
||||
|
||||
**Persistence / (data-layer) Cache: 없음** (재확인 안 함).
|
||||
|
||||
**Outbound HTTP Client: `locally-verified`** — `src/adapter-outbound/src/test/.../httpclient/` 의 단위 테스트로 다음이 검증됨(prod 배포·측정은 없음):
|
||||
- `OutboundHttpClientTest` — retry-on 500 GET 정확히 3회 / POST 정확히 1회(I4 비멱등 차단) · CB OPEN 시 0회 short-circuit + `DEPENDENCY_CIRCUIT_OPEN` · 셧다운 시 0회 + `REJECTED` · 업스트림 secret body 미유출 · buffered size 초과 시 `OutboundResponseSizeExceededException`(`stream()` 은 성공) · `outcome` 태그 존재/`kind` 태그 부재.
|
||||
- `OutboundHttpErrorMapperTest` — 위 예외 매핑 테이블 전 행 + 408/429 Open Risk + body 미유출.
|
||||
- `OutboundHttpResilienceTest` / `OutboundHttpResilienceConfigTest` — decorate 순서 · 기본값(3/100ms/2.0, 50%/100/100/60s/10) · MeterRegistry 가드.
|
||||
- `OutboundRetryPolicyTest` — 4-조건 게이트(셧다운/멱등/retryable/deadline).
|
||||
- `OutboundHttpShutdownGuardTest` — phase=`Integer.MAX_VALUE`, start/stop 플래그.
|
||||
- `OutboundHttpSettingsTest` — config 바인딩 + 잘못된 값 startup `IllegalArgumentException`.
|
||||
- `TraceContextPropagationInterceptorTest` / `OutboundHttpDependencyLoggerTest` — 헤더 주입 · 로그 레벨/필드.
|
||||
|
||||
## 운영 검증 (`prod-verified`)
|
||||
|
||||
없음. 운영(prod) 환경에 배포된 적이 없다. 따라서 릴리즈 노트 / 운영 로그 / 모니터링 대시보드 / 인시던트 보고서 어느 것도 존재하지 않는다.
|
||||
|
||||
- 운영(prod) 환경에 배포된 적이 없다. 따라서 릴리즈 노트 / 운영 로그 / 모니터링 대시보드 / 인시던트 보고서 어느 것도 존재하지 않는다.
|
||||
|
||||
## 문서/계획만 존재 (`documented-only` / `planned`)
|
||||
|
||||
다음은 모두 **문서/설계 단계**의 결정이며, 코드로 강제되어 있지 않다. 면접에서 "구현했다"고 말하면 안 되는 부분이다.
|
||||
|
||||
### Persistence (`partially-implemented`)
|
||||
|
||||
- SQLState 9-row classifier matrix(`08*` connection, `40001` serialization, `40P01` deadlock, `23xxx` integrity, `57014` query canceled 등) → Spring `DataAccessException` hierarchy 위에 `TRANSIENT_DEPENDENCY / CONFLICT / DATA_INTEGRITY` 카테고리 매핑 (`Category.java` 10-enum 정합 — 이전 `PERSISTENCE` 표기는 stale, 부모 §6 2026-06-01 정합 + `error-codes.yaml` authoritative).
|
||||
- Hibernate **OSIV off**를 baseline으로 결정 (Vlad Mihalcea anti-pattern 평가 + Spring Boot startup WARN 근거).
|
||||
- HikariCP pool wait p99 / pool exhaustion을 1차 alert 지표로 지정.
|
||||
- Read replica lag threshold를 SLO에 포함.
|
||||
- 근거: canonical [[raw/project-notes/ca-skeleton-operational-contract]] §6 + [[raw/branch-notes/feature-persistence-failure-baseline]].
|
||||
- 구현됨: idempotency/outbox RDBMS adapter, PostgreSQL migration, OSIV-off startup guard, Hikari inter-knob startup guard.
|
||||
- 남음: SQLState 9-row classifier 전체, read replica lag metric/alert, 운영 pool tuning 측정.
|
||||
|
||||
### Cache (`partially-implemented`)
|
||||
|
||||
- cache-aside default + Caffeine local lock(`@Cacheable(sync = true)` / `AsyncLoadingCache`) + Redisson `RLock` distributed mutex(multi-instance HPA 가정).
|
||||
- after-commit invalidation 강제 (Spring `TransactionSynchronizationManager.registerSynchronization`의 `afterCommit()` hook).
|
||||
- Eventual consistency window 5초로 명시.
|
||||
- Strict consistency use case(잔액, 인증, idempotency 검증)는 cache bypass.
|
||||
- Negative cache(존재하지 않는 row, TTL 60s)는 invalidation 채널 적용 대상에서 제외.
|
||||
- 근거: canonical §11 + [[raw/branch-notes/feature-cache-consistency-contract]].
|
||||
- 구현됨: `CacheStore`, `FailOpenCacheStore`, `CacheStoreRouter`, `RedisCacheStore`, `CacheBindingSettings`, 관련 단위 테스트.
|
||||
- 남음: 원래 문서의 cache-aside+Caffeine local lock+Redisson distributed mutex+after-commit invalidation 전체 contract와 운영 consistency window 측정.
|
||||
|
||||
### Outbound HTTP (결정 — **현재 구현됨**, 위 "Outbound HTTP Client" 절 참조)
|
||||
|
||||
다음은 baseline 결정 *사실*이며, 결정 자체는 그대로 유효하다. **2026-06-15 기준 코드로 구현되어 있다**(시간/리트라이 *기본값*은 결정 당시 수치와 일부 다르게 구현됨 — 아래 표시):
|
||||
|
||||
- Spring RestClient(6.1+)를 baseline으로 결정. RestTemplate은 maintenance-only로 신규 채택 제외, WebClient는 MVC servlet baseline의 blocking risk로 extension 분리, OpenFeign은 Spring Cloud 의존으로 baseline에서 제외. → **구현: `RestClient` 2종(buffered/streaming).**
|
||||
- Resilience4j로 retry / circuit breaker 일원화. Hystrix는 maintenance mode로 배제. → **구현: `OutboundHttpResilience` + `OutboundHttpResilienceConfig`.** (TimeLimiter 대신 동기 클라이언트라 deadline 예산 + `OutboundRetryPolicy` 게이트로 대체.)
|
||||
- Timeout 계층: connect / read / global **3축 모두 필수 강제**(하나라도 누락 시 startup 실패). → **구현됨. 단 결정 당시 예시값 `2s/5s/10s` 는 *기본값이 아니라 필수 입력*으로 구현**(`@ConfigurationProperties`, 기본값 없음).
|
||||
- Retry **default disabled**(`retry-enabled=false`) → **구현됨.** idempotency-key 미보장 일반 API 보수적 결정. 켜도 비멱등(POST/PATCH)은 `OutboundRetryPolicy` 가 차단.
|
||||
- 근거: canonical §11, §29 Group G-C + [[raw/branch-notes/feature-outbound-http-client-baseline]] + 코드 `src/adapter-outbound/.../httpclient/`.
|
||||
|
||||
### 대안 검토 범위 (요약 — 상세는 concept 참조)
|
||||
|
||||
각 sub-topic마다 5종 이상 대안을 비교했고 baseline을 선정했다. 비교의 출처/세부는 [[wiki/concepts/data-layer-persistence-cache-outbound]]에 있다.
|
||||
|
||||
- Persistence: SQLState classifier vs vendor-specific code, JPA blocking vs R2DBC reactive, OSIV on vs off, Hikari sizing 공식.
|
||||
- Cache: cache-aside vs write-through vs write-behind vs read-through, Caffeine vs Hazelcast(local), Redisson RLock vs SETNX vs Redlock.
|
||||
- Outbound HTTP: RestClient vs RestTemplate vs WebClient vs OpenFeign vs `@HttpExchange`, Resilience4j vs Hystrix vs Spring Retry.
|
||||
|
||||
## 면접에서 말할 수 있는 범위
|
||||
|
||||
### 자신 있게 답할 수 있는 질문
|
||||
|
||||
- ca-tmpl의 SQLState 9-row classifier matrix를 왜 만들었고, Spring `DataAccessException` hierarchy 위에서 어떤 카테고리(`TRANSIENT_DEPENDENCY / CONFLICT / DATA_INTEGRITY`, `Category.java` 10-enum)로 매핑하기로 했는가.
|
||||
- Hibernate OSIV를 anti-pattern으로 보는 근거(Vlad Mihalcea + Spring Boot WARN)와 OSIV off를 ca-tmpl baseline으로 둔 이유.
|
||||
- cache-aside의 eventual consistency window 5초가 의미하는 바, 그리고 strict consistency가 필요한 use case(잔액, 인증, idempotency 검증)를 cache bypass로 분리한 의도.
|
||||
- Resilience4j를 Hystrix 대신 선택한 이유(Hystrix maintenance mode + Resilience4j functional decorator 모델).
|
||||
- Outbound HTTP timeout을 connect 2s / read 5s / global 10s로 분리한 의도와, 셋 중 어떤 게 빠지면 어떤 위험이 생기는지.
|
||||
|
||||
### 적당히 답할 수 있는 질문
|
||||
|
||||
- Caffeine vs Hazelcast 같은 local cache 후보 비교 (개념 수준은 가능, 실측 비교 없음).
|
||||
- RestClient vs WebClient (concept-level trade-off는 답할 수 있으나 실제 throughput 측정 없음).
|
||||
- after-commit invalidation을 강제하는 이유 (개념 + Spring API 위치는 설명 가능, 실 hook 코드 없음).
|
||||
|
||||
### 답하면 안 되는 질문 (모른다고 해야 함)
|
||||
|
||||
- HikariCP pool size 튜닝 경험, pool wait p99 실측치, pool exhaustion 대응 경험 — **측정/운영 경험 없음**.
|
||||
- cache hit ratio 측정 / TTL 튜닝 / negative cache stale 사례 — **계측 없음**.
|
||||
- Resilience4j circuit breaker open 운영 경험, half-open probe 동작 관찰, 실제 retry budget 튜닝 — **운영 경험 없음**.
|
||||
- read replica lag 운영 경험, replica failover 대응 — **운영 경험 없음**.
|
||||
- 본 baseline을 적용한 서비스의 SLO 달성 여부 — **prod 배포 없음**.
|
||||
|
||||
## 과장 금지 지점
|
||||
|
||||
이 프로젝트를 외부에 설명할 때 **사실보다 부풀려지기 쉬운 표현**.
|
||||
|
||||
- "ca-tmpl에 SQLState classifier 전체를 구현했다" → **❌**. persistence failure classifier 전체는 별도 확인 필요.
|
||||
- "OSIV off startup guard와 Hikari inter-knob guard를 로컬 검증했다" → 가능.
|
||||
- "cache-aside + Redisson RLock으로 분산 환경에서 안전한 캐시를 구현했다" → **❌**. lower-layer cache SPI/router와 원래 cache-aside+distributed mutex contract를 혼동하지 않는다.
|
||||
- "Resilience4j로 circuit breaker/retry baseline을 구현했다" → 가능. 단 운영 장애 대응 경험은 없음.
|
||||
- "RestClient + timeout 2s/5s/10s로 outbound baseline을 구현하고 로컬 테스트로 검증했다" → 가능. 단 운영 SLO 보장은 아님.
|
||||
- "성능 측정 후 baseline을 튜닝했다" → **❌**. 측정·튜닝 모두 미수행.
|
||||
- "운영에서 검증된 baseline이다" → **❌**. prod 배포 없음.
|
||||
|
||||
면접·블로그·이력서에서는 항상 "**구현된 slice와 planned slice를 분리**"해야 한다. outbound/cache SPI/idempotency/outbox persistence는 구현·로컬 검증, cache-aside distributed consistency와 운영 tuning은 planned로 둔다.
|
||||
|
||||
### Blog-topic ingest: cache/webhook/outbound 묶음 (2026-07-02)
|
||||
|
||||
아래 raw seed들은 data-layer/cache/outbound canonical에 연결했다. 이 문서는 2026-07-02 기준 코드와 `./gradlew check`로 검증되어 blogify 가능하다. 단 각 글에서는 구현된 slice와 planned slice를 분리한다.
|
||||
|
||||
- [[raw/blog-topics/cache-backend-router-fail-open-decorator-2026-07-02]]: cache 장애를 backend 내부 `try/catch`가 아니라 router/decorator 조립 계약으로 중앙화하는 글감. **말할 수 있는 범위**는 ca-tmpl cache role과 검증된 backend 범위다. "모든 cache 실패를 삼켜도 된다"는 식으로 쓰지 않는다.
|
||||
- [[raw/blog-topics/cache-consistency-after-commit-stampede-contract-2026-07-02]]: after-commit invalidation, stampede guard, negative TTL, consistency window를 분리하는 글감. **주의**: planned test와 unsupported decision을 implemented처럼 쓰지 않는다.
|
||||
- [[raw/blog-topics/webhook-full-jitter-dlq-observability-2026-07-02]]: webhook retry를 Full Jitter, DLQ, metric contract로 묶는 글감. **주의**: retry/metric/DLQ 중 planned 항목은 구현 완료로 쓰지 않는다.
|
||||
- [[raw/blog-topics/webhook-signature-replay-contract-2026-07-02]]: raw bytes, timestamp, message id, replay window를 webhook signature 계약으로 묶는 글감. provider 문서는 universal standard가 아니라 사례/source-backed claim으로만 사용한다.
|
||||
- [[raw/blog-topics/webhook-ssrf-egress-proxy-redirect-block-2026-07-02]]: webhook endpoint 등록을 URL 저장이 아니라 egress proxy, redirect block, private range 차단 계약으로 다루는 글감. OWASP/source-backed SSRF 방어와 ca-tmpl planned policy를 분리한다.
|
||||
- [[raw/blog-topics/jdk-httpclient-dns-connectexception-classification-2026-07-02]]: JDK `HttpClient`에서 DNS 실패를 connection failure로 분류할 때 exception wrapping과 retry category를 어떻게 다루는지 정리하는 글감. 운영 장애 사례가 아니라 local/test evidence 중심으로 제한한다.
|
||||
- [[raw/blog-topics/micrometer-meterfilter-resilience4j-functioncounter-2026-07-02]]: outbound HTTP observability에서 Micrometer `MeterFilter`와 Resilience4j `FunctionCounter` registration/tag policy가 충돌할 수 있는 지점을 정리하는 글감. Micrometer/Resilience4j 자체의 일반 결함처럼 쓰지 않는다.
|
||||
- [[raw/blog-topics/repository-capability-archunit-fitness-function-2026-07-02]]: repository 접근 권한을 annotation + registry + ArchUnit fitness function으로 강제하는 글감. 모든 repository misuse를 자동 검출한다고 쓰지 않고, 정적 분석 rule이 볼 수 있는 구조로 제한한다.
|
||||
- [[raw/blog-topics/persistence-audit-metadata-clean-architecture-2026-07-02]]: audit column을 domain model에 섞지 않고 persistence adapter에서 채우는 boundary choice 글감. JPA Auditing이 나쁘다고 쓰지 않고 ca-tmpl skeleton의 선택으로 제한한다.
|
||||
- [[raw/blog-topics/distributed-lock-transaction-commit-boundary-2026-07-02]]: `lock.close()`와 DB commit 순서가 맞물릴 때 lost update 경계가 생기는 이유를 다루는 글감. local/JDBC lock 검증을 운영 분산 환경 보장으로 표현하지 않는다.
|
||||
- [[raw/blog-topics/hikaricp-inter-knob-constraints-startup-guard-2026-06-09]]: HikariCP knob 간 제약을 Spring Boot startup guard로 fail-fast 검증하는 글감. 기존 canonical은 persistence/cache 영역이 stale일 수 있으므로 실제 validator/test 존재 여부를 재확인하기 전까지 구현 등급을 올리지 않는다.
|
||||
|
||||
## 관련 개념
|
||||
|
||||
- [[wiki/concepts/data-layer-persistence-cache-outbound]]
|
||||
|
||||
## Sources
|
||||
|
||||
- [[raw/project-notes/ca-skeleton-operational-contract]] — §6 Operational Error Category, §11 Adapter Failure Contract, §29 Group G-C 외부 근거 인덱스
|
||||
- [[raw/branch-notes/feature-persistence-failure-baseline]] — SQLState 9-row matrix, Hikari alert threshold, OSIV off 결정 기록
|
||||
- [[raw/branch-notes/feature-cache-consistency-contract]] — cache-aside default, Caffeine + Redisson, after-commit invalidation, 5s window 결정 기록
|
||||
- [[raw/branch-notes/feature-outbound-http-client-baseline]] — RestClient baseline, Resilience4j, timeout 2s/5s/10s, shutdown retry suppression 결정 기록
|
||||
- [[raw/blog-topics/jdk-httpclient-dns-connectexception-classification-2026-07-02]] — JDK HttpClient DNS/ConnectException classification 블로그 글감 raw seed
|
||||
- [[raw/blog-topics/micrometer-meterfilter-resilience4j-functioncounter-2026-07-02]] — Micrometer/Resilience4j metric registration 블로그 글감 raw seed
|
||||
- [[raw/branch-notes/feature-repository-access-permission-contract]] — repository capability/access permission parent branch
|
||||
- [[raw/blog-topics/repository-capability-archunit-fitness-function-2026-07-02]] — repository capability ArchUnit fitness function 블로그 글감 raw seed
|
||||
- [[raw/branch-notes/feature-persistence-auditing-contract]] — persistence audit metadata parent branch
|
||||
- [[raw/blog-topics/persistence-audit-metadata-clean-architecture-2026-07-02]] — persistence audit metadata 블로그 글감 raw seed
|
||||
- [[raw/branch-notes/feature-distributed-lock-contract]] — distributed lock lifecycle parent branch
|
||||
- [[raw/blog-topics/distributed-lock-transaction-commit-boundary-2026-07-02]] — distributed lock transaction commit boundary 블로그 글감 raw seed
|
||||
- [[raw/blog-topics/hikaricp-inter-knob-constraints-startup-guard-2026-06-09]] — HikariCP startup guard 블로그 글감 raw seed
|
||||
- [[raw/branch-notes/feature-cachestore-multi-backend-router]] — cache backend router/decorator/fail-open 글감의 parent branch
|
||||
- [[raw/branch-notes/feature-webhook-outbound-contract]] — webhook retry/signature/SSRF outbound 글감의 parent branch
|
||||
- [[raw/blog-topics/cache-backend-router-fail-open-decorator-2026-07-02]] — cache router/decorator 블로그 글감 raw seed
|
||||
- [[raw/blog-topics/cache-consistency-after-commit-stampede-contract-2026-07-02]] — cache after-commit/stampede 블로그 글감 raw seed
|
||||
- [[raw/blog-topics/webhook-full-jitter-dlq-observability-2026-07-02]] — webhook retry/DLQ/observability 블로그 글감 raw seed
|
||||
- [[raw/blog-topics/webhook-signature-replay-contract-2026-07-02]] — webhook signature/replay 블로그 글감 raw seed
|
||||
- [[raw/blog-topics/webhook-ssrf-egress-proxy-redirect-block-2026-07-02]] — webhook SSRF/egress 블로그 글감 raw seed
|
||||
|
||||
## Cluster / 묶음
|
||||
|
||||
<!-- GENERATED: derived-blogs:start -->
|
||||
- [[wiki/blog/ca-tmpl-data-layer-persistence-cache-outbound-2026-07-02]]
|
||||
<!-- GENERATED: derived-blogs:end -->
|
||||
@@ -0,0 +1,141 @@
|
||||
---
|
||||
title: ca-tmpl - DevOps Baseline 결정 (CI + Supply chain + DX)
|
||||
source_type: project
|
||||
status: verified
|
||||
confidence: high
|
||||
tags: [ca-tmpl, devops, ci-cd, supply-chain, sigstore, actually-implemented, locally-verified]
|
||||
related_projects: [ca-tmpl]
|
||||
last_reviewed: 2026-07-02
|
||||
---
|
||||
|
||||
# ca-tmpl - DevOps Baseline 결정 (CI + Supply chain + DX)
|
||||
|
||||
> Layer: `wiki/projects/` — 내 프로젝트 사실. 일반 개념은 [[wiki/concepts/devops-ci-supply-chain-dx]] 참조.
|
||||
|
||||
## 프로젝트 컨텍스트
|
||||
|
||||
ca-tmpl은 ca-skeleton의 운영 가능한 백엔드 템플릿 skeleton이다. **현재 C2 구현 + 로컬 검증 완료.** DevOps baseline은 다음 결정으로 고정되어 있고, GitHub workflow / Gradle gate / supply-chain script 일부가 실제 repository에 존재한다 (canonical §29 G-E + 3개 branch-notes).
|
||||
|
||||
- **CI**: GitHub Actions `needs:` + `if: success()` 모델, **Gate ↔ Branch Contract Test 소유권 매트릭스 20행**, flaky test quarantine bucket **14일 sunset**.
|
||||
- **Supply chain**: **Cosign keyless** (Sigstore Fulcio + Rekor) signing 의무, **SLSA provenance attestation** 의무, **Gradle dependency-locking** (`lockMode = STRICT`), reproducible build.
|
||||
- **DX**: **`./gradlew bootstrap`** 5단계 단일 진입점, **Temurin 21 LTS** + `.tool-versions` 핀, **Testcontainers** `@ServiceConnection` 기반 integration test, `markdown-link-check`.
|
||||
|
||||
이 문서는 결정의 사실 범위와 검증 등급을 분리해 기록한다.
|
||||
|
||||
## 실제 구현 내용 (`actually-implemented`)
|
||||
|
||||
- `.github/workflows/ci-quality-gates.yml`, `dependency-vulnerability.yml`, `build-release-supply-chain.yml`, `link-check.yml`, `supply-chain-retention-audit.yml`가 존재한다.
|
||||
- `.github/ci-gate-matrix.yml`, `.github/dependency-review-config.yml`, `.github/supply-chain-policy.json`, `.github/scripts/verify-supply-chain-contract.sh`, `.github/scripts/test-supply-chain-scripts.sh`가 gate/supply-chain 정책을 코드화한다.
|
||||
- `src/build.gradle`의 `verifyCleanArchitectureDependencies`, `verifyEnvKeys`, `verifyQuarantineSunset`, `verifyTrivyignore`, `verifyReadmeCommands` 등이 check graph에 포함된다.
|
||||
- module별 `gradle.lockfile`, `.trivyignore.yaml`, `flaky-quarantine.yaml`가 존재한다.
|
||||
|
||||
## 로컬/dev 검증 (`locally-verified`)
|
||||
|
||||
- `./gradlew check` 통과(2026-07-02, `BUILD SUCCESSFUL`, 114 tasks).
|
||||
- 실행 중 `verifyCleanArchitectureDependencies`, `verifyEnvKeys`, `verifyQuarantineSunset`, `verifyReadmeCommands`, `verifyTrivyignore`가 OK로 통과했다.
|
||||
- `DeveloperExperienceContractTest`, `ContractRegistrySchemaGovernanceTest`, `SampleRemovalSmokeContractTest` 등 bootstrap/contract tests가 workflow/gate 파일을 검증한다.
|
||||
|
||||
## 운영 검증 (`prod-verified`)
|
||||
|
||||
없음. hosted GitHub Actions run, 실제 release publication, Rekor/GHCR/Cosign live verification은 이 문서에서 확인하지 않았다.
|
||||
|
||||
## 문서/계획만 존재 (`documented-only` / `planned`)
|
||||
|
||||
아래 항목은 구현/로컬 검증된 것과 live release 검증이 필요한 것을 분리한다.
|
||||
|
||||
### CI (`actually-implemented` / `locally-verified`)
|
||||
|
||||
- **GitHub Actions `needs:` + `if: success()`** 기반 release-blocking gate 모델 결정.
|
||||
- **Gate ↔ Branch Contract Test 소유권 매트릭스 20행** — 각 gate가 어느 branch contract test에 의해 깨질 수 있는지, 누가 소유하는지 명시 (canonical §29 G-E + [[raw/branch-notes/feature-ci-quality-gates-contract]]).
|
||||
- **OpenAPI snapshot diff** — springdoc + openapi-diff/oasdiff로 controller 변경 자동 감지. dynamic routing 누락 한계 인지됨.
|
||||
- **Trivy** image vulnerability scan gate.
|
||||
- **Flaky test quarantine bucket + 14일 sunset** — Spotify/Google/MS 운영 vs Fowler 반대 절충안.
|
||||
|
||||
### Supply chain (`partially-implemented`)
|
||||
|
||||
- **Cosign keyless signing** — Fulcio 단명(10분) cert + Rekor transparency log. `--certificate-identity` + `--certificate-oidc-issuer` 검증 정책 필요성 인지됨.
|
||||
- **SLSA provenance attestation** — in-toto attestation, DSSE envelope, Cosign이 동일 envelope 서명.
|
||||
- **Gradle dependency-locking** — `dependencyLocking { lockAllConfigurations() }` + `lockMode = STRICT`, `--write-locks`로 lockfile 생성.
|
||||
- **SemVer + git sha suffix** 버전 정책, reproducible build 목표.
|
||||
- **Cosign verify identity policy** (`--certificate-identity` + `--certificate-oidc-issuer`) — 2026-05-22 후속 보강 결정. 면접 답변 시 "identity 매칭까지 정책에 명시했다"로 정정 가능. 근거: [[raw/official-docs/cosign-keyless-identity-verification-policy]].
|
||||
- **SLSA v1.0 provenance schema 필드명 정정 결정 (2026-05-22)** — provenance 생성 시 spec 필드명(`buildDefinition.externalParameters`, `runDetails.builder.id`, `runDetails.metadata.invocationId` 등) 사용, branch-note의 약식 명명(`build.config.source`, `build.invocation`, `materials`)은 forbidden. 근거: [[raw/official-docs/slsa-v1-provenance-schema]].
|
||||
|
||||
### DX (`actually-implemented` / `locally-verified`)
|
||||
|
||||
- **`./gradlew bootstrap`** 5단계: compileTestJava → docker compose up → Flyway migrate → sample profile seed → smoke.
|
||||
- **Temurin 21 LTS** + `.tool-versions` (asdf/mise 호환).
|
||||
- **Testcontainers** `@ServiceConnection` (Spring Boot 3.1+), reuse 옵션은 CI 비활성화.
|
||||
- **markdown-link-check** dead link 검사.
|
||||
|
||||
### 검토한 대안 (5+종)
|
||||
|
||||
CI provider (GitLab CI / Jenkins / CircleCI / Tekton), signing (GPG vs Cosign), provenance (in-toto vs ad-hoc), dependency lock (Gradle vs Maven Enforcer), tool versioning (mise/asdf vs SDKMAN), dev environment (Devcontainer 단독 vs bootstrap 병행) — 상세 비교는 [[wiki/concepts/devops-ci-supply-chain-dx]] 참조.
|
||||
|
||||
## 면접에서 말할 수 있는 범위
|
||||
|
||||
### 자신 있게 답할 수 있는 질문
|
||||
|
||||
- CI **Gate ↔ Branch Contract Test 소유권 매트릭스**의 의미 (누가 어떤 gate 실패에 책임지는가).
|
||||
- **Flaky test quarantine 14일 sunset**의 근거 (Spotify/Google/MS 운영 인정 + Fowler 반대 입장 절충).
|
||||
- **Cosign keyless vs GPG** 트레이드오프 (단명 cert + Rekor 의존성 추가 vs GPG key 관리 비용 제거).
|
||||
- **SLSA Build L1/L2/L3** 각 레벨이 보장하는 것과 GitHub Actions hosted runner에서 현실적 도달 범위.
|
||||
- **Gradle dependency-locking 필요성**과 Maven에 transitive lockfile이 1급 시민으로 없는 이유.
|
||||
- **Testcontainers vs H2** 선택 이유 (production parity vs 시작 비용).
|
||||
|
||||
### 적당히 답할 수 있는 질문
|
||||
|
||||
- **Tekton vs GitHub Actions** — k8s 인프라 부담과 skeleton 적합도.
|
||||
- **in-toto attestation** statement/predicate/DSSE envelope 구조.
|
||||
|
||||
### 답하면 안 되는 질문 (모른다고 해야 함)
|
||||
|
||||
- "**CI pipeline 운영 경험**" — workflow와 local gate 검증은 가능. hosted CI 운영 이력은 별도 확인 필요.
|
||||
- "**SLSA L3 달성**" — 약식 매핑 단계, hermetic build 미구성.
|
||||
- "**Cosign signature 검증 운영 경험**" — 정책/스크립트는 존재하지만 live Rekor/GHCR 검증 이력은 별도 확인 필요.
|
||||
- "이 skeleton으로 실제 release 한 적 있는가" — 없음.
|
||||
|
||||
## 과장 금지 지점
|
||||
|
||||
- **"Cosign 서명 누락만 차단하면 안전하다"** → ❌. `--certificate-identity` + `--certificate-oidc-issuer` **identity 매칭 정책**이 없으면 임의 OIDC identity가 만든 서명도 통과된다. 정책 표현 형식은 후속 보강 대상(`needs-confirmation`).
|
||||
- **"SLSA Build L3를 달성했다"** → ❌. 현재는 **약식 매핑 단계**이며, GitHub Actions hosted runner만으로 L3(hermetic/tamper-resistant builder) 도달 어렵다. 현실 목표는 L2.
|
||||
- **"SLSA spec 필드명에 정확히 매핑됐다"** → ❌. branch-note의 약식 표현(`build.config.source`, `build.invocation`)은 spec 실제 필드명(`buildDefinition.externalParameters`, `runDetails.builder`, `materials`)과 다르며 **정정 필요**.
|
||||
- **"Google/Spotify가 quarantine 운영하므로 공식 best practice다"** → ❌. *company-tech-blog* 등급이며 Fowler 반대 입장과 양립한다.
|
||||
- **"`./gradlew bootstrap` 한 줄이 끝났다 = 정상이다"** → ❌. 5단계 중 어디서 실패했는지 step 단위 exit code 분리가 필요.
|
||||
- 본 문서는 2026-07-02 코드와 `./gradlew check`로 검증되어 `confidence: high`로 승격했다. 단 live release/supply-chain publication 경험과 혼동하지 않는다.
|
||||
|
||||
### Blog-topic ingest: gitea-act-dependency-security-gate-portability (2026-07-02)
|
||||
|
||||
[[raw/blog-topics/gitea-act-dependency-security-gate-portability-2026-07-02]] 는 GitHub Actions 전용 dependency-review/trivy-action을 Gitea와 act_runner 환경에서도 다룰 수 있도록 플랫폼 독립 CLI gate로 조정한 이유를 블로그로 풀기 위한 raw seed다.
|
||||
|
||||
- **canonical 반영 범위**: dependency vulnerability gate portability 글감을 DevOps/Supply-chain canonical에 연결했다.
|
||||
- **blogify 전 조건**: 충족. 이 문서는 2026-07-02 기준 코드와 `./gradlew check`로 검증됨.
|
||||
- **블로그 전 과장 방지**: 모든 CI에서 동작한다고 쓰지 않고, portability를 높인 설계로 제한한다.
|
||||
- [[raw/blog-topics/five-stage-local-bootstrap-contract-2026-06-24]]: 단일 bootstrap 명령의 가치를 compile/dependency/migration/contract/HTTP smoke 실패를 서로 다른 증거로 분리하는 데 둔 글감. Linux local 검증을 모든 OS/CI/prod 보장으로 표현하지 않는다.
|
||||
- [[raw/blog-topics/trivy-suppression-governance-static-gate-2026-06-20]]: `.trivyignore.yaml` suppression에 만료일·사유 없는 silent bypass가 생기지 않도록 Gradle 정적 게이트로 강제한 글감. 운영에서 취약점 우회를 막았다고 쓰지 않고 locally-verified gate로 제한한다.
|
||||
- [[raw/blog-topics/ci-gate-wiring-vs-policy-ownership-2026-06-20]]: release-blocking 여부를 정하는 gate wiring과 scanner/threshold를 정하는 policy ownership을 분리하는 글감. `always()` fan-in과 delegated-pending gate의 실제 차단 검증은 별도 확인 대상이다.
|
||||
- [[raw/blog-topics/digest-first-java-release-pipeline-2026-06-21]]: reproducible JAR, digest-bound SBOM/Cosign/SLSA 검증, rollback manifest를 하나의 release DAG로 묶는 글감. live OIDC/Rekor/GHCR evidence 전까지 production release 성공으로 쓰지 않는다.
|
||||
- [[raw/blog-topics/gradle9-java21-static-analysis-baseline-2026-06-20]]: Gradle 9 / Java 21 멀티모듈에 Spotless, Checkstyle, SpotBugs, FindSecBugs, ErrorProne을 도입하며 formatter/linter 책임과 BOM classpath 충돌을 다룬 글감. static analysis baseline을 운영 품질 보장처럼 쓰지 않는다.
|
||||
|
||||
## 관련 개념
|
||||
|
||||
- [[wiki/concepts/devops-ci-supply-chain-dx]]
|
||||
|
||||
## Sources
|
||||
|
||||
- [[raw/project-notes/ca-skeleton-operational-contract]] §29 Group G-E — DevOps / CI / Supply chain / DX 대안 조사 인덱스 (canonical SSOT).
|
||||
- [[raw/branch-notes/feature-ci-quality-gates-contract]] — Gate ↔ Branch Contract Test 소유권 매트릭스 20행, flaky quarantine 14d sunset SSOT, OpenAPI snapshot diff, Trivy.
|
||||
- [[raw/branch-notes/feature-build-release-supply-chain-contract]] — Cosign keyless 의무, SLSA provenance attestation 의무, Gradle dependency-locking, SemVer + git sha suffix, reproducibility.
|
||||
- [[raw/branch-notes/feature-dependency-vulnerability-management-contract]] — dependency security gate portability parent branch
|
||||
- [[raw/blog-topics/gitea-act-dependency-security-gate-portability-2026-07-02]] — Gitea/act dependency security gate portability 블로그 글감 raw seed
|
||||
- [[raw/branch-notes/feature-developer-experience-contract]] — `./gradlew bootstrap` 5단계, Temurin 21 LTS, Testcontainers integration, markdown-link-check.
|
||||
- [[raw/blog-topics/five-stage-local-bootstrap-contract-2026-06-24]] — five-stage local bootstrap 블로그 글감 raw seed
|
||||
- [[raw/blog-topics/trivy-suppression-governance-static-gate-2026-06-20]] — Trivy suppression governance static gate 블로그 글감 raw seed
|
||||
- [[raw/blog-topics/ci-gate-wiring-vs-policy-ownership-2026-06-20]] — CI gate wiring vs policy ownership 블로그 글감 raw seed
|
||||
- [[raw/blog-topics/digest-first-java-release-pipeline-2026-06-21]] — digest-first Java release pipeline 블로그 글감 raw seed
|
||||
- [[raw/blog-topics/gradle9-java21-static-analysis-baseline-2026-06-20]] — Gradle 9 / Java 21 static analysis baseline 블로그 글감 raw seed
|
||||
|
||||
## Cluster / 묶음
|
||||
|
||||
<!-- GENERATED: derived-blogs:start -->
|
||||
- [[wiki/blog/ca-tmpl-devops-ci-supply-chain-dx-2026-07-02]]
|
||||
<!-- GENERATED: derived-blogs:end -->
|
||||
@@ -0,0 +1,121 @@
|
||||
---
|
||||
title: ca-tmpl - Idempotency Key 결정 (triple scope + 24h TTL)
|
||||
source_type: project
|
||||
status: verified
|
||||
confidence: high
|
||||
tags: [ca-tmpl, idempotency, api-design, actually-implemented, locally-verified]
|
||||
related_projects: [ca-tmpl]
|
||||
last_reviewed: 2026-07-02
|
||||
---
|
||||
|
||||
# ca-tmpl - Idempotency Key 결정 (triple scope + 24h TTL)
|
||||
|
||||
> Layer: `wiki/projects/` — 내 프로젝트 사실. 일반 개념은 [[wiki/concepts/idempotency-key-design]] 참조.
|
||||
|
||||
## 프로젝트 컨텍스트
|
||||
|
||||
ca-tmpl skeleton 프로젝트의 API contract 설계 트랙 중 하나로 진행한 idempotency key 정책 결정입니다. 다음 형태를 contract 문서에 명시했습니다.
|
||||
|
||||
- key shape: `(authenticatedPrincipal, idempotencyKey, useCaseName)` triple scope
|
||||
- 저장소: DB table (Redis/in-memory가 아님)
|
||||
- TTL: 24h
|
||||
- 동시 도착 시: 200ms in-flight wait → 그래도 in-flight면 HTTP `409`
|
||||
- 같은 key + 다른 body fingerprint: HTTP `422`
|
||||
|
||||
**현재 단계: C2 구현 + 로컬 검증 완료.** 2026-07-02 기준 `/home/donghyeon/workspace/ca-tmpl/src`의 실제 코드와 `./gradlew check` 결과를 대조했다. application-core executor, web helper/codec, RDBMS store, PostgreSQL unique constraint contract가 존재한다. 운영 배포 검증은 없다.
|
||||
|
||||
## 실제 구현 내용 (`actually-implemented`)
|
||||
|
||||
- `application-core`에 `IdempotencyExecutor`, `IdempotencyStorePort`, `IdempotencyRecord`, `RequestFingerprint`, mismatch/in-flight 예외가 구현되어 있다.
|
||||
- `adapter-web`에 `IdempotencyKeySupport`와 `JsonIdempotentResponseCodec`이 있어 HTTP header/principal/use case scope와 저장 응답 codec을 연결한다.
|
||||
- `adapter-persistence-rdbms`에 `IdempotencyStoreAdapter`, `IdempotencyRecordEntity`, `IdempotencyRecordJpaRepository`, `IdempotencyReaper`가 구현되어 있다.
|
||||
- `adapter-persistence-postgresql`의 `V1__idempotency_record.sql`이 DB schema와 unique scope를 소유한다.
|
||||
- `app-bootstrap`의 `IdempotencyConfig` / `IdempotencySettings`가 store, executor, reaper 설정을 배선한다.
|
||||
|
||||
## 로컬/dev 검증 (`locally-verified`)
|
||||
|
||||
- `./gradlew check` 통과(2026-07-02, `BUILD SUCCESSFUL`, 114 tasks).
|
||||
- `IdempotencyExecutorTest`, `RequestFingerprintTest`, `IdempotencyScopeTest`가 executor/mismatch/scope 동작을 검증한다.
|
||||
- `IdempotencyStoreAdapterTest`, `IdempotencyReaperTest`가 RDBMS adapter와 TTL cleanup을 검증한다.
|
||||
- `IdempotencyKeySupportTest`, `IdempotencyExceptionMappingTest`가 web boundary와 error envelope mapping을 검증한다.
|
||||
- `IdempotencyUniqueScopeContractTest`가 PostgreSQL Testcontainers 기반으로 unique scope contract를 검증한다.
|
||||
|
||||
## 운영 검증 (`prod-verified`)
|
||||
|
||||
없음. 운영 환경에 배포된 적이 없습니다.
|
||||
|
||||
## 문서/계획만 존재 (`documented-only` / `planned`)
|
||||
|
||||
이 섹션은 구현된 contract의 정책 경계와 아직 과장하면 안 되는 부분을 분리한다.
|
||||
|
||||
### Key shape / TTL / 저장소 (canonical §29 Topic 5)
|
||||
|
||||
- `(authenticatedPrincipal, idempotencyKey, useCaseName)` triple로 endpoint dimension을 scope에 포함.
|
||||
- TTL 24h. Stripe v1 minimum과 동일하고, 조사한 reference 중 가장 짧은 축.
|
||||
- 저장소는 DB table (Redis 단독 의존 회피). Brandur Postgres 패턴의 변형.
|
||||
- in-flight 처리: 200ms wait 후에도 충돌이면 `409`.
|
||||
- body fingerprint mismatch: `422`.
|
||||
|
||||
### 8종 reference 비교 후 triple 채택
|
||||
|
||||
- **검토 대안**: Stripe v1 pair / Stripe v2 triple / Square body-field / PayPal `PayPal-Request-Id` 45일 / Toss 4-tuple 15일 / AWS Lambda Powertools content-hash / GitHub no-dedup / Brandur Postgres lock.
|
||||
- **채택 근거 (설계 시점)**:
|
||||
- storage 비용 — 24h TTL이 PayPal 45일·Toss 15일·Stripe v2 30일 대비 가장 짧음.
|
||||
- key 추측 공격면 — TTL 짧을수록 노출 window 감소.
|
||||
- endpoint dimension 보강 — Stripe v1 pair의 cross-use-case 충돌 위험 회피.
|
||||
- URL/method를 scope에서 제외해 (Toss 4-tuple과 달리) HTTP path version migration에 강함.
|
||||
|
||||
### 409 vs 422 응답 코드 분리
|
||||
|
||||
- `409 Conflict` — 동일 key의 in-flight 충돌 (200ms wait 후에도 원본 미완료).
|
||||
- `422 Unprocessable Entity` — 동일 key + 다른 body fingerprint (클라이언트 버그 신호).
|
||||
- IETF draft가 in-flight를 `409`로, fingerprint mismatch를 `422`로 권고(SHOULD)한 라인을 ca-tmpl 응답 코드에 그대로 반영.
|
||||
|
||||
IETF draft의 in-flight `409`, fingerprint mismatch `422` 권고는 project policy와 구현에 반영되어 있다. 단 200ms wait 값은 부하 측정 기반 튜닝값이 아니라 ca-tmpl 기본 정책값이다.
|
||||
|
||||
## 면접에서 말할 수 있는 범위
|
||||
|
||||
- **자신 있게 답할 수 있음**
|
||||
- "왜 `useCaseName`을 scope에 넣었나" — Stripe v1 pair의 cross-use-case 충돌 회피.
|
||||
- "왜 TTL 24h인가" — storage 비용·공격면 vs long-running retry window의 trade-off, 짧은 쪽 선택 이유.
|
||||
- "200ms wait의 의미" — 즉시 `409`로 끊지 않고 client retry 친화적으로 hybrid 처리한 이유.
|
||||
- "409 vs 422 분리 의도" — in-flight 충돌과 fingerprint mismatch가 클라이언트에게 다른 신호임을 코드로 구분.
|
||||
- **적당히 답할 수 있음**
|
||||
- "IETF Idempotency-Key draft와의 정합성" — `SHOULD` 라인은 따랐으나 draft 단계임을 명시.
|
||||
- **답하면 안 됨 (모른다고 해야 함)**
|
||||
- "idempotency executor/storage/web helper를 구현했고 로컬 테스트로 검증했다" — 가능. 단 운영 배포 경험은 없음.
|
||||
- "동시성 부하 테스트로 200ms wait 값을 튜닝했다" — ❌. 측정값 없음.
|
||||
- "운영에서 422 / 409 비율이 어땠다" — ❌. 운영 배포 자체가 없음.
|
||||
|
||||
## 과장 금지 지점
|
||||
|
||||
- "ca-tmpl triple이 Stripe pair보다 무조건 안전" — ❌. **v1 pair 한정** 비교. Stripe v2 triple과는 사실상 동급.
|
||||
- "IETF Idempotency-Key spec을 완전히 준수한다" — ❌. draft 단계이며, 200ms wait는 draft의 "즉시 409" 권고와 deviation.
|
||||
- "24h TTL이 업계 표준" — ❌. Stripe v1 최소값과 일치할 뿐, 다른 reference는 모두 더 김.
|
||||
- "8종을 벤치마크해 채택했다" — ❌. **문서 비교**이지 측정 비교가 아님.
|
||||
- "구현했다 / 로컬 테스트로 검증했다"는 가능. "운영에서 검증했다 / 부하로 튜닝했다"는 금지.
|
||||
|
||||
### Blog-topic ingest: application-layer idempotency executor (2026-07-02)
|
||||
|
||||
[[raw/blog-topics/idempotency-executor-application-layer-clean-architecture-2026-06-09]] 는 idempotency를 framework middleware가 아니라 application-layer executor와 storage port로 두고, rate-limit은 presentation interceptor가 소유하도록 분리한 글감이다.
|
||||
|
||||
- **canonical 반영 범위**: triple scope/TTL/409/422 결정 문서에 layer ownership 글감을 연결했다.
|
||||
- **blogify 전 조건**: 충족. 이 문서는 2026-07-02 기준 코드와 `./gradlew check`로 검증됨.
|
||||
- **블로그 전 과장 방지**: IETF draft의 즉시 409 권고와 ca-tmpl의 200ms wait deviation을 분리한다.
|
||||
|
||||
## 관련 개념
|
||||
|
||||
- [[wiki/concepts/idempotency-key-design]]
|
||||
|
||||
## Sources
|
||||
|
||||
- [[raw/project-notes/ca-skeleton-operational-contract]] §29 Topic 5
|
||||
- [[raw/branch-notes/feature-rate-limit-idempotency-contract]]
|
||||
- [[raw/branch-notes/feature-api-contract-baseline]]
|
||||
- [[raw/blog-topics/idempotency-executor-application-layer-clean-architecture-2026-06-09]] — application-layer idempotency executor 블로그 글감 raw seed.
|
||||
|
||||
## Cluster / 묶음
|
||||
|
||||
<!-- GENERATED: derived-blogs:start -->
|
||||
- [[wiki/blog/ca-tmpl-idempotency-key-design-2026-07-02]]
|
||||
<!-- GENERATED: derived-blogs:end -->
|
||||
@@ -0,0 +1,75 @@
|
||||
---
|
||||
title: ca-tmpl - Knowledge Capture Workflow 결정
|
||||
source_type: project
|
||||
status: verified
|
||||
confidence: medium
|
||||
tags: [ca-tmpl, workflow, documentation, agent-workflow, documented-only]
|
||||
related_projects: [ca-tmpl]
|
||||
last_reviewed: 2026-07-02
|
||||
---
|
||||
|
||||
# ca-tmpl - Knowledge Capture Workflow 결정
|
||||
|
||||
> Layer: `wiki/projects/` — ca-tmpl 작업 종료 조건에 지식 캡처를 포함한 workflow 결정. 블로그/면접 파생은 이 canonical을 review/verify한 뒤 진행한다.
|
||||
|
||||
## 프로젝트 컨텍스트
|
||||
|
||||
ca-tmpl 작업에서는 비자명한 구현이 끝난 뒤 코드만 남고, 왜 그렇게 했는지/어떤 오류를 겪었는지/면접과 블로그로 옮길 만한 학습이 무엇인지가 채팅 로그에 흩어지는 문제가 있었다. 이를 줄이기 위해 branch-note, error note, interview prep, blog-topic을 작업 종료 흐름의 일부로 기록하는 workflow를 repo-local rule로 둔 결정이 있다.
|
||||
|
||||
## 실제 구현 내용 (`actually-implemented`)
|
||||
|
||||
없음. 이 문서가 기록하는 것은 runtime 기능이나 애플리케이션 코드가 아니라 workflow rule과 문서화 결정이다. verified 범위도 애플리케이션 동작이 아니라 repo-local documentation workflow에 한정한다.
|
||||
|
||||
## 로컬/dev 검증 (`locally-verified`)
|
||||
|
||||
부분적이다. `feature-application-port-usecase-contract` 등 일부 branch에서 branch-note 갱신과 derived raw note 생성이 실제로 수행된 사례가 있고, 이번 `raw/blog-topics` 59개 ingest batch도 workflow의 raw→canonical 승격 사례다. 다만 자동 강제 장치가 아니라 agent workflow rule에 의존한다.
|
||||
|
||||
## 운영 검증 (`prod-verified`)
|
||||
|
||||
없음. 운영 시스템 기능이 아니며 prod verification 대상이 아니다.
|
||||
|
||||
## 문서/계획만 존재 (`documented-only` / `planned`)
|
||||
|
||||
- non-trivial 구현 종료 조건에 LLM Wiki capture를 포함한다.
|
||||
- 캡처 단위는 `raw/branch-notes/`, `raw/errors/`, `raw/interviews/`, `raw/blog-topics/`로 나눈다.
|
||||
- canonical(`wiki/concepts/`, `wiki/projects/`)과 derived(`wiki/blog/`, `wiki/interview/`, `wiki/portfolio/`)는 명시 요청과 게이트를 거친다.
|
||||
- derived raw note는 `## Parent`로 branch-note를 가리키고, branch-note는 `## Cluster`에서 되돌아 링크한다.
|
||||
- 자동 강제(git hook/CI)는 아직 없다.
|
||||
|
||||
## 면접에서 말할 수 있는 범위
|
||||
|
||||
- 자신 있게: 구현 종료 조건에 decision/error/interview/blog-topic capture를 포함한 이유와 raw/canonical/derived 계층 분리.
|
||||
- 적당히: agent workflow rule만으로 누락을 줄이는 방식의 장단점.
|
||||
- 답하면 안 됨: CI나 git hook으로 자동 강제했다고 말하면 안 된다.
|
||||
|
||||
## 과장 금지 지점
|
||||
|
||||
- "자동으로 캡처된다" → 금지. 현재는 documented workflow rule이며 runtime/CI enforcement가 아니다.
|
||||
- "모든 branch에서 누락 없이 동작했다" → 금지. 사례는 누적 중이다.
|
||||
- "raw에서 바로 blog를 만든다" → 금지. blog는 canonical 경유 후 파생한다.
|
||||
|
||||
### Blog-topic ingest: post-implementation-knowledge-capture-workflow (2026-07-02)
|
||||
|
||||
[[raw/blog-topics/post-implementation-knowledge-capture-workflow-2026-05-28]] 는 구현 완료 조건에 branch-note와 파생 raw note 캡처를 포함하는 workflow를 글감으로 풀기 위한 raw seed다.
|
||||
|
||||
- **canonical 반영 범위**: ca-tmpl 작업 종료 조건과 LLM Wiki capture workflow 결정으로 연결했다.
|
||||
- **blogify 전 조건**: 충족. 이 문서는 workflow 적용 사례와 자동 강제 부재를 분리해 verified로 승격했다.
|
||||
- **블로그 전 과장 방지**: documented workflow rule을 자동화된 enforcement처럼 쓰지 않는다.
|
||||
|
||||
## 관련 개념
|
||||
|
||||
- [[wiki/concepts/clean-architecture-package-layout]]
|
||||
|
||||
## Sources
|
||||
|
||||
- [[raw/blog-topics/post-implementation-knowledge-capture-workflow-2026-05-28]] — knowledge capture workflow 블로그 글감 raw seed.
|
||||
- [[raw/branch-notes/feature-architecture-enforcement-rules]] — workflow rule 반영 결정.
|
||||
- [[raw/branch-notes/feature-application-port-usecase-contract]] — workflow 적용 사례.
|
||||
- [[raw/errors/apply-patch-auto-approval-rejected-2026-05-28]] — workflow 문서 패치 중 도구 차단 사례.
|
||||
- [[raw/interviews/post-implementation-knowledge-capture]] — 같은 작업에서 파생된 면접 질문.
|
||||
|
||||
## Cluster / 묶음
|
||||
|
||||
<!-- GENERATED: derived-blogs:start -->
|
||||
- [[wiki/blog/ca-tmpl-knowledge-capture-workflow-2026-07-02]]
|
||||
<!-- GENERATED: derived-blogs:end -->
|
||||
@@ -0,0 +1,125 @@
|
||||
---
|
||||
title: ca-tmpl - Multi-tenancy 결정 (opt-in shared DB + tenant_id)
|
||||
source_type: project
|
||||
status: verified
|
||||
confidence: high
|
||||
tags: [ca-tmpl, multi-tenancy, saas, actually-implemented, locally-verified, documented-only]
|
||||
related_projects: [ca-tmpl]
|
||||
last_reviewed: 2026-07-02
|
||||
---
|
||||
|
||||
# ca-tmpl - Multi-tenancy 결정 (opt-in shared DB + tenant_id)
|
||||
|
||||
> Layer: `wiki/projects/` — 내 프로젝트 사실. 일반 개념은 [[wiki/concepts/multi-tenancy-isolation-patterns]] 참조.
|
||||
|
||||
## 프로젝트 컨텍스트
|
||||
|
||||
ca-tmpl(Clean Architecture skeleton)에서 multi-tenancy를 어떻게 다룰지 정한 결정 문서다. baseline은 다음 조합이다.
|
||||
|
||||
- **opt-in**: `APP_TENANT_ENABLED=true`일 때만 tenant 로직 활성. single-tenant deployment에서는 비활성화하여 skeleton 적용 범위를 넓힘.
|
||||
- **shared DB + `tenant_id` column (ULID)**: AWS Pool 모델 / Hibernate DISCRIMINATOR 전략에 해당.
|
||||
- **Tenant resolution**: JWT claim 우선, `X-Tenant-Id` header는 **admin only(`CROSS_TENANT_ADMIN` capability 보유자)** 에 한해 허용.
|
||||
- B2B 초기 단계(tenant 수 수십~수백 단위) 가정. isolation 비용 대비 운영 단순성 우선.
|
||||
|
||||
**현재 진행 상태**: tenant-aware registry/runbook/capability/idempotency scope 일부 구현 + repository tenant filter는 planned. 본 문서는 구현된 tenant support surface와 아직 없는 storage isolation enforcement를 분리한다.
|
||||
|
||||
## 실제 구현 내용 (`actually-implemented`)
|
||||
|
||||
- `docs/registries/env-keys.yaml`에 `APP_TENANT_ENABLED`, `docs/registries/headers.yaml`에 `X-Tenant-Id`, `docs/registries/capabilities.yaml`에 `CROSS_TENANT_ADMIN`, `docs/registries/error-codes.yaml`에 tenant error code가 존재한다.
|
||||
- `docs/runbooks/authz-tenant-mismatch.md`, `docs/runbooks/authz-cross-tenant-violation.md`가 cross-tenant incident response stub을 제공한다.
|
||||
- `AuthorizationPrincipal`, `RequiresPermission`, `AuthorizationPort`, role/permission registry와 `AuthorizationContractTest`가 capability 기반 authorization foundation을 제공한다.
|
||||
- `IdempotencyScope`와 `IdempotencyKeySupport`는 tenant-aware scope를 표현할 수 있다.
|
||||
- repository-level tenant predicate 강제, tenant resolver filter, storage isolation은 아직 구현되지 않았다.
|
||||
|
||||
## 로컬/dev 검증 (`locally-verified`)
|
||||
|
||||
- `./gradlew check` 통과(2026-07-02, `BUILD SUCCESSFUL`, 114 tasks).
|
||||
- `AuthorizationContractTest`, `IdempotencyScopeTest`, `IdempotencyKeySupportTest`, registry governance tests가 tenant/capability/registry surface 일부를 검증한다.
|
||||
- repository tenant filter와 cross-tenant E2E isolation은 검증되지 않았다.
|
||||
|
||||
## 운영 검증 (`prod-verified`)
|
||||
|
||||
**없음.** ca-tmpl은 skeleton이며 prod 배포 이력 없음.
|
||||
|
||||
## 문서/계획만 존재 (`documented-only` / `planned`)
|
||||
|
||||
### Tenant resolution + isolation 정책 (partially-implemented)
|
||||
|
||||
- JWT claim 우선 → admin only `X-Tenant-Id` header fallback → 해석 실패 시 reject.
|
||||
- repository 진입점에서 tenant filter 강제(`CROSS_TENANT_ADMIN` capability 없이는 모든 query에 `tenant_id` predicate).
|
||||
- **status: partially-implemented** — registry/header/capability/runbook/idempotency scope는 존재하지만 repository filter와 tenant resolver filter는 planned.
|
||||
|
||||
### 6종 대안 검토 → Pool 채택
|
||||
|
||||
검토한 6가지와 채택/기각 사유:
|
||||
|
||||
| 대안 | 분류 | 채택 여부 | 사유 |
|
||||
|------|------|-----------|------|
|
||||
| **shared DB + tenant_id** (ULID) | AWS Pool / Hibernate DISCRIMINATOR | **채택** | B2B 초기, tenant 수 수십~수백 예상. 운영 단순성. |
|
||||
| subdomain-based | resolution-only | 기각 | wildcard DNS/TLS·subdomain takeover·local dev 비용. resolution은 isolation을 보장하지 않음. |
|
||||
| JWT claim only (storage 분리 없음) | resolution-only | 기각 | claim 검증 누락 시 cross-tenant leak. storage layer 강제 필요. |
|
||||
| schema-per-tenant | Hibernate SCHEMA | 기각 | catalog bloat·`search_path` 전환 plan cache 무효화·HikariCP 설계 복잡. 초기 단계 ROI 부정. |
|
||||
| db-per-tenant | AWS Silo | 기각 | 운영 비용 폭증(마이그레이션·백업·connection pool 폭발). 규제 요구 부재. |
|
||||
| hybrid (Azure Deployment Stamps / AWS Bridge) | mixed | 기각 | 운영 복잡도 최고. PMF 이후 단계 검토 사항. |
|
||||
|
||||
근거: ca-tmpl은 skeleton이며 초기 도입 대상은 B2B 소규모 SaaS. 결정은 verified 되었지만 storage isolation enforcement는 아직 planned다.
|
||||
|
||||
### Migration trigger 3가지 정의
|
||||
|
||||
shared DB → schema/db-per-tenant로 전환을 검토할 조건:
|
||||
|
||||
1. **규제**: 금융·의료(HIPAA·FedRAMP·data residency) isolation 강제.
|
||||
2. **규모**: tenant 수 hundreds 도달 + 단일 row 수 억대 진입(noisy neighbor·index 비용 임계).
|
||||
3. **상품 tier**: enterprise tier 등장으로 isolation을 가격에 반영해야 할 때.
|
||||
|
||||
**status: documented-only** — migration trigger는 아직 관측 지표/자동 경보로 구현되지 않았다.
|
||||
|
||||
### `CROSS_TENANT_ADMIN` capability 정의
|
||||
|
||||
- admin/support 운영 동선용. 보유자만 `X-Tenant-Id` header로 tenant 전환 가능.
|
||||
- 일반 사용자 경로는 JWT claim 단독, header 무시.
|
||||
- **status: partially-implemented** — capability vocabulary와 authorization foundation은 존재하지만, repository tenant filter와 admin tenant switching E2E는 미구현.
|
||||
|
||||
## 면접에서 말할 수 있는 범위
|
||||
|
||||
### 자신 있게
|
||||
|
||||
- "Pool / Silo / Bridge의 차이와 각각의 비용·isolation trade-off."
|
||||
- "tenant resolution에서 JWT claim과 `X-Tenant-Id` header의 trust 차이, header를 admin only로 제한하는 이유."
|
||||
- "shared DB → 격리 강화 모델로 가는 **migration trigger 3가지**(규제 / 규모 / enterprise tier)."
|
||||
|
||||
### 적당히
|
||||
|
||||
- ULID vs UUID 선택 이유(정렬 가능성·index locality·시간 정보 노출 trade-off).
|
||||
- Hibernate multi-tenancy strategy(DATABASE / SCHEMA / DISCRIMINATOR) 차이와 `CurrentTenantIdentifierResolver` 동작 개요.
|
||||
|
||||
### 답하면 안 됨 (모른다고 해야 함)
|
||||
|
||||
- "tenant 격리를 어떻게 **측정**했는가" — 측정·테스트 부재.
|
||||
- "cross-tenant 침해 시도/penetration test 결과" — 수행 안 함.
|
||||
- "schema-per-tenant 운영 경험" — 검토만 했고 운영해 본 적 없음.
|
||||
- "실제 tenant 수, row 수, 성능 지표" — skeleton에 데이터 없음.
|
||||
|
||||
## 과장 금지 지점
|
||||
|
||||
- "shared DB + tenant_id가 항상 우월하다" → 금지. 규제 산업(HIPAA·금융·data residency)에서는 Silo가 사실상 강제다. 본 선택은 **B2B 초기 단계 가정에 종속된 결정**이라는 점을 함께 말할 것.
|
||||
- **Stripe/Citus schema-per-tenant 한계치 단언 금지** — 정확 인용 wording이 미완(raw 자료 `needs-confirmation`). "수백~수천 단위에서 catalog overhead가 보고된다" 정도로 출처와 함께만 언급.
|
||||
- "ca-tmpl에 multi-tenancy를 **완성했다**" → 금지. tenant-aware registry/capability/scope foundation은 구현됐지만, repository-level tenant filter와 E2E isolation은 planned다.
|
||||
- "JWT claim만 검증하면 안전하다" → 금지. repository 레벨 tenant filter가 별도로 필요하다.
|
||||
- "Atlassian이 그렇게 하니까 best practice" → 금지. company-tech-blog는 관점이지 공식 기준이 아니다.
|
||||
|
||||
## 관련 개념
|
||||
|
||||
- [[wiki/concepts/multi-tenancy-isolation-patterns]] — Pool/Silo/Bridge, Hibernate strategy, resolution 방식 공식 기준
|
||||
|
||||
## Sources
|
||||
|
||||
- [[raw/project-notes/ca-skeleton-operational-contract]] — §10 Repository Access Permission Contract, §29 Topic 6 Multi-tenancy Isolation
|
||||
- [[raw/branch-notes/feature-tenant-context-policy]] — tenant resolution(JWT > header admin only) SSOT
|
||||
- [[raw/branch-notes/feature-repository-access-permission-contract]] — `CROSS_TENANT_ADMIN` capability, repository 레벨 tenant filter contract
|
||||
|
||||
## Cluster / 묶음
|
||||
|
||||
<!-- GENERATED: derived-blogs:start -->
|
||||
- [[wiki/blog/ca-tmpl-multi-tenancy-isolation-patterns-2026-07-02]]
|
||||
<!-- GENERATED: derived-blogs:end -->
|
||||
@@ -0,0 +1,143 @@
|
||||
---
|
||||
title: ca-tmpl - Observability Baseline 결정 (Log + Metric + Trace + Runbook)
|
||||
source_type: project
|
||||
status: verified
|
||||
confidence: high
|
||||
tags: [ca-tmpl, observability, logging, metrics, tracing, actually-implemented, locally-verified, documented-only]
|
||||
related_projects: [ca-tmpl]
|
||||
last_reviewed: 2026-07-02
|
||||
---
|
||||
|
||||
# ca-tmpl - Observability Baseline 결정 (Log + Metric + Trace + Runbook)
|
||||
|
||||
> Layer: `wiki/projects/` — 내 프로젝트 사실. 일반 개념은 [[wiki/concepts/observability-log-metric-trace-runbook]] 참조.
|
||||
|
||||
## 프로젝트 컨텍스트
|
||||
|
||||
ca-tmpl은 Clean Architecture 기반 백엔드 skeleton 프로젝트다. **운영 계약(operational contract)** 단계에서 observability 4축 — **structured JSON Logback + masking, Micrometer dot.case + Prometheus, W3C tracecontext 전파, `runbook://` scheme** — 을 baseline으로 묶어 단일 운영 계약으로 통합하는 결정을 했다.
|
||||
|
||||
현재 진행 상태:
|
||||
|
||||
- **Phase E (운영 계약 설계) 완료** — 4 sub-topic 각각의 branch-note가 작성되어 대안 검토와 결정 근거가 정리됨.
|
||||
- **C2 (구현 단계) 미진입** — 어떤 Logback config, Micrometer registry, Sleuth/Tracing 설정 파일도 작성되지 않음.
|
||||
|
||||
**정정 (2026-06-04):** "문서/설계 산출물만 존재"는 더 이상 정확하지 않다. 4축(Log/Metric/Trace/Runbook)의 *full* 기능은 여전히 미구현이지만, foundation branch가 소유한 **observability 토대 slice**(MDC snake_case 표준 + 응답-로그 상관 + inbound 헤더 sanitization)는 2026-06-01 Phase C2로 코드화·로컬 검증됐다(아래 actually-implemented / locally-verified).
|
||||
|
||||
> **Ground-truth 대조 (2026-06-04, ca-tmpl @0c996fc "운영 에러 관측성 foundation 계약 구현", HEAD `db61075`에서도 존재 확인):** 아래 foundation slice 파일·MDC 키는 ca-tmpl 코드 실측으로 일치 확인. `MdcKeys.java`는 `request_id`/`trace_id`/`span_id`/`correlation_id`/`user_principal` snake_case 상수를 정의하고 **`tenant_id`는 아직 없음**(tenant-context-policy branch 도착 시 조건부). `RequestLoggingFilter.java`는 `adapter-web/filter/`에 위치(observability 패키지 아님). 패키지 root는 `dev.caskeleton.*`, 모듈 경로는 `src/<module>/src/main/java/dev/caskeleton/...`. stale 추출 잔재(`com.example.blog`/`sample-ticket`)는 없음 — sample 모듈은 `sample-portfolio`.
|
||||
|
||||
## 실제 구현 내용 (`actually-implemented`)
|
||||
|
||||
> 4축(Log/Metric/Trace/Runbook)의 *전체* 구현은 여전히 각 owner branch의 미진입 작업이다(아래 documented-only). 단 **foundation branch([[raw/branch-notes/feature-operational-error-observability-foundation]])가 소유한 observability 토대 slice**는 2026-06-01 Phase C2로 코드화됨 (grep 확인):
|
||||
|
||||
- `adapter-web/observability/MdcKeys.java` — MDC key snake_case 상수 표준(`request_id`/`trace_id`/`span_id`/`correlation_id`).
|
||||
- `app-bootstrap/logback-spring.xml` — snake_case `includeMdcKeyName` 설정.
|
||||
- `adapter-web/observability/HeaderSanitizer.java` — inbound 헤더 CR/LF·제어문자 strip + length cap (log injection / CWE-117 방어).
|
||||
- `adapter-web/filter/RequestLoggingFilter.java` — `X-Request-Id`/`X-Correlation-Id` 수신·생성·MDC set/clear + sanitization 적용.
|
||||
- `shared-contract/response/ResponseMeta.java` + `adapter-web/observability/ResponseMetaFactory.java` — `request_id`/`trace_id`/`correlation_id` MDC → `meta.{requestId,traceId,correlationId}` 응답 투영.
|
||||
|
||||
이것은 log/metric/trace 신호의 *식별자 토대*(MDC 표준 + 응답-로그 상관 + 헤더 sanitization)이며, 4축의 full 기능(JSON masking/sampling, Prometheus, trace sampling, runbook)은 포함하지 않는다.
|
||||
|
||||
## 로컬/dev 검증 (`locally-verified`)
|
||||
|
||||
위 foundation slice는 `./gradlew check` (전 모듈 test + ArchUnit) **BUILD SUCCESSFUL** (2026-06-01)로 검증됨 — `HeaderSanitizerTest`/`ResponseMetaFactoryTest`/`RequestLoggingFilterTest`/`EnvelopeMetaIntegrationTest`. **4축 full 구현(masking 효과·alert 발화·trace sampling·runbook link-check)의 로컬 검증은 여전히 없음.**
|
||||
|
||||
## 운영 검증 (`prod-verified`)
|
||||
|
||||
**없음.** 운영 환경 검증 없음. alert 발화·trace sampling 결과·log masking 효과 측정 모두 없음.
|
||||
|
||||
## 문서/계획만 존재 (`documented-only` / `planned`)
|
||||
|
||||
운영 계약 문서(canonical §8, §29 G-A)와 4 branch-note에 다음이 **설계 수준**으로만 기록되어 있다.
|
||||
|
||||
### Log (`documented-only`)
|
||||
|
||||
- Structured JSON Logback 스키마: `@timestamp`, `log.level`, `service.name`, `trace.id` 등 ECS 호환 필드.
|
||||
- Masking 항목: PII / credential / token 필드 발신지 마스킹 정책.
|
||||
- Sampling: prod 환경 일반 로그 10% sampling, error/warn 전량 sampling.
|
||||
- 대안 검토: ECS vs OTel log signal vs Loki 자체 schema — branch-note `feature-log-management-contract`.
|
||||
|
||||
### Metric (`documented-only`)
|
||||
|
||||
- Micrometer dot.case naming + Prometheus exporter(`_` 변환).
|
||||
- Alert severity: P1 / P2 / P3 분리.
|
||||
- Cardinality bound: `userId`·`requestId` 등 unbounded label 금지.
|
||||
- 대안 검토: SLO burn-rate vs traffic-based threshold — branch-note `feature-metrics-alerting-contract`.
|
||||
|
||||
### Trace (`documented-only`)
|
||||
|
||||
- W3C traceparent 헤더 채택 (B3 미채택).
|
||||
- Micrometer Tracing + OTel bridge 방향.
|
||||
- Sampling: prod 1% head-based.
|
||||
- 대안 검토: head-based vs tail-based, B3 hybrid 변환 — branch-note `feature-distributed-tracing-contract`.
|
||||
|
||||
### Runbook (`documented-only`)
|
||||
|
||||
- `runbook://` 내부 URI scheme + repo path 매핑.
|
||||
- Alert payload에 runbook URL 박아넣는 계약.
|
||||
- Link-check smoke test로 drift 방지.
|
||||
- 대안 검토: Confluence runbook vs runbook-as-code vs PagerDuty Runbook Automation — branch-note `feature-operational-runbook-contract`.
|
||||
|
||||
**모두 문서/설계 단계.** Logback config, Micrometer registry 설정, Sleuth/Tracing 설정 파일, runbook markdown 본문 모두 미작성.
|
||||
|
||||
## 면접에서 말할 수 있는 범위
|
||||
|
||||
### 자신 있게 답할 수 있는 (개념·설계 의도)
|
||||
|
||||
- Observability 3 pillars(log/metric/trace) 정의와 각 신호가 대체 불가능한 이유.
|
||||
- W3C tracecontext vs B3 propagation 차이 (128-bit vs 64-bit trace-id, 변환 한계).
|
||||
- Log masking 범위와 발신지 마스킹이 필요한 이유.
|
||||
- Runbook drift 방지를 위해 `runbook://` scheme + git 관리 + link-check를 선택한 설계 근거.
|
||||
|
||||
### 적당히 답할 수 있는
|
||||
|
||||
- SLO burn-rate alert vs traffic-based threshold의 트레이드오프 — SLO 합의 전 단계에서 traffic-based가 합리적인 이유.
|
||||
- Head-based vs tail-based sampling의 비용/정확도 trade-off.
|
||||
|
||||
### 답하면 안 되는 (실측·운영 경험 없음)
|
||||
|
||||
- "Grafana 대시보드를 운영하면서…" — 대시보드 미구축.
|
||||
- "trace 1% sampling 결과 rare-error 누락률은…" — 측정 없음.
|
||||
- "incident response를 실제로 수행하면서…" — 운영 경험 없음.
|
||||
- "log masking으로 PII 사고를 막은 사례" — 미적용.
|
||||
|
||||
## 과장 금지 지점
|
||||
|
||||
- **"OpenTelemetry로 통일했으니 vendor-neutral이다"** → ❌. instrument 표준은 중립이지만 backend(Datadog/Tempo/Jaeger) 선택 시 lock-in 잔존.
|
||||
- **"SLO burn-rate alert를 채택했다"** → ❌. 설계 단계에서 검토만 했고, SLO 자체가 합의되지 않은 단계에선 traffic-based가 더 운영 가능함을 결론으로 두었다.
|
||||
- **"운영 환경에서 alert가 동작하는 것을 확인했다"** → ❌. 미구현. alert rule 파일조차 없음.
|
||||
- **"structured logging을 적용해 PII를 안전하게 처리하고 있다"** → ❌. masking 정책은 문서에만 존재.
|
||||
- **"trace sampling 1%로 비용을 최적화했다"** → ❌. 적용 결과 없음. 설계상 채택만.
|
||||
- **"runbook을 자동화했다"** → ❌. `runbook://` scheme은 정의했으나 자동 실행 도구 미도입.
|
||||
|
||||
### Blog-topic ingest: w3c-traceparent-fork-activated-seam (2026-07-02)
|
||||
|
||||
[[raw/blog-topics/w3c-traceparent-fork-activated-seam-2026-07-02]] 는 OTel SDK를 붙이기 전에 W3C `traceparent` 계약을 먼저 둘 때 생기는 seam과 landmine을 블로그로 풀기 위한 raw seed다.
|
||||
|
||||
- **canonical 반영 범위**: distributed tracing contract의 W3C trace context seam을 observability canonical에 연결했다.
|
||||
- **source-backed 로 말할 부분**: W3C Trace Context와 OTel 관련 설명은 공식 raw source claim으로 확인된 범위에 한정한다.
|
||||
- **블로그 전 과장 방지**: end-to-end distributed tracing 구현 완료처럼 쓰지 않고, seam/contract 중심으로 제한한다.
|
||||
- [[raw/blog-topics/runbook-coverage-junit-contract-test-2026-07-02]]: 운영 runbook 링크가 문서에만 존재하는지, error registry와 연결되어 release를 막을 수 있는지 JUnit contract test로 확인하는 글감. runbook 내용 품질까지 자동 보장한다고 쓰지 않고 coverage/link existence 검증으로 제한한다.
|
||||
- [[raw/blog-topics/logback-layer1-secret-masking-json-vs-pattern-2026-06-14]]: Logback `%replace`가 JSON encoder 경로를 우회하는 문제와 JSON decorator / pattern converter가 같은 masking regex SSOT를 공유해야 하는 이유를 다루는 글감. regex masking이 모든 secret 형태를 잡는다고 쓰지 않는다.
|
||||
- [[raw/blog-topics/spring-async-taskdecorator-bounded-executor-saturation-shutdown-2026-06-13]]: bounded executor, `TaskDecorator` MDC/context propagation, saturation metric, graceful shutdown budget을 하나의 background job 운영 계약으로 다루는 글감. 숫자값을 부하테스트 튜닝 결과처럼 쓰지 않는다.
|
||||
|
||||
## 관련 개념
|
||||
|
||||
- [[wiki/concepts/observability-log-metric-trace-runbook]]
|
||||
|
||||
## Sources
|
||||
|
||||
- [[raw/project-notes/ca-skeleton-operational-contract]] — ca-tmpl 운영 계약 SSOT (§8 Structured Log, §29 G-A).
|
||||
- [[raw/branch-notes/feature-log-management-contract]] — JSON Logback + masking + trace 상관관계 계약.
|
||||
- [[raw/branch-notes/feature-metrics-alerting-contract]] — Micrometer dot.case + alert severity 계약.
|
||||
- [[raw/branch-notes/feature-distributed-tracing-contract]] — W3C tracecontext 전파 + sampling 계약.
|
||||
- [[raw/blog-topics/w3c-traceparent-fork-activated-seam-2026-07-02]] — W3C traceparent seam 블로그 글감 raw seed
|
||||
- [[raw/branch-notes/feature-operational-runbook-contract]] — `runbook://` scheme + link-check 계약.
|
||||
- [[raw/blog-topics/runbook-coverage-junit-contract-test-2026-07-02]] — runbook coverage JUnit contract test 블로그 글감 raw seed.
|
||||
- [[raw/blog-topics/logback-layer1-secret-masking-json-vs-pattern-2026-06-14]] — Logback JSON vs pattern masking 블로그 글감 raw seed
|
||||
- [[raw/blog-topics/spring-async-taskdecorator-bounded-executor-saturation-shutdown-2026-06-13]] — async executor context/saturation/shutdown 블로그 글감 raw seed
|
||||
|
||||
## Cluster / 묶음
|
||||
|
||||
<!-- GENERATED: derived-blogs:start -->
|
||||
- [[wiki/blog/ca-tmpl-observability-log-metric-trace-runbook-2026-07-02]]
|
||||
<!-- GENERATED: derived-blogs:end -->
|
||||
@@ -0,0 +1,134 @@
|
||||
---
|
||||
title: ca-tmpl - Privacy / File / Domain Modeling 결정
|
||||
source_type: project
|
||||
status: verified
|
||||
confidence: high
|
||||
tags: [ca-tmpl, privacy, gdpr, file-upload, ddd, actually-implemented, locally-verified, documented-only]
|
||||
related_projects: [ca-tmpl]
|
||||
last_reviewed: 2026-07-02
|
||||
---
|
||||
|
||||
# ca-tmpl - Privacy / File / Domain Modeling 결정
|
||||
|
||||
> Layer: `wiki/projects/` — 내 프로젝트 사실. 일반 개념 / 공식 기준 / 트레이드오프는 [[wiki/concepts/privacy-file-domain-modeling]] 참조.
|
||||
|
||||
## 프로젝트 컨텍스트
|
||||
|
||||
**ca-tmpl skeleton** — 도메인 로직을 얹기 전 단계의 운영/보안/도메인 계약을 사전에 고정하기 위한 Spring Boot 기반 Clean Architecture 템플릿 프로젝트. 2026-07-02 기준 일부 privacy/domain guardrail은 코드화됐고, file handling/DSR/backup erasure는 여전히 계획 또는 문서 단계다.
|
||||
|
||||
본 문서는 Phase E Group G-J에서 결정된 3축 — **(1) Privacy / Retention**, **(2) File / Resource Handling**, **(3) Domain Modeling Guardrails** — 의 ca-tmpl 적용 결정사항을 정리한다.
|
||||
|
||||
핵심 결정값 요약:
|
||||
|
||||
- **Privacy**: 30/180/365일 3-tier log retention, HMAC-SHA-256 + 90일 salt rotation pseudonymization, DSR SLA 30일/14일(intake → execution), `is_sample` 컬럼 기반 sample 데이터 분리.
|
||||
- **File**: app 10MB / global 12MB / gateway 20MB 3-layer size limit, content-type allowlist 6종(image/jpeg, image/png, image/gif, application/pdf, text/plain, application/zip 등), temp orphan 1h cleanup sweeper, ICAP antivirus gateway 기본값.
|
||||
- **Domain Modeling**: VO private constructor + factory method, aggregate root mutator non-public(package-private/protected), domain layer logger ban(ArchUnit forbidden import), safe reason enum, invariant in constructor, Vernon Option A(ORM 외부 매핑) 채택.
|
||||
|
||||
## 실제 구현 내용 (`actually-implemented`)
|
||||
|
||||
- `adapter-identifier`의 `HmacUserPrincipalPseudonymizer`와 `app-bootstrap`의 `PseudonymizationConfig`, `PrivacySettings`가 user principal pseudonymization 기반을 제공한다.
|
||||
- `RequestLoggingFilter`가 raw principal이 아니라 pseudonymized principal을 MDC에 넣는 흐름을 갖는다.
|
||||
- `docs/registries/env-keys.yaml`, `secrets-classification.yaml`, `mdc-keys.yaml`, `capabilities.yaml`, `error-codes.yaml`에 privacy/file/domain 관련 registry row가 존재한다.
|
||||
- domain purity, logger ban, forbidden imports, aggregate boundary guard는 `CleanArchitectureTest` 계열과 domain/sample tests에서 일부 검증된다.
|
||||
- file upload handler, DSR workflow, backup cryptographic erasure, ICAP integration은 아직 구현되지 않았다.
|
||||
|
||||
## 로컬/dev 검증 (`locally-verified`)
|
||||
|
||||
- `./gradlew check` 통과(2026-07-02, `BUILD SUCCESSFUL`, 114 tasks).
|
||||
- `HmacUserPrincipalPseudonymizerTest`, `PseudonymizationConfigTest`, `PrivacySettingsTest`, `RequestLoggingFilterTest`가 pseudonymization/logging path를 검증한다.
|
||||
- `CleanArchitectureTest`와 domain/sample tests가 domain forbidden import와 invariant 일부를 검증한다.
|
||||
- file upload, DSR, backup erasure는 로컬 검증되지 않았다.
|
||||
|
||||
## 운영 검증 (`prod-verified`)
|
||||
|
||||
**없음.** ca-tmpl skeleton 자체가 운영 환경에 배포된 적 없음.
|
||||
|
||||
## 문서/계획만 존재 (`documented-only` / `planned`)
|
||||
|
||||
다음 항목은 구현된 privacy/domain guardrail과 아직 문서/계획으로 남은 file/DSR/backup 영역을 분리한다.
|
||||
|
||||
### Privacy (canonical §19)
|
||||
|
||||
- log retention 30/180/365일 3-tier 분류 (info / warn-business / audit-security)
|
||||
- HMAC-SHA-256 + 90일 salt rotation pseudonymization (PII column 대상)
|
||||
- DSR SLA: intake → identity verification → execution 30일, internal execution 14일
|
||||
- `is_sample` boolean column으로 sample / production 데이터 분리, retention job exemption
|
||||
- backup retention: cryptographic erase 방식 채택 의도(per-principal envelope key는 **미결정**, 후속 보강 후보)
|
||||
- per-principal envelope key 패턴 선택 (2026-05-22) — **needs-confirmation**, Phase C2 결정 보류. (a) per-principal CMK / (b) per-principal DEK + master CMK envelope / (c) tenant-level CMK 3종 후보. 근거: [[raw/official-docs/gdpr-cryptographic-erasure-envelope-key-pattern]]. HMAC + 90d salt rotation은 forward security만 제공하므로 backup의 Art.17 단건 erasure에는 별도 envelope key 구조가 필요함.
|
||||
|
||||
### File (canonical §29 H, branch-notes)
|
||||
|
||||
- size limit 3-layer: Spring multipart 10MB (app envelope error) / reverse proxy 12MB / gateway WAF 20MB raw 413
|
||||
- content-type allowlist 6종 + endpoint별 재검증
|
||||
- temp file > 1h not closed → orphan 판정, sweeper가 삭제 (tus resumable session과 별도 threshold 필요성은 문서화만)
|
||||
- ICAP gateway antivirus(ClamAV 등) 기본값, in-app daemon 채택 X
|
||||
- direct S3 presigned URL은 **검토만 완료**, 채택 미정
|
||||
|
||||
### Domain Modeling (canonical §29 I-J, branch-notes)
|
||||
|
||||
- Value Object: private constructor + static factory method, invariant in constructor 강제
|
||||
- Aggregate root: mutator를 public 금지(package-private/protected만 허용)
|
||||
- Domain layer logger ban: `org.slf4j.Logger`, `java.util.logging.*`, HTTP type, `@Entity`, `@Service` 등 forbidden import — ArchUnit 테스트로 강제할 계획
|
||||
- safe reason enum (도메인 거부 사유 noun 형태 enum)
|
||||
- Vernon Option A(ORM 매핑을 domain 외부 mapper/persistence layer에서 수행) 채택, Option B(JPA direct annotation in domain) 거절
|
||||
- CQRS / event sourcing **미채택**, "domain event = transport-free fact" 정의만 차용
|
||||
|
||||
### 검토했으나 채택하지 않은 대안 (concept 참조)
|
||||
|
||||
3 sub-topic 각각 5종 이상의 대안을 검토 — 자세한 trade-off는 [[wiki/concepts/privacy-file-domain-modeling]] §"한계 / 주의점".
|
||||
|
||||
- Privacy: PII detection SaaS(AWS Macie / OneTrust / TrustArc) — vendor 종속으로 skeleton 기본값 부적절.
|
||||
- File: in-app ClamAV daemon, direct S3 presigned URL only, tus resumable 표준 채택, magic-byte sniffing only — 각각 trade-off로 인해 채택 보류.
|
||||
- Domain: Anemic model, Pure DDD aggregates(over-engineering), Event sourcing, JPA direct annotation(Option B), Functional domain modeling(Scala/F#) — 모두 검토 후 거절.
|
||||
|
||||
## 면접에서 말할 수 있는 범위
|
||||
|
||||
### 자신 있게 답할 수 있는 질문
|
||||
|
||||
- GDPR Art.17 backup erasure 처리 방식과 cryptographic erase의 의미
|
||||
- HMAC + salt rotation의 의미와 anonymization이 아닌 이유 (brute-force 가능 input space에서 tokenization 우위)
|
||||
- file size 3-layer(app / proxy / gateway)의 defense-in-depth 의미와 trade-off
|
||||
- ICAP gateway의 한계 (HTTPS E2E TLS 환경에서 평문 검사 불가)
|
||||
- VO private constructor + factory method 이유 (invariant 보장, 잘못된 인스턴스 생성 차단)
|
||||
- ORM 외부 매핑(Vernon Option A) vs JPA direct annotation(Option B) trade-off
|
||||
|
||||
### 적당히 답할 수 있는 질문
|
||||
|
||||
- Vernon Option A vs B의 코드량 / 학습 비용 trade-off 비교
|
||||
- NIST SP 800-88 cryptographic erase의 backup 적용 메커니즘 (per-principal envelope key 구조 필요성 정도까지)
|
||||
- DSR 운영 패턴 일반론 (intake → verification → scope → execution → audit)
|
||||
|
||||
### 답하면 안 되는 질문 (모른다고 해야 함)
|
||||
|
||||
- "GDPR DSR 요청을 실제로 처리해본 경험이 있는가?" → **없음.** ca-tmpl은 skeleton 단계, 운영 데이터 없음.
|
||||
- "ICAP scan을 운영 환경에서 운영해본 경험은?" → **없음.** 설계 / 문서 단계.
|
||||
- "domain event sourcing을 도입한 경험은?" → **없음.** ca-tmpl은 event sourcing **미채택**, transport-free fact 정의만 차용.
|
||||
- "per-principal envelope key를 적용한 경험은?" → **없음.** 후속 보강 후보로 문서화만 됨.
|
||||
|
||||
## 과장 금지 지점
|
||||
|
||||
외부 설명(면접 / 이력서 / README / 블로그)에서 사실보다 부풀려지기 쉬운 표현들.
|
||||
|
||||
- **"HMAC + salt rotation으로 anonymization을 적용했다"** → **부정확 (가장 흔한 과장)**. ENISA / IAPP 기준 명확히 **pseudonymization**이지 anonymization이 아니다. brute-force 가능 input(휴대폰 11자리 등)에서는 tokenization이 우위인 구간이 존재하며, 무엇보다 HMAC + salt rotation은 **forward security만** 제공한다 — rotation 이전에 기록된 backup 안의 hash는 그대로 잔존하므로 GDPR Art.17 backup erasure 수단으로 사용할 수 없다. backup 단건 erasure는 별도의 per-principal envelope key 구조(NIST SP 800-88 § 2.5 CE)가 필요하며 ca-tmpl은 **미결정** 상태이다.
|
||||
- **"ICAP antivirus gateway로 모든 위협을 막는다"** → 부정확. HTTPS end-to-end TLS 환경에서 gateway가 payload를 평문으로 보지 못하는 한계가 있음. post-upload async scan 보완 필요.
|
||||
- **"ca-tmpl은 pure DDD 기반이다"** → 부정확. Vernon Option A(ORM 외부 매핑)만 차용했으며, CQRS / event sourcing은 미채택. "transport-free domain event 정의만 차용"이 정확한 표현.
|
||||
- **"per-principal envelope key 구조를 적용해 GDPR Art.17 backup erasure를 완전 처리한다"** → 부정확. 후속 보강 후보로 **미결정** 상태. 현재는 cryptographic erase 의도만 문서화됨.
|
||||
- **"DSR SLA 30일은 GDPR 요구치다"** → 부정확. GDPR Art.12는 "원칙적으로 1개월(연장 시 +2개월)"이며 30/14일은 ca-tmpl 내부 운영 결정값.
|
||||
- **"retention job / file upload handler / DSR workflow를 구현했다"** → 거짓. pseudonymization/logging guard와 domain guardrail 일부는 구현됐지만, 이 운영 기능들은 문서/계획 단계다.
|
||||
|
||||
## 관련 개념
|
||||
|
||||
- [[wiki/concepts/privacy-file-domain-modeling]]
|
||||
|
||||
## Sources
|
||||
|
||||
- [[raw/project-notes/ca-skeleton-operational-contract]] §19 Domain Application Readiness Contract, §29 G-J 외부 근거 / 대안 조사
|
||||
- [[raw/branch-notes/feature-data-retention-privacy-contract]] — log retention by profile, HMAC pseudonymization, DSR SLA, backup retention 결정
|
||||
- [[raw/branch-notes/feature-file-resource-handling-contract]] — upload size 3-layer, content-type allowlist, temp file cleanup, antivirus position 결정
|
||||
- [[raw/branch-notes/feature-domain-modeling-guardrails]] — VO private constructor, aggregate mutator non-public, domain forbidden import 결정
|
||||
|
||||
## Cluster / 묶음
|
||||
|
||||
<!-- GENERATED: derived-blogs:start -->
|
||||
- [[wiki/blog/ca-tmpl-privacy-file-domain-modeling-2026-07-02]]
|
||||
<!-- GENERATED: derived-blogs:end -->
|
||||
@@ -0,0 +1,156 @@
|
||||
---
|
||||
title: ca-tmpl - Resource Identifier (ULID) 결정
|
||||
source_type: project
|
||||
status: verified
|
||||
confidence: high
|
||||
tags: [ca-skeleton, resource-identifier, ulid, actually-implemented]
|
||||
related_projects: [ca-skeleton, ca-tmpl]
|
||||
last_reviewed: 2026-07-02
|
||||
---
|
||||
|
||||
# ca-tmpl - Resource Identifier (ULID) 결정
|
||||
|
||||
> Layer: `wiki/projects/` — 내 프로젝트 사실. 일반 개념(ULID vs UUIDv7 vs UUIDv4 vs Snowflake tradeoff)은 [[wiki/concepts/resource-identifier-format]] 참조.
|
||||
|
||||
## 프로젝트 컨텍스트
|
||||
|
||||
- **프로젝트**: ca-tmpl — Clean Architecture 기반 백엔드 skeleton 템플릿.
|
||||
- **목표**: resource ID 형식을 **ULID** (26-char Crockford base32, time-ordered) 로 못박고, ID 가 URL / log / DB primary key / cache key / idempotency / multi-tenancy / privacy 에 미치는 계약을 한 곳에서 결정. ID 형식은 *한 번 노출되면 되돌리기 어렵다* (`/v1/worklogs/<id>` 가 client SDK + log + DB schema + cache key + FK 에 박힘) 는 인식에서 skeleton default 를 future-safe 한 선택으로 고정하는 것이 동기.
|
||||
- **결정 SSOT**: [[raw/branch-notes/feature-resource-identifier-contract]] (D1~D19 + Decision Evidence Map). 본 문서는 그 중 *실제 코드로 구현된* 사실만 추출한다.
|
||||
- **진행 단계**: **코드 구현 + 로컬 검증 완료.** `feature-resource-identifier-contract` 브랜치에서 domain VO + port, ULID adapter, persistence mapping, web serializer, ArchUnit rule, 단위 테스트까지 작성되어 코드 베이스에 존재한다. 운영 배포 / 실 DB 통합 테스트 / 측정값은 없다.
|
||||
- **이 브랜치가 신설한 모듈**: `adapter-identifier` (비-IO 인프라 능력 어댑터). `feature-skeleton-package-blueprint-contract` 가 9번째 모듈로 OUT_OF_BRANCH_SCOPE 표시했던 영역이 본 브랜치의 산출물이다.
|
||||
|
||||
## Ground-truth 대조 (2026-06-04, ca-tmpl @c36b764 "ULID 리소스 식별자 계약 구현 및 adapter-identifier 모듈 생성")
|
||||
|
||||
`/home/donghyeon/workspace/ca-tmpl` 코드를 직접 읽어 검증한 사실 (현재 checkout HEAD = `db61075`, 본 브랜치 구현 커밋 `c36b764` 는 history 에 존재하며 식별자 코드는 HEAD 에 그대로 잔존):
|
||||
|
||||
- 패키지 root 는 `dev.caskeleton.*`.
|
||||
- **신규 모듈 `adapter-identifier`** 실재 — `src/adapter-identifier/` (Gradle `settings.gradle:13 include 'adapter-identifier'`). `domain-core` 에만 의존하고 `ulid-creator:5.2.3` 를 implementation 으로 선언.
|
||||
- domain port + marker (`ResourceId`, `IdFactory`) 는 `src/domain-core/.../domain/identifier/` 에 실재.
|
||||
- sample 도메인 VO + port + adapter (`WorkLogId`, `WorkLogIdFactory`, `UlidWorkLogIdFactory`) 는 `sample-portfolio` 에 실재.
|
||||
- ArchUnit rule 4개 (`no_long_id_pk` / `no_uuid_random_in_controller` / `no_math_random_for_id` / `no_varchar_255_for_id_column`) + `identifier_adapter_does_not_depend_on_other_adapters_or_bootstrap` 는 `src/app-bootstrap/.../architecture/CleanArchitectureTest.java` 에 실재. 5번째 후보 `no_find_by_id_without_tenant` 는 코드에 **없음** (브랜치 결정대로 `feature-tenant-context-policy` 로 이관).
|
||||
- `./gradlew :adapter-identifier:test :sample-portfolio:test :app-bootstrap:test --tests '*CleanArchitectureTest' --tests '*ArchitectureViolationFixtureTest' --tests '*WorkLogId*' --tests '*UlidCodec*' --tests '*UlidWorkLogIdFactory*' --tests '*WorkLogIdSerializer*'` → BUILD SUCCESSFUL (2026-06-04 재실행, `src/` working dir 기준).
|
||||
|
||||
## 실제 구현 내용 (`actually-implemented`)
|
||||
|
||||
ca-tmpl 코드에서 직접 확인한 산출물:
|
||||
|
||||
**domain-core (재사용 가능 port + marker, `dev.caskeleton.domain.identifier.*`)**
|
||||
|
||||
- `ResourceId.java` — `ResourceId<SELF extends ResourceId<SELF>>` marker interface. `String value()` (canonical 26-char uppercase Crockford base32 ULID) 1 메서드. **의도적으로 `non-sealed`** — `permits WorkLogId` 를 쓰면 `domain-core` 가 `sample-portfolio` 를 import 하게 되어 모듈 의존 규칙 위반. closed-set 보장은 `no_long_id_pk` ArchUnit rule (빌드타임) 로 대체 (Javadoc 에 사유 명시).
|
||||
- `IdFactory.java` — `IdFactory<T extends ResourceId<?>>` domain port. `T newId()` 1 메서드. ID minting *책임* 은 도메인 port 에, 실제 *생성 행위* 는 infrastructure adapter 에 둔다 (D4/D5).
|
||||
|
||||
**sample-portfolio domain (`dev.caskeleton.sample.portfolio.domain.worklog.*`)**
|
||||
|
||||
- `WorkLogId.java` — `record WorkLogId(String value) implements ResourceId<WorkLogId>`. compact constructor 에서 `^[0-9A-HJKMNP-TV-Z]{26}$` regex 로 검증 (I/L/O/U 제외 Crockford base32). 도메인 안에 ULID 라이브러리 의존 없음 (canonical form 검증만).
|
||||
- `WorkLogIdFactory.java` — `interface WorkLogIdFactory extends IdFactory<WorkLogId>` (type-specific port specialization).
|
||||
- `WorkLog.java` — `create(WorkLogId id, ...)` / `rehydrate(WorkLogId id, ...)`. 도메인이 자기 ID 를 `UUID.randomUUID()` 로 self-mint 하지 않음 (id 는 factory 가 만들어 use case 가 주입, D4/D5).
|
||||
|
||||
**adapter-identifier (신규 모듈, `dev.caskeleton.adapter.identifier.*`)**
|
||||
|
||||
- `UlidCodec.java` — production-level, 도메인 무관 ULID 변환 유틸 (final, private ctor). `normalize(String)` (D3: case-insensitive 입력 → canonical uppercase 26-char, `Ulid.from(in.toUpperCase(Locale.ROOT)).toString()`), `toUuid(String)`, `fromUuid(UUID)` (D10: ULID ↔ 128-bit UUID).
|
||||
- `package-info.java` — 이 모듈이 *non-IO 인프라 능력 어댑터* 임을 문서화. `adapter-outbound` ("external HTTP/messaging/cache/notifications") 와 구분되는 이유 = ULID 라이브러리 래퍼는 외부 시스템 통합점이 아니라 인프라 능력이라는 것.
|
||||
- `build.gradle` — `domain-core` + `ulid-creator:5.2.3` 만 의존.
|
||||
|
||||
**sample-portfolio adapter (ULID 생성/직렬화/영속화)**
|
||||
|
||||
- `adapter/identifier/UlidWorkLogIdFactory.java` — `@Component implements WorkLogIdFactory`. `WorkLogId.of(UlidCreator.getMonotonicUlid().toString())`. monotonic factory (동일 ms 내 단조 증가, ULID-C5) + 내부 `SecureRandom` (D9). 주석에 "이 sample 에서 `UlidCreator` 직접 호출 허용은 여기뿐" 명시.
|
||||
- `adapter/persistence/entity/WorkLogEntity.java` — `@Id @Column(name="id", columnDefinition="uuid", nullable=false, updatable=false) @JdbcTypeCode(SqlTypes.UUID) private UUID id`. PostgreSQL 16 native `uuid` (16-byte binary), `varchar(26/36)` 아님 (D10). tenant 컬럼은 주석으로만 (deferred to `feature-tenant-context-policy`).
|
||||
- `adapter/persistence/mapper/WorkLogPersistenceMapper.java` — `Ulid.from(id.value()).toUuid()` / `Ulid.from(uuid).toString()` 로 ULID↔UUID 변환. persistence 가 `adapter-outbound`(및 `UlidCodec`) 에 의존하지 못하는 boundary rule 때문에 `Ulid` 를 직접 사용 (주석 명시).
|
||||
- `adapter/web/json/WorkLogIdSerializer.java` — `@JsonComponent extends JsonSerializer<WorkLogId>`. record 기본 `{"value":"..."}` 대신 bare ULID 문자열로 직렬화 (D6 NO typed prefix, §5).
|
||||
|
||||
**app-bootstrap ArchUnit fitness functions** (`architecture/CleanArchitectureTest.java`, D17 결정 SSOT = 본 브랜치):
|
||||
|
||||
- `no_long_id_pk` — `..domain..` 패키지의 `id` 필드는 `ResourceId` 구현체여야 함 (`Long`/`int` 금지). JPA entity (`..adapter.persistence..`) 의 `@Id UUID id` 는 D10 정합으로 검사 대상 제외.
|
||||
- `no_uuid_random_in_controller` — `..adapter.web..controller..` + `..application..` 가 `UUID.randomUUID()` / `com.github.f4b6a3.ulid.UlidCreator` 직접 호출 금지 (factory 주입 강제). web filter 의 trace-id 생성은 의도적으로 scope 밖 (D18).
|
||||
- `no_math_random_for_id` — `dev.caskeleton..` 전역에서 `Math.random()` 금지 (CSPRNG 아님, D9).
|
||||
- `no_varchar_255_for_id_column` — `@Column` 매핑된 `id` 필드는 명시적 `columnDefinition`(예: `"uuid"`) 또는 비-default length 의무. `haveExplicitColumnLength()` custom `ArchCondition` 으로 검사 (`columnDefinition` 비어있지 않거나 `length != 255`).
|
||||
- `identifier_adapter_does_not_depend_on_other_adapters_or_bootstrap` — `adapter-identifier` 가 sibling adapter / persistence / bootstrap 에 손대지 못하도록 격리 (§4 taxonomy).
|
||||
|
||||
## 로컬/dev 검증 (`locally-verified`)
|
||||
|
||||
- 단위 테스트 PASS (2026-06-04 재실행, BUILD SUCCESSFUL):
|
||||
- `WorkLogIdTest` — regex 검증 (valid / invalid / I·L·O·U 포함 거부).
|
||||
- `UlidCodecTest` — `normalize`/`toUuid`/`fromUuid` round-trip + case-insensitive 입력.
|
||||
- `UlidWorkLogIdFactoryTest` — monotonic 생성, 형식 적합.
|
||||
- `WorkLogIdSerializerTest` — bare ULID 문자열 직렬화.
|
||||
- `WorkLogPersistenceMapperTest`, `WorkLogRepositoryAdapterTest`, `WorkLogControllerWireTest` — ULID↔UUID 매핑 + D3 정규화 wire 경로.
|
||||
- ArchUnit fitness function PASS: `CleanArchitectureTest` (위 5개 rule) + `ArchitectureViolationFixtureTest` (의도된 위반 fixture 를 실제로 잡아냄).
|
||||
- 검증 범위는 **JVM 단위 테스트 + 정적 분석까지**. 실 PostgreSQL 16 connection 으로 `uuid` 컬럼 insert/index 동작을 검증한 통합 테스트는 **없음** (아래 planned).
|
||||
|
||||
## 운영 검증 (`prod-verified`)
|
||||
|
||||
**없음.** 운영 환경에 배포된 적이 없다. 측정값 / 인시던트 / 릴리즈 노트 / 벤치마크 어느 것도 없다.
|
||||
|
||||
## 문서/계획만 존재 (`documented-only` / `planned`)
|
||||
|
||||
다음은 설계/문서/위임 상태이며 **면접에서 "구현했다 / 검증했다"고 말하면 안 된다**.
|
||||
|
||||
- **CUID2 override (D7)**: privacy-sensitive 도메인용 timestamp-leak-free 대안. 코드에 없음 (`documented-only`).
|
||||
- **constant-time 비교 미적용 (D9)**: 공개 resource id 는 표준 record `equals` 사용. constant-time 비교는 *비밀값* 영역이라 의도적으로 적용 안 함 (`feature-security-operational-baseline` SSOT).
|
||||
- **multi-tenancy ID 정합 (D13)**: ID 자체에 tenant 인코딩 거부만 결정. `TenantId` VO / `tenant` 테이블 / composite index / `findByIdAndTenant` / tenant-scoped ArchUnit rule (`no_find_by_id_without_tenant`) 은 코드에 **없음** — `feature-tenant-context-policy` (예정) 위임. `WorkLogEntity` 의 tenant 컬럼은 주석으로만 존재 (`documented-only`).
|
||||
- **Idempotency-Key 처리 (D14)**: resource ID(ULID) 와 idempotency key(UUID v4 client-generated) 의 *형식 분리만* 명시. TTL 저장소 / fingerprint 비교 / 422 응답은 `feature-rate-limit-idempotency-contract` 위임 (`planned`).
|
||||
- **log scrubber `UlidLogScrubber` (D8/§7)**: user-linked ID redaction 코드 미작성. `feature-log-management-contract` 위임 (`documented-only`).
|
||||
- **PostgreSQL 16 `uuid` index locality 벤치마크 (D10)**: ULID time-ordered insert 의 BTREE page split 완화 정량 측정 없음 (`planned`, UNSUPPORTED_IMPL_DECISION).
|
||||
- **dual column (internal BIGINT + external ULID) override (D11)**: skeleton 은 external-only. dual 은 prod-grade 도메인 권고 수준 (`documented-only`).
|
||||
- **OpenAPI 3.1 `pattern` schema (§5)**: 브랜치 노트의 reference fragment. 실제 generated OpenAPI 문서로의 반영은 본 문서 추출 범위에서 코드로 확인하지 않음 (`documented-only`).
|
||||
|
||||
## 면접에서 말할 수 있는 범위
|
||||
|
||||
### 자신 있게 답할 수 있는 질문
|
||||
|
||||
- 왜 skeleton default resource ID 로 **ULID** 를 골랐는가 — UUID v4(DB B-tree 단편화), Snowflake(worker_id 외부 조율), sequential(enumeration) 거부 + UUID v7 은 Java 21 `java.util.UUID` native 미지원이라 3rd-party 의존이면 ULID 가 URL UX(26 vs 36자) + 라이브러리 성숙도 우위. (실제 `WorkLogId` record + `UlidWorkLogIdFactory` 로 구현.)
|
||||
- ID 생성 책임을 어느 계층에 뒀는가 — domain port (`IdFactory`/`WorkLogIdFactory`) 가 책임을 소유하고, infrastructure adapter (`UlidWorkLogIdFactory`) 가 실제 생성, application use case 가 주입·orchestration. 도메인이 `UUID.randomUUID()` 로 self-mint 하지 않도록 ArchUnit 으로 강제.
|
||||
- ULID 를 DB 에 어떻게 저장했는가 — PostgreSQL 16 native `uuid` 타입(16-byte binary), `@JdbcTypeCode(SqlTypes.UUID)` + `columnDefinition="uuid"`, `Ulid.from(...).toUuid()` 변환. `varchar(26/36)` 를 거부한 이유.
|
||||
- ArchUnit 4개 rule (`no_long_id_pk` / `no_uuid_random_in_controller` / `no_math_random_for_id` / `no_varchar_255_for_id_column`) 로 어떤 anti-pattern 을 빌드타임에 차단했는가, 위반 fixture 로 rule 동작을 보증한 방법.
|
||||
- `adapter-identifier` 모듈을 왜 신설했는가 — ULID 라이브러리 래퍼는 외부 시스템 통합(`adapter-outbound`)이 아니라 *non-IO 인프라 능력*이라 의미가 다름. 모듈 격리도 ArchUnit 으로 강제.
|
||||
- `ResourceId` 를 왜 `sealed` 가 아닌 `non-sealed` 로 뒀는가 — `permits WorkLogId` 가 `domain-core` → `sample-portfolio` 역의존을 만들기 때문. closed-set 보장은 `no_long_id_pk` 로 대체.
|
||||
- Crockford base32 가 I/L/O/U 를 제외하는 이유 + 그래서 ULID 의 URL/case 정책 (canonical uppercase 출력 + case-insensitive 입력 정규화).
|
||||
|
||||
### 적당히 답할 수 있는 질문
|
||||
|
||||
- ULID vs UUID v7 vs Snowflake 의 일반적 trade-off (정렬성, timestamp leak, 길이, 조율 부담). (개념 수준 — [[wiki/concepts/resource-identifier-format]].)
|
||||
- time-ordered ID 가 B-tree index locality 에 유리한 *원리* (Percona MySQL 벤치마크는 parallel evidence 로만 인용 — PostgreSQL HEAP/MVCC 에 직접 적용 불가).
|
||||
- timestamp leak 가 *user-facing* ID 에서 실질 문제인 이유 + CUID2 같은 완화 옵션.
|
||||
|
||||
### 답하면 안 되는 질문 (모른다고 해야 함)
|
||||
|
||||
- "PostgreSQL 에서 ULID time-ordered insert 가 random UUID 대비 page split 을 줄이는 걸 측정했는가?" → **측정 안 함. 벤치마크 없음.**
|
||||
- "실 DB 로 `uuid` 컬럼 insert/조회 통합 테스트를 했는가?" → **안 함. JVM 단위 테스트 + 정적 분석까지.**
|
||||
- "운영에서 인시던트나 성능 사례가 있었는가?" → **운영 배포 없음.**
|
||||
- "multi-tenant 격리(`WHERE tenant_id = X AND id = Y`)를 구현했는가?" → **안 함. ID 에 tenant 인코딩 거부만 결정, 모델은 `feature-tenant-context-policy` 위임.**
|
||||
- "Idempotency-Key 처리를 구현했는가?" → **형식 분리만 명시. 처리는 `feature-rate-limit-idempotency-contract` 위임.**
|
||||
|
||||
## 과장 금지 지점
|
||||
|
||||
- **"운영에서 검증했다 / prod 에서 돌고 있다" → 금지.** 로컬 단위 테스트 + 정적 분석까지가 검증 범위.
|
||||
- **"ULID 가 PostgreSQL index 성능을 개선하는 걸 측정했다" → 금지.** Percona 벤치마크는 MySQL InnoDB 기준 *parallel evidence* 일 뿐, PostgreSQL 측정값 없음.
|
||||
- **"multi-tenancy 를 구현했다" → 금지.** ID 형식이 tenant 와 충돌하지 않도록 보장만 했고, tenant 모델은 미구현.
|
||||
- **"ULID 가 무조건 UUID 보다 우월하다" → 금지.** timestamp leak(privacy), 비표준(IETF 아님), 라이브러리 의존이라는 trade-off 존재. UUID v7 native 가 되는 stack 이면 결정이 달라질 수 있음.
|
||||
- **"typed prefix(`tk_`)를 안 쓴 게 정답이다" → 단정 금지.** Stripe 는 prefix 를 쓴다 — skeleton 의 bare ULID 는 lock-in 회피를 택한 *하나의* 선택.
|
||||
|
||||
### Blog-topic ingest: resource identifier 묶음 (2026-07-02)
|
||||
|
||||
아래 raw seed들은 resource identifier canonical에 연결했다.
|
||||
|
||||
- [[raw/blog-topics/ulid-crockford-base32-excluded-letters-2026-06-01]]: ULID의 Crockford base32 charset과 예시 값 검증을 다룬다. **주의**: "대충 26자 영숫자"가 아니라 동일 parser로 fixture/example을 교차검증해야 한다.
|
||||
- [[raw/blog-topics/identifier-governance-rule-scoping-by-id-kind-2026-06-01]]: resource id, trace id, session id, idempotency key, api key처럼 ID 종류별 생성 주체·형식·수명이 다르므로 ArchUnit governance rule도 ID kind별로 scope해야 한다는 글감이다. **주의**: 모든 `UUID.randomUUID()` 금지가 항상 옳다고 쓰지 않는다.
|
||||
|
||||
## 관련 개념
|
||||
|
||||
- [[wiki/concepts/resource-identifier-format]] — ULID vs UUIDv7 vs UUIDv4 vs Snowflake 일반 trade-off, sortability, timestamp leakage, Crockford base32.
|
||||
|
||||
## Sources
|
||||
|
||||
- [[raw/branch-notes/feature-resource-identifier-contract]] — D1~D19 + Decision Evidence Map + 구현 결과(2026-06-01). 본 문서의 결정 SSOT.
|
||||
- [[raw/blog-topics/ulid-crockford-base32-excluded-letters-2026-06-01]] — ULID/Crockford base32 예시 검증 블로그 글감 raw seed
|
||||
- [[raw/blog-topics/identifier-governance-rule-scoping-by-id-kind-2026-06-01]] — identifier governance scope 블로그 글감 raw seed
|
||||
- [[raw/project-notes/ca-skeleton-operational-contract]] — §17 Sample Domain Fixture (`WorkLogId`), §22 Sample-portfolio Contract Matrix, §34 Stack Commitment (Java 21 / Spring Boot 3.5.14 / PostgreSQL 16 / archunit-junit5 1.3.0).
|
||||
- [[raw/branch-notes/feature-skeleton-package-blueprint-contract]] — `adapter-identifier` 를 9번째 모듈로 OUT_OF_BRANCH_SCOPE 표시 (본 브랜치가 그 모듈을 신설).
|
||||
- ca-tmpl @c36b764 코드 (ground-truth): `src/domain-core/.../domain/identifier/{ResourceId,IdFactory}.java`, `src/adapter-identifier/.../adapter/identifier/{UlidCodec,package-info}.java`, `src/sample-portfolio/.../domain/worklog/{WorkLogId,WorkLogIdFactory}.java`, `.../adapter/identifier/UlidWorkLogIdFactory.java`, `.../adapter/persistence/entity/WorkLogEntity.java`, `.../adapter/persistence/mapper/WorkLogPersistenceMapper.java`, `.../adapter/web/json/WorkLogIdSerializer.java`, `src/app-bootstrap/.../architecture/CleanArchitectureTest.java`.
|
||||
|
||||
## Cluster / 묶음
|
||||
|
||||
<!-- GENERATED: derived-blogs:start -->
|
||||
- [[wiki/blog/ca-tmpl-resource-identifier-format-2026-07-02]]
|
||||
<!-- GENERATED: derived-blogs:end -->
|
||||
@@ -0,0 +1,149 @@
|
||||
---
|
||||
title: ca-tmpl - Runtime / Container / Health / Migration 결정
|
||||
source_type: project
|
||||
status: verified
|
||||
confidence: high
|
||||
tags: [ca-tmpl, runtime, container, kubernetes, flyway, actually-implemented, locally-verified]
|
||||
related_projects: [ca-tmpl]
|
||||
last_reviewed: 2026-07-02
|
||||
---
|
||||
|
||||
# ca-tmpl - Runtime / Container / Health / Migration 결정
|
||||
|
||||
> Layer: `wiki/projects/` — 내 프로젝트 사실. 일반 개념은 [[wiki/concepts/runtime-container-health-migration]] 참조.
|
||||
|
||||
## 프로젝트 컨텍스트
|
||||
|
||||
ca-tmpl은 Clean Architecture 기반 백엔드 skeleton 프로젝트다. **운영 계약(operational contract)** 단계에서 JVM 서비스의 runtime baseline을 세 축으로 묶어 단일 운영 계약으로 통합하는 결정을 했다.
|
||||
|
||||
- **Container**: Eclipse Temurin (Adoptium) **JRE slim** + JVM ergonomics (`-XX:MaxRAMPercentage=75`, `-XX:+UseContainerSupport`, `-XX:+ExitOnOutOfMemoryError`) + **UTC / UTF-8** locale 고정.
|
||||
- **Health**: Kubernetes Probes 3종 (**liveness / readiness / startup**) 분리 + Spring Boot Actuator Health Groups + **Required / Optional Dependency Matrix**.
|
||||
- **Migration**: Flyway forward-only migration을 **readiness-gated**로 실행 + `repair` / `baselineOnMigrate` / `outOfOrder` 모두 **prod forbidden** + 표준 startup **exit code 78 / 70 / 71 / 72** 매핑.
|
||||
- **Graceful shutdown budget**: app **20s** + preStop **5s** + terminationGracePeriodSeconds **35s** (10s margin).
|
||||
|
||||
현재 진행 상태:
|
||||
|
||||
- **C2 구현 + 로컬 검증 완료** — `src/Dockerfile`, runtime safety/startup validators, Actuator health group contract, Flyway prod safety guard, startup exit-code mapping, graceful shutdown settings가 코드화되어 있다. 2026-07-02 `./gradlew check` 통과로 로컬 검증했다. Kubernetes manifest와 운영 rolling update 실측은 없다.
|
||||
|
||||
문서/설계 산출물만 존재하며, 코드/검증/측정은 전무하다.
|
||||
|
||||
## 실제 구현 내용 (`actually-implemented`)
|
||||
|
||||
- `src/Dockerfile`과 runtime settings가 존재한다.
|
||||
- `app-bootstrap`의 `RuntimeSafetyConfig`, `RuntimeSafetySettings`, `RuntimeNumericBoundsValidator`, `OpenInViewSafetyValidator`, `HikariPoolConstraintValidator`가 startup/runtime guard를 구성한다.
|
||||
- `MigrationStartupConfig`, `MigrationStartupRunner`, `FlywayProdSafetyValidator`, `StartupFailureException`, `StartupErrorCode`가 migration readiness-gate와 exit code mapping을 구성한다.
|
||||
- `adapter-web`의 `HealthcheckController`와 `app-bootstrap` health group contract가 liveness/readiness/startup 구분을 검증한다.
|
||||
|
||||
## 로컬/dev 검증 (`locally-verified`)
|
||||
|
||||
- `./gradlew check` 통과(2026-07-02, `BUILD SUCCESSFUL`, 114 tasks).
|
||||
- `RuntimeHealthLifecycleContractTest`가 liveness/readiness/startup group membership과 readiness-vs-liveness 분리를 검증한다.
|
||||
- `FlywayProdSafetyValidatorTest`, `MigrationStartupRunnerTest`, `RequiredEnvironmentValidatorTest`, `StartupErrorCodeTest`, `StartupFailureExceptionTest`가 migration/startup failure contract와 exit code를 검증한다.
|
||||
- `ContainerRuntimeOomContractTest`, `OperationalContractRuntimeTest`, `RuntimeNumericBoundsValidatorTest`, `OpenInViewSafetyValidatorTest`, `HikariPoolConstraintValidatorTest`가 runtime/container/startup guard를 검증한다.
|
||||
|
||||
## 운영 검증 (`prod-verified`)
|
||||
|
||||
**없음.** 운영 환경 검증 없음. K8s rolling update 동작, graceful shutdown 실측, cold start latency, migration 실패 복구 모두 없음.
|
||||
|
||||
## 문서/계획만 존재 (`documented-only` / `planned`)
|
||||
|
||||
운영 계약 문서(canonical §15 Runtime / Lifecycle Contract, §29 G-D)와 3 branch-note에 다음이 **설계 수준**으로만 기록되어 있다.
|
||||
|
||||
### Container (`actually-implemented` / `locally-verified`)
|
||||
|
||||
- Base image: **Eclipse Temurin JRE slim** 채택 (distroless / alpine+musl / GraalVM native 대안 모두 검토 후 보류).
|
||||
- JVM ergonomics: `-XX:+UseContainerSupport` (JDK 10+ default 명시) + `-XX:MaxRAMPercentage=75` + `-XX:+ExitOnOutOfMemoryError` + `-XX:HeapDumpPath`.
|
||||
- Locale: **UTC / UTF-8** 고정 (env `TZ=UTC`, `LANG=C.UTF-8`).
|
||||
- 대안 검토: distroless (보안 surface 축소 vs 디버깅 손실), alpine+musl (image 크기 vs glibc 호환성 risk), GraalVM native-image (cold start vs reflection/peak throughput 손실, hybrid 사례) — branch-note `feature-container-runtime-contract`.
|
||||
|
||||
### Health (`actually-implemented` / `locally-verified`)
|
||||
|
||||
- K8s Probes **3 endpoint 분리**: `/livez`, `/readyz`, `/startupz` (or Actuator `/actuator/health/{liveness,readiness}` + startup variant).
|
||||
- Spring Boot Actuator Health Groups로 endpoint별 HealthIndicator set 분리.
|
||||
- **Required / Optional Dependency Matrix** — DB·broker는 readiness 필수, 외부 cache는 optional 등 dependency 범위 명시.
|
||||
- 대안 검토: single `/health` (legacy, restart loop risk), custom HealthIndicator only (default readiness 외부 dependency 미포함), Istio mesh-based health (sidecar/app 구분 모호) — branch-note `feature-runtime-health-lifecycle-contract`.
|
||||
|
||||
### Migration (`actually-implemented` / `locally-verified`)
|
||||
|
||||
- **Flyway forward-only** + **readiness-gated**: migration 완료 전 readiness probe `false`.
|
||||
- **prod forbidden**: `flyway.repair`, `flyway.baselineOnMigrate`, `flyway.outOfOrder` 모두 prod에서 사용 금지.
|
||||
- 표준 startup **exit code 매핑** (sysexits.h 관례):
|
||||
- `78` — config error (env / property 누락·잘못된 값)
|
||||
- `70` — internal software error (예상 외 application failure)
|
||||
- `71` — OS error (system call / resource 실패)
|
||||
- `72` — critical OS file missing
|
||||
- 대안 검토: Liquibase (DB-agnostic + rollback, XML/YAML verbose), Hibernate `hbm2ddl=update` (anti-pattern), Atlas (declarative, JVM 외부), K8s Init Container (replica race) vs Job + migration lock — branch-note `feature-migration-startup-contract`.
|
||||
|
||||
### Graceful Shutdown (`partially-implemented`)
|
||||
|
||||
- App SIGTERM 수신 후 in-flight 처리 **20s** + preStop hook **5s** drain + K8s terminationGracePeriodSeconds **35s** (10s margin).
|
||||
- Spring Boot `server.shutdown=graceful` + `spring.lifecycle.timeout-per-shutdown-phase` 설정 예정.
|
||||
|
||||
Kubernetes manifest와 실제 rolling update/drain 실측은 아직 없다. 따라서 local/runtime guard와 운영 가정의 경계를 분리해서 말해야 한다.
|
||||
|
||||
## 면접에서 말할 수 있는 범위
|
||||
|
||||
### 자신 있게 답할 수 있는 (개념·설계 의도)
|
||||
|
||||
- **JRE slim vs distroless** 선택 근거 — 운영/디버깅 친숙도 vs 보안 surface trade-off.
|
||||
- **liveness / readiness / startup 3 probe 분리** 이유 — single `/health`로 묶으면 dependency 일시 outage가 container restart loop를 유발하고, startup 단계 liveness 오판이 긴 migration/warmup을 죽일 수 있다.
|
||||
- **Graceful shutdown 단계** — SIGTERM → app drain 20s → preStop 5s → grace 35s. 각 timeout이 sync되지 않으면 SIGKILL로 inflight 요청 유실.
|
||||
- **Flyway `repair`가 prod에서 위험한 이유** — 실제 schema 변경 없이 metadata만 수정. 공식이 직접 위험성 경고. `baselineOnMigrate`는 누락 migration skip, `outOfOrder`는 협업 일관성 깨짐.
|
||||
- **Exit code 78/70/71/72 의미** — sysexits.h 관례. config error / internal / OS / critical OS file missing 진단 분리.
|
||||
|
||||
### 적당히 답할 수 있는
|
||||
|
||||
- **GraalVM native-image trade-off** — cold start/메모리 우위 vs reflection·dynamic proxy build-time metadata 비용, peak throughput 손실. 우아한형제들도 hybrid 채택.
|
||||
- **`-XX:MaxRAMPercentage=75`** vs 절대값 `-Xmx` — container memory limit 변경에 따라가는 비율 방식이 안전한 이유.
|
||||
|
||||
### 답하면 안 되는 (실측·운영 경험 없음)
|
||||
|
||||
- "K8s rolling update를 운영하면서…" — 운영 경험 없음.
|
||||
- "cold start latency를 측정해보니…" — 측정 없음.
|
||||
- "DB migration이 prod에서 실패해서 복구한 경험" — 없음.
|
||||
- "liveness probe 오판으로 restart loop가 발생했을 때…" — 운영 incident 없음.
|
||||
- "graceful shutdown 35s budget이 실제로 충분했다" — 실측 없음.
|
||||
|
||||
## 과장 금지 지점
|
||||
|
||||
- **"GraalVM native-image가 곧 표준"** → ❌. reflection-heavy 코드와 peak throughput 손실은 실측 trade-off. ca-tmpl은 채택하지 않았고 hybrid 사례만 참조했다.
|
||||
- **"K8s probe 동작을 운영에서 확인했다"** → ❌. health group contract는 로컬 테스트로 검증했지만 Kubernetes manifest/cluster 검증은 없다.
|
||||
- **"Flyway readiness-gated migration이 운영에서 동작한다"** → ❌. startup guard와 prod forbidden option은 로컬 테스트로 검증했지만 prod migration 복구 경험은 없다.
|
||||
- **"graceful shutdown 35s가 충분히 검증되었다"** → ❌. graceful shutdown 설정은 존재하지만 운영 drain 실측은 없다.
|
||||
- **"distroless가 보안상 우월하다고 채택했다"** → ❌. ca-tmpl은 **JRE slim 채택**. distroless는 대안으로 검토만 했고 디버깅 손실을 이유로 보류.
|
||||
- **"exit code 78/70/71/72가 표준이다"** → ❌. sysexits.h는 BSD 관례. POSIX 강제 표준 아님. 조직 enum 명시가 필요.
|
||||
|
||||
### Blog-topic ingest: runtime 묶음 (2026-07-02)
|
||||
|
||||
[[raw/blog-topics/jvm-oom-vs-container-oomkill-exit-137-2026-07-02]] 는 JVM OOM과 container OOMKill이 비슷한 종료 신호로 보일 때 heap dump/native stderr/runtime signal을 어떻게 구분할지 정리하기 위한 raw seed다.
|
||||
|
||||
- **canonical 반영 범위**: container/JVM runtime failure 해석을 runtime/container 결정 문서의 blog-topic 후보로 연결했다.
|
||||
- **blogify 전 조건**: 충족. 이 문서는 2026-07-02 기준 코드와 `./gradlew check`로 검증됨.
|
||||
- **블로그 전 과장 방지**: Kubernetes 운영 장애 대응 경험처럼 쓰지 않고, local/container evidence와 운영 가정을 분리한다.
|
||||
- [[raw/blog-topics/spring-actuator-health-probe-group-split-2026-07-02]]: Spring Actuator health group을 liveness/readiness/startup으로 분리하고 startup guard/shutdown lifecycle을 같은 운영 계약으로 보는 글감. Kubernetes end-to-end readiness 보장처럼 쓰지 않는다.
|
||||
- [[raw/blog-topics/java21-context-propagation-strategy-virtual-threads-2026-07-02]]: Java 21에서 request/security/tenant context를 ThreadLocal, Micrometer Context Propagation, ScopedValue 중 어디까지 다룰지 선택 기준을 정리하는 글감. ScopedValue 채택 경험처럼 쓰지 않고 후보/기준으로 제한한다.
|
||||
- [[raw/blog-topics/spring-boot-startup-exit-code-propagation-2026-06-10]]: startup failure exit code를 `ExitCodeGenerator`/Spring Boot uncaught exception path와 sysexits 관례로 분리해 설명하는 글감. POSIX 표준처럼 쓰지 않고, ca-tmpl 내부 convention과 local 검증 경계를 구분한다.
|
||||
- [[raw/blog-topics/spring-async-taskdecorator-bounded-executor-saturation-shutdown-2026-06-13]]: executor await timeout과 app shutdown / Kubernetes grace period의 계층 부등식을 다루는 글감. executor sizing 숫자를 측정값으로 쓰지 않는다.
|
||||
|
||||
## 관련 개념
|
||||
|
||||
- [[wiki/concepts/runtime-container-health-migration]]
|
||||
|
||||
## Sources
|
||||
|
||||
- [[raw/project-notes/ca-skeleton-operational-contract]] — ca-tmpl 운영 계약 SSOT (§15 Runtime / Lifecycle Contract, §29 G-D).
|
||||
- [[raw/branch-notes/feature-container-runtime-contract]] — Temurin JRE slim + JVM ergonomics + UTC/UTF-8 계약.
|
||||
- [[raw/blog-topics/jvm-oom-vs-container-oomkill-exit-137-2026-07-02]] — JVM OOM vs container OOMKill 블로그 글감 raw seed. canonical 반영 범위: runtime/container failure interpretation + 과장 금지 항목.
|
||||
- [[raw/branch-notes/feature-runtime-health-lifecycle-contract]] — liveness/readiness/startup 3-endpoint 분리 + Required/Optional Dependency Matrix.
|
||||
- [[raw/blog-topics/spring-actuator-health-probe-group-split-2026-07-02]] — Actuator health probe group split 블로그 글감 raw seed.
|
||||
- [[raw/branch-notes/feature-runtime-context-propagation-contract]] — Java 21 context propagation 선택 기준 parent branch.
|
||||
- [[raw/blog-topics/java21-context-propagation-strategy-virtual-threads-2026-07-02]] — Java 21 context propagation strategy 블로그 글감 raw seed.
|
||||
- [[raw/branch-notes/feature-migration-startup-contract]] — Flyway readiness-gated + prod forbidden 옵션 + exit code 78/70/71/72 매핑.
|
||||
- [[raw/blog-topics/spring-boot-startup-exit-code-propagation-2026-06-10]] — startup exit code propagation 블로그 글감 raw seed.
|
||||
- [[raw/blog-topics/spring-async-taskdecorator-bounded-executor-saturation-shutdown-2026-06-13]] — async executor shutdown budget 블로그 글감 raw seed.
|
||||
|
||||
## Cluster / 묶음
|
||||
|
||||
<!-- GENERATED: derived-blogs:start -->
|
||||
- [[wiki/blog/ca-tmpl-runtime-container-health-migration-2026-07-02]]
|
||||
<!-- GENERATED: derived-blogs:end -->
|
||||
@@ -0,0 +1,129 @@
|
||||
---
|
||||
title: ca-tmpl - Sample Fixture & Adoption 결정
|
||||
source_type: project
|
||||
status: verified
|
||||
confidence: high
|
||||
tags: [ca-tmpl, sample-fixture, template, adoption, actually-implemented, locally-verified]
|
||||
related_projects: [ca-tmpl]
|
||||
last_reviewed: 2026-07-02
|
||||
---
|
||||
|
||||
# ca-tmpl - Sample Fixture & Adoption 결정
|
||||
|
||||
> Layer: `wiki/projects/` — ca-tmpl 프로젝트 내 sample fixture / removal / adoption 결정 사실 기록. 일반 개념은 [[wiki/concepts/sample-fixture-and-adoption]].
|
||||
|
||||
## 프로젝트 컨텍스트
|
||||
|
||||
- **프로젝트**: ca-tmpl (clean architecture skeleton template repository).
|
||||
- **범위**: skeleton 운영 계약(envelope / error / capability / transaction / idempotency)을 트리거하기 위한 **sample fixture** 정의와, 실제 도메인을 얹을 때 sample을 production runtime에서 비활성화하면서 운영 계약을 보존하는 **sample-off / adoption** 절차의 결정.
|
||||
- **현황**: skeleton 설계 단계. canonical operational contract 문서 작성 진행 중.
|
||||
- sample-ticket 12 scenario matrix + 6-field minimum model + state machine + optimistic lock + idempotency key 결정 완료(문서).
|
||||
- sample-off profile + production dependency 차단 + dual-mode CI matrix (sample-on / sample-off 둘 다 release-blocking) + multi-module adoption checklist 결정 완료(문서).
|
||||
- **C2 구현 + 로컬 검증 완료.** `sample-portfolio` module, sample domain/use case/web/persistence tests, `sampleFixture` configuration, `sampleOffTest`, CI sample-off job이 존재한다. 실제 외부 프로젝트 adoption 사례는 없다.
|
||||
|
||||
## 실제 구현 내용 (`actually-implemented`)
|
||||
|
||||
- `sample-portfolio` module이 template fixture/reference로 유지된다.
|
||||
- sample domain, use case, web controller, persistence adapter, OpenAPI snapshot, authz/idempotency/outbox 관련 sample tests가 존재한다.
|
||||
- `app-bootstrap/build.gradle`에 `sampleFixture` configuration과 `sampleOffTest` task가 존재한다.
|
||||
- `SampleRemovalSmokeContractTest`가 production dependency 차단, `sampleFixture` wiring, `sampleOffTest`, CI workflow sample-off command를 검증한다.
|
||||
- `.github/workflows/ci-quality-gates.yml`에 `./gradlew :app-bootstrap:sampleOffTest`가 포함된다.
|
||||
|
||||
## 로컬/dev 검증 (`locally-verified`)
|
||||
|
||||
- `./gradlew check` 통과(2026-07-02, `BUILD SUCCESSFUL`, 114 tasks).
|
||||
- `:app-bootstrap:sampleOffTest`, `checkstyleSampleOffTest`, `spotbugsSampleOffTest`가 check graph에 포함되어 실행되었다.
|
||||
- `SampleRemovalSmokeContractTest`가 sample-off classpath에 `sample-portfolio` jar가 없는지 확인한다.
|
||||
|
||||
## 운영 검증 (`prod-verified`)
|
||||
|
||||
- 없음. ca-tmpl skeleton 자체가 운영 채택 사례가 없으며, sample-on / sample-off CI matrix가 release를 실제로 차단한 사례도 없다.
|
||||
|
||||
## 문서/계획만 존재 (`documented-only` / `planned`)
|
||||
|
||||
### Sample fixture 결정 (canonical §17, §22)
|
||||
|
||||
- **자체 fixture `sample-ticket` 채택.** 5종 대안(Spring Petclinic / RealWorld / Spring Cloud microservices sample / Stripe testmode / no fixture) 검토 후 선택. 근거는 contract 매트릭스 부재(Petclinic / RealWorld), 인프라 과도(Spring Cloud), 도메인 한정 SaaS sandbox(Stripe), 행위 검증 불가(no fixture).
|
||||
- **sample-ticket 12 scenario matrix** (canonical §22): create / get / list / update / close / reopen / conflict (optimistic lock) / duplicate (idempotency) / not-found / validation-error / forbidden / transactional rollback 흐름. envelope / error code / capability gate / transaction boundary / idempotency key를 트리거하기 위한 시나리오 집합으로 정의.
|
||||
- **6-field minimum model**: `TicketId`, `TicketTitle`, `TicketStatus`, `TicketVersion`, `TicketOwner`, `IdempotencyKey`. 비즈니스 기능이 아니라 contract trigger에 필요한 최소 필드만.
|
||||
- **State machine**: `OPEN → IN_PROGRESS → CLOSED`. reopen은 `CLOSED → OPEN` 한정. 상태 전이 위반은 conflict 시나리오로 검증.
|
||||
- **Optimistic lock**: `TicketVersion` 기반. 동일 ticket에 대한 동시 update에서 conflict scenario 발생.
|
||||
- **Idempotency key**: `IdempotencyKey` 필드. 동일 key 재요청 시 동일 응답 보장 scenario.
|
||||
|
||||
### Sample-off / adoption 결정 (canonical §17, §29 G-H)
|
||||
|
||||
- **Sample-off first adoption**:
|
||||
1. Spring profile (`sample-off`)로 sample bean / route 제외.
|
||||
2. `sample-ticket` module은 template fixture/reference로 유지하되, production runtime/default profile과 새 도메인은 sample에 의존하지 않음.
|
||||
fork한 프로젝트에서 sample 코드를 정리하는 것은 선택 사항이며, ca-tmpl 기본 blueprint의 목표는 module 삭제가 아니라 runtime 노출 차단과 의존성 차단이다.
|
||||
- **Dual-mode CI matrix**: `sample-on` / `sample-off` 두 mode를 **둘 다 release-blocking** 으로 운영. sample-off 상태에서도 envelope / capability / transaction / idempotency 계약이 그대로 유지되는지 회귀 검증.
|
||||
- **Multi-module adoption checklist**: `feature-domain-feature-onboarding-contract`의 New Domain Module Slice + Read/Write Difference Table을 따른다. 핵심은 `domain-core`, `application-core`, `adapter-*`, `shared-contract`, `app-bootstrap` 경계에 새 도메인을 얹고 `sample-ticket` import 없이 sample-off smoke를 통과하는 것이다.
|
||||
- **Reference scaffolding 1순위: GitHub Template Repository.** CI/Actions workflow 파일까지 그대로 복제되어 friction이 최저. Spring Initializr / Cookiecutter / degit / Yeoman / Maven archetype / Backstage 비교 결과.
|
||||
|
||||
### 미구현 항목 (planned)
|
||||
|
||||
- sample-ticket entity / repository / use case 코드.
|
||||
- 12 scenario contract test suite.
|
||||
- `sample-off` profile bean 분기 / `sample-ticket` runtime isolation.
|
||||
- dual-mode CI matrix GitHub Actions workflow.
|
||||
- sample-off / adoption checklist를 검증하는 e2e flow.
|
||||
|
||||
## 면접에서 말할 수 있는 범위
|
||||
|
||||
### 자신 있게 답할 수 있는 질문
|
||||
|
||||
- sample-portfolio / WorkLog fixture가 어떤 운영 계약(envelope / error / capability / transaction / idempotency / outbox)을 트리거하기 위한 시나리오 집합인가.
|
||||
- WorkLog sample model이 contract trigger 역할을 하도록 구성된 이유. production feature가 아니라 skeleton verification fixture라는 점.
|
||||
- dual-mode CI matrix (`sample-on` / `sample-off` 둘 다 release-blocking)가 막으려는 회귀 시나리오가 무엇인가.
|
||||
- sample-off first adoption이 즉시 코드 삭제보다 어떤 안전성을 더 주는가.
|
||||
- Spring Petclinic / RealWorld 대신 자체 fixture를 둔 이유. contract 매트릭스 부재 / minimum 위반.
|
||||
|
||||
### 적당히 답할 수 있는 질문
|
||||
|
||||
- GitHub Template Repository vs Cookiecutter trade-off. friction 최저 모델과 generator 시점 sample-off 모델의 시맨틱 차이.
|
||||
- Backstage golden path 도입 임계점. service template / scorecard / catalog를 따로 운영할 조직 규모 이후.
|
||||
|
||||
### 답하면 안 되는 질문 (모른다고 해야 함)
|
||||
|
||||
- sample-portfolio 구현 + 로컬 검증 경험. 가능. 단 외부 프로젝트 adoption 사례나 hosted release 차단 사례로 확대하지 않는다.
|
||||
- 12 scenario matrix 전체가 hosted CI에서 contract 위반을 잡아낸 사례. 별도 확인 필요.
|
||||
- adoption checklist를 실제 프로젝트에 적용한 결과 / 도입 시간 측정값. **운영 채택 없음.**
|
||||
- dual-mode CI matrix가 hosted release를 실제 차단한 사례. workflow는 존재하지만 hosted CI 차단 이력은 별도 확인하지 않았다.
|
||||
|
||||
## 과장 금지 지점
|
||||
|
||||
- "sample-portfolio가 production 도메인이다" → ❌. **contract 검증 도구(fixture)** 이며 production feature가 아니다.
|
||||
- "Spring Initializr / Cookiecutter가 ca-tmpl과 동급 alternative다" → ❌. 두 도구 모두 **generator 시점에 sample을 빼는 모델**이라, sample-on / sample-off 둘 다 release-blocking으로 검증하는 ca-tmpl 운영 모델과 시맨틱이 다르다.
|
||||
- "12 scenario를 모두 검증했다" → ❌. **시나리오 정의만 있고**, scenario test suite은 작성되지 않았다.
|
||||
- "GitHub Template Repository가 모든 면에서 우월하다" → ❌. friction(초기 복제 마찰) 기준 1순위일 뿐, sample 제거 / adoption checklist / operational contract 보존은 ca-tmpl 측에서 별도로 정의해야 한다.
|
||||
- "Backstage가 skeleton repo의 상위 호환이다" → ❌. 조직 규모 임계점 이후의 IDP 진입점이며 동일 레이어가 아니다.
|
||||
- "sample-off가 production runtime 운영 안전성을 보장한다" → ❌. sample-off는 build/test classpath 격리 검증이며 운영 채택 사례는 없다.
|
||||
|
||||
### Blog-topic ingest: sample-domain-contract-fixture-clean-architecture (2026-07-02)
|
||||
|
||||
[[raw/blog-topics/sample-domain-contract-fixture-clean-architecture-2026-06-10]] 는 Clean Architecture 템플릿의 sample domain을 데모 기능이 아니라 validation, mapper, transaction, response, conflict 계약을 실제 흐름으로 검증하는 fixture로 다루는 글감이다.
|
||||
|
||||
- **canonical 반영 범위**: sample fixture/adoption canonical의 blog-topic 후보로 연결했다.
|
||||
- **blogify 전 조건**: 충족. 이 문서는 2026-07-02 기준 코드와 `./gradlew check`로 검증됨.
|
||||
- **블로그 전 과장 방지**: sample domain이 production feature이거나 scenario suite 전체가 검증됐다고 쓰지 않는다.
|
||||
- [[raw/blog-topics/sample-fixture-dual-mode-build-matrix-2026-06-25]]: sample domain을 runtime toggle이 아니라 sample-on/sample-off build matrix로 격리하는 글감. hosted CI release-blocking 검증과 local gate matrix를 분리한다.
|
||||
- [[raw/blog-topics/clean-architecture-reference-project-adoption-2026-06-17]]: 외부 reference project를 그대로 복제하지 않고 contract verification, event reliability, adoption checklist로 분해해 ca-tmpl에 흡수하는 글감. 정확성 감사에서 결함이 지적된 계획 문서는 수정 후에만 근거로 쓴다.
|
||||
|
||||
## 관련 개념
|
||||
|
||||
- [[wiki/concepts/sample-fixture-and-adoption]]
|
||||
|
||||
## Sources
|
||||
|
||||
- [[raw/project-notes/ca-skeleton-operational-contract]] — canonical §17 Sample Domain Fixture, §22 Sample-ticket Contract Matrix, §29 Group G-H Sample / adoption
|
||||
- [[raw/branch-notes/feature-sample-domain-contract-fixture]] — sample-ticket 12 scenario matrix + 6-field minimum + state machine + optimistic lock + idempotency key 결정 SSOT branch
|
||||
- [[raw/blog-topics/sample-domain-contract-fixture-clean-architecture-2026-06-10]] — sample domain contract fixture 블로그 글감 raw seed
|
||||
- [[raw/branch-notes/feature-sample-removal-adoption-contract]] — 2-step removal + dual-mode CI matrix + 7-step adoption checklist 결정 SSOT branch
|
||||
- [[raw/blog-topics/sample-fixture-dual-mode-build-matrix-2026-06-25]] — sample fixture dual-mode build matrix 블로그 글감 raw seed
|
||||
- [[raw/blog-topics/clean-architecture-reference-project-adoption-2026-06-17]] — reference project adoption 블로그 글감 raw seed
|
||||
|
||||
## Cluster / 묶음
|
||||
|
||||
<!-- GENERATED: derived-blogs:start -->
|
||||
- [[wiki/blog/ca-tmpl-sample-fixture-and-adoption-2026-07-02]]
|
||||
<!-- GENERATED: derived-blogs:end -->
|
||||
@@ -0,0 +1,158 @@
|
||||
---
|
||||
title: ca-tmpl - Security Baseline 결정 (JWT + Actuator + Secrets)
|
||||
source_type: project
|
||||
status: verified
|
||||
confidence: high
|
||||
tags: [ca-tmpl, security, jwt, oauth2, secrets, actually-implemented, locally-verified]
|
||||
related_projects: [ca-tmpl]
|
||||
last_reviewed: 2026-07-02
|
||||
---
|
||||
|
||||
# ca-tmpl - Security Baseline 결정 (JWT + Actuator + Secrets)
|
||||
|
||||
> Layer: `wiki/projects/` — ca-tmpl skeleton 내 보안 baseline 결정 사실 문서. 일반 개념/표준 정의는 [[wiki/concepts/security-baseline-jwt-actuator-secrets]] 참고.
|
||||
|
||||
## 프로젝트 컨텍스트
|
||||
|
||||
`ca-tmpl`은 Clean Architecture 기반 Spring Boot **skeleton/template** 저장소다. 이 문서가 다루는 범위는 운영 계약([[raw/project-notes/ca-skeleton-operational-contract]] §18 Control Plane Contract, §29 Group G-B 외부 근거 인덱스) 중 **보안 baseline 세 축**의 설계 결정이다.
|
||||
|
||||
세 축:
|
||||
|
||||
1. **데이터면 인증/인가**: JWT Resource Server + AuthN/AuthZ matrix 12행 + JWKS 10분 refresh + clock skew tolerance 60s + key rotation overlap 24h + public path snapshot diff.
|
||||
2. **제어면 (Actuator)**: management port **9001** 분리 + prod allowlist (`health` / `prometheus` / `info`) + `heapdump`/`threaddump`/`env`/`configprops`/`shutdown` prod forbidden + `loggers` prod read-only + metrics network ACL default.
|
||||
3. **Secrets / Config**: prod = secret manager OR mounted env, local만 `.env` 허용. `no-runtime-reload` default, `@RefreshScope` 금지. JWT signing key 24h overlap / DB credential dual-bind 60s / API key restart-reload / HMAC salt 90d rotation.
|
||||
|
||||
**진행 상태: C2 부분 구현 + 로컬 검증 완료.** JWT Resource Server filter chain, lazy JWT decoder, security error classifier/envelope entry point, actuator management policy, secret source/reload guard는 코드화되어 있다. secret manager 연동과 실제 rotation automation은 아직 없다.
|
||||
|
||||
## 실제 구현 내용 (`actually-implemented`)
|
||||
|
||||
- `adapter-web`의 `SecurityConfig`가 `SecurityFilterChain`과 `oauth2ResourceServer`를 구성한다.
|
||||
- `JwtDecoderConfig`가 `SupplierJwtDecoder`로 JWKS discovery를 lazy 처리하고 `JwtTimestampValidator(Duration.ofSeconds(60))`, issuer, audience validator를 명시한다.
|
||||
- `SecurityErrorClassifier`와 envelope entry point/denied handler 테스트가 filter-layer 보안 실패를 API error envelope으로 분류한다.
|
||||
- `MethodSecurityConfig`, `RequiresPermission`, `AuthorizationPort`, `AuthorizationContractTest`가 framework-free method authorization path를 구성한다.
|
||||
- `app-bootstrap`의 `ManagementSecurityConfig`, sample management config, `SecretSource*`, `SecretReloadContractTest`가 actuator/secret baseline 일부를 코드화한다.
|
||||
|
||||
## 로컬/dev 검증 (`locally-verified`)
|
||||
|
||||
- `./gradlew check` 통과(2026-07-02, `BUILD SUCCESSFUL`, 114 tasks).
|
||||
- `SecurityErrorClassifierTest`, `JwtDecoderConfigTest`, `EnvelopeAuthenticationEntryPointTest` 등 web security/error path 테스트가 통과한다.
|
||||
- `ManagementActuatorSecurityContractTest`, `ActuatorSecurityHttpTest`가 management port/exposure/loggers read-only 정책을 검증한다.
|
||||
- `SecuritySettingsTest`, `SecretSourceTest`, `SecretSourceValidatorTest`, `SecretReloadContractTest`가 설정/secret source/reload guard를 검증한다.
|
||||
|
||||
## 운영 검증 (`prod-verified`)
|
||||
|
||||
**없음.** ca-tmpl은 skeleton/template이며 운영 배포 대상이 아니다. prod 환경에서 JWT 검증 latency·JWKS rotation·secret rotation·actuator endpoint 노출을 측정한 적이 없다.
|
||||
|
||||
## 문서/계획만 존재 (`documented-only` / `planned`)
|
||||
|
||||
다음 항목은 구현된 baseline과 아직 `documented-only` / `planned`로 남은 영역을 분리한다. 면접/블로그에서 구현 범위와 혼동하면 안 된다.
|
||||
|
||||
### D1. JWT Resource Server 채택 (`actually-implemented` / `locally-verified`)
|
||||
|
||||
- **결정**: 데이터면 인증을 OAuth2 Resource Server + JWT (`spring-boot-starter-oauth2-resource-server`) 로 표준화.
|
||||
- **검토한 대안**:
|
||||
- Session + Cookie — 분산 session store 비용, stateless 확장성 손실.
|
||||
- OAuth2 Authorization Code (issuance flow) — 본 baseline은 **검증 side**이므로 직교. issuance 자체는 별도 IdP.
|
||||
- mTLS (RFC 8705 sender-constrained token) — PKI 운영 비용 + public client(SPA/mobile) 운영 어려움.
|
||||
- API key + HMAC (AWS SigV4 류) — webhook/외부 호출 인증에는 적합하나 일반 사용자 인증 모델이 아님.
|
||||
- OPA (Open Policy Agent) — 외부 호출 latency + sidecar 운영. 인가 정책 2~3종에는 과한 인프라.
|
||||
- **채택 이유**: framework-neutral skeleton 가정과 정합 (Spring Security 6 표준 경로), revocation 한계는 short expiry + JWKS rotation overlap으로 완화.
|
||||
- **설계만 동결한 파라미터**: JWKS refresh 10분 + unknown `kid` 시 on-demand refresh, clock skew 60s, key rotation overlap 24h, AuthN/AuthZ matrix 12행, public path snapshot diff.
|
||||
|
||||
### D2. Actuator management port 9001 분리 + prod allowlist (`actually-implemented` / `locally-verified`)
|
||||
|
||||
- **결정**: `management.server.port=9001` 별도 포트 + prod allowlist=`health,prometheus,info` + 그 외 prod forbidden.
|
||||
- **검토한 대안**:
|
||||
- Single port (8080) + path ACL — cloud ingress의 path 매칭 신뢰도, filter ordering / regex 우회 risk.
|
||||
- mTLS for management — 강하지만 cert 운영 부담.
|
||||
- Network ACL only (VPC SG / NetworkPolicy) — port가 같으면 비즈니스 트래픽과 분리 정책이 복잡.
|
||||
- Istio sidecar AuthorizationPolicy — mesh 도입 전제, skeleton의 framework-neutral 가정 위배.
|
||||
- **채택 이유**: 외부 노출 차단을 **네트워크 경계 단순화**(다른 포트 = 다른 ingress 정책)로 풀어 single-port + path ACL의 우회 위험을 피함.
|
||||
- **설계만 동결한 파라미터**: `heapdump`/`threaddump`/`env`/`configprops`/`shutdown` prod 차단, `loggers` prod read-only, metrics scrape는 internal network ACL default.
|
||||
|
||||
### D3. Secrets: secret manager OR mounted env + restart-only rotation + HMAC salt 90d (`partially-implemented`)
|
||||
|
||||
- **결정**: prod source = (secret manager) OR (mounted env), `.env`는 local 전용. `__LOCAL_DEV_` sentinel로 prod 오탑재 차단. `@RefreshScope` 금지 / `no-runtime-reload` default. JWT signing key 24h overlap, DB credential dual-bind 60s, API key restart-reload, HMAC salt 90d rotation.
|
||||
- **검토한 대안**:
|
||||
- Vault dynamic secrets (short lease) — `@RefreshScope` + bean 재생성을 전제 → connection pool/캐시 lifecycle과 충돌, 본 계약(`@RefreshScope` 금지)과 정면 충돌.
|
||||
- External Secrets Operator (ESO) — K8s native, 단 etcd 평문 저장은 cluster operator 책임 (이중 신뢰 경계).
|
||||
- Doppler / 1Password SDK — dev 머신 보호에 강점이나 SaaS 외부 의존.
|
||||
- **채택 이유**: runtime reload를 거부하면 bean lifecycle / connection pool 충돌이 사라지고, rotation은 **명시적 dual-bind window**로만 처리. HMAC salt 90d 주기는 NIST SP 800-57 cryptoperiod 권고 범위 내에서 누적 노출/downstream re-hash 비용을 절충한 값.
|
||||
|
||||
Secret source abstraction과 local/prod guard는 구현되어 있으나, 외부 secret manager/Vault/KMS 통합 및 실제 rotation automation은 미구현이다.
|
||||
|
||||
### D4. 한국 보안 사례 reference 추가 (2026-05-22) (`documented-only`)
|
||||
|
||||
- **추가된 reference** (raw 출처만, 구현 변경 없음):
|
||||
- [[raw/company-tech-blogs/security-woowahan-actuator-safe-usage]] — 우아한형제들 SOC팀 "Security Actuator 안전하게 사용하기" (별도 포트 + endpoint allowlist + shutdown/heapdump forbidden 권고). ca-tmpl D2 결정과 정합.
|
||||
- [[raw/company-tech-blogs/security-toss-actuator-healthcheck]] — 토스 "Spring Boot Actuator의 헬스체크 살펴보기" (health detail 민감성 분류). ca-tmpl D2 + public path misconfiguration 정책과 정합.
|
||||
- **영향**: Group G-B Actuator 결정의 한국 도메인 사례 근거 보강. 현재 ca-tmpl의 actuator exposure/management security contract와 함께 보조 근거로만 사용한다.
|
||||
- **여전히 미확보**: 한국 기업의 JWT Resource Server 구현 사례, secret manager / Vault 운영 사례 직접 source는 미발견 — follow-up 후보로 유지.
|
||||
|
||||
## 면접에서 말할 수 있는 범위
|
||||
|
||||
### 자신 있게 답할 수 있는 질문
|
||||
|
||||
- **JWT vs Session 선택 기준** — stateless 확장성, revocation trade-off, cookie 운영 비용, 클라이언트 타입에 따른 결정 근거.
|
||||
- **JWKS rotation 주기 설계** — 10분 refresh + unknown `kid` 시 on-demand refresh + 24h overlap window의 근거.
|
||||
- **Management port 분리 이유** — single-port + path ACL의 filter ordering / regex 우회 risk 대비 별도 포트의 네트워크 경계 단순화.
|
||||
- **Secret rotation 방식 (dual-bind)** — JWT key 24h overlap / DB credential dual-bind 60s / API key restart-reload가 왜 다른지.
|
||||
- **HMAC salt 90d rotation 근거** — NIST SP 800-57 cryptoperiod 권고 + 누적 노출량 한도 + downstream re-hash 비용 절충.
|
||||
|
||||
### 적당히 답할 수 있는 질문
|
||||
|
||||
- **OPA vs in-process AUTHZ trade-off** — 외부 호출 latency / sidecar 운영 / 정책 코드 분리 가치 / 정책 종수 임계.
|
||||
- **Vault dynamic secrets vs static lease** — `@RefreshScope` 강제와 bean lifecycle 충돌, dynamic secret이 본 계약과 왜 충돌하는지.
|
||||
- **clock skew tolerance 30s vs 60s** — NTP drift 가정, 발급자/검증자 분산도, expired vs replay 창 trade-off.
|
||||
|
||||
### 답하면 안 되는 질문 (모른다고 해야 함)
|
||||
|
||||
- "**JWT Resource Server baseline을 구현했다**" — 가능. 단 IdP 운영/JWKS rotation 실측은 없음.
|
||||
- "**Secret rotation을 운영에서 돌려봤다**" — prod 적용 사례 없음. dual-bind window는 설계 값.
|
||||
- "**Actuator endpoint 보안 침투 테스트 결과**" — pentest 수행 안 함.
|
||||
- "**JWKS rotation 시 latency가 얼마였다**" — 측정 안 함.
|
||||
- "**Vault/Secrets Manager를 ca-tmpl에 연결해서 돌려봤다**" — 어떤 secret manager와도 통합하지 않음.
|
||||
|
||||
## 과장 금지 지점
|
||||
|
||||
- **"JWT는 안전하다"는 단정 금지.** token theft 시 stateless 검증은 즉시 revocation이 어렵다. JWKS rotation overlap + short expiry는 완화책일 뿐 근본 해결책이 아니다.
|
||||
- **"Vault가 secret 관리의 표준"이라는 표현 금지.** dynamic secrets는 `@RefreshScope` 흐름을 전제하며, ca-tmpl의 `@RefreshScope` 금지 계약과 정면 충돌. 채택 가능한 표준이 단일하지 않다.
|
||||
- **한국 보안 기술블로그 사례 참조 범위 한정.** 2026-05-22 기준 ca-tmpl이 직접 참조하는 한국 사례는 **Actuator 노출 정책 영역에 한정**된다 ([[raw/company-tech-blogs/security-woowahan-actuator-safe-usage]] / [[raw/company-tech-blogs/security-toss-actuator-healthcheck]]). JWT Resource Server 운영, secret manager 통합, JWKS rotation 같은 영역의 한국 도메인 직접 사례는 부재 — 인용 시 영역을 actuator로 명시할 것.
|
||||
- **"Actuator를 닫아두면 안전하다"는 단정 금지.** allowlist + 네트워크 경계 + 인증의 다층 방어가 필요하다. `info`만 열어도 build/commit 메타데이터가 attack surface가 될 수 있다.
|
||||
- **"AuthN/AuthZ matrix 12행 전체가 E2E로 검증됐다"고 말하면 안 됨.** 주요 security/error path와 method authorization contract는 테스트되지만, 모든 matrix row의 외부 IdP 통합 검증은 없다.
|
||||
|
||||
### Blog-topic ingest: secret-source-port-restart-only-rotation (2026-07-02)
|
||||
|
||||
[[raw/blog-topics/secret-source-port-restart-only-rotation-2026-07-02]] 는 secret source를 문자열 규칙이 아니라 `SecretSource` port, restart-only rotation, `@RefreshScope` 금지 계약으로 닫은 이유를 블로그로 풀기 위한 raw seed다.
|
||||
|
||||
- **canonical 반영 범위**: secrets/source/rotation 정책 글감을 security baseline canonical에 연결했다.
|
||||
- **blogify 전 조건**: 충족. 이 문서는 2026-07-02 기준 코드와 `./gradlew check`로 검증됨.
|
||||
- **블로그 전 과장 방지**: Vault/KMS dynamic secret 운영이나 secret manager 통합을 구현한 것처럼 쓰지 않고, restart-only contract 범위로 제한한다.
|
||||
- [[raw/blog-topics/framework-free-method-authorization-clean-architecture-2026-06-08]]: Spring Security annotation을 application layer에 직접 붙이지 않고 plain annotation + authorization port + adapter method-security로 분리하는 글감. Spring Security 자체를 부정하지 않고 ca-tmpl layer boundary 선택으로 제한한다.
|
||||
- [[raw/blog-topics/spring-security-filter-layer-error-envelope-2026-06-08]]: Spring Security 인증/인가 실패가 filter layer에서 entry point / denied handler로 처리되어 ControllerAdvice에 도달하지 않는다는 점을 envelope 통일과 연결하는 글감. heuristic 분류의 한계를 유지한다.
|
||||
|
||||
## 관련 개념
|
||||
|
||||
- [[wiki/concepts/security-baseline-jwt-actuator-secrets]]
|
||||
|
||||
## Sources
|
||||
|
||||
### Canonical project SSOT
|
||||
|
||||
- [[raw/project-notes/ca-skeleton-operational-contract]] — §18 Control Plane Contract, §29 Group G-B 외부 근거 인덱스
|
||||
|
||||
### Branch-notes (결정 동결 위치)
|
||||
|
||||
- [[raw/branch-notes/feature-security-operational-baseline]] — JWT Resource Server + AuthN/AuthZ Matrix 12행 + JWKS 10min refresh + clock skew 60s + rotation overlap 24h + public path snapshot diff
|
||||
- [[raw/branch-notes/feature-secrets-config-source-contract]] — secret source port + restart-only rotation + `@RefreshScope` 금지 결정
|
||||
- [[raw/blog-topics/secret-source-port-restart-only-rotation-2026-07-02]] — secret source/restart-only rotation 블로그 글감 raw seed
|
||||
- [[raw/blog-topics/framework-free-method-authorization-clean-architecture-2026-06-08]] — framework-free method authorization 블로그 글감 raw seed
|
||||
- [[raw/blog-topics/spring-security-filter-layer-error-envelope-2026-06-08]] — Spring Security filter-layer envelope 블로그 글감 raw seed
|
||||
- [[raw/branch-notes/feature-management-actuator-security-contract]] — management port 9001 + prod allowlist + heapdump/threaddump prod forbidden + loggers prod read-only + metrics network ACL default
|
||||
- [[raw/branch-notes/feature-secrets-config-source-contract]] — prod = secret manager OR mounted env + no-runtime-reload default + `__LOCAL_DEV_` sentinel + JWT key 24h overlap / DB credential dual-bind 60s / API key restart-reload + HMAC salt 90d
|
||||
|
||||
## Cluster / 묶음
|
||||
|
||||
<!-- GENERATED: derived-blogs:start -->
|
||||
- [[wiki/blog/ca-tmpl-security-baseline-jwt-actuator-secrets-2026-07-02]]
|
||||
<!-- GENERATED: derived-blogs:end -->
|
||||
+146
@@ -0,0 +1,146 @@
|
||||
---
|
||||
title: ca-tmpl - Skeleton Governance 결정 (Registry + Verification + Test + Scorecard)
|
||||
source_type: project
|
||||
status: verified
|
||||
confidence: high
|
||||
tags: [ca-tmpl, governance, archunit, testcontainers, scorecard, actually-implemented, locally-verified]
|
||||
related_projects: [ca-tmpl]
|
||||
last_reviewed: 2026-07-02
|
||||
---
|
||||
|
||||
# ca-tmpl - Skeleton Governance 결정 (Registry + Verification + Test + Scorecard)
|
||||
|
||||
> Layer: `wiki/projects/` — 내 프로젝트 사실. 일반 개념은 [[wiki/concepts/skeleton-governance-registry-verification-test-scorecard]] 참고.
|
||||
|
||||
## 프로젝트 컨텍스트
|
||||
|
||||
**ca-tmpl skeleton** — Clean Architecture 기반의 재사용 가능한 Spring Boot 템플릿 프로젝트. 이 문서는 그 중 **governance 4축**(Registry / Verification / Test taxonomy / Scorecard)의 설계 결정을 기록한다.
|
||||
|
||||
- **현재 단계**: C2 부분 구현 + 로컬 검증 완료.
|
||||
- **scope**: markdown SSOT + YAML registry + 11 release-blocking gate + 6 test level + binary pass/fail scorecard (15 area).
|
||||
- **registry yaml 위치**: `/home/donghyeon/workspace/ca-tmpl/docs/registries/` (LLM Wiki 외부, ca-tmpl 저장소 내부).
|
||||
- **목적**: skeleton을 "남에게 줘도 망가지지 않는 상태"로 굳히기 위한 governance 계약을 명문화. 검증·테스트·도입 준비도가 **branch-note ≈ mini-ADR** 한 장과 1:1로 묶이도록 설계.
|
||||
|
||||
자세한 운영 계약은 [[raw/project-notes/ca-skeleton-operational-contract]] (§12 / §21 / §27 / §29 G-G) 참고.
|
||||
|
||||
## 실제 구현 내용 (`actually-implemented`)
|
||||
|
||||
- `docs/registries/` 아래 `error-codes.yaml`, `env-keys.yaml`, `secrets-classification.yaml`, `headers.yaml`, `mdc-keys.yaml`, `metrics.yaml`, `capabilities.yaml`가 존재한다.
|
||||
- `.github/ci-gate-matrix.yml`가 gate ↔ owner ↔ mechanism matrix를 코드화한다.
|
||||
- `ContractRegistrySchemaGovernanceTest`, `OutboxStatusRegistryContractTest`, `EnvProfileMatrixContractTest` 등 registry/gate contract tests가 존재한다.
|
||||
- `CleanArchitectureTest`, `DisabledAdapterArchitectureTest`, `NamingConventionTest`, `ProductionClassImportOption`, sample-off test source set이 architecture/test taxonomy 일부를 강제한다.
|
||||
- scorecard 자체는 아직 별도 CI badge/자동 산출물까지 구현되지 않았다.
|
||||
|
||||
## 로컬/dev 검증 (`locally-verified`)
|
||||
|
||||
- `./gradlew check` 통과(2026-07-02, `BUILD SUCCESSFUL`, 114 tasks).
|
||||
- 실행 중 `verifyCleanArchitectureDependencies`, `verifyEnvKeys`, `verifyQuarantineSunset`, `verifyReadmeCommands`, `verifyTrivyignore`가 OK로 통과했다.
|
||||
- outbox/idempotency integration tests가 PostgreSQL Testcontainers 기반으로 실행되어 contract 일부를 검증한다.
|
||||
|
||||
## 운영 검증 (`prod-verified`)
|
||||
|
||||
**없음.** ca-tmpl은 운영 배포 대상 자체가 아닌 skeleton/template.
|
||||
|
||||
## 문서/계획만 존재 (`documented-only` / `planned`)
|
||||
|
||||
아래 항목은 구현된 registry/gate/test taxonomy slice와 아직 자동화되지 않은 scorecard/coverage slice를 분리한다.
|
||||
|
||||
### Registry (canonical §21)
|
||||
|
||||
- **결정**: markdown SSOT (사람이 읽는 정의) + YAML **generated constants** (코드가 읽는 사본). 두 곳을 둬도 SSOT는 markdown 한 곳.
|
||||
- **7-column schema** 정의: `key / kind / description / since / status / owner / notes`.
|
||||
- **7개 yaml**: `error.yaml`, `env.yaml`, `secrets.yaml`, `headers.yaml`, `mdc.yaml`, `metrics.yaml`, `capabilities.yaml`.
|
||||
- **구현됨**: YAML registry files + schema governance test. **남음**: generated constants/code generator 전체와 markdown ↔ yaml 완전 drift gate.
|
||||
- **ArchUnit annotation-as-registry 대안 평가 (2026-05-22)** — markdown SSOT 유지. framework-neutral + git diff review + 외부 도구 호환 근거. ArchUnit은 verifier 역할 한정. 상세: [[raw/official-docs/archunit-annotation-as-registry-evaluation]].
|
||||
- 근거: [[raw/branch-notes/feature-contract-registry-governance]].
|
||||
|
||||
### Verification (canonical §12)
|
||||
|
||||
- **결정**: 11개 release-blocking gate + JSON snapshot 기반 contract 검증. **Pact CDC는 out-of-scope** — single-team / 단일 release train에는 over-engineering.
|
||||
- gate 예시: ArchUnit / dependency / API snapshot / error envelope / observability / OpenAPI / Testcontainers 강제 / 등.
|
||||
- **구현됨**: 다수 Gradle verification task와 `.github/ci-gate-matrix.yml`. **남음**: 11 gate 전체의 hosted release-blocking 이력과 gate별 실패 메시지 표준 완전성 확인.
|
||||
- 근거: [[raw/branch-notes/feature-contract-verification-test-suite]].
|
||||
|
||||
### Test taxonomy (canonical §29 G-G)
|
||||
|
||||
- **결정**: 6 level test taxonomy. Testcontainers는 **integration level부터 강제** (unit/slice에서 금지).
|
||||
- **src/testFixtures** 사용: fixture 코드가 main classpath에 새는 것 방지.
|
||||
- **5min budget**: skeleton local fast feedback loop 목표.
|
||||
- **구현됨**: sample-off source set, `sampleFixture`, Testcontainers integration tests, ArchUnit fixture pattern. **남음**: 6 level 전체 budget 측정/강제 mechanism.
|
||||
- 근거: [[raw/branch-notes/feature-test-taxonomy-fixture-contract]].
|
||||
|
||||
### Scorecard (canonical §27)
|
||||
|
||||
- **결정**: **binary pass/fail** (maturity 점수 X) × **15 area** × **1:1 branch evidence** (각 area는 branch-note 1개를 evidence로 지목).
|
||||
- 도입 gate 한정 — "이 skeleton을 도입해도 되는가" 여부 판단용. 운영 SLO나 코드 품질 점수 도구가 **아님**.
|
||||
- **남음**: scorecard CI step, badge, branch-note ↔ area 매핑 자동 검증.
|
||||
- 근거: [[raw/branch-notes/feature-implementation-readiness-scorecard]].
|
||||
|
||||
## 면접에서 말할 수 있는 범위
|
||||
|
||||
### 자신 있게 답할 수 있는 질문
|
||||
|
||||
- "registry의 SSOT를 markdown에 두는 이유와 code-generated YAML의 역할 분리"
|
||||
- "Pact CDC를 도입하지 않고 JSON snapshot으로 contract를 잡은 trade-off (단일 팀 / 단일 release train 한정)"
|
||||
- "Testcontainers를 integration level부터 강제하고 unit/slice에서 금지하는 이유"
|
||||
- "6 level test taxonomy의 각 level이 무엇을 책임지는지"
|
||||
- "binary pass/fail vs maturity score를 선택한 이유 — 도입 gate 용도 한정"
|
||||
- "branch-note를 mini-ADR로 보고 scorecard area와 1:1로 묶는 설계 의도"
|
||||
|
||||
### 적당히 답할 수 있는 질문
|
||||
|
||||
- "정식 ADR vs branch-note의 관계 — branch-note가 ADR의 경량 대체로 어디까지 커버되는가"
|
||||
- "fitness function 도입 검토 — ArchUnit 외 어떤 측정 지표를 자동화 후보로 보고 있는가"
|
||||
|
||||
### 답하면 안 되는 질문 (모른다고 해야 함)
|
||||
|
||||
- "verifier task를 직접 구현해 봤는가" → 일부 구현. `verifyCleanArchitectureDependencies`, `verifyEnvKeys`, `verifyQuarantineSunset`, `verifyTrivyignore` 등은 로컬 check에 포함됨.
|
||||
- "scorecard 자동화를 CI에서 운영해 봤는가" → ❌. 미작성.
|
||||
- "5min test budget을 실제로 측정해 봤는가" → ❌. 정책 선언이며 budget gate는 별도 구현 필요.
|
||||
- "11 gate가 실제로 release를 차단한 사례" → ❌. 없음.
|
||||
|
||||
## 과장 금지 지점
|
||||
|
||||
- "Pact가 항상 우월하다" → ❌. ca-tmpl 같은 single-team / 단일 release train 환경에는 over-engineering. JSON snapshot이 비용 대비 충분.
|
||||
- "binary pass/fail이 모든 품질 측정의 절대 기준" → ❌. **skeleton 도입 gate 한정**. 운영 SLO나 코드 품질 maturity 측정에 그대로 쓰면 안 됨.
|
||||
- "11 gate 검증 자동화를 완성했다" → ❌. 일부 gate는 구현됐지만 전체 완성으로 쓰지 않는다.
|
||||
- "Testcontainers 5min budget을 보장한다" → ❌. 정책 선언, 실측 / 강제 mechanism 없음.
|
||||
- "registry YAML이 SSOT다" → ❌. **markdown이 SSOT**, YAML은 generated constants.
|
||||
|
||||
### Blog-topic ingest: verification/scorecard 묶음 (2026-07-02)
|
||||
|
||||
[[raw/blog-topics/contract-verification-suite-release-gates-2026-07-02]] 는 skeleton 운영 계약을 문서로만 두지 않고 release-blocking test suite로 묶는 이유를 블로그로 풀기 위한 raw seed다.
|
||||
|
||||
- **canonical 반영 범위**: verification suite/release gate 글감을 governance/registry/scorecard canonical에 연결했다.
|
||||
- **blogify 전 조건**: 충족. 이 문서는 2026-07-02 기준 코드와 `./gradlew check`로 검증됨. 단 hosted CI/prod evidence는 분리한다.
|
||||
- **블로그 전 과장 방지**: verifier 자동화나 release 차단 운영 사례가 이미 있다고 쓰지 않는다. 정의/정책/로컬 검증 범위를 구분한다.
|
||||
- [[raw/blog-topics/binary-readiness-scorecard-clean-architecture-skeleton-2026-07-02]]: 좋아 보이는 skeleton과 도입 가능한 skeleton을 15개 영역의 binary gate로 분리하는 글감. local readiness를 production readiness나 외부 채택 가능성으로 확대하지 않는다.
|
||||
- [[raw/blog-topics/contract-registry-schema-owner-vs-row-owner-gate-2026-06-20]]: contract registry에서 schema owner와 row owner를 분리하고 schema gate가 reference row 면제를 명시적으로 검증해야 하는 이유를 다루는 글감. schema 정합을 token 사용 강제나 runtime verification으로 확대하지 않는다.
|
||||
- [[raw/blog-topics/test-taxonomy-archunit-enforcement-2026-06-19]]: test taxonomy를 README 컨벤션이 아니라 ArchUnit import graph rule로 강제하는 글감. 테스트 품질 전체 보장이 아니라 level misplacement와 dependency boundary 방지로 제한한다.
|
||||
- [[raw/blog-topics/archunit-violations-as-data-pattern-2026-05-28]]: fitness function 자체를 negative fixture로 검증하는 글감. governance/test scorecard 관점에서는 non-vacuity proof pattern으로 연결한다.
|
||||
- [[raw/blog-topics/archunit-testcompileonly-fixture-annotation-pattern-2026-06-02]]: `testCompileOnly` 타입을 ArchUnit fixture에서 annotation-only로 안전하게 참조하는 글감. 모든 fixture 참조 패턴에 일반화하지 않는다.
|
||||
|
||||
## 관련 개념
|
||||
|
||||
- [[wiki/concepts/skeleton-governance-registry-verification-test-scorecard]]
|
||||
|
||||
## Sources
|
||||
|
||||
- [[raw/project-notes/ca-skeleton-operational-contract]] — §12 (Verification), §21 (Registry), §27 (Scorecard), §29 G-G (Test taxonomy)
|
||||
- [[raw/branch-notes/feature-contract-registry-governance]]
|
||||
- [[raw/branch-notes/feature-contract-verification-test-suite]]
|
||||
- [[raw/blog-topics/contract-verification-suite-release-gates-2026-07-02]] — contract verification suite/release gate 블로그 글감 raw seed
|
||||
- [[raw/branch-notes/feature-implementation-readiness-scorecard]]
|
||||
- [[raw/blog-topics/binary-readiness-scorecard-clean-architecture-skeleton-2026-07-02]] — binary readiness scorecard 블로그 글감 raw seed
|
||||
- [[raw/blog-topics/contract-registry-schema-owner-vs-row-owner-gate-2026-06-20]] — registry schema owner vs row owner gate 블로그 글감 raw seed
|
||||
- [[raw/blog-topics/test-taxonomy-archunit-enforcement-2026-06-19]] — test taxonomy ArchUnit enforcement 블로그 글감 raw seed
|
||||
- [[raw/blog-topics/archunit-violations-as-data-pattern-2026-05-28]] — violations-as-data ArchUnit fixture 블로그 글감 raw seed
|
||||
- [[raw/blog-topics/archunit-testcompileonly-fixture-annotation-pattern-2026-06-02]] — ArchUnit `testCompileOnly` fixture annotation-only 패턴 블로그 글감 raw seed
|
||||
- [[raw/branch-notes/feature-test-taxonomy-fixture-contract]]
|
||||
- [[raw/branch-notes/feature-implementation-readiness-scorecard]]
|
||||
|
||||
## Cluster / 묶음
|
||||
|
||||
<!-- GENERATED: derived-blogs:start -->
|
||||
- [[wiki/blog/ca-tmpl-skeleton-governance-registry-verification-test-scorecard-2026-07-02]]
|
||||
<!-- GENERATED: derived-blogs:end -->
|
||||
@@ -0,0 +1,142 @@
|
||||
---
|
||||
title: ca-tmpl - 이벤트 스트리밍 미지원 결정 + ArchUnit 정적 강제
|
||||
source_type: project
|
||||
status: verified
|
||||
confidence: high
|
||||
tags: [ca-skeleton, streaming, archunit, actually-implemented]
|
||||
related_projects: [ca-skeleton, ca-tmpl]
|
||||
last_reviewed: 2026-07-02
|
||||
---
|
||||
|
||||
# ca-tmpl - 이벤트 스트리밍 미지원 결정 + ArchUnit 정적 강제
|
||||
|
||||
> Layer: `wiki/projects/` — 내 프로젝트 사실. SSE / WebSocket / long-polling / chunked 의 일반 trade-off 는 [[wiki/concepts/streaming-response-patterns]] 참조.
|
||||
>
|
||||
> **핵심 framing**: 본 문서가 `actually-implemented` 로 주장하는 것은 **"스트리밍 지원" 이 아니라 "스트리밍 미지원을 빌드타임에 강제하는 ArchUnit 가드레일"** 이다. ca-skeleton 은 이벤트/server-push 스트리밍을 **지원하지 않으며**, 그 미지원을 코드(ArchUnit rule)로 못박았다. 스트리밍 지원 계약 자체는 `planned`(보류).
|
||||
|
||||
## 프로젝트 컨텍스트
|
||||
|
||||
- **프로젝트**: ca-tmpl — Clean Architecture 기반 백엔드 skeleton 템플릿.
|
||||
- **결정**: ca-skeleton 은 **이벤트/server-push 스트리밍(SSE · WebSocket)을 default 미지원으로 확정** (D1) 하고, 그 미지원을 **ArchUnit import-ban rule 3개로 정적 강제** (D3) 한다. controller/adapter 가 streaming API 를 import 하면 build 가 실패한다.
|
||||
- **왜 미지원을 *결정* 으로 다루는가**: streaming-response 는 독립 결정이 아니라 *통신/전송 프로토콜 계약(HTTP vs gRPC vs streaming)의 한 facet* 이다. 전송 프로토콜은 모든 파생 프로젝트의 기본기로 박을 근거가 가장 약한, skeleton 에서 *가장 마지막에 고정* 해야 할 영역. 현재 sample-portfolio fixture 에 server-push use case 가 없으므로 (YAGNI / speculative generality 회피) "미지원 default + ArchUnit 차단" 을 택했다. 단순한 누락이 아니라 *의도적 미지원 + 정적 강제* 라는 점이 차이다.
|
||||
- **용어 주의 (핵심)**: 여기서 "streaming response" = **이벤트/server-push 스트리밍** (통신 모델이 request-response → server-push 로 바뀌는 것). 대용량 파일 다운로드용 `StreamingResponseBody`(응답 body 청크 전송, 통신 모델은 여전히 request-response)는 **별개 관심사이며 차단 대상이 아니다** — [[raw/branch-notes/feature-file-resource-handling-contract]] D8 소유.
|
||||
- **결정 SSOT**: [[raw/branch-notes/feature-streaming-response-contract]] (D1/D3 in-scope + D2 보류 + Decision Evidence Map). 본 문서는 그 중 *실제 코드로 구현된* 사실만 추출한다.
|
||||
- **진행 단계**: **코드 구현 + 로컬 검증 완료** (ArchUnit rule 3개 + violations-as-data fixtures + over-block guard). 운영 배포 / 측정값 없음.
|
||||
|
||||
## Ground-truth 대조 (2026-06-04, ca-tmpl @9693d72 "이벤트 스트리밍 미지원 ArchUnit 검증")
|
||||
|
||||
`/home/donghyeon/workspace/ca-tmpl` 코드를 직접 읽어 검증한 사실 (D3 구현 커밋 `9693d72`; 현재 checkout HEAD = `db61075`, 본 streaming 코드는 HEAD 에 그대로 잔존):
|
||||
|
||||
- 패키지 root 는 `dev.caskeleton.*`.
|
||||
- **3개 D3 rule 실재** — `src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/architecture/CleanArchitectureTest.java` 의 `// ---- feature-streaming-response-contract D3 ----` 블록 (line 673~718): `no_sse_emitter`, `no_response_body_emitter`, `no_websocket_handler`. 셋 다 `noClasses().that().resideInAPackage("dev.caskeleton..").should().dependOnClassesThat()...` + `.allowEmptyShould(true)` 형태.
|
||||
- **scan 범위 = production only** — class 레벨 `@AnalyzeClasses(packages = "dev.caskeleton", importOptions = ImportOption.DoNotIncludeTests.class)`. 테스트 fixture 는 scope 밖.
|
||||
- **production 코드에 streaming import 0건** — `grep -rln "SseEmitter|ResponseBodyEmitter|web.socket|jakarta.websocket" src/ | grep -v /test/` → 결과 없음. 즉 미지원(ban)이 실제이며 예외 production 사용처 없음.
|
||||
- **violations-as-data fixtures 실재** (`..architecture/violations/streaming/`): `SseEmitterUsingFixture`, `ResponseBodyEmitterUsingFixture`, `SpringWebSocketHandlerFixture`(`@EnableWebSocket`), `JakartaWebSocketEndpointFixture`(`@ServerEndpoint`).
|
||||
- **over-block guard fixture 실재** (`..architecture/allowed/streaming/`): `StreamingResponseBodyAllowedFixture` — 3개 rule 모두 이것을 *잡지 않아야* 정상(파일 다운로드 회귀 방지).
|
||||
- **WebSocket fixture 격리 corpus** — `ArchitectureViolationFixtureTest` 가 `SPRING_WEBSOCKET_FIXTURE_ONLY` / `JAKARTA_WEBSOCKET_FIXTURE_ONLY` 로 spring·jakarta glob 을 *각각 독립 import* 해 평가 (공유 풀에서 한 glob 만 동작해도 통과하던 vacuous-pass 갭 차단).
|
||||
|
||||
## 실제 구현 내용 (`actually-implemented`)
|
||||
|
||||
ca-tmpl 코드에서 직접 확인한 산출물. **차단(ban) 가드레일 + 근거** 가 구현 실체다.
|
||||
|
||||
**D3 ArchUnit rule 3개** (`app-bootstrap/.../architecture/CleanArchitectureTest.java`):
|
||||
|
||||
| rule | 차단 대상 FQN / 패키지 | 메커니즘 | 근거(차단 대상 정의) |
|
||||
|---|---|---|---|
|
||||
| `no_sse_emitter` | `org.springframework.web.servlet.mvc.method.annotation.SseEmitter` | `dependOnClassesThat().haveFullyQualifiedName(...)` | `SPRING-ASYNC-C4` (`SseEmitter` = `ResponseBodyEmitter` subclass, W3C SSE 포맷) |
|
||||
| `no_response_body_emitter` | `...ResponseBodyEmitter` | 동일 (단일 FQN) | `SPRING-ASYNC-C3` (`ResponseBodyEmitter` = 객체 stream emit, SSE 의 base) |
|
||||
| `no_websocket_handler` | `org.springframework.web.socket..` + `jakarta.websocket..` (패키지 glob) | `dependOnClassesThat().resideInAnyPackage(...)` | `RFC6455-C1` (full-duplex). spring-websocket handler/STOMP + Jakarta `@ServerEndpoint` 표면 일괄 차단 |
|
||||
|
||||
- 셋 다 대상 = `dev.caskeleton..` production code. `.allowEmptyShould(true)` (현재 production 에 streaming 클래스 미사용이므로 빈 결과 허용).
|
||||
- **명시적 비-차단 (의도적)**: `StreamingResponseBody`(대용량 다운로드, request-response 모델 유지) 는 차단 *안 함* — [[raw/branch-notes/feature-file-resource-handling-contract]] D8 소유. blanket ban 시 파일 다운로드 build 가 깨지므로 의도적으로 제외. rule Javadoc 에 이 경계가 명시됨.
|
||||
- **suite host** = boundary branch 의 ArchUnit suite ([[raw/branch-notes/feature-boundary-validation-mapping-contract]] D5 `no_problem_detail_usage` 와 동일 import-ban 메커니즘 선례). archunit-junit5 1.3.0 (project §34 Stack Commitment).
|
||||
|
||||
**테스트 fixtures** (`testCompileOnly` 의존 + annotation-only 참조 패턴):
|
||||
|
||||
- violations-as-data: `SseEmitterUsingFixture`, `ResponseBodyEmitterUsingFixture`, `SpringWebSocketHandlerFixture`, `JakartaWebSocketEndpointFixture` — 각 rule 이 위반을 *실제로 잡아내는지* 검증.
|
||||
- over-block guard: `StreamingResponseBodyAllowedFixture` — 3개 rule 이 이것을 *잡지 않는지* (false positive 없음) 검증.
|
||||
- WebSocket fixture 는 `@EnableWebSocket`(spring) / `@ServerEndpoint`(jakarta) annotation-only 참조 — `testCompileOnly` jar 가 runtime classpath 에 없어 `extends` 시 `NoClassDefFoundError` 가 나던 문제를 annotation lazy-access 로 회피 ([[raw/errors/archunit-testcompileonly-class-loading-2026-06-02]]).
|
||||
|
||||
## 로컬/dev 검증 (`locally-verified`)
|
||||
|
||||
- `CleanArchitectureTest` (D3 3개 rule 포함) + `ArchitectureViolationFixtureTest` (위 fixtures) GREEN — 2026-06-02 기준 ArchUnit 33 rules / FixtureTest 24 tests 모두 통과로 branch-note 에 기록.
|
||||
- 검증한 사실:
|
||||
- `no_sse_emitter` / `no_response_body_emitter` 가 `SseEmitter`·`ResponseBodyEmitter` import fixture 를 실제로 위반으로 잡음.
|
||||
- `no_websocket_handler` 의 `org.springframework.web.socket..` + `jakarta.websocket..` glob 이 spring·jakarta fixture 를 각각 격리 corpus 에서 잡음 (over-block 없음).
|
||||
- `StreamingResponseBodyAllowedFixture` 가 3개 rule 어디에도 안 걸림 (file-resource D8 다운로드 회귀 방지).
|
||||
- 검증 범위는 **JVM 정적 분석(ArchUnit bytecode) + 단위 테스트까지**. 실제 SSE/WebSocket 연결을 띄워 동작/부하를 본 것이 아니다 (애초에 미지원이므로 그런 통합 테스트 없음).
|
||||
|
||||
## 운영 검증 (`prod-verified`)
|
||||
|
||||
**없음.** 운영 환경에 배포된 적이 없다. connection 수 / event throughput / 인시던트 / 릴리즈 노트 어느 것도 없다 (스트리밍 자체가 미지원이므로 운영 streaming 지표도 존재하지 않는다).
|
||||
|
||||
## 문서/계획만 존재 (`documented-only` / `planned`)
|
||||
|
||||
다음은 설계/문서/보류 상태이며 **면접에서 "구현했다 / 지원한다"고 말하면 안 된다**.
|
||||
|
||||
- **이벤트 스트리밍 지원 계약 전체 (D2)**: `planned` / not-adopted. *만약* 지원하기로 하면 필요한 ① 매커니즘 선택(SSE vs WebSocket — 재개 시 SSE 우선) ② event envelope shape(envelope `{success,data,meta}` 적용 여부 vs SSE 고유 `event:/data:` 포맷) ③ per-event trace context 전파 ④ timeout/heartbeat/reconnect/connection cap ⑤ reverse proxy 설정 의무 — **전부 보류**. 근거 raw 6개는 branch-note §Sources 에 보존.
|
||||
- **재개 트리거**: (a) 실제 server→client push use case 등장 (실시간 알림 / LLM token streaming / 대용량 export 진행률) 또는 (b) 통신/전송 프로토콜 계약 branch 착수. 재개 시 D3 SSE 차단 rule 을 명시적으로 해제해야 함.
|
||||
- **per-event trace span 정책 (OPEN)**: tracing branch ([[raw/branch-notes/feature-distributed-tracing-contract]] D5/D7)는 traceparent 를 *request 단위* 로만 전파 — "한 long-lived connection 의 N개 event 에 traceId 를 어떻게" 는 기존 Decision 으로 닫히지 않는 진짜 OPEN 갭. D2 재개 시 동시 결정 필요.
|
||||
- **미지원 시 비동기 우회 경로**: server-push 가 필요하면 LRO polling([[raw/branch-notes/feature-api-contract-baseline]] D17: 202 + `Location` + polling + `Retry-After`) 또는 webhook outbound([[raw/branch-notes/feature-webhook-outbound-contract]], 미결정). 본 branch 가 작성한 코드 아님 — 형제 branch 결정 재사용.
|
||||
|
||||
## 면접에서 말할 수 있는 범위
|
||||
|
||||
### 자신 있게 답할 수 있는 질문
|
||||
|
||||
- ca-skeleton 이 이벤트 스트리밍을 왜 *미지원으로 결정* 했는가 — 전송 프로토콜은 가장 마지막에 고정할 facet + 현재 fixture 에 server-push use case 부재(YAGNI) + api-contract-baseline 의 "request-response only" 선언과의 일관성.
|
||||
- 그 미지원을 *어떻게 강제* 했는가 — 단순 누락이 아니라 ArchUnit import-ban rule 3개(`no_sse_emitter` / `no_response_body_emitter` / `no_websocket_handler`)로 production 코드가 streaming API 를 import 하면 build 실패. boundary branch 의 `no_problem_detail_usage` import-ban 선례를 차용.
|
||||
- `StreamingResponseBody` 를 왜 차단 *안* 했는가 — 그것은 server-push 가 아니라 대용량 다운로드(request-response 모델 유지)이고 file-resource D8 소유. blanket ban 했으면 다운로드 build 가 깨졌을 것. *무엇을 차단하고 무엇을 제외했는지의 경계* 를 설명할 수 있음.
|
||||
- rule 동작을 어떻게 보증했는가 — violations-as-data fixtures 로 "위반을 실제로 잡는지" + over-block guard fixture 로 "허용 케이스를 안 잡는지" 양방향 검증. WebSocket spring/jakarta glob 은 격리 import corpus 로 각각 독립 검증(vacuous-pass 차단).
|
||||
- `testCompileOnly` fixture 에서 `NoClassDefFoundError` 를 어떻게 피했는가 — `extends TextWebSocketHandler` 대신 `@EnableWebSocket` annotation-only 참조 (annotation 은 JVM lazy access 라 class load 시 불필요, ArchUnit bytecode 분석은 정상).
|
||||
|
||||
### 적당히 답할 수 있는 질문
|
||||
|
||||
- SSE vs WebSocket vs long-polling vs chunked 의 일반 trade-off (단방향 vs 양방향, HTTP 인프라 재사용, proxy 부담). (개념 수준 — [[wiki/concepts/streaming-response-patterns]].)
|
||||
- 재개 시 왜 SSE 를 우선 후보로 두는가 — 단방향 push 에 적합 + 기존 HTTP 인프라 재사용 + WebSocket 대비 proxy 부담 낮음 (WHATWG-SSE-C / SPRING-ASYNC-C4 근거).
|
||||
- SSE/WebSocket 운영 부담의 *일반적* 성격 (thundering herd, fan-out, 이벤트 유실) — 우아한형제들 사례를 *참고* 로 인용하되 공식 best practice 로 말하지 않음.
|
||||
|
||||
### 답하면 안 되는 질문 (모른다고 해야 함)
|
||||
|
||||
- "ca-skeleton 에서 SSE/WebSocket 을 구현/지원하는가?" → **미지원. 오히려 ArchUnit 으로 차단했다.**
|
||||
- "스트리밍 응답을 운영에서 돌려봤는가 / connection 부하를 측정했는가?" → **미지원이므로 그런 운영 지표 없음.**
|
||||
- "per-event trace span / reconnect / connection cap 정책을 설계했는가?" → **D2 보류. 설계 안 함.**
|
||||
|
||||
## 과장 금지 지점
|
||||
|
||||
- **"스트리밍을 지원/구현했다" → 절대 금지.** 구현한 것은 *미지원을 강제하는 차단 rule* 이지 스트리밍 기능이 아니다.
|
||||
- **"운영에서 검증했다 / prod 에서 돌고 있다" → 금지.** 정적 분석(ArchUnit) + 단위 테스트까지가 검증 범위.
|
||||
- **우아한형제들 SSE/WebSocket 사례를 "공식 best practice" 로 인용 → 금지.** company-case-study 이며 ca-skeleton 규모에 그대로 일반화 불가.
|
||||
- **"미지원이 정답이다" → 단정 금지.** real-time 요구가 있는 도메인이면 결정이 달라진다 — skeleton 의 minimalist default 일 뿐, 도입 가능성은 열어둠(D2).
|
||||
- **`StreamingResponseBody` 도 차단했다고 말하기 → 금지.** 명시적으로 *제외* 했다 (file-resource D8 경계).
|
||||
|
||||
### Blog-topic ingest: streaming-response-not-supported-archunit-ban (2026-07-02)
|
||||
|
||||
[[raw/blog-topics/streaming-response-not-supported-archunit-ban-2026-07-02]] 는 SSE/WebSocket을 지금 지원하지 않는다는 결정을 문서 선언이 아니라 ArchUnit import-ban으로 고정한 이유를 블로그로 풀기 위한 raw seed다.
|
||||
|
||||
- **locally-verified 로 말할 수 있는 부분**: `SseEmitter`, `ResponseBodyEmitter`, Spring/Jakarta WebSocket import-ban rule과 fixture 검증.
|
||||
- **project-local policy 로 말할 부분**: ca-tmpl skeleton의 sync baseline/minimal default에서는 streaming을 기본 surface로 열지 않는다.
|
||||
- **블로그 전 과장 방지**: streaming 기술 자체가 나쁘다는 결론으로 쓰지 않고, `StreamingResponseBody` 제외 경계와 D2 보류 범위를 보존한다.
|
||||
- [[raw/blog-topics/archunit-testcompileonly-fixture-annotation-pattern-2026-06-02]]: `testCompileOnly` WebSocket/Jakarta fixture가 JUnit discovery에서 class loading failure를 내는 문제를 annotation-only 참조로 피한 글감. annotation-only가 모든 fixture 참조를 안전하게 만든다고 쓰지 않는다.
|
||||
|
||||
## 관련 개념
|
||||
|
||||
- [[wiki/concepts/streaming-response-patterns]] — SSE vs WebSocket vs long-polling vs chunked transfer 의 일반 trade-off, sync-baseline rationale, 언제 스트리밍이 가치 있고 언제 아닌가.
|
||||
|
||||
## Sources
|
||||
|
||||
- [[raw/branch-notes/feature-streaming-response-contract]] — D1(미지원 확정) / D3(ArchUnit 강제) / D2(지원 계약 보류) + Decision Evidence Map + 구현 가이드(2026-06-02). 본 문서의 결정 SSOT.
|
||||
- [[raw/blog-topics/streaming-response-not-supported-archunit-ban-2026-07-02]] — streaming 미지원 + ArchUnit ban 블로그 글감 raw seed. canonical 반영 범위: verified import-ban rule + skeleton scope decision + 과장 금지 항목.
|
||||
- [[raw/blog-topics/archunit-testcompileonly-fixture-annotation-pattern-2026-06-02]] — ArchUnit `testCompileOnly` fixture annotation-only 패턴 블로그 글감 raw seed.
|
||||
- [[raw/project-notes/ca-skeleton-operational-contract]] — §3 Structured API Response Contract, §13 API Contract Surface, §34 Stack Commitment (archunit-junit5 1.3.0).
|
||||
- [[raw/branch-notes/feature-boundary-validation-mapping-contract]] — D5 `no_problem_detail_usage` (import-ban 메커니즘 선례 + ArchUnit suite host).
|
||||
- [[raw/branch-notes/feature-file-resource-handling-contract]] — D8 (`StreamingResponseBody` 소유, 차단 제외 경계).
|
||||
- [[raw/errors/archunit-testcompileonly-class-loading-2026-06-02]] — `testCompileOnly` fixture `NoClassDefFoundError` + annotation-only 해결 패턴.
|
||||
- ca-tmpl @9693d72 코드 (ground-truth): `src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/architecture/CleanArchitectureTest.java` (line 673~718, D3 rule 3개), `.../architecture/violations/streaming/{SseEmitterUsingFixture,ResponseBodyEmitterUsingFixture,SpringWebSocketHandlerFixture,JakartaWebSocketEndpointFixture}.java`, `.../architecture/allowed/streaming/StreamingResponseBodyAllowedFixture.java`, `.../architecture/ArchitectureViolationFixtureTest.java`.
|
||||
|
||||
> Claim ID / Decision Evidence Map / UNSUPPORTED_DECISION: handled per branch-note Decision Evidence Map.
|
||||
|
||||
## Cluster / 묶음
|
||||
|
||||
<!-- GENERATED: derived-blogs:start -->
|
||||
- [[wiki/blog/ca-tmpl-streaming-response-support-2026-07-02]]
|
||||
<!-- GENERATED: derived-blogs:end -->
|
||||
@@ -0,0 +1,142 @@
|
||||
---
|
||||
title: ca-tmpl - Transaction Boundary Abstraction 결정 (TransactionPort)
|
||||
source_type: project
|
||||
status: verified
|
||||
confidence: high
|
||||
tags: [ca-tmpl, transaction, application-layer, actually-implemented]
|
||||
related_projects: [ca-tmpl]
|
||||
last_reviewed: 2026-07-02
|
||||
---
|
||||
|
||||
# ca-tmpl - Transaction Boundary Abstraction 결정 (TransactionPort)
|
||||
|
||||
> Layer: `wiki/projects/` — 내 프로젝트 사실. 일반 개념은 [[wiki/concepts/transaction-boundary-abstraction]] 참조.
|
||||
|
||||
## 프로젝트 컨텍스트
|
||||
|
||||
- **프로젝트**: ca-tmpl — Clean Architecture 기반 백엔드 skeleton 템플릿.
|
||||
- **목표**: application layer가 Spring transaction API(`@Transactional`, `PlatformTransactionManager`, `TransactionTemplate`)를 직접 import하지 않도록 `TransactionPort` abstraction을 도입.
|
||||
- **이유**: Clean Architecture / Hexagonal 의존성 규칙("application은 framework를 모른다")을 트랜잭션 경계까지 일관되게 적용하기 위함. 부차적으로 use case 단위 테스트에서 Spring context 없이 트랜잭션 경계를 검증할 수 있도록 testability 확보.
|
||||
- **진행 단계**: **Phase C2 (코드) 구현 + 로컬 검증 완료.** `feature-application-port-usecase-contract` 브랜치에서 contract type, Spring 구현체, ArchUnit fitness function, 단위 테스트, sample 모듈 마이그레이션까지 작성되어 코드 베이스에 존재한다. 운영 배포 / 통합(DB) 테스트 / 측정값은 아직 없다.
|
||||
|
||||
## Ground-truth 대조 (2026-06-04, ca-tmpl @ffb0e13 "트랜잭션 포트와 웹 설정")
|
||||
|
||||
`/home/donghyeon/workspace/ca-tmpl` 의 commit `ffb0e13` (본 브랜치 구현 커밋) 코드를 직접 읽어 검증한 사실:
|
||||
|
||||
- 패키지 root 는 `dev.caskeleton.*` (브랜치 노트의 이전 stale 값 `com.example.blog` 아님). 본 문서의 이전 "구현 없음" 서술이 stale 이었음 — 실제로는 구현 완료 상태.
|
||||
- contract type 들은 `src/application-core/.../application/transaction|usecase|command|query|capability` 에 실재.
|
||||
- `SpringTransactionPort` 는 `src/adapter-persistence/.../transaction/SpringTransactionPort.java` 에 실재 (`@Component`, `PlatformTransactionManager` 주입, 모드별 pre-built `TransactionTemplate` 3개).
|
||||
- ffb0e13 시점의 reference sample 모듈명은 **`sample-ticket`** (`PostService` / `UserService`). 이후 커밋(현재 HEAD `db61075`)에서 **`sample-portfolio`** (`WorkLog*` use case) 로 rename 됨. 본 문서는 ffb0e13 기준 사실을 기록하되, 모듈 rename 은 후속 브랜치 사실로 본다.
|
||||
- `./gradlew :application-core:test :adapter-persistence:test :app-bootstrap:test --tests '*CleanArchitectureTest' --tests '*ArchitectureViolationFixtureTest'` → 현재 checkout 기준 PASS (exit 0, 2026-06-04 재실행).
|
||||
|
||||
## 실제 구현 내용 (`actually-implemented`)
|
||||
|
||||
ca-tmpl @ffb0e13 코드에서 직접 확인한 산출물:
|
||||
|
||||
**application-core (contract types, `dev.caskeleton.application.*`)**
|
||||
|
||||
- `transaction/TransactionPort.java` — outbound port. `<T> T inWrite(Supplier<T>)` / `inRead(Supplier<T>)` / `inNew(Supplier<T>)` 3 메서드 + `Runnable` default 오버로드 3개. Javadoc 에 D11(`Supplier`/`Runnable` 만 받아 checked exception 차단 → 호출 측 `RuntimeException` wrap) + D12(`inNew` = REQUIRES_NEW = 새 physical JDBC connection, pool-sizing 공식 `hikari.maximumPoolSize >= (concurrent_threads * (1 + max_inNew_depth)) + 1`, loop 내 호출 forbidden) 명시.
|
||||
- `transaction/TransactionMode.java` — `WRITE` / `READ_ONLY` / `REQUIRES_NEW` 3값.
|
||||
- `transaction/Isolation.java` — `READ_COMMITTED` **단일 값만 노출** (REPEATABLE_READ / SERIALIZABLE 은 `feature-transaction-concurrency-contract` 로 위임, READ_UNCOMMITTED 는 forbidden).
|
||||
- `usecase/UseCase.java` / `CommandUseCase.java` / `QueryUseCase.java` — inbound port base + command/query 분리.
|
||||
- `command/Command.java` / `query/Query.java` — write/read intent marker.
|
||||
- `capability/UseCaseCapability.java` — runtime-retained annotation (필수 필드). `capability/Idempotency.java` — `IDEMPOTENT` / `KEYED` / `NOT_IDEMPOTENT`. `capability/RepositoryAccess.java` — `NONE` / `READ_REPOSITORY` / `WRITE_REPOSITORY`.
|
||||
- `application-core/build.gradle` — `spring-tx` 의존을 의도적으로 선언하지 않음 (주석으로 사유 명시). `spring-boot-starter` 는 유지(DI 목적, D13).
|
||||
|
||||
**adapter-persistence**
|
||||
|
||||
- `transaction/SpringTransactionPort.java` — `TransactionPort` 의 Spring 구현. 생성자에서 모드별 `TransactionTemplate` 3개(write / read / requiresNew)를 미리 빌드. 모두 `ISOLATION_READ_COMMITTED` pin. write=REQUIRED+readOnly false, read=REQUIRED+readOnly true, requiresNew=REQUIRES_NEW+readOnly false. 호출당 mutation 으로 인한 동시성 race 차단.
|
||||
|
||||
**app-bootstrap (ArchUnit fitness functions)** — `architecture/CleanArchitectureTest.java` 에 다음 rule 실재:
|
||||
|
||||
- `application_does_not_use_spring_transactional_annotation` — application 패키지에서 `org.springframework.transaction.annotation.Transactional` 의존 금지 (D3).
|
||||
- `inbound_port_implementations_end_with_use_case` — `CommandUseCase`/`QueryUseCase` 구현은 `UseCase` suffix 강제 (D1).
|
||||
- `inbound_port_implementations_declare_capability` — 모든 use case 구현에 `@UseCaseCapability` 강제.
|
||||
- `inbound_port_implementations_do_not_declare_keyed_idempotency` — custom `ArchCondition` 으로 `Idempotency.KEYED` 선언 차단 (D14 freeze, `feature-rate-limit-idempotency-contract` merge 시 제거 예정).
|
||||
- `application_does_not_depend_on_application_context` (D11), `application_does_not_depend_on_adapters_or_transport` (+`org.springframework.web..` 추가), `domain_is_pure`.
|
||||
- `ArchitectureViolationFixtureTest` + `architecture/violations/` 의 의도된 위반 fixture 클래스들 — violations-as-data 네거티브 테스트.
|
||||
|
||||
**sample 모듈 마이그레이션 (ffb0e13: `sample-ticket`)**
|
||||
|
||||
- `sample-ticket/.../application/PostService.java`, `UserService.java` — 기존 `@Transactional` 을 전부 제거하고 `tx.inWrite(...)` / `tx.inRead(...)` 호출로 교체. `TransactionPort` 를 생성자 주입.
|
||||
- `sample-ticket/.../adapter/persistence/repository/PostRepositoryAdapter.java` — `deleteByAuthorId` 의 `@Transactional` 제거 (트랜잭션은 호출 측 use case 가 소유).
|
||||
|
||||
## 로컬/dev 검증 (`locally-verified`)
|
||||
|
||||
- 단위 테스트 PASS: `application-core` (`TransactionPortTest` Supplier/Runnable delegation, `UseCaseCapabilityTest`, `UseCaseContractTest`), `adapter-persistence` (`SpringTransactionPortTest` — 모드별 propagation / isolation / readOnly / rollback-on-exception 확인).
|
||||
- ArchUnit fitness function PASS: `CleanArchitectureTest` (위 rule들) + `ArchitectureViolationFixtureTest` (각 rule 이 의도된 위반 fixture 를 실제로 잡아냄).
|
||||
- `./gradlew check` green (브랜치 노트 기록: 25 actionable tasks). 2026-06-04 재실행 시 위 핵심 test task 들 exit 0 확인.
|
||||
- 검증 범위는 JVM 단위 테스트 + 정적 분석까지. **실 DB 통합 테스트는 아직 없음** (아래 planned 참조).
|
||||
|
||||
## 운영 검증 (`prod-verified`)
|
||||
|
||||
**없음.** 운영 환경에 배포된 적이 없다. 측정값 / 인시던트 / 릴리즈 노트 어느 것도 없다.
|
||||
|
||||
## 문서/계획만 존재 (`documented-only` / `planned`)
|
||||
|
||||
다음 항목은 설계/문서/위임 상태이며 **면접에서 "구현했다 / 검증했다"고 말하면 안 된다**.
|
||||
|
||||
- **`TransactionalUseCaseRunner` 대안**: 검토 후 미채택. 단일 abstraction(`TransactionPort`)만 채택했으므로 코드에 존재하지 않는다 (`documented-only`).
|
||||
- **`REPEATABLE_READ` / `SERIALIZABLE` isolation**: `Isolation` enum 에 노출하지 않음. `feature-transaction-concurrency-contract` 로 위임 (`documented-only`).
|
||||
- **`inNew` (REQUIRES_NEW) 의 outbox/audit 실제 동작 통합 테스트**: `feature-domain-event-outbox-contract` 로 위임. `max_inNew_depth` 실측은 도메인 use case별 통합 테스트 필요 (`planned`).
|
||||
- **`@UseCaseCapability(idempotency = KEYED)` 활성화**: `feature-rate-limit-idempotency-contract` merge 전까지 ArchUnit rule 로 freeze (`planned` / 의도적 차단).
|
||||
- **`externalOutboundAllowed` 의 dependency-aware ArchUnit rule** 및 **`*Port` outbound naming rule**: outbound port marker 정의 후 추가 예정 (`documented-only`).
|
||||
- **`readOnly = true` 의 driver flush-mode 변경 통합 검증**: Testcontainers 환경에서 Hibernate session statistics 측정 PoC 필요. 현재는 단위 테스트로 `TransactionTemplate.isReadOnly() == true` 만 확인 (`planned`).
|
||||
|
||||
## 면접에서 말할 수 있는 범위
|
||||
|
||||
### 자신 있게 답할 수 있는 질문
|
||||
|
||||
- 왜 application layer에서 Spring `@Transactional` 직접 부착을 금지했는가, 어떤 trade-off가 있는가. (실제 `TransactionPort` 로 구현 + ArchUnit 으로 강제까지 함.)
|
||||
- `TransactionPort` 를 어떻게 설계했는가 — `inWrite`/`inRead`/`inNew` 3 메서드, `Supplier<T>`/`Runnable` 시그니처, `READ_COMMITTED` 단일 isolation, checked exception 을 노출하지 않는 이유(D11).
|
||||
- `SpringTransactionPort` 가 모드별 `TransactionTemplate` 을 미리 빌드한 이유 (per-call mutation 의 동시성 race 차단).
|
||||
- ArchUnit fitness function 으로 `org.springframework.transaction.annotation.Transactional` import 를 실제로 차단하고, violations-as-data 네거티브 fixture 로 rule 동작을 보증한 방법.
|
||||
- AOP self-invocation 문제가 무엇이고 표준 우회가 무엇인지, `TransactionPort` abstraction 과 어떤 관계인지.
|
||||
- `REQUIRES_NEW`(`inNew`)가 새 physical connection 을 잡아 pool 을 소모하는 비용 + loop 내 호출 anti-pattern.
|
||||
|
||||
### 적당히 답할 수 있는 질문
|
||||
|
||||
- `REQUIRES_NEW` 와 `NESTED` 의 차이, JPA 에서 `NESTED` 가 일반적으로 권장되지 않는 이유 (savepoint / JDBC 한정 / provider 의존). (단 ca-tmpl 은 `NESTED` 를 API 에 노출하지 않음 — 일반 개념 수준 답변.)
|
||||
- Isolation level 4단계와 dirty/non-repeatable/phantom read 의 관계, vendor default 차이 (PostgreSQL `READ_COMMITTED` vs MySQL InnoDB `REPEATABLE_READ`).
|
||||
- 단순 CRUD vs 도메인 복잡도가 큰 프로젝트에서 `TransactionPort` 도입 trade-off 가 어떻게 다른가.
|
||||
|
||||
### 답하면 안 되는 질문 (모른다고 해야 함)
|
||||
|
||||
- "`readOnly = true` 가 실제 driver flush mode 를 바꾸는 것을 측정했는가?" → **측정 안 함. 단위 테스트로 `isReadOnly()` flag 만 확인.**
|
||||
- "`inNew` 의 outbox REQUIRES_NEW 동작을 실 DB 로 통합 검증했는가?" → **안 함. `feature-domain-event-outbox-contract` 로 위임.**
|
||||
- "운영에서 어떤 인시던트나 사례가 있었는가? 성능/지연을 `@Transactional` 과 비교 측정했는가?" → **운영 배포 없음, 측정 없음.**
|
||||
- "`KEYED` idempotency 를 실제로 적용했는가?" → **freeze 상태. ArchUnit rule 로 선언 자체를 차단 중.**
|
||||
|
||||
## 과장 금지 지점
|
||||
|
||||
- **"운영에서 검증했다 / prod 에서 돌고 있다" → 금지.** 로컬 단위 테스트 + 정적 분석까지가 검증 범위.
|
||||
- **"실 DB 통합 테스트로 트랜잭션 전파를 검증했다" → 금지.** `SpringTransactionPortTest` 는 mock `PlatformTransactionManager` 로 template 설정값만 확인한다. 실 connection 동작은 미검증.
|
||||
- **"UNIL 팀과 동일한 경로를 거쳤다" → 금지.** [[raw/company-tech-blogs/transaction-port-clean-ddd-spring-medium]](UNIL, 2024-05)는 동일 결론에 도달한 **별개 외부 사례**다.
|
||||
- **"AOP `@Transactional` 은 self-invocation 때문에 깨진다" → 단정 금지.** 표준 우회로 다수 production 에서 잘 동작한다. 함정이지 치명적 결함이 아니다.
|
||||
- **"`TransactionPort` 가 무조건 우월하다" → 금지.** 단순 CRUD + framework 교체 계획 없음 + Spring 숙련 팀이면 `@Transactional` 직접 부착이 합리적이다. Buckpal(hex-arch 공식 reference), Spring Modulith 등 OSS 다수파/공식 incubator 는 오히려 `@Transactional` 직접/meta-annotation 부착을 한다 — ca-tmpl 의 forbidden 정책은 소수파 자체 taste 임을 함께 인정.
|
||||
|
||||
### Blog-topic ingest: transaction boundary 묶음 (2026-07-02)
|
||||
|
||||
아래 raw seed들은 transaction boundary canonical에 연결했다.
|
||||
|
||||
- [[raw/blog-topics/transaction-port-abstraction-over-spring-transactional-2026-05-28]]: application 계층이 Spring `@Transactional`을 직접 import하지 않도록 `TransactionPort`와 ArchUnit fitness function을 결합한 이유를 다룬다. **주의**: `TransactionPort`가 다수파보다 우월하다고 쓰지 않고 ca-tmpl template repository 맥락의 선택으로 제한한다.
|
||||
- [[raw/blog-topics/transaction-isolation-vendor-default-pin-2026-07-02]]: DB vendor default isolation 차이를 skeleton contract에서 명시 pin/test 대상으로 다루는 이유를 다룬다. **주의**: 모든 concurrency anomaly를 isolation pin으로 해결한다고 쓰지 않는다.
|
||||
|
||||
## 관련 개념
|
||||
|
||||
- [[wiki/concepts/transaction-boundary-abstraction]]
|
||||
|
||||
## Sources
|
||||
|
||||
- [[raw/project-notes/ca-skeleton-operational-contract]] — §14 Transaction/Concurrency, §19 Domain Application Readiness, §29 Topic 2 (TransactionPort 결정 사유)
|
||||
- [[raw/branch-notes/feature-application-port-usecase-contract]] — TransactionPort interface spec, forbidden import 규칙, Decision Evidence Map (D1~D14), 구현 결과 (round 1 + round 2)
|
||||
- [[raw/blog-topics/transaction-port-abstraction-over-spring-transactional-2026-05-28]] — TransactionPort abstraction 블로그 글감 raw seed
|
||||
- [[raw/branch-notes/feature-transaction-concurrency-contract]] — isolation default, propagation default, idempotency / lock 분류
|
||||
- [[raw/blog-topics/transaction-isolation-vendor-default-pin-2026-07-02]] — transaction isolation vendor default pin 블로그 글감 raw seed
|
||||
- ca-tmpl @ffb0e13 코드 (ground-truth): `src/application-core/.../application/transaction|usecase|command|query|capability/*.java`, `src/adapter-persistence/.../transaction/SpringTransactionPort.java`, `src/app-bootstrap/.../architecture/CleanArchitectureTest.java`
|
||||
|
||||
## Cluster / 묶음
|
||||
|
||||
<!-- GENERATED: derived-blogs:start -->
|
||||
- [[wiki/blog/ca-tmpl-transaction-boundary-abstraction-2026-07-02]]
|
||||
<!-- GENERATED: derived-blogs:end -->
|
||||
@@ -0,0 +1,143 @@
|
||||
---
|
||||
title: ca-tmpl - Transactional Outbox 결정 (SKIP LOCKED polling)
|
||||
source_type: project
|
||||
status: verified
|
||||
confidence: high
|
||||
tags: [ca-tmpl, outbox, event-driven, actually-implemented, locally-verified]
|
||||
related_projects: [ca-tmpl]
|
||||
last_reviewed: 2026-07-02
|
||||
---
|
||||
|
||||
# ca-tmpl - Transactional Outbox 결정 (SKIP LOCKED polling)
|
||||
|
||||
> Layer: `wiki/projects/` — 내 프로젝트(ca-tmpl) 사실. 일반 패턴 정의는 [[wiki/concepts/transactional-outbox-pattern]] 참조.
|
||||
|
||||
## 프로젝트 컨텍스트
|
||||
|
||||
ca-tmpl은 Clean Architecture 기반 백엔드 skeleton 템플릿입니다. 도메인 변경과 외부 이벤트 발행의 정합성 요구에서 **dual-write를 회피**하기 위해 outbox table + SKIP LOCKED polling 방식을 채택한다는 운영 계약을 문서화한 상태입니다.
|
||||
|
||||
진척 상황:
|
||||
|
||||
- **C2 구현 + 로컬 검증 완료**: outbox row schema, append/store port, SKIP LOCKED claim repository, relay use case, scheduler, metrics, reaper, disabled publisher, sample event append path가 코드화되어 있다. 2026-07-02 `./gradlew check` 통과로 로컬 검증했다.
|
||||
|
||||
본 문서는 그 결정 자체와 검토한 대안, 그리고 "지금 시점에 말할 수 있는 범위"를 분리해 둡니다.
|
||||
|
||||
## 실제 구현 내용 (`actually-implemented`)
|
||||
|
||||
- `application-core`의 `OutboxAppendPort`, `OutboxStorePort`, `OutboxEvent`, `OutboxEventStatus`, `PublishPendingOutboxEventsUseCase`, `OutboxBackoffPolicy`.
|
||||
- `adapter-persistence-rdbms`의 `OutboxEventEntity`, `OutboxStoreAdapter`, `OutboxReaper`, `OutboxClaimRepository`, `OutboxEventJpaRepository`.
|
||||
- `adapter-persistence-postgresql`의 `PostgreSqlOutboxClaimRepository`와 `V3__outbox_event.sql`. claim query는 `FOR UPDATE SKIP LOCKED`를 사용한다.
|
||||
- `adapter-outbound`의 `OutboxMessagePublishAdapter`, `DisabledOutboxMessagePublisher`, `OutboxEnvelopeJson`.
|
||||
- `app-bootstrap`의 `OutboxConfig`, `OutboxSettings`, `OutboxRelayScheduler`, `OutboxMetrics`, `OutboxLeaderElectionToken`.
|
||||
- `sample-portfolio`의 `CreateWorkLogOutboxTest`와 `WorkLogReservedIntegrationEvent*` 계열이 sample domain event → integration event/outbox append path를 검증한다.
|
||||
|
||||
## 로컬/dev 검증 (`locally-verified`)
|
||||
|
||||
- `./gradlew check` 통과(2026-07-02, `BUILD SUCCESSFUL`, 114 tasks).
|
||||
- `PublishPendingOutboxEventsUseCaseTest`, `OutboxBackoffPolicyTest`, `NewOutboxEventTest`가 application relay logic을 검증한다.
|
||||
- `OutboxStoreAdapterTest`, `OutboxReaperTest`, `OutboxReaperWiringTest`가 RDBMS adapter와 cleanup wiring을 검증한다.
|
||||
- `OutboxRowLifecycleContractTest`, `OutboxPublisherLeaderElectionContractTest`, `OutboxAppendTransactionalContractTest`가 PostgreSQL Testcontainers 기반으로 row lifecycle, SKIP LOCKED multi-relay claim, transactional append를 검증한다.
|
||||
- `OutboxStatusRegistryContractTest`, `EventPayloadPiiContractTest`, `OutboxMessagePublishAdapterTest`가 registry/status, payload safety, publish adapter를 검증한다.
|
||||
|
||||
## 운영 검증 (`prod-verified`)
|
||||
|
||||
**없음.** ca-tmpl은 skeleton 템플릿이며 운영 인스턴스가 존재하지 않음.
|
||||
|
||||
## 문서/계획만 존재 (`documented-only` / `planned`)
|
||||
|
||||
다음 항목들은 모두 canonical operational contract(§11, §29 Topic 3) 및 branch-notes에 합의된 **문서/설계 수준**입니다. 구현 사실 아님.
|
||||
|
||||
### Outbox row schema (implemented)
|
||||
|
||||
- `id`, `aggregate_type`, `aggregate_id`, `event_type`, `payload`, `headers`, `status`, `attempts`, `next_attempt_at`, `created_at`, `published_at`, `last_error` 컬럼 어휘 합의.
|
||||
- row status: `PENDING → IN_FLIGHT → PUBLISHED` 정상 경로, 실패 시 `FAILED → DEAD`(DLQ).
|
||||
- per-aggregate FIFO 순서 보존을 목표로 함.
|
||||
|
||||
### Publisher state machine (implemented)
|
||||
|
||||
- claim transaction: `READ_COMMITTED` isolation + `SELECT ... FOR UPDATE SKIP LOCKED LIMIT n`.
|
||||
- multi-instance publisher 운영 시 row 단위 lock으로 중복 claim 방지.
|
||||
- publish 성공 → `PUBLISHED`로 update + commit.
|
||||
- publish 실패 → `attempts++`, `next_attempt_at` 갱신(backoff with jitter), `FAILED`로 회귀.
|
||||
- `attempts >= max(=3)` 도달 시 `DEAD`로 전이 후 DLQ 대상.
|
||||
|
||||
### Retry / DLQ vocabulary (partially implemented)
|
||||
|
||||
- exponential backoff with jitter, 최대 3회 retry, 그 이후 `DEAD` → DLQ.
|
||||
- DLQ 상태와 runbook은 존재하지만, 운영 재처리 도구/대시보드는 없다.
|
||||
|
||||
### 대안 검토 (decided, not implemented)
|
||||
|
||||
ca-tmpl이 outbox 구현 방식을 결정하면서 검토한 7종 대안과 채택 사유:
|
||||
|
||||
1. **SKIP LOCKED polling** — 채택. RDB만으로 운영 가능, Kafka Connect 인프라 불요, lag 수 초 허용 범위.
|
||||
2. **Debezium CDC** — 보류. WAL 기반으로 lag은 짧지만 Kafka Connect 클러스터·connector·slot 운영 인력 부재.
|
||||
3. **Kafka Connect Outbox SMT (Debezium event router)** — 보류. Debezium 도입 자체가 보류되므로 동반 제외.
|
||||
4. **Dual-write (직접 publish)** — 명시적 anti-pattern. 채택 안 함(outbox 채택의 negative reference).
|
||||
5. **Event sourcing** — 미채택. 전달 정합성이 아닌 도메인 모델링 결정이므로 ca-tmpl 범위 밖.
|
||||
6. **Spring `@TransactionalEventListener`** — 미채택. JVM in-process 한정이라 외부 broker 발행에는 부적합. in-process side effect 용도로만 사용 가능.
|
||||
7. **Netflix DBLog 류 자체 CDC** — 미채택. 베이스라인 인프라 투자 규모가 ca-tmpl 범위를 초과.
|
||||
|
||||
### Migration trigger (planned)
|
||||
|
||||
- 다음 가정이 깨지면 Debezium CDC로 마이그레이션 검토:
|
||||
- publish lag SLO 위반(수 초 허용을 깨는 sub-second 요구가 생김), 또는
|
||||
- polling 쿼리로 DB load가 포화되는 신호 발생.
|
||||
- 현 시점에는 가정이 유지된다고만 말할 수 있음. 도입 시점/일정 약속 없음.
|
||||
|
||||
## 면접에서 말할 수 있는 범위
|
||||
|
||||
### 자신 있게 답할 수 있는 질문
|
||||
|
||||
- **dual-write가 왜 위험한가** — DB commit과 broker publish 사이의 프로세스/네트워크 실패가 정합성을 깨는 시나리오를 설명할 수 있음.
|
||||
- **`FOR UPDATE SKIP LOCKED` semantics** — 잠긴 row를 차단 없이 skip하여 multi-instance publisher 간 claim 경합을 해소하는 원리, 잠금 범위가 row 단위 + 트랜잭션 종료 시 해제임을 설명할 수 있음.
|
||||
- **outbox cleanup 정책의 필요성** — archived row를 TTL/파티션 회전으로 정리하지 않으면 인덱스 비대·vacuum 비용 증가가 발생하는 이유.
|
||||
- **at-least-once + idempotent consumer** — outbox + 비동기 publish가 exactly-once가 아니라는 점과, consumer가 `eventId`/`idempotencyKey`로 dedupe해야 정합성이 닫힌다는 점.
|
||||
|
||||
### 적당히 답할 수 있는 질문
|
||||
|
||||
- **Debezium CDC migration trigger** — 어떤 가정(lag SLO, DB load)이 깨질 때 전환을 정당화하는지 설명 가능. 단, 실제 운영 경험은 없음.
|
||||
- **outbox row status 머신** — 어휘는 합의되어 있으나 직접 구현하지는 않았음을 전제로 설명.
|
||||
|
||||
### 답하면 안 되는 질문 (모른다고 해야 함)
|
||||
|
||||
- "outbox를 직접 구현했는가" → **구현했다.** 단 로컬/Testcontainers 검증까지이며 운영 배포 검증은 없다.
|
||||
- "polling lag을 측정해 본 수치는?" → **측정값 없음.** relay 동작 검증은 있지만 부하/lag 수치 단정 금지.
|
||||
- "DLQ 운영 / 재처리 경험" → 어휘는 정의했지만 **실제 DLQ를 운영해 본 적 없음**.
|
||||
- "production에서 outbox로 인한 인시던트 처리 경험" → 운영 인스턴스 자체가 없음.
|
||||
|
||||
## 과장 금지 지점
|
||||
|
||||
ca-tmpl을 설명할 때 사실보다 부풀려지기 쉬운 표현:
|
||||
|
||||
- **"outbox = exactly-once delivery"** → 틀림. 정확한 표현은 **at-least-once delivery + idempotent consumer**. ca-tmpl 운영 계약도 at-least-once 전제.
|
||||
- **"Debezium도 검토했고 곧 도입 예정"** → 틀림. Debezium은 검토 결과 **migration trigger만 정의된 상태**이며 도입 일정·작업 없음. "lag 가정이 깨질 때만 전환을 검토한다"가 정확.
|
||||
- **"outbox 패턴을 운영에서 검증했다"** → 틀림. 구현과 로컬/Testcontainers 검증은 있으나 운영 배포·측정은 없다.
|
||||
- **"SKIP LOCKED로 모든 동시성 문제를 막았다"** → 틀림. SKIP LOCKED는 **claim 단계 row 경합**만 해소. publish 후 commit 실패로 인한 재발행은 별개 문제이며 consumer dedupe가 해결.
|
||||
- **"event sourcing도 비교 검토했고 도입할 수 있었다"** → 과장. event sourcing은 도메인 재설계 결정이며 ca-tmpl 범위 밖. "비교군으로만 언급"이 정확.
|
||||
- **DLQ / 재처리 경험을 가진 것처럼 말하기** → 어휘 합의만 있고 운영 경험 없음.
|
||||
|
||||
### Blog-topic ingest: outbox ordering gate (2026-07-02)
|
||||
|
||||
[[raw/blog-topics/skip-locked-outbox-per-aggregate-fifo-gate-2026-06-11]] 는 `FOR UPDATE SKIP LOCKED` claim이 row 경합은 줄이지만 per-aggregate FIFO와 충돌할 수 있다는 점, 그리고 `NOT EXISTS` head gate로 tail 선발행을 막는 설계를 블로그로 풀기 위한 raw seed다.
|
||||
|
||||
- **canonical 반영 범위**: SKIP LOCKED polling 결정 문서에 ordering gate와 strict FIFO trade-off 글감을 연결했다.
|
||||
- **blogify 전 조건**: 충족. 이 문서는 2026-07-02 기준 코드와 `./gradlew check`로 검증됨.
|
||||
- **블로그 전 과장 방지**: SKIP LOCKED가 순서 보존까지 해결한다고 쓰지 않고, claim 경합 해소와 ordering gate를 분리한다.
|
||||
|
||||
## 관련 개념
|
||||
|
||||
- [[wiki/concepts/transactional-outbox-pattern]]
|
||||
|
||||
## Sources
|
||||
|
||||
- [[raw/project-notes/ca-skeleton-operational-contract]] — §11 Adapter Failure / §29 Topic 3 (outbox 결정 canonical map)
|
||||
- [[raw/branch-notes/feature-domain-event-outbox-contract]] — outbox publisher SSOT, row status, claim transaction, at-least-once + dedupe 합의
|
||||
- [[raw/branch-notes/feature-background-job-async-contract]] — outbox publisher가 공유하는 retry/DLQ vocabulary(exp backoff with jitter, max 3, DLQ exhausted) SSOT
|
||||
- [[raw/blog-topics/skip-locked-outbox-per-aggregate-fifo-gate-2026-06-11]] — SKIP LOCKED vs per-aggregate FIFO gate 블로그 글감 raw seed.
|
||||
|
||||
## Cluster / 묶음
|
||||
|
||||
<!-- GENERATED: derived-blogs:start -->
|
||||
- [[wiki/blog/ca-tmpl-transactional-outbox-pattern-2026-07-02]]
|
||||
<!-- GENERATED: derived-blogs:end -->
|
||||
Reference in New Issue
Block a user