@Scheduled의 숨겨진 함정
Spring의 @Scheduled는 간편하지만, 기본 설정에 치명적인 함정이 있습니다. 기본 스케줄러 스레드 풀 크기는 1입니다. 즉, 모든 @Scheduled 메서드가 하나의 스레드를 공유합니다. 한 작업이 오래 걸리면 다른 모든 스케줄 작업이 밀립니다.
// 문제 시나리오: 스레드 1개로 3개 작업 실행
@Scheduled(fixedRate = 1000) // 1초마다
public void fastTask() { /* 10ms 소요 */ }
@Scheduled(fixedRate = 5000) // 5초마다
public void slowTask() { /* 30초 소요! */ }
@Scheduled(cron = "0 * * * * *") // 매분
public void reportTask() { /* 2초 소요 */ }
// slowTask가 30초 동안 스레드를 점유하면
// fastTask, reportTask 모두 30초간 실행 불가!
이 글에서는 스케줄러 스레드 풀 최적 설정, TaskScheduler 커스터마이징, Virtual Thread 활용, 그리고 작업 모니터링 패턴까지 심층적으로 다룹니다.
스레드 풀 크기 설정
가장 기본적인 해결은 스레드 풀 크기를 늘리는 것입니다:
# application.yml — Spring Boot 3.x
spring:
task:
scheduling:
pool:
size: 5 # 기본값 1 → 5로 증가
thread-name-prefix: sched-
shutdown:
await-termination: true
await-termination-period: 30s
풀 크기 결정 기준:
- 동시 실행 가능한 @Scheduled 메서드 수를 세고, 그보다 1~2개 여유를 줍니다.
- I/O 바운드 작업이 많으면 더 크게, CPU 바운드면 코어 수 이하로 설정합니다.
- 너무 크면 메모리 낭비, 너무 작으면 작업 지연 — 모니터링 기반으로 조정하세요.
TaskScheduler 커스터마이징
yml 설정만으로 부족할 때 ThreadPoolTaskScheduler를 직접 빈으로 등록합니다:
@Configuration
@EnableScheduling
public class SchedulerConfig implements SchedulingConfigurer {
@Override
public void configureTasks(ScheduledTaskRegistrar taskRegistrar) {
taskRegistrar.setTaskScheduler(taskScheduler());
}
@Bean
public ThreadPoolTaskScheduler taskScheduler() {
var scheduler = new ThreadPoolTaskScheduler();
scheduler.setPoolSize(10);
scheduler.setThreadNamePrefix("app-sched-");
// 작업 실패 시 에러 핸들링
scheduler.setErrorHandler(t ->
log.error("Scheduled task failed: {}", t.getMessage(), t)
);
// 거부 정책: 풀이 가득 차면 호출 스레드에서 실행
scheduler.setRejectedExecutionHandler(
new ThreadPoolExecutor.CallerRunsPolicy()
);
// 종료 시 실행 중인 작업 대기
scheduler.setWaitForTasksToCompleteOnShutdown(true);
scheduler.setAwaitTerminationSeconds(60);
return scheduler;
}
}
용도별 스케줄러 분리
모든 스케줄 작업을 하나의 풀에 넣으면 중요한 작업이 덜 중요한 작업에 밀릴 수 있습니다. 용도별로 스케줄러를 분리하세요:
@Configuration
public class MultiSchedulerConfig {
// 크리티컬 작업용 (결제 정산, 알림 등)
@Bean("criticalScheduler")
public TaskScheduler criticalScheduler() {
var scheduler = new ThreadPoolTaskScheduler();
scheduler.setPoolSize(3);
scheduler.setThreadNamePrefix("critical-");
scheduler.setErrorHandler(t ->
alertService.sendSlack("Critical scheduler failed: " + t.getMessage())
);
return scheduler;
}
// 배치/리포트 작업용
@Bean("batchScheduler")
public TaskScheduler batchScheduler() {
var scheduler = new ThreadPoolTaskScheduler();
scheduler.setPoolSize(5);
scheduler.setThreadNamePrefix("batch-");
return scheduler;
}
// 헬스체크/모니터링 작업용
@Bean("monitorScheduler")
public TaskScheduler monitorScheduler() {
var scheduler = new ThreadPoolTaskScheduler();
scheduler.setPoolSize(2);
scheduler.setThreadNamePrefix("monitor-");
return scheduler;
}
}
특정 스케줄러를 지정하려면 프로그래매틱하게 등록합니다:
@Component
@RequiredArgsConstructor
public class ScheduledTasks implements InitializingBean {
@Qualifier("criticalScheduler")
private final TaskScheduler criticalScheduler;
@Qualifier("batchScheduler")
private final TaskScheduler batchScheduler;
@Override
public void afterPropertiesSet() {
// 크리티컬 스케줄러에 결제 정산 등록
criticalScheduler.scheduleAtFixedRate(
this::processSettlement, Duration.ofMinutes(5)
);
// 배치 스케줄러에 리포트 등록
batchScheduler.scheduleWithFixedDelay(
this::generateDailyReport, Duration.ofHours(1)
);
}
private void processSettlement() { /* ... */ }
private void generateDailyReport() { /* ... */ }
}
Virtual Thread 스케줄러 (Spring Boot 3.2+)
Java 21 Virtual Thread를 사용하면 스레드 풀 크기 고민 없이 스케줄 작업을 실행할 수 있습니다:
# application.yml
spring:
threads:
virtual:
enabled: true # Virtual Thread 전역 활성화
// Virtual Thread 기반 스케줄러
@Configuration
@EnableScheduling
public class VirtualThreadSchedulerConfig implements SchedulingConfigurer {
@Override
public void configureTasks(ScheduledTaskRegistrar taskRegistrar) {
taskRegistrar.setScheduler(
Executors.newScheduledThreadPool(0, Thread.ofVirtual()
.name("vsched-", 0)
.factory())
);
}
}
// 이제 @Scheduled 작업마다 Virtual Thread 생성
// → 스레드 풀 고갈 걱정 없음
@Scheduled(fixedRate = 1000)
public void ioHeavyTask() {
// I/O 대기 시 carrier thread 반환 → 수천 개도 가능
var result = restClient.get().uri("/api/data").retrieve().body(String.class);
processResult(result);
}
| 방식 | 장점 | 주의점 |
|---|---|---|
| Platform Thread Pool | 예측 가능, 리소스 제한 명확 | 풀 크기 튜닝 필요, 고갈 가능 |
| Virtual Thread | 풀 크기 무관, I/O 병렬성 극대화 | synchronized 블록에서 pinning, CPU 바운드에 비효율 |
작업 실행 보호: 중복 실행 방지
fixedRate로 설정하면 이전 실행이 끝나지 않았어도 새 실행이 시작됩니다. 이를 방지하는 패턴들:
// 패턴 1: fixedDelay 사용 (이전 완료 후 대기)
@Scheduled(fixedDelay = 5000) // 이전 완료 후 5초 대기
public void safeTask() { /* ... */ }
// 패턴 2: Lock으로 중복 방지
@Component
public class GuardedScheduler {
private final AtomicBoolean running = new AtomicBoolean(false);
@Scheduled(fixedRate = 10000)
public void guardedTask() {
if (!running.compareAndSet(false, true)) {
log.warn("Previous execution still running, skip");
return;
}
try {
doHeavyWork();
} finally {
running.set(false);
}
}
}
// 패턴 3: @SchedulerLock (분산 환경)
@Scheduled(cron = "0 */5 * * * *")
@SchedulerLock(
name = "settlement",
lockAtLeastFor = "PT1M", // 최소 1분 잠금
lockAtMostFor = "PT30M" // 최대 30분 잠금
)
public void distributedTask() {
// 여러 인스턴스 중 하나만 실행
}
모니터링: Micrometer 메트릭 통합
스케줄 작업의 실행 시간, 성공/실패를 모니터링합니다:
@Component
@RequiredArgsConstructor
public class MonitoredScheduler {
private final MeterRegistry registry;
@Scheduled(fixedRate = 60000)
public void monitoredTask() {
Timer.Sample sample = Timer.start(registry);
try {
doWork();
registry.counter("scheduler.task.success",
"task", "monitoredTask").increment();
} catch (Exception e) {
registry.counter("scheduler.task.failure",
"task", "monitoredTask",
"error", e.getClass().getSimpleName()).increment();
throw e;
} finally {
sample.stop(registry.timer("scheduler.task.duration",
"task", "monitoredTask"));
}
}
}
// AOP로 공통 모니터링 적용
@Aspect
@Component
@RequiredArgsConstructor
public class SchedulerMetricsAspect {
private final MeterRegistry registry;
@Around("@annotation(org.springframework.scheduling.annotation.Scheduled)")
public Object measureScheduledTask(ProceedingJoinPoint pjp) throws Throwable {
String taskName = pjp.getSignature().getName();
Timer.Sample sample = Timer.start(registry);
try {
Object result = pjp.proceed();
registry.counter("scheduled.completed", "task", taskName).increment();
return result;
} catch (Throwable t) {
registry.counter("scheduled.failed", "task", taskName).increment();
throw t;
} finally {
sample.stop(registry.timer("scheduled.duration", "task", taskName));
}
}
}
동적 스케줄 변경
런타임에 cron 표현식이나 실행 간격을 변경해야 할 때:
@Component
public class DynamicScheduler implements SchedulingConfigurer {
private final AppConfigRepository configRepo;
@Override
public void configureTasks(ScheduledTaskRegistrar registrar) {
registrar.addTriggerTask(
this::executeTask,
triggerContext -> {
// DB에서 cron 표현식 동적 로딩
String cron = configRepo.findByKey("report.cron")
.map(AppConfig::getValue)
.orElse("0 0 * * * *"); // 기본: 매시
return new CronTrigger(cron)
.nextExecution(triggerContext);
}
);
}
private void executeTask() {
// 실행 시점마다 DB에서 최신 cron 확인
}
}
마무리
Spring @Scheduled의 기본 스레드 풀 크기 1은 프로덕션에서 반드시 조정해야 하는 설정입니다. 용도별 스케줄러를 분리하고, Virtual Thread로 I/O 병목을 해소하며, Micrometer 메트릭으로 실행 상태를 모니터링하면 안정적인 스케줄링 인프라를 구축할 수 있습니다.
관련 글로 Spring ShedLock 분산 스케줄링과 Spring Virtual Thread 실전도 함께 참고하세요.