[스프링/Spring] Quartz 스케줄러(1)

dongbrown·2025년 7월 4일

Spring

목록 보기
20/23

현대 웹 애플리케이션에서 배치 작업, 정기적인 데이터 처리, 알림 발송과 같은 스케줄링 작업은 필수적입니다. 이러한 요구사항을 효과적으로 해결할 수 있는 Java 생태계의 표준 스케줄링 라이브러리인 Quartz의 핵심 개념부터 실제 구현까지 체계적으로 알아보겠습니다.

Quartz란 무엇인가?

Quartz는 Java 애플리케이션에서 작업 스케줄링을 위한 강력한 오픈소스 라이브러리입니다. 단순한 시간 기반 작업부터 복잡한 엔터프라이즈급 스케줄링 요구사항까지 모든 것을 처리할 수 있으며, Spring Framework와의 뛰어난 통합성으로 많은 개발자들이 선택하는 솔루션입니다.

주요 특징

  • 강력한 스케줄링 기능: 단순 반복부터 복잡한 cron 표현식까지 지원
  • 클러스터링 지원: 여러 인스턴스 간 작업 분산 처리 가능
  • 지속성: 데이터베이스를 통한 작업 정보 영구 저장
  • 유연한 작업 관리: 동적 작업 추가, 수정, 삭제 지원
  • 강력한 오류 처리: 작업 실패 시 재시도, 복구 메커니즘 제공

Quartz 핵심 구성 요소

Quartz의 아키텍처를 이해하기 위해서는 다음 핵심 클래스와 인터페이스들을 반드시 알아야 합니다.

1. Job - 실행할 작업의 정의

public interface Job {
    void execute(JobExecutionContext context) throws JobExecutionException;
}

Job은 실제로 실행될 작업의 로직을 담고 있는 인터페이스입니다. 모든 작업은 이 인터페이스를 구현해야 합니다.

@Component
public class EmailNotificationJob implements Job {
    
    @Autowired
    private EmailService emailService;
    
    @Override
    public void execute(JobExecutionContext context) throws JobExecutionException {
        // 작업 실행 로직
        JobDataMap dataMap = context.getJobDetail().getJobDataMap();
        String recipient = dataMap.getString("recipient");
        String message = dataMap.getString("message");
        
        try {
            emailService.sendEmail(recipient, message);
            log.info("이메일 전송 완료: {}", recipient);
        } catch (Exception e) {
            throw new JobExecutionException("이메일 전송 실패", e);
        }
    }
}

2. JobDetail - Job의 메타데이터

public interface JobDetail extends Serializable, Cloneable {
    JobKey getKey();
    Class<? extends Job> getJobClass();
    JobDataMap getJobDataMap();
    boolean isDurable();
    // ... 기타 메서드들
}

JobDetail은 Job 클래스의 인스턴스와 실행에 필요한 추가 정보들을 포함하는 클래스입니다. Job의 "설명서" 역할을 한다고 생각하면 됩니다.

// JobDetail 생성 예시
JobDetail jobDetail = JobBuilder.newJob(EmailNotificationJob.class)
    .withIdentity("emailJob", "notification")  // job 이름과 그룹
    .withDescription("일일 이메일 알림 작업")
    .usingJobData("recipient", "user@example.com")  // 작업에 전달할 데이터
    .usingJobData("template", "daily-report")
    .storeDurably(true)  // 트리거가 없어도 저장
    .build();

JobDetail의 주요 속성:

  • Identity: Job을 식별하는 유니크한 키 (이름 + 그룹)
  • JobClass: 실행할 Job 구현 클래스
  • JobDataMap: Job에 전달할 파라미터들
  • Durability: 트리거가 없어도 Job을 저장할지 여부

3. Trigger - 작업 실행 시점 정의

Trigger는 언제, 얼마나 자주 Job을 실행할지를 정의하는 클래스입니다.

public abstract class Trigger implements Serializable, Cloneable {
    public abstract Date getNextFireTime();
    public abstract Date getPreviousFireTime();
    public abstract boolean mayFireAgain();
    // ... 기타 메서드들
}

3-1. SimpleTrigger - 단순 반복 스케줄링

지정된 시간 간격으로 Job을 실행하기 위한 Trigger입니다.

