Files
clean-architecture-backend-…/src/application-core
DongHyeonkaandClaude Opus 5 ef947e5bb0 refactor(build,ci): 현재 상태 검증을 걷어내고 불변조건만 남기는 검증 표면 축소
외부 리뷰("현재 상태를 유지하기 위한 검증이 너무 많고, 그 검증 자체를
다시 검증하는 구조까지 생겼다")를 설계 문서로 정리하고 코드로 반영한다.
설계·판단 근거는 docs/superpowers/specs/2026-09-16-verification-surface-reduction-design.md.

삭제
- .github/ci-gate-matrix.yml(1,025줄) + verify-gate-matrix.sh(568줄):
  Gradle task graph와 workflow graph에 이미 있는 정보의 3중 복제
- verify-gradle-wrapper.sh(799줄): workflow 바이트 해시 잠금.
  wrapper 검증은 gradle/actions/wrapper-validation(full SHA 핀)에 위임
- DeveloperExperienceContractTest 등의 CI YAML mutation 테스트:
  애플리케이션 test suite가 GitHub Actions YAML 파서를 검증하던 계층 역전
- 문서 drift 파서: verifyReadmeCommands, verifyRunbookReferences,
  verifyDocumentedLeafCount, verifyTestSourceSetRegistry
- 빈 레지스트리를 지키던 커스텀 YAML 파서: verifyTrivyignore,
  verifyQuarantineSunset, flaky-quarantine.yaml
- verifyConfigurationPropertiesProcessor, verifyOneTypePerFile:
  각각 ca.spring-config convention과 Checkstyle OneTopLevelClass가 대체
- 정상 입력으로도 성공할 수 없던 messaging always-fail task
- ModuleRegistry의 JSON 필드 집합 정확 일치, sample-portfolio negative guard

이동
- java/quality/spring 공통 설정을 configure(subprojects) 블록에서
  ca.java-conventions / ca.quality-conventions / ca.java-library /
  ca.spring-library convention plugin으로
- 아키텍처 검증을 ca.architecture로, JPA·messaging qualification을
  gradle/qualification/ 아래로, verifyEnvKeys를 :app-bootstrap 소유로

완화
- Git revision은 releaseCheck·아카이브 생성에서만 요구. 일반 빌드는 SNAPSHOT
- SpotBugs/FindSecBugs는 로컬 check에서 빼고 qualityCheck 레인으로

task 계층
- leaf check는 그 leaf만. architectureCheck / qualityCheck /
  configContractCheck / integrationCheck / ci / releaseCheck로 이름 분리

CI
- _reusable-gradle.yml 신규. checkout + wrapper validation + JDK/캐시 공통화
- fileserver-release.yml -> fileserver-certification.yml (CD가 아니라 certification)
- GitHub Actions = CI + artifact, Argo CD = CD 경계를 docs/ci-cd/boundary.md로 고정

순증감 +3,274 / -7,483.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-16 20:33:19 +09:00
..

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.contractmessaging.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 어댑터 호출 금지.
    • repositoryAccessWRITE_REPOSITORY 가 아닌 유스케이스는 리포지토리 포트의 쓰기 메서드(save/delete/update/insert)를 호출할 수 없다 — read_only_use_cases_do_not_call_repository_write_methods 규칙이 막는다. 단 정적 분석은 직접 호출만 잡으므로, helper/mapper 를 거친 쓰기는 리뷰가 본다.
    • bulkWrite = trueWRITE_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). IOExceptionUncheckedIOException, 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가 만든 NotificationIntentDraftNotificationPlanPort에서 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. replayCOMPLETED 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 로 왕복시키기만 한다.
  • IdempotencyStatusIN_FLIGHT / COMPLETED 두 값.
  • SleeperThread.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 EXECUTINGRECOVERY_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 <= nowFAILED, next_attempt_at <= nowIN_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 >= maxAttemptsmarkDead, 아니면 markFailed(nextAttemptAt)를 먼저 성공시킨 뒤 해당 typed failure report를 보낸다.
  • 발행 실패는 절대 삼키지 않는다: relay 는 각 발행 예외를 잡아 FAILED/DEAD 상태 머신을 구동하고, 성공한 전이만 OutboxRelayFailureReportPort로 보고한 뒤 rethrow 하지 않는다 (스케줄러 루프가 다음 이벤트로 계속 가야 하므로). 상태 전이가 실패하면 예외가 전파되고 report는 없다. reporter가 RuntimeException을 던져도 persisted outcome을 바꾸거나 다음 이벤트를 막지 못한다.
  • 안전한 allowlist report: OutboxRelayFailureReportcode/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.yamlretry_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 개입이 필요하다.
  • OutboxEventclaimBatch 가 반환하는 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 를 시작한다.
    • 올바른 패턴: tryAcquiretry { 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()AutoCloseablethrows 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-webRequestLoggingFilter)가 이 포트를 주입받아 AuthenticatedPrincipal.idpUserId() 를 가명화한 뒤 MDC.put("user_principal", ...) 한다.