Theme:

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

@Scheduled란

@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();
    }

    // 타임존 지정
    @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);
        }

        // 새로운 스케줄 등록
        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 필요자체 지원클러스터 모드
대용량 처리부적합최적화됨스케줄링 특화
적합한 경우단순 반복 작업대용량 배치복잡한 스케줄 관리

정리

  • fixedRate는 작업 시작 기준, fixedDelay는 작업 완료 기준으로 주기를 결정합니다.
  • cron 표현식으로 특정 시간에 작업을 실행할 수 있고, zone 속성으로 타임존을 지정합니다.
  • 기본 스케줄러는 단일 스레드입니다. 여러 작업이 있다면 반드시 pool-size를 늘리세요.
  • 다중 인스턴스 환경에서는 ShedLock으로 중복 실행을 방지합니다.
  • 대용량 데이터 처리가 필요하면 Spring Batch를, 복잡한 스케줄 관리가 필요하면 Quartz를 고려하세요.
댓글 로딩 중...