개발일지/Quartz

Spring Boot + Quartz Scheduler 실습: 배치와 스케줄러 완전 이해하기

recording or reCoding 2025. 10. 4. 15:00

최근 프로젝트에서 반복적으로 실행해야 하는 작업, 일정한 간격으로 수행되는 백그라운드 작업을 효율적으로 관리할 필요가 있었습니다. 이런 경우 **스케줄러(Scheduler)**와 배치(Batch) 개념을 이해하고 Quartz를 사용하는 것이 핵심입니다. 이번 글에서는 Quartz Scheduler를 Spring Boot에서 활용한 실습 경험을 공유하며, 스케줄러와 배치의 의미, Quartz 선택 이유, 그리고 실습 기능까지 상세히 정리하겠습니다.


스케줄러와 배치의 차이

1️⃣ 스케줄러(Scheduler)

스케줄러는 작업(Job)을 특정 시간이나 간격에 따라 자동으로 실행하는 도구입니다.
주요 특징:

  • 시간 기반 또는 반복 주기 기반 실행 가능
  • Job이 독립적으로 실행
  • 예: 매일 새벽 2시에 로그 백업, 10분마다 실시간 데이터 체크

2️⃣ 배치(Batch)

배치는 대량의 데이터를 한 번에 처리하는 작업을 의미합니다.
주요 특징:

  • 데이터 처리량이 많음
  • 트랜잭션 단위로 처리 가능
  • 예: 매일 전체 회원 데이터 분석, 월말 결산 처리

정리하면, 스케줄러는 “언제 실행할지”를, 배치는 “무엇을 어떻게 처리할지”를 관리하는 역할로 이해하면 쉽습니다.


Quartz를 선택한 이유

Spring Boot에서 스케줄링 기능을 제공하는 방법은 여러 가지가 있습니다.

Spring @Scheduled 간단하고 즉시 사용 가능 복잡한 반복 주기, 동적 Job 등록/삭제 어려움
Quartz 강력한 스케줄링 기능, Job 상태 관리, 반복 주기/동적 Job 등록/삭제 가능 초기 설정이 다소 복잡

이번 프로젝트에서는 동적으로 Job 등록, 삭제, 조회, 반복 주기 설정이 필요했기 때문에 Quartz를 선택했습니다. 특히 반복 횟수 관리, TriggerListener 활용, Timer 상태 갱신 등 실시간으로 Job 상태를 관리하는 데 Quartz가 적합했습니다.


실습 환경 및 요구 사항

  • Java 17+
  • Spring Boot 3.x
  • Quartz 2.x
  • Gradle 또는 Maven 기반 프로젝트

실습 기능 요약

이번 실습에서는 플레이그라운드용 Quartz Scheduler를 구현했습니다. 주요 기능은 다음과 같습니다.

  1. HelloJobs 등록
    • TimerInfo를 기반으로 Job 등록
    • 총 실행 횟수(totalFireCount), 반복 간격(repeatIntervalMs) 설정 가능
  2. 실행 중 Timer 조회
    • 모든 Timer 조회
    • 특정 Timer 조회
  3. Timer 삭제
    • 등록된 Job 삭제 가능
  4. TriggerListener 활용
    • Trigger 발동 시 남은 실행 횟수 차감
    • SchedulerService 통해 Timer 상태 갱신

프로젝트 구조

 
com.scheduler.Quartz
├─ jobs
│ └─ HelloJobs.java // [소스]
├─ playground │
                         ├─ PlaygroundController.java // [소스]
                         │ └─ PlaygroundService.java // [소스]
├─ timeservices │
                           ├─ SchedulerService.java // [소스]
                           └─ SimpleTriggerListener.java // [소스]
├─ util │
            └─ TimerUtils.java // [소스]
└─ info
            └─ TimerInfo.java // [소스]

TimerInfo 구조 및 활용

TimerInfo는 Job을 등록할 때 필요한 설정 DTO입니다.

