Skip to content

@Async가 applicationTaskExecutor를 안 쓰고 무제한 스레드 생성 폴백으로 조용히 떨어진다 #684

Description

@Khyojae

배경

@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이 기본 실행기를 이렇게 찾는다:

  1. 이름이 정확히 taskExecutor인 Executor 빈을 찾는다
  2. 없으면, 타입이 Executor인 빈이 단 하나일 때만 그걸 쓴다
  3. 그것도 안 되면(이름도 안 맞고 후보가 여럿이면) 조용히 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)도 같은 문제를 겪을 수 있으니 같이 확인이 필요하다.

참고

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't working

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions