본문 바로가기

Java/Spring

How does @Async work? @Async를 지금까지 잘 못 쓰고 있었습니다(@Async 사용할 때 주의해야 할 것, 사용법)

@Async in Spring boot

스프링 부트에서 개발자에게 비동기 처리를 손쉽게 할 수 있도록 다양한 방법을 제공하고 있다.

대세는 Reactive stack, CompletableFuture를 쓰겠으나 역시 가장 쉬운 방법으로는 @Async annotation을 적용하는 것이다.

그래서 필자도 @Async를 비동기 작업이 필요한 메서드에 덕지덕지 발라놨으나 제대로 작동하지 않는 것을 알게 되었고 주의해야 할 내용과 잘 회피하는 방법(?)을 생각해봤다.


@Async 사용법

많은 블로그에도 정리가 잘 나와있는 방법이다.

1. @EnableAsync로 @Async를 쓰겠다고 스프링에게 알린다.

2. 비동기로 수행되었으면 하는 메서드위에 @Async를 적용한다.

스프링 가이드에도 마찬가지로 설명해준다.

만약에 별도로 @Async에 대한 설정이 없으면 새로운 비동기 작업을 스레드 풀에서 처리하는 게 아니라 새로운 스레드를 매번 생성해서 작업을 수행시키는 것이 디폴트 설정이다.

그래서 쓰레드풀을 빈으로 등록시켜줘서 자동으로 해당 스레드 풀로 작업을 넘기도록 설정한다.

@Configuration
@EnableAsync
public class AsyncThreadConfiguration {
	@Bean
	public Executor asyncThreadTaskExecutor() {
		ThreadPoolTaskExecutor threadPoolTaskExecutor = new ThreadPoolTaskExecutor();
		threadPoolTaskExecutor.setCorePoolSize(8);
		threadPoolTaskExecutor.setMaxPoolSize(8);
		threadPoolTaskExecutor.setThreadNamePrefix("jeong-pro-pool");
		return threadPoolTaskExecutor;
	}
}

위와 같이 configuration 클래스를 하나 만들고 Bean을 등록하면 자동으로 내가 만든 스레드 풀에 작업이 할당될 것이다.

springboot 2.0 이상이라면 auto configuration으로 Executor를 등록해주기 때문에 아래와 같이 설정 파일에서 설정해도 똑같이 스레드 풀이 생성 및 적용될 것이다. (application.yml)

spring:
  task:
    execution:
      pool:
        core-size: 8
        max-size: 8

위와 같이 설정단계를 거쳤으면 아래 코드처럼 @Async를 통해 호출할 수 있다.

@RestController
public class TestController {
	@Autowired
	private TestService testService;
	
	@GetMapping("/test1")
	public void test1() {
		for(int i=0;i<10000;i++) {
			testService.asyncHello(i);
		}
	}
}
@Service
public class TestService {
	private static final Logger logger = LoggerFactory.getLogger(TestService.class);
	
	@Async
	public void asyncHello(int i) {
		logger.info("async i = " + i);
	}
}

아래와 같이 설정하고 테스트를 해볼 수 있다.

보이는 대로 브라우저에서 "localhost:8080/test1"로 연결해보면 로그를 10,000번 찍는 과정이 비동기로 호출되는 것을 확인할 수 있다.

threadName을 보니 jeong-pro-pool로 적은 prefix가 잘 적용되어서 해당 풀을 사용하고 있음을 알 수 있고, 0부터 9999까지 순서대로 찍히는 게 아니라 비동기로 수행되기 때문에 순서가 뒤죽박죽인 것을 확인할 수 있다.

* 여기까지는 다른 블로그도 잘 설명해준다! (다른 블로그가 더 잘 설명해준다)


@Async is not a silver bullet

@Async는 은 탄환이 아니다.

  • private 메서드에는 적용이 안된다. public만 된다.
  • self-invocation(자가 호출)해서는 안된다. -> 같은 클래스 내부의 메서드를 호출하는 것은 안된다.

위와 같은 주의사항이 있다. (+ 리턴값에 대해서 void나 CompletableFuture<> 여야 한다는데...)

필자는 위의 테스트코드를 한 번 적용해보고 되니까, 아무 생각도 없이 "@Async만 붙이면 알아서 되겠구나" 했다. 

그런 생각으로 같은 클래스에 있는 private 내부 메서드에도 @Async 달고 그랬으니 안됐다...

아래 테스트를 보자.

@RestController
public class TestController {
	@Autowired
	private TestService testService;
	
	@GetMapping("/test2")
	public void test2() {
		for(int i=0;i<10000;i++) {
			testService.innerMethodCall(i);
		}
	}
}
@Service
public class TestService {
	private static final Logger logger = LoggerFactory.getLogger(TestService.class);
	
	@Async
	public void innerMethod(int i) {
		logger.info("async i = " + i);
	}
	
	public void innerMethodCall(int i) {
		innerMethod(i);
	}
}

위 코드를 테스트해보면 controller에서 testService.innerMethodCall()를 동기로 호출하지만 내부에서 하는 작업이 비동기로 @Async가 걸린 innerMethod를 호출하니까 결국에는 비동기로 로그가 찍힐 것을 예상할 수 있다.

하지만 틀렸다. 아래 처럼 하나의 스레드로 동기 처리됨을 볼 수 있다.

왜 그럴까?

