#SpringBoot #Quartz #스케줄러

Spring Boot Quartz 스케줄러 사용법과 컴포넌트 구조

Job은 얇게, 실제 업무는 Service로: 유지보수하기 쉬운 정기 배치 구성

작성: 2026-08-20

Quartz를 쓰면 무엇이 달라질까?

단순히 한 서버에서 정해진 시간마다 메서드 하나를 실행한다면 Spring의 @Scheduled만으로도 충분합니다. 반면 실행 일정을 런타임에 바꾸거나, 실패 후 재실행 정책이 필요하거나, 여러 서버가 같은 스케줄을 공유해야 한다면 Quartz가 더 잘 맞습니다. Quartz는 “무슨 일을 할지”와 “언제 실행할지”를 분리합니다. 그래서 같은 Job에 서로 다른 Trigger를 연결하거나, Job은 그대로 두고 Cron 표현식만 교체할 수 있습니다.

핵심 실행 흐름

Scheduler → Trigger → JobDetail → Job → Service → Repository

  • Scheduler: Job과 Trigger를 등록·일시정지·재개·삭제하는 실행 엔진입니다.
  • Trigger: 실행 시점을 정의합니다. 반복 간격은 SimpleTrigger, 달력 기반 일정은 CronTrigger가 대표적입니다.
  • JobDetail: 실행할 Job 클래스, 이름, 그룹, JobDataMap 같은 메타데이터를 담습니다.
  • Job: 스케줄이 발화될 때 Quartz 스레드가 호출하는 진입점입니다.
  • Service: 조회, 정산, 알림 발송처럼 테스트해야 할 실제 비즈니스 로직을 담당합니다.

추천 패키지와 컴포넌트 구조

예제는 매일 새벽 2시에 전날의 주문 리포트를 생성하는 상황입니다. 스케줄 정의와 업무 로직을 아래처럼 나누면 실행 시각이 바뀌어도 Service는 건드리지 않고, 리포트 생성 방식이 바뀌어도 Quartz 설정은 건드리지 않아도 됩니다.

com.example.batch
├─ config
│  └─ QuartzConfig.java          # JobDetail, Trigger 빈 등록
├─ job
│  └─ DailyReportJob.java        # Quartz 실행 진입점
├─ service
│  └─ DailyReportService.java    # 실제 리포트 생성 로직
└─ repository
   └─ OrderRepository.java       # 데이터 조회

Job 안에 긴 SQL, 외부 API 호출, 복잡한 분기까지 모두 넣으면 단위 테스트가 어려워집니다. Job은 실행 로그와 파라미터 확인, Service 호출까지만 담당하게 두는 편이 안전합니다.

1. 의존성과 기본 설정 추가

Spring Boot에서는 Quartz 스타터를 추가하면 Scheduler가 자동 구성됩니다. Gradle 기준 의존성은 한 줄이면 됩니다.

dependencies {
    implementation 'org.springframework.boot:spring-boot-starter-quartz'
}

처음에는 메모리 저장소로 동작을 확인하고, 운영 환경에서는 뒤에서 설명할 JDBC JobStore로 전환하는 흐름이 이해하기 쉽습니다.

spring:
  quartz:
    scheduler-name: order-report-scheduler
    wait-for-jobs-to-complete-on-shutdown: true
    properties:
      org.quartz.threadPool.threadCount: 5

2. 실제 업무를 담당하는 Service 작성

Service는 Quartz 타입을 몰라도 되게 작성합니다. 그러면 웹 요청, 관리자 수동 실행, 통합 테스트에서도 같은 로직을 재사용할 수 있습니다.

package com.example.batch.service;

import java.time.LocalDate;
import org.springframework.stereotype.Service;

@Service
public class DailyReportService {

    public void createReport(LocalDate targetDate) {
        // 주문 조회 → 집계 → 파일 저장 또는 알림 전송
        System.out.println("리포트 생성: " + targetDate);
    }
}

3. Quartz Job은 얇게 작성

QuartzJobBean을 상속하고 executeInternal에서 Service를 호출합니다. 같은 Job이 이전 실행을 끝내기 전에 다시 시작되면 중복 집계가 생길 수 있으므로 @DisallowConcurrentExecution도 붙였습니다. 이 제한은 Job 클래스 전체가 아니라 같은 JobKey로 등록된 JobDetail에 적용된다는 점을 기억하세요.

package com.example.batch.job;

import java.time.LocalDate;
import org.quartz.DisallowConcurrentExecution;
import org.quartz.JobExecutionContext;
import org.springframework.scheduling.quartz.QuartzJobBean;
import com.example.batch.service.DailyReportService;

@DisallowConcurrentExecution
public class DailyReportJob extends QuartzJobBean {

    private DailyReportService dailyReportService;

    public void setDailyReportService(DailyReportService dailyReportService) {
        this.dailyReportService = dailyReportService;
    }

    @Override
    protected void executeInternal(JobExecutionContext context) {
        LocalDate targetDate = LocalDate.now().minusDays(1);
        dailyReportService.createReport(targetDate);
    }
}

