본문 바로가기
💻 트러블슈팅 (에러 해결)

스케줄러가 멈췄어요! 스프링 @Scheduled 단일 스레드 병목 원인과 ThreadPool 완벽 해결법

by 데브프리 2026. 8. 16.

안녕하세요, 여러분의 든든한 IT 길잡이 데브프리입니다.

 

백엔드 서버를 운영하다 보면 특정 시간에 자동으로 실행되어야 하는 배치(Batch) 작업이나 알림 기능이 필수적으로 요구됩니다. 스프링 부트(Spring Boot)에서는 이를 위해 메서드 위에 @Scheduled(cron = "...") 어노테이션 하나만 툭 얹어주면 마법처럼 타이머가 작동하는 아주 편리한 기능을 제공합니다.

 

하지만 개발 서버에서는 한 치의 오차도 없이 잘 돌던 스케줄러가, 실서버에 배포하고 데이터가 쌓이기 시작하면 갑자기 이상하게 동작하곤 합니다. 아침 9시에 정확히 발송되어야 할 '출근 알림' 메시지가 9시 10분에 발송되거나, 아예 실행조차 되지 않고 서버가 조용히 침묵하는 무시무시한 현상이 발생하죠. 콘솔을 확인해 봐도 빨간 에러 로그 하나 없습니다.

 

"도대체 에러도 없는데 스케줄러가 왜 제시간에 안 도는 걸까요?"

 

오늘은 실무에서 백엔드 개발자들이 무심코 사용했다가 낭패를 보는 스프링 @Scheduled의 '단일 스레드(Single Thread) 병목 현상'을 객관적으로 분석하고, 이를 가장 우아하게 해결하는 스레드 풀(Thread Pool) 최적화 트러블슈팅 가이드를 짚어드리겠습니다.

 

1. 실전 트러블슈팅: 로그는 조용한데 알림이 지연돼요

문제가 발생하는 전형적인 실무 백엔드 코드입니다. 매일 아침 9시에 동작해야 하는 두 개의 스케줄러가 있습니다.

Java

@Service
public class DailySchedulerService {

    // 작업 A: 매일 아침 9시 정각에 외부 API에서 대량의 데이터를 동기화 (약 10분 소요)
    @Scheduled(cron = "0 0 9 * * *")
    public void syncExternalData() {
        log.info("데이터 동기화 시작...");
        // 무거운 네트워크 I/O 및 DB Insert 작업 진행
    }

    // 작업 B: 매일 아침 9시 정각에 사용자들에게 모닝 푸시 알림 발송 (약 1초 소요)
    @Scheduled(cron = "0 0 9 * * *")
    public void sendMorningPush() {
        log.info("모닝 푸시 알림 발송 완료!");
    }
}

여러분의 기대대로라면 9시 정각에 작업 A와 작업 B가 동시에 출발해야 합니다.

하지만 서버의 실제 실행 로그를 까보면 충격적인 사실을 알 수 있습니다.

Plaintext

09:00:00.001 [scheduling-1] INFO DailySchedulerService - 데이터 동기화 시작...
09:10:00.042 [scheduling-1] INFO DailySchedulerService - 모닝 푸시 알림 발송 완료!

로그의 스레드 이름을 유심히 보세요. 두 작업 모두 [scheduling-1] 이라는 동일한 스레드에서 실행되고 있습니다.

작업 A가 시작되어 10분 동안 스레드를 꽉 물고 놓아주지 않으니, 작업 B는 작업 A가 끝날 때까지 10분 동안 대기석에 앉아 무한정 기다리다가 9시 10분이 되어서야 지각 발송을 하게 된 것입니다.

 

2. 왜 지연됐을까? (기본 스레드 풀 사이즈 '1'의 함정)

이 어처구니없는 지연의 원인은 스프링 부트의 기본 자동 설정(Auto Configuration) 구조에 있습니다.

 

스프링 부트가 @EnableScheduling을 만나 스케줄러를 세팅할 때 사용하는 ThreadPoolTaskScheduler의 기본 스레드 풀 사이즈(Pool Size)는 정확히 '1'입니다. 즉, 일꾼이 단 한 명뿐입니다.

 

일꾼이 한 명인데 동시에 2개, 3개의 스케줄링 작업이 예약되면 어떻게 될까요? 먼저 큐(Queue)에 들어간 작업이 끝날 때까지 뒤에 있는 모든 스케줄링 작업은 병목(Bottleneck) 구간에 갇혀 실행을 거부당합니다.

단일 스레드(Single Thread) 환경에서의 스케줄러 병목 현상 톨게이트 비유
일꾼(스레드)이 단 하나뿐인 기본 설정에서는, 무겁고 오래 걸리는 작업 하나가 톨게이트를 막아버리면 뒤이은 모든 가벼운 스케줄링 작업들이 무한정 지연되는 병목 현상이 발생합니다.

 

3. 해결 베스트 프랙티스: 스케줄러에 날개 달아주기

이 병목을 뚫어내는 방법은 아키텍처의 상황에 따라 두 가지 객관적인 실무 프랙티스로 나뉩니다.

해결책 1: 설정 파일(yml)로 스레드 풀 사이즈 늘리기

Spring Boot 2.1 이상(현재 최신 3.x 포함)부터는 별도의 자바 설정 코드(Config)를 작성할 필요 없이, application.yml에 단 한 줄만 추가하면 닫혀있던 톨게이트 차로를 시원하게 개방할 수 있습니다.

YAML

