Spring-10) Scheduler - 자동 작업 실행 메커니즘
2025. 11. 21. 09:08

학습 목표

  • Spring Boot의 @EnableScheduling@Scheduled 어노테이션의 동작 원리를 이해한다
  • CRON 표현식을 활용한 스케줄 설정 방법을 학습한다
  • 스케줄러의 내부 작동 순서와 Spring의 자동 관리 메커니즘을 파악한다
  • 실무 프로젝트에서 스케줄러를 활용한 데이터 집계 방법을 익힌다

<Spring Boot 스케줄러>

Spring Boot 스케줄러는 특정 시간에 자동으로 작업을 실행하는 기능이다. 개발자가 직접 스레드를 관리하거나 타이머를 설정할 필요 없이, 어노테이션만으로 반복 작업을 간편하게 구현할 수 있다. 실무에서는 매일 새벽 데이터 집계, 주기적인 로그 정리, 정시 리포트 생성 등의 배치 작업에 활용된다.

스케줄러를 학습하는 것은 단순히 자동화 기능을 사용하는 것을 넘어, Spring Framework의 빈 생명주기와 어노테이션 기반 프로그래밍의 핵심 원리를 이해하는 과정이다. 이러한 이해는 더 복잡한 엔터프라이즈 애플리케이션 개발의 기초가 된다.

- 스케줄러의 필요성

실무 개발 환경에서는 특정 시간에 자동으로 실행되어야 하는 작업들이 존재한다. 예를 들어, 헬스장 관리 시스템에서 매일 새벽 2시에 전날의 출석 데이터를 집계하여 혼잡도 통계를 생성하는 작업이 필요하다고 가정한다.

이러한 작업을 수동으로 처리하면 다음과 같은 문제가 발생한다. 첫째, 개발자가 매일 새벽 2시에 직접 스크립트를 실행해야 한다. 둘째, 스레드와 타이머를 직접 관리하는 복잡한 코드를 작성해야 한다. 셋째, 서버 재시작 시 스케줄 설정이 초기화되어 재설정이 필요하다. 넷째, 예외 처리와 로깅을 모두 수동으로 구현해야 한다.

- Spring Boot 스케줄러의 해결책

Spring Boot는 @EnableScheduling@Scheduled 어노테이션을 통해 선언적 스케줄링을 지원한다. 개발자는 단 두 개의 어노테이션만으로 복잡한 스케줄링 로직을 구현할 수 있다. Spring이 스레드 풀 관리, 작업 실행, 예외 처리, 생명주기 관리를 모두 자동으로 처리한다.

이러한 방식은 설정보다 관례(Convention over Configuration) 원칙에 따라 최소한의 설정으로 최대의 기능을 제공한다. 개발자는 비즈니스 로직에만 집중할 수 있으며, 인프라 레벨의 스케줄링 관리는 프레임워크가 담당한다.


<스케줄러 구성 요소>

- @EnableScheduling 어노테이션

@EnableScheduling은 Spring 애플리케이션에서 스케줄링 기능을 활성화하는 스위치 역할을 한다. 이 어노테이션은 메인 애플리케이션 클래스에 선언되며, Spring 컨테이너에게 스케줄링 관련 빈들을 등록하도록 지시한다.

@SpringBootApplication
@EnableScheduling
public class GymhubApplication {
    public static void main(String[] args) {
        SpringApplication.run(GymhubApplication.class, args);
    }
}

이 어노테이션이 선언되면 Spring은 내부적으로 SchedulingConfiguration 클래스를 활성화한다. SchedulingAnnotationBeanPostProcessorTaskScheduler 빈이 자동으로 등록되며, 이들이 실제 스케줄링 작업을 담당한다.

- @Scheduled 어노테이션

@Scheduled는 실행할 메서드와 실행 시간을 정의하는 어노테이션이다. 이 어노테이션이 붙은 메서드는 Spring 빈의 메서드여야 하며, 반환 타입은 void여야 한다. 파라미터를 받을 수 없다는 제약도 존재한다.

@Service
public class AttCacheServiceImpl implements AttCacheService {

    @Scheduled(cron = "0 0 2 * * ?")
    public void scheduledCongestionCalculation() {
        // 매일 새벽 2시에 실행되는 로직
    }
}

cron 속성 외에도 fixedDelay, fixedRate, initialDelay 등의 속성을 사용할 수 있다. fixedDelay는 이전 작업 종료 후 지정된 시간 대기, fixedRate는 고정된 주기로 실행을 의미한다.

- CRON 표현식

