62 KiB
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 |
|
|
|
2026-05-22 | in-progress | BR-CA-SKELETON-OPERATIONAL-CONTRACT-025 | project-work-item | ca-skeleton-operational-contract | WI-CA-SKELETON-OPERATIONAL-CONTRACT-025 |
|
1 | 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-propagationclasspath 필수 (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-contractOperationalError에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-bootstrapasync/AsyncExecutorConfig가 boundedThreadPoolTaskExecutor(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):AsyncContextTaskDecorator1개 — 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-inDelegatingSecurityContextTaskExecutor, 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/<scenario>→docs/runbooks/job-<scenario>.md해소(BackgroundJobErrorCodeContractTest 가 파일 존재 검증). - wiring:
application.ymlca-skeleton.async.executor.*+src/.envAPP_ASYNC_EXECUTOR_*3종(verifyEnvKeys green). - 검증 명령:
:shared-contract:test72/72 green;:app-bootstrap:test256/257 green(유일 실패는 선재CleanArchitectureTest#outbound_adapter_method_returns_only_domain_or_primitives— adapter-outboundOutboundHttpSettings, 본 작업 무관,git stashbaseline 로 확인 → raw/errors/archunit-b7-configprops-nested-record-accessor-outbound-2026-06-13);verifyEnvKeysgreen;verifyCleanArchitectureDependenciesgreen. 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은
TaskDecorator1개를 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 필요 시
ContextPropagatingTaskDecoratorupgrade. (Decision Evidence Map D6 갱신 반영.) - 2026-06-13 (긴장 해소): "Async Context Propagation Contract" 의 "principal 이 caller 와 동일" 라인은 D6(
user_principal=propagation:[none]) + 보안(풀 스레드 stale principal 위험)과 충돌 → principal 은 기본 비전파로 확정. SecurityContext 필요 use case 만DelegatingSecurityContextTaskExecutoropt-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-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 | 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-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 검증으로 "등록된 모든TaskExecutorbean 은 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-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.totalrecorder 와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.totalinvoke +JOB_DEAD_LETTERemit" 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-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):
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/DisposableBeanshutdown 경로) —@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-tmpldocs/registries/env-keys.yaml의APP_SERVER_SHUTDOWN_TIMEOUTdefault 는 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종 +D12 와 §외부 근거/대안 조사의 outbox 부분은 사용자 작성분이므로 보존하되, owner branch 로의 이관을 권고. outbox 노트가 이미 본 branch D4 를 cross-reference 중 (양방향 확인됨).outboxLeaderElectionbean 모두 그 branch 소유). 본 branch 의 소유는 retry/DLQ vocabulary (D2) 까지. D9 - 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_CAPACITYdefault 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_idsource: observation_context, user_principalpropagation: [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 파일 prefixSF-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) —distributedLockProviderbean 의 제공 결정이 어느 branch 에도 없음. ca-tmplStartupSafetyValidator주석은 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 이
distributedLockProviderbean 계약을 소유 (기본 provider = Spring IntegrationJdbcLockRegistry, 그 branch D3). 본 branch 는 consume 관계 유지. ✅ 본 §테스트 계약·§구현 가이드 4 의 FQCN 표기를distributedLockProviderbean(JdbcLockRegistry 기반, portDistributedLockPort) 기준으로 갱신 완료 (2026-06-13) — ShedLocknet.javacrumbs.shedlock.core.LockProvider타입 표기 폐기.
- ✅ RESOLVED (2026-06-12) — owner 확정: raw/branch-notes/feature-distributed-lock-contract D1 이
- 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 해야 함.
- shutdown 중 신규 job enqueue → REJECTED 상태 +
- 다른 계약 의존:
- 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) distributedLockProviderbean — owner = raw/branch-notes/feature-distributed-lock-contract D1/D3 (A7 ✅ RESOLVED, 기본JdbcLockRegistry). 본 branch 는 multi-instance 시 그 bean 존재를 전제(consume); ca-tmplStartupSafetyValidator주석의 runtime-health 표기는 stale → owner branch 가 코드 주석 갱신 예정.- raw/project-notes/ca-skeleton-operational-contract — 20s app shutdown 예산의 소유자. 예산 변경 시 D8 의 19s 도출 무효.
- raw/branch-notes/feature-env-driven-runtime-configuration 의 D2(
테스트 계약
- 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==19000reflection 검증). 권고 보강: 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-tmplStartupSafetyValidator가 bean 이름distributedLockProvider존재를 강제(부재 시 exit 72) — 기존StartupSafetyValidatorTest가 검증. (이전판의net.javacrumbs.shedlock.core.LockProvider타입 기준은 ShedLock 가정 시절 표기 — owner D3 가 JdbcLockRegistry 로 확정해 폐기.)
Async Context Propagation Contract
2026-06-13 구현 정합 갱신 — §구현 기록·D6 와 일치하도록 정정.
TaskDecorator1개를 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.
- MDC: submit 시점 전체 스냅숏 복사(
- 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 사용 시에만 전파.
- @Async 메서드 안에서
검증해야 할 주장
| 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<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 -ubaseline 에서도 동일 실패 → 본 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
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 교차검증),AsyncExecutorConfigbounded executor + AbortPolicy saturation,BackgroundJobMetrics4 메트릭 recorder,ScheduledJobOverlapPolicyTestoverlap 규칙, runbook 3종.locally-verified항목:AsyncContextTaskDecoratorMDC+도메인 컨텍스트 전파(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 문서화만).