Skip to content

fix(core): @Scheduled 7개가 스레드 1개를 공유 + 스케줄러 생존 신호 없음 - #257

Merged
i3months merged 1 commit into
devfrom
fix/scheduler-pool-and-heartbeat
Sep 27, 2026
Merged

i3months merged 1 commit into
devfrom
fix/scheduler-pool-and-heartbeat

Conversation

@i3months

Copy link
Copy Markdown
Member

#256 에서 꼬리질문 스위퍼를 붙이다 스케줄러 구조를 보고 발견했다. #256 과 파일이 겹치지 않아
독립 머지 가능하다.

문제

@Scheduled 가 7개인데 Spring Boot 기본 spring.task.scheduling.pool.size 는 1 이다.
전부 한 줄로 선다.

작업 주기 성격
ExpiredRefreshTokenSweeper 1일 정리
ProcessedMessageSweeper 1일 정리
AiRequestLogSweeper 1일 정리
OrphanedObjectSweeper 15분 S3/MinIO 를 돈다
SessionTimeoutSweeper 5분 면접 복구
StaleTranscriptionSweeper 2분 면접 복구
StaleFollowupSweeper (#256) 2분 면접 복구

뒤의 셋은 멈춘 면접을 푸는 유일한 장치다. 느린 작업 하나가 스레드를 잡으면 면접 복구가
통째로 멈춘다 — 그리고 아무도 모른다.

1) 풀 크기 → 4

${SCHEDULING_POOL_SIZE:4}. 설정 한 줄이 지워져도 컴파일·테스트가 통과하고 운영에서도
장애가 날 때까지 아무 증상이 없다.
그래서 SchedulingPoolSizeTest 가 CI 에서 대신 본다.

2) S3 호출 상한

AWS SDK v2 기본값에는 호출 전체 상한이 없다 — 소켓 타임아웃과 재시도뿐이다. 같은
S3Client 를 요청 스레드(업로드·프록시)와 스케줄러가 공유하므로, MinIO 가 느려지면 전자는
톰캣 스레드를, 후자는 스케줄러를 붙잡는다. apiCallTimeout 30초 / apiCallAttemptTimeout
10초를 건다. "느림"이 "멈춤"으로 번지지 않게 하는 것이 목적이다.

3) 지금 돌고 있기는 한가 — 알 방법이 없었다

스위퍼들은 할 일이 없으면 로그를 남기지 않는다. 그래서 "돌았는데 대상이 없었다"와
"아예 안 돌았다"를 구분할 수 없다.
운영 백엔드 로그를 통째로 뒤져도 sweeper 관련 줄이
0건이다:

$ docker logs stackup-backend | grep -icE 'sweeper|swept'
0

SchedulerHeartbeat 가 1분마다 타임스탬프만 갱신하고, 5분 넘게 멈추면
SchedulerHealthIndicator 가 DOWN 을 낸다 (/api/system/health 의 scheduler).

DB·네트워크를 건드리지 않는다 — 심장박동이 자기가 감시하는 문제(느린 I/O)로 막히면 안 된다.

BACKUP 과 같이 informational 이라 aggregate 는 끌어내리지 않는다. 스케줄러가 멈춰도
면접·로그인·조회는 정상이다. 여기서 DOWN 은 "서비스가 죽었다"가 아니라 "고장 복구가 안 되고
있다"는 뜻이고, 섞으면 업타임 감시가 헛울린다.

검증

  • 백엔드 전체 스위트 315건 통과, 0 실패 (로컬에서 Docker 를 띄워 Testcontainers 포함 전부 실행)
  • 새 테스트: SchedulerHealthIndicatorTest 5, SchedulingPoolSizeTest 1,
    SystemHealthServiceTest 에 informational 케이스 1 추가

남은 것 (이 PR 범위 밖)

풀을 4로 늘려도 작업이 겹치면 여전히 밀린다. 지금 주기(2·5·15분)에서는 문제되지 않지만,
장기적으로는 무거운 작업(S3 순회)을 별도 executor 로 빼는 편이 맞다. 지금은 관측 수단을
먼저 만들어 두고 실제로 밀리는지 본다.

꼬리질문 스위퍼를 붙이다(#256) 스케줄러 구조를 보고 발견했다.

`@Scheduled` 가 7개인데 Spring Boot 기본 `spring.task.scheduling.pool.size`
는 **1** 이다. 전부 한 줄로 선다.

  ExpiredRefreshTokenSweeper      1일
  ProcessedMessageSweeper         1일
  AiRequestLogSweeper             1일
  OrphanedObjectSweeper          15분   ← S3/MinIO 를 돈다
  SessionTimeoutSweeper           5분   ← 면접 복구
  StaleTranscriptionSweeper       2분   ← 면접 복구
  StaleFollowupSweeper (#256)     2분   ← 면접 복구

뒤의 셋은 **멈춘 면접을 푸는 유일한 장치**다. 느린 작업 하나가 스레드를 잡으면
면접 복구가 통째로 멈춘다. 가장 유력한 후보는 OrphanedObjectSweeper 다.

## 1) 풀 크기

`${SCHEDULING_POOL_SIZE:4}`. 설정 한 줄이 지워져도 컴파일·테스트가 통과하고
운영에서도 장애 때까지 아무 증상이 없어서 SchedulingPoolSizeTest 로 지킨다.

## 2) S3 호출 상한

AWS SDK v2 기본값에는 **호출 전체 상한이 없다** — 소켓 타임아웃과 재시도뿐이다.
같은 S3Client 를 요청 스레드(업로드·프록시)와 스케줄러가 공유하므로, MinIO 가
느려지면 전자는 톰캣 스레드를, 후자는 스케줄러를 붙잡는다.
apiCallTimeout 30초 / apiCallAttemptTimeout 10초를 건다.

## 3) 그래서 지금 돌고 있기는 한가 — 알 방법이 없었다

스위퍼들은 할 일이 없으면 로그를 남기지 않는다. 그래서 "돌았는데 대상이 없었다"
와 "아예 안 돌았다"를 구분할 수 없다. 운영 백엔드 로그를 통째로 뒤져도 sweeper
관련 줄이 **0건**이다.

SchedulerHeartbeat 가 1분마다 타임스탬프만 갱신하고, 5분 넘게 멈추면
SchedulerHealthIndicator 가 DOWN 을 낸다(/api/system/health 의 scheduler).
DB·네트워크를 건드리지 않는다 — 심장박동이 자기가 감시하는 문제(느린 I/O)로
막히면 안 된다.

BACKUP 과 같이 **informational** 이라 aggregate 는 끌어내리지 않는다. 스케줄러가
멈춰도 면접·로그인·조회는 정상이다. 여기서 DOWN 은 "서비스가 죽었다"가 아니라
"고장 복구가 안 되고 있다"는 뜻이고, 섞으면 업타임 감시가 헛울린다.
@i3months
i3months merged commit 8647c14 into dev Sep 27, 2026
5 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant