Files
clean-architecture-backend-…/src/application-core/README.md
T
donghyeon-ka bbccccc195 merge: integrate messaging R2 polling producer
# 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
2026-08-01 00:04:02 +09:00

602 lines
43 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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", ...)` 한다.