# Conflicts: # docs/superpowers/specs/2026-07-28-messaging-production-capability-design.md # src/app-bootstrap/gradle.lockfile # src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/architecture/ArchitectureViolationFixtureTest.java # src/build.gradle
602 lines
43 KiB
Markdown
602 lines
43 KiB
Markdown
# application-core — 설계 결정 참조
|
||
|
||
애플리케이션 유스케이스 계층. 패키지 루트: `dev.caskeleton.application`.
|
||
|
||
허용/금지 의존, 유스케이스 형태, 트랜잭션 모드, 명명 규칙 같은 **모듈 규칙**은
|
||
[CLAUDE.md](CLAUDE.md) 가 SSOT 다. 이 문서는 코드 주석에서 덜어낸 **설계 결정의 근거**를
|
||
모아둔 참조용 기록이다 — 코드를 읽다 "왜 이렇게 했나"가 궁금할 때 본다. 아래 설명은 별도
|
||
추적 ID를 몰라도 읽히도록 결정의 배경과 트레이드오프를 문장으로 풀어 둔다.
|
||
|
||
이 계층을 관통하는 큰 원칙 하나: **application-core 는 프레임워크-free 다.** Spring/JPA/HTTP
|
||
타입뿐 아니라 SLF4J/JUL/Logback/Log4j/Micrometer도 직접 들이지 않고, 필요한 인프라 능력
|
||
(트랜잭션·락·인가·알림·운영 진단 등)은 전부 구체적인 목적의 `*Port` 인터페이스로 추상화한다.
|
||
구현은 adapter 모듈에 있고 컴파일 타임엔 보이지 않는다. Gradle
|
||
`verifyApplicationCoreDependencyPurity`와 ArchUnit
|
||
`APPLICATION_HAS_NO_DIAGNOSTIC_FRAMEWORK`가 이 계약을 자동 검증한다.
|
||
|
||
Cache 진단도 같은 원칙을 따른다. `CacheObservationEvent`는 code-owned bounded cache name,
|
||
local/Redis tier, enum outcome과 finite duration/count만 표현하며 semantic key, user/tenant ID,
|
||
endpoint를 담지 않는다. `CacheObservationPort`는 이 event를 전달하는 framework-free 경계이고,
|
||
Micrometer meter/tag 렌더링은 Redis adapter가 소유한다. 관측 실패는 cache lookup/invalidation
|
||
결과를 바꾸지 않는다.
|
||
|
||
---
|
||
|
||
## 메시징 semantic contract R1
|
||
|
||
`messaging.contract`와 `messaging.event`는 feature가 integration event를 동적 JSON이나 provider
|
||
타입으로 넘기지 않게 만드는 application 경계다.
|
||
|
||
- `IntegrationPayload` 구현은 feature가 소유한 불변 typed record다.
|
||
- `IntegrationEventContractContribution`은 contract ID와 payload version을 분리하고, exact final
|
||
record type token, 실제 record component 순서, repository-local schema resource/hash와
|
||
provider-neutral `ContractDescriptor`만 기여한다. assignable-type 탐색, `Class.forName`, Java
|
||
class-name routing, `Map`, raw JSON string/tree는 이 SPI에 들어오지 않는다.
|
||
- `IntegrationEventDraft`는 canonical event/aggregate/order/correlation identity와 typed payload를
|
||
보유한다. tenant가 없는 모드도 null 대신 canonical system tenant scope를
|
||
`AggregateIdentity`에 넣어 dedupe/order identity가 PostgreSQL nullable uniqueness에 기대지
|
||
않게 한다.
|
||
- `IntegrationEventEncoderPort` 뒤의 adapter가 deterministic encoding과 schema validation을
|
||
수행하고 `ValidatedIntegrationEvent`를 돌려준다. 결과는 logical destination, exact US-ASCII
|
||
partition key, exact encoded envelope bytes, schema/envelope hash와 catalog/binding revision을
|
||
defensive copy로 보존한다.
|
||
- `ContractDescriptor`는 owner module, logical destination, serializer ID, ordering requirement,
|
||
payload/envelope byte limit, sensitivity classification, same-event requeue horizon만 표현한다.
|
||
physical topic, Kafka cluster/security topology는 deployment binding의 책임이다.
|
||
|
||
이 단계의 완성 범위는 **framework-free semantic contract R1**이다. JSON Schema validator와
|
||
deterministic writer, Kafka ACK producer, PostgreSQL outbox append/relay는 후속 R2 작업이며 여기서
|
||
구현되었거나 검증됐다고 주장하지 않는다.
|
||
|
||
---
|
||
|
||
## 유스케이스 계약 (usecase / command / query / capability)
|
||
|
||
### UseCase / CommandUseCase / QueryUseCase
|
||
|
||
- inbound port(헥사고날의 primary port)다. 모든 구체 유스케이스는 쓰기면 `CommandUseCase`,
|
||
읽기면 `QueryUseCase` 를 extends 해서 **연산의 종류를 타입에 박아 둔다.** 이렇게 해야
|
||
capability·트랜잭션 모드 같은 계약을 컴파일/ArchUnit 단계에서 강제할 수 있다. 클래스 이름은
|
||
반드시 `UseCase` 로 끝난다(ArchUnit 강제).
|
||
- 제네릭 입출력 타입(`<I, O>`)에는 web 요청 DTO·JPA 엔티티·외부 클라이언트 응답 타입이 올 수
|
||
없다. 입력은 `Command`/`Query`, 출력은 도메인 객체·도메인 프로젝션·`Void` 만 허용. 이 자리에
|
||
transport 타입이 들어오면 application 계층 경계가 깨진다.
|
||
|
||
### Command / Query (마커)
|
||
|
||
- 둘 다 마커 인터페이스. `Command` = 쓰기 의도, `Query` = 읽기 의도.
|
||
- 반드시 불변 타입(가능하면 `record`)이고 도메인 타입·원시 값 객체로만 구성한다. web DTO,
|
||
JPA 엔티티, 외부 응답 타입은 필드에 넣을 수 없다 — 이게 들어오면 transport 관심사가
|
||
application 계층으로 새는 것.
|
||
|
||
### UseCaseCapability (모든 유스케이스 필수 애너테이션)
|
||
|
||
- 모든 구체 `*UseCase` 클래스에 필수. 유스케이스의 **트랜잭션 모양·멱등성·리포지토리 접근·외부
|
||
호출 여부를 본문을 읽지 않고도** 알 수 있게 만든다. ArchUnit 이 모든 구체 inbound port 에 이
|
||
애너테이션이 일관되게 붙어 있는지 검사한다.
|
||
- 일관성 규칙(ArchUnit 으로 강제):
|
||
- `QueryUseCase` 구현은 `transactionMode = READ_ONLY` + `repositoryAccess = READ_REPOSITORY` 여야 한다.
|
||
- `transactionMode = REQUIRES_NEW` 는 outbox / audit / compensation 흐름 전용이다.
|
||
- 외부 `*Port`(outbound adapter 에 바인딩된 포트)를 호출하려면 `externalOutboundAllowed = true`
|
||
가 필요하다. 없으면 outbound 어댑터 호출 금지.
|
||
- `repositoryAccess` 가 `WRITE_REPOSITORY` 가 아닌 유스케이스는 리포지토리 포트의 쓰기
|
||
메서드(save/delete/update/insert)를 호출할 수 없다 — `read_only_use_cases_do_not_call_repository_write_methods`
|
||
규칙이 막는다. 단 정적 분석은 **직접 호출만** 잡으므로, helper/mapper 를 거친 쓰기는 리뷰가 본다.
|
||
- `bulkWrite = true` 는 `WRITE_REPOSITORY` 를 함께 요구한다(대량 쓰기도 결국 쓰기).
|
||
`bulk_write_capability_requires_write_repository_access` 가 강제.
|
||
- 이 애너테이션은 `docs/registries/capabilities.yaml` 의 7개 capability 를 코드로 구현한 것이며,
|
||
**코드가 SSOT** 다. 추가 플래그의 의미:
|
||
- `sensitiveRead` — PII/자격증명/비밀을 읽는 유스케이스 선언. 필드 단위 마커(엔티티 FQN +
|
||
필드명 테이블)와 자동 강제는 이 영역의 책임이고 아직 미구현이라,
|
||
지금은 리뷰 기반의 선언적 계약이다. 이 마커를 **도메인/JPA 엔티티 애너테이션으로 표현하면 안
|
||
된다**(프레임워크 의존이 도메인에 새는 것을 막기 위함).
|
||
- `bulkWrite` — 단일 트랜잭션에서 100행/배치를 초과하는 쓰기(registry 임계치 = 100). 임계치
|
||
미만이면 평범한 `WRITE_REPOSITORY` 선언으로 충분하다.
|
||
- `crossTenantAdmin` — 테넌트 경계를 넘는 admin 연산. cross-tenant 접근 정책 자체는
|
||
이 영역의 책임(여기선 어휘만 제공). single-tenant 유스케이스에 붙이면
|
||
리뷰 reject.
|
||
|
||
---
|
||
|
||
## 트랜잭션 경계 (transaction)
|
||
|
||
### TransactionPort
|
||
|
||
- **존재 이유**: application 유스케이스가 `org.springframework.transaction.annotation.Transactional`
|
||
을 import 하지 않고도 트랜잭션 의도를 선언하게 하기 위한 추상화다. 구현(보통
|
||
`SpringTransactionPort`)은 persistence adapter 가 Spring `PlatformTransactionManager` 로 제공한다.
|
||
application/domain 을 프레임워크-free 로 유지하는 핵심 장치.
|
||
- 네 가지 경계:
|
||
- `inWrite` — REQUIRED + read-write, `READ_COMMITTED`. command 유스케이스 기본.
|
||
- `inRootWrite` — 물리 root 전용 REQUIRED + read-write, `READ_COMMITTED`. 실제 ambient
|
||
transaction 이 하나라도 있으면 action 실행 전에
|
||
`NestedRootTransactionRejectedException` 으로 거부한다. 성공 값은 commit 이 끝난 뒤에만
|
||
호출자에게 반환되며, commit 실패는 그대로 전파된다.
|
||
- `inRead` — REQUIRED + read-only, `READ_COMMITTED`. query 유스케이스 기본.
|
||
- `inNew` — REQUIRES_NEW + read-write. UseCaseCapability 에 `REQUIRES_NEW` 를 명시한
|
||
유스케이스(outbox/audit/compensation)에서만 허용.
|
||
- **root-only 사용 조건**: `inRootWrite` 는 join 가능한 일반 command 경계의 대체물이 아니다.
|
||
외부 효과를 commit 이후에만 시작해야 하는 orchestration처럼 물리 root를 증명해야 하는 경우에만
|
||
쓴다. 기존 transaction 안에서 `REQUIRES_NEW` 로 몰래 분리하지 않고 fail-fast하므로, 호출자는
|
||
transaction 없는 진입점에서 이 경계를 시작해야 한다.
|
||
- **콜백 시그니처(D11)**: 네 메서드 모두 `Supplier`/`Runnable` 을 받아 checked exception 을 던질
|
||
수 없다. Spring `TransactionCallback<T>` 제약과 동일하다. 그래서 호출자는 도메인 checked
|
||
exception 을 `RuntimeException` 하위로 감싸야 한다(`DomainException extends RuntimeException`).
|
||
`IOException` → `UncheckedIOException`, `SQLException` 은 Spring `DataAccessException` 계층이
|
||
자동 변환한다. 콜백 안에서 던진 `RuntimeException` 은 롤백 + 호출자 전파.
|
||
- **`inNew` 풀 사이징 비용(D12)**: REQUIRES_NEW 는 바깥 트랜잭션의 커넥션을 잡아둔 채 **새 물리
|
||
JDBC 커넥션을 추가로** 잡는다. 즉 미완료 `inNew` 호출 하나당 풀에서 커넥션 하나를 더 쓴다.
|
||
|
||
```
|
||
hikari.maximumPoolSize >= (concurrent_threads × (1 + max_inNew_depth)) + 1
|
||
```
|
||
|
||
여기서 `max_inNew_depth` 는 스레드당 미완료 `inNew` 중첩의 최대 깊이다.
|
||
**금지**: 많은 레코드를 도는 루프 안에서 `inNew` 호출(예: per-row outbox dispatch). 풀 고갈 +
|
||
데드락 위험. 레코드를 한 번의 `inNew` 안에서 배치 처리하거나, 루프를 트랜잭션 경계 밖으로 빼라.
|
||
- **금지 목록**: `NESTED`/`NEVER` propagation, `READ_UNCOMMITTED` isolation, application 패키지에서
|
||
`@Transactional` 직접 사용, `inRootWrite` 의 ambient transaction 진입, `inNew` 의 per-record
|
||
루프 호출.
|
||
|
||
---
|
||
|
||
## Notification R1 오케스트레이션 경계
|
||
|
||
`dev.caskeleton.application.notification`은 알림 vendor 구현이 아니라 알림 capability의 순수
|
||
애플리케이션 계약이다.
|
||
|
||
- 입력은 typed recipient/template value와 코드 소유 `NotificationKindPolicy`로 제한한다. feature가
|
||
만든 `NotificationIntentDraft`는 `NotificationPlanPort`에서 immutable
|
||
`NotificationFrozenPlan`으로 고정되고, append/inline 포트는 이 plan만 소비한다.
|
||
- dispatch는 claim → reserve/authorize → provider call → terminal-once finalize 순서다. 짧은 DB
|
||
transaction 사이에서 provider를 호출하며, opaque claim/version/execution token으로 stale 결과를
|
||
거부한다. submission certainty가 `INDETERMINATE`면 blind retry나 fallback을 하지 않는다.
|
||
- receipt reducer는 fact 순서와 무관한 monotonic projection을 만든다. hard bounce/complaint만
|
||
technical suppression 후보이고, business consent/unsubscribe는 다른 capability가 소유한다.
|
||
- admission/reconciliation/maintenance는 bounded batch와 주입된 `Clock`을 사용한다. scheduler는
|
||
이 유스케이스만 호출하며 store/provider 포트를 직접 조율하지 않는다.
|
||
- legacy→canonical writer cutover는 exact route/generation/profile registry, root-only commit,
|
||
서명된 inventory/quiescence evidence와 closed transition action으로 표현한다. 애플리케이션은
|
||
verifier/operation 포트의 입력 계약을 강제하고, 실제 서명 검증·행 잠금·불변 journal·provider
|
||
egress 차단은 후속 adapter 구현이 증명해야 한다.
|
||
|
||
현재 증거 등급은 **R1 application contract with fakes**다. PostgreSQL DDL/locking, provider
|
||
protocol, receipt ingress, runtime wiring을 포함한 R2/R3 완료 주장이 아니다.
|
||
|
||
### TransactionMode
|
||
|
||
- `WRITE` / `READ_ONLY` / `REQUIRES_NEW`. TransactionPort API 와 UseCaseCapability 양쪽에서 쓰여,
|
||
유스케이스의 트랜잭션 계약을 본문 없이 드러낸다.
|
||
- `READ_ONLY` 일 때 persistence adapter 는 `readOnly = true` 를 켜서 드라이버 읽기 최적화
|
||
(Hibernate flush-mode MANUAL 등)를 적용하는 것이 좋다.
|
||
|
||
### Isolation
|
||
|
||
- `READ_COMMITTED` 가 고정 기본값이고, **모든 트랜잭션 템플릿에 명시적으로** 설정한다. vendor
|
||
기본값에 위임하지 않는 이유: 엔진마다 기본 isolation 이 다르다(PostgreSQL = READ COMMITTED,
|
||
MySQL InnoDB = REPEATABLE READ). 위임하면 같은 코드가 DB 에 따라 다른 isolation 으로 도는
|
||
silent 위험이 생긴다.
|
||
- `REPEATABLE_READ` / `SERIALIZABLE` 은 쓰기 무거운/읽기 일관성 유스케이스를 위한 명시적
|
||
opt-in. `READ_UNCOMMITTED` 는 금지라 enum 에 아예 선언하지 않았다.
|
||
- 단, 더 엄격한 레벨을 TransactionPort 호출 경로로 라우팅하는 per-use-case 선택 메커니즘은
|
||
관련 추상화 설계와의 공동 변경이라 아직 `planned` 다.
|
||
그전까지 이 상수들은 **어휘만** 선언하고, 실제 호출 경로는 `READ_COMMITTED` 로 고정된다.
|
||
|
||
---
|
||
|
||
## 인가 (security)
|
||
|
||
인증("누구냐")은 web/security adapter 책임이고, 이 패키지는 인가("이걸 해도 되냐")만 소유한다.
|
||
|
||
### AuthorizationPort
|
||
|
||
- 제품 인가 enforcement point(PEP). 인증된 호출자가 권한이 필요한 연산을 수행해도 되는지
|
||
결정한다. 인증된 principal 의 raw role 을 입력으로 받는다(인증 자체는 베이스라인 소유).
|
||
- **왜 포트인가, `@PreAuthorize` 가 아니라**: Spring method-security 애너테이션은 빈을
|
||
`org.springframework.security` 타입에 묶는다. application/domain 은 프레임워크-free 여야 하므로
|
||
(TransactionPort 선례), enforcement *결정* 은 평범한 Java 포트로 표현하고 Spring 의존
|
||
enforcement *메커니즘*(커스텀 `AuthorizationManager`)은 web adapter 에 둔다.
|
||
- **fail-closed**: 호출자가 필요한 권한을 가진 경우에만 정상 반환한다. 그 외(미지 role, role 없음,
|
||
매핑 누락)는 전부 `AuthorizationDeniedException`.
|
||
|
||
### RequiresPermission (애너테이션)
|
||
|
||
- 인증된 호출자가 가드된 유스케이스를 호출하려면 가져야 하는 `Permission` 을 선언한다. 값은
|
||
`resource:action` 토큰(예: `"worklog:close"`). `@UseCaseCapability` 패턴을 그대로 따라, 유스케이스가
|
||
요구하는 권한을 본문 없이 보이게 한다.
|
||
- **순수 선언 — Spring-free**: Spring Security 타입을 일절 담지 않는다. 실제 enforcement 는 web
|
||
adapter 의 `RequiresPermissionAuthorizationManager` 가 RUNTIME retention 으로 이 애너테이션을
|
||
읽어 `AuthorizationPort` 에 위임한다. enforcement 가 Spring AOP 프록시를 거치므로
|
||
self-invocation 이나 비-Spring 빈 호출은 우회된다 — 모든 mutating 진입점을 가드하는 책임은
|
||
아키텍처 강제 규칙 몫이다.
|
||
- **적용 대상**: skeleton 기본은 mutating/민감 유스케이스(`repositoryAccess = WRITE_REPOSITORY`)에
|
||
필수, public read 는 항상 면제(D4). 타입/메서드 어디에도 붙일 수 있다.
|
||
|
||
### AuthorizationPrincipal
|
||
|
||
- `AuthorizationPort` 가 소비하는 프레임워크-free 호출자 뷰 — IdP subject + 호출자의 raw role
|
||
이름. web adapter 는 Spring 인지 principal(`AuthenticatedPrincipal`, Keycloak 스타일 raw role)을
|
||
노출하지만 application 계층은 그 타입을 import 할 수 없으므로, enforcement point 가 이 추상으로
|
||
내려 매핑한다.
|
||
- **role 은 raw, Spring authority 가 아니다**: role set 은 IdP 가 발급한 그대로의 문자열
|
||
(`"admin"`, `"user"`)이며 `ROLE_*` GrantedAuthority 형태가 아니다. role→permission 해석이 raw
|
||
이름을 키로 쓰므로(D3), 둘을 섞으면 권한 0개로 해석돼 fail-closed denial 이 된다.
|
||
|
||
### AuthorizationDeniedException
|
||
|
||
- 인증된 호출자가 필요한 `Permission` 을 못 가졌을 때 `AuthorizationPort` 가 던진다.
|
||
- **프레임워크-free, application 소유**: application 계층은 Spring Security 에 의존할 수 없어
|
||
Spring `AccessDeniedException` 을 던질 수 없다. web adapter 의 method-security enforcement
|
||
point 가 경계에서 이 예외를 잡아 Spring authorization 실패로 번역하고, error pipeline 이
|
||
`AUTHZ_INSUFFICIENT_PERMISSION` 403 으로 매핑한다(코드 SSOT 는 베이스라인 소유).
|
||
- **fail-closed**: denial 은 필요한 권한과 호출자 subject 만 담고, 호출자의 *유효 권한 집합* 은
|
||
절대 담지 않는다(인가 표면을 클라이언트에 노출하지 않기 위함).
|
||
|
||
---
|
||
|
||
## 멱등성 (idempotency)
|
||
|
||
application 계층이 in-flight 대기·replay **정책** 을 소유하고, 저장은
|
||
`IdempotencyStorePort` 로 위임한다.
|
||
|
||
### IdempotencyExecutor
|
||
|
||
- application 유스케이스 경계에서 멱등 실행을 조율한다. 하나의 `IdempotencyScope` 에 대해 순서대로:
|
||
1. **claim** — store 유니크 제약 위의 atomic insert-or-read(D7). 이긴 caller 가 action 을
|
||
정확히 한 번 실행한다.
|
||
2. **fingerprint mismatch** — 같은 key, 다른 body(다른 fingerprint)인 live record 는
|
||
`IdempotencyRequestMismatchException` → 422(D8).
|
||
3. **replay** — `COMPLETED` record 는 저장된 응답을 codec 으로 재생(§B).
|
||
4. **in-flight wait** — 동시 `IN_FLIGHT` record 는 최대 `IN_FLIGHT_WAIT`(200ms) 동안 폴링하고,
|
||
여전히 미해결이면 `IdempotencyInFlightException` → 409(D7).
|
||
- claim 한 action 이 예외를 던지면 in-flight record 를 `discard` 한다 — 그래야 재시도가 TTL
|
||
만료까지 409 에 갇히지 않는다.
|
||
- **200ms 대기 창의 출처(표준이 강제한 값 아님 — 스켈레톤 기본 선택)**: 이 200ms 대기 창은
|
||
인용 가능한 표준이 강제하는 값이 아니다(IETF/Toss 는 즉시 409 를 권장). 표준의 운영 친화적
|
||
변형이다 — "표준 기반 + 운영 변형" 으로 설명해야지 "표준을 따른다" 고 말하면 안 된다. 블로킹
|
||
폴링이 요청 스레드를 점유하므로, 중복 도착이 몰리면 스레드 점유 vs 클라이언트 재시도 친화성의
|
||
트레이드오프가 있다(임계치는 부하 테스트로 검증 필요).
|
||
- 상수: `IN_FLIGHT_WAIT = 200ms`(D7), `MAX_TTL = 72h`(D6 하드 캡), `POLL_INTERVAL = 20ms`.
|
||
- package-private 한 `Sleeper` 주입 생성자가 따로 있는 이유: 테스트 `Sleeper` 가 mutable clock 을
|
||
전진시켜 실제 시간 블로킹 없이 in-flight wait 를 결정적으로 검증하기 위함.
|
||
|
||
### IdempotencyStorePort
|
||
|
||
- 멱등 record 저장 outbound 포트(D3). `adapter-persistence` 가 유니크 제약
|
||
`(tenant, principal, idempotency_key, use_case_name)` 위에 구현한다. 포트는 저장 primitive
|
||
(tryBegin/find/complete/discard)만 노출하고, 정책은 executor 가 가진다.
|
||
- **in-memory 프로덕션 구현 금지**: 계약상 내구성 있고 유일성을 강제하는 백킹 스토어가 필요하다.
|
||
Redis 는 내구 스토어 앞단의 선택적 캐시로만 허용된다.
|
||
- **만료 record 는 reclaimable**: `expiresAt <= now` 인 record 는 없는 것처럼 취급해야 한다.
|
||
`tryBegin` 은 만료된 죽은 행을 purge/replace 해서 새 claim 이 이기게 하고, `find` 는 만료된
|
||
record 를 absent 로 보아 만료 replay 를 거부한다(§E / TTL 경계).
|
||
|
||
### IdempotencyScope
|
||
|
||
- 멱등 요청의 정체성이자 계약의 SSOT scope 모양(D2). 기본은 triple
|
||
`(authenticatedPrincipal, idempotencyKey, useCaseName)`. 테넌트 격리가 활성이면 `tenant` 차원을
|
||
앞에 붙인 4-tuple 이 되고, single-tenant 모드에선 `tenant = null`.
|
||
- **여기서 막는 실패 모드**: `principal`/`useCaseName` 이 빠진 key 는 전역적으로 충돌해 다른
|
||
caller 의 응답을 재생할 수 있다. `of` 팩토리가 blank 필수 차원을 `IdempotencyScopeMissingException`
|
||
으로 거부해, scope 없는 key 가 store 에 닿지 못하게 막는다. 막으려는 실패 모드는 "scope 누락으로
|
||
인한 silent 전역 충돌"이다.
|
||
- `storageKey()` 는 진단/단일 컬럼 조회용으로 사람이 읽는 join 문자열일 뿐이다. 유일성은 차원
|
||
컬럼에 강제되지 이 문자열에 강제되지 않는다.
|
||
|
||
### RequestFingerprint
|
||
|
||
- 요청 body 의 SHA-256 지문(D8). 같은 scope 인데 body 가 다르면(지문이 다르면) 클라이언트
|
||
버그로 보고 executor 가 422 로 거부한다. null/빈 body 는 zero-length payload 지문으로 취급해
|
||
빈 replay 의 일관성을 지킨다.
|
||
- **SHA-256 선택(표준이 강제한 값 아님 — 스켈레톤 기본 선택)**: MD5/SHA-1 대비 충돌 저항성 때문에
|
||
SHA-256 을 골랐다 — 인용 가능한 표준이 강제한 것도, 측정된 성능 근거가 있는 것도 아니다. body
|
||
**정규화**(JSON 키 순서·공백·인코딩)는 의도적으로 적용하지 않는다 — 전송된 raw 바이트를 그대로
|
||
해시한다. 정규 동등성이 필요한 caller 는 `ofSha256` 호출 전에 직접 정규화해야 한다. 안 하면 키
|
||
순서만 바뀐 의미상 동일 replay 가 false mismatch 가 된다(부하와 실제 요청 형태로 검증 필요).
|
||
- `NoSuchAlgorithmException` catch 는 도달 불가다(SHA-256 은 모든 JDK 에 필수). 그래서
|
||
`IllegalStateException` 으로 감싼다.
|
||
|
||
### 멱등성 값/계약 타입 (Context · Record · StoredResponse · Codec · Status · Sleeper)
|
||
|
||
- `IdempotencyContext` — executor 입력. scope + fingerprint + 선택적 per-use-case TTL
|
||
override(D6). 결제/송금 같은 장기 유스케이스가 72h 캡까지 override 가능하고, 캡 초과는 executor 가
|
||
거부한다.
|
||
- `IdempotencyRecord` — application 이 보는 영속 record. `COMPLETED` 면 반드시 response 를
|
||
동반해야 한다(생성자가 강제). adapter 가 테이블 행과 매핑한다.
|
||
- `StoredResponse` — 완료된 응답의 불투명 직렬화 표현으로, 중복 caller 에게 그대로 재생된다(§B).
|
||
**어디에** 물리 저장되는지(작으면 행 인라인 ≤8KB, 크면 object store + 행엔 ref 만 — §F/D9)는
|
||
전적으로 persistence adapter 관심사라 여기선 보이지 않는다 → application 계층을 transport·저장
|
||
중립으로 유지.
|
||
- `IdempotentResponseCodec` — 유스케이스 결과를 `StoredResponse` payload 로 직렬화/역직렬화.
|
||
application 은 wire-format 중립이다 — 구체 JSON 인코딩은 `R` 의 응답 모양을 아는 `adapter-web`
|
||
caller 가 소유하고, executor 는 그 문자열을 store 로 왕복시키기만 한다.
|
||
- `IdempotencyStatus` — `IN_FLIGHT` / `COMPLETED` 두 값.
|
||
- `Sleeper` — `Thread.sleep` 우회 인터페이스. executor 의 in-flight 폴링 대기를 결정적으로
|
||
테스트하기 위함(테스트 Sleeper 가 실제 시간 블로킹 대신 mutable clock 전진).
|
||
|
||
### 멱등성 예외 3종 (공통 패턴)
|
||
|
||
- `IdempotencyRequestMismatchException`(422), `IdempotencyInFlightException`(409),
|
||
`IdempotencyScopeMissingException`(400, `VALIDATION_FAILED`).
|
||
- 셋 다 **프레임워크-free, application 소유**(`AuthorizationDeniedException` 선례). web adapter 가
|
||
경계에서 각각의 HTTP 코드로 매핑한다. 진단 메시지는 scope storage key / 누락 차원을 **로그용으로만**
|
||
담고, 클라이언트에는 핸들러가 고정 client-safe 메시지로 대체한다(fingerprint 자체는 노출 안 함).
|
||
- retryable: mismatch / in-flight 모두 `false`. 클라이언트는 body 를 고치거나 결과를 polling 해야지
|
||
단순 재시도를 하면 안 된다.
|
||
|
||
### Owner-safe idempotency V2
|
||
|
||
`idempotency.v2`는 V1의 scope-only `tryBegin/complete/discard`를 대체하는 additive contract다.
|
||
provider가 JPA인지 Redis인지와 무관하게 claim에는 secure owner token과 stable operation ID가
|
||
필요하고, 모든 mutation은 owner/attempt/claim-operation/state-revision을 검증한다.
|
||
|
||
- processing lease와 completed replay TTL을 분리한다.
|
||
- expired `CLAIMED`만 takeover하고 expired `EXECUTING`은 `RECOVERY_REQUIRED`로 닫는다.
|
||
- complete/fail/release는 operation ID와 result digest가 같은 재호출만 prior result로 replay한다.
|
||
- `SAME_STORE_TRANSACTIONAL` JPA profile의 response는 8 KiB 이하 inline 값만 지원한다.
|
||
- raw principal/client key는 versioned HMAC scope digest로 바꾼 뒤 adapter에 전달한다.
|
||
|
||
V1은 rolling migration compatibility를 위해 유지된다. 새 reliability profile이 V2 claim과 V1
|
||
scope-only mutation을 섞는 것은 금지한다.
|
||
|
||
---
|
||
|
||
## 트랜잭셔널 아웃박스 릴레이 (outbox)
|
||
|
||
핵심 패턴: 비즈니스 쓰기와 같은 트랜잭션에 이벤트를 append → 별도 relay 가 짧은 트랜잭션으로
|
||
claim → 트랜잭션 밖에서 발행 → at-least-once 보장.
|
||
|
||
### OutboxAppendPort
|
||
|
||
- 트랜잭셔널 아웃박스에 새 이벤트를 append 하는 outbound 포트.
|
||
- **dual-write 금지(D2)**: 반드시 이벤트를 만든 비즈니스 연산과 **같은 DB 트랜잭션 안에서**
|
||
호출해야 한다(그 트랜잭션은 caller 가 `TransactionPort.inWrite` 로 연다). 트랜잭션 밖에서, 또는
|
||
비즈니스 연산과 다른 트랜잭션에서 호출하면 dual-write 금지 위반이다 — 비즈니스는 커밋됐는데
|
||
outbox append 가 안 되거나(또는 그 반대) 이벤트가 silent 손실/허위 방출된다. 따라서 구현은
|
||
내부에서 새 트랜잭션을 열면 안 되고, 바깥 트랜잭션에 참여해야 한다.
|
||
|
||
### OutboxStorePort
|
||
|
||
- relay 측에서 outbox store 에 접근하는 포트. 모든 mutating 연산
|
||
(`claimBatch`/`markPublished`/`markFailed`/`markDead`)은 relay 유스케이스가 소유한
|
||
`TransactionPort.inWrite` 경계 안에서 호출하며, 구현은 자체 트랜잭션을 열지 않는다.
|
||
- **claim 의미(I4 / I6)** — `claimBatch` 가 적격 행을 atomic 하게 `IN_FLIGHT` 로 전이시킨다:
|
||
- 적격 행: `PENDING`, `next_attempt_at <= now` 인 `FAILED`, `next_attempt_at <= now` 인
|
||
`IN_FLIGHT`(고아 in-flight — I6 timeout 재사용).
|
||
- **FIFO 게이트(I4)**: 같은 `aggregate_id` 의 더 이른 `occurred_at` 행이 아직 `PUBLISHED` 가
|
||
아니면 그 aggregate 의 뒤 행들은 skip 한다. `DEAD` 행도 그 aggregate 큐를 막는다(strict
|
||
FIFO — 해제하려면 runbook 개입 필요).
|
||
- claim 후: `status = IN_FLIGHT`, `attempt_count += 1`,
|
||
`next_attempt_at = now + inFlightTimeout`.
|
||
- 순서: adapter 는 claim 쿼리에 `ORDER BY occurred_at ASC` 를 쓰는 게 좋고, relay 도 받은
|
||
배치를 방어적으로 재정렬한다.
|
||
- **in-flight timeout 재사용(I6)**: `next_attempt_at` 컬럼을 in-flight 고아 timeout 으로
|
||
재사용한다. relay 인스턴스가 죽어 `IN_FLIGHT` 로 남은 행이 `next_attempt_at` 을 지나면 다음
|
||
폴링에서 다시 claim 가능해진다. 별도 `claimed_at` 컬럼 없이 at-least-once 전달을 보장하는 트릭.
|
||
- `countByStatus` / `oldestUnpublishedAgeSecondsByEventType` 는 메트릭 게이지
|
||
(`outbox.pending.size`, `outbox.publisher.lag`)의 데이터 소스다. 트랜잭션 밖 read-only
|
||
(metric-scrape 경로)로 호출된다.
|
||
|
||
### OutboxMessagePublishPort
|
||
|
||
- claim 된 outbox 이벤트를 메시지 브로커에 발행하는 outbound 포트.
|
||
- **fail-closed 계약(I8)**: 발행 실패는 반드시 `RuntimeException` 으로 표면화해야 한다. 구현은
|
||
예외를 삼키거나 실패 시 log-and-return 하면 안 된다. 일반적인 fail-open 메시징 publisher(잡고
|
||
로그 후 정상 반환)와의 **의도적·문서화된 차이** 다 — relay 의 Failure condition 이 발행 실패를
|
||
예외로 관측해야 FAILED/DEAD 전이 + typed failure report를 구동할 수 있기 때문. 삼킨 실패
|
||
(예외 없음·전이 없음·report 없음)가
|
||
금지 조건이다 — 행이 영원히 `IN_FLIGHT` 로 남고, aggregate FIFO 큐가 조용히 막히며, 메트릭엔
|
||
이상이 안 보인다.
|
||
- **호출 위치**: relay 유스케이스가 **트랜잭션 밖에서** 호출한다. 짧은 `inWrite` 로 배치 claim →
|
||
트랜잭션 해제 → 발행 → 결과별로 다시 짧은 `inWrite` 로 상태 갱신. 브로커 호출이 진행되는 동안
|
||
트랜잭션 보유 시간을 최소화한다.
|
||
|
||
### PublishPendingOutboxEventsUseCase (릴레이 본체)
|
||
|
||
- pending outbox 이벤트를 claim 해 브로커에 발행하는 relay 유스케이스.
|
||
- **알고리즘(claim short, publish outside tx)**:
|
||
1. 짧은 write 트랜잭션 안에서 배치를 claim 한다.
|
||
2. `occurred_at` 오름차순으로 방어적 정렬(claim 쿼리도 정렬하지만, adapter 가 안 해도 relay 가
|
||
FIFO 를 강제한다).
|
||
3. 각 이벤트를 **트랜잭션 밖에서** 발행하고 결과로 상태 머신을 구동한다:
|
||
- 발행 성공 → `inWrite { markPublished }` → `PUBLISHED`.
|
||
- 발행 실패(`RuntimeException`): `attemptCount >= maxAttempts` 면 `markDead`, 아니면
|
||
`markFailed(nextAttemptAt)`를 먼저 성공시킨 뒤 해당 typed failure report를 보낸다.
|
||
- **발행 실패는 절대 삼키지 않는다**: relay 는 각 발행 예외를 잡아 FAILED/DEAD 상태 머신을
|
||
구동하고, 성공한 전이만 `OutboxRelayFailureReportPort`로 보고한 뒤 rethrow 하지 않는다
|
||
(스케줄러 루프가 다음 이벤트로 계속 가야 하므로). 상태 전이가 실패하면 예외가 전파되고 report는
|
||
없다. reporter가 `RuntimeException`을 던져도 persisted outcome을 바꾸거나 다음 이벤트를 막지
|
||
못한다.
|
||
- **안전한 allowlist report**: `OutboxRelayFailureReport`는
|
||
`code/eventId/eventType/aggregateId/correlationId/attemptCount/nextAttemptAt/cause`만 가진다.
|
||
payload, idempotency key, whole `OutboxEvent`, severity/template, arbitrary map은 타입 수준에서
|
||
전달할 수 없다. retry factory는 `OUTBOX_PUBLISH_FAILED`와 필수 `nextAttemptAt`, dead factory는
|
||
`OUTBOX_DEAD_LETTER`와 null retry time을 고정한다.
|
||
- **상태 갱신 실패는 시끄럽게 전파한다**: 발행 성공 후의 `markPublished` 실패는 store/인프라
|
||
에러지 발행 실패가 아니다. 따라서 FAILED/DEAD 머신을 구동하면 안 된다(이미 전달된 이벤트를
|
||
dead-letter 하는 꼴). 대신 스케줄러 catch 블록으로 전파되고, 행은 `IN_FLIGHT` 로 남아 고아
|
||
visibility-timeout 재claim 경로로 복구된다 → 재발행 → consumer dedupe 가 중복을
|
||
흡수(at-least-once). 배치 중간의 상태 갱신 실패가 그 tick 의 남은 배치를 중단시키는 것은
|
||
허용된다 — DB 가 실패 중이면 뒤따르는 갱신도 실패할 테고, 다음 tick 이 모든 `IN_FLIGHT` 고아를
|
||
재시도하기 때문.
|
||
- **수동 와이어링(@Service 아님, context 빈 아님)**: `app-bootstrap` 이 수동으로 생성한다
|
||
(`batchSize`·`inFlightTimeout` 같은 설정값이 필요해서 자동 등록이 안 된다). `UseCaseCapability`
|
||
는 빈 등록 방식과 무관하게 필수다(ArchUnit 강제). 그리고 **Spring 빈으로 등록하면 안 된다**:
|
||
클래스 레벨 `@RequiresPermission` pointcut(web `MethodSecurityConfig`)이 이 타입 빈을 CGLIB
|
||
프록시하려 드는데, `final` 클래스라 불가능하고, 스케줄러 스레드엔 `Authentication` 이 없어 매
|
||
relay tick 이 fail-closed 거부될 것이다.
|
||
- **권한**: `"outbox:relay"` 는 시스템 내부 권한이다. relay 는 엔드유저가 아니라 스케줄러 빈이
|
||
호출한다. `@RequiresPermission` 선언은 ArchUnit `mutating_use_cases_declare_required_permission`
|
||
(D4) 을 충족시키기 위함이고, 스케줄러 컨텍스트에서의 실제 강제는 관례에 맡긴다(스케줄러는
|
||
app-bootstrap 내부).
|
||
|
||
### OutboxBackoffPolicy
|
||
|
||
- relay 재시도 스케줄링용 exponential-backoff-with-full-jitter 정책(I10).
|
||
- 공식: `base = 30s`, `maxAttempts = 3`, `delay = base × 2^(attemptCount-1) + jitter(0..base)`,
|
||
`nextAttemptAt = now + delay`. 여기서 `attemptCount` 는 방금 실패한 시도의 1-based 번호다.
|
||
- full jitter 는 주입된 `RandomGenerator` 로 `[0, base]` 균등 분포에서 뽑는다. 고정 시드/0 을
|
||
반환하는 generator 를 주면 테스트가 결정적이 된다(I10).
|
||
- **상수는 고정이다(I10)**: `BASE_DELAY`·`MAX_ATTEMPTS`·jitter 범위는 설정 프로퍼티가 아니다.
|
||
외부화하면 registry alert 임계치(`error-codes.yaml` 의 `retry_after_seconds=30`)와
|
||
`outbox-publish-failed` runbook 이 실제 런타임 값과 어긋난다. 이 상수는 registry·runbook
|
||
업데이트와 **함께만** 바꾼다.
|
||
- 지수 계산은 overflow 방지를 위해 `2^30` 에서 cap 한다(`2^30 × 30s > 30년` 이라 안전).
|
||
|
||
### outbox 값/상태 타입 (Status · Event · NewOutboxEvent · RelayResult · Command)
|
||
|
||
- `OutboxEventStatus` — 행 상태 머신(D5). `PENDING → IN_FLIGHT → {PUBLISHED | FAILED | DEAD}`,
|
||
`FAILED → IN_FLIGHT`(재claim), `IN_FLIGHT 고아 → IN_FLIGHT`(I6). `DEAD` 는 FIFO 형제를 막는다 —
|
||
같은 aggregate 의 뒤 행 claim 을 차단하므로 runbook 개입이 필요하다.
|
||
- `OutboxEvent` — `claimBatch` 가 반환하는 claim-result 읽기 모델. `NewOutboxEvent` 필드 + 현재
|
||
`status` + `attemptCount`. relay 가 `attemptCount` 로 다음 실패 시 dead-letter 여부를 결정한다.
|
||
- `NewOutboxEvent` — caller 의 write 트랜잭션 안에서 append 할 새 이벤트 값 객체. 모든 필드가
|
||
필수이고 null/blank 면 생성 시점에 거부한다(D12). caller 가 `eventId`·`idempotencyKey` 를
|
||
공급한다(I12 — outbox core 는 ID 생성에 비의존). 권장 기본값은 `idempotencyKey = eventId`
|
||
(per-event dedup, CloudEvents-C2).
|
||
- `OutboxRelayResult` — relay 한 사이클의 결과. `claimedCount` + per-event outcome 목록. 내부
|
||
`Outcome` enum 이 `OutboxEventStatus` 의 5값을 재사용하지 않고 3값(PUBLISHED/FAILED/DEAD)을
|
||
따로 두는 이유: relay 관점은 "이번 실행에서 무슨 일이 있었나" 이지 "행의 현재 영속 상태" 가
|
||
아니다(후자는 store 가 5값으로 추적). 별도 enum 이 메트릭·스케줄러 로깅 consumer 에게 relay API
|
||
를 깔끔하게 유지해 준다.
|
||
- `PublishPendingOutboxEventsCommand` — relay 커맨드 마커. 스케줄러 구동이라 caller 파라미터가
|
||
없고, 모든 운영 파라미터는 생성 시점에 주입된다(IdempotencyExecutor 선례). 호출마다 새 인스턴스를
|
||
만들 필요가 없게 `INSTANCE` 싱글톤을 제공한다.
|
||
- `OutboxRelayFailureReportPort` / `OutboxRelayFailureReport` — confirmed FAILED/DEAD 상태를
|
||
adapter에 전달하는 framework-free outbound contract. 구조화 ERROR 필드와 runbook 렌더링은
|
||
messaging adapter가 소유한다.
|
||
|
||
### Immutable outbox/polling delivery V2
|
||
|
||
`outbox.v2`는 domain event intent와 delivery state를 분리한다.
|
||
|
||
- `NewOutboxEventV2`의 aggregate version과 deterministic ordinal이 ordering authority다.
|
||
- `OutboxAppendPortV2`는 caller의 primary write transaction에 참여하고 publication epoch와
|
||
authority를 DB control row에서 얻는다.
|
||
- immutable event ID 충돌과 aggregate ordering tuple 충돌은 서로 다른 outcome이다.
|
||
- `OutboxPollingDeliveryPortV2`는 publish 밖의 짧은 claim/completion transaction만 소유하고
|
||
owner/token/attempt/version/epoch CAS로 stale relay를 거절한다.
|
||
- broker publish와 DB completion 사이 ACK 유실은 stable event ID의 duplicate publish를 만들 수
|
||
있으므로 exactly-once delivery로 표현하지 않는다.
|
||
|
||
### Same-store inbox
|
||
|
||
`inbox.InboxStorePort`는 broker redelivery를 DB business mutation과 같은 transaction에서
|
||
deduplicate한다. `RECEIVED -> PROCESSING -> COMPLETED`가 기본이며 expired `PROCESSING`은 blind
|
||
takeover하지 않는다. broker ACK는 transaction commit 이후 adapter 바깥에서만 수행하고 remote
|
||
side effect는 outbox/workflow로 옮긴다.
|
||
|
||
---
|
||
|
||
## 분산 락 (lock)
|
||
|
||
application 계층은 락 획득/해제 계약만 알고, 실제 구현은 adapter 가 소유한다.
|
||
|
||
### DistributedLockPort
|
||
|
||
- 분산 락 획득 outbound 포트.
|
||
- **왜 이 포트가 있나(D2)**: 유스케이스는 락 클라이언트·Spring `LockRegistry`·advisory-lock SQL
|
||
을 직접 import 하면 안 된다. 이 포트가 application-core 안에서 협조적 상호배제를 위해 허용된
|
||
유일한 의존이다. adapter 구현(`JdbcLockRegistry` 멀티 인스턴스, `DefaultLockRegistry` 단일
|
||
인스턴스)은 `adapter-persistence` 에 있고 컴파일 타임엔 application 계층에 안 보인다.
|
||
- **try-lock + 유한 waitTime + 필수 leaseTtl(D5)**: `tryAcquire` 는 try-lock 이다 — 최대
|
||
`waitTime` 만 블로킹하고 held 핸들을 반환하거나 `LockAcquisitionTimeoutException` 을 던진다.
|
||
무한 블로킹은 금지(항상 유한 `waitTime` 공급). `leaseTtl` 은 JVM 이 `close()` 전에 죽어도
|
||
adapter 가 락을 유지하는 최대 시간으로, 죽은 보유자가 시스템을 무한 데드락 시키는 것을
|
||
막는다. `JdbcLockRegistry` 는 registry 기본 TTL 과 호출별 lock TTL 을 강제하고, in-process
|
||
`DefaultLockRegistry` 는 advisory(강제 없음)다.
|
||
- **트랜잭션 커밋 순서 불변식(D4)**: 반환된 핸들은 보호 작업의 DB 트랜잭션이 **커밋된 후에만**
|
||
해제해야 한다. 트랜잭션 안(커밋 전)에서 해제하면 lost-update 경합이 생긴다 — 두 번째 스레드가
|
||
락을 얻어, 첫 트랜잭션의 쓰기가 DB 에 보이기 전에 자신의 read-modify-write 를 시작한다.
|
||
- 올바른 패턴: `tryAcquire` → `try { txPort.inWrite(...) } finally { lock.close() }`(커밋 후 해제).
|
||
- 금지된 역순: `inWrite` 콜백 안에서 `lock.close()`(커밋 전 해제 → 다른 스레드가 stale 상태를 본다).
|
||
- **효율 락이지 정합성 락이 아니다(D6)**: 이 락은 경합·불필요한 재시도를 줄이는 *효율* 장치다.
|
||
정합성(중복·충돌 쓰기 방지)은 여전히 DB 제약(유니크 인덱스·낙관적 동시성)이 강제한다. 이 락
|
||
하나만 정합성 가드로 의존하면 안 된다.
|
||
- **lease 만료 시 해제(SI-LOCK-C5)**: TTL 기반 provider(`JdbcLockRegistry`)에서 보유자가
|
||
`close()` 하기 전에 lease 가 만료되면, 락 행은 이미 다른 인스턴스가 회수했을 수 있다. metered
|
||
`distributedLockProvider` 가 이를 로그 + 카운트 이벤트(`lock.lease.expired`)로 표면화하고
|
||
`close()` 는 정상 반환한다 — 만료가 caller 의 `finally` 를 터뜨리거나 보호 작업 자신의 예외를
|
||
가리지 않게 하기 위함.
|
||
- `leaseTtl` 이 provider 설정 TTL 을 초과하면 `IllegalArgumentException`. shipped 와이어링은 provider
|
||
설정과 같은 `LockSettings.leaseTtl()` 로 바인딩하므로 런타임엔 안 터지고, mis-wired caller/test 를
|
||
잡는 가드다.
|
||
|
||
### DistributedLock (핸들)
|
||
|
||
- 획득한 분산 락 핸들(`AutoCloseable`). `close()` 가 해제이고 `finally` 에서 한 번 호출하기에
|
||
안전하며, 구현은 idempotent 해야 한다(여러 번 close 해도 throw 금지).
|
||
- D4 커밋 순서 불변식은 `DistributedLockPort` 와 동일하다 — 커밋 후에만 `close`.
|
||
- `close()` 가 `AutoCloseable` 의 `throws Exception` 을 제거하도록 override 한 이유: 구현이 해제
|
||
시 checked exception 을 던지지 못하게 해서, caller 가 checked-exception 의식 없이 `finally` 에
|
||
둘 수 있게 하기 위함.
|
||
|
||
### LockAcquisitionTimeoutException
|
||
|
||
- `waitTime` 안에 락을 얻지 못하면 `tryAcquire` 가 던진다(D5).
|
||
- `OperationalError.LOCK_ACQUISITION_TIMEOUT`(CONFLICT, 409, retryable=true)을 운반한다. 락
|
||
경합은 일시적이다 — 현재 보유자가 임계 구역을 떠나거나 lease TTL 이 만료되면 재시도가
|
||
획득한다(D6 효율 락).
|
||
- web adapter 가 `errorCode()` + 고정 client-safe 메시지로 409 를 만든다. 진단용 `getMessage()`
|
||
(key·waitTime 포함)는 서버 로그 전용이고 API 클라이언트에 전달하면 안 된다.
|
||
|
||
---
|
||
|
||
## 알림 포트 (notification)
|
||
|
||
### NotificationPort
|
||
|
||
- named channel + 논리 route 로 알림을 전달하는 outbound 포트.
|
||
- 2-인자 overload 는 관례적 `"default"` route 를 쓰는 편의 메서드다. adapter 가
|
||
`app.notification.routes.<channel>.<route>` 에서 구체 provider 목록을 해석하므로, application
|
||
계층은 provider 선택과 분리된다(호출부에 provider id 가 없다).
|
||
- fan-out(route 당 여러 provider)은 adapter 관심사다. 어떤 provider 가 실패하면 adapter 가
|
||
fail-open 정책(관측만, 전파 안 함)을 적용하고, route 자체가 unbound 면
|
||
`AdapterDisabledException("notification", ...)` 을 던진다.
|
||
- outbound 포트의 `*Port` 접미사 명명 규칙을 따른다(CLAUDE.md).
|
||
|
||
### Notification (값 객체)
|
||
|
||
- transport-중립 알림 값으로, routing target 과 내용만 운반한다.
|
||
- **절대 logger(또는 dependency logger)에 넘기면 안 된다** — recipient 주소·body 가 로그 라인에
|
||
닿을 수 없게 하기 위함(PII 계약).
|
||
- `adapter-outbound` 에서 `application-core` 로 옮긴 이유: 유스케이스가 adapter 타입을 import 하지
|
||
않고도 알림을 만들어 `NotificationPort` 로 전달할 수 있게 하기 위함(clean-architecture
|
||
HARD-STOP #3).
|
||
|
||
### Channel
|
||
|
||
- 알림 채널 판별자(`EMAIL`/`SLACK`). provider 와 독립적으로 전달 매체를 식별한다.
|
||
- caller 가 `Channel` 값(compile-safe) + 논리 route 이름을 `NotificationPort` 에 넘기면 adapter 가
|
||
`app.notification.routes.<channel>.<route>` 로 provider 목록을 해석한다 — 도메인은 provider
|
||
세부를 들여다보지 않는다(HARD-STOP #4).
|
||
|
||
---
|
||
|
||
## 로그 가명화 포트 (observability)
|
||
|
||
### CorrelationIdPort
|
||
|
||
- 현재 application invocation의 correlation id를 `Optional<String>`으로 읽는 framework-free
|
||
경계다. application/sample use case는 MDC나 SLF4J를 직접 알지 않는다.
|
||
- inbound web adapter가 sanitized `correlation_id` MDC 슬롯을 구현 세부로 읽는다.
|
||
- 값이 없거나 blank이면 event publisher는 생성한 event id를 correlation id로 재사용해 기존
|
||
self-correlation 동작을 유지한다.
|
||
|
||
### UserPrincipalPseudonymizerPort
|
||
|
||
- raw 보안 principal id 를, 값이 로그/MDC 에 쓰이기 전에 안정적 가명 토큰으로 바꾸는 outbound
|
||
포트.
|
||
- **null/blank 의미**: `rawPrincipal` 이 null/blank 면 null 을 반환한다. 가명화할 게 없으니, 이
|
||
경우 caller 는 `user_principal` MDC 키에 아무 값도 넣으면 안 된다.
|
||
- **안정 토큰 계약**: non-blank 입력에 대해 (1) 같은 입력 + 같은 salt 는 같은 salt epoch 안에서
|
||
항상 같은 출력을 낸다(안정), (2) raw principal 의 trivial 복원이 불가능하다(일방향 derivation
|
||
이지 인코딩·가역 변환이 아니다), (3) `user_principal` MDC 와 로그 라인에 안전하다.
|
||
- **알고리즘 SSOT**: 구체 알고리즘(90일 회전 salt 로 keyed 한 HMAC-SHA-256)은
|
||
이 영역의 책임다. 이 포트는
|
||
"보안 principal 을 가명 형태로 기록한다" 는 계약만 소유한다. 구현은 `adapter-identifier` 에
|
||
있고 `app-bootstrap` 이 와이어링한다.
|
||
- **계층**: 순수 Java 인터페이스다. 구현은 application-core/domain-core 에서 참조하면 안 된다.
|
||
consumer(`adapter-web` 의 `RequestLoggingFilter`)가 이 포트를 주입받아
|
||
`AuthenticatedPrincipal.idpUserId()` 를 가명화한 뒤 `MDC.put("user_principal", ...)` 한다.
|