[Spring] Spring에서 주기적인 작업 자동화하기: @Scheduled + ThreadPool로 병렬 스케줄링 처리하기

조민경·2025년 4월 22일
0

Spring

목록 보기
12/13
post-thumbnail

🌱 Spring 프레임워크에서 제공하는 @Scheduled 어노테이션은 주기적으로 실행되는 작업(task)을 스케줄링을 통해 자동화하는 도구입니다.

이번 포스팅에서는 @Scheduled 어노테이션에 대한 개념과 이를 활용한 주기적인 작업 실행 예제를 다루겠습니다.


@Scheduled 란?

  • Spring Framework의 @Scheduled 어노테이션은 Spring에서 제공하는 스케줄링 작업을 정의하는 어노테이션입니다. 이를 통해 특정 메서드를 주기적으로 실행하거나, 예약된 시간에 실행하도록 설정할 수 있습니다.

  • 특정 시간 간격, 특정 시간, 또는 CRON 표현식을 사용하여 메서드를 자동 실행할 수 있습니다.

  • Spring Boot 애플리케이션에서 스케쥴러는 백그라운드 작업(예: 캐시 갱신, 데이터 집계, 상태 업데이트)을 처리하는 데 자주 사용됩니다.

  • Spring에서 @Scheduled 어노테이션은 기본적으로 싱글 스레드 환경에서 동작합니다. 따라서 두 개 이상의 @Scheduled 작업이 정의되어 있더라도, 별도로 병렬 실행 설정을 하지 않으면 작업들이 순차적으로 실행됩니다. 즉, 여러 작업이 동시에 실행되는 것이 아니라, 하나의 작업이 끝난 후 다음 작업이 실행되는 구조입니다.


@Scheduled 설정 및 사용 방법

1. Scheduling 활성화

@EnableScheduling
@SpringBootApplication
public class PicketApplication {

    public static void main(String[] args) {
        SpringApplication.run(PicketApplication.class, args);
    }
}
  • PicketApplication 클래스에 @EnableScheduling을 추가하거나,
@Configuration
@EnableScheduling
@EnableSchedulerLock(defaultLockAtMostFor = "PT5M")
public class SchedulerConfig {
    @Bean
    public RedisLockProvider lockProvider(RedisConnectionFactory redisConnectionFactory) {
        return new RedisLockProvider(redisConnectionFactory);
    }
}
  • SchedulerConfig로 분리해 @EnableScheduling를 추가하여 Scheduling을 활성화합니다.

🙋‍♀️ Picket Project에선 후자의 방법으로 Config를 분리하였습니다. 이유는 다음과 같습니다.

1. 관심사 분리
설정들을 각각의 설정 클래스 (@Configuration)로 분리하면, 설정 목적별로 코드를 명확하게 관리할 수 있습니다.
=> 즉, SchedulerConfig는 "스케줄링" 관련 설정만 모아서 담고 있다는 게 명확해집니다.

2. 테스트 시 유연성 증가
단위 테스트나 슬라이스 테스트에서 스케줄링을 선택적으로 포함/제외하기 쉬워집니다.
예: @Import(SchedulerConfig.class) 또는 아예 해당 설정을 제외하여 테스트할 수 있습니다.

3. 조건부 등록 (@Conditional 등) 활용 가능
예를 들어 특정 프로파일(dev, prod)이나 설정 여부에 따라 스케줄링을 켜고 끄고 싶을 경우, 다음처럼 응용할 수 있습니다.

@Configuration
@ConditionalOnProperty(name = "scheduler.enabled", havingValue = "true")
@EnableScheduling
public class SchedulerConfig {
    ...
}

이렇게 하면 application.ymlscheduler.enabled: true/false로 제어 가능해집니다.

4. 다양한 스케줄 관련 설정들과의 집합성
ShedLock, ThreadPoolTaskScheduler, 커스텀 Executor 등을 한 클래스에서 구성할 수 있어서 일관성이 생깁니다.


@Scheduled Annotation 적용

@Slf4j
@Component
public class SchedulerTest {
    @Scheduled(cron = "* * * * * ?") // 매초 실행
    public void myTask() {
        log.info("Scheduled task");
    }
}

@Component를 사용하여 해당 스케줄러를 Spring Bean에 등록합니다.

해당 클래스를 Bean으로 등록 후 애플리케이션을 실행하면 설정한 시간(매초)마다 실행되는 모습을 확인할 수 있습니다.


@Scheduled 주요 옵션

아래의 내용은 Spring 공식 문서에서 정리한 내용입니다.

