티스토리 뷰

Spring

[Spring] @Async를 활용한 비동기 처리를 위한 올바른 설정 가이드

망나니개발자 2025. 4. 29. 10:00
반응형



 

1. @Async를 활용한 비동기 처리를 위한 올바른 설정 가이드


[ 스프링 부트의 @Async 기본 설정 ]

개발을 하다 보면 빠른 API 응답 등을 위해 특정 작업을 비동기로 처리해야 하는 경우가 있다. 스프링을 이용해 개발을 하는 경우에는 @Async를 사용해 비동기 처리를 위임하는 경우가 많은데, 관련 설정을 올바르게 해주지 않으면 서비스에 문제를 일으킬 수 있다.

@Async는 @Transactional과 동일하게 AOP 기반으로 프록시 패턴을 활용하여 스레드 풀에 작업을 제출하는 방식으로 동작한다. 따라서 @Async를 통해 제출한 작업을 수행할 스레드 풀이 필요한데, 스프링에서는 별도의 @Async 관련 설정을 해주지 않을 경우  TaskExecutorConfigurations 클래스를 통해서 SimpleAsyncTaskExecutor라는 처리자를 등록한다.

 

 

 

 

문제는 SimpleAsyncTaskExecutor가 각각의 작업을 처리하기 위해 매번 새로운 스레드가 생성된다는 것이다. 스레드 풀을 사용하지 않을 경우에는 다음과 같은 문제가 생길 수 있음을 다른 포스팅에서 살펴보았다.

  • 스레드 라이프 사이클 문제: 스레드의 생성과 종료에 많은 시간이 소요됨
  • 자원 낭비: 스레드마다 각자 독립적인 스택 영역이 필요한데, 많은 수의 스레드를 생성하면 지나치게 많은 메모리와 기타 시스템 리소스를 사용하며, 스레드 수가 많으면 스레드 간 컨텍스트 스위칭에 따른 비용이 증가함
  • 안정성 문제: 모든 시스템에는 생성할 수 있는 스레드의 개수가 제한되어 있으며, 만약 제한된 양을 모두 사용하고 나면 OutOfMemoryError가 발생하여 안정성이 떨어질 수 있음

 

따라서 @Asnyc를 통한 비동기 처리 시에 사용될 별도의 스레드 풀을 설정하고 지정해줄 필요가 있다.

 

 

 

 

[ 다양한 ThreadPoolExecutor 사용 전략들 ]

이전 포스팅에서 ThreadPoolExecutor를 살펴보았듯이, ThreadPoolExecutor는 기본적으로 다음과 같은 방식으로 동작한다.

  1. 작업 요청이 들어오면, core 사이즈 만큼 스레드를 생성함
  2. core 사이즈를 초과하면 큐에 작업을 넣음
  3. 큐를 초과하면 max 사이즈 만큼 스레드(초과 스레드)를 만들어 실행시키는데, 이는 설정된 시간을 넘어서도 활용되지 않으면 소멸됨
  4. 스레드의 수가 max 사이즈를 초과하면 정책에 따라 처리함(기본적으로 예외를 발생시킴)

 

 

예를 들어 기본 스레드 100개, 최대 스레드 200개, 큐의 크기는 무한대로 설정하면, 스레드의 수가 결코 최대 사이즈 만큼 늘어나지 않는다. 왜냐하면 큐가 가득차야지만 초과 스레드가 생성되기 때문이다. 따라서 가장 먼저 올바른 corePoolSize와 maxPoolSize를 설정해주는 것이 중요한데, 자바의 ThreadPoolExecutor는 기본적으로 3가지 정적 메서드를 제공한다. (엄밀히는 ThreadPoolExecutor가 갖는 Executors 인터페이스가 제공하는데, 보다 자세한 내용은 관련 포스팅을 참고해주도록 하자.)

  • newSingleThreadPool()
    • 단일 스레드 풀 전략으로 주로 테스트 용도로 사용됨
    • corePoolSize와 maxPoolSize가 동일하기 때문에 초과 스레드는 생성하지 않음
    • 큐 사이즈에 제한이 없음 (LinkedBlockingQueue)
  • newFixedThreadPool(nThreads)
    • N개의 고정 스레드 풀 전략으로 CPU, 메모리 리소스가 어느정도 예측 가능하여 안정적임
    • corePoolSize와 maxPoolSize가 동일하기 때문에 초과 스레드는 생성하지 않음
    • 큐 사이즈에 제한이 없음 (LinkedBlockingQueue)
  • newCachedThreadPool()
    • 캐시 스레드 풀 전략으로, 기본 스레드는 사용하지 않고 60초 생존 주기를 갖는 초과 스레드만 사용함(초과 스레드의 수에는 제한이 없음)
    • 놀고 있는 스레드가 많을 경우에는 종료시키고 작업이 많아지면 필요한 만큼 스레드를 새로 생성함
    • 큐 사이즈의 크기가 0이라 큐에 작업을 저장하지 않음 (SynchronousQueue)

 

 

