고가용성(HA)과 유사 무중단을 요구하는 고객사 환경에 맞춰 복합 이중화 클러스터링 엔진을 설치했습니다.
그 결과 두 가지 증상이 동시에 발생했습니다.
기존 엔진에는 단순 반복 작업은 Spring Scheduler, DB 영속성이 필요한 작업은 Quartz가 혼용되어 있었습니다. 두 스케줄러의 메커니즘을 면밀히 분석하고, 역할(등록/실행) 분리 + 쿼리 기반 균등 분배로 해결한 과정을 공유합니다.
용어: 이 글에서 각 노드가 처리할 데이터 범위를 키(예:
id)로 강제로 잘라 나눠 갖는 방식은 DB의 물리적 파티셔닝(테이블을 조각으로 저장)이 아니라 애플리케이션 레벨 샤딩(shard 키로 처리 주체를 나눔)에 가깝습니다. 본문은 이 의미로 "샤딩"을 사용합니다.
두 스케줄러 모두 시간 기반(Time-based)이지만, 트리거를 관리·실행하는 주체가 근본적으로 다릅니다.
⚠️ 핵심: Spring의
@Scheduled는 태생이 단일 노드 time-based 작업입니다. 다중화 HA를 이 방식으로 달성하려는 시도 자체가 어렵고, "동시에 깨어나는 노드들"을 전제로 재설계해야 합니다.
QRTZ_LOCKS, QRTZ_TRIGGERS 테이블을 공유합니다.SELECT ... FOR UPDATE)을 시도하고, 가장 먼저 Lock을 획득한 노드만 해당 Job을 실행합니다.기본 클러스터링의 한계를 애플리케이션 레벨 샤딩(shard 키로 처리 주체를 강제 분할) 전략으로 극복했습니다.
Quartz의 Lock 경쟁 대신, 각 Worker가 shard 키 조건으로 자기 몫만 조회·실행합니다.
-- Worker 0 (전체의 25%)
SELECT * FROM job_schedule
WHERE status = 'READY'
AND MOD(id, 4) = 0 -- 노드별 shard 번호 주입
AND next_fire_time <= :now
FOR UPDATE SKIP LOCKED; -- 잠긴 행은 건너뛰어 Worker 간 대기 제거@Value("${scheduler.shard.index}") // 0..3, 배포 시 노드별 주입
private int shardIndex;
@Value("${scheduler.shard.total}") // 4
private int shardTotal;
@Scheduled(fixedDelay = 1000)
public void pollAndRun() {
List<Job> jobs = jobRepository.findDueJobsForShard(shardIndex, shardTotal, Instant.now());
jobs.forEach(this::execute);
}MOD(id,4) = 0..3 으로 각 노드가 정확히 25%씩 처리.name/키로 인덱싱하고 분배 통계 테이블을 만들어, 실측 처리량 기반으로 shard를 재조정하는 방법도 사용 가능.정적 modulo는 노드 수 변경(3→4대)이나 작업량 편중에 취약합니다. 운영 중에는 다음을 주기적으로 조정해야 합니다.
원칙: 분산 스케줄링은 "한 번 나누고 끝"이 아니라, 처리량·리소스 지표를 보고 샤드와 백프레셔를 지속적으로 튜닝하는 제어 루프로 운영합니다.
동일 시각에 다수 노드가 깨어나는 환경에서는 단순 상태 체크(내가 실행할 차례인가?) 로직이 통하지 않습니다. Read–Check–Act 사이에 다른 노드가 끼어들기 때문입니다. 분산이라면 처음부터 '동시 접근'을 전제로 설계해야 합니다.
Quartz 기본 클러스터링은 가용성에 집중되어 있어, 균등 부하 분산까지 기대하면 Greedy Locking에 부딪힙니다.
@Scheduled(time-based)로 다중화 HA를 시도하는 것은 태생적으로 어렵습니다. 비동기 병렬 스레드·Quartz·배치를 다중 노드에서 쓰려면, 규칙적인 샤딩과 백프레셔 조건 조절을 전제해야 합니다.
댓글
GitHub 계정으로 의견이나 질문을 남길 수 있습니다.