Files
llm-wiki/raw/interviews/async-executor-saturation-context-propagation-2026-06-13.md

54 lines
5.0 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
---
title: interview / async-executor-saturation-context-propagation-2026-06-13
source_type: interview-prep
status: raw
related_branches: [feature-background-job-async-contract]
related_projects: [ca-skeleton]
tags: [interview, ca-skeleton, async, threadpool, taskdecorator, mdc, graceful-shutdown, micrometer]
created: 2026-06-13
status_label: captured
---
# interview: async-executor-saturation-context-propagation-2026-06-13
> Layer: `raw/interviews/` — 작업에서 정직하게 도출 가능한 면접 질문. 답은 실제 구현/검증 근거에 묶는다.
## Parent / 부모
- [[raw/branch-notes/feature-background-job-async-contract]] — D5/D6/D7/D8 실 구현에서 도출한 질문.
## 질문 / Questions
### Q1. Spring Boot 의 기본 `@Async` executor 를 운영에서 그대로 쓰면 무슨 문제가 있나?
- 핵심: `ThreadPoolTaskExecutor` 의 queue capacity 기본값이 `Integer.MAX_VALUE`(사실상 unbounded). JDK `ThreadPoolExecutor` 는 큐가 가득 찰 때만 core→max 로 성장하므로, unbounded 큐에서는 `maxPoolSize` 가 영원히 발동하지 않는다. 부하가 몰리면 스레드가 아니라 큐(=힙)가 무한정 쌓여 OOM/지연으로 번진다.
- 후속: 어떻게 고치나? → bounded queue 강제 + 직접 executor 빈 등록(자동 구성은 `@ConditionalOnMissingBean(Executor.class)` 로 back-off). `Integer.MAX_VALUE` 큐 용량은 "이름만 bounded" 이므로 설정 검증에서 거부.
### Q2. saturation(거부)이 발생했을 때 무엇이 "조용히 삼켜지는" 위험인가? 어떻게 막나?
- AbortPolicy 는 `RejectedExecutionException` 을 던지지만, fire-and-forget `@Async` 호출이면 호출부가 그 예외를 못 본다. 따라서 거부 핸들러를 감싸 (1) 구조화 ERROR 로그(error.code), (2) 카운터(`executor.rejected.total`) 를 먼저 남기고 예외를 재던진다. 거부율은 alert(p1)로 노출.
- 후속: CallerRunsPolicy 는 왜 기본이 아닌가? → caller 가 request 스레드면 back-pressure 가 요청 지연을 직접 침식한다. use case 차원에서 명시 선언할 때만 허용.
### Q3. `@Async` 작업에 호출 스레드의 MDC(request_id/trace_id 등)를 어떻게 넘기나? 함정은?
- `TaskDecorator` 로 submit 시점에 `MDC.getCopyOfContextMap()` 스냅숏을 떠 worker 에서 복원. 두 함정: (1) **캡처 시점** — run time 이 아니라 decorate(submit) time 에 떠야 호출 당시 컨텍스트가 잡힌다. (2) **대칭 복원** — 작업 후 worker 의 이전 MDC 로 되돌리지 않으면 풀 재사용 스레드가 한 작업의 MDC 를 다음 작업으로 흘린다(MDC bleed).
- 후속: 왜 `InheritableThreadLocal` 을 안 쓰나? → 풀 스레드는 미리 생성/재사용되므로 상속 시점이 호출과 무관해 stale. 명시적 capture/restore 가 정답.
### Q4. SecurityContext(principal)는 왜 기본 전파하지 않나?
- 풀 스레드 재사용 + `MODE_INHERITABLETHREADLOCAL` 조합은 다른 요청의 principal 이 남아있는 stale context 위험. 그래서 기본 전파 대상은 MDC 4키뿐이고(registry 상 user_principal=`propagation: [none]`), principal 이 필요한 use case 만 `DelegatingSecurityContextTaskExecutor` 로 명시적 opt-in.
### Q5. graceful shutdown 에서 in-flight 배경 작업을 어떻게 다루나? 19s 같은 숫자는 어디서 오나?
- `setWaitForTasksToCompleteOnShutdown(true)` + `setAwaitTerminationSeconds(N)`. 예산 계층: executor await(≤19s) < app shutdown(20s) ≤ `spring.lifecycle.timeout-per-shutdown-phase` < k8s `terminationGracePeriodSeconds`(기본 30s). 19 = 20s 1s 정리 마진. grace period 초과 시 SIGKILL 이라 await 가 그 안에 끝나야 한다.
- 후속: interrupt 에 반응 안 하는 blocking call(JDBC)이면? → awaitTermination 초과 → SIGKILL 노출. 그래서 in-flight 가 19s 를 넘으면 멱등 retry-on-next-startup 을 전제로 설계.
### Q6. retry 횟수(retry_attempt)를 metric 태그로 넣으면 안 되는 이유는?
- 카디널리티 폭발. retry_attempt 는 값 범위가 작아 보여도 job_name×outcome×attempt 조합이 시계열을 곱한다. registry 에서 `job.retry.total` 의 태그는 `job_name`+`outcome`(bounded 4: SUCCESS/RETRY/EXHAUSTED/DLQ)뿐이고, retry_attempt 는 **로그 필드**로만 둔다. 메트릭 레코더의 시그니처에 attempt 를 넣지 않는 이유.
## Sources / 근거
- 로컬 검증: `:app-bootstrap:test` 의 async 패키지 30 테스트 green (AsyncContextTaskDecoratorTest 의 submit-time 캡처·대칭 복원·stale clear, LoggingAbortPolicyTest 의 거부 로그+카운터+재던짐, AsyncExecutorConfigTest 의 bounded queue·19s await·decorator-missing fail).
- 외부 근거: [[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/official-docs/spring-security-concurrency-delegating-security-context-executor]].