Spring Quartz 분산 스케줄링

Spring Quartz란?

Spring의 @Scheduled는 단일 인스턴스에서 간단한 스케줄링에 적합하지만, 다중 인스턴스 환경에서는 중복 실행 문제가 발생합니다. Quartz Scheduler는 JDBC 기반 클러스터링으로 분산 환경에서도 정확히 한 번만 실행을 보장하며, 동적 Job 등록·수정·삭제, 미실행 보상(Misfire), 다양한 트리거 전략을 지원합니다. Spring Boot와의 자동 설정 통합으로 복잡한 스케줄링 요구사항을 안정적으로 처리할 수 있습니다.

@Scheduled vs Quartz

항목 @Scheduled Quartz
클러스터링 미지원 (ShedLock 필요) JDBC 클러스터 내장
동적 Job 관리 불가 런타임 CRUD
Misfire 전략 없음 3가지 정책
영속화 메모리 DB (JDBC JobStore)
설정 복잡도 낮음 중간

프로젝트 설정

// build.gradle.kts (Spring Boot 3.x)
dependencies {
    implementation("org.springframework.boot:spring-boot-starter-quartz")
    implementation("org.springframework.boot:spring-boot-starter-data-jpa")
    runtimeOnly("org.postgresql:postgresql")
}

application.yml 핵심 설정

spring:
  quartz:
    job-store-type: jdbc              # DB 기반 영속화
    jdbc:
      initialize-schema: always       # 테이블 자동 생성
    properties:
      org.quartz:
        scheduler:
          instanceName: my-scheduler
          instanceId: AUTO            # 인스턴스별 고유 ID 자동 생성
        jobStore:
          class: org.quartz.impl.jdbcjobstore.JobStoreTX
          driverDelegateClass: org.quartz.impl.jdbcjobstore.PostgreSQLDelegate
          tablePrefix: QRTZ_
          isClustered: true           # 클러스터 모드
          clusterCheckinInterval: 15000  # 15초 주기 헬스체크
          misfireThreshold: 60000     # 60초 이내 지연은 정상
        threadPool:
          threadCount: 10
          threadPriority: 5

Job 클래스 구현

Quartz Job은 QuartzJobBean을 상속하며, Spring DI를 그대로 사용할 수 있습니다.

// 주문 정산 배치 Job
@Component
@DisallowConcurrentExecution  // 동시 실행 방지
@PersistJobDataAfterExecution // JobDataMap 변경 영속화
public class OrderSettlementJob extends QuartzJobBean {

    private final OrderService orderService;
    private final MeterRegistry meterRegistry;

    public OrderSettlementJob(OrderService orderService,
                               MeterRegistry meterRegistry) {
        this.orderService = orderService;
        this.meterRegistry = meterRegistry;
    }

    @Override
    protected void executeInternal(JobExecutionContext context)
            throws JobExecutionException {
        JobDataMap dataMap = context.getMergedJobDataMap();
        String batchDate = dataMap.getString("batchDate");
        int batchSize = dataMap.getInt("batchSize");

        try {
            Timer.Sample sample = Timer.start(meterRegistry);

            SettlementResult result = orderService
                .settleOrders(batchDate, batchSize);

            sample.stop(Timer.builder("quartz.job.duration")
                .tag("job", "order-settlement")
                .register(meterRegistry));

            // 결과를 JobDataMap에 저장 (다음 실행에서 참조)
            context.getJobDetail().getJobDataMap()
                .put("lastProcessed", result.getProcessedCount());
            context.getJobDetail().getJobDataMap()
                .put("lastRunAt", Instant.now().toString());

        } catch (Exception e) {
            throw new JobExecutionException(
                "정산 Job 실패: " + batchDate, e, false);
        }
    }
}

// 만료 쿠폰 정리 Job
@Component
@DisallowConcurrentExecution
public class ExpiredCouponCleanupJob extends QuartzJobBean {

    private final CouponRepository couponRepo;

    public ExpiredCouponCleanupJob(CouponRepository couponRepo) {
        this.couponRepo = couponRepo;
    }

    @Override
    protected void executeInternal(JobExecutionContext context) {
        int deleted = couponRepo.deleteExpiredBefore(
            LocalDate.now().minusDays(30));
        context.setResult("삭제된 쿠폰: " + deleted);
    }
}

Job + Trigger 등록

Spring Boot 자동 설정으로 JobDetailTrigger 빈을 등록하면 자동 스케줄링됩니다.

@Configuration
public class QuartzJobConfig {