// 매 30초마다 10번 실행
Trigger simpleTrigger = TriggerBuilder.newTrigger()
    .withIdentity("simpleTrigger", "group1")
    .startNow()
    .withSchedule(SimpleScheduleBuilder.simpleSchedule()
        .withIntervalInSeconds(30)
        .withRepeatCount(9))  // 총 10번 실행 (0~9)
    .build();

// 5분 후 시작해서 무한 반복
Trigger delayedTrigger = TriggerBuilder.newTrigger()
    .withIdentity("delayedTrigger", "group1")
    .startAt(new Date(System.currentTimeMillis() + 300000))  // 5분 후
    .withSchedule(SimpleScheduleBuilder.simpleSchedule()
        .withIntervalInMinutes(10)
        .repeatForever())
    .build();

3-2. CronTrigger - Cron 표현식 스케줄링

Unix cron과 유사한 표현식으로 복잡한 스케줄링을 정의할 수 있습니다.

// 매일 오전 9시에 실행
Trigger cronTrigger = TriggerBuilder.newTrigger()
    .withIdentity("dailyReportTrigger", "reports")
    .withSchedule(CronScheduleBuilder.cronSchedule("0 0 9 * * ?"))
    .build();

// 평일 오전 9시부터 오후 6시까지 30분마다 실행
Trigger businessHoursTrigger = TriggerBuilder.newTrigger()
    .withIdentity("businessHoursTrigger", "monitoring")
    .withSchedule(CronScheduleBuilder.cronSchedule("0 0/30 9-18 ? * MON-FRI"))
    .build();

// 매월 마지막 날 오후 11시 30분에 실행
Trigger monthEndTrigger = TriggerBuilder.newTrigger()
    .withIdentity("monthEndTrigger", "reports")
    .withSchedule(CronScheduleBuilder.cronSchedule("0 30 23 L * ?"))
    .build();

Cron 표현식 구조:

초(0-59) 분(0-59) 시(0-23) 일(1-31) 월(1-12) 요일(1-7) [연도]

특수 문자:
* : 모든 값
? : 특정 값 없음 (일/요일에서만 사용)
- : 범위 (예: 1-5)
, : 여러 값 (예: 1,3,5)
/ : 증분 (예: 0/15 = 0,15,30,45)
L : 마지막 (월의 마지막 날, 주의 마지막 요일)
W : 평일 (가장 가까운 평일)
# : N번째 요일 (예: 2#3 = 3번째 화요일)

4. Scheduler - 스케줄링 관리자

Scheduler는 Job과 Trigger를 관리하고 실제 작업 실행을 담당하는 핵심 인터페이스입니다.

public interface Scheduler {
    void start() throws SchedulerException;
    void standby() throws SchedulerException;
    void shutdown(boolean waitForJobsToComplete) throws SchedulerException;
    
    Date scheduleJob(JobDetail jobDetail, Trigger trigger) throws SchedulerException;
    boolean deleteJob(JobKey jobKey) throws SchedulerException;
    void triggerJob(JobKey jobKey) throws SchedulerException;
    
    List<JobExecutionContext> getCurrentlyExecutingJobs() throws SchedulerException;
    // ... 기타 메서드들
}
@Service
public class SchedulerService {
    
    @Autowired
    private Scheduler scheduler;
    
    public void scheduleEmailJob() throws SchedulerException {
        // JobDetail 생성
        JobDetail job = JobBuilder.newJob(EmailNotificationJob.class)
            .withIdentity("dailyEmail", "notifications")
            .build();
            
        // Trigger 생성
        Trigger trigger = TriggerBuilder.newTrigger()
            .withIdentity("dailyEmailTrigger", "notifications")
            .withSchedule(CronScheduleBuilder.cronSchedule("0 0 9 * * ?"))
            .build();
            
        // 스케줄러에 등록
        scheduler.scheduleJob(job, trigger);
        
        log.info("일일 이메일 작업이 스케줄링되었습니다.");
    }
    
    public void pauseJob(String jobName, String groupName) throws SchedulerException {
        JobKey jobKey = JobKey.jobKey(jobName, groupName);
        scheduler.pauseJob(jobKey);
    }
    