TimerInfo info = new TimerInfo(); info.setTotalFireCount(5); // 총 실행 횟수
info.setRemainingFireCount(5); // 남은 실행 횟수
info.setRepeatIntervalMs(5000); // 반복 간격 (ms)
info.setInitialOffsetMs(1000); // 최초 시작 지연 시간 (ms)
info.setCallbackData("My callback data"); // Job 내부에서 활용 가능

remainingFireCount는 Trigger가 실행될 때마다 감소하며, TriggerListener에서 관리됩니다.


REST API 활용

MethodEndpoint설명
POST /api/timer/runHelloJob HelloJobs 실행 등록
GET /api/timer 모든 실행 중 Timer 조회
GET /api/timer/{timerId} 특정 Timer 조회
DELETE /api/timer/{timerId} Timer 삭제

예시

등록

POST /api/timer/runHelloJob

 

TriggerLisner를 이용해서 차감 적용 하여 실행시 차감 확인.

조회

GET /api/timer
 
GET /api/timer/HelloJobs

 

삭제
DELETE /api/timer/HelloJobs


동작 원리

  1. PlaygroundService에서 TimerInfo 생성 후 SchedulerService.schedule() 호출
  2. TimerUtils에서 JobDetail과 Trigger 생성 [소스]
  3. Quartz Scheduler에 Job 등록
  4. TriggerListener에서 실행 시 remainingFireCount 차감 후 SchedulerService.updateTimer() 호출
  5. Controller를 통해 Timer 조회/삭제 가능 [그림]

[그림]: Job → Scheduler → TriggerListener → TimerInfo 상태 갱신 흐름

 


코드 리뷰 

Utils 공통 코드 정의 

- buildJobDetail 

  : Quartz에서 제공하는 JobDataMap에 Job에 대한 key,value를 담는다.

key값은 class 의 getSimpleName을 적용.

 

-buildTrigger

 :구성된 JobInfo에 대해 Trigger에 적용하여 해당 시간에 실행하도록 정의.

package com.scheduler.Quartz.util;

import com.scheduler.Quartz.info.TimerInfo;
import org.quartz.*;

import java.util.Date;

public class TimerUtils {

    private TimerUtils() {}

    public static JobDetail buildJobDetail(final Class jobClass, final TimerInfo info) {
        final JobDataMap jobDataMap = new JobDataMap();
        // JobDataMap에 class SimpleName 을 key로 잡고 TimerInfo 내용을 담는다.
        jobDataMap.put(jobClass.getSimpleName(), info);

        return JobBuilder
                .newJob(jobClass)
                .withIdentity(jobClass.getSimpleName())
                .setJobData(jobDataMap)
                .build();
    }

    public static Trigger buildTrigger(final Class jobClass, final TimerInfo info) {

        // 타이머 시간 정보 설정.
        SimpleScheduleBuilder builder = SimpleScheduleBuilder.simpleSchedule().withIntervalInMilliseconds(info.getRepeatIntervalMs());

        // 반복 수행 여부 설정 및 횟수 설정
        if (info.isRunForever()) {
            builder = builder.repeatForever();
        } else {
            builder = builder.withRepeatCount(info.getTotalFireCount() - 1);
        }

        // Trigger 적용.
        return TriggerBuilder
                .newTrigger()
                .withIdentity(jobClass.getSimpleName())
                .withSchedule(builder)
                .startAt(new Date(System.currentTimeMillis() + info.getInitialOffsetMs()))
                .build();
    }
}

 

실제 스케줄러 서비스 

- schedule 서비스 : Util에서 구현한 JobDetail 생성 후 트리거 적용 

-getAllRunningTimers : 모든 Job에 구성된 정보를 조회.

1️⃣ JobKey와 TriggerKey 개념
Quartz에서 Job과 Trigger는 Key로 식별됩니다.


JobKey jobKey = new JobKey("sendEmail", "dailyJobs"); TriggerKey triggerKey = 
new TriggerKey("sendEmailTrigger", "dailyJobs");
name: Job 또는 Trigger 이름
group: 관련 있는 Job/Trigger를 묶는 논리적 그룹 이름
group을 활용하면 관련 작업들을 한 번에 관리하고 조회할 수 있습니다.

