Spring Scheduler 분산 락

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입니다. 여러 작업이 동시에 실행되어야 한다면 반드시 풀 사이즈를 늘려야 합니다.

위로 스크롤
WordPress Appliance - Powered by TurnKey Linux