공공부하자개발 · 영어 학습 노트
자바
실무 확장Excel · 파일 업로드 · DB 연동0/22 완료
  • 01Excel(XLSX) 구조와 순수 JDK로 읽기/쓰기
  • 02Apache POI로 Excel 업로드/다운로드
  • 03파일 업로드/다운로드 서버 (HttpServer)
  • 04JDBC 기초와 트랜잭션 (H2)
  • 05MyBatis 어노테이션 매퍼로 쿼리 연동
  • 06MyBatis XML 매퍼 · Oracle 방언 · PageHelper · Spring Boot
  • 07REST API 서버와 JSON
  • 08Vue 3 SPA 와 Java 서버 연동
  • 09@Scheduled 운영
  • 10로깅 실무: 레벨·계층, MDC 추적, 예외·성능, 마스킹, 롤링, JSON 로그
  • 11외부 API 연동
  • 12테스트 실무
  • 13암호화·개인정보 보호
  • 14인코딩·한글 실무
  • 15@Transactional 심화
  • 16긴 작업 비동기 처리와 진행률
  • 17SFTP·FTP 파일 연계
  • 18로컬 캐시와 @Cacheable
  • 19메일·알림 발송
  • 20웹 보안 체크리스트
  • 21빌드 도구와 폐쇄망 의존성 반입
  • 22성능 측정: p50·p95·p99, 측정 계층, JFR, JMH 함정, 자체 부하 테스트, 병목 순위
사이트 소개개인정보처리방침연락처
© 2026 공부하자
홈 › 실무 확장 › 09 / 22

@Scheduled 운영

fixedRate·fixedDelay·cron, 예외·겹침·다중 인스턴스·종료
섹션 7진행 0 / 22
1왜 배우는가2핵심 원리3코드 예제4응용 변형 예제5자주 하는 실수 (Tip)6연습 문제7정리‹ 이전다음 ›

2. 핵심 원리

2.1 @Scheduled 의 정체

@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 이 그 이상 동작들입니다.

2.2 fixedRate vs fixedDelay

fixedRate 는 시작 시각 을 기준으로 다음 실행 시각을 정합니다. fixedDelay 는 끝난 시각 을 기준으로 다음 실행까지의 간격을 정합니다. 작업 시간이 간격보다 짧으면 둘의 차이가 안 보입니다.

작업이 간격보다 오래 걸리면 차이가 드러납니다. SES 는 같은 작업을 겹쳐 실행하지 않으므로, fixedRate 는 밀린 회차를 간격 0 으로 연달아 실행합니다. 이른바 catch-up 입니다.

text
[1] 작업 300ms, 간격 200ms: fixedRate vs fixedDelay (1.5초 관찰)
  fixedRate  실행 5회 ← 밀린 만큼 연달아 실행(간격 0)
  fixedDelay 실행 3회 ← 300+200 = 500ms 마다
  경과 1614ms
  • 정각성이 중요한 배치(예: 매 시각 정각 집계)는 fixedRate 가 맞습니다.
  • 부하 제어가 중요한 배치(예: 외부 API 폴링)는 fixedDelay 가 안전합니다.

대부분의 사내 배치는 후자입니다. 별다른 이유가 없으면 fixedDelay 를 기본값으로 삼는 편이 사고를 줄입니다.

2.3 예외가 스케줄을 죽인다

SES 는 scheduleAtFixedRate/scheduleWithFixedDelay 로 등록한 Runnable 이 예외를 던지면, 그 ScheduledFuture 를 완료 상태로 만들고 다시 실행하지 않습니다. 콘솔에도 로그가 남지 않습니다. f.get() 을 호출해야 감춰진 예외가 드러납니다.

text
[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 로 같은 처리를 하면 됩니다. 핵심은 "예외를 잡아서 로그를 남기고, 스케줄은 계속 살린다" 는 원칙입니다.

2.4 스레드 풀 크기

Spring 기본 스레드 풀은 1개입니다. 느린 @Scheduled 메서드 하나가 같은 풀의 다른 모든 @Scheduled 를 뒤로 밀어냅니다.

text
[3] 스레드 1개 vs 4개: 느린 작업(800ms) 옆의 빠른 작업(100ms 간격) 최대 지연 (1초 관찰)
  스레드 1개: 빠른 작업 10회, 최대 간격 817ms ← 느린 작업 뒤에 갇혔다가 몰아서 실행
  스레드 4개: 빠른 작업 11회, 최대 간격 119ms ← 100ms 간격 유지

spring.task.scheduling.pool.size 프로퍼티나 ThreadPoolTaskScheduler 빈으로 풀 크기를 늘리면 해결됩니다. 크기는 "동시에 돌 수 있는 배치 수 + 1" 정도가 기준입니다. 너무 크게 잡으면 배치들이 DB 커넥션 풀을 서로 경쟁합니다.

2.5 겹침과 다중 인스턴스

같은 스케줄러에 등록된 같은 작업은 SES 특성상 원래 겹치지 않습니다. 그런데도 겹치는 경우가 세 가지 있습니다.

  • (a) 수동 트리거: 관리 API 로 즉시 실행을 걸면 스케줄 실행과 겹칩니다.
  • (b) 여러 스케줄러: 코드 어딘가 새 ScheduledExecutorService 나 TaskScheduler 빈을 또 만들면 같은 작업이 두 스케줄러에서 각각 돕니다.
  • (c) 인스턴스 2대 이상 배포: 각 인스턴스가 자기 프로세스 안에서는 안 겹쳐도, 서버 두 대가 같은 배치를 각자 돌립니다.
text
[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 에서 코드로 다룹니다.

2.6 cron 과 시간대

Spring cron 은 6필드입니다. 순서는 "초 분 시 일 월 요일" 이고, 리눅스 crontab 의 5필드(분 시 일 월 요일)와 다릅니다. 초가 없다고 5필드로 쓰면 파싱 오류가 납니다.

*/15, 9-18, MON-FRI 는 리눅스 cron 과 공통이지만, L(말일)·W(가장 가까운 평일)·#(N번째 요일)은 Spring 전용 확장입니다.

MiniCron 은 기본 문법(* , - / 요일 이름)만 지원하는 축소판입니다. 원리는 "1초씩 전진하며 여섯 필드가 모두 맞는 순간을 찾는다" 는 것뿐입니다. 실무는 Spring 의 CronExpression.parse(...).next(now) 를 그대로 씁니다.

text
[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 전환일에도 계산이 맞는지 별도로 확인해야 합니다.

2.7 종료와 관측

shutdown() 은 새 회차 예약을 중단하고, 이미 시작된 작업은 끝까지 기다립니다. shutdownNow() 는 실행 중인 스레드에 인터럽트를 걸어 즉시 멈추려 시도합니다.

text
[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 에서 이력 테이블로 확장합니다.

핵심 원리
  • 2.1 @Scheduled 의 정체
  • 2.2 fixedRate vs fixedDelay
  • 2.3 예외가 스케줄을 죽인다
  • 2.4 스레드 풀 크기
  • 2.5 겹침과 다중 인스턴스
  • 2.6 cron 과 시간대
  • 2.7 종료와 관측
이전 섹션1 왜 배우는가2 / 7다음 섹션3 코드 예제