CRON 표현식은 스케줄 실행 시간을 정의하는 문자열 패턴이다. 총 6개 또는 7개의 필드로 구성되며, 각 필드는 공백으로 구분된다.

초  분  시  일  월  요일
0   0   2   *   *   ?

위 표현식은 매일 새벽 2시 0분 0초를 의미한다. 각 필드의 의미는 다음과 같다.

  • : 0-59 범위의 값
  • : 0-59 범위의 값
  • : 0-23 범위의 값
  • : 1-31 범위의 값, *는 모든 일
  • : 1-12 범위의 값, *는 모든 월
  • 요일: 0-7 범위의 값(0과 7은 일요일), ?는 특정 값 없음

특수 문자를 활용하면 더욱 유연한 스케줄을 설정할 수 있다. *는 모든 값을 의미하고, ?는 값을 지정하지 않음을 의미한다. /는 증가값(예: 0/15는 0부터 15씩 증가), -는 범위(예: 10-12는 10시부터 12시), ,는 여러 값 나열(예: MON,WED,FRI)을 표현한다.

- TaskScheduler

TaskScheduler는 실제로 스케줄된 작업을 실행하는 스레드 풀 관리자이다. Spring은 기본적으로 ThreadPoolTaskScheduler를 사용하며, 단일 스레드로 모든 스케줄 작업을 순차 실행한다.

기본 설정은 스레드 풀 크기 1이지만, 필요시 커스터마이징할 수 있다. 여러 @Scheduled 메서드가 동시에 실행될 가능성이 있다면 스레드 풀 크기를 늘리거나 @Async를 함께 사용하여 비동기 처리를 고려해야 한다.


<스케줄러 작동 순서>

- 1단계: 애플리케이션 초기화

Spring Boot 애플리케이션이 시작되면 main() 메서드에서 SpringApplication.run()이 호출된다. 이 시점에 Spring 컨테이너가 생성되고 컴포넌트 스캔이 시작된다.

@SpringBootApplication 어노테이션은 @ComponentScan을 포함하고 있어 지정된 패키지 하위의 모든 @Component, @Service, @Repository, @Controller 어노테이션이 붙은 클래스를 찾아 빈으로 등록한다. 이 과정에서 @EnableScheduling 어노테이션도 함께 감지된다.

- 2단계: 스케줄링 인프라 구축

@EnableScheduling이 감지되면 Spring은 SchedulingConfiguration을 활성화한다. 이 설정 클래스는 스케줄링에 필요한 핵심 빈들을 등록한다.

첫째, SchedulingAnnotationBeanPostProcessor가 생성된다. 이 빈 후처리기는 모든 빈을 스캔하여 @Scheduled 어노테이션을 찾는 역할을 한다. 둘째, TaskScheduler 빈이 생성되고 초기화된다. 기본 구현체인 ThreadPoolTaskScheduler는 단일 스레드 풀을 생성하여 스케줄된 작업을 실행할 준비를 마친다.

- 3단계: @Scheduled 메서드 스캔

SchedulingAnnotationBeanPostProcessor는 빈 생명주기의 postProcessAfterInitialization 단계에서 동작한다. 모든 빈이 생성되고 초기화된 후, 각 빈의 메서드를 검사하여 @Scheduled 어노테이션이 붙은 메서드를 찾는다.

@Service
public class AttCacheServiceImpl implements AttCacheService {

    @Scheduled(cron = "0 0 2 * * ?")
    public void scheduledCongestionCalculation() {
        // 스케줄링 대상 메서드
    }
}

AttCacheServiceImpl 빈이 생성되면 빈 후처리기가 이 클래스의 모든 메서드를 검사한다. scheduledCongestionCalculation() 메서드에 @Scheduled(cron = "0 0 2 * * ?") 어노테이션이 붙어있는 것을 발견하고, 이를 스케줄링 대상으로 등록한다.

- 4단계: CRON 표현식 파싱

스케줄링 대상으로 등록된 메서드의 CRON 표현식이 파싱된다. "0 0 2 * * ?"는 매일 새벽 2시 0분 0초를 의미한다.

Spring은 CronTrigger 객체를 생성하여 이 표현식을 관리한다. CronTrigger는 현재 시간을 기준으로 다음 실행 시간을 계산하는 로직을 포함한다. 예를 들어, 현재 시각이 오전 10시라면 오늘 새벽 2시는 이미 지났으므로 내일 새벽 2시가 다음 실행 시간으로 계산된다.

- 5단계: 스케줄 등록 및 백그라운드 대기

