@EnableScheduling 을 붙이면 ScheduledAnnotationBeanPostProcessor 가 빈을 스캔해 @Scheduled 메서드를 찾습니다. 찾은 메서드는 TaskScheduler 에 등록됩니다.
기본 구현은 ThreadPoolTaskScheduler 이고 기본 스레드 수는 1개입니다. 이 TaskScheduler 는 결국 JDK ScheduledExecutorService 를 감싼 얇은 층입니다.
| @Scheduled 속성 | 대응하는 SES 메서드 |
|---|---|
fixedRate |
scheduleAtFixedRate |
fixedDelay |
scheduleWithFixedDelay |
cron |
schedule (실행마다 다음 시각을 계산해 재예약) |
initialDelay |
최초 호출의 delay 인자 |
zone |
cron 파싱에 쓰는 ZoneId |
이 대응 관계 하나만 기억하면, @Scheduled 에서 벌어지는 이상 동작을 대부분 SES 문서로 설명할 수 있습니다. 아래 2.2~2.7 이 그 이상 동작들입니다.
fixedRate 는 시작 시각 을 기준으로 다음 실행 시각을 정합니다. fixedDelay 는 끝난 시각 을 기준으로 다음 실행까지의 간격을 정합니다. 작업 시간이 간격보다 짧으면 둘의 차이가 안 보입니다.
작업이 간격보다 오래 걸리면 차이가 드러납니다. SES 는 같은 작업을 겹쳐 실행하지 않으므로, fixedRate 는 밀린 회차를 간격 0 으로 연달아 실행합니다. 이른바 catch-up 입니다.
[1] 작업 300ms, 간격 200ms: fixedRate vs fixedDelay (1.5초 관찰)
fixedRate 실행 5회 ← 밀린 만큼 연달아 실행(간격 0)
fixedDelay 실행 3회 ← 300+200 = 500ms 마다
경과 1614msfixedRate 가 맞습니다.fixedDelay 가 안전합니다.대부분의 사내 배치는 후자입니다. 별다른 이유가 없으면 fixedDelay 를 기본값으로 삼는 편이 사고를 줄입니다.
SES 는 scheduleAtFixedRate/scheduleWithFixedDelay 로 등록한 Runnable 이 예외를 던지면, 그 ScheduledFuture 를 완료 상태로 만들고 다시 실행하지 않습니다. 콘솔에도 로그가 남지 않습니다. f.get() 을 호출해야 감춰진 예외가 드러납니다.
[2] 3번째 실행에서 예외: 날것 vs SafeTask (1초 관찰)
날것 실행 3회, isDone=true ← 3회 후 영구 정지, 로그 없음
숨어 있던 예외: f.get() → java.lang.IllegalStateException: DB 연결 끊김
safe: 성공 9, 건너뜀 0, 실패 1 ← 실패해도 다음 회차 계속Spring ThreadPoolTaskScheduler 는 ErrorHandler 로 예외를 로그에 남기고 스케줄을 계속 돌립니다. 다만 @Scheduled 메서드 안에서 사용자 정의 처리 없이 던지면, Error(예: OutOfMemoryError) 계열은 원인 추적이 여전히 어렵습니다. SafeTask.run 처럼 try/catch(Throwable)/finally 로 감싸는 패턴이 필요합니다.
Spring 에서는 @Scheduled 메서드 본문 전체를 try-catch 로 감싸거나, 여러 배치에 공통 적용할 때는 AOP 로 같은 처리를 하면 됩니다. 핵심은 "예외를 잡아서 로그를 남기고, 스케줄은 계속 살린다" 는 원칙입니다.
Spring 기본 스레드 풀은 1개입니다. 느린 @Scheduled 메서드 하나가 같은 풀의 다른 모든 @Scheduled 를 뒤로 밀어냅니다.
[3] 스레드 1개 vs 4개: 느린 작업(800ms) 옆의 빠른 작업(100ms 간격) 최대 지연 (1초 관찰)
스레드 1개: 빠른 작업 10회, 최대 간격 817ms ← 느린 작업 뒤에 갇혔다가 몰아서 실행
스레드 4개: 빠른 작업 11회, 최대 간격 119ms ← 100ms 간격 유지spring.task.scheduling.pool.size 프로퍼티나 ThreadPoolTaskScheduler 빈으로 풀 크기를 늘리면 해결됩니다. 크기는 "동시에 돌 수 있는 배치 수 + 1" 정도가 기준입니다. 너무 크게 잡으면 배치들이 DB 커넥션 풀을 서로 경쟁합니다.
같은 스케줄러에 등록된 같은 작업은 SES 특성상 원래 겹치지 않습니다. 그런데도 겹치는 경우가 세 가지 있습니다.
ScheduledExecutorService 나 TaskScheduler 빈을 또 만들면 같은 작업이 두 스케줄러에서 각각 돕니다.[4] 같은 작업을 두 스케줄러가 동시에: SafeTask 의 SKIP (0.6초 관찰)
[B-1] report SKIP 이전 실행이 아직 진행 중
[A-1] report OK 259ms
[B-1] report SKIP 이전 실행이 아직 진행 중
report: 성공 3, 건너뜀 2, 실패 0프로세스 안 겹침은 AtomicBoolean 하나로 막을 수 있습니다. SafeTask 가 이 방식입니다. 프로세스 간 겹침, 즉 (c) 는 프로세스를 넘는 잠금이 필요합니다.
| 방식 | 적용 범위 | 장점 | 단점 |
|---|---|---|---|
| 파일 잠금(LeaderLock) | 같은 서버의 여러 프로세스 | 추가 의존성 없음 | 서버가 다르면 무용 |
| DB 행 잠금 | 인스턴스 전체 | 이미 쓰는 DB 재사용 | 트랜잭션 관리 필요 |
| ShedLock | 인스턴스 전체 | 어노테이션 한 줄 | 별도 테이블 필요 |
| Quartz 클러스터 | 인스턴스 전체 | 스케줄 관리 자체가 정교함 | 도입 비용이 큼 |
사내 시스템처럼 인스턴스가 몇 대 안 되는 경우, ShedLock 이 도입 비용과 효과의 균형이 가장 좋습니다. 4.2 에서 코드로 다룹니다.
Spring cron 은 6필드입니다. 순서는 "초 분 시 일 월 요일" 이고, 리눅스 crontab 의 5필드(분 시 일 월 요일)와 다릅니다. 초가 없다고 5필드로 쓰면 파싱 오류가 납니다.
*/15, 9-18, MON-FRI 는 리눅스 cron 과 공통이지만, L(말일)·W(가장 가까운 평일)·#(N번째 요일)은 Spring 전용 확장입니다.
MiniCron 은 기본 문법(* , - / 요일 이름)만 지원하는 축소판입니다. 원리는 "1초씩 전진하며 여섯 필드가 모두 맞는 순간을 찾는다" 는 것뿐입니다. 실무는 Spring 의 CronExpression.parse(...).next(now) 를 그대로 씁니다.
[5] cron 다음 실행 3회 (기준 2026-09-11 금 17:50 KST)
0 */15 9-18 * * MON-FRI → [09-11(금) 18:00:00, 09-11(금) 18:15:00, 09-11(금) 18:30:00]
0 0 2 * * * → [09-12(토) 02:00:00, 09-13(일) 02:00:00, 09-14(월) 02:00:00]
같은 순간을 UTC 로 두면 '0 0 2 * * *' → 09-12(토) 02:00:00 UTC = 09-12(토) 11:00:00 KST ← 9시간 어긋남zone 속성 없이 배포하면 @Scheduled 는 서버 JVM 의 기본 시간대를 씁니다. 컨테이너 이미지는 흔히 UTC 이므로, 새벽 2시 배치를 의도했는데 실제로는 낮 11시에 도는 사고가 납니다.
zone = "Asia/Seoul" 을 명시하면 서버 시간대와 무관하게 항상 KST 기준으로 계산됩니다. 한국은 DST 가 없어 이 문제만 막으면 안전합니다. 해외 리전에 배포한다면 DST 전환일에도 계산이 맞는지 별도로 확인해야 합니다.
shutdown() 은 새 회차 예약을 중단하고, 이미 시작된 작업은 끝까지 기다립니다. shutdownNow() 는 실행 중인 스레드에 인터럽트를 걸어 즉시 멈추려 시도합니다.
[7] 종료: shutdown + awaitTermination(2초) 후 shutdownNow
작업 시작(700ms)
작업 정상 완료
awaitTermination → true (진행 중 작업까지 끝내고 종료)Spring 은 spring.task.scheduling.shutdown.await-termination=true 와 await-termination-period 로 같은 순서를 제공합니다. 롤링 배포 중 배치가 어중간하게 끊기면 반쪽 데이터가 남을 수 있으므로, shutdown() 이 진행 중 작업을 기다리도록 켜 두고, 배치 자체는 트랜잭션 경계를 짧게 잡아 재실행해도 안전하게(멱등하게) 설계해야 합니다.
운영에서는 실행이 도는지 눈으로 볼 수 없습니다. 실행 횟수·실패 횟수·소요 시간·마지막 성공 시각을 SafeTask.stats() 같은 형태로 로그나 메트릭에 남기고, "N분 이상 성공 기록이 없음" 을 알림 조건으로 걸어야 합니다. 4.4 에서 이력 테이블로 확장합니다.