대부분 안정적인 사용이 가능한 고정 스레드 풀이 좋은 선택지일텐데, 트래픽이 많아지는 상황에서는 오히려 서버 자원의 여유가 있음에도 불구하고 처리만 점점 느려지는 문제가 발생할 수 있다. 따라서 캐시 스레드 풀 전략과 같이 유연한 방법도 고려해봄직한데, 캐시 스레드 풀 경우에는 모든 요청이 대기하지 않고 스레드가 바로바로 처리하기 때문에 빠른 처리가 가능하지만 초과 스레드의 제한이 없다는 문제가 있다. 따라서 과도한 스레드 생성 및 활용으로 인해 시스템 리소스가 부족하여 시스템이 다운되는 등의 문제를 주의할 필요가 있다.

 

 

 

[ 스프링의 ThreadPoolTaskExecutor와 올바른 설정 가이드 ]

자바가 ThreadPoolExecutor를 제공하듯이, 스프링 프레임워크는 해당 객체를 합성으로 받아서 확장된 기능을 제공하는 ThreadPoolTaskExecutor를 제공한다. ThreadPoolTaskExecutor는 ThreadPoolExecutor를 빈 스타일로 구성할 수 있게 구현해둔 클래스로, 관리 및 모니터링(JMX 등)에 보다 적합하다고 볼 수 있다. ThreadPoolTaskExecutor의 기본 설정은 core pool size가 1이고, max pool size와 queue capacity는 무제한이다. 또한 예외 처리 역시 스프링 TaskExecutor 인터페이스의 스펙에 따라 작업이 거부될 경우 TaskRejectedException이 발생하므로 서비스에 맞는 적합한 설정을 반드시 해주어야 한다.

대부분의 상황에서는 고정 스레드 풀 전략이 적합하기 때문에, 기본적으로 corePoolSize와 maxPoolSize를 고정적으로 가져가는 것이 좋다. 어떤 값을 할당할 것인지는 하드웨어 스펙, 시스템 특성 및 트래픽 등에 따라 달라질 수 있기 때문에 반드시 서비스 모니터링 및 테스트를 통한 값으로 설정해주어야 한다. 아직 이러한 정보가 아직 부족한 상황이라면 기본적으로 시스템 코어의 수(Runtime.getRuntime().availableProcessors())를 활용하는 방법을 고려해봄직하다. 해당 값은 최신 자바 11 이상부터 컨테이너의 설정을 따라가고, 그렇지 않다면 하드웨어 스펙을 따라가기 때문에 해당 버전 미만을 사용중이라면 관련 설정을 참고하도록 하자.

모든 스레드가 사용중인 경우에 작업들을 보관할 큐의 크기 역시 서비스에 맞게 설정해주어야 하는데, 기본적으로 100만개 정도라면 충분히 비동기 작업을 처리할 수 있을 것이다.

또한 비동기로 처리된다는 것은 별도의 스레드에서 동작한다는 의미이기 때문에, MDC와 같이 비동기 스레드로 전달해주어야 하는 값을 TaskDecorator에 설정해주어야 한다. 그 외에도 예러가 발생할 경우에 핸들링하는 클래스도 챙겨줄 필요가 있다.

