Notice
Recent Posts
Recent Comments
Link
«   2024/09   »
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
Tags
more
Archives
Today
Total
관리 메뉴

DevYGwan

비동기 처리 시 @Async와 Springboot의 마법 본문

Study/Spring

비동기 처리 시 @Async와 Springboot의 마법

YGwan 2024. 7. 18. 03:08

기본적으로 스프링에서 비동기 처리를 할때, 기본적으로 비동기 처리에 필요한 Executor를 등록하고 @Async 어노테이션과 @EnableAsync을 사용해서 비동기 처리를 진행합니다. 이때 이에 대한 원리를 제대로 알지 못해 제가 등록한 Executor와 @Async 어노테이션에서 사용하는 Executor가 다른 문제를 확인했고 이를 공유하려고 합니다.

 

간단하게 위에서 말한 개념들을 설명하자면,

  • Executor : Java 표준 라이브러리에서 제공하는 인터페이스로, 비동기 작업을 실행하는 데 사용됩니다.
    • 구현체로 ThreadPoolExecutor, ForkJoinPool이 있습니다.
    • 쓰레드풀을 관리하고 작업을 실행하는 역할을 합니다.
  • @Async : 메서드를 비동기적으로 실행하도록 만들어줍니다.
    •  이를 통해 메서드 실행이 동기적으로 이루어지는 것이 아니라, 별도의 쓰레드에서 실행되도록 할 수 있습니다.
  • @EnableAsync : Spring 프레임워크에서 비동기 실행 기능을 활성화하는 데 사용되는 어노테이션입니다.
    • @Async 어노테이션이 붙은 메서드는 비동기적으로 실행되도록 설정합니다.
    • 비동기 실행과 관련된 설정(예: 쓰레드 풀 크기, 실행 전략 등)을 커스터마이징할 수 있습니다.
    • 비동기 실행을 위한 AOP 기반 프록시가 자동으로 생성됩니다.

 

그렇다면, 어떤 문제를 확인했는지 설명드리도록 하겠습니다.

@Configuration
@EnableAsync
public class AsyncConfigV1 {


    private static final int CORE_POOL_SIZE = 2;
    private static final int MAX_POOL_SIZE = 4;
    private static final int queueCapacity = 20;

    @Bean("CustomTaskExecutor")
    public Executor CustomTaskExecutor() {
        final ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
        executor.setCorePoolSize(CORE_POOL_SIZE);
        executor.setMaxPoolSize(MAX_POOL_SIZE);
        executor.setQueueCapacity(queueCapacity);
        executor.setThreadNamePrefix("CustomTaskExecutor-");
        executor.initialize();
        executor.setRejectedExecutionHandler(new ThreadPoolExecutor.CallerRunsPolicy());
        return executor;
    }
}

@Service
public class AsyncTestService {

    @Async
    public CompletableFuture<String> callOnlyAsync() {
        return CompletableFuture.completedFuture(Thread.currentThread().getName());
    }
}

이러한 코드로 제가 CustomTaskExecutor을 Bean으로 등록하고 @Async 어노테이션을 통해 특정 메서드를 비동기로 처리했습니다. 그 후, 테스트를 진행하니 제대로 비동기 처리가 되는 것을 확인 할 수 있었고 저는 제가 등록한 CustomTaskExecutor 을 사용해서 비동기 처리가 진행된다고 생각하고 있었습니다.

 

그러다 문득 의문이 들었습니다. 

 

@Async 어노테이션이 어떻게 내가 등록한 빈을 알고 이를 사용할까?

 

결론부터 말하자면, "이렇게 해서는 스프링에서 Default로 설정된 Executor를 사용하고 추가적으로 명시하거나 설정해야 내가 등록한 빈을 사용할 수 있다"가 정답입니다. 즉 저는 빈으로 Executor을 등록했지만, 단순히 등록만 했기 때문에 내가 등록한 빈을 사용하지 않고 비동기 처리가 됐습니다. 그렇다면 어떻게 스프링에서 이러한 처리가 되는지 설명드리도록 하겠습니다.

 


 