    // ─── 주문 정산: 매일 새벽 2시 ───
    @Bean
    public JobDetail orderSettlementJobDetail() {
        return JobBuilder.newJob(OrderSettlementJob.class)
            .withIdentity("order-settlement", "billing")
            .withDescription("일별 주문 정산 배치")
            .usingJobData("batchSize", 1000)
            .usingJobData("batchDate", "")  // 실행 시 동적 설정
            .storeDurably()                 // 트리거 없어도 유지
            .requestRecovery(true)          // 장애 복구 시 재실행
            .build();
    }

    @Bean
    public Trigger orderSettlementTrigger(
            JobDetail orderSettlementJobDetail) {
        return TriggerBuilder.newTrigger()
            .forJob(orderSettlementJobDetail)
            .withIdentity("order-settlement-trigger", "billing")
            .withSchedule(CronScheduleBuilder
                .cronSchedule("0 0 2 * * ?")           // 매일 02:00
                .withMisfireHandlingInstructionFireAndProceed()
                .inTimeZone(TimeZone.getTimeZone("Asia/Seoul")))
            .build();
    }

    // ─── 쿠폰 정리: 매주 일요일 04:00 ───
    @Bean
    public JobDetail couponCleanupJobDetail() {
        return JobBuilder.newJob(ExpiredCouponCleanupJob.class)
            .withIdentity("coupon-cleanup", "maintenance")
            .withDescription("만료 쿠폰 정리")
            .storeDurably()
            .build();
    }

    @Bean
    public Trigger couponCleanupTrigger(
            JobDetail couponCleanupJobDetail) {
        return TriggerBuilder.newTrigger()
            .forJob(couponCleanupJobDetail)
            .withIdentity("coupon-cleanup-trigger", "maintenance")
            .withSchedule(CronScheduleBuilder
                .cronSchedule("0 0 4 ? * SUN")
                .withMisfireHandlingInstructionDoNothing())
            .build();
    }
}

동적 Job 관리 API

운영 중 Job을 동적으로 등록·수정·삭제하는 관리 API입니다.

@RestController
@RequestMapping("/api/scheduler")
@RequiredArgsConstructor
public class SchedulerController {

    private final Scheduler scheduler;

    // Job 목록 조회
    @GetMapping("/jobs")
    public List<JobInfo> listJobs() throws SchedulerException {
        List<JobInfo> jobs = new ArrayList<>();
        for (String group : scheduler.getJobGroupNames()) {
            for (JobKey key : scheduler.getJobKeys(
                    GroupMatcher.jobGroupEquals(group))) {
                JobDetail detail = scheduler.getJobDetail(key);
                List<? extends Trigger> triggers =
                    scheduler.getTriggersOfJob(key);
                jobs.add(JobInfo.from(detail, triggers));
            }
        }
        return jobs;
    }

    // Cron 스케줄 동적 변경
    @PutMapping("/jobs/{group}/{name}/cron")
    public void updateCron(@PathVariable String group,
                           @PathVariable String name,
                           @RequestBody CronUpdateRequest req)
            throws SchedulerException {
        TriggerKey triggerKey = TriggerKey.triggerKey(
            name + "-trigger", group);
        Trigger oldTrigger = scheduler.getTrigger(triggerKey);

        Trigger newTrigger = TriggerBuilder.newTrigger()
            .forJob(JobKey.jobKey(name, group))
            .withIdentity(triggerKey)
            .withSchedule(CronScheduleBuilder
                .cronSchedule(req.getCronExpression())
                .withMisfireHandlingInstructionFireAndProceed())
            .build();

        scheduler.rescheduleJob(triggerKey, newTrigger);
    }

    // Job 즉시 실행
    @PostMapping("/jobs/{group}/{name}/trigger")
    public void triggerNow(@PathVariable String group,
                           @PathVariable String name,
                           @RequestBody(required = false)
                               Map<String, String> params)
            throws SchedulerException {
        JobDataMap dataMap = new JobDataMap();
        if (params != null) dataMap.putAll(params);
        scheduler.triggerJob(JobKey.jobKey(name, group), dataMap);
    }

    // Job 일시정지
    @PostMapping("/jobs/{group}/{name}/pause")
    public void pause(@PathVariable String group,
                      @PathVariable String name)
            throws SchedulerException {
        scheduler.pauseJob(JobKey.jobKey(name, group));
    }

    // Job 재개
    @PostMapping("/jobs/{group}/{name}/resume")
    public void resume(@PathVariable String group,
                       @PathVariable String name)
            throws SchedulerException {
        scheduler.resumeJob(JobKey.jobKey(name, group));
    }
}