JobKey emailJob = new JobKey("sendEmail", "dailyJobs"); JobKey reportJob 
= new JobKey("generateReport", "dailyJobs"); 

JobKey cleanupJob = new JobKey("cleanup", "weeklyJobs");

"dailyJobs" 그룹에는 sendEmail, generateReport Job이 들어갑니다.
"weeklyJobs" 그룹에는 cleanup Job이 들어갑니다.
그룹을 사용하면 다음과 같은 작업이 가능합니다:

2-1. 특정 그룹만 조회

Set<JobKey> dailyJobs = scheduler.getJobKeys(GroupMatcher.jobGroupEquals("dailyJobs"));
"dailyJobs" 그룹에 속한 모든 Job을 조회
관련 있는 Job들을 한 번에 관리 가능
2-2. 전체 Job 조회

Set<JobKey> allJobs = scheduler.getJobKeys(GroupMatcher.anyGroup());
모든 그룹에 있는 Job 조회
Scheduler에 등록된 전체 Job을 확인할 때 유용


3️⃣ 그룹 활용 장점
논리적 묶음: 관련 작업들을 그룹 단위로 관리 가능
효율적 조회/삭제: 특정 그룹만 선택적으로 조회하거나 삭제 가능
코드 가독성 향상: Job을 그룹 단위로 나누면 프로젝트 구조가 명확해짐
Trigger 관리 용이: 그룹 단위 Trigger 조회/제어 가능
예: 매일 실행되는 Job은 dailyJobs, 매주 실행되는 Job은 weeklyJobs처럼 구분

 

 

package com.scheduler.Quartz.timeservices;

import com.scheduler.Quartz.info.TimerInfo;
import com.scheduler.Quartz.util.TimerUtils;
import jakarta.annotation.PostConstruct;
import jakarta.annotation.PreDestroy;
import lombok.RequiredArgsConstructor;
import org.quartz.*;
import org.quartz.impl.matchers.GroupMatcher;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.stereotype.Service;

import java.util.Collections;
import java.util.List;
import java.util.Objects;
import java.util.stream.Collectors;

@Service
@RequiredArgsConstructor
public class SchedulerService {
    private static final Logger LOGGER = LoggerFactory.getLogger(SchedulerService.class);
    private final Scheduler scheduler;

    /**
     * 실제 서비스에서 Utils의 함수를 이용해서  JobDetail 적용 및 트리거 적용
     * @param jobClass
     * @param info
     * @param <T>
     */
    public <T extends Job> void schedule(final Class<T> jobClass, final TimerInfo info) {
        final JobDetail jobDetail = TimerUtils.buildJobDetail(jobClass, info);
        final Trigger trigger = TimerUtils.buildTrigger(jobClass, info);

        try {
            scheduler.scheduleJob(jobDetail, trigger);
        } catch (SchedulerException e) {
            LOGGER.error(e.getMessage(), e);
        }
    }


    /**
     * 현재 등록된 모든 Job 정보 조회
     * @return 실행중인 TimerInfo 리스트
     */
    public List<TimerInfo> getAllRunningTimers() {
        try {

            // 스케줄러에서 모든 Job에 적용된 key를 가져와서 Job 정보를 조회한다.
            return scheduler.getJobKeys(GroupMatcher.anyGroup())
                    .stream()
                    .map(jobKey -> {
                        try {
                            final JobDetail jobDetail = scheduler.getJobDetail(jobKey);
                            return (TimerInfo) jobDetail.getJobDataMap().get(jobKey.getName());
                        } catch (final SchedulerException e) {
                            LOGGER.error(e.getMessage(), e);
                            return null;
                        }
                    })
                    .filter(Objects::nonNull)  // null은 필터처리 나머지 리스트화.
                    .collect(Collectors.toList());
        } catch (final SchedulerException e) {
            LOGGER.error(e.getMessage(), e);
            return Collections.emptyList();
        }
    }