계산된 실행 시간과 함께 작업이 TaskScheduler에 등록된다. TaskScheduler는 내부적으로 ScheduledExecutorService를 사용하여 백그라운드 스레드를 관리한다.

// Spring 내부 동작 개념도
taskScheduler.schedule(() -> {
    scheduledCongestionCalculation();
}, cronTrigger);

백그라운드 스레드는 지정된 시간까지 대기 상태에 들어간다. 메인 애플리케이션 스레드는 블로킹되지 않고 정상적으로 요청을 처리한다. 스케줄러 스레드는 독립적으로 동작하며 지정된 시간이 될 때까지 sleep 상태를 유지한다.

- 6단계: 지정된 시간에 작업 실행

설정된 시간(매일 새벽 2시)이 되면 백그라운드 스레드가 깨어나 scheduledCongestionCalculation() 메서드를 실행한다. 이 시점에 실제 비즈니스 로직이 수행된다.

@Scheduled(cron = "0 0 2 * * ?")
public void scheduledCongestionCalculation() {
    try {
        System.out.println("[혼잡도 집계 스케줄러] 시작: " + new java.util.Date());

        // 1. 모든 헬스장 조회
        List<Gym> gyms = gymService.getAllGyms();

        // 2. 전날 날짜 계산
        Calendar cal = Calendar.getInstance();
        cal.add(Calendar.DAY_OF_MONTH, -1);
        Date yesterday = new Date(cal.getTimeInMillis());

        // 3. 각 헬스장별로 데이터 집계
        for (Gym gym : gyms) {
            calculateAndSaveCongestion(gym.getGymNo(), yesterday);
        }

        System.out.println("[혼잡도 집계 스케줄러] 완료");

    } catch (Exception e) {
        System.err.println("[혼잡도 집계 스케줄러] 오류 발생: " + e.getMessage());
    }
}

작업 실행 중 발생한 예외는 스케줄러에 의해 catch되며, 다음 실행 스케줄에는 영향을 주지 않는다. 하나의 스케줄 작업이 실패해도 다음 스케줄은 정상적으로 실행된다.

- 7단계: 데이터 집계 수행

실제 비즈니스 로직인 데이터 집계가 수행된다. 이 단계에서는 전날의 출석 데이터를 조회하여 시간대별 평균 인원수를 계산하고, 결과를 캐시 테이블에 저장한다.

public int calculateAndSaveCongestion(int gymNo, Date targetDate) {
    String[] timeSlots = {"06-08", "08-10", "10-12", "12-14", 
                          "14-16", "16-18", "18-20", "20-22", "22-24"};

    int savedCount = 0;

    for (String timeSlot : timeSlots) {
        int startHour = Integer.parseInt(timeSlot.substring(0, 2));
        int endHour = Integer.parseInt(timeSlot.substring(3, 5));

        // 시작 시간과 종료 시간 설정
        Calendar startCal = Calendar.getInstance();
        startCal.setTime(targetDate);
        startCal.set(Calendar.HOUR_OF_DAY, startHour);
        Date startDate = new Date(startCal.getTimeInMillis());

        Calendar endCal = Calendar.getInstance();
        endCal.setTime(targetDate);
        endCal.set(Calendar.HOUR_OF_DAY, endHour);
        Date endDate = new Date(endCal.getTimeInMillis());

        // 평균 혼잡도 계산
        Integer avgCount = attCacheMapper.calculateAverageCountByTimeSlot(
                gymNo, startDate, endDate, timeSlot);

        // 캐시 테이블에 저장
        AttCache attCache = new AttCache();
        attCache.setGymNo(gymNo);
        attCache.setCacheDate(targetDate);
        attCache.setTimeSlot(timeSlot);
        attCache.setMemberCount(avgCount != null ? avgCount : 0);

        int result = attCacheMapper.insertAttCache(attCache);
        if (result > 0) {
            savedCount++;
        }
    }

    return savedCount;
}

9개의 시간대(06-08, 08-10, ..., 22-24)를 순회하며 각 시간대별 평균 인원수를 계산한다. 계산 결과는 ATT_CACHE 테이블에 저장되어 이후 조회 성능을 향상시킨다. 전날 데이터를 미리 집계해두면 실시간 집계 부담이 줄어든다.

- 8단계: 다음 실행 예약

작업이 완료되면 CronTrigger가 다시 다음 실행 시간을 계산한다. 현재 시각이 새벽 2시이므로 다음 실행 시간은 내일 새벽 2시로 계산된다. TaskScheduler는 이 정보를 바탕으로 작업을 재등록한다.

