Spring 스케줄링 — @Scheduled와 Cron 표현식 완전 정복

@EnableScheduling, @Scheduled의 fixedRate·fixedDelay·cron 옵션 차이, Spring Cron 6자리 표현식 문법, 멀티 스레드 스케줄러 설정, 그리고 분산 환경에서의 중복 실행 방지까지 실전 코드로 정리합니다.

· 8 min read · PALDYN Team

지난 글에서 @Async와 스레드풀 설정을 살펴봤다. 이번 글에서는 정해진 시간이나 주기에 자동으로 작업을 실행하는 Spring 스케줄링 메커니즘을 다룬다.

@EnableScheduling 활성화

스케줄링을 사용하려면 설정 클래스에 @EnableScheduling을 추가해야 한다.

@Configuration
@EnableScheduling
public class AppConfig {
}

Spring Boot에서는 @SpringBootApplication이 붙은 메인 클래스에 함께 붙이거나 별도 설정 클래스로 분리하는 것이 일반적이다.

@Scheduled 세 가지 실행 방식

@Scheduled는 메서드의 실행 시점을 결정하는 세 가지 방식을 제공한다.

@Scheduled 실행 간격 옵션 비교

fixedRate — 고정 빈도

@Scheduled(fixedRate = 5000)  // 5초마다 (밀리초)
public void heartbeat() {
    healthCheckService.ping();
}

이전 실행 시작 시점부터 지정한 시간이 지나면 다시 실행한다. 작업 실행 시간이 fixedRate보다 길면 이전 작업이 끝나기 전에 다음 작업이 시작될 수 있다. 주로 상태 확인, 메트릭 수집처럼 가볍고 빠른 작업에 적합하다.

fixedDelay — 완료 후 지연

@Scheduled(fixedDelay = 10_000, initialDelay = 3_000)
public void cleanupTempFiles() {
    fileService.deleteExpired();
}

이전 실행 완료 시점부터 지정한 시간이 지나면 다시 실행한다. 중복 실행이 발생하지 않아 실행 순서와 완료가 중요한 작업에 적합하다. initialDelay는 애플리케이션 시작 후 첫 실행까지의 초기 지연이다.

cron — 특정 시각 지정

@Scheduled(cron = "0 0 2 * * *", zone = "Asia/Seoul")
public void dailyReport() {
    reportService.generateAndSend();
}

특정 시각이나 요일에 실행할 때 사용한다. zone 속성으로 타임존을 명시할 수 있으며, 지정하지 않으면 서버의 기본 타임존을 사용한다. 한국 서비스는 반드시 zone = "Asia/Seoul"을 명시하는 것이 안전하다.

Spring Cron 표현식 문법

Spring Cron 표현식 구조

Unix 계열 cron은 분-시-일-월-요일의 5자리이지만, Spring Cron은 초-분-시-일-월-요일의 6자리다. 이 차이를 모르면 의도한 시각과 다르게 실행된다.

┌───── 초 (0-59)
│ ┌───── 분 (0-59)
│ │ ┌───── 시 (0-23)
│ │ │ ┌───── 일 (1-31)
│ │ │ │ ┌───── 월 (1-12 또는 JAN-DEC)
│ │ │ │ │ ┌───── 요일 (0-6 또는 SUN-SAT)
│ │ │ │ │ │
0 0 2 * * *

자주 쓰는 Cron 표현식 예시

// 매일 새벽 2시
@Scheduled(cron = "0 0 2 * * *", zone = "Asia/Seoul")

// 평일 오전 9시 30분
@Scheduled(cron = "0 30 9 * * MON-FRI", zone = "Asia/Seoul")

// 매 5분마다
@Scheduled(cron = "0 */5 * * * *")

// 매월 1일 자정
@Scheduled(cron = "0 0 0 1 * *", zone = "Asia/Seoul")

// 매주 월요일 오전 8시
@Scheduled(cron = "0 0 8 * * MON", zone = "Asia/Seoul")

// 1시, 13시 (하루 두 번)
@Scheduled(cron = "0 0 1,13 * * *", zone = "Asia/Seoul")

? (물음표) 사용 규칙

?는 일(day-of-month)과 요일(day-of-week) 필드에서만 사용할 수 있으며, “지정하지 않음”을 의미한다. 두 필드 중 하나만 지정하고 나머지를 ?로 두어야 한다.

// 매월 15일 (요일 무관)
"0 0 9 15 * ?"

// 매주 월요일 (일 무관)
"0 0 9 ? * MON"

스케줄러 스레드 설정

@EnableScheduling의 기본 스케줄러는 단일 스레드다. 여러 @Scheduled 메서드가 있을 때 하나가 오래 걸리면 다른 스케줄이 지연된다.

ThreadPoolTaskScheduler 등록

