Files
llm-wiki/raw/blog-topics/spring-async-taskdecorator-bounded-executor-saturation-shutdown-2026-06-13.md
T

7.8 KiB
Raw Blame History

title, source_type, status, related_branches, related_projects, tags, created, status_label, target_audience, inspiration_url, archive_url
title source_type status related_branches related_projects tags created status_label target_audience inspiration_url archive_url
blog-topic / spring-async-taskdecorator-bounded-executor-saturation-shutdown-2026-06-13 blog-topic raw
feature-background-job-async-contract
ca-tmpl
blog-topic
ca-tmpl
async
threadpooltaskexecutor
taskdecorator
mdc
graceful-shutdown
micrometer
clean-architecture
2026-06-13 ready-for-canonical backend-engineer

blog-topic: spring-async-taskdecorator-bounded-executor-saturation-shutdown-2026-06-13

Layer: raw/blog-topics/ — 채용공고가 아닌 작업·학습·트러블슈팅에서 나온 블로그 글감 원석. canonical 정제 전 raw 후보이며, wiki/blog/ 직접 생성 근거가 아니다.

Parent / 부모

트리거 / 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 문자열 복사(로그 연속성)까지만.

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 / 근거 후보

미해결 / Unknown

  • 아직 확인해야 할 사실: 부하테스트나 executor sizing 실측 여부.
  • 과장하면 안 되는 부분: core/max/queue 숫자를 측정 기반 튜닝값처럼 쓰지 않는다.

Decision / 처리 결정

  • 액션: promote-to-canonical
  • 이유: wiki/projects/ca-tmpl/observability-log-metric-trace-runbook.mdwiki/projects/ca-tmpl/runtime-container-health-migration.md 에 async executor 운영 계약 글감으로 반영한다.
  • 다음 단계: blogify 전 MDC-only propagation과 Observation scope propagation을 분리한다.