Files
llm-wiki/raw/branch-notes/feature-background-job-async-contract.md

63 KiB
Raw Permalink Blame History

title, source_type, status, branch, parent_branch, related_projects, governing_docs, tags, created, target_merge, status_label, id, kind, project, work_item, inherits, refines, overrides, depends_on, contract_packet, contract_packet_sha256
title source_type status branch parent_branch related_projects governing_docs tags created target_merge status_label id kind project work_item inherits refines overrides depends_on contract_packet contract_packet_sha256
branch / feature-background-job-async-contract branch-note raw feature-background-job-async-contract
ca-skeleton
raw/project-notes/ca-skeleton-operational-contract
branch
ca-skeleton
async
scheduler
background-job
2026-05-22 in-progress BR-CA-SKELETON-OPERATIONAL-CONTRACT-025 project-work-item ca-skeleton-operational-contract WI-CA-SKELETON-OPERATIONAL-CONTRACT-025
DEC-CA-SKELETON-OPERATIONAL-CONTRACT-SCHEDULER-LOCK-001@1
DEC-CA-SKELETON-OPERATIONAL-CONTRACT-EVENT-BROKER-001@1
1 928d7721870b6023078790c09e8a4319b34e3a3a37e0e3b0cfa9666b4c1e4a46

branch: feature-background-job-async-contract

Layer: raw/branch-notes/ — background job, scheduler, async executor 기준을 정의합니다.

[!note] 귀속 정정 (2026-07-28) 최초 표기는 본 branch 를 multi-instance 활성화 검증(H8)의 이전 원천으로 적었으나 오류였다. 실제 owner 는 raw/branch-notes/feature-env-driven-runtime-configuration D8 이다 (설계 docs/superpowers/specs/2026-07-28-ca-skeleton-production-capability-feature-decomposition-design.md §5.1 정정 참조). 본 branch D3 은 그 계약의 소비자(distributedLockProvider bean 필요)로 남으며 이전 대상이 아니다. 다만 판정 메커니즘이 bean 이름 presence 에서 typed descriptor 로 바뀌면 D3 의 표현을 그에 맞춰 갱신해야 한다 — 새 owner: raw/branch-notes/feature-capability-provider-selection-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 참조.

추가 외부 근거 (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 OperationalErrorJOB_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 로 고정. BackgroundJobMetricsjob.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_BEANSdistributedLockProvider 를 consume(재구현 아님) — StartupSafetyValidatorTest 가 이미 검증(exit 72).
  • §Audit A4 runbooks (actually-implemented): docs/runbooks/job-executor-rejected.md·job-timeout.md·job-dead-letter.md 작성 — runbook://job/<scenario>docs/runbooks/job-<scenario>.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 불요; truedistributedLockProvider 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-C1C6, SB-TASK-C1C3, SF-TPTE-C1C3) + D8 (EXEC-CS-C1C4).

  • 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 AbortPolicyRejectedExecutionException 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-C1C4, MICRO-CP-C1C5, 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_principalpropagation: [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-C1C5, SPRING-RETRY-C1/C2, AWS-JITTER-C1C5 사례).

  • 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 BackgroundJobMetricsjob.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=trueStartupSafetyValidator.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-C1C3, SB-GS-C2/C4/C5, EXEC-CS-C1C4).

  • UNSUPPORTED_IMPL_DECISION: 1s cleanup margin 값 — 외부 권고 없음, 사용자 trade-off (executor 종료 후 잔여 리소스 정리 시간 확보).
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): AsyncExecutorConfigsetWaitForTasksToCompleteOnShutdown(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.yamlAPP_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 메커니즘 선택·대안 비교(D9D12)는 registry 상 outbox 계약 owner 인 raw/branch-notes/feature-domain-event-outbox-contract 의 결정 영역 (outbox.* metrics 3종 + outboxLeaderElection bean 모두 그 branch 소유). 본 branch 의 소유는 retry/DLQ vocabulary (D2) 까지. D9D12 와 §외부 근거/대안 조사의 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 해야 함.
  • 다른 계약 의존:

테스트 계약

  • 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) 적용 검증(현 AsyncExecutorConfigTestawaitTerminationMillis==19000 reflection 검증). 권고 보강: reflection(설정값)을 행위 검증으로 승급 — 느린 job 제출 → context close → (a) job 이 예산 내 완료, (b) 종료 후 신규 제출은 JOB_EXECUTOR_REJECTED 로 거부. (이전판의 ApplicationListener<ContextClosedEvent> + 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 활성 상태에서 @AsyncMDC.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<ContextClosedEvent> 등록 + 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.

묶음

2026-06-13 실 구현 단계에서 파생 자료 누적. 아래 errors/interview/blog-topics 는 본 branch 로 upward link 되어 있다.

오류 기록 (본 feature 작업 중 발생)

면접 준비 (이 작업에서 나올 수 있는 면접 질문)

Blog topics (이 작업에서 나온 글감)

관련 일일 노트

이 브랜치를 작업한 날짜들. 양방향 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 문서화만).