본문 바로가기

카테고리 없음

@Async 파해치기

최근 프로젝트에서 비동기로 메소드를 처리하여 응답 속도를 높이기 위해 @Async 어노테이션을 사용한적 있었다. 하지만 기능 구현에 급급해 @Async가 어떻게 동작하는지, 주의할 점은 무엇인지 알지 못한 채 단순히 어노테이션을 적용만했기 때문에 동작 과정을 살펴보기로 했다.

 

우선 간단히 어떻게 동작하는지 살펴보자

우선 @EnableAsync 어노테이션을 Application 클래스 위에 붙여준다.

그리고 위와 같이 비동기로 처리하려는 메소드에 @Async 어노테이션을 적용하면 된다.

위 코드로 메소드를 만약 동기적으로 호출한다면 

  1. start test method 출력
  2. end test method 출력
  3. service start 출력

위 순서대로 콘솔에 출력되어야 한다.

하지만 testService.test() 메소드는 비동기 메소드이기 때문에 순서를 보장하지 않는다. 따라서 출력 결과를 보면 service start가 가장 먼저 출력되었다. 그리고 컨트롤러의 메소드를 호출할 때는 nio-8080-exec-1이라는 이름의 스레드가 사용됐지만 비동기로 호출한 서비스의 메소드를 호출할 때는 task-1이라는 이름의 스레드가 사용되었다.

 

즉, @Async 어노테이션을 사용하여 비동기 메소드를 호출하면 기존에 사용되던 스레드 대신 새로운 스레드가 사용되는 것을 알 수 있다.

위와 같이 testService.test() 메소드가 호출되는 시점을 BreakPoint로 잡고 디버깅을 해보면

우선 testService.test() 메소드가 호출되는 즉시 AOP를 통해 가로챈다.

그리고 코드를 쭉 보면 invoke() 메소드에서 동적으로 메소드를 호출하게 되는데, 여기서 targetClass는 우리가 비동기로 호출한 메소드의 클래스인 TestService이고 userDeclaredMethod는 비동기로 호출한 메소드인 TestService.test() 메소드이다. 그리고 메소드를 비동기로 호출하기 위해 스레드 풀을 사용하는데, 현재 임의로 지정한 스레드 풀이 없기 때문에 스프링에서 기본으로 등록한 스레드 풀을 사용하는 듯 하다.

위와 같은 설정값을 사용한다.

그리고 현재 비동기로 호출한 메소드의 리턴 타입에 따라 다르게 동작하는 것을 알 수 있다.

현재 메소드의 리턴 타입은 void이기 때문에 위 부분이 실행된다. 결과적으로 null을 반환한다.

최종적으로 비동기로 호출하고자 했던 메소드를 스레드풀을 통해 새로운 스레드에서 실행하게 된다.

 

@Async의 기본 스레드풀

스레드풀에 대해 아무 설정도 해주지 않았을 때 기본적으로 사용하는 스레드풀 옵션은 다음과 같았다.

옵션을 하나씩 살펴보면 다음과 같다.

  • corePoolSize : 스레드 풀에서 항상 유지되는 최소한의 스레드 수를 의미한다. 스레드 풀이 초기화될 때 생성되는 스레드의 개수는 corePoolSize에 해당한다.
  • maxPoolSize : corePoolSize 이상의 작업이 들어올 경우, 작업은 작업 큐에 대기하게 된다. 작업 큐에 대기 중인 작업이 queueCapacity를 초과하면, maxPoolSize에 도달할 때까지 새로운 스레드가 생성되어 작업을 처리한다.
  • queueCapacity : 작업 큐의 용량을 의미한다. 작업의 수가 corePoolSize를 넘어가게 되면 작업 큐에 저장된다.

 