    public TimerInfo getRunningTimer(final String timerId) {
        try {
            final JobDetail jobDetail = scheduler.getJobDetail(new JobKey(timerId));
            if (jobDetail == null) {
                LOGGER.error("Failed to find timer with ID '{}'", timerId);
                return null;
            }

            return (TimerInfo) jobDetail.getJobDataMap().get(timerId);
        } catch (final SchedulerException e) {
            LOGGER.error(e.getMessage(), e);
            return null;
        }
    }

    public void updateTimer(final String timerId, final TimerInfo info) {
        try {
            final JobDetail jobDetail = scheduler.getJobDetail(new JobKey(timerId));
            if (jobDetail == null) {
                LOGGER.error("Failed to find timer with ID '{}'", timerId);
                return;
            }

            jobDetail.getJobDataMap().put(timerId, info);

            scheduler.addJob(jobDetail, true, true);
        } catch (final SchedulerException e) {
            LOGGER.error(e.getMessage(), e);
        }
    }


    public Boolean deleteTimer(final String timerId) {
        try {
            return scheduler.deleteJob(new JobKey(timerId));
        } catch (SchedulerException e) {
            LOGGER.error(e.getMessage(), e);
            return false;
        }
    }


    @PostConstruct
    public void init(){
        try {
            scheduler.start();
            scheduler.getListenerManager().addTriggerListener(new SimpleTriggerListener(this));
        }catch (SchedulerException e){
            LOGGER.error(e.getMessage());
        }

    }

    @PreDestroy
    public void preDestroy(){
        try {
            scheduler.shutdown();
        }catch (SchedulerException e){

        }
    }
}

 

updateTimer를 위한 리스너

- 사용된Trigger의 현재 남아있는 count 개수를 파악 및 update 할 수 있습니다. - TriggerListener 사용.

 

package com.scheduler.Quartz.timeservices;

import com.scheduler.Quartz.info.TimerInfo;
import lombok.RequiredArgsConstructor;
import org.quartz.JobDataMap;
import org.quartz.JobExecutionContext;
import org.quartz.Trigger;
import org.quartz.TriggerListener;


@RequiredArgsConstructor
public class SimpleTriggerListener implements TriggerListener {
    private final SchedulerService schedulerService;

    @Override
    public String getName() {
        return SimpleTriggerListener.class.getSimpleName();
    }

    @Override
    public void triggerFired(Trigger trigger, JobExecutionContext context) {
        final String timerId = trigger.getKey().getName();

        final JobDataMap jobDataMap = context.getJobDetail().getJobDataMap();
        final TimerInfo info = (TimerInfo) jobDataMap.get(timerId);

        if (!info.isRunForever()) {
            int remainingFireCount = info.getRemainingFireCount();
            if (remainingFireCount == 0) {
                return;
            }

            info.setRemainingFireCount(remainingFireCount - 1);
        }

        schedulerService.updateTimer(timerId, info);
    }

    @Override
    public boolean vetoJobExecution(Trigger trigger, JobExecutionContext context) {
        return false;
    }

    @Override
    public void triggerMisfired(Trigger trigger) {

    }

    @Override
    public void triggerComplete(Trigger trigger, JobExecutionContext context, Trigger.CompletedExecutionInstruction triggerInstructionCode) {

    }
}

실습에서 배운 점

  • Quartz Scheduler는 단순 반복 작업뿐 아니라 동적 Job 등록/삭제, 반복 횟수 관리까지 가능
  • TriggerListener를 활용하면 실행 횟수, 상태, Callback 데이터를 실시간으로 관리할 수 있음
  • Spring Boot와 결합하면 REST API를 통해 Job을 외부에서 제어 가능

결론

이번 실습을 통해 스케줄러와 배치의 의미를 확실히 이해하고, Quartz를 활용해 동적 Job 관리와 반복 작업 제어를 직접 구현해 볼 수 있었습니다.
실무에서는 반복 작업, 알람, 백업, 통계 수집 등 다양한 시나리오에 그대로 적용할 수 있으며, 필요에 따라 Job 상태를 DB에 저장하거나, 분산 환경에서도 안전하게 관리할 수 있습니다.