15.5 Spring Batch와 Quartz 스케줄링 연동 실전
Spring 기본 스케줄러인 @Scheduled는 간단한 주기로 실행할 때는 유용하지만, 분산 환경(다중 서버)에서의 중복 실행 방지나 실패 시 재시도, 섬세한 클러스터링 기반 스케줄링이 필요할 때는 Quartz Scheduler 를 사용해야 합니다.
현업에서 대용량 데이터를 다루는 Spring Batch 애플리케이션은 십중팔구 Quartz(또는 Jenkins, Airflow 등)와 연동되어 실행됩니다. 여기서는 가장 대표적으로 사용되는 Spring Batch + Quartz + 클러스터링(DB 기반) 실전 구성 방법을 알아봅니다.
1. Quartz 의존성 및 데이터베이스 설정
1.1 의존성 추가 (build.gradle)
dependencies {
implementation 'org.springframework.boot:spring-boot-starter-batch'
implementation 'org.springframework.boot:spring-boot-starter-quartz' // Quartz 스타터 추가
}
1.2 application.yml 설정 (JDBC JobStore)
Quartz가 스케줄 정보를 메모리(RAM)가 아닌 데이터베이스에 저장(JDBC JobStore)하도록 하여, 다중 서버(Scale-Out) 환경에서도 동일한 배치가 중복 실행되지 않도록 보장(클러스터링)합니다.
spring:
quartz:
job-store-type: jdbc # 메모리가 아닌 DB 기반 스케줄링 관리
jdbc:
initialize-schema: never # 최초 DB 세팅 시에만 always로 실행하거나 직접 스키마 스크립트 실행 권장
properties:
org.quartz.scheduler.instanceName: MyBatchScheduler # 클러스터 노드 간 동일한 이름 설정 필수
org.quartz.scheduler.instanceId: AUTO # 노드별 고유 ID 자동 발급
org.quartz.jobStore.isClustered: true # 다중 서버 클러스터링 활성화
org.quartz.jobStore.clusterCheckinInterval: 20000
org.quartz.jobStore.driverDelegateClass: org.quartz.impl.jdbcjobstore.PostgreSQLDelegate # DB에 맞게 선택 (MySQL은 StdJDBCDelegate)
2. Job과 Trigger 구성
Quartz의 핵심은 JobDetail(무엇을 실행할 것인가)과 Trigger(언제 실행할 것인가)입니다. Spring Batch 단일 Job을 파라미터와 함께 실행시켜 줄 QuartzJobBean 구현체가 필요합니다.
2.1 Batch Job 실행기 (QuartzJobBean 구현)
특정 시간마다 실행되면서 Spring Batch의 JobLauncher를 호출하는 브릿지 역할을 합니다.
@Slf4j
@Component
@RequiredArgsConstructor
public class DailyReportQuartzJob extends QuartzJobBean {
private final JobLauncher jobLauncher;
private final JobLocator jobLocator; // 등록된 Batch Job을 이름으로 찾아오는 역할
@Override
protected void executeInternal(JobExecutionContext context) throws JobExecutionException {
try {
// 1. 실행할 Batch Job 이름 가져오기 (Trigger 구성 시 넘긴 파라미터)
String jobName = context.getMergedJobDataMap().getString("jobName");
Job job = jobLocator.getJob(jobName);
// 2. Batch Job 파라미터 생성 (중복 실행 방지를 위해 시간을 파라미터에 추가)
JobParameters params = new JobParametersBuilder()
.addString("jobID", String.valueOf(System.currentTimeMillis()))
.addString("requestDate", java.time.LocalDate.now().toString())
.toJobParameters();
// 3. Batch Job 실행
log.info("Starting Batch Job: {}", jobName);
JobExecution jobExecution = jobLauncher.run(job, params);
log.info("Batch Job {} finished with status: {}", jobName, jobExecution.getStatus());
} catch (Exception e) {
log.error("Failed to execute Batch Job via Quartz", e);
throw new JobExecutionException("Quartz Job execution failed", e);
}
}
}
2.2 Quartz 스케줄러 빈(Bean) 등록
작성한 DailyReportQuartzJob을 매일 새벽 2시에 실행되도록 등록합니다.
@Configuration
public class QuartzConfig {
public static final String DAILY_REPORT_JOB_NAME = "dailyReportBatchJob";
// 1. JobDetail 설정: 무엇을 실행할지 정의
@Bean
public JobDetailFactoryBean dailyReportJobDetail() {
JobDetailFactoryBean jobDetailFactory = new JobDetailFactoryBean();
jobDetailFactory.setJobClass(DailyReportQuartzJob.class);
jobDetailFactory.setDescription("매일 새벽 2시에 일일 리포트를 생성하는 배치 Job");
jobDetailFactory.setDurability(true); // Trigger가 없어져도 Job 정보는 DB에 유지
// JobDataMap을 통해 Batch Job의 이름을 전달합니다.
Map<String, Object> jobDataMap = new HashMap<>();
jobDataMap.put("jobName", DAILY_REPORT_JOB_NAME);
jobDetailFactory.setJobDataAsMap(jobDataMap);
return jobDetailFactory;
}
// 2. Trigger 설정: 언제 실행할지(Cron 표현식) 정의
@Bean
public CronTriggerFactoryBean dailyReportJobTrigger(@Qualifier("dailyReportJobDetail") JobDetail jobDetail) {
CronTriggerFactoryBean trigger = new CronTriggerFactoryBean();
trigger.setJobDetail(jobDetail);
trigger.setCronExpression("0 0 2 * * ?"); // 매일 새벽 2시 0분 0초
trigger.setDescription("일일 리포트 배치 트리거");
return trigger;
}
}
3. 이중화(Clustering) 환경에서 중복 실행 방지 원리 및 필수 설정
여러 대의 서버(이중화/다중화)에서 Quartz가 동일하게 구동될 때, 매일 새벽 2시에 모든 서버가 동시에 배치를 실행해버리면 데이터가 중복 처리되는 치명적인 문제가 발생합니다. 이를 방지하기 위한 대처법은 다음과 같습니다.
3.1 DB 행 잠금(Row Lock)을 이용한 선점 원리
Quartz는 메모리가 아닌 데이터베이스(QRTZ_LOCKS 등 11개 스키마 테이블) 를 상태 공유 저장소로 사용합니다.
새벽 2시가 되면 모든 서버의 Quartz 인스턴스가 동시에 DB의 QRTZ_LOCKS 테이블에 대해 SELECT ... FOR UPDATE(행 배타락)를 시도합니다.
가장 먼저 Lock을 획득한 단 1대의 서버만이 Trigger를 Fire(실행) 상태로 변경하고 Job을 가져가며, 나머지 서버들은 Lock 획득에 대기/실패하여 해당 스케줄을 건너뛰게 됩니다. 이로써 완벽하게 중복 실행이 방지 됩니다.
3.2 필수 설정 확인 및 추가 어노테이션
// QuartzJobBean 구현체 클래스 상단에 필수 추가
@DisallowConcurrentExecution
@Slf4j
@Component
@RequiredArgsConstructor
public class DailyReportQuartzJob extends QuartzJobBean {
// ... 기존 코드 동일 (executeInternal 등)
}
@DisallowConcurrentExecution: 동일한JobDetail에 대해 여러 인스턴스가 동시에 실행되는 것을 방지합니다. 만약 새벽 2시에 실행된 배치가 새벽 2시 10분까지 안 끝났는데 2시 5분에 또 트리거될 상황이 오더라도, 이전 배치가 끝날 때까지 새로운 실행을 대기시킵니다.- 서버 간 시간 동기화(NTP): 클러스터링을 구성하는 모든 서버 인스턴스의 시스템 시간이 반드시 동기화(NTP 연동)되어 있어야 합니다. 시간이 1초라도 어긋나면 Lock 매커니즘에 문제가 생길 수 있습니다. (권장 오차: 1초 이내)
- 동일한
instanceName:application.yml설정처럼 모든 클러스터 노드는org.quartz.scheduler.instanceName이 완전히 동일해야 하나의 클러스터 묶음으로 인식됩니다. 반면instanceId는AUTO로 두어 충돌을 피해야 합니다.
4. 실무 아키텍처 연동 팁 (Pro Tip)
- DB 스키마 초기화 주의:
spring.quartz.jdbc.initialize-schema: always는 서버 재시작마다 스키마가 초기화되므로 데이터가 날아갈 수 있습니다. 실무에서는 Quartz 공식 홈페이지에서 제공하는 SQL 스크립트를 직접 DB에 실행(never)하는 것을 강력히 권장합니다. - 다중 서버 환경(Clustered):
isClustered: true옵션 덕분에 3대의 API 서버가 떠있더라도, 징후를 감지한 단 1대의 서버에서만 새벽 2시에 배치가 단 1번 실행됩니다. - Misfire 처리: 서버가 내려가서 새벽 2시에 배치를 돌리지 못한 경우(Misfire), 다음 서버 구동 시 즉시 누락된 배치를 실행할지(
withMisfireHandlingInstructionFireAndProceed) 스킵할지 운영 정책에 맞게 트러블슈팅을 구성해야 합니다.