스프링부트 환경에서, @Async 어노테이션 사용해서 비동기 메서드 구현
이 글에서는, 스프링 프레임워크에서 제공하는 @Aysnc 비동기 메서드 사용 방법에 대해서 설명한다. 이 글을 읽기 위해서는 기본적인 병렬 프로그래밍에 대한 개념 이해가 필수이며, 스프링프레임워크 AOP 및 스프링부트 AutoConfiguration 에 대해서 알고 있다면 글을 읽는 것이 어렵지 않을 것이다.
(스프링 @Async 어노테이션에 대해서 알아보기 전에)
병렬 프로그래밍에 대한 기본 개념을 알아보자. 잘못된 내용이 있다면 댓글로 의견을 남겨주길 바란다.
- 병렬 프로그래밍에 대한 이해가 있는 개발자는 1장은 패스하고, 2장을 바로 읽어주길 바란다.
'비동기'와 '논블록킹'에 대한 비교는 개발자마다 조금씩 기준이 다른 것 같다. 필자의 개인적인 생각에 의하면, '비동기'는 메서드(기능)을 제공하는 입장에서의 개념이고, '논블록킹'은 메서드(기능)을 사용하는 클라이언트 입장에서의 개념이라고 생각한다. 사실 두 개는 입장이 다르기 때문에 비교 대상이 아니라는 얘기다. 비동기&논블록킹에 대해서 명확하게 정의할 수 있는 개발자는 필자에게 조언을 꼭 해주길 부탁한다. 어쨋든, 이 글에서 자세하게 설명하지는 않겠다. 아래 글에서, 논블록킹, 비동기에 대해서 나름 자세하게 다루었다.
https://brunch.co.kr/@springboot/267
쓰레드풀에 대한 개념을 이해해야 한다.
(병렬프로그래밍을 알아보기 전에)
순차 프로그래밍의 예시를 먼저 알아본다. 수행시간이 1초가 걸리는 메서드가 있다고 가정한다.
해당 메서드를 100번 실행하면 어떻게 될까?
100초의 시간이 걸릴 것이다. 단 하나의 쓰레드(main 쓰레드)로 모든 작업을 순차적으로 처리하게 된다. 그래서 1초를 100(백)번 수행하기 때문에, 최소 100초의 지연 시간이 발생한다.
짧은 시간에 모든 작업이 완료될 수 있도록 '병렬 프로그래밍'으로 개선해보자. Executors.newFixedThreadPool 메서드를 사용해서 쓰레드풀을 정의한다.
참고로, newFixedThreadPool 은 ThreadPoolExecutor 를 반환한다.
자바 병렬프로그래밍 [에이콘 출판사 185page] 를 참고해서 정리하였다.
- newFixedThreadPool : 처리할 작업이 등록되면 그에 따라 실제 작업할 스레드를 하나씩 생성한다. 생성할 수 있는 쓰레드의 최대 개수는 제한되어 있으며, 제한된 개수까지 쓰레드를 생성한 후 쓰레드를 유지한다.
- newCachedThreadPool : 캐시 쓰레드풀은 현재 갖고 있는 쓰레드의 수가 처리할 작업의 수보다 많아서 쉬는 쓰레드가 많이 발생할 때 쉬는 쓰레드를 종료시켜 훨씬 유연하게 대응할 수 있다. 처리할 작업의 수가 많아지면 그만큼 쓰레드를 생성한다. 반면에 쓰레드의 수에는 제한을 두지 않는다.
- newSingleThreadExecutor : 단일 쓰레드로 동작하는 Executor 로서 작업을 처리하는 쓰레드가 단 하나뿐이다.
- newScheduledThreadPool : 일정 시간 이후에 실행하거나 주기적으로 작업을 실행할 수 있으며, 쓰레드의 수가 고정되어 있는형태의 Executor.Timer 클래스의 기능과 유사하다.
자세한 내용은, "자바 병렬 프로그래밍" 185page를 읽어보길 바란다.
Executor 를 활용해서 병렬로 동시에 실행하는 로직이다.(100개의 작업을 병렬 처리한다. 즉, 순차적으로 처리하지 않는다.) 새로운 작업이 발생할 때마다 쓰레드는 새롭게 생성될 것이다.
1초만에 100개의 작업이 순식간에 처리된다. 물론, CPU코어 개수는 제한되어있기 때문에, 컨텍스트 스위칭이 발생하면서 순간적으로 CPU 수치가 높게 올라갈 수는 있다.
해당 샘플 예시에서는, 병렬 프로그래밍의 지극히 일부에 대해서 소개하였다. 사실, 병렬 프로그래밍을 공부하다보면, 병렬처리에 대한 기술이 얼마나 방대하며 어려운 영역인지 깨닫게 될 것이다. 이 글 하나로 병렬 프로그래밍에 대해서 전부 설명할 수가 없다. 또한, 필자는 설명할 실력도 되지 않는다.
이 글의 핵심 주제는 스프링 @Async 이니깐,
병렬프로그래밍에 대한 얘기는 아주 간단하게만 설명하도록 하겠다.
생략한다.
"병렬 프로그래밍 187page - 에이콘 출판사" 를 참고하자.
생략한다.
"병렬 프로그래밍 257page - 에이콘 출판사"를 참고하자.
생략한다.
생략한다.
CompletableFuture 는 Java8 에서 처음 도입되었는데, 비동기 메서드로 부터 데이터를 전달 받아서 처리가 필요한 경우에 매우 유용하게 사용할 수 있다. CompletableFuture 이 등장하기 전에는, Future, FutureTask 등 으로 구현했었는데, 개발하다보면 머리가 많이 아플 것이다.
특히, 콜백 메서드를 구현하는 부분에서 매우 스트레스...
아래와 같이, 처리 결과를 리턴해주는 메서드가 있는데, 해당 메서드는 5초의 지연시간이 발생한다.
동시에 많은 작업을 처리할 수있도록, ComplatableFuture 를 사용해서 병렬 처리를 구현해보자.
모든 작업이 완료가 되면, 결과 데이터를 취합할 수 있다.
스프링에서 제공하는 ThreadPoolTaskExecutor는, CompletableFuture 를 사용할 때도 적용할 수 있다. 일단, 아래와 같이 ThreadPoolTaskExecutor를 Bean으로 정의한다.
최대 쓰레드풀 사이즈는 200으로 설정하였다. 해당 빈을 DI 로 주입해서 사용하면 된다. 아래 샘플 코드에서의 CompletableFuture 사용 구문을 참고하길 바란다.
참고로, supplyAsync 메서드를 실행할 때 별도의 Executor 를 정의해주지 않으면, ForkJoinPool 의 CommonPool를 사용하게 된다. Common Pool 사용은 애플리케이션에 예상치 못한 장애가 발생할 수 있으니 사용에 주의가 필요하다.
CompletableFuture 에 대해서 간단하게 소개하였다. 이 글에서 모든 내용을 설명할 수 없어서 아쉽게 생각한다. 아래 링크를 반드시 읽어보길 바란다.
https://brunch.co.kr/@springboot/267
비동기, 병렬프로그래밍에 대한 개념 없이 스프링의 @Async 를 사용하는 것은 바람직하지 않다고 생각한다. 필자는, 비동기 프로그래밍으로 개발할때는 항상 꼼꼼하게 코드리뷰하는데, 이유는... 필자처럼 개발을 잘 못하는 사람이 비동기 프로그래밍을 잘못 적용하면 오히려 독이 될 수가 있기 때문이다.
본격적으로!!!
Spring Boot 환경에서 @Async 에 대해서 살펴보자.
스프링 부트에서 @Async 를 사용하기 위해서는 @EnableAsync 어노테이션을 먼저 선언해야 한다.
스프링의 @Async는 AOP 에 의해서 동작하는데, @Async 어노테이션이 선언된 메서드는 비동기 메서드로 동작하게 된다. 스프링 프레임워크의 AOP 내부 로직을 찾아가보면 최종적으로는 아래의 클래스&메서드에 도착하게 된다. 앞으로 이 글을 읽으면서 계속 보게 되는 메서드인데, springframework. aop. interceptor 패키지에서 확인할 수 있다.
AsyncExecutionAspectSupport 클래스에서 doSubmit 메서드를 주목하자.
@Async 어노테이션으로 선언된 메서드는, 최종적으로 해당 로직에 의해서 비동기로 동작하게 되는데, @Async 어노테이션으로 선언된 메서드의 리턴타입에 따라서 상이하게 구현 되어있다.
1.CompletableFuture
2.ListenableFuture
3.Future
4.리턴이 없는 경우
등 4가지 케이스에 대해서 구현이 되어있다.
@Async 어노테이션으로 선언된 메서드는,
리턴타입에 따라서 내부적으로는 상이하게 동작한다. 리턴타입을 하나씩 살펴보도록 하자.
비동기로 처리 해야하는 메서드가 처리 결과를 전달할 필요가 없는 경우이다. 이 경우에는 @Async 어노테이션의 리턴 void 를 선언해주면 된다. 샘플 코드의 메서드는 3초의 지연시간이 있다.
클라이언트에서 비동기 메서드를 호출해보자.
1. 비동기 메서드 호출
2. 그 다음에 log.info("non blocking")
비동기 메서드는 3초의 지연시간이 있지만, order 메서드는 비동기 메서드이기 때문에, 해당 메서드를 호출하는 클라이언트는, 처리 결과가 완료될 때까지 계속해서 기다릴 필요가 전혀 없다.
즉, 기다리지 않고 다른 작업을 계속 수행할 수 있게 된다. (논블록킹)
스프링부트 환경에서 실행한 로그는 아래과 같다. 필자의 생각대로 동작한다면, 아마도 order 메서드의 3초 지연시간을 기다리지 않고, "논-블록킹" 로그가 수행할 것이다.
애플리케이션은 처음에는 "main" 쓰레드로 실행되다가, order 메서드는 "task-1"라는 별도의 쓰레드로 실행을 시켜준다. order 메서드는 별도의 쓰레드로 실행되며, 비동기 메서드이기 때문에 order 를 호출하는 클라이언트는 결과를 굳이 기다릴 필요가 없다. 참고로, task-1 이라는 쓰레드는 스프링부트에서 자동으로 만들어서 제공하는 ThreadPoolTaskExecutor 에 의해 동작한다.
어떻게 동작하는지 좀 더 자세히 살펴보자. 스프링 프레임워크의 aop.interceptor 의AsyncExecutionAspectSupport 클래스에서 확인할 수 있다.
클래스의 doSubmit 메서드의, if/else 구문을 유심히 보자.
@Async 어노테이션이 선언된 메서드의 리턴 타입이 void 인 경우에는 executor.submit(task)를 실행하고, 곧바로 return null; 이 실행된다. 여기서 executor 은 스프링부트에서 자동으로 정의된 ThreadPoolTaskExecutor이 동작한다.(스프링부트 2.1 이상에 해당하며, 별도의 executor 를 사용할수도 있다.) 디버깅을 해보면 아래와 같다.
코드를 살펴본 결과, 비동기 메서드는 별도의 쓰레드로 작업을 수행시켜주며 결과에 상관없이 바로 null 을 리턴해준다.
참고로, executor 의 Pool 사이즈를 조정하고 싶다면, 아래와 같이 프로퍼티 설정을 할 수 있다.
해당 설정은 아마도 스프링부트 2.1 부터 제공하는 기능인데,
완벽하게 이해하고 싶은 개발자는, 스프링부트의 AutoConfiguration 에 대해서 따로 공부를 하길 바란다.
@Async 어노테이션을 설명하기 위해서 시작한 글인데, 글을 쓰다보니 내용이 산으로 가고 있다. @Async 를 제대로 이해하기 위해서는 병렬 프로그래밍에 대한 기본적인 이해가 필수이며, 스프링 프레임워크의 핵심 개념인 AOP 에 대해서 이해해야 한다. 또한, 필자의 샘플 코드는 스프링부트 기반으로 설명하고 있기 때문에 AutoConfiguration 에 대해서도 제대로 이해하고 있어야 한다. 주니어 개발자가 이 글을 읽고 이해가 안되는 부분이 많을 것이다. 하나씩 따로 찾아서 공부하면서 각자 알아서 퍼즐을 맞춰가길 바란다.
위 코드는 메서드의 결과를 전달받을 필요가 없는 경우였다.
하지만, 비동기로 수행한 메서드의 결과를 받아서 처리해야 한다면 어떻게 할까?
Future 를 사용해야 한다. 이제부터 설명하겠다.
메서드의 결과를 전달받아야 한다면, Future 를 사용해야 한다. Future 계열(Future, ListenableFuture, CompletableFuture) 중 가장 기본이 되는 Future 에 대해서 먼저 알아본다. 스프링에서 제공하는 AsyncResult 는 Future 의 구현체이며, 아래 샘플코드와 같이 @Async 어노테이션에 AsyncResult를 사용해서 Future 를 리턴할 수 있다.
비동기 메서드를 호출하는, 클라이언트 측에서는 아래 코드와 같이 작성한다.
future 의 get 메서드는 메서드의 결과를 조회할 때까지 계속 기다린다. 즉, 메서드의 수행이 완료 될때까지 기다려야 하며, 블록킹 현상이 발생한다. 만약, 비동기 메서드를 호출하는 클라이언트가 기다리지 않고 다른 작업을 계속 수행하도록 구현하고 싶다면... 즉, 논블록킹으로 동작하게 하고 싶다면, 콜백 메서드를 처리하는 로직을 구현해주면 된다. 필자는 Future 는 별로 쓰고 싶지가 않기 때문에 더이상 설명하고 싶지가 않다. Future 로 리턴하는 기능은... 왠지 사용하고 싶지가 않다.
Future 보다는 좀 더 나이스한 ListenableFuture 에 대해서 알아보자. ListenableFuture 는 Future 와 마찬가지로, 비동기로 수행한 메서드의 결과를 전달받을 수 있는데..
참고로, AsyncResult 는 Future 의 구현체이면서, 동시에 ListenableFuture 의 구현체이다. 역시, AsyncResult 로 리턴을 구현할 수 있다.
메서드를 호출하는 클라이언트 코드는 아래와 같다.
future.addClasback 메서드는 비동기 메서드의 내부 로직이 완료되면 수행되는 콜백 기능이다. Future를 사용했을 때는 future.get를 사용했을 때 메서드가 처리될 때까지 블록킹 현상이 발생했지만, 콜백 메서드를 사용한다면 결과를 얻을때까지 무작정 기다릴 필요가 없다.
실행 로그를 확인해보자. 비동기 메서드에서 3초의 지연시간이 발생하는 동안, "non blocking" 로그가 남는 것을 확인할 수 있다.
1. 비동기 메서드 호출
2. 비동기 콜백 메서드 실행
3. 다른 작업 수행(논블록킹 로그 남김)
클라이언트에서 1,2,3 의 순서로 코드를 작성했지만, 실제로 클라이언트 입장에서는
1 -> 3 -> 2 로 동작할 것이다. 즉, 콜백 메서드가 수행되기 전에, 논블록킹 로그가 먼저 찍히게 된다.
내부적으로는 어떻게 동작할까?
자세한 설명은 생략한다...
리턴 타입이 CompletableFuture 인 경우를 알아보자.
AsyncResult 에서 제공하는 completable 디폴트 메서드를 사용하면 CompletableFuture 로 리턴할 수 있다. 참고로, AsyncResult의 completable는 아래와 같이 동작한다.
사실, ListenableFuture, CompletableFuture 모두 Future 를 상속받기 때문에 Future 의 기본적인 기능을 사용할 수 있다. 즉, Future 의 get 메서드를 CompletableFuture 에서도 그대로 사용할 수 있다. 아래 샘플 코드를 보자.
Future 와 마찬가지로, CompletableFuture 의 get 메서드는 결과를 조회할 때까지, 블록킹 현상이 발생한다.
비동기 메서드인 getPricePriceAsyncWithCompletableFuture 는 메인쓰레드가 아니라 별도의 쓰레드로 동작하는 것을 확인하였다. 또한, 메인 쓰레드는 getPrice...메서드를 실행시킨 후 바로 다른 작업을 수행한다. 즉, non blocking으로 잠시동안 논블록킹하게 동작한다. 하지만, get 메서드를 실행하는 시점에서 다시 블록킹 현상이 발생한다.
만약, 전체 과정을 non-blocking 으로 동작하게 하고 싶다면, 콜백함수를 사용하면 되는데, CompletableFuture 에서 제공하는 thenAccept 메서드를 사용할 수 있다. (참고로, thenAccept 말고 thenApply 등 더 많은 메서드가 있지만 자세한 내용은 각자 찾아보길 바란다.)
메인 쓰레드는 결과를 계속 기다리지 않고 다음 작업(non-blocking 2 ) 를 계속 수행할 수 있다. 즉, 완벽하게 논-블록킹 하게 동작한다.
메인 쓰레드는 non blocking 1, 2 를 계속 수행하며, 별도의 쓰레드에서 getPrice...비동기 메서드를 실행한다. 결과를 리턴받는 콜백함수 역시 별도의 쓰레드에서 동작을 하게 된다.
스프링 코드를 분석해보자. 위에서 봤던 AsyncExecutionAspectSupport 클래스의 doSumit 메서드를 보면 리턴타입이 CompletableFuture 인 경우에 대해서 아래와 같이 구현되어 있다. CompletableFuture 의 supplyAsync 를 사용한다. 또한, executor 도 적용된다.
리턴타입이 CompletableFuture 인 경우에는, CompletableFuture 의 supplyAsync 메서드를 사용하는 것을 확인할 수 있다.
만약 @Async 어노테이션을 사용하지 않고 비슷하게 구현해보면 어떻게 하면 될까?
@Async 어노테이션을 사용하지 않고 구현할 수 있으며, 아래와 같이 비동기로 잘 동작한다.
@Async 를 사용했을 때와 차이는, @Async 어노테이션을 사용했을 때는 메서드 실행 자체를 별도의 쓰레드로 동작시켰지만, 이번 경우는 메서드 실행은 메인 쓰레드로 동작한다. 즉, 클라이언트의 쓰레드가 메서드 호출할 때도, 해당 쓰레드를 그대로 사용하고, 메서드 내부적으로 별도의 쓰레드를 생성해서 CompletableFuture 를 실행해주는 로직이다.
참고로, 별도의 executor 를 선언해주지 않으면, common Pool로 동작하는데, 별도의 쓰레드풀을 설정해서 사용하는게 좋다. CompletableFuture 에 executor 를 적용하면 아래와 같다.
리턴 타입에 따라서 다르게 동작하는 @Async 의 동작 방식에 대해서 알아봤다. 쉬운 내용이 아니며, 필자의 필력이 개판이기 때문에, 이해가 잘 안되는 개발자가 많을 것이다. 최대한 객관적이고 잼있게 글을 작성하고 싶지만, 필자의 글이 항상 그렇듯이 재미는 없다.
어쨋든, 기술적인 내용 중 잘못된 내용이 있다면 피드백을 남겨주길 바란다.
이제 정말 본격적으로...(?)
@Async 에 대해서 상세하게 알아볼 생각이지만, 글이 너무 길어져서 이후 내용은 짧게 작성하겠다.
@Async 를 선언한 메서드가 비동기로 실행될 때 쓰레드는 새로 생성되는데, 별도의 설정을 하지 않는다면 스프링부트에서 만들어서 제공되는 ThreadPoolTaskExecutor 에 의해서 동작한다. 만약, 쓰레드풀을 별도로 관리해서 적용할 수 있을까?
ThreadPoolTaskExecutor @Bean 을 정의하고, 명시적인 이름을 설정해주면 된다.
정의한 ThreadPoolTaskExecutor에 myThreadPoolTaskExecutor 라는 빈 이름도 정의하였는데. @Async 어노테이션에 빈 이름을 설정해주면, 해당 쓰레드풀에 의해서 동작하게 된다.
로그를 확인해보면, 필자가 정의한 ThreadPoolTaskExecutor 에 의해서 실행된다는 것을 확인할 수 있다.
위 방법은 메서드 레벨에서 Executor 를 정의하는 방법이다. 만약, 애플리케이션 레벨에서 정의를 해줄수도 있는데...(필자가 따로 확인을 해보진 못했다. 정확히 아는 개발자는 피드백을 해주길 바란다.)
스프링 @Async 기능을 사용하기 위해서는, @EnableAsync 어노테이션을 선언한다. 이때 별도의 설정을 하지 않는다면, AdviceMode 는 PROXY 모드로 동작한다.
AdviceMode 를 보면 PROXY 와 ASPECTJ 두 가지 모드를 선택할 수 있다.
즉, @Async 어노테이션으로 동작하는 비동기 메서드는, 기본적인 스프링 AOP 의 제약사항을 그대로 따르게 된다. 더 많은 기능을 제공하는 ASPECTJ 모드로 사용하고 싶다면 아래와 같이 변경하면 될 것 같지만...필자가 해보진 않았다.
참고로, 필자가 ASPECTJ 모드로 실행해보진 않아서... 자세한 장단점은 잘 모르겠지만,
@Async 가 기본적으로 Proxy 모드로 동작할 때, 일반적으로 알려진 제약사항은 아래와 같다.
- public 메서드만 사용 가능하다.
- 같은 객체내의 메서드끼리 호출 시 AOP 가 동작하지 않는다.
- 성능이 ASPECTJ 모드에 비해서 좋지 않다.
너무 중요한 내용이지만,
필자가 아직 내공이 부족하므로 자세한 내용은 생략할테니 아래 링크를 참고하길 바란다.
https://www.baeldung.com/spring-aop-vs-aspectj
https://dzone.com/articles/effective-advice-on-spring-async-part-1
@Async 어노테이션을 사용할 때 별도의 Executor 를 선언하지 않으면, 스프링부트에서 자동으로 생성해주는 ThreadPoolTaskExecutor 를 사용하게 된다. 스프링부트 프로퍼티 설정을 확인해보자.
자세한 내용은 공식 레퍼런스를 참고하였다.
AutoConfiguration 이 어떻게 구성되는지는, TaskExecutionAutoConfiguration 클래스를 확인해볼 수 있다.
아래 내용은 기술적으로 확인이 필요합니다. 시간 관계상 자세히 확인하지 못했습니다.
TaskExecutionAutoConfiguration 코드를 읽어보면, Executor 이 별도로 선언이 되지 않은 경우에만 Eecutor 를 만드는 것으로 되어있다. 즉, 애플리케이션에서 ThreadPoolTaskExecutor 를 하나라도 이미 선언해줬다면, 스프링부트는 자동으로 생성하지 않는 것으로 보인다.
애플리케이션에서 컨피그 설정에 의해서 딱 한개의 ThreadPoolTaskExecutor 빈을 선언해준 상황에서, @Async 어노테이션에 ThreadPoolTaskExecutor 를 설정하지 않았다면 어떻게 될까?
1.스프링부트에서 기본으로 제공하는 ThreadpoolTaskExecutor 로 실행될까?
(사실은 생성이 되지도 않는다. ConditionalOnMissingBean 으로 선언되어있기 때문에...)
2.아니면 애플리케이션에서 선언한 단 하나의 ThreadPoolTaskExecutor 로 실행될까?
@Async (..빈이름) 을 지정하지 않았음에도 불구하고, 애플리케이션에서 선언해준 단 하나의 ThreadPoolTaskExecutor 에 의해서 동작할 것이다.
근데...
ThreadPoolTaskExecutor 를 2개 이상 선언한 경우에서 @Async 어노테이션에 빈을 지정해 주지 않으면 어떻게 될까? 필자는 이 경우 빈을 지정해주지 않았기 때문에 에러가 발생할거라 생각했지만, 에러 없이 잘 동작한다. 이때는, SimpleAsyncTaskExecutor 라는 녀석이 동작한다....
SimpleAsyncTaskExecutor.... 가 어떻게 동작하는 빈인지 다시 찾아봐야할 것 같지만...
시간 관계상 이정도로만 마무리 하겠다.
아쉽지만, 이정도로 정리하고 마무리하겠다. 나중에 시간 여유 있을 때 글을 다시 작성하겠다. (참고로, 스프링 버전에 따라서 다르게 동작할 수 도 있는데, 이 글은 스프링 5.2.5, 스프링부트 2.2.6 기준이다.)
생략한다.
이번 글에서는 스프링부트 환경에서 @Async가 어떻게 동작하는지 정리하였다. 이 글은 기본적인 병렬프로그래밍, @Async, AOP, 스프링부트의 AutoConfiguration 등 다양한 내용을 설명하다보니 글이 많이 길어졌지만, 주니어 개발자들에게 조금이라도 도움이 되었기를 바란다.
지루하고 어려운(친절하지 않은) 이 글을,
정독해서 읽은 개발자는 별로 없을 것 같지만...
혹시라도, 글에서 이상한 내용이 있다면, 댓글로 의견을 남겨주시길 바랍니다.