Q&A - @Scheduled, @Async 과 ThreadPool

랏 뜨·2025년 10월 15일

 본 포스트는 학습 중 생긴 궁금증 해결 및 아이디어 검증에 관한 내용을 정리 및 공유용도가 아닌 기록 용도로 작성하였습니다.
학습 Q&A 태그 포스팅은 동일하게 아래와 같이 진행될 예정입니다.

  • 학습 중 생긴 여러 아이디어가 실현 가능한지 확인한 결과를 보관
  • 학습 중 생긴 궁금증을 해소한 과정 및 결과를 보관
  • 추후 개발에 해당 아이디어들을 참고하기 위한 기록 용도이므로 구조가 자유롭고 다소 두서없을 수 있으며, 자유롭게 작성



Q1. @Scheduled 메서드는 동기로 실행될까, 비동기로 실행될까?


A1. 동기 로 실행

  • 기본적으로 @Scheduled 메서드는 동기로 실행


Q2. @Scheduled 메서드를 사용할 때의 기본 스레드풀 타입생성 전략 은 무엇일까?


A2. DefaultManagedTaskScheduler 사용

  • 별도의 설정이 없다면, DefaultManagedTaskScheduler 를 생성
  • 내부적으로 SingleThreadScheduledExecutor스레드풀로 사용
    • 오직 하나의 스레드만 생성
    • 해당 스레드만 스레드풀에 저장 후 재사용
  • 모든 @Scheduled 메서드는 해당 스레드풀에서 관리

  • 따라서 @Scheduled 메서드는 기본적으로 동기로 실행되므로, 한 @Scheduled 메서드가 실행 중이면 완료시까지 다른 메서드들은 실행되지 못함
    • @Scheduled 메서드가 비슷한 시간에 여러 개 작성되어 있을 경우, 문제가 발생할 수 있음


Q3. @Scheduled 용 스레드풀을 만들 때 반환 타입 은?


A3. TaskScheduler 인터페이스, 혹은 ThreadPoolTaskScheduler

  • @Scheduled 메서드는 내부적으로 ScheduledAnnotationBeanPostProcessor -> TaskScheduler 인터페이스를 기준으로 동작
  • ThreadPoolTaskSchedulerTaskScheduler 인터페이스의 구현체
  • 보통 ThreadPoolTaskScheduler 타입으로 반환
    • 빈 등록 시 인터페이스 타입으로 반환하는 것이 일반적
    • 하지만 TaskScheduler 는 기능이 제한적
    • ThreadPoolTaskScheduler 를 사용하면 구체 클래스 설정 편리

@Scheduled 메서드가 자동으로 등록하는 스레드풀 빈 이름

  • @Scheduled 메서드는 taskScheduler 라는 이름의 스레드풀 빈을 먼저 탐색 후 사용
  • 메서드명taskScheduler 로 설정하여 빈으로 등록하면, @Scheduled 메서드에서 이 스레드풀 빈을 자동으로 DI 받아서 사용



Q4. @Async 메서드의 기본 스레드풀 타입생성 전략 은 무엇일까?


A4. SimpleAsyncTaskExecutor 사용

  • 별도의 설정이 없다면, SimpleAsyncTaskExecutor기본 스레드풀로 지정
  • 매번 새로운 스레드를 생성
    • 즉, 스레드를 재사용하지 않음
    • 요청이 많을 경우, 그만큼의 스레드를 새로 생성하여 작업하므로 성능 저하 또는 OOM 발생 가능
    • 매 스레드는 작업 종료 시 반납되어 GC 에 의해 처리가 되기는 함


Q5. @Async 용 스레드풀을 만들 때 반환 타입 은?


A5. Executor 사용

  • @Async 메서드는 내부적으로 AsyncExecutionAspectSupport -> Executor 인터페이스를 기준으로 동작
  • 보통 Executor 타입으로 반환
    • 일반적으로 빈 등록 시 인터페이스 타입으로 반환
    • 해당 인터페이스 타입 그대로 반환해도 충분

@Async 메서드가 자동으로 등록하는 스레드풀 빈 이름

  • @Async 메서드는 taskExecutor 라는 이름의 스레드풀 빈을 먼저 탐색 후 사용
  • 메서드명taskExecutor 로 설정하여 빈으로 등록하면, @Async 메서드에서 이 스레드풀 빈을 자동으로 DI 받아서 사용


Q6. @Async 스레드풀에는 QueueSize 를 정의하고, @Scheduled 스레드풀에는 QueueSizeMaxPoolSize 를 정의하지 않는 이유


A6. 자체 관리예측 가능 하기 때문

  • 사용자 요청으로 인한 @Async 메서드와는 달리, @Scheduled 메서드 실행 수는 개발자가 충분히 예측 가능
  • 따라서 CorePoolSize 만 설정해줘도 충분
    • 문제 발생 시 CorePoolSize 만 조금씩 증가
  • QueueSize 의 경우, ThreadPoolTaskScheduler 는 내부적으로 ScheduledThreadPoolExecutor 사용
    • ScheduledThreadPoolExecutor대기 큐를 자체적으로관리