Spring Boot의 Quartz 구성은 Job 속성의 setter를 통해 일반 Spring Bean을 주입할 수 있습니다. JobDataMap 값도 같은 방식으로 받을 수 있지만, DB 연결 객체나 Service 자체를 JobDataMap에 넣는 방식은 피하는 것이 좋습니다. 영속 JobStore에서는 직렬화 문제로 이어질 수 있기 때문입니다.

4. JobDetail과 CronTrigger를 Bean으로 연결

Spring Boot는 컨텍스트에 등록된 JobDetail과 Trigger Bean을 찾아 Scheduler에 연결합니다. 아래 Cron 표현식 0 0 2 * * ?는 매일 02:00:00을 뜻합니다. 서버 기본 시간대에 기대지 않고 Asia/Seoul을 명시하면 배포 지역이 바뀌어도 실행 시각이 흔들리지 않습니다.

package com.example.batch.config;

import java.util.TimeZone;
import org.quartz.CronScheduleBuilder;
import org.quartz.JobBuilder;
import org.quartz.JobDetail;
import org.quartz.Trigger;
import org.quartz.TriggerBuilder;
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import com.example.batch.job.DailyReportJob;

@Configuration
public class QuartzConfig {

    @Bean
    public JobDetail dailyReportJobDetail() {
        return JobBuilder.newJob(DailyReportJob.class)
                .withIdentity("dailyReportJob", "report")
                .withDescription("전날 주문 리포트 생성")
                .storeDurably()
                .build();
    }

    @Bean
    public Trigger dailyReportTrigger(
            @Qualifier("dailyReportJobDetail") JobDetail jobDetail) {
        return TriggerBuilder.newTrigger()
                .forJob(jobDetail)
                .withIdentity("dailyReportTrigger", "report")
                .withSchedule(CronScheduleBuilder
                        .cronSchedule("0 0 2 * * ?")
                        .inTimeZone(TimeZone.getTimeZone("Asia/Seoul"))
                        .withMisfireHandlingInstructionDoNothing())
                .build();
    }
}

Misfire는 서버 중단이나 스레드 부족으로 예정 시각을 놓친 상황입니다. 예제의 DoNothing은 놓친 실행을 즉시 보충하지 않고 다음 일정부터 재개합니다. 결제 정산처럼 빠진 회차를 반드시 처리해야 하는 업무라면 정책을 그대로 복사하지 말고, 데이터 기준의 재처리와 멱등성까지 함께 설계해야 합니다.

운영 환경에서는 JDBC JobStore 검토

기본 메모리 JobStore는 애플리케이션을 재시작하면 실행 정보가 사라집니다. 일정과 실행 상태를 DB에 보관하거나 여러 인스턴스가 하나의 스케줄을 공유해야 한다면 JDBC 저장소와 클러스터링을 검토합니다.

spring:
  quartz:
    job-store-type: jdbc
    jdbc:
      initialize-schema: never
    overwrite-existing-jobs: true
    properties:
      org.quartz.jobStore.isClustered: true
      org.quartz.scheduler.instanceId: AUTO

운영 DB에서 initialize-schema: always를 켜는 것은 주의해야 합니다. Spring Boot가 제공하는 기본 Quartz 초기화 스크립트는 기존 테이블을 삭제할 수 있어 등록된 Trigger가 사라질 수 있습니다. 테이블 생성은 Flyway나 Liquibase 같은 마이그레이션으로 한 번만 관리하고, 애플리케이션 설정은 never로 두는 방식이 안전합니다. 여러 서버에서 클러스터링할 때는 모든 인스턴스가 같은 Quartz 테이블을 바라보고 서버 시각도 동기화되어야 합니다.

실무 점검 체크리스트

  • Job은 Service 호출만 담당하고, 핵심 로직은 일반 단위 테스트가 가능한 Service에 둡니다.
  • 중복 실행에 취약한 작업은 @DisallowConcurrentExecution과 DB 유니크 키, 처리 상태값으로 이중 방어합니다.
  • Cron 표현식과 TimeZone을 함께 명시하고, 서머타임이 있는 지역이면 경계 날짜를 테스트합니다.
  • 실패 로그에는 JobKey, TriggerKey, fireInstanceId를 남겨 어떤 실행이 실패했는지 추적합니다.
  • JobDataMap에는 문자열·숫자 같은 단순 값만 넣고, 대용량 데이터는 식별자만 전달한 뒤 Service에서 조회합니다.
  • 외부 API 호출과 메일 발송에는 타임아웃을 설정하고, 재시도해도 결과가 중복되지 않는 멱등성을 확보합니다.

정리

Spring Boot Quartz의 기본 구조는 어렵지 않습니다. Trigger가 실행 시각을 결정하고, JobDetail이 실행 대상을 설명하며, Job은 실제 Service를 호출합니다. 이 경계를 지키면 Cron 변경, 수동 재실행, JDBC 전환, 클러스터 확장 같은 요구가 생겨도 수정 범위를 작게 유지할 수 있습니다. 처음에는 메모리 저장소와 단일 Job으로 흐름을 확인한 뒤, 운영 요건에 맞춰 영속화·중복 방지·실패 복구를 추가해 보세요.

참고한 공식 문서