spring:
  task:
    scheduling:
      pool:
        size: 5 # 💡 스케줄러 전용 스레드 풀(일꾼)을 5명으로 늘립니다!
      thread-name-prefix: "my-scheduler-" # 스레드 이름 커스텀 (디버깅 용이)

이렇게 설정하고 서버를 켜면, 작업 A는 [my-scheduler-1]이 가져가서 10분 동안 일하고, 그와 동시에 작업 B는 대기하지 않고 비어있는 [my-scheduler-2]가 즉시 가져가서 정각에 푸시 알림을 발송하게 됩니다.

 

💡 [데브프리의 실무 꿀팁: "풀 사이즈 늘렸는데도 지연돼요!" Queue 용량의 함정]

spring.task.scheduling.pool.size를 늘렸는데도 여전히 작업이 밀린다고 호소하는 분들이 있습니다.

스케줄러 스레드 풀은 기본적으로 '무한대(Integer.MAX_VALUE) 크기의 Queue(대기열)'를 가집니다. 즉, 5개의 스레드가 모두 일하고 있을 때 6번째 작업이 들어오면, 새로운 스레드를 급하게 더 만드는 것이 아니라 이 무한한 큐에 작업을 밀어 넣고 한없이 기다리게 만듭니다. 따라서 순간적으로 수십 개의 스케줄링이 폭주하는 시스템이라면, 단순히 풀 사이즈만 늘릴 것이 아니라 스케줄러 전용 ThreadPoolTaskScheduler 빈(Bean)을 직접 등록하여 Queue Capacity(대기열 크기)를 제한하고, 거절 정책(RejectedExecutionHandler)을 세팅해야 메모리 누수와 무한 대기 현상을 완벽히 방어할 수 있습니다!

 

해결책 2: @Async와 조합하여 작업 위임하기 (가장 우아한 실무 표준)

스케줄러의 스레드 풀을 늘리는 것만으로도 문제는 해결되지만, 진정한 백엔드 최적화는 '트리거(Trigger) 역할'과 '실행(Execution) 역할'을 분리하는 것입니다.

 

스케줄러 스레드는 단지 "시간이 됐으니 일해!"라고 명령(트리거)만 내린 뒤 즉시 빠지고, 무거운 실제 작업은 비동기 작업 전용 스레드 풀(@Async)에게 위임하는 설계가 실무에서 가장 안정적입니다.

Java

@Service
public class DailySchedulerService {

    // 💡 스케줄러는 명령만 내리고 즉시 스레드를 반환합니다.
    @Scheduled(cron = "0 0 9 * * *")
    @Async // 💡 실제 작업은 별도의 비동기 스레드 풀(TaskExecutor)에서 실행됩니다!
    public void syncExternalData() {
        log.info("데이터 동기화 시작...");
    }

    @Scheduled(cron = "0 0 9 * * *")
    @Async 
    public void sendMorningPush() {
        log.info("모닝 푸시 알림 발송 완료!");
    }
}

이 방식을 사용하려면 스프링 부트 메인 클래스나 Config에 @EnableAsync가 필수적으로 붙어 있어야 합니다. 이 아키텍처를 도입하면 스케줄링 타이머가 밀리는 현상을 원천적으로 차단할 수 있습니다.

 

4. 전문가의 확고한 결론 (Verdict)

스프링 @Scheduled, 실무에 언제 도입하고 언제 피해야 할까요?

  • 👍 확실한 도입 타이밍: 서버가 단 1대(Single Instance)로 운영되는 사내 백오피스 시스템이거나, 단순한 DB 임시 데이터 삭제, 일일 통계 집계처럼 인프라 구조가 복잡하지 않은 모놀리식(Monolithic) 환경에서는 @Scheduled와 스레드 풀 튜닝만으로도 압도적으로 편리하고 훌륭한 성능을 낼 수 있습니다.
  • 🛑 도입을 절대 피해야 할 때 (다중 서버 환경): 트래픽을 감당하기 위해 스프링 부트 서버 인스턴스를 2대 이상으로 스케일 아웃(Scale-out)한 환경이라면 순수 @Scheduled 사용은 재앙을 부릅니다. 매일 아침 9시에 A서버와 B서버가 동시에 스케줄러를 실행하여, 고객에게 모닝 알림톡이 2번씩 발송되는 대참사가 터지기 때문입니다. 이처럼 다중 서버 환경에서는 어플리케이션 메모리에 의존하는 @Scheduled 대신, DB 락(Lock)을 활용하는 ShedLock을 도입하거나, 아예 별도의 분산 스케줄러 인프라(Quartz, Spring Batch + Jenkins, Airflow 등)로 스케줄링 제어권을 완벽히 분리해 내야 합니다.

"로그에 에러가 없으니 정상이다"라는 생각은 트래픽과 시간이 만들어내는 병목 현상을 놓치는 지름길입니다. 프레임워크가 제공하는 기본값(Default)의 한계를 명확히 이해하고 스레드의 흐름을 제어하는 것, 그것이 시니어 백엔드 개발자로 향하는 가장 확실한 길입니다.

 

항상 팩트에 기반한 객관적인 실무 가이드로 여러분의 삽질 시간을 줄여드리겠습니다. 다음 포스팅에서도 시스템을 견고하게 만드는 백엔드 최적화 팁으로 찾아뵙겠습니다!

 

👉 대규모 트래픽 처리 중 내 서버가 뻗을 위험도는? 카프카(Kafka) 리밸런싱 타임아웃 계산기 [링크]