    public void resumeJob(String jobName, String groupName) throws SchedulerException {
        JobKey jobKey = JobKey.jobKey(jobName, groupName);
        scheduler.resumeJob(jobKey);
    }
}

5. SchedulerFactory - Scheduler 인스턴스 생성

SchedulerFactory는 Scheduler 인스턴스를 생성하고 구성하기 위한 인터페이스입니다.

public interface SchedulerFactory {
    Scheduler getScheduler() throws SchedulerException;
    Scheduler getScheduler(String schedName) throws SchedulerException;
    Collection<Scheduler> getAllSchedulers() throws SchedulerException;
}

가장 일반적으로 사용되는 구현체는 StdSchedulerFactory입니다:

// 기본 설정으로 Scheduler 생성
SchedulerFactory schedulerFactory = new StdSchedulerFactory();
Scheduler scheduler = schedulerFactory.getScheduler();

// 사용자 정의 설정으로 Scheduler 생성
Properties props = new Properties();
props.put("org.quartz.scheduler.instanceName", "MyScheduler");
props.put("org.quartz.threadPool.threadCount", "10");
props.put("org.quartz.jobStore.class", "org.quartz.impl.jdbcjobstore.JobStoreTX");

SchedulerFactory customFactory = new StdSchedulerFactory(props);
Scheduler customScheduler = customFactory.getScheduler();

Quartz 아키텍처 다이어그램

Scheduler 생명주기

Scheduler는 다음과 같은 생명주기 상태를 가집니다:

1. Created (생성됨)

  • SchedulerFactory를 통해 인스턴스가 생성된 상태
  • 아직 작업이 실행되지 않음
  • isShutdown() = false, isStarted() = false, isInStandbyMode() = true

2. Standby (대기)

  • 스케줄러가 생성되었지만 작업 실행이 일시 중단된 상태
  • Job과 Trigger는 등록되어 있지만 실행되지 않음
  • start() 호출로 실행 상태로 전환 가능

3. Started (실행 중)

  • 스케줄러가 활발하게 작업을 실행하는 상태
  • 등록된 Trigger에 따라 Job들이 실행됨
  • standby() 호출로 일시 중단 가능

4. Shutdown (종료됨)

  • 스케줄러가 완전히 종료된 상태
  • 모든 리소스가 해제됨
  • 재시작 불가능 (새로운 인스턴스 생성 필요)
@Component
public class SchedulerLifecycleManager {
    
    @Autowired
    private Scheduler scheduler;
    
    @PostConstruct
    public void initializeScheduler() throws SchedulerException {
        if (!scheduler.isStarted()) {
            scheduler.start();
            log.info("Scheduler 시작됨: {}", scheduler.getSchedulerName());
        }
    }
    
    @PreDestroy
    public void shutdownScheduler() throws SchedulerException {
        if (scheduler.isStarted() && !scheduler.isShutdown()) {
            scheduler.shutdown(true);  // 실행 중인 작업 완료 후 종료
            log.info("Scheduler 종료됨: {}", scheduler.getSchedulerName());
        }
    }
    
    public void pauseScheduler() throws SchedulerException {
        if (scheduler.isStarted() && !scheduler.isInStandbyMode()) {
            scheduler.standby();
            log.info("Scheduler 일시 중단됨");
        }
    }
    
    public void resumeScheduler() throws SchedulerException {
        if (scheduler.isInStandbyMode()) {
            scheduler.start();
            log.info("Scheduler 재시작됨");
        }
    }
}

Spring Boot와의 통합

Spring Boot에서는 @EnableScheduling과 함께 Quartz를 쉽게 통합할 수 있습니다:

@Configuration
@EnableScheduling
public class QuartzConfig {
    