1. cron

  • cron 표현식은 작업이 실행될 시점을 세밀하게 제어하기 위한 문자열 포맷입니다.

  • Spring에서는 일반 UNIX cron과 달리 초 단위부터 시작해서 총 6자리 또는 7자리 표현식을 사용합니다.

  • 필드 순서는 아래와 같습니다.

    • 초, 분, 시, 일, 월, 요일, [연도(선택)]
  • 예시

    @Scheduled(cron = "0 0/5 * * * *") // 매 5분마다 실행
    public void run() {
        System.out.println("cron 작업 실행");
    }

cron 필드

필드범위설명
초 (second)0–59작업이 실행될 초
분 (minute)0–59작업이 실행될 분
시 (hour)0–23작업이 실행될 시각
일 (day)1–31작업이 실행될 일
월 (month)1–12, JAN–DEC실행할 월 (영문도 가능)
요일 (day)0–6, SUN–SAT실행할 요일 (0: 일요일)
연도 (year)(옵션)생략 가능

특수 기호

기호의미
*모든 값 (전체 범위)
?특정 값 지정 안 함 (주로 일/요일 중 하나에 사용)
-범위 지정 (예: 1-5)
/간격 지정 (예: */5 → 5단위 실행)
,여러 값 지정 (예: 1,3,5)
L마지막 (예: L = 마지막 날, 5L = 마지막 금요일)
W가장 가까운 평일 (예: 15W = 15일에 가까운 평일)
#몇 번째 요일인지 (예: MON#2 = 두 번째 월요일)

cron 예시

표현식설명
0 0 * * * *매 시 정각
*/10 * * * * *매 10초마다
0 0 8-10 * * *매일 8~10시 정각
0 0 6,19 * * *매일 6시, 19시에 실행
0 0/30 8-10 * * *8시~10시 사이 30분 간격 실행
0 0 9-17 * * MON-FRI평일 9~17시 매 정각
0 0 0 25 12 ?매년 12월 25일 0시 (크리스마스 자정)
0 0 0 L * *매달 마지막 날 자정
0 0 0 L-3 * *매달 마지막에서 3일 전 자정
0 0 0 1W * *매달 1일 기준 가장 가까운 평일 자정
0 0 0 LW * *매달 마지막 평일 자정
0 0 0 ? * 5#2매달 두 번째 금요일 자정
0 0 0 ? * MON#1매달 첫 번째 월요일 자정

매크로 표현식

표현식확장된 cron 표현식의미
@yearly0 0 0 1 1 *매년 1월 1일 자정
@monthly0 0 0 1 * *매달 1일 자정
@weekly0 0 0 * * 0매주 일요일 자정
@daily0 0 0 * * *매일 자정
@hourly0 0 * * * *매시간 정각

주의 사항

  • 일(day)과 요일(weekday) 필드에 동시에 숫자를 넣을 수 없음. 하나는 반드시 ? 사용해야 함.

  • Spring에서 cron 표현식은 기본적으로 서버 시간대를 기준으로 작동함. 필요하면 zone 속성 지정 가능 (예: zone = "Asia/Seoul").

  • cron 표현식이 잘못되면 IllegalArgumentException 발생하니 주의


2. fixedDelay

  • 이전 작업이 종료된 시점 기준으로 지정한 시간(ms)만큼 기다린 후 다음 실행이 시작됩니다.

  • 예: 작업이 10:00:00에 시작해서 10:00:03에 끝났고, fixedDelay = 2000이면 다음 실행은 10:00:05에 시작됩니다.

@Scheduled(fixedRate = 5000) // 5초마다 실행
public void run() {
    System.out.println("fixedRate 작업 실행");
}
  • 기본값: -1L (비활성화)

3. fixedRate

  • 이전 작업이 시작된 시점 기준으로 지정한 시간(ms)마다 반복 실행됩니다. 이전 작업이 아직 끝나지 않았더라도 다음 실행이 시작될 수 있습니다.

  • 예: 작업이 10:00:00에 시작했고, fixedRate = 5000이면 다음 실행은 10:00:05에 시작됩니다.

@Scheduled(fixedDelay = 3000) // 이전 작업 종료 후 3초 후 실행
public void run() {
    System.out.println("fixedDelay 작업 실행");
}
  • 기본값: -1L (비활성화)

4. initialDelay

  • 애플리케이션 시작 후, 최초 실행을 지연시킬 시간(ms)입니다. fixedRate 또는 fixedDelay와 함께 사용됩니다.

  • 예: initialDelay = 10000이면 앱 시작 후 10초 뒤에 첫 실행이 시작됩니다.

