매일 새벽에 데이터를 정리하거나, 5분마다 외부 API를 폴링해야 한다면 스프링에서 어떻게 구현할까요?

개념 정의

@Scheduled 는 메서드를 주기적으로 실행하도록 예약하는 스프링 어노테이션입니다. 별도의 스케줄러 프레임워크 없이 간단한 배치 작업이나 폴링 로직을 구현할 수 있습니다.

기본 설정

JAVA
@Configuration
@EnableScheduling  // 필수: 스케줄링 활성화
public class SchedulingConfig {
}

실행 방식 3가지

1. fixedRate — 일정 간격으로 실행

이전 작업의 시작 시점 부터 간격을 계산합니다.

JAVA
@Component
@Slf4j
public class MetricsCollector {

    // 10초마다 실행 (이전 작업이 끝나지 않아도 시간이 되면 실행 예약)
    @Scheduled(fixedRate = 10_000)
    public void collectMetrics() {
        log.info("메트릭 수집 시작");
        // 서버 상태, 메모리 사용량 등 수집
    }

    // 문자열로 외부 설정 가능
    @Scheduled(fixedRateString = "${metrics.collect.interval:10000}")
    public void collectMetricsConfigurable() {
        // application.yml에서 간격 설정
    }
}

2. fixedDelay — 이전 작업 완료 후 대기

이전 작업의 완료 시점 부터 간격을 계산합니다. 작업이 겹치지 않습니다.

JAVA
@Component
public class ExternalApiPoller {

    // 이전 작업이 끝나고 5초 후 다시 실행
    @Scheduled(fixedDelay = 5_000)
    public void pollExternalApi() {
        // 외부 API 호출 — 응답 시간이 가변적일 때 적합
        List<Event> events = externalApi.fetchNewEvents();
        eventProcessor.process(events);
    }

    // 애플리케이션 시작 후 30초 뒤에 첫 실행
    @Scheduled(fixedDelay = 5_000, initialDelay = 30_000)
    public void pollWithInitialDelay() {
        // 초기화가 완료된 후에 실행
    }
}

3. cron — 특정 시간에 실행

유닉스 cron과 유사한 표현식으로 실행 시간을 지정합니다.

PLAINTEXT
초  분  시  일  월  요일
0   0   2   *   *   *      → 매일 새벽 2시
0   */5 *   *   *   *      → 5분마다
0   0   9   *   *   MON-FRI → 평일 오전 9시
0   0   0   1   *   *      → 매월 1일 자정
JAVA
@Component
public class DailyBatchJob {

    // 매일 새벽 2시에 실행
    @Scheduled(cron = "0 0 2 * * *")
    public void cleanupExpiredData() {
        log.info("만료 데이터 정리 시작");
        dataCleanupService.removeExpiredRecords();
    }

    // 평일 오전 9시에 리포트 생성
    @Scheduled(cron = "0 0 9 * * MON-FRI")
    public void generateDailyReport() {
        reportService.createDailyReport();
    }

이어서 스케줄링 작업을 정의합니다.

JAVA
    // 타임존 지정
    @Scheduled(cron = "0 0 2 * * *", zone = "Asia/Seoul")
    public void seoulTimeJob() {
        // 한국 시간 기준 새벽 2시
    }

    // 외부 설정으로 cron 표현식 관리
    @Scheduled(cron = "${batch.cleanup.cron:0 0 2 * * *}")
    public void configurableCronJob() {
        // application.yml에서 cron 변경 가능
    }
}

fixedRate vs fixedDelay 비교

PLAINTEXT
fixedRate = 5초, 작업 시간 = 3초인 경우:
|--작업(3초)--|--대기(2초)--|--작업(3초)--|--대기(2초)--|
0            3            5            8           10

