7.8 KiB
7.8 KiB
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 |
|
|
|
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 / 부모
- 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). JDKThreadPoolExecutor는 큐가 가득 찰 때만 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< k8sterminationGracePeriodSeconds(기본 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보다 작아야 한다.
- unbounded queue에서는
- 의견/해석 후보:
- background job 안정성은 비동기 실행 자체보다 실패, 포화, 종료를 관측 가능한 계약으로 만드는 데 달려 있다.
Outline seed
- Spring Boot 기본 executor queue 설정과 maxPoolSize 함정을 설명한다.
- TaskDecorator로 MDC와 domain context를 복사하고 복원하는 흐름을 정리한다.
- SecurityContext는 기본 전파하지 않고 opt-in으로 다루는 이유를 적는다.
- 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을 분리한다.