// 내부 동작 개념도
public void scheduleNext() {
    Date nextExecution = cronTrigger.nextExecutionTime();
    taskScheduler.schedule(() -> {
        scheduledCongestionCalculation();
        scheduleNext(); // 재귀적 재등록
    }, nextExecution);
}

이러한 방식으로 스케줄러는 무한 반복된다. 애플리케이션이 종료되기 전까지 매일 새벽 2시마다 자동으로 작업이 실행된다. Spring 컨테이너가 종료될 때는 TaskScheduler도 함께 종료되며, 실행 중인 작업이 있다면 완료를 기다린 후 종료된다.


<실무 적용 사례>

- 헬스장 혼잡도 데이터 집계

프로젝트에서는 매일 새벽 2시에 전날의 출석 데이터를 집계하여 시간대별 평균 혼잡도를 계산한다. 이 작업은 사용자가 적은 새벽 시간에 수행되어 서비스 성능에 영향을 주지 않는다.

@Service
public class AttCacheServiceImpl implements AttCacheService {

    @Autowired
    private AttCacheMapper attCacheMapper;

    @Autowired
    private GymService gymService;

    @Scheduled(cron = "0 0 2 * * ?")
    public void scheduledCongestionCalculation() {
        try {
            List<Gym> gyms = gymService.getAllGyms();

            Calendar cal = Calendar.getInstance();
            cal.add(Calendar.DAY_OF_MONTH, -1);
            Date yesterday = new Date(cal.getTimeInMillis());

            for (Gym gym : gyms) {
                List<AttCache> existingData = attCacheMapper
                    .selectCongestionByGymNoAndDate(gym.getGymNo(), yesterday);

                if (existingData != null && !existingData.isEmpty()) {
                    continue; // 중복 집계 방지
                }

                calculateAndSaveCongestion(gym.getGymNo(), yesterday);
            }

        } catch (Exception e) {
            System.err.println("집계 오류: " + e.getMessage());
        }
    }
}

실행 결과:

[혼잡도 집계 스케줄러] 시작: Fri Jan 15 02:00:00 KST 2024
[혼잡도 집계 스케줄러] 헬스장 1 (강남점) - 9개 시간대 데이터 저장 완료
[혼잡도 집계 스케줄러] 헬스장 2 (서초점) - 9개 시간대 데이터 저장 완료
[혼잡도 집계 스케줄러] 완료: 2개 헬스장 처리, 18개 레코드 저장

이 방식의 장점은 실시간 집계 부하를 제거하고 조회 성능을 향상시킨다는 점이다. 사용자가 혼잡도 정보를 조회할 때 이미 계산된 데이터를 빠르게 반환할 수 있다. 또한 중복 집계를 방지하는 로직을 통해 데이터 무결성을 보장한다.

- 중복 실행 방지

스케줄러는 기본적으로 단일 스레드로 동작하지만, 이전 작업이 완료되기 전에 다음 실행 시간이 도래하면 문제가 발생할 수 있다. 이를 방지하기 위해 중복 체크 로직을 구현한다.

List<AttCache> existingData = attCacheMapper
    .selectCongestionByGymNoAndDate(gym.getGymNo(), yesterday);

if (existingData != null && !existingData.isEmpty()) {
    System.out.println("이미 집계된 데이터가 있습니다. 스킵합니다.");
    continue;
}

데이터베이스에서 해당 날짜의 데이터가 이미 존재하는지 확인한 후, 존재하면 집계를 건너뛴다. 이 방식은 네트워크 장애나 일시적인 시스템 오류로 인해 스케줄러가 재실행되어도 중복 저장을 방지한다.

- 예외 처리 전략

스케줄러 내부에서 발생하는 예외는 전체 작업을 중단시키지 않도록 적절히 처리해야 한다. 개별 헬스장 처리 중 오류가 발생해도 다른 헬스장의 집계는 계속 진행되어야 한다.

for (Gym gym : gyms) {
    try {
        calculateAndSaveCongestion(gym.getGymNo(), yesterday);
    } catch (Exception e) {
        System.err.println("헬스장 " + gym.getGymNo() + 
                          " 집계 중 오류 발생: " + e.getMessage());
        e.printStackTrace();
        // 다음 헬스장 처리 계속 진행
    }
}

각 헬스장별 처리를 별도의 try-catch 블록으로 감싸 개별 오류를 격리한다. 오류가 발생해도 로그만 남기고 다음 헬스장 처리를 계속 진행한다. 이러한 방식은 부분 실패를 허용하여 시스템의 견고성을 높인다.


<주의사항 및 최적화>