https://dzone.com/articles/effective-advice-on-spring-async-part-1

위의 출처에서 제대로 설명해준다.

결론부터 말하면 AOP가 적용되어 Spring context에 등록되어 있는 빈 객체의 메서드가 호출되었을 때 스프링이 끼어들 수 있고 @Async가 적용되어 있다면 스프링이 메서드를 가로채서 다른 스레드(풀)에서 실행시켜주는 메커니즘이라는 것이다.

출처 - https://dzone.com/articles/effective-advice-on-spring-async-part-1

그렇기 때문에 위에 제약조건이었던 것들이 이해가 된다.

public이어야 가로챈 스프링의 다른 클래스에서 호출이 가능할 것이고,

self-invocation이 불가능 했던 이유도 spring context에 등록된 빈의 메서드 호출이어야 프록시를 적용받을 수 있기에 내부 메서드 호출은 프록시 영향을 받지 않기 때문이다.

위와 같은 문제를 겪은 다른 사람의 정보를 얻기 위해 구글 검색을 하던 중,

스택오버플로우에 올라온 답변중에 나름 신박한 방법이 있어 아래에 소개한다. (참고로 같은 기능을 하는 서비스를 두 개(sync, async)로 나눈 다는 답변도 있었다;;)

@Service
public class AsyncService {
	@Async
	public void run(Runnable runnable) {
		runnable.run();
	}
}

위와 같이 AsyncService를 하나 두고 해당 서비스는 유틸 클래스처럼 전역에서 사용하도록 두는 것이다.

@Async메서드 run을 통해 들어오는 Runnable을 그냥 실행만 해주는 메서드다.

@Service
public class TestService {
	private static final Logger logger = LoggerFactory.getLogger(TestService.class);
	@Autowired
	private AsyncService asyncService;
	
	public void innerMethod(int i) {
		logger.info("async i = " + i);
	}
	
	public void innerMethodCall(int i) {
		asyncService.run(()->innerMethod(i));
		
	}
}

그다음에 비동기 메서드 호출이 필요할 때 해당 서비스로 메서드를 호출해버리는 것이다.

저렇게 하니까 결과도 비동기로 처리하는 모습을 볼 수 있었다.

@Async annotation을 수행하는 자바 클래스의 내부를 보고 싶은데 어떻게 보는지 몰라서 못 봤다.

실제로 그 클래스가 runnable을 생성하고 메서드를 태워 보내는 방법이라면 위와 같은 해결방법도 나쁘지 않을 수 있겠다는 생각을 했다. (혹시 내부 수행 과정을 아시는 분은 댓글에 설명 부탁드립니다.)

(뭐 물론 Bean의 메서드만 호출할 꺼고 그 호출이 비동기이기를 바라는 것이라면 그냥 @Async만으로도 충분하겠지만 개인적인 경험에서는 service의 메서드는 동기로 호출되길 바라지만 내부에서 하는 기능(동작)에서 일부만 비동기로 실행되기를 바랐었다.)

그런데 개인적인 생각으로는 @Async를 안쓰는 방법이긴 하지만 차라리 CompletableFuture를 쓰되 해당 스레드 풀에서 실행되기를 바라면 아래와 같이 Executor를 주입받고 호출하는 것이 나을 것 같다.

@Service
public class TestService {
	private static final Logger logger = LoggerFactory.getLogger(TestService.class);
	@Autowired
	private Executor executor;
	
	public void innerMethod(int i) {
		logger.info("async i = " + i);
	}
	
	public void innerMethodCall(int i) {
		CompletableFuture.runAsync(()->innerMethod(i),executor);
		
	}
}

위 코드를 실행해도 executor로 등록한 jeong-pro-pool이 주입되어 해당 풀에서 작업들이 수행된다.

* 아, 참고로 void나 future가 아닌 String 리턴 값을 가진 메서드에 @Async를 달았는데도 잘 수행되었다... 테스트에 무슨 문제가 있는지 알고 싶다...

  • * 애플리케이션에서 사용하는 ThreadPool의 수가 여러 개가 있을 수 있습니다.
    이때 타입으로 Bean을 가져오게 되는데 같은 타입이라면 Bean 이름으로 구분해서 가져올 수 있습니다.
    흔히 아래와 같이 @Qualifier annotation으로 이름을 지정하는데 이용하지 않는 방법도 있습니다.
    @Qualifier("threadPoolTaskExecutor")
    @Autwired
    private Executor threadPoolTaskExecutor;

    1. Bean의 이름을 찾아오는게 기본입니다.
    따라서 쓰레드풀에 따로 이름을 지정해야합니다. -> @Bean(name="threadPoolTaskExecutor")
    이렇게 이름을 지정하지 않으면 기본적으로 메서드명이 Bean의 이름이 됩니다.
    @Bean
    public Executor myPool(){
    //...
    }
    이렇게 하면 myPool이라는 이름의 Executor가 등록됩니다.
    따라서 주입받을 때도 @Qualifier("myPool") 을 쓰시거나,
    @Autowired
    private Exeuctor mypool; 이런식으로 변수명을 아예 이름과 일치시켜버리면 @Qualifier를 쓰지 않아도 찾아서 주입이 됩니다.
    그런데 메서드명은 다른 클래스에 있으면 겹치는 경우도 있습니다.
    스프링에서 Exception을 발생시키지 않을 수 있도록 명시적인 방법이 좋을 듯합니다.

태그