마지막으로 스레드가 처리할 수 없을 정도로 많은 작업이 채워진 경우에 어떻게 처리할 것인지에 대한 예외 정책 역시 중요하다. ThreadPoolExecutor는 다음과 같은 다양한 방식의 구현을 제공하며, 기본적으로 AbortPolicy를 활용해 예외를 발생시킨다.

  • AbortPolicy: 새로운 작업을 제출할 때 RejectedExecutionException 을 발생킴(기본 정책임)
  • DiscardPolicy: 새로운 작업을 조용히 버림
  • CallerRunsPolicy: 새로운 작업을 제출한 스레드가 대신해서 직접 작업을 실행함
  • 사용자 정의(RejectedExecutionHandler): 개발자가 직접 정의한 거절 정책을 사용함

 

 

이러한 부분을 참고하여 @Async를 설정하는 클래스를 다음과 같이 구현할 수 있다.

@Configuration
@EnableAsync
public class AsyncConfig implements AsyncConfigurer {

    @Bean(name = TaskExecutionAutoConfiguration.APPLICATION_TASK_EXECUTOR_BEAN_NAME)
    public ThreadPoolTaskExecutor asyncTaskExecutor() {
        ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
        executor.setCorePoolSize(32);
        executor.setMaxPoolSize(32);
        executor.setThreadNamePrefix("async");
        executor.setQueueCapacity(1000000);
        executor.setAwaitTerminationSeconds(5);
        executor.setWaitForTasksToCompleteOnShutdown(true);
        executor.setTaskDecorator(new MdcTaskDecorator());
        executor.initialize();
        executor.getThreadPoolExecutor().prestartAllCoreThreads();
        return executor;
    }

    @Override
    public Executor getAsyncExecutor() {
        return asyncTaskExecutor();
    }

    @Override
    public AsyncUncaughtExceptionHandler getAsyncUncaughtExceptionHandler() {
        return new AsyncExceptionHandler();
    }

    @Slf4j
    private static class AsyncExceptionHandler implements AsyncUncaughtExceptionHandler {

        @Override
        public void handleUncaughtException(Throwable throwable, Method method, Object... objects) {
            log.error("Error on async execution: ", throwable);
        }
    }

    private static class MdcTaskDecorator implements TaskDecorator {

        @Override
        public Runnable decorate(Runnable runnable) {
            Map<String, String> contextMap = MDC.getCopyOfContextMap();
            return () -> {
                try {
                    if (contextMap != null) {
                        MDC.setContextMap(contextMap);
                    }
                    runnable.run();
                } finally {
                    MDC.clear();
                }
            };
        }
    }
}

 

 

여기서 또 하나 중요한 부분이 prestartAllCoreThreads이다. 스레드 풀을 관리하는 ThreadPoolExecutor는 기본적으로 어떠한 스레드도 생성하지 않고, 요청이 들어오면 이를 처리하기 위한 기본 스레드를 corePoolSize 만큼 생성한다. 따라서 빠른 처리가 필요한 경우에는 스레드 생성을 위한 시간도 줄이는 것이 필요한데, 이를 처리해주는 것이 바로 prestartAllCoreThreads이다. 위에서 얘기한 부분을 요약 및 정리하면 다음과 같다. 이는 지극히 일반화한 설정에 불과하므로, 실제 서비스에 맞게 커스터마이징을 해주도록 하자.

  • corePoolSize = maxPoolSize로 동일하게 설정
  • corePoolSize와 maxPoolSize는 서비스에 맞게 테스트를 통해 설정
  • 큐의 크기 역시 서비스에 맞게 테스트를 통해 설정
  • TaskDecorator를 통한 필요 값 전달
  • 예외가 발생한 경우의 핸들러 구현
  • 기본 스레드에 대한 prestart 처리(prestartAllCoreThreads)

 

 

 

 

 

 

 

 

 

 

반응형
댓글
반응형
공지사항
최근에 올라온 글
최근에 달린 댓글
Total
Today
Yesterday
링크
TAG more
«   2025/11   »
1
2 3 4 5 6 7 8
9 10 11 12 13 14 15
16 17 18 19 20 21 22
23 24 25 26 27 28 29
30
글 보관함