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 자동 설정으로 JobDetail과 Trigger 빈을 등록하면 자동 스케줄링됩니다.
@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로 운영 중 스케줄을 동적 변경할 수 있습니다. 정산·리포트·정리 같은 주기적 배치 작업의 신뢰성을 확보하는 핵심 인프라입니다.