TECH/웹개발

[Spring Boot] 주기적인 작업 자동화, 스케줄러(Scheduler) 완벽 가이드

런코리치 2026. 3. 20. 08:00

현대 백엔드 시스템에서 특정 시간에 리포트를 생성하거나, 주기적으로 유효 기간이 만료된 데이터를 정리하는 등의 '배치성 작업'은 필수적입니다. Spring 프레임워크는 이러한 요구사항을 아주 간단하게 해결할 수 있도록 @Scheduled 어노테이션을 제공합니다. 하지만 단순히 어노테이션을 붙이는 것과, 운영 환경에서 안정적으로 동작하는 스케줄러를 만드는 것은 천지차이입니다. 오늘은 기초부터 실무 레벨의 고급 설정까지 상세히 파헤쳐 보겠습니다.


 1. 스케줄링의 심장: @EnableScheduling과 기본 설정

Spring Boot에서 스케줄러를 사용하기 위한 첫 단추는 활성화 설정입니다. 메인 애플리케이션 클래스나 설정 클래스(@Configuration)에 @EnableScheduling을 추가해야 합니다.

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

이 설정이 추가되면 Spring은 내부적으로 ScheduledAnnotationBeanPostProcessor를 등록하여 빈(Bean)으로 등록된 클래스 내의 @Scheduled 어노테이션을 탐색하고 실행 대기열에 올립니다. 여기서 주의할 점은 스케줄링 대상 메서드가 있는 클래스는 반드시 Spring Bean(예: @Component, @Service)으로 등록되어야 한다는 것입니다.


2. @Scheduled의 핵심 속성 3가지 집중 분석

스케줄링의 핵심은 '언제, 얼마나 자주' 실행하느냐입니다. Spring은 이를 위해 세 가지 주요 속성을 제공합니다.

 ① fixedDelay: 이전 작업 종료 시점이 기준

fixedDelay는 이전 작업이 완전히 끝난 시점부터 지정된 시간만큼 대기한 후 다음 작업을 시작합니다.

  • 장점: 작업 간의 겹침(Overlap)이 절대 발생하지 않습니다.
  • 적용 사례: DB 데이터 동기화처럼 이전 작업의 결과가 다음 작업에 영향을 줄 때 적합합니다.

 ② fixedRate: 작업 시작 시점이 기준

fixedRate는 이전 작업의 시작 시점을 기준으로 주기를 계산합니다.

  • 주의점: 작업 실행 시간이 주기보다 길어질 경우, 기본 설정(싱글 스레드) 하에서는 다음 작업이 밀리게 됩니다.
  • 적용 사례: 정확한 시간 간격으로 하트비트(Heartbeat)를 보내거나 상태를 체크해야 할 때 사용합니다.

 ③ cron: 복잡한 일정 예약 (Unix 스타일)

가장 강력하고 범용적인 방식입니다. 초 분 시 일 월 요일 순서로 작성하며, 특수 기호(*, ?, L, W, #)를 조합해 '매월 마지막 날 23시' 같은 복잡한 설정이 가능합니다.

Java
 
@Scheduled(cron = "0 0 0 * * *") // 매일 자정에 실행
public void dailyCleanup() { ... }

3. 실무자라면 반드시 알아야 할 "병렬 처리"의 비밀

많은 입문자가 간과하는 사실이 있습니다. Spring의 스케줄러는 기본적으로 '싱글 스레드'로 동작합니다. 즉, 하나의 스케줄링 작업이 10초 동안 돌고 있다면, 다른 모든 @Scheduled 작업은 그 작업이 끝날 때까지 대기해야 합니다. 이는 심각한 성능 병목을 야기할 수 있습니다.

이를 해결하려면 SchedulingConfigurer를 구현하여 스레드 풀(Thread Pool)을 설정해야 합니다.

Java
 
@Configuration
public class SchedulerConfig implements SchedulingConfigurer {
    @Override
    public void configureTasks(ScheduledTaskRegistrar taskRegistrar) {
        ThreadPoolTaskScheduler threadPoolTaskScheduler = new ThreadPoolTaskScheduler();
        threadPoolTaskScheduler.setPoolSize(10); // 10개의 스레드가 스케줄을 나눠 처리
        threadPoolTaskScheduler.setThreadNamePrefix("rich-scheduler-");
        threadPoolTaskScheduler.initialize();
        taskRegistrar.setTaskScheduler(threadPoolTaskScheduler);
    }
}

이렇게 설정을 분리하면 각 스케줄 작업이 서로의 발목을 잡지 않고 병렬로 쾌적하게 실행됩니다.


4. 유연한 운영을 위한 설정 분리 전략

코드 내에 cron = "0 0 12 * * *"을 직접 적어두는(Hard Coding) 방식은 운영 환경에서 유연성을 떨어뜨립니다. 배포 없이 실행 주기를 바꾸고 싶다면 application.yml과 @Value를 조합하세요.

YAML
 
# application.yml
my-job:
  cron: "0 0/10 * * * *"  # 10분마다
  enabled: true
Java
 
@Scheduled(cron = "${my-job.cron}")
public void dynamicJob() {
    if (!enabled) return; // 실행 여부까지 제어 가능
    log.info("배포 없이 수정 가능한 스케줄러 작동 중...");
}

이 방식을 사용하면 개발 환경에서는 짧게, 운영 환경에서는 길게 주기를 가져가는 식의 유연한 대응이 가능해집니다.


5. 에러 처리와 트랜잭션 주의사항

스케줄링 메서드 내부에서 예외(Exception)가 발생하면 어떻게 될까요? 기본적으로 별도의 처리가 없다면 해당 회차의 작업은 중단되고 로그에 에러가 남지만, 스케줄러 자체는 다음 주기에 다시 실행됩니다. 하지만 스케줄러가 멈추지 않는다고 안심해서는 안 됩니다.

  • 에러 로깅: 반드시 try-catch로 감싸서 어떤 데이터에서 에러가 났는지 상세히 로깅해야 합니다.
  • 트랜잭션: @Scheduled 메서드 위에도 @Transactional을 붙일 수 있습니다. 하지만 긴 작업 시간 동안 트랜잭션을 잡고 있으면 커넥션 풀(Connection Pool) 부족 현상이 생길 수 있으므로, 내부 서비스 레이어에서 필요한 부분만 짧게 트랜잭션을 거는 것이 권장됩니다.

🏁 마무리하며: "스케줄러는 살아있는 생명체와 같습니다"

Spring Boot의 @Scheduled는 매우 편리하지만, 시스템이 커질수록 관리 포인트가 늘어납니다. 만약 서버가 여러 대(L4 분산 환경)인 상황이라면, 각 서버마다 스케줄러가 동시에 돌아 중복 작업이 발생하는 '분산 환경의 문제'에 직면하게 될 것입니다. 이럴 때는 ShedLock 라이브러리나 Spring Batch + Quartz 조합을 고려해야 합니다.

단순한 구현을 넘어, 여러분의 서비스가 어느 정도의 부하를 견뎌야 하는지 고민하고 그에 맞는 적절한 스케줄링 전략을 선택해 보시기 바랍니다. 인덱스를 활용한 No-Offset 쿼리와 오늘 배운 효율적인 스케줄링 설정이 만난다면, 진정한 대용량 데이터 처리의 전문가로 거듭나실 수 있을 것입니다.