--- title: blog-topic / spring-async-taskdecorator-bounded-executor-saturation-shutdown-2026-06-13 source_type: blog-topic status: raw related_branches: [feature-background-job-async-contract] related_projects: [ca-tmpl] tags: [blog-topic, ca-tmpl, async, threadpooltaskexecutor, taskdecorator, mdc, graceful-shutdown, micrometer, clean-architecture] created: 2026-06-13 status_label: ready-for-canonical target_audience: backend-engineer inspiration_url: archive_url: --- # blog-topic: spring-async-taskdecorator-bounded-executor-saturation-shutdown-2026-06-13 > Layer: `raw/blog-topics/` — 채용공고가 아닌 작업·학습·트러블슈팅에서 나온 블로그 글감 원석. canonical 정제 전 raw 후보이며, `wiki/blog/` 직접 생성 근거가 아니다. ## Parent / 부모 - [[raw/branch-notes/feature-background-job-async-contract]] — D5/D6/D7/D8 (executor + context propagation + saturation + graceful shutdown) 실 구현에서 추출. ## 트리거 / Trigger - 트리거 유형: `branch-work` - "요청 스레드 밖의 실패는 GlobalExceptionHandler 가 못 잡는다" 는 문제의식으로 배경 작업(@Async/scheduler) 운영 계약을 코드로 구현하면서, Spring Boot 의 기본 executor 가 운영에 부적합한 기본값(unbounded queue)을 갖는다는 점과 컨텍스트 전파/우아한 종료의 세부가 한데 모였다. ## 글감 코어 / Core idea - **기본 executor 를 그대로 쓰면 안 되는 이유**: Spring Boot 가 자동 구성하는 `applicationTaskExecutor` 의 queue capacity 기본값은 `Integer.MAX_VALUE`(사실상 unbounded). JDK `ThreadPoolExecutor` 는 큐가 *가득 찰 때만* core→max 로 성장하므로(3단계 성장), unbounded 큐에서는 `maxPoolSize` 가 영원히 무효 — OOM 직전까지 큐만 쌓인다. 따라서 bounded queue 를 강제하고 `@ConditionalOnMissingBean(Executor.class)` 로 자동 구성을 back-off 시킨 뒤 직접 빈을 등록한다. `Integer.MAX_VALUE` 큐 용량은 "이름만 bounded 인 unbounded" 라 설정 검증에서 거부. - **TaskDecorator 1개로 컨텍스트 전파**: caller→worker 로 (1) MDC 맵 전체(`MDC.getCopyOfContextMap()` — request_id/trace_id/correlation_id/tenant_id + tracing bridge 가 채운 span_id 까지 한 번에), (2) 도메인 컨텍스트(별도 propagator seam 의 `wrap(Runnable)`)를 복사. **캡처 시점이 핵심**: `decorate()` 호출 시점(=submit time)에 스냅숏을 떠야 하며 run time 이 아니다. 그리고 **대칭 복원**: 작업 후 worker 의 이전 MDC 로 되돌려, 풀 재사용 스레드가 한 작업의 MDC 를 다음 작업으로 흘리지 않게 한다. - **SecurityContext 는 기본 전파하지 않는다**: `MODE_INHERITABLETHREADLOCAL` 은 풀 스레드 재사용 시 stale principal 위험. principal 이 필요한 use case 만 `DelegatingSecurityContextTaskExecutor` 로 명시적 opt-in. (registry 상 user_principal 은 `propagation: [none]`.) - **Saturation 을 침묵시키지 않는다**: AbortPolicy 를 감싸 거부 시 (1) 구조화 ERROR 로그(error.code=JOB_EXECUTOR_REJECTED) + (2) `executor.rejected.total{executor_name, policy}` 카운터 증가 후 (3) `RejectedExecutionException` 재던짐(AbortPolicy 시맨틱 보존). `executor.saturation` 게이지로 큐 점유율 관측. - **Graceful shutdown 예산 계층**: `setWaitForTasksToCompleteOnShutdown(true)` + `setAwaitTerminationSeconds(19)`. 19 = 컨테이너 app shutdown 예산 20s − 1s 정리 마진. 계층 부등식: executor await(≤19s) < app shutdown(20s) ≤ `timeout-per-shutdown-phase` < k8s `terminationGracePeriodSeconds`(기본 30s, 초과 시 SIGKILL). - **async 예외의 두 경로**: `submit()` 은 throwable 을 `Future` 에 가둬 `get()` 으로 표면화(삼켜지지 않음); `execute()` 는 worker 의 uncaught handler 로 간다 — 그래서 모든 작업을 감싸는 decorator 는 예외를 **재던져야** 하고 MDC 복원 `finally` 에서 삼키면 안 된다. ## 글감 / Topic seed - 한 문장 요지: 운영 가능한 Spring async executor는 bounded queue, context propagation, rejection metric, graceful shutdown budget을 하나의 계약으로 묶어야 한다. - 예상 제목 후보: - `@Async`를 운영 계약으로 만들기 - Spring `ThreadPoolTaskExecutor`에서 MDC, saturation, shutdown을 다루는 법 ## 핵심 주장 후보 / Claim candidates - 사실 후보: - unbounded queue에서는 `ThreadPoolExecutor`의 maxPoolSize가 사실상 성장 조건을 만나기 어렵다. - `TaskDecorator`는 submit 시점의 MDC/context snapshot을 worker 실행으로 넘길 수 있다. - executor await time은 application/container shutdown budget보다 작아야 한다. - 의견/해석 후보: - background job 안정성은 비동기 실행 자체보다 실패, 포화, 종료를 관측 가능한 계약으로 만드는 데 달려 있다. ## Outline seed 1. Spring Boot 기본 executor queue 설정과 maxPoolSize 함정을 설명한다. 2. TaskDecorator로 MDC와 domain context를 복사하고 복원하는 흐름을 정리한다. 3. SecurityContext는 기본 전파하지 않고 opt-in으로 다루는 이유를 적는다. 4. rejection logging/metric과 graceful shutdown budget 계층을 하나의 운영 계약으로 묶는다. ## 왜 흥미로운가 / Why it matters - "그냥 @Async 붙이면 된다" 와 운영 가능한 background 실행의 간극(기본값 함정 · 컨텍스트 전파 · saturation 가시성 · 종료 예산)을 구체 코드로 보여주는 좋은 사례. Clean Architecture 관점에서 executor 배선은 composition root(app-bootstrap) 가 소유하고, 도메인 컨텍스트 전파는 별도 seam 인터페이스로 분리한 점도 곁들일 수 있다. ## 확장 메모 / Notes - 본문 작성 시 정량 근거(부하테스트로 core=10/max=50/queue=200 검증)는 아직 `planned` 임을 명시 — 수치는 trade-off 기본값이지 측정값이 아니다. - Observation **scope** 전파(worker 에서 만든 child span 의 부모 연결)는 `io.micrometer:context-propagation` + `ContextPropagatingTaskDecorator` 가 필요한 별도 업그레이드 — 본 구현은 MDC 문자열 복사(로그 연속성)까지만. ## 관련 / Related - [[raw/branch-notes/feature-background-job-async-contract]] - [[raw/official-docs/jdk21-threadpoolexecutor-javadoc]] · [[raw/official-docs/spring-framework-threadpooltaskexecutor-javadoc]] · [[raw/official-docs/spring-executor-configuration-support-javadoc]] · [[raw/official-docs/kubernetes-pod-lifecycle-termination]] - [[raw/interviews/async-executor-saturation-context-propagation-2026-06-13]] ## Canonical 전환 후보 / Canonical extraction candidates - `wiki/projects/ca-tmpl/observability-log-metric-trace-runbook.md` 후보: - async executor MDC/context propagation, saturation metric, graceful shutdown 글감. - `wiki/projects/ca-tmpl/runtime-container-health-migration.md` 후보: - shutdown budget와 executor await hierarchy 글감. - 필요한 추가 검증: - current executor bean, TaskDecorator, rejection metric, shutdown budget test. ## Sources / 근거 후보 - [[raw/branch-notes/feature-background-job-async-contract]] - [[raw/official-docs/jdk21-threadpoolexecutor-javadoc]] - [[raw/official-docs/spring-framework-threadpooltaskexecutor-javadoc]] - [[raw/official-docs/spring-executor-configuration-support-javadoc]] - [[raw/official-docs/kubernetes-pod-lifecycle-termination]] ## 미해결 / Unknown - 아직 확인해야 할 사실: 부하테스트나 executor sizing 실측 여부. - 과장하면 안 되는 부분: core/max/queue 숫자를 측정 기반 튜닝값처럼 쓰지 않는다. ## Decision / 처리 결정 - 액션: `promote-to-canonical` - 이유: `wiki/projects/ca-tmpl/observability-log-metric-trace-runbook.md` 와 `wiki/projects/ca-tmpl/runtime-container-health-migration.md` 에 async executor 운영 계약 글감으로 반영한다. - 다음 단계: blogify 전 MDC-only propagation과 Observation scope propagation을 분리한다.