[Spring 완전 정복 #14] Spring @Scheduled — cron 표현식과 다중 서버 중복 실행 방지

fixedRate vs fixedDelay vs cron 차이, 크론 표현식 작성법, 단일 스레드 함정, 그리고 다중 서버 환경에서 ShedLock으로 중복 실행을 방지하는 방법을 정리한다.

매일 새벽 2시에 배치를 실행하려면

통계 집계, 만료 데이터 정리, 리포트 생성 같은 작업은 주기적으로 실행해야 한다. Spring @Scheduled는 메서드에 어노테이션 하나만 붙여서 이런 작업을 등록할 수 있다.

단 하나의 함정이 있다. 서버가 여러 대라면 모든 서버가 동시에 실행한다.


기본 설정

@Configuration
@EnableScheduling  // 스케줄링 활성화
public class SchedulingConfig {

    // 기본값은 단일 스레드 — 독립 실행이 필요하면 풀 설정
    @Bean
    public TaskScheduler taskScheduler() {
        ThreadPoolTaskScheduler scheduler = new ThreadPoolTaskScheduler();
        scheduler.setPoolSize(5);
        scheduler.setThreadNamePrefix("scheduled-");
        scheduler.initialize();
        return scheduler;
    }
}

기본값이 단일 스레드라는 점을 기억해두자. 스케줄 메서드 A가 10초 걸리면 B는 A가 끝날 때까지 대기한다. 독립적으로 실행돼야 한다면 위처럼 스레드 풀을 설정해야 한다.


세 가지 실행 방식

fixedRate — 시작 시점 기준

@Scheduled(fixedRate = 5000)  // 5초마다 (이전 실행 시작으로부터)
public void fixedRateTask() { ... }
실행 시작: 0초
실행 완료: 3초
다음 시작: 5초 (0초 + 5초)
다음 시작: 10초 (5초 + 5초)

실행 시간이 주기를 초과하면 다음 실행이 겹칠 수 있다(스레드 풀이 있을 때).

fixedDelay — 완료 시점 기준

@Scheduled(fixedDelay = 5000)  // 이전 실행 완료 후 5초
public void fixedDelayTask() { ... }
실행 시작: 0초
실행 완료: 3초
다음 시작: 8초 (3초 완료 + 5초)

실행이 아무리 오래 걸려도 완료 후 5초 뒤에 시작한다. 겹칠 위험이 없다.

cron — 특정 시각에 실행

@Scheduled(cron = "0 0 2 * * *")  // 매일 새벽 2시
public void dailyBatchJob() { ... }

@Scheduled(cron = "0 */30 9-18 * * MON-FRI")  // 평일 9~18시 30분마다
public void businessHoursTask() { ... }

크론 표현식 구조

초  분  시  일  월  요일
0   0   2   *   *   *    → 매일 02:00:00

자주 쓰는 표현:

"0 0 * * * *"       // 매 시간 정각
"0 0 0 * * *"       // 매일 자정
"0 0 0 1 * *"       // 매월 1일 자정
"0 0 9 * * MON-FRI" // 평일 오전 9시
"0 */5 * * * *"     // 5분마다

환경별 cron 분리

로컬에서는 빠르게 테스트하고, 운영에서는 실제 스케줄을 적용한다.

# application.yml
schedule:
  daily-batch:
    cron: "0 0 2 * * *"

# application-local.yml
schedule:
  daily-batch:
    cron: "0 */1 * * * *"  # 1분마다 테스트
@Scheduled(cron = "${schedule.daily-batch.cron}")
public void dailyBatch() { ... }

다중 서버 환경의 중복 실행 문제

서버가 3대면 새벽 2시에 배치가 3번 실행된다.

서버 A ─┐
서버 B ─┼─ 모두 02:00에 dailyBatch() 실행 → 데이터 3번 처리
서버 C ─┘

해결: ShedLock

DB나 Redis를 락 저장소로 사용해 단 하나의 서버만 실행을 보장한다.

<dependency>
    <groupId>net.javacrumbs.shedlock</groupId>
    <artifactId>shedlock-spring</artifactId>
</dependency>
<dependency>
    <groupId>net.javacrumbs.shedlock</groupId>
    <artifactId>shedlock-provider-jdbc-template</artifactId>
</dependency>
-- 락 테이블 (MySQL)
CREATE TABLE shedlock (
    name        VARCHAR(64)  NOT NULL,
    lock_until  TIMESTAMP(3) NOT NULL,
    locked_at   TIMESTAMP(3) NOT NULL,
    locked_by   VARCHAR(255) NOT NULL,
    PRIMARY KEY (name)
);
@Configuration
@EnableScheduling
@EnableSchedulerLock(defaultLockAtMostFor = "10m")
public class SchedulingConfig {
    @Bean
    public LockProvider lockProvider(DataSource dataSource) {
        return new JdbcTemplateLockProvider(dataSource);
    }
}
@Service
public class BatchJobService {

    @Scheduled(cron = "0 0 2 * * *")
    @SchedulerLock(
        name = "dailyBatchJob",   // 유니크한 락 이름
        lockAtLeastFor = "5m",    // 빠르게 끝나도 5분간 락 유지 (중복 방지)
        lockAtMostFor = "30m"     // 서버가 죽어도 30분 후 자동 해제
    )
    public void dailyBatchJob() {
        // 이 서버만 실행 — 다른 서버는 락을 잡지 못해 건너뜀
        processOrders();
    }
}

lockAtMostFor이 중요하다. 서버가 배치 실행 중에 죽으면 DB의 락 레코드가 남아있게 된다. 이 설정이 있어야 일정 시간 후 락이 자동으로 해제된다.


주의사항

예외는 반드시 잡는다

@Scheduled(cron = "0 0 2 * * *")
public void dailyBatch() {
    try {
        processBatch();
    } catch (Exception e) {
        log.error("배치 실패", e);
        alertService.sendAlert("dailyBatch 실패: " + e.getMessage());
    }
}

예외가 전파되면 다음 실행이 안 될 수 있다. 항상 try-catch로 잡고 알림을 보내는 것이 좋다.

트랜잭션은 서비스에 위임

@Scheduled(cron = "0 0 2 * * *")
public void dailyBatch() {
    batchService.processDailyOrders(); // @Transactional이 있는 서비스에 위임
}

@Scheduled는 트랜잭션을 자동으로 걸지 않는다. 필요하면 @Transactional을 추가하거나 서비스 레이어에 위임한다.


마치며

단일 서버라면 @Scheduled만으로 충분하다. 다중 서버 환경이라면 중복 실행 방지가 필수다. ShedLock이 가장 간단한 해결책이다. DB에 락 레코드를 남기는 방식이라 별도 인프라 없이 사용할 수 있다. 복잡한 잡 관리(의존성, 재시도, 히스토리)가 필요하다면 Quartz를 고려한다.

Spring 시리즈 마지막 편이다. Servlet 기초부터 스케줄링까지 14편을 통해 Spring의 핵심 구조를 정리했다.