배경
@Async 풀 큐 길이(H1) 재측정(docs/decisions/async-pool-backpressure-experiment.md) 중 발견. #582(계측 채널 문제)가 #667로 이미 해결돼 applicationTaskExecutor 지표가 정상적으로 잡히는 걸 확인한 뒤, AI를 완전히 정지시키고(docker pause shadowfit-ai) 세션 15개를 동시에 태워도 큐·활성 스레드·풀 크기가 전부 0.0에서 한 번도 안 움직였다.
현상 (근거, file:line)
loadtest/results/async-pool-2026-09-07/rep2_B_poll.log — AI 정지 상태에서 세션 15개가 IN_PROGRESS→FAILED로 전이하는 전 구간(t=0.1~8.9초) 동안:
t=0.1 cb[...CLOSED] q=0.0 active=0.0 poolsize=0.0 status[IN_PROGRESS 15;]
t=4.2 cb[...CLOSED] q=0.0 active=0.0 poolsize=0.0 status[FAILED 15;]
t=6.4 cb[...OPEN] q=0.0 active=0.0 poolsize=0.0 status[FAILED 15;]
(q=executor_queued_tasks, active=executor_active_threads, poolsize=executor_pool_size_threads, 전부 name="applicationTaskExecutor" 태그)
- 직접 로그로 확인: 세션을 하나씩 시작해
sendAnalysisRequestToFastApi(backend/src/main/java/com/shadowfit/service/exercise/ExerciseAnalysisService.java:362, @Async 무인자)가 실제로 실행되는 것을 로그로 확인했는데도(FastAPI 응답 수신 로그 출력), executor_completed_tasks_total{name="applicationTaskExecutor"}은 그 후에도 0.0 그대로였다.
- 실행 스레드 이름이 호출마다 계속 올라간다(
TaskExecutor-77부터 TaskExecutor-92까지) — applicationTaskExecutor(core=8)가 스레드를 재사용하는 패턴이 아니라, 매 호출마다 새 스레드를 만드는 폴백 실행기의 패턴이다.
유력한 원인 (코드로 100% 확정은 아니지만 정황이 강하다)
@Async(파라미터 없음, :362)는 Spring이 기본 실행기를 이렇게 찾는다:
- 이름이 정확히
taskExecutor인 Executor 빈을 찾는다
- 없으면, 타입이
Executor인 빈이 단 하나일 때만 그걸 쓴다
- 그것도 안 되면(이름도 안 맞고 후보가 여럿이면) 조용히
SimpleAsyncTaskExecutor(매 호출 새 스레드, 무제한)로 폴백한다 — 예외를 안 던지고 디버그 로그만 남긴다
이 프로젝트엔 Executor 타입 빈이 최소 둘 있다 — AsyncConfig.applicationTaskExecutor(backend/src/main/java/com/shadowfit/global/config/AsyncConfig.java:30-34)와 Boot가 자동 구성하는 taskScheduler(ThreadPoolTaskScheduler는 TaskScheduler이면서 동시에 Executor/AsyncTaskExecutor이기도 하다 — async-pool-backpressure-experiment.md §0-b가 이미 executor_pool_core_threads{name="taskScheduler"} 5.0으로 이 빈의 존재를 실측해뒀다). 어느 쪽도 이름이 taskExecutor가 아니고 후보가 둘이라 위 2번도 실패 → 3번(무제한 폴백)으로 떨어지는 조건이 정확히 성립한다.
영향
applicationTaskExecutor에 설정된 core=8은 죽은 설정이었을 수 있다 — @Async 호출이 애초에 그 풀을 안 거친다면 그 제한 자체가 의미 없다.
- 원래 가설(H1: 큐가 무제한이라 쌓인다)보다 더 심각할 수 있는 결함이다 — 큐가 쌓이는 게 아니라 AI가 느려질 때마다 스레드가 무제한으로 계속 새로 생성되는 쪽이라면, 메모리·컨텍스트 스위칭 비용이 훨씬 나쁜 방향으로 샌다.
ExerciseAnalysisService.java:334의 기존 주석("@async가 조용히 무시되고 동기 실행되는 문제, 2026-07-24")과는 다른 결함이다 — 이번엔 비동기 실행 자체는 되는데(프록시는 정상), 의도한 실행기가 아닌 다른 실행기로 조용히 떨어지는 문제다.
재현
# shadowfit-backend가 뜬 상태에서, @Async 를 한 번도 안 부른 초기 상태 확인
curl -s http://localhost:9090/actuator/prometheus | grep applicationTaskExecutor
# → executor_active_threads 등 전부 0.0 (정상, 아직 안 씀)
# 세션 하나 시작해서 @Async 를 실제로 태우고 다시 확인
# (POST /exercises/sessions 로 세션 시작 → sendAnalysisRequestToFastApi 실행됨)
curl -s http://localhost:9090/actuator/prometheus | grep applicationTaskExecutor
# → executor_completed_tasks_total 등이 여전히 0.0 이면 이 결함 재현
제안
ExerciseAnalysisService.java:362의 @Async에 명시적으로 실행기 이름을 지정한다: @Async("applicationTaskExecutor"). 이러면 Spring의 이름/타입 애매성 분기를 아예 안 타고 항상 의도한 빈을 쓴다. 같은 클래스의 다른 @Async 지점(PoseDataCleanupService)도 같은 문제를 겪을 수 있으니 같이 확인이 필요하다.
참고
배경
@Async풀 큐 길이(H1) 재측정(docs/decisions/async-pool-backpressure-experiment.md) 중 발견. #582(계측 채널 문제)가 #667로 이미 해결돼applicationTaskExecutor지표가 정상적으로 잡히는 걸 확인한 뒤, AI를 완전히 정지시키고(docker pause shadowfit-ai) 세션 15개를 동시에 태워도 큐·활성 스레드·풀 크기가 전부 0.0에서 한 번도 안 움직였다.현상 (근거, file:line)
loadtest/results/async-pool-2026-09-07/rep2_B_poll.log— AI 정지 상태에서 세션 15개가 IN_PROGRESS→FAILED로 전이하는 전 구간(t=0.1~8.9초) 동안:q=executor_queued_tasks,active=executor_active_threads,poolsize=executor_pool_size_threads, 전부name="applicationTaskExecutor"태그)sendAnalysisRequestToFastApi(backend/src/main/java/com/shadowfit/service/exercise/ExerciseAnalysisService.java:362,@Async무인자)가 실제로 실행되는 것을 로그로 확인했는데도(FastAPI 응답 수신로그 출력),executor_completed_tasks_total{name="applicationTaskExecutor"}은 그 후에도 0.0 그대로였다.TaskExecutor-77부터TaskExecutor-92까지) —applicationTaskExecutor(core=8)가 스레드를 재사용하는 패턴이 아니라, 매 호출마다 새 스레드를 만드는 폴백 실행기의 패턴이다.유력한 원인 (코드로 100% 확정은 아니지만 정황이 강하다)
@Async(파라미터 없음,:362)는 Spring이 기본 실행기를 이렇게 찾는다:taskExecutor인Executor빈을 찾는다Executor인 빈이 단 하나일 때만 그걸 쓴다SimpleAsyncTaskExecutor(매 호출 새 스레드, 무제한)로 폴백한다 — 예외를 안 던지고 디버그 로그만 남긴다이 프로젝트엔
Executor타입 빈이 최소 둘 있다 —AsyncConfig.applicationTaskExecutor(backend/src/main/java/com/shadowfit/global/config/AsyncConfig.java:30-34)와 Boot가 자동 구성하는taskScheduler(ThreadPoolTaskScheduler는TaskScheduler이면서 동시에Executor/AsyncTaskExecutor이기도 하다 —async-pool-backpressure-experiment.md§0-b가 이미executor_pool_core_threads{name="taskScheduler"} 5.0으로 이 빈의 존재를 실측해뒀다). 어느 쪽도 이름이taskExecutor가 아니고 후보가 둘이라 위 2번도 실패 → 3번(무제한 폴백)으로 떨어지는 조건이 정확히 성립한다.영향
applicationTaskExecutor에 설정된core=8은 죽은 설정이었을 수 있다 —@Async호출이 애초에 그 풀을 안 거친다면 그 제한 자체가 의미 없다.ExerciseAnalysisService.java:334의 기존 주석("@async가 조용히 무시되고 동기 실행되는 문제, 2026-07-24")과는 다른 결함이다 — 이번엔 비동기 실행 자체는 되는데(프록시는 정상), 의도한 실행기가 아닌 다른 실행기로 조용히 떨어지는 문제다.재현
제안
ExerciseAnalysisService.java:362의@Async에 명시적으로 실행기 이름을 지정한다:@Async("applicationTaskExecutor"). 이러면 Spring의 이름/타입 애매성 분기를 아예 안 타고 항상 의도한 빈을 쓴다. 같은 클래스의 다른@Async지점(PoseDataCleanupService)도 같은 문제를 겪을 수 있으니 같이 확인이 필요하다.참고
docs/decisions/async-pool-backpressure-experiment.md— H1 재측정 설계·이번 발견의 전체 맥락