※  @EnableAsync 사용 후 Spring 비동기 처리과정

  1. @EnableAsync을 통해 Spring 컨텍스트에 비동기 지원이 활성화됩니다.
  2. AsyncConfigurationSelector라는 클래스를 통해 필요한 설정을 로드합니다.
  3. AsyncConfigurationSelector는 AsyncAnnotationBeanPostProcessor을 등록합니다.
  4. AsyncAnnotationBeanPostProcessor는 빈 생성 과정에서 @Async 어노테이션이 된 메서드를 찾아 AOP 기반의 프록시 객체를 생성합니다. 이때 해당 클래스 안에 Executor을 설정합니다.
  5. 프록시 빈은 원본 메서드의 기능을 수행하면서 비동기 실행을 처리합니다.
  6. 비동기 실행 시, 프록시 빈은 비동기 실행을 위해 Executor 구현체를 사용하여 작업을 실행합니다.

 

그렇다면, 이러한 기본 설정이 어떻게 처리가 되는지 코드를 통해 확인해보도록 하겠습니다.

 

※  처리과정을 공식 문서 코드를 통해 확인하기

위에서 설명한 공식문서를 실제 spring-framework 코드를 통해 확인해보도록 하겠습니다. 코드 출처는 아래와 같습니다.

https://github.com/spring-projects/spring-framework/tree/main/spring-context/src/main/java/org/springframework/scheduling/annotation

 

spring-framework/spring-context/src/main/java/org/springframework/scheduling/annotation at main · spring-projects/spring-framew

Spring Framework. Contribute to spring-projects/spring-framework development by creating an account on GitHub.

github.com

 

 

※  AsyncConfigurationSelector

* @author Chris Beams
 * @author Juergen Hoeller
 * @since 3.1
 * @see EnableAsync
 * @see ProxyAsyncConfiguration
 */
public class AsyncConfigurationSelector extends AdviceModeImportSelector<EnableAsync> {

	private static final String ASYNC_EXECUTION_ASPECT_CONFIGURATION_CLASS_NAME =
			"org.springframework.scheduling.aspectj.AspectJAsyncConfiguration";


	/**
	 * Returns {@link ProxyAsyncConfiguration} or {@code AspectJAsyncConfiguration}
	 * for {@code PROXY} and {@code ASPECTJ} values of {@link EnableAsync#mode()},
	 * respectively.
	 */
	@Override
	@NonNull
	public String[] selectImports(AdviceMode adviceMode) {
		return switch (adviceMode) {
			case PROXY -> new String[] {ProxyAsyncConfiguration.class.getName()};
			case ASPECTJ -> new String[] {ASYNC_EXECUTION_ASPECT_CONFIGURATION_CLASS_NAME};
		};
	}

}

AsyncConfigurationSelector 예시 코드는 이렇게 되어 있습니다. 이에 대해서 간단히 설명드리면, selectImports 메서드의 경우 Spring에서 비동기 처리를 설정할 때 @EnableAsync 어노테이션의 mode 속성에 따라 적절한 구성 클래스를 선택하여 반환하는 역할을 합니다. 기본적으로 @EnableAsync 어노테이션의 mode 속성은 PROXY 입니다. 그렇다면, 해당 프록시와 관련된 클래스인 ProxyAsyncConfiguration 코드를 확인해보도록 하겠습니다.

 

※  ProxyAsyncConfiguration

* @author Chris Beams
 * @author Stephane Nicoll
 * @author Juergen Hoeller
 * @since 3.1
 * @see EnableAsync
 * @see AsyncConfigurationSelector
 */
@Configuration(proxyBeanMethods = false)
@Role(BeanDefinition.ROLE_INFRASTRUCTURE)
public class ProxyAsyncConfiguration extends AbstractAsyncConfiguration {