- 스레드 풀 크기 설정

기본 스레드 풀 크기는 1이므로 여러 @Scheduled 메서드가 동시에 실행되어야 한다면 스레드 풀 크기를 늘려야 한다. 설정 방법은 다음과 같다.

@Configuration
@EnableScheduling
public class SchedulerConfig implements SchedulingConfigurer {

    @Override
    public void configureTasks(ScheduledTaskRegistrar taskRegistrar) {
        ThreadPoolTaskScheduler scheduler = new ThreadPoolTaskScheduler();
        scheduler.setPoolSize(5); // 스레드 풀 크기 설정
        scheduler.setThreadNamePrefix("scheduled-task-");
        scheduler.initialize();
        taskRegistrar.setTaskScheduler(scheduler);
    }
}

스레드 풀 크기를 5로 설정하면 최대 5개의 스케줄 작업이 동시에 실행될 수 있다. 스레드 이름에 접두사를 지정하면 로그 분석 시 스케줄러 관련 스레드를 쉽게 식별할 수 있다.

- 작업 실행 시간 고려

스케줄된 작업이 오래 걸리면 다음 실행이 지연될 수 있다. fixedDelayfixedRate의 차이를 이해하고 상황에 맞게 선택해야 한다.

// fixedDelay: 이전 작업 완료 후 지정된 시간 대기
@Scheduled(fixedDelay = 60000) // 60초 대기 후 실행
public void taskWithFixedDelay() { }

// fixedRate: 고정된 주기로 실행 (이전 작업 완료와 무관)
@Scheduled(fixedRate = 60000) // 60초마다 실행
public void taskWithFixedRate() { }

fixedDelay는 작업 완료 후 대기 시간을 보장하므로 작업이 오래 걸려도 안전하다. fixedRate는 정확한 주기를 유지하지만 이전 작업이 완료되지 않았어도 새 작업을 시작하므로 주의가 필요하다.

- 트랜잭션 관리

스케줄러 메서드 내에서 데이터베이스 작업을 수행한다면 @Transactional을 함께 사용하는 것이 좋다. 단, 트랜잭션 범위를 적절히 설정하여 장시간 락이 발생하지 않도록 주의해야 한다.

@Scheduled(cron = "0 0 2 * * ?")
@Transactional
public void scheduledCongestionCalculation() {
    // 트랜잭션 내에서 실행
}

큰 배치 작업의 경우 트랜잭션을 너무 크게 잡으면 락 대기 시간이 길어질 수 있다. 필요시 작은 단위로 트랜잭션을 분할하거나 @Transactional(propagation = Propagation.REQUIRES_NEW)를 사용하여 개별 트랜잭션으로 처리하는 것을 고려한다.


<정리 및 요약>

Spring Boot 스케줄러는 @EnableScheduling@Scheduled 어노테이션을 통해 선언적 방식으로 자동 작업을 구현하는 강력한 기능이다. 개발자는 복잡한 스레드 관리나 타이머 설정 없이 비즈니스 로직에만 집중할 수 있다.

스케줄러의 작동 순서는 애플리케이션 시작, 스케줄링 인프라 구축, @Scheduled 메서드 스캔, CRON 표현식 파싱, 백그라운드 대기, 작업 실행, 다음 실행 예약의 8단계로 진행된다. 이 과정은 모두 Spring이 자동으로 관리하며, 개발자는 실행할 메서드와 실행 시간만 정의하면 된다.

CRON 표현식은 초, 분, 시, 일, 월, 요일의 6개 필드로 구성되며, 특수 문자를 활용하여 복잡한 스케줄 패턴을 표현할 수 있다. 실무에서는 매일 새벽 시간대에 데이터 집계, 로그 정리, 리포트 생성 등의 작업을 수행하는 데 활용된다.

스케줄러를 학습하며 얻은 핵심 인사이트는 Spring의 자동화 철학이다. 프레임워크는 개발자가 비즈니스 로직에 집중할 수 있도록 인프라 레벨의 복잡성을 추상화한다. 어노테이션 기반 프로그래밍은 설정을 최소화하고 가독성을 높이는 현대적인 개발 방식이다.

Spring Boot 스케줄러를 효과적으로 활용하려면 스레드 풀 관리, 작업 실행 시간, 트랜잭션 범위를 고려한 설계가 필요하다. 예외 처리와 중복 실행 방지 로직을 적절히 구현하여 시스템의 견고성을 확보해야 한다. 이러한 이해를 바탕으로 안정적이고 효율적인 배치 작업 시스템을 구축할 수 있다.