fixedDelay = 5초, 작업 시간 = 3초인 경우:
|--작업(3초)--|----대기(5초)----|--작업(3초)--|----대기(5초)----|
0            3               8            11              16
  • fixedRate: 정확한 주기가 중요할 때 (메트릭 수집, 상태 체크)
  • fixedDelay: 작업 겹침을 방지해야 할 때 (외부 API 폴링, 데이터 동기화)

TaskScheduler 설정 — 스레드 풀 튜닝

기본 스케줄러는 ** 단일 스레드 **입니다. 여러 스케줄 작업이 있으면 하나가 오래 걸릴 때 다른 작업도 밀립니다.

JAVA
@Configuration
public class SchedulerConfig implements SchedulingConfigurer {

    @Override
    public void configureTasks(ScheduledTaskRegistrar registrar) {
        ThreadPoolTaskScheduler scheduler = new ThreadPoolTaskScheduler();
        scheduler.setPoolSize(5);                    // 스레드 5개
        scheduler.setThreadNamePrefix("scheduler-");
        scheduler.setErrorHandler(t ->
            log.error("스케줄 작업 에러: ", t));       // 에러 핸들링
        scheduler.setWaitForTasksToCompleteOnShutdown(true);
        scheduler.setAwaitTerminationSeconds(30);
        scheduler.initialize();

        registrar.setTaskScheduler(scheduler);
    }
}

또는 application.yml로 간단하게 설정할 수도 있습니다.

YAML
spring:
  task:
    scheduling:
      pool:
        size: 5
      thread-name-prefix: scheduler-
      shutdown:
        await-termination: true
        await-termination-period: 30s

예외 처리

@Scheduled 메서드에서 예외가 발생하면 기본적으로 로그만 남기고 다음 실행을 계속합니다. 하지만 명시적으로 처리하는 것이 좋습니다.

JAVA
@Scheduled(fixedRate = 60_000)
public void robustScheduledTask() {
    try {
        riskyOperation();
    } catch (TransientException e) {
        // 일시적 오류 — 다음 실행에서 재시도
        log.warn("일시적 오류, 다음 실행에서 재시도: {}", e.getMessage());
    } catch (Exception e) {
        // 심각한 오류 — 알림 발송
        log.error("스케줄 작업 실패: ", e);
        alertService.sendAlert("스케줄 작업 실패: " + e.getMessage());
    }
}

ShedLock — 분산 환경의 중복 실행 방지

서버 인스턴스가 여러 개일 때, 동일한 스케줄 작업이 모든 인스턴스에서 동시에 실행됩니다. ShedLock은 DB나 Redis를 이용한 분산 락으로 이를 방지합니다.

JAVA
// build.gradle
implementation 'net.javacrumbs.shedlock:shedlock-spring:5.16.0'
implementation 'net.javacrumbs.shedlock:shedlock-provider-jdbc-template:5.16.0'

DB 테이블 생성

SQL
CREATE TABLE shedlock (
    name       VARCHAR(64)  NOT NULL PRIMARY KEY,
    lock_until TIMESTAMP(3) NOT NULL,
    locked_at  TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
    locked_by  VARCHAR(255) NOT NULL
);

설정

JAVA
@Configuration
@EnableSchedulerLock(defaultLockAtMostFor = "10m")
public class ShedLockConfig {

    @Bean
    public LockProvider lockProvider(DataSource dataSource) {
        return new JdbcTemplateLockProvider(
            JdbcTemplateLockProvider.Configuration.builder()
                .withJdbcTemplate(new JdbcTemplate(dataSource))
                .usingDbTime()   // DB 서버 시간 사용
                .build()
        );
    }
}

사용

JAVA
@Component
public class DistributedBatchJob {

