@Scheduled — 주기적 작업을 스프링에서 실행하는 방법
매일 새벽에 데이터를 정리하거나, 5분마다 외부 API를 폴링해야 한다면 스프링에서 어떻게 구현할까요?
개념 정의
@Scheduled 는 메서드를 주기적으로 실행하도록 예약하는 스프링 어노테이션입니다. 별도의 스케줄러 프레임워크 없이 간단한 배치 작업이나 폴링 로직을 구현할 수 있습니다.
기본 설정
@Configuration
@EnableScheduling // 필수: 스케줄링 활성화
public class SchedulingConfig {
}
실행 방식 3가지
1. fixedRate — 일정 간격으로 실행
이전 작업의 시작 시점 부터 간격을 계산합니다.
@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 — 이전 작업 완료 후 대기
이전 작업의 완료 시점 부터 간격을 계산합니다. 작업이 겹치지 않습니다.
@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과 유사한 표현식으로 실행 시간을 지정합니다.
초 분 시 일 월 요일
0 0 2 * * * → 매일 새벽 2시
0 */5 * * * * → 5분마다
0 0 9 * * MON-FRI → 평일 오전 9시
0 0 0 1 * * → 매월 1일 자정
@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 비교
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 설정 — 스레드 풀 튜닝
기본 스케줄러는 ** 단일 스레드 **입니다. 여러 스케줄 작업이 있으면 하나가 오래 걸릴 때 다른 작업도 밀립니다.
@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로 간단하게 설정할 수도 있습니다.
spring:
task:
scheduling:
pool:
size: 5
thread-name-prefix: scheduler-
shutdown:
await-termination: true
await-termination-period: 30s
예외 처리
@Scheduled 메서드에서 예외가 발생하면 기본적으로 로그만 남기고 다음 실행을 계속합니다. 하지만 명시적으로 처리하는 것이 좋습니다.
@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를 이용한 분산 락으로 이를 방지합니다.
// build.gradle
implementation 'net.javacrumbs.shedlock:shedlock-spring:5.16.0'
implementation 'net.javacrumbs.shedlock:shedlock-provider-jdbc-template:5.16.0'
DB 테이블 생성
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
);
설정
@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()
);
}
}
사용
@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를 직접 사용하면 런타임에 스케줄을 변경할 수 있습니다.
@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
| 기준 | @Scheduled | Spring Batch | Quartz |
|---|---|---|---|
| 복잡도 | 단순 | 중간 | 높음 |
| 재시도/재개 | 없음 | 지원 | 지원 |
| 분산 실행 | 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 고려 |