@Configuration
@EnableScheduling
public class SchedulerConfig implements SchedulingConfigurer {

    @Override
    public void configureTasks(ScheduledTaskRegistrar registrar) {
        ThreadPoolTaskScheduler scheduler = new ThreadPoolTaskScheduler();
        scheduler.setPoolSize(5);  // 동시 실행 가능한 스케줄 수
        scheduler.setThreadNamePrefix("sched-");
        scheduler.initialize();
        registrar.setTaskScheduler(scheduler);
    }
}

PoolSize는 동시에 실행될 수 있는 스케줄 작업의 최대 수다. 스케줄링 주기가 겹치는 작업이 여러 개라면 PoolSize >= 동시 실행 가능 작업 수로 설정한다.

@Async와 함께 사용

스케줄러 스레드를 즉시 해방하고 싶으면 @Scheduled@Async를 함께 사용한다.

@Scheduled(fixedRate = 5000)
@Async("taskExecutor")
public void asyncJob() {
    // taskExecutor 스레드풀에서 실행
    // 스케줄러 스레드는 즉시 해방
    heavyTask();
}

프로퍼티로 Cron 표현식 관리

하드코딩 대신 프로퍼티로 분리하면 환경마다 다른 스케줄을 적용할 수 있다.

@Scheduled(cron = "${scheduler.daily-report.cron:0 0 2 * * *}",
           zone   = "${scheduler.timezone:Asia/Seoul}")
public void dailyReport() {
    reportService.generate();
}
# application-prod.yml
scheduler:
  daily-report:
    cron: "0 0 1 * * *"   # 운영: 새벽 1시
  timezone: Asia/Seoul

# application-dev.yml
scheduler:
  daily-report:
    cron: "0 */1 * * * *"  # 개발: 매분 (빠른 테스트)

분산 환경에서 중복 실행 방지

수평 확장 환경(서버 여러 대)에서는 모든 인스턴스가 동시에 같은 스케줄을 실행하는 문제가 생긴다. Spring 자체에는 분산 락 기능이 없으므로 외부 도구를 활용한다.

ShedLock으로 단일 실행 보장

<dependency>
  <groupId>net.javacrumbs.shedlock</groupId>
  <artifactId>shedlock-spring</artifactId>
  <version>5.10.0</version>
</dependency>
<dependency>
  <groupId>net.javacrumbs.shedlock</groupId>
  <artifactId>shedlock-provider-jdbc-template</artifactId>
  <version>5.10.0</version>
</dependency>
@Configuration
@EnableSchedulerLock(defaultLockAtMostFor = "10m")
public class ShedLockConfig {

    @Bean
    public LockProvider lockProvider(DataSource dataSource) {
        return new JdbcTemplateLockProvider(dataSource);
    }
}
@Scheduled(cron = "0 0 2 * * *", zone = "Asia/Seoul")
@SchedulerLock(name = "dailyReport",
               lockAtLeastFor = "1m",
               lockAtMostFor  = "30m")
public void dailyReport() {
    reportService.generate();
}

lockAtLeastFor은 최소 잠금 유지 시간(작업이 빨리 끝나도 이 시간 동안은 다른 노드가 실행 못 함), lockAtMostFor은 최대 잠금 시간(노드 다운 시 자동 해제 대기 시간)이다.

스케줄 실행 로깅

@Scheduled(cron = "0 0 2 * * *", zone = "Asia/Seoul")
public void dailyReport() {
    log.info("[SCHEDULED] dailyReport started");
    long start = System.currentTimeMillis();
    try {
        reportService.generate();
        log.info("[SCHEDULED] dailyReport completed in {}ms",
                 System.currentTimeMillis() - start);
    } catch (Exception e) {
        log.error("[SCHEDULED] dailyReport failed", e);
        throw e;
    }
}

스케줄 작업은 요청-응답 흐름 밖에서 실행되므로 시작과 완료를 항상 로그로 남겨야 운영 중 이슈를 추적할 수 있다.

정리

옵션기준 시점중복 실행적합한 사용 사례
fixedRate시작 시점가능가벼운 상태 확인, 메트릭 수집
fixedDelay완료 시점불가파일 처리, 순차 실행 필요 작업
cron특정 시각설정에 따라배치, 정산, 리포트 생성

분산 환경에서는 ShedLock 같은 분산 락 라이브러리로 단일 실행을 보장하고, 기본 단일 스레드 스케줄러는 ThreadPoolTaskScheduler로 교체해 스케줄 블로킹을 방지해야 한다.


지난 글: Spring 비동기 스레드풀 — @Async와 ThreadPoolTaskExecutor 완전 정복

다음 글: Spring 비동기 예외 처리 — AsyncUncaughtExceptionHandler 완전 정복


읽어주셔서 감사합니다. 😊