	@Bean(name = TaskManagementConfigUtils.ASYNC_ANNOTATION_PROCESSOR_BEAN_NAME)
	@Role(BeanDefinition.ROLE_INFRASTRUCTURE)
	public AsyncAnnotationBeanPostProcessor asyncAdvisor() {
		Assert.state(this.enableAsync != null, "@EnableAsync annotation metadata was not injected");
		AsyncAnnotationBeanPostProcessor bpp = new AsyncAnnotationBeanPostProcessor();
		bpp.configure(this.executor, this.exceptionHandler);
		Class<? extends Annotation> customAsyncAnnotation = this.enableAsync.getClass("annotation");
		if (customAsyncAnnotation != AnnotationUtils.getDefaultValue(EnableAsync.class, "annotation")) {
			bpp.setAsyncAnnotationType(customAsyncAnnotation);
		}
		bpp.setProxyTargetClass(this.enableAsync.getBoolean("proxyTargetClass"));
		bpp.setOrder(this.enableAsync.<Integer>getNumber("order"));
		return bpp;
	}

ProxyAsyncConfiguration 클래스 코드는 이렇게 되어 있습니다. 뭔가 처리를 많이 하는군여... 근데 뭔가 위에서 설명한  @EnableAsync 사용 후 Spring 비동기 처리과정과 관련된 내용이 많이 있는 것 같습니다. 이에 대한 코드 중 중요한 부분을 분석해보면, 

  • Assert.state(this.enableAsync != null, "@EnableAsync annotation metadata was not injected");
    • this.enableAsync가 null이 아닌지 확인합니다. 이는 @EnableAsync 어노테이션이 있는지를 확인하고 없다면 에러를 발생시키는 코드입니다.
  • AsyncAnnotationBeanPostProcessor bpp = new AsyncAnnotationBeanPostProcessor();
    • AsyncAnnotationBeanPostProcessor 인스턴스 생성
  • bpp.configure(this.executor, this.exceptionHandler);
    • executor와 exceptionHandler를 사용하여 AsyncAnnotationBeanPostProcessor를 구성합니다
  • bpp에 대한 추가적인 설정을 끝낸 후 bbp(AsyncAnnotationBeanPostProcessor)를 return 한다.

 

이러한 과정을 통해 @Async 어노테이션이 처리됩니다. 코드와 함께 보니 어떻게 진행되는지를 좀 더 잘 이해할 수 있었습니다.

 

그렇다면, AsyncAnnotationBeanPostProcessor이 어떤 Executor을 사용해서 처리가 될까요?

 

spring docs 공식문서

위의 내용은 어떻게 Executor을 사용하는지에 대한 설명입니다. 이를 요약하자면, 기본적으로 메서드에 @Async을 지정할 때 사용되는 Executor는

  1. AsyncConfigurer가 있는 경우 해당 구현체에서 선언한 Executor을 사용합니다.
  2. 메서드를 실행할 때 기본값이 아닌 Executor 사용하고 싶은 경우, @Async 어노테이션의 value 값을 통해 사용할 수 있습니다.

 

그래서 전, AsyncConfigurer 코드를 확인해봤습니다.

* @author Chris Beams
 * @author Stephane Nicoll
 * @since 3.1
 * @see AbstractAsyncConfiguration
 * @see EnableAsync
 */
public interface AsyncConfigurer {

	/**
	 * The {@link Executor} instance to be used when processing async
	 * method invocations.
	 */
	@Nullable
	default Executor getAsyncExecutor() {
		return null;
	}

	/**
	 * The {@link AsyncUncaughtExceptionHandler} instance to be used
	 * when an exception is thrown during an asynchronous method execution
	 * with {@code void} return type.
	 */
	@Nullable
	default AsyncUncaughtExceptionHandler getAsyncUncaughtExceptionHandler() {
		return null;
	}
  • 위에서 첨부한 공식 코드에 적혀 있는 내용입니다.
  • 해당 인터페이스를 구현한 AsyncConfigurerSupport 클래스도 잇지만 주석에, @deprecated로 나와 있어 따로 설명은 생략하겠습니다.

 

그렇다면, 이는 return을 null로 한다는 건데 null 일 경우 어떻게 처리가 될지 확인해보기위해 전체적인 코드를 다시 찾아봤습니다. 그랬더니 spring-task.xsd 파일에 아래와 같은 내용이 있었습니다.

요악하자면, 크게 3가지로 정리할 수 있습니다.

  • 비동기 메서드를 호출할 때 사용할 Executor가 지정되지 않을 경우 SimpleAsyncTaskExecutor가 기본으로 사용됩니다.
  • Spring 3.1.2를 기준으로 @Async에 개별적인 Executor을 부여될 수 있습니다.
  • 여기에 지정된 Executor는 자격이 없는 @Async에 기본값으로 작용한다는 것을 의미합니다.

 


 

 

 

그렇다면, 해당 부분이 실제로 그렇게 동작하는지 테스트를 진행해보도록 하겠습니다.
테스트는 3개의 시나리오로 Test를 진행하도록 하겠습니다.

 

※  시나리오 1 - 사용할 Executor가 지정되지 않을 경우 @Async 테스트

@Service
public class AsyncTestService {

