본문으로 건너뛰기

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이 완전히 동일해야 하나의 클러스터 묶음으로 인식됩니다. 반면 instanceIdAUTO로 두어 충돌을 피해야 합니다.

4. 실무 아키텍처 연동 팁 (Pro Tip)

  1. DB 스키마 초기화 주의: spring.quartz.jdbc.initialize-schema: always는 서버 재시작마다 스키마가 초기화되므로 데이터가 날아갈 수 있습니다. 실무에서는 Quartz 공식 홈페이지에서 제공하는 SQL 스크립트를 직접 DB에 실행(never)하는 것을 강력히 권장합니다.
  2. 다중 서버 환경(Clustered): isClustered: true 옵션 덕분에 3대의 API 서버가 떠있더라도, 징후를 감지한 단 1대의 서버에서만 새벽 2시에 배치가 단 1번 실행됩니다.
  3. Misfire 처리: 서버가 내려가서 새벽 2시에 배치를 돌리지 못한 경우(Misfire), 다음 서버 구동 시 즉시 누락된 배치를 실행할지(withMisfireHandlingInstructionFireAndProceed) 스킵할지 운영 정책에 맞게 트러블슈팅을 구성해야 합니다.