8 min read

<Spring> @Async 동작 원리

이번 포스팅에서는 스프링 환경에서 @Async 어노테이션을 통해 비동기로 메서드 로직을 처리하는 방법과 원리에 대해서 알아본다.

@Async 적용하기

우선 springboot 환경에서 @Async 를 적용하는 방법부터 먼저 알아보자. 아래와 같이 @EnableAsync 어노테이션을 추가해주자. 별도의 Config가 있다면 해당 클래스에 붙여주어도 상관없다. 해당 어노테이션이 있어야 @Async를 통해 비동기적으로 메서드 로직을 수행시킬 수 있다.

@EnableAsync
@SpringBootApplication
public class AsyncApplication {
	public static void main(String[] args) {
		SpringApplication.run(AsyncApplication.class, args);
	}
}

이후 @Async를 비동기로 로직이 처리되기 원하는 메서드에 추가해준다. 본인은 총 3가지 리턴 타입을 가지는 메서드에 붙여주었다. 만약 아래 3가지 타입이 아닌 리턴 타입을 가지는 메서드에 해당 어노테이션을 적용 시킬 경우,예외가 발생한다.

@Service
public class AsyncService {

  @Async
  public void returnVoid() {
    log.info("Thread name = {}", Thread.currentThread().getName());
    try {
      Thread.sleep(3000);
    } catch (InterruptedException e) {
      throw new RuntimeException(e);
    }
    System.out.println("BasicService.returnVoid");
  }

  @Async
  public Future<Integer> returnFuture() {
    log.info("Thread name = {}", Thread.currentThread().getName());
    try {
      Thread.sleep(3000);
    } catch (InterruptedException e) {
      throw new RuntimeException(e);
    }
    return new FutureImpl<>(1500);
  }

  @Async
  public CompletableFuture<Integer> returnCmplFuture() {
    log.info("Thread name = {}", Thread.currentThread().getName());
    try {
      Thread.sleep(3000);
    } catch (InterruptedException e) {
      throw new RuntimeException(e);
    }
    return CompletableFuture.completedFuture(1700);
  }
}

3가지 메서드에 대해서 간략히 설명하면 1) void 타입 2) Future<Integer> 타입 3) CompletableFuture<Integer> 타입을 각각 리턴한다. 이 메서드들 모두는 호출된 메서드의 로직을 수행하는 스레드의 이름을 출력한 뒤, 3000ms의 Blocking 되는 시간을 가진다. 위와 같은 리턴타입에 따라서 @Async 어노테이션이 동작하는 방식이 서로 다르다.

@Async 분석하기

해당 어노테이션을 분석하기 위해서 spring 프레임워크의 org.springframework.aop.interceptor 패키지에 존재하는 AsyncExecutionAspectSupport 클래스의 doSubmit 메서드를 살펴보자.

(참고로 아래 코드는 spring6 기준 코드이다.)

    @Nullable
	@SuppressWarnings("deprecation")
	protected Object doSubmit(Callable<Object> task, AsyncTaskExecutor executor, Class<?> returnType) {
		if (CompletableFuture.class.isAssignableFrom(returnType)) {
			return executor.submitCompletable(task);
		}
		else if (org.springframework.util.concurrent.ListenableFuture.class.isAssignableFrom(returnType)) {
			return ((org.springframework.core.task.AsyncListenableTaskExecutor) executor).submitListenable(task);
		}
		else if (Future.class.isAssignableFrom(returnType)) {
			return executor.submit(task);
		}
		else if (void.class == returnType || "kotlin.Unit".equals(returnType.getName())) {
			executor.submit(task);
			return null;
		}
		else {
			throw new IllegalArgumentException(
					"Invalid return type for async method (only Future and void supported): " + returnType);
		}
	}

