1. 도입: 왜 분산 스케줄링이 필요했나
Vizend 에서 기존에는 각 서비스가 개별적으로 스케줄링을 처리하고 있었습니다. 이 구조는 단순한 환경에서는 문제가 없지만, 서비스가 확장되며 동일 작업이 여러 인스턴스에서 실행되는 중복 실행, 장애 발생 시 스케줄이 유실되는 복구 불가 문제, 서비스마다 스케줄 로직이 분산되어 운영 복잡도 등의 문제가 발생하고 있었습니다.
이러한 문제를 해결하기 위해 시간 기반 작업 실행을 담당하는 스케줄링 마이크로 서비스를 개발하였습니다. 다른 서비스들이 "특정 시각에 이벤트를 발행해달라" 또는 "특정 시각에 API를 호출해달라"는 요청을 보내면, 해당 시각에 정확히 실행합니다. 지정된 시각에 Kafka 토픽으로 이벤트를 발행하거나, 외부 HTTP API를 호출할 수 있습니다. 또한 반복 스케줄(초/분/시/일/주/월/년 단위)과 일회성 스케줄을 모두 지원합니다. 예를 들어, "매일 오전 9시에 청구서 알림 이벤트를 발행"하거나, "2024년 12월 31일 자정에 정산 API를 한 번 호출" 등입니다. 추가적으로 동적 스케줄 등록/수정/삭제 (REST API 기반), 분산 환경에서 중복 없이 단일 실행 보장, 장애 발생 시 자동 복구도 지원하는 것을 목표로 개발하였습니다.
2. 기술 선택: 왜 Quartz JDBC Clustering인가
Spring의 @Scheduled 어노테이션을 활용한 단순한 스케줄링을 고려했지만 한계가 있었습니다. 중복 실행, 장애 복구 불가, 동적 스케줄 관리 불가의 3가지 이유로 다른 대안을 검토하였습니다. 분산 스케줄링을 구현하기 위해 찾은 기술 스택은 ShedLock, db-scheduler, Spring Batch, Quartz Scheduler였습니다. ShedLock은 단순 락 기반으로, 복잡한 스케줄 표현이 어려웠습니다. db-scheduler는 경량이지만 트리거 표현이 제한되었고, Spring Batch는 배치 중심으로 실시간 스케줄링에는 부적합했습니다.
최종적으로 Quartz Scheduler의 JDBC Clustering 모드를 선택한 이유는 다음과 같습니다.
-
JDBC 기반 클러스터 조율: 별도의 ZooKeeper나 Redis 없이, 이미 사용 중인 RDBMS만으로 클러스터 노드 간 조율이 가능합니다.
-
동적 스케줄 관리: 런타임에 Job을 등록/수정/삭제할 수 있어, REST API를 통한 스케줄 관리가 가능합니다.
-
풍부한 트리거 타입: CronTrigger, SimpleTrigger, CalendarIntervalTrigger 등 다양한 반복 패턴을 네이티브로 지원합니다.
-
Misfire 처리: 네트워크 지연이나 노드 장애로 놓친 작업에 대한 정책(즉시 실행, 무시, 재스케줄 등)을 세밀하게 제어할 수 있습니다.
-
Spring Boot 통합: spring-boot-starter-quartz를 통해 자동 설정이 제공되어, 초기 셋업이 비교적 간결하다는 장점이 있습니다.
추가 인프라 없이도 분산 환경에서 안전한 스케줄링이 가능한 Quartz JDBC Clustering을 사용하였습니다.
3. 설계: Application과 Quartz의 역할 분리
Application과 Quartz의 책임을 명확히 분리한 구조를 채택한 것이 핵심입니다. Client에서 Schedule 관련한 스케줄 정보를 생성하여 전달하면, DB에 저장하며 등록 도메인 이벤트를 발행합니다. 이후 도메인 이벤트를 수신하여, 해당 스케줄 정보를 기반으로 Quartz에 등록할 Job과 Trigger를 생성하고 Scheduler에 반영합니다. Quartz에 등록된 스케줄은 Quartz Scheduler 내부의 동작에 의해 처리되며, App에서는 실행 컨텍스트(Context) 관리, Log 등록 등의 기능을 담당합니다.
요약하면 Application은 스케줄 정의/로그만 관리하고 Quartz는 스케줄 실행을 담당합니다.
4. 구현 핵심
4.1 클러스터 설정
Quartz 클러스터링(분산 스케줄링)의 핵심은 application.yml 설정입니다. 여러 개의 pod에 있는 스케줄링 서비스들이 동일한 DB를 바라보면서, DB 락을 통해 작업 실행을 분산 조율합니다.
spring:
quartz:
job-store-type: jdbc
jdbc:
initialize-schema: never # DB Schema를 별도로 관리하는 경우 (예시: Flyway)
properties:
org.quartz:
scheduler:
instanceName: SchedulerName
instanceId: AUTO # 노드마다 고유 ID 자동 생성
jobStore:
class: LocalDataSourceJobStore
isClustered: true # 클러스터 모드 활성화
tablePrefix: qrtz_ # 테이블 prefix 설정(vendor별 대소문자 특징 주의)
clusterCheckinInterval: 10000 # 10초마다 하트비트
misfireThreshold: 60000 # 60초 이내 지연은 허용
threadPool:
class: SimpleThreadPool
threadCount: 10 # 노드당 동시 실행 10개
threadPriority: 5
각 설정 항목의 의미를 살펴보면:
-
isClustered: 클러스터 모드를 활성화/비활성화합니다. 이 설정을 키면 Quartz는 DB의 QRTZ_LOCKS 테이블을 통해 분산 락을 획득하고, QRTZ_SCHEDULER_STATE 테이블에 주기적으로 하트비트를 기록합니다.
-
instanceId: AUTO: 각 Pod가 시작될 때 고유한 인스턴스 ID를 자동 생성합니다. K8s 환경에서는 Pod마다 다른 ID가 부여되어, 클러스터 내에서 각 노드를 식별할 수 있습니다.
-
clusterCheckinInterval: 10000: 10초마다 하트비트를 DB에 기록합니다. 다른 노드는 이 하트비트를 확인하여 특정 노드의 생존 여부를 판단합니다. 노드가 이 간격의 수 배 이상 응답하지 않으면, 해당 노드는 죽은 것으로 간주되어 해당 노드가 점유하던 작업이 다른 노드로 이전됩니다.
-
misfireThreshold: 60000: 예정된 실행 시각에서 60초 이내의 지연은 정상으로 간주합니다.
-
threadCount: 10: 한 노드에서 최대 10개의 Job을 동시에 실행할 수 있습니다. 전체 클러스터의 최대 동시 처리량은 (노드 수 × 10)으로 결정됩니다.
클러스터링이 동작하려면 Quartz 전용 테이블이 DB에 존재해야 합니다.
(참고 – ddl) https://github.com/quartznet/quartznet/tree/main/database/tables
전체적으로 약 11개의 Quartz 테이블이 필요한데, 저는 Flyway 마이그레이션 스크립트로 서비스 배포 시 자동 생성되도록 구성했습니다.
4.2 Trigger 전략
Quartz에 Job(스케줄)을 등록할 때, 초 단위부터 년 단위까지의 다양한 반복 스케줄 설정을 위해서는 단순한 Trigger로는 한계가 있습니다. cron 표현식만으로는 다음과 같은 패턴을 정확히 표현하기 어렵습니다. "매 3일마다"를 정확히 표현할 수 없으며 (매월 1일, 4일, 7일... 식으로 날짜가 고정됨), "매 2주마다"나 "매 3개월마다" 같은 패턴도 cron으로는 표현이 불가능합니다. 또한 DST(서머타임) 전환 시 CronTrigger는 시간을 문자 그대로 해석하여 예기치 않은 동작이 발생할 수 있습니다. 따라서 CalendarIntervalTrigger를 함께 사용하여 달력 기반으로 "N일 후", "N주 후", "N개월 후"를 정확히 계산하였습니다. 추가로 Quartz Trigger에는 스케줄 실행이 실패했을 때의 Misfire 정책을 설정할 수가 있습니다. 대부분의 경우, 놓친 실행이 있으면 즉시 1회 실행한 뒤 다음 정규 스케줄을 이어가는 정책을 사용했습니다.
4.3 Smart Rescheduling
App과 Quartz Scheduler 간의 스케줄 동기화는 중요합니다. App에서 관리하는 스케줄 데이터가 변경되었을 때, Quartz의 Scheduler에도 해당 내용을 반영해야 합니다. 트리거를 재등록해야 하는 경우와 그렇지 않은 경우를 정확히 구분하는 것이 중요합니다. 스케줄 엔티티가 수정되면 도메인 이벤트를 발행하고, 이를 통해 Quartz의 스케줄을 다시 등록하거나, 업데이트를 해야 하는 경우를 명확히 구분해야 합니다.
모든 스케줄 수정에 대해 재스케줄링을 한다면, 실행 완료 → 상태 변경 → 재스케줄링 → 실행 → 상태 변경 → ... 의 무한 루프에 빠지게 됩니다. 변경 유형을 분류하여 스케줄 실행에 관련한 필드가 변경되었는지, 관련 없는 필드가 변경되었는지를 잘 판단하여 조건을 짜는 것이 중요합니다.
5. 겪었던 문제와 해결
5.1 중복 실행 방지: 이중 방어
클러스터 환경에서 테스트 중, 동일한 이벤트가 2회 발행되는 현상이 발생하였고, 이벤트 broker의 재전송으로 인해 같은 스케줄이 중복 등록되었습니다. 동일한 Job이 여러 노드에서 동시에 실행되는 것을 방지하기 위해 두 단계의 방어 장치를 적용했습니다. 첫 번째로 Job 클래스에 @DisallowConcurrentExecution 어노테이션 적용입니다.
클러스터 DB 락은 같은 Trigger의 발화 시점에 여러 노드가 동시에 잡아가는 것을 막아 "단 하나의 노드만 실행"을 보장하고, @DisallowConcurrentExecution은 반복 스케줄에서 이전 실행이 아직 끝나지 않았을 때 다음 발화가 시작되는 것을 막아 같은 Job이 동시에 2개 이상 실행되지 않도록 합니다. 두 번째로 로직에서 Quartz에 스케줄 등록 시 중복을 체크하도록 하였습니다.
5.2 반복 스케줄의 "마지막 실행" 판단
반복 스케줄이 종료 조건에 도달했는데도 계속 실행되는 문제가 있었고, 횟수 제한이 있는 스케줄에서 초과 실행이 발생했습니다. 다양한 반복 조건과 함께, 5번 실행 후 종료, 지정한 시간 이후로는 실행 금지 등 다양한 반복 종료 조건도 있어 많은 케이스를 고려해야 했습니다. 핵심은 app에서 관리하는 스케줄 정보와 Quartz에서 자동으로 관리하는 스케줄 정보를 잘 대조하는 것입니다. 종료 판단은 Quartz + Application 레벨 이중 검증입니다. 예를 들어 반복 스케줄의 다음 실행을 체크할 때, Quartz에서 자동으로 계산되는 값(Next Fire Time 등)을 통해 다음 반복 스케줄이 존재하는지를 판단할 수 있습니다. 그 후에 스케줄을 정의할 때 설정한 반복 종료 시간/반복 횟수 등을 체크하여 반복이 종료되었는지 계속되는지를 체크해야 합니다.
5.3 TimeZone 통일
시간을 다룰 때는 항상 TimeZone 설정을 반드시 고려해야 합니다. 다양한 상황에서 타임존 처리가 일관성 없이 관리된다면 사용자와 개발자 모두에게 혼란을 초래할 수 있습니다. 따라서 로직 처리, DB 저장, 이벤트 payload, API Request/Response 등 모든 영역에서 시간 정보를 UTC를 기준으로 처리하였으며, 프론트에서 보여줄 때만 dayjs를 활용하여 browser 시간대로 표시하였습니다. 이를 통해 통일된 시간 관리로 개발자도, 사용자도 헷갈리지 않을 수 있습니다.
6. 결론
Quartz Scheduler의 JDBC Clustering을 적용하면서 가장 크게 느낀 것은 우선 DB 기반 조율의 실용성입니다. 별도의 분산 코디네이터 없이도, RDBMS의 행 단위 락 만으로 충분히 신뢰성 있는 클러스터 스케줄링을 구현할 수 있었습니다. 이미 운영 중인 DB를 활용하므로 인프라 복잡도가 증가하지 않는 점이 가장 큰 이점이었습니다. 다음으로는 이벤트 기반 아키텍처와의 조화였습니다. 도메인 이벤트를 통해 스케줄의 라이프사이클(등록/수정/삭제)을 Quartz와 연동하니, 관심사 분리가 자연스러웠습니다. 다만 이벤트 연쇄로 인한 부작용(무한 루프 등)을 사전에 예측하고 방어하는 것이 중요했습니다. 마지막으로는 다양한 케이스에 대한 대응입니다. 사용 패턴에 맞는 트리거 타입을 분기하고, 다양한 이벤트 종료 조건에 따라 스케줄을 조정하며 이벤트 연쇄로 인한 부작용을 예측하고 방어하는 등 고려해야 할 부분이 많았습니다. 현재 이 문서에서 말씀드린 서비스는 아직 활발하게 사용되기 전입니다. 차후 기능들이 추가되고, 다양한 곳에서 사용되면서 더 많은 문제를 해결하는 과정에서 지속적으로 발전해 나갈 것으로 기대합니다.
Quartz는 2001년부터 개발된 오래된 프로젝트이지만, JDBC 클러스터링이라는 핵심 메커니즘은 여전히 견고하고 실용적입니다. 특히 이미 RDBMS를 사용하고 있는 마이크로서비스 환경에서, 추가 인프라 없이 분산 스케줄링을 구현해야 할 때 충분히 실용적인 선택지라고 생각합니다.
참고 자료
Quartz Scheduler 공식 웹사이트
Spring Quartz 공식 웹사이트
Tistory, Velog 블로그-
Brown