🎯 따라서 별도의 큐 설정은 불필요하며, setPoolSize() 로 풀 사이즈만 조정해주면 된다.



💡 관련 토픽 아이디어


📌 @Async 메서드용 커넥션풀@Scheduled 메서드용 커넥션풀 을 직접 지정하여 사용


@Configuration
@EnableAsync
@EnableScheduling
public class ThreadPoolConfig {

	private final int core = Runtime.getRuntime().availableProcessors();
	
    @Bean
    public Executor taskExecutor() {
    	ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
        executor.setCorePoolSize(core);
        executor.setMaxPoolSize(core * 10);
        executor.setQueueCapacity(1000);
        executor.setThreadNamePrefix("async-");
        
        return executor;
    }
    
    @Bean
    public ThreadPoolTaskScheduler taskScheduler() {
    	ThreadPoolTaskScheduler scheduler = new ThreadPoolTaskScheduler();
        scheduler.setPoolSize(core * 2);
        scheduler.setThreadNamePrefix("scheduler-");
        
        return scheduler;
    }
}
  • taskExecutor 빈은 @Async 메서드에서 자동으로 사용
  • @Async 메서드는 일반적으로 I/O 바운드 작업일 확률이 높으므로, maxPoolSizecore * 10 으로 설정
    • 일반적으로 코어 수 * 10 까지는 안전하게 실행 가능
    • 그럼에도, 스레드풀 관련 작업은 모니터링 및 성능 테스트 진행 권장
  • queueCapacity1,000 으로 설정
    • 해당 큐에는 스레드가 바쁠 때 대기할 작업들을 보관
    • I/O 바운드 작업일 확률이 높으므로, 어느정도 넉넉하게 1,000개로 설정
    • 마찬가지로 모니터링 및 성능 테스트 권장
  • taskScheduler 빈은 @Scheduled 메서드에 자동으로 사용
  • @Scheduled 메서드는 예약 작업이므로, 개발자가 어느 정도 실행될지 예상 가능
    • corePoolSizecore 수 만큼, 혹은 넉넉하게 core * 2 로 설정
    • @Scheduled 로 설정한 작업이 많다면 그에 비례해 성능 테스트 및 부하 테스트 진행해가며 조율
  • ThreadNamePrefixasync-, scheduler-로 구분
    • 디버깅 편리


📌 각 용도의 스레드풀을 명시적으로 지정하는 방법

// ...
public class ThreadPoolConfig implements AsyncConfigurer, SchedulingConfigurer {
	// ...
    
    // @Async 전용 스레드풀 지정
    @Override
    public Executor getAsyncExecutor() {
    	return taskExecutor();
    }
    
    // @Scheduled 전용 스레드풀 지정
    @Override
    public void configureTasks(ScheduledTaskRegistrar registrar) {
    	registrar.setScheduler(taskScheduler());	// taskScheduler() 반환 타입을 Executor 로 변경
    }
}
  • 각 인터페이스를 구현
  • getAsyncExecutor() 에서 생성한 스레드풀 반환
    • @Async 메서드 전용 스레드풀로 사용
  • configureTasks() 에서 스케쥴러에 사용할 스레드풀 지정
    • taskScheduler() 반환 타입을 기존 ThreadPoolTaskScheduler -> Executor 로 변경
      • 사용될 스레드풀을 지정해주기 위함
    • registrar.setScheduler 에서 해당 스레드풀 사용하도록

‼️ 처음 방식으로 충분히 구현할 수 있으므로, 추천하는 방식은 아님



💡 설정에 사용될 코어 수 제한 등의 값들은 설정 파일@ConfigurationProperties 조합을 이용해 설정해두는 것이 효율적일 수 있다.


  • ex)
custom:
  thread-pool:
    async:
      core: #{T(java.lang.Runtime).getRuntime().availableProcessors()}
      max: #{T(java.lang.Runtime).getRuntime().availableProcessors() * 10}
      queue-capacity: 1000
    scheduler:
      pool: #{T(java.lang.Runtime).getRuntime().availableProcessors() * 2}
  • SpEL 식을 이용해서 동적으로 CPU 코어 수 환산
@Getter
@Setter
@Component
@ConfigurationProperties(prefix = "custom.thread-pool")
public class ThreadPoolProperties {
	private Async async = new Async();
    private Scheduler scheduler = new Scheduler();
    
    @Getter
    @Setter
    public static class Async {
    	private int core;
        private int max;
        private int queueCapacity;
    }
    
    @Getter
    @Setter
    public static class Scheduler {
    	private int pool;
    }
}
  • 설정 파일을 읽어 프로퍼티 빈으로 등록
  • 이후 ThreadPoolConfig 에서 해당 빈을 주입 받아서 사용
profile
기록

0개의 댓글