@Scheduled(initialDelay = 10000, fixedDelay = 5000)
public void run() {
    System.out.println("앱 시작 후 10초 뒤, 이후 5초 간격으로 실행");
}
  • 기본값: -1L (비활성화)

참고사항

  • 단위는 기본적으로 밀리초(ms) 입니다.

  • Java 21 이상의 가상 스레드(Virtual Thread) 환경에서는 fixedRatecron 방식 사용이 권장됩니다.

  • fixedDelay는 단일 스레드에서 실행되며, 병렬 실행이 되지 않습니다. 하나의 작업이 끝난 뒤 다음 작업이 시작됩니다.


기본 동작: 싱글 스레드

Spring의 스케줄링은 기본적으로 싱글 스레드로 작동해서, 여러 @Scheduled 메서드가 있을 경우 하나씩 순차적으로 실행됩니다.
동시에 실행되길 원한다면, 스레드 풀을 구성해서 병렬 처리를 가능하게 만들어야 합니다.

@Slf4j
@Component
public class SchedulerTest {
    @Scheduled(zone = "Asia/Seoul", fixedDelay = 1000) // 타임존 서울, 이전 작업이 끝난 후 1초마다 실행
    public void run1() {
        log.info("Scheduler task 1 : {}", Thread.currentThread().getName());
    }

    @Scheduled(zone = "Asia/Seoul", fixedDelay = 1000) // 타임존 서울, 이전 작업이 끝난 후 1초마다 실행
    public void run2() {
        log.info("Scheduler task 2 : {}", Thread.currentThread().getName());
    }
}

위에서 설명한 것과 같이 @Scheduled는 기본적으로 싱글 스레드로 작동합니다. (task1과 task2의 currentThread는 동일한 scheduling-1) 이 때, 아래와 같은 이유로 병렬 처리가 필요할 수 있습니다.

1. 작업 시간이 긴 경우 다른 스케줄에 영향

  • 기본 스케줄러는 단일 스레드로 작동하므로, 하나의 작업이 오래 걸리면 다음 작업이 기다려야 합니다.

  • 예를 들어
    A 스케줄러: 매 1분마다 실행되지만 30초 소요
    B 스케줄러: 매 1분마다 실행되지만 A가 끝나야 실행됨 → 지연 발생

2. 정확한 주기로 실행되어야 하는 작업이 있는 경우

  • 예를 들어 실시간 모니터링, 트래픽 수집 등은 정해진 간격에 정확히 실행되어야 하는데, 단일 스레드로는 이전 작업 종료를 기다려야 하므로 시간 정확도 보장이 어렵습니다.

3. 서로 다른 성격의 작업을 동시에 처리해야 하는 경우

  • 예: 하나는 빠른 로그 수집, 다른 하나는 느린 리포트 생성 작업

  • 성격이 다른 작업을 병렬로 분리해서 처리하지 않으면 전체 시스템 응답이 느려질 수 있습니다.

4. 에러 격리 및 리소스 분산

  • 한 작업에서 에러가 발생해도 다른 작업은 독립적으로 실행되도록 할 수 있습니다.

  • 병렬 처리 시, 각 스레드가 격리되기 때문에 서비스 안정성도 높아집니다.

따라서 이와 같은 이유로 Thread Pool을 구성해서 작업의 병렬 처리를 가능하게 만들 수 있습니다.


SchedulerConfig 설정

@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);
    }
}
  • ScheduledTaskRegistrar는 Spring에서 스케줄링 작업을 관리하는 객체고,
    기본적으로는 내부에서 단일 스레드 기반의 DefaultTaskScheduler를 사용합니다.

  • 하지만 ThreadPoolTaskScheduler를 만들어서 병렬 처리 가능한 스케줄러를 구성했다면, Spring에게 "스케줄링 작업은 이제 이 커스텀 스레드풀을 써!"라고 알려줘야 합니다. 그것이 아래의 코드입니다.

    taskRegistrar.setTaskScheduler(scheduler);
    • 이것을 통해 커스텀한 ThreadPoolTaskScheduler를 실제로 @Scheduled 작업들이 사용하게 되는 것입니다.
  • taskRegistrar: 스케줄링 작업들을 등록/관리하는 객체

  • setTaskScheduler(...): 사용할 TaskScheduler(스레드풀 등)를 설정

  • 실제로 task를 늘리고 scheduler.setPoolSize(5)을 통해 스레드 수를 5개로 설정한 뒤 실행해보면 실행되는 task가 각각 다른 스레드에서 병렬 처리 되는 것을 확인할 수 있습니다.

0개의 댓글