Spring Scheduler란?
Spring의 @Scheduled 어노테이션은 주기적 작업을 간편하게 실행합니다. 하지만 프로덕션에서는 다중 인스턴스 중복 실행 방지, 동적 스케줄 변경, 분산 락, 모니터링이 필수입니다. 이 글에서는 기본 스케줄링부터 ShedLock 분산 락, 동적 cron 변경, TaskScheduler 프로그래밍 방식, 에러 처리까지 심화 패턴을 다룹니다.
기본 설정과 @Scheduled
// 스케줄링 활성화
@Configuration
@EnableScheduling
public class ScheduleConfig {
// 스케줄러 스레드 풀 설정 (기본: 1개 스레드)
@Bean
public TaskScheduler taskScheduler() {
ThreadPoolTaskScheduler scheduler = new ThreadPoolTaskScheduler();
scheduler.setPoolSize(5); // 동시 실행 가능한 작업 수
scheduler.setThreadNamePrefix("scheduler-");
scheduler.setErrorHandler(t ->
log.error("스케줄 작업 실행 실패", t));
scheduler.setWaitForTasksToCompleteOnShutdown(true);
scheduler.setAwaitTerminationSeconds(30);
return scheduler;
}
}
@Service
@RequiredArgsConstructor
@Slf4j
public class ScheduledTasks {
// 1. fixedRate: 이전 시작 시점 기준 주기 실행
@Scheduled(fixedRate = 60000) // 60초마다
public void collectMetrics() {
log.info("메트릭 수집 시작");
metricsService.collect();
}
// 2. fixedDelay: 이전 완료 시점 기준 주기 실행
@Scheduled(fixedDelay = 30000, initialDelay = 5000)
public void processQueue() {
// 이전 작업이 끝나고 30초 후 실행
// 시작 후 5초 대기 후 첫 실행
queueProcessor.process();
}
// 3. cron: 정밀한 시간 스케줄
@Scheduled(cron = "0 0 9 * * MON-FRI") // 평일 오전 9시
public void sendDailyReport() {
reportService.generateAndSend();
}
@Scheduled(cron = "0 */5 * * * *") // 5분마다
public void checkHealth() {
healthChecker.check();
}
@Scheduled(cron = "0 0 2 * * *") // 매일 새벽 2시
public void cleanupExpiredData() {
cleanupService.removeExpired();
}
// 4. 프로퍼티에서 cron 표현식 주입
@Scheduled(cron = "${app.schedule.report-cron:0 0 9 * * MON-FRI}")
public void configurableReport() {
reportService.generate();
}
}
Cron 표현식 레퍼런스
| 필드 | 허용값 | 예시 |
|---|---|---|
| 초 | 0-59 | 0, */15 |
| 분 | 0-59 | 0, 30 |
| 시 | 0-23 | 9, 0-6 |
| 일 | 1-31 | 1, 15, L(마지막) |
| 월 | 1-12 | 1, JAN-DEC |
| 요일 | 0-7 (0,7=일) | MON-FRI, 1-5 |
// 자주 쓰는 cron 패턴
"0 0 * * * *" // 매시 정각
"0 */10 * * * *" // 10분마다
"0 0 9 * * MON-FRI" // 평일 오전 9시
"0 0 0 1 * *" // 매월 1일 자정
"0 0 2 * * SUN" // 매주 일요일 새벽 2시
"0 0 0 L * *" // 매월 마지막 날 자정
ShedLock: 분산 환경 중복 실행 방지
다중 인스턴스 환경에서 @Scheduled는 모든 인스턴스에서 동시에 실행됩니다. ShedLock은 DB나 Redis에 락을 걸어 하나의 인스턴스만 실행하도록 보장합니다.
// build.gradle
dependencies {
implementation 'net.javacrumbs.shedlock:shedlock-spring:5.10.0'
implementation 'net.javacrumbs.shedlock:shedlock-provider-jdbc-template:5.10.0'
// 또는 Redis 사용 시
// implementation 'net.javacrumbs.shedlock:shedlock-provider-redis-spring:5.10.0'
}
// DB 테이블 생성 (MySQL)
-- CREATE TABLE shedlock (
-- name VARCHAR(64) NOT NULL PRIMARY KEY,
-- lock_until TIMESTAMP NOT NULL,
-- locked_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
-- locked_by VARCHAR(255) NOT NULL
-- );
// 설정
@Configuration
@EnableScheduling
@EnableSchedulerLock(defaultLockAtMostFor = "10m")
public class ShedLockConfig {
// JDBC 기반 LockProvider
@Bean
public LockProvider lockProvider(DataSource dataSource) {
return new JdbcTemplateLockProvider(
JdbcTemplateLockProvider.Configuration.builder()
.withJdbcTemplate(new JdbcTemplate(dataSource))
.usingDbTime() // DB 시간 사용 (인스턴스 간 시간 차이 방지)
.build()
);
}
// Redis 기반 LockProvider (대안)
// @Bean
// public LockProvider lockProvider(RedisConnectionFactory factory) {
// return new RedisLockProvider(factory);
// }
}
@Service
@Slf4j
public class ScheduledTasks {
// lockAtMostFor: 락 최대 유지 시간 (장애 시 자동 해제)
// lockAtLeastFor: 락 최소 유지 시간 (빠른 완료 시에도 재실행 방지)
@Scheduled(cron = "0 0 9 * * MON-FRI")
@SchedulerLock(
name = "dailyReport",
lockAtMostFor = "30m", // 최대 30분 락
lockAtLeastFor = "5m" // 최소 5분 락 (빠른 완료 시에도)
)
public void sendDailyReport() {
log.info("일일 리포트 생성 시작");
reportService.generateAndSend();
}
@Scheduled(fixedRate = 60000)
@SchedulerLock(name = "processQueue", lockAtMostFor = "55s")
public void processQueue() {
// 60초 주기, 락 최대 55초 → 다음 주기와 겹치지 않음
queueProcessor.process();
}
@Scheduled(cron = "0 0 2 * * *")
@SchedulerLock(name = "cleanup", lockAtMostFor = "2h", lockAtLeastFor = "30m")
public void cleanupExpiredData() {
cleanupService.removeExpired();
}
}
동적 스케줄 변경
@Scheduled의 cron 표현식은 컴파일 타임에 고정됩니다. 런타임에 스케줄을 변경하려면 SchedulingConfigurer를 사용합니다. Spring WebClient로 관리 API를 호출하여 스케줄을 변경할 수 있습니다.
@Configuration
@EnableScheduling
public class DynamicScheduleConfig implements SchedulingConfigurer {
@Autowired
private ScheduleRepository scheduleRepository;
@Override
public void configureTasks(ScheduledTaskRegistrar taskRegistrar) {
taskRegistrar.addTriggerTask(
// 실행할 작업
() -> {
log.info("동적 스케줄 작업 실행");
reportService.generate();
},
// 다음 실행 시간 결정 (매번 DB에서 조회)
triggerContext -> {
String cronExpression = scheduleRepository
.findByName("report")
.map(ScheduleEntity::getCronExpression)
.orElse("0 0 9 * * MON-FRI"); // 기본값
return new CronTrigger(cronExpression)
.nextExecution(triggerContext);
}
);
}
}
// 관리 API: 런타임에 cron 변경
@RestController
@RequestMapping("/admin/schedules")
public class ScheduleController {
@PutMapping("/{name}")
public void updateSchedule(
@PathVariable String name,
@RequestBody UpdateScheduleRequest request) {
// cron 표현식 유효성 검증
try {
CronExpression.parse(request.getCronExpression());
} catch (IllegalArgumentException e) {
throw new BadRequestException("Invalid cron: " + request.getCronExpression());
}
scheduleRepository.updateCron(name, request.getCronExpression());
}
}
TaskScheduler로 프로그래밍 방식 스케줄링
런타임에 동적으로 작업을 등록하고 취소하는 패턴입니다.
@Service
@RequiredArgsConstructor
public class DynamicTaskService {
private final TaskScheduler taskScheduler;
private final Map<String, ScheduledFuture<?>> scheduledTasks = new ConcurrentHashMap<>();
// 동적 작업 등록
public void scheduleTask(String taskId, String cronExpression, Runnable task) {
// 기존 작업 취소
cancelTask(taskId);
ScheduledFuture<?> future = taskScheduler.schedule(
task,
new CronTrigger(cronExpression)
);
scheduledTasks.put(taskId, future);
log.info("작업 등록: {} [{}]", taskId, cronExpression);
}
// 일회성 지연 실행
public void scheduleOnce(String taskId, Runnable task, Duration delay) {
ScheduledFuture<?> future = taskScheduler.schedule(
task,
Instant.now().plus(delay)
);
scheduledTasks.put(taskId, future);
}
// 작업 취소
public boolean cancelTask(String taskId) {
ScheduledFuture<?> future = scheduledTasks.remove(taskId);
if (future != null) {
future.cancel(false); // false: 현재 실행 중인 건 완료까지 대기
log.info("작업 취소: {}", taskId);
return true;
}
return false;
}
// 등록된 작업 목록
public Set<String> getActiveTaskIds() {
return scheduledTasks.entrySet().stream()
.filter(e -> !e.getValue().isCancelled())
.map(Map.Entry::getKey)
.collect(Collectors.toSet());
}
}
에러 처리와 모니터링
@Scheduled 메서드에서 예외가 발생하면 기본적으로 로그만 남기고 다음 실행에 영향을 주지 않습니다. 하지만 운영에서는 체계적인 에러 처리가 필요합니다. Spring Actuator와 연동하면 스케줄 작업의 상태를 모니터링할 수 있습니다.
// AOP로 스케줄 작업 래핑
@Aspect
@Component
@RequiredArgsConstructor
@Slf4j
public class ScheduledTaskAspect {
private final MeterRegistry meterRegistry;
private final AlertService alertService;
@Around("@annotation(org.springframework.scheduling.annotation.Scheduled)")
public Object monitorScheduledTask(ProceedingJoinPoint joinPoint) throws Throwable {
String taskName = joinPoint.getSignature().getName();
Timer.Sample sample = Timer.start(meterRegistry);
try {
Object result = joinPoint.proceed();
sample.stop(Timer.builder("scheduled.task.duration")
.tag("task", taskName)
.tag("status", "success")
.register(meterRegistry));
meterRegistry.counter("scheduled.task.executions",
"task", taskName, "status", "success").increment();
return result;
} catch (Exception e) {
sample.stop(Timer.builder("scheduled.task.duration")
.tag("task", taskName)
.tag("status", "error")
.register(meterRegistry));
meterRegistry.counter("scheduled.task.executions",
"task", taskName, "status", "error").increment();
log.error("스케줄 작업 실패 [{}]: {}", taskName, e.getMessage(), e);
// 연속 실패 시 알림
alertService.notifyIfThresholdReached(taskName, e);
throw e; // 또는 삼켜서 다음 실행에 영향 없게
}
}
}
정리
Spring Scheduler는 @Scheduled의 단순함과 TaskScheduler의 유연함을 겸비한 스케줄링 프레임워크입니다. 프로덕션에서는 반드시 ShedLock으로 다중 인스턴스 중복 실행을 방지하고, 스레드 풀 크기를 적절히 설정하세요. SchedulingConfigurer로 런타임 cron 변경을 지원하고, AOP로 실행 시간·성공률을 모니터링하면 안정적인 배치 인프라가 완성됩니다. 가장 흔한 실수는 기본 스레드 풀 크기 1입니다. 여러 작업이 동시에 실행되어야 한다면 반드시 풀 사이즈를 늘려야 합니다.