43 KiB
application-core — 설계 결정 참조
애플리케이션 유스케이스 계층. 패키지 루트: dev.caskeleton.application.
허용/금지 의존, 유스케이스 형태, 트랜잭션 모드, 명명 규칙 같은 모듈 규칙은 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-neutralContractDescriptor만 기여한다. 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 가 SpringPlatformTransactionManager로 제공한다. 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 을 던질 수 없다. SpringTransactionCallback<T>제약과 동일하다. 그래서 호출자는 도메인 checked exception 을RuntimeException하위로 감싸야 한다(DomainException extends RuntimeException).IOException→UncheckedIOException,SQLException은 SpringDataAccessException계층이 자동 변환한다. 콜백 안에서 던진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/NEVERpropagation,READ_UNCOMMITTEDisolation, application 패키지에서@Transactional직접 사용,inRootWrite의 ambient transaction 진입,inNew의 per-record 루프 호출.
Notification R1 오케스트레이션 경계
dev.caskeleton.application.notification은 알림 vendor 구현이 아니라 알림 capability의 순수
애플리케이션 계약이다.
- 입력은 typed recipient/template value와 코드 소유
NotificationKindPolicy로 제한한다. feature가 만든NotificationIntentDraft는NotificationPlanPort에서 immutableNotificationFrozenPlan으로 고정되고, 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_PERMISSION403 으로 매핑한다(코드 SSOT 는 베이스라인 소유). - fail-closed: denial 은 필요한 권한과 호출자 subject 만 담고, 호출자의 유효 권한 집합 은 절대 담지 않는다(인가 표면을 클라이언트에 노출하지 않기 위함).
멱등성 (idempotency)
application 계층이 in-flight 대기·replay 정책 을 소유하고, 저장은
IdempotencyStorePort 로 위임한다.
IdempotencyExecutor
- application 유스케이스 경계에서 멱등 실행을 조율한다. 하나의
IdempotencyScope에 대해 순서대로:- claim — store 유니크 제약 위의 atomic insert-or-read(D7). 이긴 caller 가 action 을 정확히 한 번 실행한다.
- fingerprint mismatch — 같은 key, 다른 body(다른 fingerprint)인 live record 는
IdempotencyRequestMismatchException→ 422(D8). - replay —
COMPLETEDrecord 는 저장된 응답을 codec 으로 재생(§B). - in-flight wait — 동시
IN_FLIGHTrecord 는 최대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 가 된다(부하와 실제 요청 형태로 검증 필요). NoSuchAlgorithmExceptioncatch 는 도달 불가다(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— 유스케이스 결과를StoredResponsepayload 로 직렬화/역직렬화. application 은 wire-format 중립이다 — 구체 JSON 인코딩은R의 응답 모양을 아는adapter-webcaller 가 소유하고, 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하고 expiredEXECUTING은RECOVERY_REQUIRED로 닫는다. - complete/fail/release는 operation ID와 result digest가 같은 재호출만 prior result로 replay한다.
SAME_STORE_TRANSACTIONALJPA 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):
- 짧은 write 트랜잭션 안에서 배치를 claim 한다.
occurred_at오름차순으로 방어적 정렬(claim 쿼리도 정렬하지만, adapter 가 안 해도 relay 가 FIFO 를 강제한다).- 각 이벤트를 트랜잭션 밖에서 발행하고 결과로 상태 머신을 구동한다:
- 발행 성공 →
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, wholeOutboxEvent, 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 빈으로 등록하면 안 된다: 클래스 레벨@RequiresPermissionpointcut(webMethodSecurityConfig)이 이 타입 빈을 CGLIB 프록시하려 드는데,final클래스라 불가능하고, 스케줄러 스레드엔Authentication이 없어 매 relay tick 이 fail-closed 거부될 것이다. - 권한:
"outbox:relay"는 시스템 내부 권한이다. relay 는 엔드유저가 아니라 스케줄러 빈이 호출한다.@RequiresPermission선언은 ArchUnitmutating_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-failedrunbook 이 실제 런타임 값과 어긋난다. 이 상수는 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 목록. 내부Outcomeenum 이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-processDefaultLockRegistry는 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 가 만료되면, 락 행은 이미 다른 인스턴스가 회수했을 수 있다. metereddistributedLockProvider가 이를 로그 + 카운트 이벤트(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_idMDC 슬롯을 구현 세부로 읽는다. - 값이 없거나 blank이면 event publisher는 생성한 event id를 correlation id로 재사용해 기존 self-correlation 동작을 유지한다.
UserPrincipalPseudonymizerPort
- raw 보안 principal id 를, 값이 로그/MDC 에 쓰이기 전에 안정적 가명 토큰으로 바꾸는 outbound 포트.
- null/blank 의미:
rawPrincipal이 null/blank 면 null 을 반환한다. 가명화할 게 없으니, 이 경우 caller 는user_principalMDC 키에 아무 값도 넣으면 안 된다. - 안정 토큰 계약: non-blank 입력에 대해 (1) 같은 입력 + 같은 salt 는 같은 salt epoch 안에서
항상 같은 출력을 낸다(안정), (2) raw principal 의 trivial 복원이 불가능하다(일방향 derivation
이지 인코딩·가역 변환이 아니다), (3)
user_principalMDC 와 로그 라인에 안전하다. - 알고리즘 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", ...)한다.