3 min read

Part 3. Quartz 클러스터 아키텍처: JobStore, Misfire, 대규모 스케줄 관리

Quartz를 단순 스케줄러가 아니라 운영 제어 시스템으로 활용하기 위한 JobStore, Misfire, 클러스터 설계 기준을 다룬다.

Series: Spring Boot 배치 전략 완전 정복

12편 구성. 현재 3편을 보고 있습니다.

썸네일 - 알람 시계와 스케줄
썸네일 - 알람 시계와 스케줄

출처: Pexels - Black Alarm Clock on Desk

버전 기준

  • Java 21
  • Spring Boot 3.3.x
  • Spring Batch 5.2.x
  • Quartz 2.3.x
  • PostgreSQL 15
  • OpenSearch 2.x

1) 문제 제기

@Scheduled로는 감당하기 어려운 시점이 있다. 대표적으로 다음 조건이다.

  • 작업 수가 수백 개 이상으로 증가한다.
  • 작업별 우선순위, 캘린더 제외일, 재시도 정책이 다르다.
  • 장애 후 "놓친 실행"을 어떤 규칙으로 보정할지 중요하다.

이 지점에서 Quartz는 단순 cron 도구가 아니라 "스케줄 상태 저장소 + 실행 제어기"가 된다. 그러나 Quartz를 도입했다고 자동으로 안정성이 생기지는 않는다. JobStore와 Misfire 정책을 잘못 잡으면 오히려 장애가 확대된다.

2) 핵심 개념 정리

JobStore 선택

JobStore장점단점권장 상황
RAMJobStore빠르고 단순재기동 시 스케줄 유실, 클러스터 불가로컬 개발/테스트
JDBCJobStore영속성, 클러스터 지원, 감사 추적DB 부하/스키마 관리 필요운영 환경 기본 선택

운영 환경에서는 사실상 JDBCJobStore가 표준이다. 클러스터 모드에서 Quartz 인스턴스들은 같은 QRTZ_* 테이블을 공유하고, 락(row lock)으로 실행 권한을 조정한다.

Misfire 정책

Misfire는 "정해진 시점에 실행하지 못한 트리거"를 의미한다. 정책을 잘못 선택하면 장애 복구 시 트래픽 폭탄이 발생한다.

  • MISFIRE_INSTRUCTION_FIRE_NOW: 즉시 실행. 누락 건수를 빠르게 보정하나 급격한 부하 가능.
  • MISFIRE_INSTRUCTION_DO_NOTHING: 다음 스케줄까지 건너뜀. 정합성보다 안정성 우선.
  • 커스텀 재스케줄: 도메인 중요도별로 보정량 제한.

클러스터 다이어그램

Mermaid diagram rendering...

본문 이미지 - 데이터센터 운영자
본문 이미지 - 데이터센터 운영자

출처: Pexels - Engineer beside server racks

3) 코드 예시

예시 A: Quartz 클러스터 설정 (Spring Boot)

spring:
  quartz:
    job-store-type: jdbc
    properties:
      org.quartz.scheduler.instanceName: batchScheduler
      org.quartz.scheduler.instanceId: AUTO
      org.quartz.jobStore.class: org.quartz.impl.jdbcjobstore.JobStoreTX
      org.quartz.jobStore.driverDelegateClass: org.quartz.impl.jdbcjobstore.PostgreSQLDelegate
      org.quartz.jobStore.isClustered: true
      org.quartz.jobStore.clusterCheckinInterval: 15000
      org.quartz.threadPool.threadCount: 20

예시 B: 동시 실행 방지 Job

@DisallowConcurrentExecution
public class ReindexJob implements Job {

    @Override
    public void execute(JobExecutionContext context) throws JobExecutionException {
        String tenantId = context.getMergedJobDataMap().getString("tenantId");
        try {
            // 실제 재색인 로직
            reindexTenant(tenantId);
        } catch (Exception ex) {
            throw new JobExecutionException(ex, true);
        }
    }

    private void reindexTenant(String tenantId) {
        // id-range 또는 search-after 기반으로 분할 실행
    }
}

예시 C: Misfire 모니터링 SQL

SELECT trigger_name,
       trigger_group,
       next_fire_time,
       prev_fire_time,
       misfire_instr
FROM qrtz_triggers
WHERE next_fire_time < EXTRACT(EPOCH FROM NOW()) * 1000
ORDER BY next_fire_time ASC
LIMIT 200;

예시 D: Quartz 실행 이력 키셋 조회

SELECT id, scheduler_name, job_name, status, started_at
FROM batch_job_execution
WHERE scheduler_name = 'quartz'
  AND id > :last_id
ORDER BY id ASC
LIMIT 500;

4) 실제 장애/운영 시나리오

상황: DB 점검으로 25분 다운 이후 Quartz가 복구되자 Misfire 트리거가 1,200건 누적되었다. 정책이 모두 FIRE_NOW라서 복구 직후 동시 실행이 폭증했고, OLTP DB 커넥션 풀이 고갈되며 API 지연까지 발생했다.

분석:

  • 중요한 작업(정산)과 덜 중요한 작업(리포트 생성)에 동일 Misfire 정책을 적용했다.
  • threadCount=50로 높였지만 downstream 제한(DB pool 30)을 고려하지 않았다.
  • 트랜잭션 격리 수준을 REPEATABLE READ로 통일해 락 유지시간이 증가했다.

개선:

  1. 도메인별 Misfire 프로필 분리: 정산은 제한적 FIRE_NOW, 리포트는 DO_NOTHING.
  2. Quartz 실행 스레드와 DB pool, 외부 API QPS를 동일한 한도 모델로 조정.
  3. 실패 재시도는 즉시가 아니라 지수 백오프로 분산.

5) 설계 체크리스트

  • 운영 환경에서 RAMJobStore를 사용하지 않는가?
  • Misfire 정책을 업무 중요도별로 분리했는가?
  • Quartz threadCount와 DB/외부 시스템 용량을 함께 튜닝했는가?
  • @DisallowConcurrentExecution 적용 범위를 검토했는가?
  • 클러스터 체크인 간격과 장애 감지 시간을 수치로 정의했는가?
  • 실행 이력과 누락 건수(Misfire backlog)를 대시보드로 관측하는가?

6) 요약

Quartz는 "정교한 스케줄 제어"가 필요한 순간 강력한 선택지다. 하지만 JobStore, Misfire, 스레드 설정을 비즈니스 정합성과 인프라 한도에 맞추지 않으면 장애를 증폭할 수 있다.

7) 다음 편 예고

다음 편에서는 Spring Batch의 Chunk 처리 모델을 깊게 다룬다. Reader/Processor/Writer의 트랜잭션 경계, 재시작 전략, 대량 데이터 처리에서 왜 Spring Batch가 표준이 되는지 설명한다.

참고 링크

시리즈 네비게이션

댓글