Misfire 전략

서버 다운이나 스레드 부족으로 예정된 실행이 누락되었을 때의 보상 정책입니다.

전략 동작 적합한 경우
FireAndProceed 즉시 1회 실행 후 정상 스케줄 복귀 정산, 리포트 (최소 1회 실행 필요)
DoNothing 건너뛰고 다음 스케줄 대기 정리 작업 (놓쳐도 무방)
IgnoreMisfires 누락된 모든 실행을 순차 보상 모든 실행이 중요한 경우

JobListener: 실행 모니터링

Job 실행 전후에 로깅, 메트릭, 알림을 처리합니다.

@Component
public class JobMonitoringListener implements JobListener {

    private final MeterRegistry meterRegistry;
    private final AlertService alertService;

    public JobMonitoringListener(MeterRegistry meterRegistry,
                                  AlertService alertService) {
        this.meterRegistry = meterRegistry;
        this.alertService = alertService;
    }

    @Override
    public String getName() { return "job-monitoring"; }

    @Override
    public void jobToBeExecuted(JobExecutionContext context) {
        log.info("Job 시작: {} (instance={})",
            context.getJobDetail().getKey(),
            context.getFireInstanceId());
    }

    @Override
    public void jobWasExecuted(JobExecutionContext context,
                                JobExecutionException exception) {
        String jobName = context.getJobDetail().getKey().getName();
        long duration = context.getJobRunTime();

        meterRegistry.timer("quartz.job.execution",
            "job", jobName,
            "result", exception == null ? "success" : "failure"
        ).record(duration, TimeUnit.MILLISECONDS);

        if (exception != null) {
            meterRegistry.counter("quartz.job.failure",
                "job", jobName).increment();
            alertService.sendJobFailureAlert(jobName, exception);
        }
    }

    @Override
    public void jobExecutionVetoed(JobExecutionContext context) {
        log.warn("Job 거부됨: {}", context.getJobDetail().getKey());
    }
}

// Listener 등록
@Configuration
public class QuartzListenerConfig {

    @Bean
    public SchedulerFactoryBeanCustomizer listenerCustomizer(
            JobMonitoringListener listener) {
        return factory -> factory.setGlobalJobListeners(listener);
    }
}

테스트 전략

Spring Boot Test에서 Quartz Job을 직접 실행하여 검증합니다.

@SpringBootTest
class OrderSettlementJobTest {

    @Autowired Scheduler scheduler;
    @Autowired OrderRepository orderRepo;

    @Test
    void settlementJob_processesOrders() throws Exception {
        // given: 미정산 주문 생성
        orderRepo.saveAll(createUnsettledOrders(50));

        // when: Job 즉시 실행
        JobDataMap data = new JobDataMap();
        data.put("batchDate", "2026-03-18");
        data.put("batchSize", 100);

        scheduler.triggerJob(
            JobKey.jobKey("order-settlement", "billing"), data);

        // then: 정산 완료 확인 (비동기 대기)
        await().atMost(Duration.ofSeconds(10))
            .until(() -> orderRepo.countUnsettled() == 0);
    }

    @Test
    void clusterMode_onlyOneInstanceExecutes() throws Exception {
        // 두 Scheduler 인스턴스에서 동일 Job 등록
        // → JDBC Lock으로 하나만 실행됨을 검증
        AtomicInteger executionCount = new AtomicInteger(0);
        // ... 클러스터 테스트 로직
        assertThat(executionCount.get()).isEqualTo(1);
    }
}

운영 체크리스트

항목 확인 사항
isClustered: true 다중 인스턴스 중복 실행 방지
instanceId: AUTO 인스턴스별 고유 ID 자동 생성
requestRecovery(true) 장애 복구 시 미완료 Job 재실행
@DisallowConcurrentExecution 같은 Job 동시 실행 방지
Misfire 전략 선택 Job 특성에 맞는 보상 정책
Actuator 메트릭 실행 시간·실패율 모니터링

마치며

Spring Quartz는 @Scheduled로는 해결할 수 없는 분산 환경 스케줄링, 동적 Job 관리, Misfire 보상을 체계적으로 지원합니다. JDBC JobStore 클러스터링으로 다중 인스턴스에서 정확히 한 번 실행을 보장하고, JobListener로 실행 모니터링을 통합하며, REST API로 운영 중 스케줄을 동적 변경할 수 있습니다. 정산·리포트·정리 같은 주기적 배치 작업의 신뢰성을 확보하는 핵심 인프라입니다.

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