--- title: branch / feature-background-job-async-contract source_type: branch-note status: raw branch: feature-background-job-async-contract parent_branch: related_projects: [ca-skeleton] governing_docs: [raw/project-notes/ca-skeleton-operational-contract] tags: [branch, ca-skeleton, async, scheduler, background-job] created: 2026-05-22 target_merge: status_label: in-progress id: BR-CA-SKELETON-OPERATIONAL-CONTRACT-025 kind: project-work-item project: ca-skeleton-operational-contract work_item: WI-CA-SKELETON-OPERATIONAL-CONTRACT-025 inherits: [DEC-CA-SKELETON-OPERATIONAL-CONTRACT-SCHEDULER-LOCK-001@1, DEC-CA-SKELETON-OPERATIONAL-CONTRACT-EVENT-BROKER-001@1] refines: [] overrides: [] depends_on: [] contract_packet: 1 contract_packet_sha256: 928d7721870b6023078790c09e8a4319b34e3a3a37e0e3b0cfa9666b4c1e4a46 --- # branch: feature-background-job-async-contract > Layer: `raw/branch-notes/` — background job, scheduler, async executor 기준을 정의합니다. ## 부모 (필수) - **Parent project (canonical SSOT)**: [[raw/project-notes/ca-skeleton-operational-contract]] > ca-skeleton 은 별도 root branch 없이 project-note 가 SSOT 역할. 본 feature branch 는 project-note 의 운영 계약 중 해당 영역 (§<관련 섹션>) 의 결정/근거/금지 사항을 정제한다. ## 브랜치 계약 패킷 - **생성 시 프로젝트 개정**: `1` - **패킷 스키마**: `contract_packet: 1` - **완료 조건**: duplicate scheduler/outbox execution 방지 test가 통과한다 ### 상속한 프로젝트 결정 | Decision Ref | Project Summary | Branch Application | Source | |---|---|---|---| | `DEC-CA-SKELETON-OPERATIONAL-CONTRACT-SCHEDULER-LOCK-001@1` | single-instance가 default이며 multi-instance scheduler/outbox는 DB advisory lock을 사용한다 | Work Item 완료 조건에 적용 | [[raw/project-notes/ca-skeleton-operational-contract]] | | `DEC-CA-SKELETON-OPERATIONAL-CONTRACT-EVENT-BROKER-001@1` | core는 broker-agnostic outbox를 제공하고 Kafka는 optional adapter로 둔다 | Work Item 완료 조건에 적용 | [[raw/project-notes/ca-skeleton-operational-contract]] | ### 브랜치 지역 결정 > 기존 branch-local 결정은 아래 `## Decision Evidence Map / 결정-근거 매핑`의 D-row가 소유하며 이 packet에서 복제하지 않는다. | Decision ID | Decision | Relation | Supporting Claims | Status | |---|---|---|---|---| ### 선언한 예외 | Override ID | Overrides | Reason | Approval | Status | |---|---|---|---|---| ## 목표 요청 스레드 밖에서 발생하는 실패는 GlobalExceptionHandler로 잡히지 않습니다. async exception, executor saturation, scheduled job overlap, shutdown 중 job 처리 기준이 필요합니다. - 이슈: - PR: ## 범위 ### 포함 범위 - async exception handling. - executor saturation/rejection 기준. - scheduled job overlap 기준. - job id/correlationId 기준. - retry/backoff 기준. - shutdown 중 job 처리 기준. - background failure logging 기준. ### 제외 범위 - business batch job 구현. - external scheduler platform 연동. - distributed job lock 기본 구현. ## 근거 (필수, 최소 1개+) > 본 branch의 결정 근거. 상세 비교는 §외부 근거 / 대안 조사 (있다면) 참조. | Source | 정당화하는 결정 | |---|---| | [[raw/official-docs/outbox-skip-locked-microservices-io]] | Chris Richardson 원형 | | [[raw/official-docs/skip-locked-postgres-docs]] | Postgres SKIP LOCKED 원리 | | [[raw/company-tech-blogs/outbox-woowahan-techblog-pattern]] | 우아한형제들 polling 사례 | | [[raw/official-docs/outbox-debezium-official-docs]] | [[raw/company-tech-blogs/outbox-wix-engineering-debezium]] | | [[raw/company-tech-blogs/outbox-wix-engineering-debezium]] | — | | [[raw/company-tech-blogs/outbox-confluent-kafka-connect-smt]] | — | | [[raw/official-docs/spring-transactional-event-listener]] | — | | [[raw/official-docs/event-sourcing-vs-outbox-microservices-io]] | — | | [[raw/company-tech-blogs/outbox-netflix-domain-events-cdc]] | — | | [[raw/official-docs/dual-write-antipattern-microservices-io]] | outbox 도입 근거 | | [[raw/official-docs/spring-framework-observability-context-propagating-task-decorator]] | D5/D6 — ContextPropagatingTaskDecorator + setTaskDecorator() 패턴이 Spring 공식 권고, MDC + Observation context worker thread 전파 근거 | | [[raw/official-docs/retry-spring-retry-readme-backoff-defaults]] | D4 — maxAttempts default = 3 verbatim 확인 (SPRING-RETRY-C1); exp+jitter 는 라이브러리 default 아님, 명시 설정 필요 (SPRING-RETRY-C2) | | [[raw/company-tech-blogs/retry-aws-exponential-backoff-and-jitter]] | D4 — Full Jitter 공식·no-jitter 열위 근거·Full vs Equal vs Decorrelated 비교 (AWS-JITTER-C1~C5) | | [[raw/official-docs/spring-boot-graceful-shutdown-reference]] | D8 — SmartLifecycle earliest phase 신규 요청 차단(SB-GS-C2/C5) + `spring.lifecycle.timeout-per-shutdown-phase` phase timeout 상한(SB-GS-C4) | | [[raw/official-docs/kubernetes-pod-lifecycle-termination]] | D8 — k8s terminationGracePeriodSeconds 기본 30s + SIGTERM→SIGKILL 시퀀스 근거 (K8S-POD-LC-C1~C3) | | [[raw/official-docs/spring-executor-configuration-support-javadoc]] | D8 — setWaitForTasksToCompleteOnShutdown(true) + setAwaitTerminationSeconds(N) 조합이 in-flight job 을 컨테이너 종료와 정합시키는 공식 API (default 는 즉시 interrupt) — EXEC-CS-C1~C4 | | [[raw/official-docs/micrometer-context-propagation-purpose-thread-local-accessor]] | D5/D6 — ContextSnapshot/ThreadLocalAccessor 가 async cross-thread ThreadLocal 전파의 공식 메커니즘 (MICRO-CP-C1~C5) | | [[raw/official-docs/retry-aws-well-architected-rel05-bp03]] | D4 — "limit the maximum number of retries" 공식 근거 (WAF-REL05-C1/C2) + non-transient error retry 금지 (WAF-REL05-C3) + multi-layer retry storm anti-pattern (WAF-REL05-C4) + non-idempotent retry 금지 (WAF-REL05-C5) | | [[raw/official-docs/spring-boot-task-execution-scheduling-reference]] | D7 — auto-configured executor 기본값(8 core / unbounded queue) 대비 bounded queue 강제의 공식 근거; virtual threads 대안 존재(SB-TASK-C1~C4) | | [[raw/official-docs/spring-security-concurrency-delegating-security-context-executor]] | D5 — SecurityContext 의 `@Async` 전파는 `DelegatingSecurityContextExecutor`/`DelegatingSecurityContextTaskExecutor` 를 통한 explicit opt-in 이 공식 메커니즘 (SS-CONC-C3, SS-CONC-C4) | | [[raw/official-docs/jdk21-threadpoolexecutor-javadoc]] | D7 — JDK `ThreadPoolExecutor` pool growth 3단계(TPE-JDK21-C1/C2), unbounded queue 에서 maximumPoolSize 무효(TPE-JDK21-C3), bounded queue resource-exhaustion 방지(TPE-JDK21-C4), AbortPolicy 기본값 시맨틱(TPE-JDK21-C5), CallerRunsPolicy 피드백 감속 메커니즘(TPE-JDK21-C6) | | [[raw/official-docs/spring-framework-threadpooltaskexecutor-javadoc]] | D7 — Spring `ThreadPoolTaskExecutor` queueCapacity default = `Integer.MAX_VALUE` unbounded (SF-TPTE-C1) — bounded queue 강제의 negative evidence; 양수 → LinkedBlockingQueue / 0이하 → SynchronousQueue 분기(SF-TPTE-C2); maxPoolSize default = `Integer.MAX_VALUE`(SF-TPTE-C3); TaskDecorator primary use case = execution context + monitoring(SF-TPTE-C4) | ## 외부 근거 / 대안 조사 (2026-05-22 — Topic 3) 본 branch의 retry/DLQ/scheduler 결정에 대한 외부 source 조사. outbox publisher는 본 branch의 retry vocabulary를 consume. 6종 대안 비교는 (예정) `wiki/concepts/transactional-outbox-pattern.md` 참조. - **채택 결정 (DB polling + FOR UPDATE SKIP LOCKED)**: - [[raw/official-docs/outbox-skip-locked-microservices-io]] — Chris Richardson 원형 - [[raw/official-docs/skip-locked-postgres-docs]] — Postgres SKIP LOCKED 원리 - [[raw/company-tech-blogs/outbox-woowahan-techblog-pattern]] — 우아한형제들 polling 사례 - **검토한 대안**: - **대안 1: Debezium CDC** — [[raw/official-docs/outbox-debezium-official-docs]], [[raw/company-tech-blogs/outbox-wix-engineering-debezium]] - **대안 2: Kafka Connect outbox SMT** — [[raw/company-tech-blogs/outbox-confluent-kafka-connect-smt]] - **대안 3: Spring @TransactionalEventListener** (in-process only) — [[raw/official-docs/spring-transactional-event-listener]] - **대안 4: Event sourcing 전환** — [[raw/official-docs/event-sourcing-vs-outbox-microservices-io]] - **대안 5: Netflix DBLog 급 자체 CDC** — [[raw/company-tech-blogs/outbox-netflix-domain-events-cdc]] - **Negative reference (금지)**: [[raw/official-docs/dual-write-antipattern-microservices-io]] — outbox 도입 근거 - **비교 핵심**: polling lag vs CDC 인프라 비용이 결정 축. ca-tmpl 가정 = lag 수 초 허용 + Kafka Connect 운영 인력 부재 + DB가 SSOT. 가정 깨지면 Debezium migration. event sourcing은 "대안"이라기보다 도메인 모델 교체. background-job branch는 outbox publisher의 retry/DLQ를 owns. Debezium은 retry를 Kafka Connect dead-letter에 위임, ca-tmpl은 자체 DLQ vocabulary. ### 추가 외부 근거 (2026-06-11 — D4/D5/D6/D7/D8 자동조사) `/branch-spec` 자동조사로 UNSUPPORTED 였던 D4·D5·D6·D7·D8 에 공식 doc 근거 12건을 아카이브 (위 Sources 표 11~22행). 대안 비교 요지: - **D4 retry shape**: 채택 = exp + jitter + max 3 + DLQ. 대안 = fixed-interval(단일 인스턴스·예측 가능 복구 한정 — Spring Retry/Resilience4j 라이브러리 default), unlimited retry + circuit breaker(외부 HTTP 의존 전용 — DB 기반 DLQ 와 시맨틱 충돌). non-transient error 는 retry 자체가 anti-pattern (WAF-REL05-C3). - **D5/D6 context propagation**: 채택 = TaskDecorator 1개 등록. Spring 공식 구현체 `ContextPropagatingTaskDecorator` 가 MDC + Observation 을 동시 전파 (SF-OBS-C1/C2) — 수동 4-key copy 대비 우위이나 `io.micrometer:context-propagation` classpath 필수 (SF-OBS-C3). `SecurityContextHolder.MODE_INHERITABLETHREADLOCAL` 은 thread pool 재사용 시 stale context 위험으로 부적합 — explicit opt-in 은 `DelegatingSecurityContext*` (SS-CONC-C3/C4). - **D7 saturation**: 채택 = bounded queue + AbortPolicy. 대안 = CallerRunsPolicy(caller 가 request thread 가 아닐 때만 — request latency 직접 침식, TPE-JDK21-C6), unbounded queue 는 REJECTED(max pool 무효화 — TPE-JDK21-C3 + SF-TPTE-C1). Boot 3.2+ virtual threads(`SimpleAsyncTaskExecutor`)는 별도 검토 대상 (SB-TASK-C4). - **D8 shutdown**: 채택 = budget-fit (await ≤ 19s + 멱등 retry-on-next-startup). 대안 = terminationGracePeriodSeconds 연장 — parent project 운영 계약 변경이므로 본 branch 범위 밖. ## TODO > TODO drained — 결정은 아래 표/결정 사항 참조. ## 진행 중 메모 - background failure는 HTTP response가 없으므로 log/metric/alert가 핵심 계약입니다. ## 구현 기록 > `documented-only`/`planned` → 실 구현 + 로컬 검증 완료. 구현 git 브랜치: `feature/domain-event-outbox-contract` (background-job 을 outbox 브랜치 위에서 이어서 구현). §Audit A6 의 "전부 미구현(planned)" 상태가 아래로 갱신됨. - **D4 retry/DLQ vocabulary** (`actually-implemented`): `shared-contract` `OperationalError` 에 `JOB_EXECUTOR_REJECTED`(TRANSIENT_DEPENDENCY/503/true), `JOB_TIMEOUT`(TRANSIENT_DEPENDENCY/500/true), `JOB_DEAD_LETTER`(INTERNAL/500/false) 추가 — registry SSOT 와 일치(ErrorCodeRegistryMappingTest + BackgroundJobErrorCodeContractTest 가 category/status/retryable/runbook_link 교차검증). retry **carrier 는 미구현(planned, §3 UNSUPPORTED_IMPL)** — 어휘(error code + metric recorder)만 SSOT 로 고정. `BackgroundJobMetrics` 가 `job.retry.total{job_name,outcome}`·`job.dlq.total{job_name}` recorder seam 제공(outbox/outbound consume 용). NOTE: `retry_attempt` 는 metric tag 아님(log field) — recorder 시그니처는 `(job_name, outcome)`. - **D7 executor + saturation** (`actually-implemented` / 수치 `planned`): `app-bootstrap` `async/AsyncExecutorConfig` 가 bounded `ThreadPoolTaskExecutor`(core=10/max=50/queue=200, `applicationTaskExecutor`, `@Primary`, Boot unbounded auto-executor back-off) 등록. `AsyncExecutorSettings`(`ca-skeleton.async.executor.*`)가 `Integer.MAX_VALUE` 큐를 거부(unbounded forbidden). saturation = `LoggingAbortPolicy`(AbortPolicy + 구조화 ERROR 로그 error.code=JOB_EXECUTOR_REJECTED + `executor.rejected.total` + 재던짐) + `executor.saturation` 게이지. **수치(10/50/200)는 부하테스트 미검증 `planned`**. - **D5/D6 context propagation** (`locally-verified`): `AsyncContextTaskDecorator` 1개 — submit 시점 `MDC.getCopyOfContextMap()` 스냅숏(request_id/trace_id/correlation_id/tenant_id + span_id) + `DomainContextPropagator.wrap` (shared seam), 대칭 복원으로 풀 스레드 MDC bleed 방지. "TaskDecorator 미설정이면 fail" = decorator 를 executor @Bean 의 필수 의존성으로 주입(부재 시 context 기동 실패, AsyncExecutorConfigTest 가 검증). **SecurityContext principal 은 기본 전파 안 함**(opt-in `DelegatingSecurityContextTaskExecutor`, registry user_principal=`propagation:[none]`) — spec "Async Context Propagation Contract" 의 principal 라인과의 긴장은 registry SSOT + D6 우선으로 해소(문서화). Observation **scope** 전파는 `context-propagation` 라이브러리 미반입으로 MDC 문자열 복사까지만(업그레이드 경로 문서화). - **D8 graceful shutdown** (`locally-verified`): `setWaitForTasksToCompleteOnShutdown(true)` + `setAwaitTerminationSeconds(19)` (AsyncExecutorConfigTest 가 awaitTerminationMillis=19000 검증). - **D3 scheduler overlap / multi-instance** (`actually-implemented`): overlap = `ScheduledJobOverlapPolicyTest` (ArchUnit) 가 production `@Scheduled` 의 fixedRate 사용 금지(전부 fixedDelay). multi-instance lock 은 **기존** `StartupSafetyValidator.REQUIRED_MULTI_INSTANCE_BEANS` 의 `distributedLockProvider` 를 consume(재구현 아님) — `StartupSafetyValidatorTest` 가 이미 검증(exit 72). - **§Audit A4 runbooks** (`actually-implemented`): `docs/runbooks/job-executor-rejected.md`·`job-timeout.md`·`job-dead-letter.md` 작성 — `runbook://job/` → `docs/runbooks/job-.md` 해소(BackgroundJobErrorCodeContractTest 가 파일 존재 검증). - **wiring**: `application.yml` `ca-skeleton.async.executor.*` + `src/.env` `APP_ASYNC_EXECUTOR_*` 3종(verifyEnvKeys green). - **검증 명령**: `:shared-contract:test` 72/72 green; `:app-bootstrap:test` 256/257 green(유일 실패는 **선재** `CleanArchitectureTest#outbound_adapter_method_returns_only_domain_or_primitives` — adapter-outbound `OutboundHttpSettings`, 본 작업 무관, `git stash` baseline 로 확인 → [[raw/errors/archunit-b7-configprops-nested-record-accessor-outbound-2026-06-13]]); `verifyEnvKeys` green; `verifyCleanArchitectureDependencies` green. 3단 리뷰 체인(architect-sentinel PASS / spec-reviewer 22/22 / quality-reviewer 2건 수정) 통과. - **변경 파일**: `shared-contract/.../OperationalError.java`(+test), `app-bootstrap/.../bootstrap/async/{AsyncExecutorSettings,AsyncContextTaskDecorator,BackgroundJobMetrics,LoggingAbortPolicy,AsyncExecutorConfig}.java`(+6 test), `app-bootstrap/.../contract/BackgroundJobErrorCodeContractTest.java`, `application.yml`, `src/.env`, `docs/runbooks/job-*.md`, `docs/superpowers/plans/2026-06-13-background-job-async-contract.md`. ## 결정 사항 (decisions) - 2026-05-22: scheduler/async는 runtime lifecycle에서 별도 branch로 분리. - 2026-05-22: retry/DLQ vocabulary의 SSOT는 이 branch. outbox/outbound branches는 이 vocabulary를 소비. - 2026-05-22: scheduler/outbox publisher는 single-instance 기본이며 multi-instance 지원 시 DB advisory lock 또는 ShedLock contract test가 필요. - 2026-05-22: 기본 backoff는 exponential backoff with jitter, max attempts 3, DLQ after exhausted attempts. - 2026-05-22: @Async context propagation은 `TaskDecorator` 1개를 ThreadPoolTaskExecutor에 등록해 caller→worker thread로 MDC(`request_id`/`trace_id`/`correlation_id`/`tenant_id`), Micrometer Observation context를 복사한다. SecurityContext는 explicit opt-in 시에만 전파. executor 등록 시 TaskDecorator 미설정이면 fail. - 2026-05-22: span_id는 Micrometer Observation context에서 자동 전파(MDC explicit copy 불필요), user_principal은 SecurityContext propagation이 opt-in일 때만 복사. 따라서 explicit MDC copy 대상은 foundation 6개 중 4개(request_id, trace_id, correlation_id, tenant_id). - 2026-05-22: executor pool sizing default = core=10, max=50, queue=200. saturation policy default = AbortPolicy. CallerRunsPolicy는 명시적 use case-level 선언 시에만 허용. - 2026-05-22: graceful shutdown = executor await termination ≤ **19s** (container-runtime의 app shutdown 20s 내부에서 1s cleanup margin 확보. 25s는 force-stop 유발하므로 forbidden). - 2026-06-13 (구현 정정): D6 의 "span_id 는 Observation context 자동 전파(MDC explicit copy 불필요)" 는 구현과 어긋남 — 실제는 MDC **전체 스냅숏 문자열 복사**로 span_id 가 동승하며 Observation *scope* 는 전파하지 않음(context-propagation 라이브러리 미반입). parent-span linkage 필요 시 `ContextPropagatingTaskDecorator` upgrade. (Decision Evidence Map D6 갱신 반영.) - 2026-06-13 (긴장 해소): "Async Context Propagation Contract" 의 "principal 이 caller 와 동일" 라인은 D6(`user_principal` = `propagation:[none]`) + 보안(풀 스레드 stale principal 위험)과 충돌 → **principal 은 기본 비전파**로 확정. SecurityContext 필요 use case 만 `DelegatingSecurityContextTaskExecutor` opt-in. 테스트 계약을 "principal 비전파" negative 검증으로 교체. ## Work Item Contract 각 TODO는 아래 판정 단위로 재작성되어야 canonical 승급 가능합니다. TODO가 단순히 `기준 작성`으로 남아 있으면 이 branch는 완료로 보지 않습니다. | field | required | rule | | --- | --- | --- | | Decision | yes | 구현자가 선택해야 하는 기본값 | | Allowed | yes | 허용되는 예외와 조건 | | Forbidden | yes | 절대 금지되는 구현/문서 상태 | | Required registry update | conditional | error/env/header/log/metric/capability 변경 시 필수 | | Required contract test | yes | 계약 위반 시 실패해야 하는 테스트 | | Failure condition | yes | review/build에서 실패로 판정할 상태 | | Canonical extraction target | yes | `wiki/projects` 승급 위치 | ## Decisionized Work Items | item | Decision | Allowed | Forbidden | Required test | | --- | --- | --- | --- | --- | | async exception | structured log + metric + runbook link | fail-fast for critical background worker | swallowed exception | async exception test | | saturation | bounded executor + rejection log | caller-runs only if documented | unbounded queue | rejection test | | scheduler overlap | no overlap by default | overlap only with idempotent job proof | concurrent same job mutation | overlap test | | retry/DLQ | exp backoff jitter, max 3, DLQ exhausted | branch-specific override with metric | infinite retry | retry/DLQ test | | multi-instance lock | single-instance default | DB advisory lock or ShedLock | multi-replica without lock | distributed lock test | | async context propagation | TaskDecorator 1개로 MDC + Observation 전파 | SecurityContext explicit opt-in | TaskDecorator 미설정 executor 등록 | @Async 메서드 안에서 MDC.get("request_id"), traceId, principal이 caller와 동일해야 함 | | saturation policy | AbortPolicy default (core=10, max=50, queue=200) | CallerRunsPolicy with explicit use case 선언 | unbounded queue / 미선언 fallback | saturation policy test | | graceful shutdown | await termination ≤ 19s (app shutdown 20s − 1s cleanup margin) | 짧은 quiet period override | await ≥ 20s / terminate without await | shutdown await test | ## 결정-근거 매핑 > 각 결정이 어떤 raw source claim 으로 뒷받침되는지 명시. Company-tech-blog 인용은 사례 (`company-case-study`) 로만 사용하며 공식 best practice 로 단정하지 않는다. > `선택 조건` 열(R2): "이 조건일 때 이 결정, 다른 조건이면 어떤 대안". 분기 없으면 `N/A`. | Decision ID | Decision | 선택 조건 (언제 이 결정 / 언제 대안) | Supporting Claims | Evidence Strength | Open Risk | |---|---|---|---|---|---| | D1 | scheduler/async 는 runtime lifecycle 에서 별도 branch 로 분리 (이 branch 가 retry/DLQ vocabulary SSOT) | N/A — 내부 스코프 결정 (분기 없음) | UNSUPPORTED_DECISION — 내부 조직/스코프 결정으로 외부 raw 근거 부재 | `internal-only` | 다른 branch (outbox/outbound) 가 이 vocabulary 를 일관 참조하는지 lint 필요 | | D2 | retry/DLQ vocabulary SSOT 결정 — outbox/outbound branches 가 이를 consume | N/A — 내부 계약 (분기 없음) | UNSUPPORTED_DECISION — 외부 raw 의 단일 SSOT 권고 인용 부재 (내부 계약) | `internal-only` | vocabulary drift 위험 | | D3 | scheduler/outbox publisher 는 single-instance 기본, multi-instance 시 DB advisory lock 또는 ShedLock 필수 | `APP_MULTI_INSTANCE_ENABLED=false`(default) → lock 불요; `true` → `distributedLockProvider` bean 필수 (ca-tmpl `StartupSafetyValidator` 가 startup fail 로 강제) | `raw/official-docs/skip-locked-postgres-docs.md#SK-PG-C2`, `raw/official-docs/outbox-skip-locked-microservices-io.md#OUTBOX-MIO-C4` | `official-vendor-doc + needs-confirmation` (microservices.io needs verbatim recheck) | SKIP LOCKED 는 lock contention 회피만 보장, 순서 보장은 별도 — `SK-PG-C2` 의 "inconsistent view" 경고 | | D4 | 기본 backoff = exponential + jitter, max attempts 3, DLQ after exhausted | multi-instance 가능 또는 공유 자원(DB) 대상 transient 실패 → exp+jitter (동기화 retry spike 방지, WAF-REL05-C1); 보장된 단일 인스턴스 + 예측 가능한 짧은 복구 → fixed-interval 허용(라이브러리 default); non-transient error(권한/도메인/스키마) → retry 없이 즉시 DLQ (WAF-REL05-C3); 외부 HTTP 의존 → circuit breaker 는 outbound adapter 레이어 보완재(대체재 아님) | maxAttempts=3: `raw/official-docs/retry-spring-retry-readme-backoff-defaults.md#SPRING-RETRY-C1`; exp+jitter 는 default 아님 명시 설정 필요: `#SPRING-RETRY-C2`; exp+jitter+max limit 조합 필수(WAF 공식 권고): `raw/official-docs/retry-aws-well-architected-rel05-bp03.md#WAF-REL05-C1`; max limit 없으면 metastable failure: `#WAF-REL05-C2`; non-transient error → retry 금지(DLQ 방향): `#WAF-REL05-C3`; single-layer retry 원칙: `#WAF-REL05-C4`; non-idempotent retry 금지: `#WAF-REL05-C5`; Full Jitter 사례: `raw/company-tech-blogs/retry-aws-exponential-backoff-and-jitter.md#AWS-JITTER-C1~C5` (company-case-study). DLQ 아키텍처 자체는 WAF-REL05-C3 방향으로 정당화, DLQ 설계 상세는 별도 doc 부재 | `official-vendor-doc` (WAF-REL05-C1~C5 + SPRING-RETRY-C1/C2) + `company-case-study` (AWS-JITTER); DLQ 설계 상세 `unsupported` | max=3 이 ca-tmpl 부하에 적합한지 측정 필요 (`WAF-REL05-C2` use-case 별 조정 권고); exp+jitter `@Backoff` 명시 설정 필요; retry carrier 미확정 (§구현 가이드 3) | | D5 | `@Async` TaskDecorator 1개로 MDC(`request_id`/`trace_id`/`correlation_id`/`tenant_id`) + Observation context 전파, SecurityContext explicit opt-in, 미설정 fail | Micrometer tracing 활성 + `io.micrometer:context-propagation` classpath(Boot 3.2+) → `ContextPropagatingTaskDecorator` 권장(SF-OBS-C1); 라이브러리 반입 불가 또는 key 별 fine-grained 통제 필요 → 수동 4-key copy decorator; SecurityContext 필요 use case → `DelegatingSecurityContextTaskExecutor` opt-in(SS-CONC-C3); `MODE_INHERITABLETHREADLOCAL` 은 thread pool 에서 금지 | MDC+Observation: `raw/official-docs/spring-framework-observability-context-propagating-task-decorator.md#SF-OBS-C1~C4`; Micrometer: `raw/official-docs/micrometer-context-propagation-purpose-thread-local-accessor.md#MICRO-CP-C1`; **SecurityContext explicit opt-in: `raw/official-docs/spring-security-concurrency-delegating-security-context-executor.md#SS-CONC-C3` + `#SS-CONC-C4`**; 미설정 fail: UNSUPPORTED_DECISION | `official-vendor-doc` (SF-OBS/MICRO-CP/SS-CONC) + `unsupported` (미설정 fail 강제 메커니즘) | SecurityContext 를 `DelegatingSecurityContextTaskExecutor` 로 감싸는 것과 TaskDecorator 내 manual propagation 의 중복 여부 별도 검증 필요 | | D6 | **구현 정정 (2026-06-13)**: TaskDecorator 가 submit 시점 `MDC.getCopyOfContextMap()` **전체 스냅숏**을 복사 → foundation 4키(request_id/trace_id/correlation_id/tenant_id) + 그 시점 MDC 에 있는 span_id 가 **문자열로 동승**. user_principal 은 MDC 비대상(`propagation:[none]`)이라 미전파. **Observation *scope* 자체는 전파 안 함**(context-propagation 라이브러리 미반입) — span_id 연속성은 "Observation 자동 전파"가 아니라 MDC 문자열 복사에 의존 | 현재 = MDC whole-map 복사(로그 필드 연속성까지); `io.micrometer:context-propagation` 도입 시 `ContextPropagatingTaskDecorator` 로 교체하면 Observation scope(parent-span linkage)까지 전파 — upgrade 경로 | whole-map 복사로 4키 포함 보장: `raw/official-docs/spring-framework-observability-context-propagating-task-decorator.md#SF-OBS-C2`; cross-thread ThreadLocal 전파 원리: `raw/official-docs/micrometer-context-propagation-purpose-thread-local-accessor.md#MICRO-CP-C1/C2/C5` | `official-vendor-doc` (메커니즘) + `locally-verified` (구현·테스트) | (1) whole-map 복사라 비-foundation MDC 키도 동승 — 의도적(로그 연속성), negative test 부재. (2) Observation scope 미전파 = trace parent-span linkage 단절; tracing bridge 가 span_id 를 MDC 에 안 쓰는 구성이면 worker 로그 span_id 공백 가능 — upgrade 경로로 해소 | | D7 | executor pool sizing default = core=10, max=50, queue=200, saturation = AbortPolicy default (CallerRunsPolicy 는 use-case 선언 시) | caller = HTTP request thread + saturation 관찰 필요 + DLQ/retry 계약 존재 → AbortPolicy (TPE-JDK21-C5); caller 가 request thread 아님 + task 손실 불허 + DLQ 부재 → CallerRunsPolicy use-case 명시 선언 (TPE-JDK21-C6 의 감속 = request latency 침식); unbounded queue → FORBIDDEN (max pool 무효 — TPE-JDK21-C3, SF-TPTE-C1) | `[[raw/official-docs/jdk21-threadpoolexecutor-javadoc]]#TPE-JDK21-C1` (pool growth 3단계), `#TPE-JDK21-C2` (max 도달 시 거부), `#TPE-JDK21-C3` (unbounded queue 에서 max 무효), `#TPE-JDK21-C4` (bounded queue resource-exhaustion 방지), `#TPE-JDK21-C5` (AbortPolicy 기본값), `#TPE-JDK21-C6` (CallerRunsPolicy 피드백 감속); Boot 기본값 대비: `raw/official-docs/spring-boot-task-execution-scheduling-reference.md#SB-TASK-C1~C3`; Spring default unbounded: `raw/official-docs/spring-framework-threadpooltaskexecutor-javadoc.md#SF-TPTE-C1~C3` | `official-reference` (구조) | 구체적 수치(core=10/max=50/queue=200)는 `UNSUPPORTED_IMPL_DECISION` — 부하 테스트로 별도 검증 필요 (registry `APP_ASYNC_EXECUTOR_*` default 는 본 branch 결정의 반영이므로 외부 근거 아님) | | D8 | graceful shutdown = executor await termination ≤ 19s (container 20s − 1s cleanup margin), 25s 는 forbidden | job p99 실행 시간 < 19s + 멱등 retry-on-next-startup 가능 → budget-fit await ≤ 19s; long-running job(> 19s) 이 정당한 비즈니스 요건 → grace period 연장 검토는 OUT_OF_BRANCH_SCOPE (parent project 운영 계약 소유자 승인 필요) | `raw/official-docs/spring-executor-configuration-support-javadoc.md#EXEC-CS-C1` (default=false → 명시 필수), `#EXEC-CS-C2` (true 시 running+queued 완료 후 종료), `#EXEC-CS-C3` (setAwaitTerminationSeconds 공식 API), `#EXEC-CS-C4` (significantly higher timeout rule-of-thumb); `raw/official-docs/spring-boot-graceful-shutdown-reference.md#SB-GS-C2/C5`; `raw/official-docs/kubernetes-pod-lifecycle-termination.md#K8S-POD-LC-C1` (terminationGracePeriodSeconds default=30s), `#K8S-POD-LC-C2` (grace period 초과 시 SIGKILL), `#K8S-POD-LC-C3` (kubelet → SIGTERM to process 1); 19s 수치는 `UNSUPPORTED_IMPL_DECISION` (20s app shutdown − 1s margin — app-level 20s 는 SB-GS-C4 + ca-tmpl 설정 확인 필요) | `official-vendor-doc` (Spring + k8s) + `UNSUPPORTED_IMPL_DECISION` (19s = 20s − 1s margin) | 19s 초과 금지 이유는 k8s grace period 초과 시 SIGKILL (K8S-POD-LC-C2) 로 직접 정당화됨. 20s app timeout 과 k8s 30s grace period 의 관계 — ca-tmpl 실제 `terminationGracePeriodSeconds` 설정 확인 필요 (§Audit A1 drift 참조) | | D9 | outbox publisher baseline = SKIP LOCKED polling (대안 검토 후 채택) | lag 수 초 허용 + Kafka Connect 운영 인력 부재 + DB 가 SSOT → SKIP LOCKED polling; lag SLO 강화 또는 polling 비용 임계 초과 → Debezium CDC migration (D10) | `raw/official-docs/skip-locked-postgres-docs.md#SK-PG-C1`, `raw/official-docs/skip-locked-postgres-docs.md#SK-PG-C2`, `raw/official-docs/outbox-skip-locked-microservices-io.md#OUTBOX-MIO-C1`, `raw/official-docs/outbox-skip-locked-microservices-io.md#OUTBOX-MIO-C3`, `raw/official-docs/outbox-skip-locked-microservices-io.md#OUTBOX-MIO-C4` | `official-vendor-doc + needs-confirmation` | `OUTBOX-MIO-C3` 의 "frequently polling can be expensive" 한계 — polling interval 측정 필요. **owner 이관 권고 — §Audit A2** | | D10 | 대안 1 (Debezium CDC) 비교 — Kafka Connect 운영 인력 부재 시 부적합 | Kafka Connect 운영 가능 + lag SLO 빡빡 → Debezium 재검토; 그 외 → polling 유지 | `raw/official-docs/outbox-debezium-official-docs.md#OUTBOX-DBZ-C1`, `raw/official-docs/outbox-debezium-official-docs.md#OUTBOX-DBZ-C2`, `raw/official-docs/outbox-debezium-official-docs.md#OUTBOX-DBZ-C4`, `raw/company-tech-blogs/outbox-wix-engineering-debezium.md#WIX-DEBEZIUM-C1` | `needs-confirmation + company-case-study(needs-confirmation)` | Debezium raw 전체가 `needs-confirmation` (WebFetch 403 차단) — verbatim 재확인 필요. Wix 인용은 사례, 공식 best practice 아님. **owner 이관 권고 — §Audit A2** | | D11 | 대안 5 (Spring `@TransactionalEventListener`) = in-process only, 외부 broker 발행 부적합 | in-process 소비만 필요한 이벤트 → 사용 가능; 외부 broker 발행 필요 → outbox 필수 | `raw/official-docs/spring-transactional-event-listener.md#TX-EVT-C1`, `raw/official-docs/spring-transactional-event-listener.md#TX-EVT-C3`, `raw/official-docs/spring-transactional-event-listener.md#TX-EVT-C4` | `official-vendor-doc` | `TX-EVT-C4` 의 "no transaction → not invoked" 시맨틱 — fallbackExecution 사용 시 별도 검증 필요. **owner 이관 권고 — §Audit A2** | | D12 | dual-write 금지 (outbox 도입 근거) | N/A — negative reference (금지 규칙, 분기 없음) | `raw/official-docs/dual-write-antipattern-microservices-io.md#DUAL-WRITE-C1`, `raw/official-docs/dual-write-antipattern-microservices-io.md#DUAL-WRITE-C2`, `raw/official-docs/dual-write-antipattern-microservices-io.md#DUAL-WRITE-C3` | `needs-confirmation` (microservices.io verbatim recheck 필요) | dual-write 의 inconsistency 형태 (lost vs phantom event) 별도 분류 필요. **owner 이관 권고 — §Audit A2** | ## 구현 가이드 > 작성일 2026-06-11 (명세). ca-tmpl ground truth (registry + src grep) 대조 완료 — 계약 값은 전부 registry 기존 값 재사용, invent 없음. **구현 상태는 §구현 기록(2026-06-13) 이 authoritative** — 아래 표의 `planned` 중 다수가 구현 완료로 갱신됨(executor bean / saturation / TaskDecorator / awaitTermination 등). §Audit A6 의 "전부 미구현" 은 명세 시점 스냅숏이며 §구현 기록으로 대체됨. ### 1. Executor 구성 + saturation (D7, D8) > **Trace**: In-scope "executor saturation/rejection 기준" → D7 (TPE-JDK21-C1~C6, SB-TASK-C1~C3, SF-TPTE-C1~C3) + D8 (EXEC-CS-C1~C4). > > - **UNSUPPORTED_IMPL_DECISION**: ① 수치 core=10/max=50/queue=200 — 외부 doc 은 구조(bounded queue + max 발동 조건)만 권고, 수치는 부하테스트 전 사용자 trade-off. ② RejectedExecutionHandler 를 structured log + error code 로 wrapping 하는 패턴 — 공식 reference 부재, JOB_EXECUTOR_REJECTED 매핑은 registry 계약에서 도출. | 항목 | 명세 | 상태 | |---|---|---| | bean 위치 | `src/app-bootstrap/.../bootstrap/` config 클래스 (app-bootstrap CLAUDE.md: 최종 cross-module wiring 책임 — `IdempotencyConfig` 선례 패턴) | `planned` | | pool 설정 키 | `APP_ASYNC_EXECUTOR_CORE_SIZE`(10) / `APP_ASYNC_EXECUTOR_MAX_SIZE`(50) / `APP_ASYNC_EXECUTOR_QUEUE_CAPACITY`(200) — ca-tmpl `docs/registries/env-keys.yaml` 기존 값 (owner_branch = 본 branch, required_test 3종 포함) | registry 확정 / 코드 `planned` | | queue | bounded 필수 — unbounded 는 max pool 무효 (TPE-JDK21-C3) + Spring default `Integer.MAX_VALUE` 금지 (SF-TPTE-C1) | `planned` | | rejection | `AbortPolicy` → `RejectedExecutionException` catch → structured log + `JOB_EXECUTOR_REJECTED` (error-codes.yaml: TRANSIENT_DEPENDENCY / 503 / retryable / retry_after 5s) + `executor.rejected.total` counter (metrics.yaml, alert p1) | registry 확정 / 코드 `planned` | | saturation 관측 | `executor.saturation` gauge (metrics.yaml: p2 queue > 80% / p1 rejection > 0 for 1m) | registry 확정 / 코드 `planned` | | shutdown knob | `setWaitForTasksToCompleteOnShutdown(true)` + `setAwaitTerminationSeconds(19)` (EXEC-CS-C2/C3 — default 는 즉시 interrupt, EXEC-CS-C1) | `planned` | ### 2. Async context propagation (D5, D6) > **Trace**: In-scope "job id/correlationId 기준" → D5/D6 (SF-OBS-C1~C4, MICRO-CP-C1~C5, SS-CONC-C3/C4, SF-TPTE-C4/C5). > > - **구현 현황 + 권고 (2026-06-13)**: "TaskDecorator 미설정이면 fail" 을 현 구현은 *decorator 를 executor @Bean 의 필수 생성자 의존성으로 주입*해 강제 — 단 이는 **이 executor bean 하나만** 보호한다(다른 곳에 bare `ThreadPoolTaskExecutor` 를 또 등록하면 통과). 스켈레톤은 drift guardrail 이 핵심이므로 **전역 가드로 승격 권고**: `ScheduledJobOverlapPolicyTest`·`CleanArchitectureTest` 와 같은 결의 ArchUnit/startup 검증으로 "등록된 모든 `TaskExecutor` bean 은 context decorator 보유"를 강제. 승급 완료 시 이 항목의 `UNSUPPORTED_IMPL_DECISION` 성격 제거. | 항목 | 명세 | 상태 | |---|---|---| | 연결 seam | `shared-contract` `dev.caskeleton.shared.concurrency` `DomainContextPropagator.wrap(Runnable)` 를 `AsyncContextTaskDecorator` 가 실제 호출 | `actually-implemented` | | MDC copy 대상 | submit 시점 `MDC.getCopyOfContextMap()` 전체 스냅숏 — foundation 4키(`request_id`/`trace_id`/`correlation_id`/`tenant_id`) 보장 + span_id 동승(문자열). `user_principal` 은 `propagation: [none]` 미전파. mdc-keys.yaml foundation 과 정합 | `locally-verified` | | 구현 캐리어 | 현재 = 수동 MDC whole-map decorator(`AsyncContextTaskDecorator`). upgrade 경로 = `ContextPropagatingTaskDecorator` (Spring 6.1+, SF-OBS-C1) — `io.micrometer:context-propagation` 도입 시 Observation scope 까지 전파. D5 "TaskDecorator 1개" 는 Composite 1개 등록으로 충족 | 현 `locally-verified` / upgrade `planned` | | SecurityContext | `DelegatingSecurityContextTaskExecutor` wrapper 로 use-case 별 explicit opt-in (SS-CONC-C3/C4). `MODE_INHERITABLETHREADLOCAL` 금지(thread pool stale context). 기본 비전파 | opt-in `planned` / 기본 비전파 `actually-implemented` | | **미설정 fail 강제** | 현: decorator = executor @Bean 필수 생성자 의존성(이 bean 한정 — `AsyncExecutorConfigTest` 검증). **구현됨 (2026-06-13)**: 전역 ArchUnit 규칙 `every_task_executor_bean_has_context_decorator`(production `TaskExecutor` @Bean 은 factory method 안에서 `setTaskDecorator(...)` 호출 필수, `getMethodCallsFromSelf` 검사) — decorator 없는 executor @Bean 추가 시 ArchUnit fail. delegating wrapper(예: `DelegatingSecurityContextTaskExecutor`)는 명시적 예외 등록 필요(rule javadoc) | 생성자 강제 `locally-verified` / 전역 가드 `actually-implemented` (`TaskExecutorDecoratorPolicyTest`) | | 예외 경로 주의 | submit() 경로의 TaskDecorator 예외는 FutureTask 로 래핑되어 자동 전파 안 됨 (SF-TPTE-C5) — async exception test 는 execute()/submit() 두 경로 모두 검증 | `locally-verified` (2경로 테스트) | ### 3. Retry / DLQ vocabulary (D4) > **Trace**: In-scope "retry/backoff 기준" → D4 (WAF-REL05-C1~C5, SPRING-RETRY-C1/C2, AWS-JITTER-C1~C5 사례). > > - **UNSUPPORTED_IMPL_DECISION**: retry carrier 선택 (Spring Retry vs Resilience4j vs 자체 구현) — Spring Retry 는 maintenance mode 진입 (SPRING-RETRY-C4: "superseded by Spring Framework 7"), 사용자 trade-off 로 carrier 확정 전까지 vocabulary 만 SSOT 로 고정. > - **소비자-활성화 계약 (2026-06-13)**: `job.retry.total`/`job.dlq.total` recorder 와 `JOB_TIMEOUT`/`JOB_DEAD_LETTER` 코드는 이 branch 가 *제공*(SSOT)하되 *활성화*는 **소비자 branch** 책임 — 1차 소비자 = [[raw/branch-notes/feature-domain-event-outbox-contract]] publisher 의 발행 소진(exhaustion) 경로. 따라서 이 branch 에서 recorder 가 live-invoke 되지 않는 것은 "미구현"이 아니라 "소비자 대기". **rot 방지 가드 권고**: outbox branch 에 "발행 소진 시 `job.dlq.total` invoke + `JOB_DEAD_LETTER` emit" contract test 를 둬 recorder 가 영원히 안 불리는 dead-contract 차단 — 이 가드 부재가 현 vocabulary 경계의 *유일한 잔여 리스크*. | 항목 | 명세 | 상태 | |---|---|---| | retry 실패 코드 | `JOB_TIMEOUT` (TRANSIENT_DEPENDENCY / retryable), DLQ 진입 = `JOB_DEAD_LETTER` (INTERNAL / retryable=false) — `OperationalError` enum + error-codes.yaml 교차검증(BackgroundJobErrorCodeContractTest). NOTE: `JOB_TIMEOUT` http_status=500(503 아님) | `actually-implemented` | | metric recorder | `BackgroundJobMetrics` 가 `job.retry.total{job_name,outcome}`·`job.dlq.total{job_name}` recorder seam 제공 — metrics.yaml 정합. `executor.rejected.total`·`executor.saturation` 은 live-invoke(saturation 경로) | recorder seam `actually-implemented` / job.* live-invoke 는 소비자 대기 | | **소비자 활성화** | recorder seam(`recordRetryOutcome`/`recordDeadLetter`)는 제공만 — 활성화 owner = outbox publisher 발행 소진 경로. **contract test 가드(outbox branch)**: 발행 N회 소진 → `job.dlq.total{job_name}` +1 & status=DEAD_LETTER & `JOB_DEAD_LETTER` emit | seam `actually-implemented` / 소비자 invoke + 가드 `planned` (outbox branch) | | retryable 분류 | non-transient (권한/도메인 규칙/스키마 불일치) → retry 없이 즉시 DLQ (WAF-REL05-C3); retry 는 단일 레이어 원칙 (WAF-REL05-C4) — outbound adapter 의 Resilience4j retry 와 중첩 금지; non-idempotent 작업 retry 금지 (WAF-REL05-C5). **런타임 분류 로직은 carrier 와 함께 미구현** — 현재는 enum `retryable` 정적 플래그만 | 설계 확정 / 런타임 분류 `planned` (carrier 동반) | | backoff 설정 | exp+jitter 는 라이브러리 default 아님 — Spring Retry 라면 `multiplier > 1.0` + `random=true` 명시 (SPRING-RETRY-C2); jitter 종류는 Full Jitter 사례 우세 (AWS-JITTER-C1~C4 — company-case-study, 공식 단정 금지) | `planned` (carrier 동반) | ### 4. Scheduler / multi-instance lock (D3) > **Trace**: In-scope "scheduled job overlap 기준" → D3 (SK-PG-C2, OUTBOX-MIO-C4) + ca-tmpl 코드 ground truth. > > - **UNSUPPORTED_IMPL_DECISION**: 없음 — 본 sub-section 은 전부 registry/코드 실측 값. | 항목 | 명세 | 상태 | |---|---|---| | @EnableScheduling | `app-bootstrap` `IdempotencyConfig` 에 실재 | `actually-implemented` | | @Scheduled 선례 | `adapter-persistence` `IdempotencyReaper` (`fixedDelayString = "${ca-skeleton.idempotency.reaper-interval:PT10M}"`) | `actually-implemented` | | multi-instance 강제 | `APP_MULTI_INSTANCE_ENABLED=true` 시 `StartupSafetyValidator.REQUIRED_MULTI_INSTANCE_BEANS` 의 `"distributedLockProvider"` bean 부재 → startup fail (exit 72) — 키 owner 는 [[raw/branch-notes/feature-env-driven-runtime-configuration]] D8. lock bean 제공 owner = [[raw/branch-notes/feature-distributed-lock-contract]] D1/D3 (A7 ✅ RESOLVED 2026-06-12) | validator `actually-implemented` / 본 branch consume | | lock provider (delegated) | bean 이름 `distributedLockProvider` 존재만 전제(consume) — 제공 owner = [[raw/branch-notes/feature-distributed-lock-contract]] D1/D3 (기본 `JdbcLockRegistry`, ShedLock *배제*; port `DistributedLockPort`). 검증: `StartupSafetyValidator` bean-presence(exit 72) | owner branch `planned` / 본 branch consume 계약 확정 | ### 5. Graceful shutdown 예산 계층 (D8) > **Trace**: In-scope "shutdown 중 job 처리 기준" → D8 (K8S-POD-LC-C1~C3, SB-GS-C2/C4/C5, EXEC-CS-C1~C4). > > - **UNSUPPORTED_IMPL_DECISION**: 1s cleanup margin 값 — 외부 권고 없음, 사용자 trade-off (executor 종료 후 잔여 리소스 정리 시간 확보). ```text executor awaitTermination (≤19s) < app shutdown budget (20s — parent project 운영 계약 소유) ≤ spring.lifecycle.timeout-per-shutdown-phase (SB-GS-C4; APP_SERVER_SHUTDOWN_TIMEOUT — owner: feature-env-driven-runtime-configuration D2, registry default 30s ⚠ §Audit A1) < terminationGracePeriodSeconds (k8s default 30s — K8S-POD-LC-C1; 초과 시 SIGKILL — K8S-POD-LC-C2) ``` - 신규 요청 차단은 SmartLifecycle earliest phase 의 web server graceful stop 이 선행 (SB-GS-C2/C5) — executor await 는 그 이후 phase. - in-flight job 이 19s 초과 → interrupt → **retry-on-next-startup** (멱등 전제, §Claims To Verify). - **구현/검증 (2026-06-13)**: `AsyncExecutorConfig` 가 `setWaitForTasksToCompleteOnShutdown(true)` + `setAwaitTerminationSeconds(19)` 설정. 검증 2종 — (1) 정확값 핀 `AsyncExecutorConfigTest`(reflection: `awaitTerminationMillis`=19000, `waitForTasksToCompleteOnShutdown`=true — 19s vs 25s 같은 *수치* 계약 보증), (2) **행위 검증 `AsyncGracefulShutdownBehaviorTest`**: in-flight job 이 context close 중 예산 내 drain 완료 + shutdown 후 신규 submit → `RejectedExecutionException` & `JOB_EXECUTOR_REJECTED` 구조화 로그. 자체 관리 `AnnotationConfigApplicationContext` 사용(=동일 `SmartLifecycle`/`DisposableBean` shutdown 경로) — `@SpringBootTest` 는 ① 테스트 중 context close 시 post-test listener 실패 ② application.yml `${SPRING_PROFILES_ACTIVE}` 등 dotenv 의존(bootRun 전용) 때문에 부적합. `locally-verified`. ## Audit & Findings (2026-06-11 — /branch-spec ground-truth 대조) > ca-tmpl registry/코드와 본 노트의 정합 감사 결과. 사용자 작성 결정은 수정하지 않고 권고만 기록. - **A1. `SHUTDOWN_BUDGET_DRIFT`** — 본 노트 D8 은 "container-runtime 의 app shutdown **20s**" 를 전제하나, ca-tmpl `docs/registries/env-keys.yaml` 의 `APP_SERVER_SHUTDOWN_TIMEOUT` default 는 **30s** (owner: [[raw/branch-notes/feature-env-driven-runtime-configuration]] D2, validation: `spring_duration_shorthand_le_termination_grace`). 19s await 는 어느 쪽 기준으로도 안전하지만, "20s" 의 출처(parent project 운영 계약)와 registry default 30s 의 관계를 owner branch 와 명문화 권고. 자동 수정하지 않음 (사용자 결정 영역). - **A2. `OUT_OF_BRANCH_SCOPE` 권고 (D9~D12)** — outbox publisher 메커니즘 선택·대안 비교(D9~D12)는 registry 상 outbox 계약 owner 인 [[raw/branch-notes/feature-domain-event-outbox-contract]] 의 결정 영역 (outbox.* metrics 3종 + `outboxLeaderElection` bean 모두 그 branch 소유). 본 branch 의 소유는 retry/DLQ **vocabulary** (D2) 까지. D9~D12 와 §외부 근거/대안 조사의 outbox 부분은 사용자 작성분이므로 보존하되, owner branch 로의 이관을 권고. outbox 노트가 이미 본 branch D4 를 cross-reference 중 (양방향 확인됨). - **A3. `REGISTRY_CONFIRMED`** — 본 노트의 계약 값 전수 registry 대조 통과 (invent 없음): `JOB_EXECUTOR_REJECTED`/`JOB_TIMEOUT`/`JOB_DEAD_LETTER` (error-codes.yaml, owner = 본 branch), `APP_ASYNC_EXECUTOR_CORE_SIZE/MAX_SIZE/QUEUE_CAPACITY` default 10/50/200 (env-keys.yaml — 노트 D7 수치와 일치), `executor.saturation`/`executor.rejected.total`/`job.retry.total`/`job.dlq.total` (metrics.yaml), mdc-keys.yaml foundation 6키 (D6 의 4+2 분류와 일치 — span_id `source: observation_context`, user_principal `propagation: [none]`). - **A4. `RUNBOOK_MISSING`** — error-codes.yaml 이 참조하는 `runbook://job/executor-rejected`·`runbook://job/timeout`·`runbook://job/dead-letter` 의 실제 파일이 `docs/runbooks/` 에 부재 — `documented-only`. 구현 단계에서 작성 필요. - **A5. `CLAIM_PREFIX_FIX`** — D5 행에 일시 기재됐던 `SPRING-OBS-C*` 표기를 실제 raw 파일 prefix `SF-OBS-C1~C4` 로 정정 (2026-06-11 자동조사 중 발생한 표기 불일치). - **A6. `IMPLEMENTATION_STATUS`** — src grep 실측: TaskDecorator / ThreadPoolTaskExecutor bean / `awaitTermination` / ShedLock wiring / outbox 클래스 전부 **미구현** (`planned`). 실구현은 `@EnableScheduling` + `IdempotencyReaper` + `StartupSafetyValidator` 뿐. 본 노트의 계약은 전체적으로 documented-only 단계 — `actually-implemented` 로 표현 금지. - **A7. `LOCK_BEAN_OWNER_UNRESOLVED`** (coverage-auditor 2026-06-11) — `distributedLockProvider` bean 의 제공 결정이 어느 branch 에도 없음. ca-tmpl `StartupSafetyValidator` 주석은 [[raw/branch-notes/feature-runtime-health-lifecycle-contract]] 를 가리키나 그 노트는 "consume only" 로 자기 서술. owner 를 확정해 해당 branch 결정으로 등록하기 전까지 본 branch 는 *bean 존재를 전제로 consume* 만 한다 (multi-instance contract test 는 bean 부재 시 fail 로 이 미확정을 노출). - **✅ RESOLVED (2026-06-12)** — owner 확정: [[raw/branch-notes/feature-distributed-lock-contract]] D1 이 `distributedLockProvider` bean 계약을 소유 (기본 provider = Spring Integration `JdbcLockRegistry`, 그 branch D3). 본 branch 는 consume 관계 유지. ✅ 본 §테스트 계약·§구현 가이드 4 의 FQCN 표기를 `distributedLockProvider` bean(JdbcLockRegistry 기반, port `DistributedLockPort`) 기준으로 **갱신 완료 (2026-06-13)** — ShedLock `net.javacrumbs.shedlock.core.LockProvider` 타입 표기 폐기. - **A8. `STALE_OWNER_FIXED`** (coverage-auditor 2026-06-11) — §엣지·의존 의 MDC 어휘 위임 대상을 `feature-log-management-contract`(consumer 오기) → `feature-operational-error-observability-foundation`(mdc-keys.yaml L4 SSOT 자기 선언) 으로 정정. ## 엣지·실패·의존 - **실패·엣지 경로**: - shutdown 중 신규 job enqueue → REJECTED 상태 + `JOB_EXECUTOR_REJECTED` (§테스트 계약과 동일 기대 동작). - in-flight job 19s 초과 → interrupt → retry-on-next-startup (멱등 전제 — 미검증, §Claims To Verify). - interrupt 에 반응하지 않는 blocking call (JDBC 등) → awaitTermination 초과 → SIGKILL 노출 경로 (K8S-POD-LC-C2). - queue drain: `waitForTasksToCompleteOnShutdown(true)` 는 queue 잔여 task 까지 전부 실행 (EXEC-CS-C2/C4) — queue=200 × 평균 job 시간이 19s 를 초과하는 burst 시나리오의 기대 동작 미정의 (§Claims To Verify). - saturation: queue full + max pool 도달 → `RejectedExecutionException` — fire-and-forget `@Async` 호출이면 예외 소실 위험 → async exception 계약으로 흡수 필수. - submit() 경로 예외는 FutureTask 에 래핑되어 uncaught handler 미통과 (SF-TPTE-C5) — async exception test 는 execute()/submit() 두 경로 모두 검증. - non-transient 예외 → retry 없이 즉시 DLQ (WAF-REL05-C3) — retryable 분류기 누락 시 무한 재시도가 아니라 분류 실패로 fail 해야 함. - **다른 계약 의존**: - [[raw/branch-notes/feature-env-driven-runtime-configuration]] 의 D2(`APP_SERVER_SHUTDOWN_TIMEOUT`)·D8(`APP_MULTI_INSTANCE_ENABLED`) 에 의존 — 본 branch 는 consume. shutdown timeout default 변경 시 19s 예산 재검토, multi-instance 키 변경 시 lock 강제 테스트 영향 (⚠ A1 drift). - [[raw/branch-notes/feature-domain-event-outbox-contract]] — 본 branch 의 retry/DLQ vocabulary (D2, D4) 를 consume. vocabulary 변경 시 비차단 전파 알림 필요 (consistency-contract). - [[raw/branch-notes/feature-operational-error-observability-foundation]] — MDC key 어휘(mdc-keys.yaml L4 SSOT 자기 선언) + error Category enum owner (`TRANSIENT_DEPENDENCY`/`INTERNAL` 은 그 branch 계약의 재사용). foundation 키 변경 시 D5/D6 의 copy 대상 재산정. ([[raw/branch-notes/feature-log-management-contract]] 는 같은 어휘의 consumer — owner 아님, coverage-auditor STALE_OWNER 정정 2026-06-11) - `distributedLockProvider` bean — owner = [[raw/branch-notes/feature-distributed-lock-contract]] D1/D3 (A7 ✅ RESOLVED, 기본 `JdbcLockRegistry`). 본 branch 는 multi-instance 시 그 bean 존재를 전제(consume); ca-tmpl `StartupSafetyValidator` 주석의 runtime-health 표기는 stale → owner branch 가 코드 주석 갱신 예정. - [[raw/project-notes/ca-skeleton-operational-contract]] — 20s app shutdown 예산의 소유자. 예산 변경 시 D8 의 19s 도출 무효. ## 테스트 계약 - async exception이 조용히 삼켜지면 실패. - executor rejection이 structured log 없이 발생하면 실패. - scheduled job overlap 기준이 없으면 실패. - shutdown 중 job 정책: in-flight job 은 await 예산(≤19s) 내 완료, 초과분은 interrupt 후 retry-on-next-startup. 측정 방법(2026-06-13 정정 — 내장 메커니즘 채택): `setWaitForTasksToCompleteOnShutdown(true)` + `setAwaitTerminationSeconds(19)` 적용 검증(현 `AsyncExecutorConfigTest` 가 `awaitTerminationMillis==19000` reflection 검증). **권고 보강**: reflection(설정값)을 *행위* 검증으로 승급 — 느린 job 제출 → context close → (a) job 이 예산 내 완료, (b) 종료 후 신규 제출은 `JOB_EXECUTOR_REJECTED` 로 거부. (이전판의 `ApplicationListener` + ListAppender 명세는 ThreadPoolTaskExecutor 내장 메커니즘 채택으로 폐기 — custom listener 안 씀.) - multi-instance lock = **delegated → [[raw/branch-notes/feature-distributed-lock-contract]]** (D1/D3, 기본 provider = Spring Integration `JdbcLockRegistry`; ShedLock 은 그 branch D3 에서 *배제*). 본 branch 는 *적용처*(D3 scheduler/outbox)로서 provider 존재를 전제로 consume 만. 측정 방법(2026-06-13 정정): `APP_MULTI_INSTANCE_ENABLED=true` 시 ca-tmpl `StartupSafetyValidator` 가 bean 이름 `distributedLockProvider` 존재를 강제(부재 시 exit 72) — 기존 `StartupSafetyValidatorTest` 가 검증. (이전판의 `net.javacrumbs.shedlock.core.LockProvider` 타입 기준은 ShedLock 가정 시절 표기 — owner D3 가 JdbcLockRegistry 로 확정해 폐기.) ## Async Context Propagation Contract > 2026-06-13 구현 정합 갱신 — §구현 기록·D6 와 일치하도록 정정. - `TaskDecorator` 1개를 ThreadPoolTaskExecutor 에 등록해 caller→worker thread 로 복사한다: - MDC: submit 시점 **전체 스냅숏 복사**(`MDC.getCopyOfContextMap()`) — foundation 4키(`request_id`/`trace_id`/`correlation_id`/`tenant_id`) 보장, span_id 는 그 시점 MDC 에 있으면 문자열로 동승. worker 종료 시 대칭 복원(풀 스레드 MDC bleed 방지). - Observation **scope** 는 전파하지 않음(context-propagation 라이브러리 미반입). parent-span linkage 필요 시 `ContextPropagatingTaskDecorator` 로 upgrade. - SecurityContext / principal: **기본 비전파**. 필요 use case 만 `DelegatingSecurityContextTaskExecutor` 로 explicit opt-in. - executor 등록 시 TaskDecorator 미설정이면 fail — 현재는 decorator 를 executor @Bean 의 필수 생성자 의존성으로 강제(이 bean 한정). 전역 강제는 §구현 가이드 2 의 ArchUnit 가드 권고 참조. - 테스트 계약: - @Async 메서드 안에서 `MDC.get("request_id")`/`trace_id`/`correlation_id`/`tenant_id` 가 caller 와 동일. - **principal 은 worker 로 전파되지 *않는다*** (negative 검증) — opt-in executor 사용 시에만 전파. ## 검증해야 할 주장 | Claim | Why uncertain | How to verify | Status | |---|---|---|---| | TaskDecorator 1개로 MDC 4-key (request_id/trace_id/correlation_id/tenant_id) + Observation context 가 caller→worker 정확히 전파됨 | 공식 메커니즘 근거는 확보 (SF-OBS-C1/C2, MICRO-CP-C1) — 그러나 공식 doc 은 메커니즘만 보장, ca-tmpl 구성에서의 실 전파는 미검증 | `@Async` 메서드에서 `MDC.get("request_id")`, Micrometer `Observation.getCurrent()` 가 caller thread 와 동일한지 contract test | `needs-confirmation` | | SLF4J-Micrometer tracing bridge 활성 시 span_id 가 worker thread MDC 에 자동 기입됨 (D6 전제) | bridge 공식 doc 인용 미확보 — SF-OBS-C4 는 이 페이지 범위 밖이라 명시 | bridge 활성 상태에서 `@Async` 내 `MDC.get("span_id")` non-null contract test + bridge 공식 doc 추가 아카이브 | `needs-confirmation` | | Executor pool default (core=10, max=50, queue=200) + AbortPolicy 가 ca-tmpl 부하 프로파일에 적합 | 정량 trade-off 의 외부 reference 부재 (D7 — 구조만 공식 확보) | 부하테스트 (k6 / JMeter) 로 saturation 임계 측정 + rejection log 확인 | `planned` | | Graceful shutdown 19s 내 executor await termination 이 실제 in-flight job 완료 보장 | k8s/Spring 공식 메커니즘 근거 확보 (K8S-POD-LC-C1/C2, EXEC-CS-C2/C3) — 잔여: job p99 실행 시간 < 19s 미측정 + queue drain 시간(queue=200 × 평균 job 시간) 미계산 | `ApplicationListener` 등록 + ListAppender 로 shutdown phase reject log 검증 + job p99 측정 | `planned` | | Multi-instance 환경에서 ShedLock 또는 DB advisory lock 이 publisher claim consistency 보장 | SKIP LOCKED 는 lock contention 회피만 보장 (`SK-PG-C2`), 순서/claim consistency 별도 | `@TestPropertySource("app.multi-instance.enabled=true")` 테스트에서 `LockProvider` bean 존재 verify | `needs-confirmation` | | Exponential backoff + jitter + max=3 + DLQ 가 ca-tmpl 도메인 retry 성공률에 적합 | max=3 은 Spring Retry default 와 일치 (SPRING-RETRY-C1) 하나 ca-tmpl 도메인 적합성은 미측정 (WAF-REL05-C2 의 use-case 별 조정 권고) | DLQ 진입률 metric (`job.dlq.total`) 측정 + max attempts 조정 실험 | `planned` | | Debezium CDC 가 본 프로젝트 lag SLO 충족 (수 초 lag 허용 가정 깨질 때) | `OUTBOX-DBZ-C2` 는 "polling 비용 회피" 까지만 보장, lag 수치는 침묵 | Debezium PoC + WAL lag metric 측정 (활성화 시) | `needs-confirmation` | | SKIP LOCKED polling 의 순서 보장 안 됨이 ca-tmpl 도메인에 허용 가능 | `SK-PG-C2` 의 "inconsistent view" 경고 | partition key 별 단일 publisher 시 순서 보장되는지 contract test + 도메인 검토 | `needs-confirmation` | ## 관심사 커버리지 (coverage-auditor 자동 생성 — 2026-06-11) > `/coverage` 생성물 — 손으로 유지하지 않는다. 기준: `rules/coverage-gate.md`. 판정: **Covered** (Blocking 0 / Should-fix 2 — A7·A8 로 처리 / Advisory 1 — A4 runbook). | 관심사 | 상태 | owner | 심각도 | 근거 | |--------|------|-------|--------|------| | async exception handling (삼켜진 예외 금지, structured log + metric + runbook link) | covered-here | — | — | Decisionized Work Items "async exception" 행; D5; 테스트 계약 1항 | | executor saturation/rejection (bounded queue 강제, AbortPolicy default, rejection log) | covered-here | — | — | D7; §구현 가이드 1; `executor.rejected.total` (metrics.yaml, owner = 본 branch) | | scheduled job overlap (single-instance 기본, multi-instance 시 distributed lock) | covered-here | — | — | D3; §구현 가이드 4 | | job id / correlationId (MDC 4-key + Observation context) | covered-here | — | — | D5, D6; §구현 가이드 2; mdc-keys.yaml `propagation: [async]` 대조 | | retry / backoff (exp+jitter, max=3, DLQ after exhausted) | covered-here | — | — | D4; §구현 가이드 3; `JOB_TIMEOUT`/`JOB_DEAD_LETTER` (error-codes.yaml) | | shutdown 중 job 처리 (await ≤ 19s, retry-on-next-startup) | covered-here | — | — | D8; §구현 가이드 5 | | background failure logging (log/metric/alert 핵심 계약) | covered-here | — | — | Decisionized Work Items "async exception" 행; §진행 중 메모; JOB_* 3코드의 runbook_link | | `APP_ASYNC_EXECUTOR_*` env 키 3종 / JOB_* error 코드 3종 / executor.*·job.* 메트릭 4종 (registry 본 branch 소유분) | covered-here | — | — | §Audit A3 (registry 전수 대조) | | `APP_MULTI_INSTANCE_ENABLED`·`APP_SERVER_SHUTDOWN_TIMEOUT` env 키 정의 | delegated | [[raw/branch-notes/feature-env-driven-runtime-configuration]] (D8, D2) | OK | §엣지·의존 위임 링크 | | outbox publisher 메커니즘 (D9~D12) | delegated | [[raw/branch-notes/feature-domain-event-outbox-contract]] | OK | §Audit A2 이관 권고 + 양방향 cross-ref 확인 | | `distributedLockProvider` bean 제공 결정 | delegated | [[raw/branch-notes/feature-distributed-lock-contract]] (D1) | OK | §Audit A7 ✅ RESOLVED 2026-06-12 — [[raw/branch-notes/feature-distributed-lock-contract]] D1/D3 | | MDC key 어휘 (mdc-keys.yaml) | delegated | [[raw/branch-notes/feature-operational-error-observability-foundation]] | OK (A8 정정 완료) | mdc-keys.yaml L4 SSOT 자기 선언 | ## 마주친 문제 - 2026-06-13: `:app-bootstrap:test` 전체 실행 시 선재(pre-existing) ArchUnit 실패 1건 발견 — `CleanArchitectureTest#outbound_adapter_method_returns_only_domain_or_primitives` (`OutboundHttpSettings.retry()`/`circuitBreaker()` 중첩 record accessor 가 B7 규칙 위반). `git stash -u` baseline 에서도 동일 실패 → 본 background-job 작업과 무관, owner 는 feature-outbound-http-client-baseline. 상세·권고 해결 → [[raw/errors/archunit-b7-configprops-nested-record-accessor-outbound-2026-06-13]]. ## 묶음 - [[raw/company-tech-blogs/outbox-confluent-kafka-connect-smt]] - [[raw/company-tech-blogs/outbox-netflix-domain-events-cdc]] - [[raw/company-tech-blogs/outbox-wix-engineering-debezium]] - [[raw/company-tech-blogs/outbox-woowahan-techblog-pattern]] - [[raw/company-tech-blogs/retry-aws-exponential-backoff-and-jitter]] - [[raw/official-docs/dual-write-antipattern-microservices-io]] - [[raw/official-docs/event-sourcing-vs-outbox-microservices-io]] - [[raw/official-docs/jdk21-threadpoolexecutor-javadoc]] - [[raw/official-docs/kubernetes-pod-lifecycle-termination]] - [[raw/official-docs/lock-shedlock-readme]] - [[raw/official-docs/micrometer-context-propagation-purpose-thread-local-accessor]] - [[raw/official-docs/outbound-resilience4j-vs-spring-retry]] - [[raw/official-docs/outbox-debezium-official-docs]] - [[raw/official-docs/outbox-skip-locked-microservices-io]] - [[raw/official-docs/retry-aws-well-architected-rel05-bp03]] - [[raw/official-docs/retry-spring-retry-readme-backoff-defaults]] - [[raw/official-docs/skip-locked-postgres-docs]] - [[raw/official-docs/spring-boot-graceful-shutdown-reference]] - [[raw/official-docs/spring-boot-task-execution-scheduling-reference]] - [[raw/official-docs/spring-executor-configuration-support-javadoc]] - [[raw/official-docs/spring-framework-observability-context-propagating-task-decorator]] - [[raw/official-docs/spring-framework-threadpooltaskexecutor-javadoc]] - [[raw/official-docs/spring-security-concurrency-delegating-security-context-executor]] - [[raw/official-docs/spring-transactional-event-listener]] - [[raw/interviews/async-executor-saturation-context-propagation-2026-06-13]] - [[raw/errors/archunit-b7-configprops-nested-record-accessor-outbound-2026-06-13]] - [[raw/blog-topics/spring-async-taskdecorator-bounded-executor-saturation-shutdown-2026-06-13]] > 2026-06-13 실 구현 단계에서 파생 자료 누적. 아래 errors/interview/blog-topics 는 본 branch 로 upward link 되어 있다. ### 오류 기록 (본 feature 작업 중 발생) - [[raw/errors/archunit-b7-configprops-nested-record-accessor-outbound-2026-06-13]] — 전체 테스트 실행 중 발견한 선재 B7 위반(adapter-outbound 소유, 본 작업 무관). ### 면접 준비 (이 작업에서 나올 수 있는 면접 질문) - [[raw/interviews/async-executor-saturation-context-propagation-2026-06-13]] — bounded executor·saturation·MDC/도메인 컨텍스트 전파·graceful shutdown·retry metric cardinality 6문항. ### Blog topics (이 작업에서 나온 글감) - [[raw/blog-topics/spring-async-taskdecorator-bounded-executor-saturation-shutdown-2026-06-13]] — @Async TaskDecorator + bounded executor + saturation/shutdown 운영 계약. ## 관련 일일 노트 > 이 브랜치를 작업한 날짜들. 양방향 nav 유지. - (없음 — documented-only 단계. 실 구현 착수 시 `[[raw/daily-notes/YYYY-MM-DD]]` 형식으로 연결) ## 완료 후 정리 - PR 링크: (미생성 — 사용자가 직접 커밋/PR) - 리뷰 메모: 3단 리뷰 체인 통과 — ca-architect-sentinel(PASS, 신규 위반 0), ca-spec-reviewer(22/22, plan 텍스트 2건 정정), ca-quality-reviewer(important 1 + minor 1 수정: tautological MDC-clear 테스트 보강, Supplier import). - 머지 결과 / 배포 환경: (미머지 — 구현 git 브랜치 `feature/domain-event-outbox-contract`) - **wiki 추출 대상** (verified만, `wiki/projects/`로만 추출): - `actually-implemented` 항목: JOB_* error code 3종(registry 교차검증), `AsyncExecutorConfig` bounded executor + AbortPolicy saturation, `BackgroundJobMetrics` 4 메트릭 recorder, `ScheduledJobOverlapPolicyTest` overlap 규칙, runbook 3종. - `locally-verified` 항목: `AsyncContextTaskDecorator` MDC+도메인 컨텍스트 전파(submit-time 캡처·대칭 복원), D8 awaitTermination 19s, async 예외 2경로(submit/execute) 미삼킴. - `prod-verified` 항목: (없음 — 로컬 검증까지) - **추출하지 않을 항목** (planned / documented-only / abandoned): retry **carrier**(Spring Retry/Resilience4j/자체 — §3 UNSUPPORTED_IMPL, vocabulary 만 고정), executor 수치 core=10/max=50/queue=200 부하 적합성(`planned`, 부하테스트 미실시), Observation **scope** 전파(라이브러리 미반입 — MDC 문자열까지만), SecurityContext principal 자동 전파(opt-in 문서화만).