여기서 위 기본 설정은 maxPoolSize와 queueCapacity의 크기를 각각 Integer.MAX_VALUE로 잡고 있다. 이는, 스레드가 처리할 작업의 수가 corePoolSize인 8개를 넘어가게 되면 최대 21억개까지 queue에 작업들이 저장되고, 이를 넘어간다면 또 최대 21억개까지 스레드를 새로 생성하여 작업을 처리하게된다. 이는 성능상 좋지 않은 결과를 초래할 수 있을 뿐만 아니라 OOM 예외를 발생시킬 수 있다. 따라서 상황에 맞게 스레드 풀을 직접 커스텀하여 해당 스레드풀을 사용하는게 좋다.

 

커스텀 스레드풀 생성

application.properties와 같은 설정 파일에 위와 같이 스레드풀에 대한 정보를 지정하여 설정을 변경할 수 있다.

변경된 스레드풀

 

직접 사용될 스레드풀을 빈으로 설정 클래스에서 빈으로 등록할 수도 있다.

만약, application.properties에도 스레드풀을 설정하고, 수동 빈 등록으로도 스레드풀을 직접 등록했을 때는 수동 빈 등록으로 만든 스레드풀이 더 우선순위를 갖는다.

 

여러 스레드풀 정보를 지정한다면

SimpleAsyncTaskExecutor를 사용한다. 이것은 스레드풀을 사용하는게 아니라 매 작업마다 새로운 스레드를 만들기 때문에 성능상 좋지 않으므로 사용하지 않는게 좋다.

 

따라서 스레드풀을 여러 개 빈으로 등록한 경우 사용할 스레드풀의 빈 이름을 @Async의 옵션값으로 넣어주면 된다.

빈 이름을 위와 같이 지정해주고

@Async 어노테이션에 옵션값으로 빈 이름을 적어준다.

지정한 스레드풀이 사용되었다.

 

리턴 타입 별 반환

앞서 비동기로 호출할 메서드의 리턴 타입에 따라 다르게 동작하는 것을 확인했다.

 

리턴 값이 없는 경우

앞서 살펴본 대로 리턴 값이 없는 경우는 null을 반환한다.

 

리턴값이 Future인 경우

위와 같은 비동기 메소드를 생성하고

컨트롤러에서 5번 연속으로 다른 값을 넣어서 호출한다면

결과는 위와 같이 나온다. 이 때 호출된 순서대로 출력됐는데 이것이 Future로 반환하는 방법이 잘 사용되지 않는 이유이다. Future로 반환하면 비동기 메소드가 호출되고 반환값을 받을 때까지 기다린다. 즉, 비동기 블로킹 방식이 되어버린다.

 

리턴값이 ListenableFuture인 경우

반환 타입이 ListenableFuture인 비동기 메소드를 생성하였다. ListenableFuture은 Spring 6.0부터 deprecated 되었다.

ListenableFuture의 addCallback() 메소드의 첫 번째 인자로 작업 완료 콜백 메소드를, 두 번째 인자로 에러 발생시 콜백 메소드를 받는다.

작업이 비동기로 처리되었다.

 

리턴값이 CompletableFuture인 경우

반환 타입이 CompletableFuture인 비동기 메소드를 생성하였다.

thenAccpet() 메소드를 사용하여 성공했을 때 콜백 메소드를 작성한다. excptionally() 메소드를 사용하여 에러 발생 시 실행할 메소드를 작성한다. 가독성 측면에서 훨씬 좋아진 것을 볼 수 있다.

논 블로킹 방식의 비동기 처리로 수행한다.

 

주의 사항

@Async는 기본적으로 PROXY 모드로 동작하는데, 이때 다음과 같은 특징을 가지고 있다.

  • public 메소드만 사용 가능
  • 같은 객체 내에서 메소드 호출 시 AOP가 동작하지 않는다.
  • 성능이 ASPECTJ에 비해 좋지 않다.

 

 

 

 

📜 References

https://steady-coding.tistory.com/611

https://woodcock.tistory.com/31

https://brunch.co.kr/@springboot/401