    @Async
    public CompletableFuture<String> callOnlyAsync() {
        return CompletableFuture.completedFuture(Thread.currentThread().getName());
    }
}


@SpringBootTest
class AsyncV1TestServiceTest {

    @Autowired
    public AsyncTestService asyncTestService;
    
    @DisplayName("비동기 처리 시 @Async 어노테이션에 아무것도 없을 때 테스트")
    @Test
    void test() throws ExecutionException, InterruptedException {
        var response = asyncTestService.callOnlyAsync();

        System.out.println(response.get());
    }
  }

 

  • 첫번째 시나리오는 별도의 Executor을 등록하지 않고 @Async 어노테이션을 사용했을 때, 해당 로직을 사용하는 쓰레드의 name을 출력하는 로직
  • 테스트 결과 : SimpleAsyncTaskExecutor-1 출력
  • 결과 해석 : 사용할 Executor가 지정되지 않을 경우 SimpleAsyncTask Executor가 기본으로 사용
  • 스프링 컨테이너에는 따로 SimpleAsyncTask Executor가 따로 빈으로 등록되지 않음

 

※  시나리오 2 - AsyncConfigurer을 상속 받아 구현체를 구현 후 @Async 테스트

@EnableAsync
@Configuration
public class AsyncConfigV2 implements AsyncConfigurer {

    private static final int CORE_POOL_SIZE = 2;
    private static final int MAX_POOL_SIZE = 4;
    private static final int queueCapacity = 20;

    @Override
    public Executor getAsyncExecutor() {
        final ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
        executor.setCorePoolSize(CORE_POOL_SIZE);
        executor.setMaxPoolSize(MAX_POOL_SIZE);
        executor.setQueueCapacity(queueCapacity);
        executor.setThreadNamePrefix("AsyncConfigurerThreadExecutor-");
        executor.initialize();
        executor.setRejectedExecutionHandler(new ThreadPoolExecutor.CallerRunsPolicy());
        return executor;
    }
}

@Service
public class AsyncTestService {

    @Async
    public CompletableFuture<String> callOnlyAsync() {
        return CompletableFuture.completedFuture(Thread.currentThread().getName());
    }
}

@SpringBootTest
class AsyncV1TestServiceTest {

    @Autowired
    public AsyncTestService asyncTestService;
    
    @DisplayName("비동기 처리 시 AsyncConfigurer로 Executor 선언 후 테스트")
    @Test
    void test() throws ExecutionException, InterruptedException {
        var response = asyncTestService.callOnlyAsync();

        System.out.println(response.get());
    }
  }
  • 두번째 시나리오는 AsyncConfigurer을 상속 받은 클래스를 구현하고 @Async 어노테이션을 사용했을 때, 해당 로직을 사용하는 쓰레드의 name을 출력하는 로직
  • 테스트 결과 : AsyncConfigurerThreadExecutor-1 출력
  • 결과 해석 : AsyncConfigurer을 상속 받아 구현체를 구현하면 해당 구현체에서 선언한 Executor을 사용
  • AsyncConfigurer에서 구현한 Executor는 스프링 컨테이너에 "applicationTaskExecutor" 로 저장됨

 

※  시나리오 3 - Executor 직접 구현해 빈에 등록하고, @Async("이름")을 통해 선언 후 @Async 테스트

@Configuration
@EnableAsync
public class AsyncConfigV1 {


    private static final int CORE_POOL_SIZE = 2;
    private static final int MAX_POOL_SIZE = 4;
    private static final int queueCapacity = 20;

    @Bean("CustomTaskExecutor")
    public Executor CustomTaskExecutor() {
        final ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
        executor.setCorePoolSize(CORE_POOL_SIZE);
        executor.setMaxPoolSize(MAX_POOL_SIZE);
        executor.setQueueCapacity(queueCapacity);
        executor.setThreadNamePrefix("CustomTaskExecutor-");
        executor.initialize();
        executor.setRejectedExecutionHandler(new ThreadPoolExecutor.CallerRunsPolicy());
        return executor;
    }
}

@Service
public class AsyncTestService {

