Spring Scheduler ThreadPool 심화

@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 실전도 함께 참고하세요.

위로 스크롤
WordPress Appliance - Powered by TurnKey Linux