첫 로직부터 살펴보면 인자로 들어온 returnType에 의해서 로직이 분기되는것을 확인할 수 있다.가장 먼저 CompletableFuture, ListenableFuture, Future 그리고 마지막으로 Void 타입 즉, 리턴 타입이 없는 경우에 대해서 각각 처리한다. 각각의 처리는 모두 executor에 실행할 메서드 로직이 담긴 Callable 타입의 task를 submit 하는 형태이다. 다음단계로 executor라는 스레드풀이 어떻게 작업을 처리하는지 확인해보자.

SimpleAsyncTaskExecutor

스프링에서 디폴트로 제공하는 SimpleAsyncTaskExecutor의 공식 레퍼런스를 읽어보면 아래 문장이 보인다. 쉽게 요약하면, 스레드를 재사용하는 우리가 알고있는 일반적인 방식의 스레드풀이 아니다라는 내용이다.그래서 사용자가 직접 스레드를 재사용하는 스레드풀을 사용하는것을 추천하고 있다.

NOTE: This implementation does not reuse threads! Consider a thread-pooling TaskExecutor implementation instead, in particular for executing a large number of short-lived tasks

그러면 디폴트로 설정된 SimpleAsyncTaskExecutor가 아닌 스레드풀을 사용하기 위해서는 어떻게 해야 할까?

간단하게 아래와 같이 config 설정으로 사용할 스레드풀을 등록시켜 주면된다.

@Configuration
public class ThreadPoolConfig {
    @Bean
    public Executor asyncThreadPool() {
        ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
        executor.setCorePoolSize(5);
        executor.setMaxPoolSize(10);
        executor.setThreadNamePrefix("Brido Thread - ");
        return executor;
    }
}

하지만, 사실 여기서 spirngboot를 사용할 경우 숨겨진 사실이 하나가 있다. 만약 위와 같은 빈을 등록하지 않고 AsyncService의 메서드 중 하나를 호출해보자.로그가 해당 로직를 수행하는 스레드 이름을 함께 보여주는데, 이때 스레드 이름이 “task-n”으로 설정되어 있을 것이다. 그러면 SimpleAsyncTaskExecutor에서 제공하는 스레드의 이름이 “task-n”임을 확인해보면 논리적으로 오류가 없다.하지만 내부의 스레드 생성 로직을 파악해보면 “SimpleAsyncTaskExecutor-n”으로 스레드 이름을 설정해준다. 즉, 스프링부트에서 자동으로 어떠한 config에 스레드풀을 등록시켜서 디폴트로 제공하는 스레드풀을 사용하지 않도록 해준것이다.

TaskExecutionAutoConfiguration

@SpringBootApplication에 존재하는 @EnableAutoConfiguration라는 어노테이션에 의해 TaskExecutionAutoConfiguration에 등록된 스레드풀 빈을 등록하고 개발자가 따로 스레드풀을 사용하지 않아도, SimpleAsyncTaskExecutor가 아닌 스레드풀을 사용하도록 해준다.마지막으로 확인해볼 것은 위 config를 통해 ThreadPoolTaskExecutor이라는 타입의 스레드풀을 제공하는데 이는 적절히 스레드를 재사용하는 스레드풀인지 확인해보자.

해당 클래스가 가지는 필드를 간단히 보면, java에서 제공하는 TaskExecutor에서 필요로 하는 다양한 스레드풀의 필드들을 전부 가지고 있음을 확인할 수 있다.특히 corePoolSize라는 필드를 가진다는것 자체가 스레드를 재사용하기 위한 프로퍼티이기에 스레드를 재사용하는 풀임을 유추해낼 수 있다.

public class ThreadPoolTaskExecutor extends ExecutorConfigurationSupport
		implements AsyncListenableTaskExecutor, SchedulingTaskExecutor {
    //...

	private final Object poolSizeMonitor = new Object();

	private int corePoolSize = 1;

	private int maxPoolSize = Integer.MAX_VALUE;

	private int keepAliveSeconds = 60;

	private int queueCapacity = Integer.MAX_VALUE;

	private boolean allowCoreThreadTimeOut = false;

	private boolean prestartAllCoreThreads = false;
    //...
}

