5.0 KiB
5.0 KiB
title, source_type, status, related_branches, related_projects, tags, created, status_label
| title | source_type | status | related_branches | related_projects | tags | created | status_label | ||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| interview / async-executor-saturation-context-propagation-2026-06-13 | interview-prep | raw |
|
|
|
2026-06-13 | 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). JDKThreadPoolExecutor는 큐가 가득 찰 때만 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< k8sterminationGracePeriodSeconds(기본 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.