Scheduler 주의사항을 제대로 파악하지 못하면, 어느 날 갑자기 동일한 이메일이 수천 명에게 중복 발송되거나, 정산 금액이 두 배로 계산되거나, 디스크가 꽉 차서 서비스가 멈추는 상황을 경험하게 됩니다. 스케줄러는 “설정해두면 알아서 돌아가겠지”라는 생각으로 방치하기 쉬운 컴포넌트이지만, 실무에서 가장 조용히, 가장 치명적으로 장애를 만들어내는 영역 중 하나입니다. Spring @Scheduled부터 Quartz, 분산 환경의 ShedLock까지 – 이 글에서는 스케줄러를 실무에서 안전하게 운영하기 위해 반드시 알아야 할 주의사항을 원리부터 해결책까지 빠짐없이 정리합니다.
목차
- 스케줄러란? 기초 개념과 동작 원리 정리
- 스케줄러의 핵심 작동 메커니즘 – 스레드와 Cron의 원리
- 정상적으로 운영될 때의 스케줄러 – 올바른 설계의 기준
- 실무에서 마주치는 Scheduler 주의사항 8가지
- 분산 환경에서의 스케줄러 – ShedLock·Quartz 실전 적용
- 전문가 관점 – 스케줄러 모니터링 전략과 추천 설계 패턴
1. 스케줄러란? 기초 개념과 동작 원리 정리
스케줄러(Scheduler)는 특정 시간 또는 특정 주기에 자동으로 작업을 실행하는 메커니즘입니다. 사람이 직접 버튼을 누르거나 API를 호출하지 않아도, 미리 정의된 시간 조건에 따라 코드가 자동으로 실행됩니다.
일상적인 비유로 설명하면, 알람 시계와 같습니다. “매일 오전 9시에 울려라”, “5분마다 확인해라”, “매월 1일 자정에 실행해라”처럼 조건을 등록해두면 시스템이 자동으로 실행합니다.
스케줄러의 주요 사용 사례
실무에서 스케줄러가 사용되는 대표적인 상황은 다음과 같습니다.
| 사용 사례 | 실행 주기 | 설명 |
|---|---|---|
| 정산 처리 | 매일 새벽 | 전일 거래 내역 집계 및 정산 |
| 알림·이메일 발송 | 특정 시각 | 예약 발송, 미결제 알림 |
| 데이터 동기화 | 주기적 | 외부 API·DB 간 데이터 맞추기 |
| 임시 파일 정리 | 매일/매주 | 로그, 캐시, 업로드 파일 삭제 |
| 통계 집계 | 매시간/매일 | 대시보드용 집계 데이터 생성 |
| 만료 데이터 처리 | 주기적 | 세션 만료, 쿠폰 만료 처리 |
| 헬스체크·모니터링 | 수초~수분 | 외부 서비스 상태 점검 |
| 배치 보고서 생성 | 주간·월간 | 경영진 리포트 자동 생성 |
Java·Spring 생태계의 스케줄러 종류
Java 백엔드 환경에서 사용할 수 있는 대표적인 스케줄러는 다음과 같습니다.
① Spring @Scheduled
Spring Framework에 내장된 가장 간단한 스케줄러입니다. 어노테이션 하나로 메서드를 스케줄링할 수 있어 진입 장벽이 낮습니다.
java
@Component
public class DailyReportScheduler {
@Scheduled(cron = "0 0 2 * * *") // 매일 새벽 2시 실행
public void generateDailyReport() {
// 일간 보고서 생성 로직
}
@Scheduled(fixedDelay = 5000) // 이전 실행 완료 후 5초 대기
public void syncExternalData() {
// 외부 데이터 동기화
}
@Scheduled(fixedRate = 10000) // 10초마다 실행 (완료 여부 무관)
public void healthCheck() {
// 헬스체크
}
}
② Quartz Scheduler
엔터프라이즈급 스케줄링 라이브러리입니다. 작업(Job)과 트리거(Trigger)를 분리해 관리하며, DB 기반 클러스터링을 지원합니다.
③ Spring Batch + Spring Scheduler
대용량 배치 처리를 위한 조합입니다. 스케줄러가 배치 Job을 트리거하고, Spring Batch가 청크(Chunk) 단위로 안전하게 처리합니다.
④ 외부 스케줄링 시스템
Jenkins, GitHub Actions, AWS EventBridge, Kubernetes CronJob 등 애플리케이션 외부에서 스케줄링을 관리합니다.
2. 스케줄러의 핵심 작동 메커니즘 – 스레드와 Cron의 원리
스케줄러를 안전하게 사용하려면 내부 동작 원리를 정확히 이해해야 합니다. 특히 스레드 모델과 Cron 표현식 파싱 방식이 핵심입니다.
Spring @Scheduled의 스레드 동작 원리
Spring @Scheduled는 기본적으로 **단일 스레드(Single Thread)**로 동작합니다. 이것은 매우 중요한 사실이며, 많은 개발자들이 놓치는 부분입니다.
[Spring @Scheduled 기본 스레드 동작]
스케줄러 스레드 풀 (기본: 크기 1)
│
├─ 작업 A (새벽 2시, 실행 시간 30분)
│ └─ 실행 중...
│
├─ 작업 B (새벽 2시 10분, 정상이라면 2:10에 실행)
│ └─ 작업 A가 끝나지 않아 2:30까지 대기 중 ⚠️
│
└─ 작업 C (새벽 2시 20분)
└─ 작업 A, B가 끝날 때까지 대기 중 ⚠️
→ 스레드 1개로 모든 작업을 순차 처리
→ 하나의 작업이 오래 걸리면 나머지 작업 전체 지연
이 문제를 해결하려면 스케줄러 전용 스레드 풀을 명시적으로 설정해야 합니다.
java
@Configuration
public class SchedulerConfig implements SchedulingConfigurer {
@Override
public void configureTasks(ScheduledTaskRegistrar taskRegistrar) {
ThreadPoolTaskScheduler scheduler = new ThreadPoolTaskScheduler();
scheduler.setPoolSize(5); // 스레드 풀 크기
scheduler.setThreadNamePrefix("scheduler-"); // 스레드 이름 접두사
scheduler.setWaitForTasksToCompleteOnShutdown(true); // 종료 시 대기
scheduler.setAwaitTerminationSeconds(60); // 최대 60초 대기
scheduler.initialize();
taskRegistrar.setTaskScheduler(scheduler);
}
}
Cron 표현식 완전 해부
Spring에서 사용하는 Cron 표현식은 6자리 형식입니다(Linux의 5자리와 다릅니다).
[Spring Cron 표현식 구조]
┌─── 초 (0-59)
│ ┌─── 분 (0-59)
│ │ ┌─── 시 (0-23)
│ │ │ ┌─── 일 (1-31)
│ │ │ │ ┌─── 월 (1-12 또는 JAN-DEC)
│ │ │ │ │ ┌─── 요일 (0-7 또는 SUN-SAT, 0과 7 모두 일요일)
│ │ │ │ │ │
* * * * * *
자주 쓰는 Cron 표현식 예시:
"0 0 2 * * *" → 매일 새벽 2시 정각
"0 30 9 * * MON" → 매주 월요일 오전 9시 30분
"0 0 0 1 * *" → 매월 1일 자정
"0 */10 * * * *" → 10분마다 실행
"0 0 9-18 * * MON-FRI" → 평일 오전 9시~오후 6시 매 정각
fixedRate vs fixedDelay – 혼동 주의
@Scheduled의 두 가지 주기 설정 방식은 개념이 비슷해 보이지만 동작이 완전히 다릅니다.
[fixedRate = 10000 (10초마다 실행)]
시작 |──작업(8초)──| 2초 후 |──작업(8초)──| 2초 후 |──작업──|
0초 10초 18초 ...
→ 이전 작업 완료 여부와 무관하게 10초마다 실행 시도
→ 작업 실행 시간이 10초를 초과하면 중복 실행 위험!
[fixedDelay = 10000 (이전 완료 후 10초 대기)]
시작 |──작업(8초)──| 대기(10초) |──작업(8초)──| 대기(10초) |──작업──|
0초 18초 28초 46초
→ 이전 작업이 완료된 시점에서 10초를 기다린 후 다음 실행
→ 중복 실행 위험 없음, 단 실행 간격이 늘어날 수 있음
3. 정상적으로 운영될 때의 스케줄러 – 올바른 설계의 기준
Scheduler 주의사항을 논하기 전에, 스케줄러가 올바르게 설계되고 운영될 때 어떤 모습이어야 하는지 기준을 잡아야 합니다.
이상적인 스케줄러 설계의 5가지 기준
① 멱등성(Idempotency) 보장
같은 작업이 두 번 실행되더라도 결과가 한 번 실행한 것과 동일해야 합니다. 예를 들어 “오늘 날짜 기준 미결제 주문에 알림 발송” 작업이 실수로 두 번 실행되어도, 한 번만 발송된 것처럼 처리되어야 합니다.
② 실행 시간 예측 가능
작업 하나의 예상 실행 시간을 알고 있어야 합니다. 실행 시간이 스케줄 주기보다 짧아야 안전합니다.
✅ 안전한 설계:
실행 주기 = 10분, 예상 실행 시간 = 2분 → 여유 있음
⚠️ 위험한 설계:
실행 주기 = 5분, 예상 실행 시간 = 7분 → 중복 실행 위험
③ 실패 감지와 알림
스케줄러 실행이 실패했을 때 담당자에게 즉시 알림이 가야 합니다. 조용히 실패하는 스케줄러는 며칠 후 대량 데이터 불일치 상황을 만들어냅니다.
④ 로그와 감사 추적
언제 시작했고, 얼마나 걸렸으며, 몇 건을 처리했고, 성공했는지 실패했는지를 반드시 기록해야 합니다.
⑤ 재실행(Retry) 전략
실패 시 자동 재시도 정책과 최대 재시도 횟수, 지수 백오프(Exponential Backoff) 전략을 미리 정의해야 합니다.
올바르게 설계된 스케줄러 코드 예시
java
@Slf4j
@Component
@RequiredArgsConstructor
public class OrderNotificationScheduler {
private final OrderService orderService;
private final NotificationService notificationService;
private final SlackAlertService slackAlertService;
@Scheduled(cron = "0 0 9 * * MON-FRI") // 평일 오전 9시
public void sendUnpaidOrderNotification() {
log.info("[Scheduler] 미결제 주문 알림 시작 - {}", LocalDateTime.now());
long startTime = System.currentTimeMillis();
try {
List<Order> unpaidOrders = orderService.findUnpaidOrders();
log.info("[Scheduler] 대상 건수: {}건", unpaidOrders.size());
for (Order order : unpaidOrders) {
if (!order.isNotificationSent()) { // 멱등성 체크
notificationService.send(order);
order.markNotificationSent(); // 발송 완료 표시
}
}
long elapsed = System.currentTimeMillis() - startTime;
log.info("[Scheduler] 미결제 주문 알림 완료 - 소요 시간: {}ms", elapsed);
} catch (Exception e) {
log.error("[Scheduler] 미결제 주문 알림 실패", e);
slackAlertService.sendAlert("🚨 미결제 주문 알림 스케줄러 실패: " + e.getMessage());
}
}
}
4. 실무에서 마주치는 Scheduler 주의사항 8가지
이제 핵심입니다. 실무 현장에서 스케줄러로 인해 실제 장애가 발생했던 유형을 8가지 주의사항으로 정리합니다.
주의사항 ① 분산 환경에서의 중복 실행 – 가장 치명적인 함정
단일 서버 환경에서 잘 작동하던 스케줄러를 다중 인스턴스(Scale-out) 환경에 배포하면 심각한 문제가 발생합니다.
[분산 환경 중복 실행 시나리오]
서버 인스턴스 A ─── @Scheduled 실행 → "오늘 정산 처리 시작"
서버 인스턴스 B ─── @Scheduled 실행 → "오늘 정산 처리 시작" ← 동시 실행!
서버 인스턴스 C ─── @Scheduled 실행 → "오늘 정산 처리 시작" ← 동시 실행!
결과:
→ 정산 금액 3배 계산
→ 이메일 3배 발송
→ 재고 3배 차감
→ 데이터 정합성 완전 붕괴
해결책: ShedLock으로 분산 락 적용
xml
<!-- pom.xml -->
<dependency>
<groupId>net.javacrumbs.shedlock</groupId>
<artifactId>shedlock-spring</artifactId>
<version>5.10.0</version>
</dependency>
<dependency>
<groupId>net.javacrumbs.shedlock</groupId>
<artifactId>shedlock-provider-jdbc-template</artifactId>
<version>5.10.0</version>
</dependency>
sql
-- ShedLock 테이블 생성 (MySQL 기준)
CREATE TABLE shedlock (
name VARCHAR(64) NOT NULL,
lock_until TIMESTAMP(3) NOT NULL,
locked_at TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
locked_by VARCHAR(255) NOT NULL,
PRIMARY KEY (name)
);
java
@Configuration
@EnableSchedulerLock(defaultLockAtMostFor = "PT10M") // 최대 10분 락 유지
public class ShedLockConfig {
@Bean
public LockProvider lockProvider(DataSource dataSource) {
return new JdbcTemplateLockProvider(
JdbcTemplateLockProvider.Configuration.builder()
.withJdbcTemplate(new JdbcTemplate(dataSource))
.usingDbTime() // DB 서버 시간 기준 (클록 스큐 방지)
.build()
);
}
}
@Component
public class SafeScheduler {
@Scheduled(cron = "0 0 2 * * *")
@SchedulerLock(
name = "dailySettlement", // 락 이름 (유니크해야 함)
lockAtLeastFor = "PT1M", // 최소 1분간 락 유지 (빠른 완료 후 재실행 방지)
lockAtMostFor = "PT30M" // 최대 30분 (비정상 종료 시 자동 해제)
)
public void dailySettlement() {
// 정산 로직 – 단 하나의 인스턴스에서만 실행됨
}
}
주의사항 ② 스레드 풀 미설정으로 인한 작업 지연 연쇄
앞서 설명했듯 Spring @Scheduled는 기본적으로 스레드 1개로 동작합니다. 여러 스케줄러가 등록된 환경에서 하나가 오래 걸리면 나머지 전체가 도미노처럼 지연됩니다.
[스레드 1개로 인한 연쇄 지연 실제 시나리오]
09:00 - 작업A (예상 5분) 시작
09:00 - 작업B (매시 정각 실행 예약) → 스레드 없어 대기
09:05 - 작업A 완료 → 작업B 5분 늦게 시작
09:07 - 작업B 완료
09:00에 실행됐어야 할 작업B가 09:07에 완료
→ 작업B에 의존하는 다운스트림 작업 전체 지연
→ SLA(서비스 수준 협약) 위반 가능
해결책: 전용 스레드 풀 구성 + 작업별 비동기 분리
java
// 스케줄러 스레드 풀 설정
@Configuration
public class SchedulerThreadConfig implements SchedulingConfigurer {
@Override
public void configureTasks(ScheduledTaskRegistrar taskRegistrar) {
ThreadPoolTaskScheduler scheduler = new ThreadPoolTaskScheduler();
scheduler.setPoolSize(10);
scheduler.setThreadNamePrefix("my-scheduler-");
scheduler.setRejectedExecutionHandler(new ThreadPoolExecutor.CallerRunsPolicy());
scheduler.setWaitForTasksToCompleteOnShutdown(true);
scheduler.setAwaitTerminationSeconds(60);
scheduler.initialize();
taskRegistrar.setTaskScheduler(scheduler);
}
}
// 오래 걸리는 작업은 @Async로 별도 스레드 분리
@Component
public class HeavyScheduler {
@Scheduled(cron = "0 0 1 * * *")
@Async("heavyTaskExecutor") // 전용 비동기 스레드 풀 사용
public void heavyBatchJob() {
// 오래 걸리는 배치 작업
}
}
주의사항 ③ Cron 표현식 오류 – 운영 환경에서 침묵하는 버그
Cron 표현식을 잘못 작성하면 애플리케이션이 정상 구동되어도 스케줄러가 실행되지 않습니다. 더 위험한 것은 이 오류가 서비스 시작 시 예외를 발생시키지 않고 조용히 넘어가는 경우가 있다는 점입니다.
java
// 실수하기 쉬운 Cron 표현식 오류 사례
// ❌ 잘못된 예시 1: 5자리 Linux Cron 형식 (Spring은 6자리)
@Scheduled(cron = "0 2 * * *") // 초(second) 필드 누락
// → IllegalArgumentException 또는 예상치 못한 시각에 실행
// ❌ 잘못된 예시 2: 월(month) 범위 오류
@Scheduled(cron = "0 0 2 * 13 *") // 13월은 존재하지 않음
// ❌ 잘못된 예시 3: 요일과 일자 동시 지정 (AND 조건이 아닌 OR 조건)
@Scheduled(cron = "0 0 9 15 * MON")
// → 매월 15일 또는 매주 월요일 오전 9시 (AND가 아님!)
// ✅ 올바른 예시
@Scheduled(cron = "0 0 2 * * *") // 매일 새벽 2시 (6자리 형식)
// ✅ 환경별 Cron 분리 – 운영 환경만 실제 실행 주기 설정
@Scheduled(cron = "${scheduler.daily-report.cron:0 0 2 * * *}")
public void generateReport() { ... }
yaml
# application-dev.yml (개발 환경)
scheduler:
daily-report:
cron: "0 */1 * * * *" # 개발 시 1분마다 빠른 테스트
# application-prod.yml (운영 환경)
scheduler:
daily-report:
cron: "0 0 2 * * *" # 운영 시 매일 새벽 2시
주의사항 ④ 실행 시간 초과 – 타임아웃 미설정
스케줄러 작업에 타임아웃을 설정하지 않으면 작업이 무한정 실행되거나 외부 API 응답을 영원히 기다리며 스레드를 점유합니다.
[타임아웃 미설정 시 발생하는 상황]
작업 실행 → 외부 API 호출 → 네트워크 장애로 응답 무한 대기
└─ 스레드 점유 상태로 시간 계속 흐름
└─ 다음 스케줄 시각 도달 → 새 스레드 필요
└─ 모든 스레드 점유 → 스레드 풀 고갈
└─ 새 작업 처리 불가 → 전체 스케줄러 마비
해결책: 명시적 타임아웃 설정
java
@Component
@RequiredArgsConstructor
public class ExternalSyncScheduler {
private final RestTemplate restTemplate;
@Scheduled(fixedDelay = 60000)
public void syncFromExternalAPI() {
// RestTemplate에 타임아웃 설정
HttpComponentsClientHttpRequestFactory factory =
new HttpComponentsClientHttpRequestFactory();
factory.setConnectTimeout(3000); // 연결 타임아웃 3초
factory.setReadTimeout(10000); // 읽기 타임아웃 10초
try {
ResponseEntity<String> response = restTemplate.getForEntity(
"https://external-api.example.com/data", String.class
);
// 처리 로직
} catch (ResourceAccessException e) {
log.error("[Scheduler] 외부 API 타임아웃 발생", e);
}
}
}
주의사항 ⑤ 트랜잭션 범위 오설정
스케줄러 메서드 전체에 @Transactional을 걸면, 대량 데이터를 처리하는 배치 작업에서 하나의 거대한 트랜잭션이 오랫동안 열려 있어 DB 커넥션 고갈, 락 타임아웃, 롤백 시 전체 재처리 등의 문제가 생깁니다.
java
// ❌ 잘못된 예시 – 전체 배치를 하나의 트랜잭션으로 처리
@Scheduled(cron = "0 0 3 * * *")
@Transactional // ← 수십만 건 처리 중 트랜잭션이 계속 열려 있음!
public void processLargeData() {
List<Data> allData = repository.findAll(); // 수십만 건 조회
for (Data data : allData) {
process(data); // 각각 DB 작업 → 트랜잭션 계속 유지
}
}
// ✅ 올바른 예시 – 청크(Chunk) 단위로 트랜잭션 분리
@Scheduled(cron = "0 0 3 * * *")
public void processLargeData() {
int page = 0;
int chunkSize = 1000;
while (true) {
List<Data> chunk = repository.findByStatusPending(
PageRequest.of(page, chunkSize)
);
if (chunk.isEmpty()) break;
processChunk(chunk); // 내부에서 트랜잭션 처리
page++;
}
}
@Transactional // 청크 단위로 트랜잭션 경계 설정
public void processChunk(List<Data> chunk) {
for (Data data : chunk) {
process(data);
}
}
주의사항 ⑥ 애플리케이션 종료 시 실행 중인 작업 강제 중단
기본 설정에서 Spring 컨텍스트가 종료될 때(배포, 서버 재시작 등) 실행 중이던 스케줄러 작업이 즉시 중단됩니다. 정산 처리나 파일 생성 도중 강제 종료되면 데이터가 불완전한 상태로 남을 수 있습니다.
java
// ✅ Graceful Shutdown 설정 – 실행 중 작업 완료 후 종료
// 1. application.yml 설정
// server:
// shutdown: graceful
// spring:
// lifecycle:
// timeout-per-shutdown-phase: 60s
// 2. 스케줄러 스레드 풀에 종료 대기 설정
@Configuration
public class SchedulerConfig implements SchedulingConfigurer {
@Override
public void configureTasks(ScheduledTaskRegistrar registrar) {
ThreadPoolTaskScheduler scheduler = new ThreadPoolTaskScheduler();
scheduler.setPoolSize(5);
scheduler.setWaitForTasksToCompleteOnShutdown(true); // 핵심 설정
scheduler.setAwaitTerminationSeconds(120); // 최대 2분 대기
scheduler.initialize();
registrar.setTaskScheduler(scheduler);
}
}
// 3. 종료 신호 감지하여 작업 중단 플래그 처리
@Component
public class GracefulScheduler {
private volatile boolean shutdownRequested = false;
@PreDestroy
public void onShutdown() {
shutdownRequested = true;
log.info("[Scheduler] 종료 신호 감지 – 현재 작업 완료 후 종료 예정");
}
@Scheduled(cron = "0 0 3 * * *")
public void processData() {
List<Data> items = repository.findPending();
for (Data item : items) {
if (shutdownRequested) {
log.warn("[Scheduler] 종료 요청으로 작업 중단 – 처리 완료: {}건", processedCount);
break;
}
process(item);
}
}
}
주의사항 ⑦ 예외 처리 누락 – 조용히 사라지는 스케줄러
스케줄러 메서드에서 처리되지 않은 예외가 발생하면, 해당 실행 인스턴스만 종료되고 다음 주기에는 다시 실행됩니다. 문제는 이 실패가 로그 없이 조용히 사라질 수 있다는 점입니다.
java
// ❌ 예외 처리 없는 스케줄러 – 실패를 알 수 없음
@Scheduled(cron = "0 0 2 * * *")
public void riskyJob() {
// 예외 발생 시 스택트레이스만 출력되고 조용히 종료
// 담당자는 내일 아침 데이터 불일치로 알게 됨
processData();
}
// ✅ 포괄적 예외 처리 + 알림 연동
@Scheduled(cron = "0 0 2 * * *")
public void safeJob() {
String jobName = "daily-data-process";
MDC.put("jobName", jobName); // 로그 추적 컨텍스트
try {
log.info("[{}] 시작", jobName);
processData();
log.info("[{}] 완료", jobName);
} catch (DataAccessException e) {
log.error("[{}] DB 오류 발생", jobName, e);
alertService.sendCritical(jobName, "DB 접근 오류: " + e.getMessage());
} catch (ExternalApiException e) {
log.error("[{}] 외부 API 오류 발생", jobName, e);
alertService.sendWarning(jobName, "외부 API 오류 – 재시도 예정");
} catch (Exception e) {
log.error("[{}] 예상치 못한 오류", jobName, e);
alertService.sendCritical(jobName, "알 수 없는 오류: " + e.getMessage());
} finally {
MDC.clear();
}
}
주의사항 ⑧ 테스트 환경에서 스케줄러 미제어
개발·테스트 환경에서 스케줄러가 의도치 않게 실행되면 외부 API를 실제로 호출하거나, 테스트 DB의 데이터를 변경하거나, 이메일을 실제로 발송하는 사고가 발생합니다.
java
// ❌ 문제: 테스트 환경에서도 스케줄러 실행
@SpringBootTest
class SchedulerTest {
// 테스트 실행 시 모든 @Scheduled 동작 → 의도치 않은 부작용 발생
}
// ✅ 해결 1: 테스트 시 스케줄링 비활성화
@SpringBootTest
@TestPropertySource(properties = "spring.task.scheduling.enabled=false")
class SchedulerTest { ... }
// ✅ 해결 2: @ConditionalOnProperty로 환경별 활성화 제어
@Component
@ConditionalOnProperty(
name = "scheduler.enabled",
havingValue = "true",
matchIfMissing = false
)
public class ConditionalScheduler {
@Scheduled(cron = "0 0 2 * * *")
public void job() { ... }
}
yaml
# application-prod.yml
scheduler:
enabled: true
# application-dev.yml / application-test.yml
scheduler:
enabled: false
5. 분산 환경에서의 스케줄러 – ShedLock·Quartz 실전 적용
분산 환경에서 스케줄러를 안전하게 운영하는 두 가지 대표적인 접근 방법을 비교합니다.
ShedLock vs Quartz – 선택 기준
| 구분 | ShedLock | Quartz |
|---|---|---|
| 도입 복잡도 | 낮음 (어노테이션 추가) | 높음 (Job·Trigger 설정) |
| 분산 락 | DB·Redis·ZooKeeper 기반 | DB 기반 클러스터링 |
| 동적 스케줄 변경 | 불가 (코드 변경 필요) | 가능 (런타임 변경) |
| 실패 이력 관리 | 없음 | 있음 (Job History) |
| 모니터링 | 별도 구현 필요 | 내장 지원 |
| Spring 통합 | 매우 쉬움 | 보통 |
| 적합한 규모 | 소~중규모 | 중~대규모 |
ShedLock 심화 – Redis 기반 락 (DB 부하 줄이기)
xml
<dependency>
<groupId>net.javacrumbs.shedlock</groupId>
<artifactId>shedlock-provider-redis-spring</artifactId>
<version>5.10.0</version>
</dependency>
java
@Configuration
@EnableSchedulerLock(defaultLockAtMostFor = "PT10M")
public class RedisShedLockConfig {
@Bean
public LockProvider lockProvider(RedisConnectionFactory connectionFactory) {
return new RedisLockProvider(connectionFactory, "production");
// "production"은 환경 구분 prefix
}
}
Quartz 분산 클러스터링 설정
yaml
# application.yml – Quartz 클러스터 설정
spring:
quartz:
job-store-type: jdbc # DB에 Job 정보 저장
jdbc:
initialize-schema: never # 스키마는 별도 관리
properties:
org.quartz.scheduler.instanceId: AUTO
org.quartz.jobStore.isClustered: true
org.quartz.jobStore.clusterCheckinInterval: 10000 # 10초마다 상태 확인
org.quartz.threadPool.threadCount: 5
java
@Component
public class QuartzJobConfig {
@Bean
public JobDetail sampleJobDetail() {
return JobBuilder.newJob(SampleJob.class)
.withIdentity("sampleJob")
.storeDurably()
.build();
}
@Bean
public Trigger sampleJobTrigger(JobDetail sampleJobDetail) {
return TriggerBuilder.newTrigger()
.forJob(sampleJobDetail)
.withIdentity("sampleJobTrigger")
.withSchedule(
CronScheduleBuilder.cronSchedule("0 0 2 * * ?")
.withMisfireHandlingInstructionDoNothing() // 누락 실행 무시
)
.build();
}
}
Kubernetes 환경에서의 스케줄러 전략
yaml
# Kubernetes CronJob – 애플리케이션 외부에서 스케줄 관리
apiVersion: batch/v1
kind: CronJob
metadata:
name: daily-report-job
spec:
schedule: "0 2 * * *" # Linux 5자리 Cron (UTC 기준 주의!)
concurrencyPolicy: Forbid # 이전 실행이 완료되지 않으면 새 실행 금지
successfulJobsHistoryLimit: 3
failedJobsHistoryLimit: 5
jobTemplate:
spec:
template:
spec:
restartPolicy: OnFailure
containers:
- name: report-generator
image: my-app:latest
command: ["java", "-jar", "app.jar", "--job=dailyReport"]
6. 전문가 관점 – 스케줄러 모니터링 전략과 추천 설계 패턴
스케줄러를 배포했다고 끝이 아닙니다. 실제로 실행되고 있는지, 얼마나 걸리는지, 실패하진 않았는지를 지속적으로 관찰해야 합니다.
Dead Man’s Switch 패턴 – 침묵 장애 감지
스케줄러가 실패하면 보통 아무 신호도 보내지 않습니다. “정상 실행 완료” 신호를 기대하다가 신호가 오지 않으면 경보를 울리는 방식이 Dead Man’s Switch 패턴입니다.
java
// Healthchecks.io 또는 Cronitor 활용 예시
@Scheduled(cron = "0 0 2 * * *")
public void monitoredJob() {
String pingUrl = "https://hc-ping.com/your-uuid";
try {
// 시작 신호
restTemplate.getForObject(pingUrl + "/start", String.class);
executeActualJob();
// 완료 신호 (이 신호가 오지 않으면 경보 발생)
restTemplate.getForObject(pingUrl, String.class);
} catch (Exception e) {
// 실패 신호
restTemplate.getForObject(pingUrl + "/fail", String.class);
throw e;
}
}
Prometheus + Grafana로 스케줄러 메트릭 수집
java
@Component
@RequiredArgsConstructor
public class MetricsScheduler {
private final MeterRegistry meterRegistry;
private final Counter successCounter;
private final Counter failureCounter;
private final Timer executionTimer;
@PostConstruct
public void init() {
Counter.builder("scheduler.daily.report.success")
.description("스케줄러 성공 횟수")
.register(meterRegistry);
Timer.builder("scheduler.daily.report.duration")
.description("스케줄러 실행 시간")
.register(meterRegistry);
}
@Scheduled(cron = "0 0 2 * * *")
public void trackedJob() {
Timer.Sample sample = Timer.start(meterRegistry);
try {
executeJob();
meterRegistry.counter("scheduler.daily.report.success").increment();
} catch (Exception e) {
meterRegistry.counter("scheduler.daily.report.failure").increment();
throw e;
} finally {
sample.stop(meterRegistry.timer("scheduler.daily.report.duration"));
}
}
}
스케줄러 설계 체크리스트 – 배포 전 최종 점검
배포 전 스케줄러 안전 체크리스트
□ 분산 환경 대비: ShedLock 또는 Quartz 클러스터링 적용?
□ 스레드 풀 크기: 기본값(1) 대신 명시적 설정 완료?
□ 멱등성 보장: 중복 실행 시 데이터 이상 없음 확인?
□ 타임아웃 설정: 외부 API 호출에 커넥션·읽기 타임아웃 설정?
□ 예외 처리: 모든 예외 캐치 + 알림(Slack·PagerDuty 등) 연동?
□ Graceful Shutdown: 배포 시 실행 중 작업 중단 방지 설정?
□ 트랜잭션 범위: 대량 처리 시 청크 단위 트랜잭션 분리?
□ 환경별 Cron 분리: 개발·운영 환경 다른 주기 설정?
□ 테스트 환경 비활성화: @ConditionalOnProperty 또는 Profile 설정?
□ 모니터링: 실행 이력, 소요 시간, 성공/실패 메트릭 수집?
□ Dead Man's Switch: 침묵 장애 감지 체계 구축?
□ 로그: 시작·완료·처리 건수·소요 시간 로그 기록?
추천 기술 스택 조합
| 환경 | 추천 스케줄러 구성 |
|---|---|
| 단일 서버, 소규모 | Spring @Scheduled + 명시적 스레드 풀 |
| 다중 인스턴스, 중규모 | Spring @Scheduled + ShedLock(Redis or DB) |
| 엔터프라이즈, 대규모 | Quartz Cluster + Spring Batch |
| Kubernetes 환경 | Kubernetes CronJob + Spring Batch |
| 서버리스·클라우드 | AWS EventBridge + Lambda / GCP Cloud Scheduler |
결론
Scheduler 주의사항의 핵심은 세 가지입니다. 첫째, 분산 환경에서의 중복 실행을 ShedLock이나 Quartz 클러스터링으로 반드시 막아야 합니다. 둘째, 스레드 풀·타임아웃·트랜잭션 범위·Graceful Shutdown을 명시적으로 설정하지 않으면 언젠가 반드시 장애가 발생합니다. 셋째, 예외 처리와 모니터링 없는 스케줄러는 조용히 실패하고, 그 결과는 며칠 뒤 데이터 불일치로 돌아옵니다. 스케줄러는 간단해 보이지만 운영 환경에서는 치밀한 설계가 필요한 컴포넌트입니다.
지금 바로 운영 중인 스케줄러에 본문의 체크리스트를 적용해보고, 분산 환경 대비와 모니터링 체계부터 하나씩 갖춰 나가세요.
답글 남기기