    @Bean
    public SchedulerFactoryBean schedulerFactoryBean() {
        SchedulerFactoryBean factory = new SchedulerFactoryBean();
        
        // 애플리케이션 컨텍스트 설정 (Spring Bean 주입 가능)
        factory.setApplicationContextSchedulerContextKey("applicationContext");
        
        // 시작 지연 설정
        factory.setStartupDelay(10);
        
        // 종료 시 실행 중인 작업 완료 대기
        factory.setWaitForJobsToCompleteOnShutdown(true);
        
        // 사용자 정의 속성
        Properties quartzProperties = new Properties();
        quartzProperties.put("org.quartz.scheduler.instanceName", "MyAppScheduler");
        quartzProperties.put("org.quartz.scheduler.instanceId", "AUTO");
        quartzProperties.put("org.quartz.threadPool.threadCount", "10");
        quartzProperties.put("org.quartz.threadPool.threadPriority", "5");
        quartzProperties.put("org.quartz.threadPool.threadsInheritContextClassLoaderOfInitializingThread", "true");
        
        factory.setQuartzProperties(quartzProperties);
        
        return factory;
    }
    
    @Bean
    public JobDetail emailJobDetail() {
        return JobBuilder.newJob(EmailNotificationJob.class)
            .withIdentity("emailJob", "notification")
            .withDescription("일일 이메일 알림")
            .storeDurably(true)
            .build();
    }
    
    @Bean
    public Trigger emailJobTrigger() {
        return TriggerBuilder.newTrigger()
            .forJob(emailJobDetail())
            .withIdentity("emailTrigger", "notification")
            .withDescription("매일 오전 9시 실행")
            .withSchedule(CronScheduleBuilder.cronSchedule("0 0 9 * * ?"))
            .build();
    }
}

실전 예제: 데이터 백업 스케줄러

@Component
@Slf4j
public class DatabaseBackupJob implements Job {
    
    @Autowired
    private BackupService backupService;
    
    @Override
    public void execute(JobExecutionContext context) throws JobExecutionException {
        JobDataMap dataMap = context.getJobDetail().getJobDataMap();
        String backupType = dataMap.getString("backupType");
        String targetPath = dataMap.getString("targetPath");
        
        try {
            log.info("데이터베이스 백업 시작 - Type: {}, Path: {}", backupType, targetPath);
            
            BackupResult result = backupService.performBackup(backupType, targetPath);
            
            if (result.isSuccess()) {
                log.info("데이터베이스 백업 완료 - File: {}, Size: {} MB", 
                    result.getFilePath(), result.getFileSizeMB());
            } else {
                throw new JobExecutionException("백업 실패: " + result.getErrorMessage());
            }
            
        } catch (Exception e) {
            log.error("데이터베이스 백업 중 오류 발생", e);
            
            // 재시도 설정
            JobExecutionException jee = new JobExecutionException("백업 실패", e);
            jee.setRefireImmediately(false);  // 즉시 재시도 안함
            throw jee;
        }
    }
}

@Configuration
public class BackupSchedulerConfig {
    
    @Bean
    public JobDetail backupJobDetail() {
        return JobBuilder.newJob(DatabaseBackupJob.class)
            .withIdentity("databaseBackup", "maintenance")
            .withDescription("일일 데이터베이스 백업")
            .usingJobData("backupType", "full")
            .usingJobData("targetPath", "/backup/database")
            .storeDurably(true)
            .requestRecovery(true)  // 시스템 재시작 시 복구
            .build();
    }
    
    @Bean
    public Trigger backupJobTrigger() {
        return TriggerBuilder.newTrigger()
            .forJob(backupJobDetail())
            .withIdentity("backupTrigger", "maintenance")
            .withDescription("매일 새벽 2시 백업 실행")
            .withSchedule(CronScheduleBuilder.cronSchedule("0 0 2 * * ?")
                .withMisfireHandlingInstructionFireAndProceed())  // 누락 시 즉시 실행
            .build();
    }
}

핵심 개념 정리

  1. Job: 실행할 작업의 로직을 담은 인터페이스
  2. JobDetail: Job의 메타데이터와 실행 정보를 포함
  3. Trigger: Job의 실행 시점과 주기를 정의
  4. SimpleTrigger: 단순 반복 스케줄링
  5. CronTrigger: 복잡한 cron 표현식 스케줄링
  6. Scheduler: Job과 Trigger를 관리하는 핵심 인터페이스
  7. SchedulerFactory: Scheduler 인스턴스 생성 팩토리

다음 편에서는 실제 운영 환경에서 Scheduler를 관리하는 시스템 구현, 모니터링, 에러 처리 등 더 깊이 있는 내용을 다뤄보겠습니다.

0개의 댓글