    @Async("CustomTaskExecutor")
    public CompletableFuture<String> callOnlyAsync() {
        return CompletableFuture.completedFuture(Thread.currentThread().getName());
    }
}

@SpringBootTest
class AsyncV1TestServiceTest {

    @Autowired
    public AsyncTestService asyncTestService;
    
    @DisplayName("비동기 처리 시 @Async에 Executor 선언 후 테스트")
    @Test
    void test() throws ExecutionException, InterruptedException {
        var response = asyncTestService.callOnlyAsync();

        System.out.println(response.get());
    }
  }
  • 마지막 시나리오는 Executor를 직접 빈으로 등록하고, @Async 어노테이션의 value 값에서 해당 Executor를 선언했을 때, 해당 로직을 사용하는 쓰레드의 name을 출력하는 로직
  • 테스트 결과 : CustomTaskExecutor-1 출력
  • 결과 해석 : Custom Executor 구현해 빈으로 등록하고, @Async("빈 이름")을 통해 선언해주면 해당 Executor을 사용합니다.
  • Custom Executor 스프링 컨테이너에 @Bean에 설정한 이름인, "CustomTaskExecutor" 로 저장됨

 

정리하자면, @Async은 메서드를 비동기적으로 실행하도록 만들어주는데, 이때 Executor을 사용하여 비동기에 사용할 쓰레드 풀 환경을 제공해줍니다. 이때 @Async 처리 된 메서드가 사용하는 Executor는

1. 사용할 Executor가 지정되지 않을 경우 SimpleAsyncTask Executor가 기본으로 사용됩니다.
2. AsyncConfigurer을 상속 받아 구현체를 구현하면 해당 구현체에서 선언한 Executor을 사용합니다.
3. Custom Executor 구현해 빈으로 등록하고, @Async("빈 이름")을 통해 선언해주면 해당 Executor을 사용합니다.

 


※  저의 구현 코드에서의 문제점

Custom Executor 구현해 빈으로 등록하고 @Async 어노테이션을 사용해서 비동기 처리를 진행했지만, @Async 어노테이션에 value 값에 내가 구현한 Custom Executor의 빈 이름을 명시하지 않아서 생기는 문제였습니다. 위와 같이 테스트를 진행한 결과 저의 Custom Executor는 빈에 잘 등록이 되어 있지만, @Async을 사용한 메서드에서는 Executor를 SimpleAsyncTaskExecutor을 사용하는 것으로 확인했습니다. 이후에, value로 명시하거나 Config 클래스에 AsyncConfigurer을 상속하여 구현하는 방식으로 문제를 해결했습니다.

 그렇다면, SimpleAsyncTaskExecutor랑 제가 구현한 Custom Executor(ThreadPoolTaskExecutor) 의 차이를 설명하는 것으로 이번 블로그를 마치겠습니다.

 

※  SimpleAsyncTaskExecutor  vs  ThreadPoolTaskExecutor

특성 SimpleAsyncTaskExecutor ThreadPoolTaskExecutor
쓰레드 생성 매번 새로운 쓰레드를 생성 쓰레드 풀을 관리하고, 재사용 가능
대기 큐 사용하지 않음 작업 대기 큐를 사용하여, 큐에 대기할 수 있음
쓰레드 수 제한 무제한 코어 쓰레드 수와 최대 쓰레드 수 설정 가능
쓰레드 이름 SimpleAsyncTaskExecutor-1, SimpleAsyncTaskExecutor-2, ... 사용자 설정 가능 (ThreadNamePrefix 속성)
  •  SimpleAsyncTaskExecutor는 작업이 많아질 때마다 새로운 쓰레드를 계속 생성하므로, 시스템 자원이 부족해질 수 있어 많은 작업을 비동기로 처리해야 할 때 성능 문제를 일으킬 수 있습니다.
  • ThreadPoolTaskExecutor는 기존 쓰레드를 재사용하여 효율적으로 리소스 관리가 가능하고 쓰레드 수 제한이 가능합니다. 또한 작업 큐를 사용하여 들어오는 작업을 큐에 저장하고 처리할 수 있어 비동기 처리 시 많은 장점을 가지고 있습니다.