주의사항

포스팅을 작성하면서 알게 된 사실을 간단히 정리해보면 아래와 같다. 스프링 부트 환경에서 @Async를 동작시킬 경우 어떤 스레드풀이 사용되는지 정리해보았다.

  1. 빈으로 어떠한 ThreadPoolTaskExecutor 를 등록하지 않은 경우
  2. 별도의 Config에 하나의 ThreadPoolTaskExecutor 등록한 경우
  3. n개의 ThreadPoolTaskExecutor을 등록한 경우

1번의 경우 앞서 살펴본것처럼 TaskExecutionAutoConfiguration에서 제공하는 스레드풀을 사용해서 비동기 로직을 처리한다.2번의 경우는 사용자가 설정한 스레드풀 타입을 활용해서 비동기 로직을 처리한다. 3번의 경우가 좀 특이한데, 뜬금없이 SimpleAsyncTaskExecutor를 통해 작업을 처리한다.

이를 확인하기 위해 우선 아래와 같이 복수개의 스레드풀을 빈으로 등록한다.

@Configuration
public class ThreadPoolConfig {
    @Bean
    public Executor asyncThreadPool1() {
        ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
        executor.setCorePoolSize(5);
        executor.setMaxPoolSize(10);
        executor.setThreadNamePrefix("Brido1 Thread - ");
        return executor;
    }

    @Bean
    public Executor asyncThreadPool2() {
        ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
        executor.setCorePoolSize(5);
        executor.setMaxPoolSize(10);
        executor.setThreadNamePrefix("Brido2 Thread - ");
        return executor;
    }
}

아래 @EnableAsync의 주석을 읽어보면 하나 이상의 빈이 등록된 경우는 빈 이름을 통해서 처리한다고 합니다. 즉, "taskExecutor”라는 이름의 빈을 찾아내고 이가 없을 경우는 디폴트로 설정된 SimpleAsyncTaskExecutor 사용한다는 것이죠.

By default, Spring will be searching for an associated thread pool definition: either a unique org.springframework.core.task.TaskExecutor bean in the context, or an java.util.concurrent.Executor bean named "taskExecutor" otherwise. If neither of the two is resolvable, a org.springframework.core.task.SimpleAsyncTaskExecutor will be used to process async method invocations

그러면 위의 config에서 스프링 빈 네임을 변경하여 확인해 볼 수 있습니다. 아래와 같이 변경한 뒤, 테스트 코드를 돌려보면 저희가 설정한 “Brido1 Thread -“ 프리픽스를 가지는 스레드가 사용됩니다.

    @Bean
    public Executor taskExecutor() {
        ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
        executor.setCorePoolSize(5);
        executor.setMaxPoolSize(10);
        executor.setThreadNamePrefix("Brido1 Thread - ");
        return executor;
    }

실제 사용할 때는?

사실 위와 같이 사용할 경우, 전역적으로 설정된 풀을 모든 메서드가 사용하기에 해당 스레드풀의 부하를 무시할 수 없을 것이다. 아마 실제 로직에서는 아래와 같이 @Async 마다 처리할 스레드풀을 설정하여 로직을 구성하지 않을까라고 예상해본다. 이는 CompletableFutre를 사용할 때도, 모든 체이닝 메서드가 common-pool을 사용할 경우 발생하는 부작용과 일맥상통하는 부분이라고 생각한다.

아래와 같이 자신이 사용할 스레드풀의 빈 이름을 명시해주면,해당 스레드풀을 통해 비동기 로직을 수행한다.

  @Async(value = "asyncThreadPool1")
  public CompletableFuture<Integer> returnCmplFuture() {
    log.info("Thread name = {}", Thread.currentThread().getName());
    log.info("Thread group name = {}", Thread.currentThread().getId());
    try {
      Thread.sleep(3000);
    } catch (InterruptedException e) {
      throw new RuntimeException(e);
    }
    return CompletableFuture.completedFuture(1700);
  }