    @Scheduled(cron = "0 0 2 * * *")
    @SchedulerLock(
        name = "cleanupExpiredData",     // 락 이름 (고유해야 함)
        lockAtLeastFor = "5m",           // 최소 잠금 유지 시간
        lockAtMostFor = "30m"            // 최대 잠금 유지 시간
    )
    public void cleanupExpiredData() {
        // 여러 인스턴스 중 하나만 실행
        dataCleanupService.removeExpiredRecords();
    }
}
  • lockAtLeastFor: 작업이 빨리 끝나더라도 이 시간 동안은 다른 인스턴스가 실행하지 못하게 합니다. 매우 짧은 작업이 여러 인스턴스에서 동시에 실행되는 것을 방지합니다.
  • lockAtMostFor: 작업이 비정상적으로 오래 걸릴 때 락이 자동 해제되는 안전장치입니다.

동적 스케줄링

@Scheduled의 cron 표현식은 정적이지만, TaskScheduler를 직접 사용하면 런타임에 스케줄을 변경할 수 있습니다.

JAVA
@Service
@RequiredArgsConstructor
public class DynamicSchedulerService {
    private final TaskScheduler taskScheduler;
    private ScheduledFuture<?> scheduledFuture;

    public void scheduleTask(String cronExpression) {
        // 기존 스케줄 취소
        if (scheduledFuture != null) {
            scheduledFuture.cancel(false);
        }

이어서 스케줄링 작업을 정의합니다.

JAVA
        // 새로운 스케줄 등록
        scheduledFuture = taskScheduler.schedule(
            () -> {
                log.info("동적 스케줄 작업 실행");
                executeTask();
            },
            new CronTrigger(cronExpression)
        );
    }

    public void cancelTask() {
        if (scheduledFuture != null) {
            scheduledFuture.cancel(false);
            scheduledFuture = null;
        }
    }
}

@Scheduled vs Spring Batch vs Quartz

기준@ScheduledSpring BatchQuartz
복잡도단순중간높음
재시도/재개없음지원지원
분산 실행ShedLock 필요자체 지원클러스터 모드
대용량 처리부적합최적화됨스케줄링 특화
적합한 경우단순 반복 작업대용량 배치복잡한 스케줄 관리

주의할 점

1. 기본 스케줄러가 단일 스레드라서 작업이 밀린다

스프링의 기본 TaskScheduler는 스레드가 1개뿐입니다. 여러 @Scheduled 작업이 있을 때 하나가 오래 걸리면 다른 작업들이 예정 시간에 실행되지 못하고 밀립니다. 새벽 2시 정리 작업이 30분 걸리면, 2시 5분에 실행될 다른 작업이 2시 30분까지 지연됩니다. 반드시 pool-size를 작업 수에 맞게 늘려야 합니다.

2. 다중 인스턴스에서 같은 작업이 중복 실행된다

서버를 2대 이상 운영하면 동일한 @Scheduled 작업이 모든 인스턴스에서 동시에 실행됩니다. 정산 배치가 2번 실행되거나, 이메일이 중복 발송되는 운영 사고로 이어집니다. ShedLock 같은 분산 락을 적용하지 않으면 스케줄 작업의 멱등성을 보장할 수 없습니다.

3. fixedRate에서 작업 시간이 간격보다 길면 작업이 쌓인다

fixedRate = 5000인데 작업이 10초 걸리면, 이전 작업이 끝나자마자 밀린 작업이 즉시 실행됩니다. 작업 시간이 가변적인 외부 API 폴링 같은 경우에 fixedRate를 쓰면 작업이 계속 쌓여 스레드가 고갈될 수 있습니다. 작업 완료 후 대기가 필요하면 fixedDelay를 사용하세요.

정리

항목설명
fixedRate작업 시작 기준 주기. 작업이 겹칠 수 있음
fixedDelay작업 ** 완료** 기준 주기. 겹침 없음
cron특정 시간 실행. zone으로 타임존 지정
기본 스케줄러단일 스레드 — 반드시 pool-size 늘릴 것
다중 인스턴스ShedLock으로 중복 실행 방지 필수
대규모 배치Spring Batch 또는 Quartz 고려
댓글 로딩 중...