Backend/Java

@Async, CompletableFuture, parallelStream

findmypiece 2021. 7. 14. 00:00
728x90

논블로킹, 비동기, 병렬 과 관련되어 있을 때 자주 거론되는 것들이다. 비슷한거 같으면서도 다른 이 세가지 개념에 대해 정리한다.

 

우선 논블로킹, 비동기 은 병렬처리를 위한 과정이라고 생각하면 된다. 병렬은 동시에 여러가지 작업이 수행되는 것이고 이를 위해 어플리케이션 입장에서는 일을 시키는 과정이 논블로킹으로 진행되어야 한다. 또한 논블로킹으로 수행된 작업의 결과는 비동기적으로 받을 수 밖에 없다.

 

@Async 를 통해 논블로킹/비동기 작업을 수행할 수 있는데 이 자체 목적으로 활용되기도 하고 CompletableFuture 와 조합해서 병렬처리에 활용하기도 한다.

 

엄밀히 따지면 @Async 역시 별도 스레드를 통해 작업을 수행하는 것이기 때문에 병렬처리라고 볼 수도 있겠지만 여기에서 말하는 병렬처리는 동일한 목적을 가지는 최소 3개 이상의 작업을 동시에 수행하는 것을 의미한다.

 

더군다나 @Async 의 경우 어플리케이션 전체에서 공유되는 스레드풀을 사용하기 때문에 병렬처리를 하려는 순간에 필요로 하는 스레드 갯수가 보장되지 않아 순간 처리량을 높히고 싶은 병렬처리에는 맞지 않는 선택이다.

 

parallelStream과 CompletableFuture 에서는 기본적으로 ForkJoinPool이 사용된다. 이는 스레드풀과 비슷하지만 좀 더 병렬처리에 특화된 기능을 제공한다. 간단하게 분할정복 알고리즘의 병렬버전이라고 생각하면 된다.

 

병렬처리가 최대 성능을 내기 위해서는 작업을 정확히 나눠서 CPU자원이 골고루 사용되도록 해야 한다. 각 작업간 처리 시간 차이가 많아지는 만큼 Idle 시간이 많아지고 병렬처리의 성능 또한 떨어지게 된다. 하지만 작업 처리 시간까지 정확히 나누는 일은 결코 쉽지 않은 일이다.

 

ForkJoinPool 에서는 Work-Stealing 알고리즘을 통해 이러한 단점을 보완해서 CPU 자원을 좀 더 효율적으로 사용할 수 있게 한다. 전체 처리 과정은 아래와 같은데 간단하게 말해 Idle 상태에 놓인 스레드가 다른 스레드의 작업을 가져와서 처리를 하는 방식이다.

1. 큰 작업이 들어온 경우에 Fork를 통해 업무 분할을 한다.
2. 분할 된 업무는 아래 그림처럼 submit과정을 통해 inbound queue에 쌓인다.
3. queue에서 들어온 작업들은 ForkJoinPool에 할 당된 CPU개수(여기서는 A,B 두개) 만큼의 쓰레드 개수로 작업이 분산된다.
4. 이 때 각 쓰레드는 내부적으로 Queue를 가지고있고(정확히는 Dequeue) 해당 큐에 작업을 추가한 후에 차례대로 실행한다.
5. 그런데 이때 B쓰레드 처럼 모든 일을 다 처리한 경우에 CPU자원이 놀게되는데(idle)
   이런 상황에서 work-stealling이 동작하면서 A쓰레드 큐에서 남은 작업을 B로 가져와 처리함으로써 최적의 성능을 낸다.
6. A가 B에게 어떤 작업을 훔쳐가면되는지 알려주는데, 이 때 마땅한게 없다면 Fail메시지를 날린다.
7. B는 상황에 따라 A의 쓰레드에서 업무를 훔칠지 shared inbound queue에서 가져올지 결정한다.

 

ForkJoinPool 에서 사용되는 스레드 갯수는 아래 메서드가 반환하는 값으로 풀에서 사용할 스레드 수를 결정하는데 이 메서드의 반환값은 일반적으로 우리가 알고 있는 CPU(프로세스) 갯수로 이해하면 되는데 그 외 하이퍼스레딩과 관련된 가상 프로세스 개수도 포함한다.

Runtime.getRuntime().availableProcessors()

 

예를 들어 Inter - i7 은 CPU 코어 갯수가 4개이기 때문에 위와 같은 코드를 실행시키면 4가 출력될 것 같지만 실제로는 8이 출력된다. 이는 Intel이라는 회사가 하이퍼스레딩이라는 기술을 지원 해주기 때문인데, 이것은 물리적 코어 한 개당 스레드 2개를 할당해 성능을 높이는 기술이다. 물리적 코어는 4개이지만 논리적 코어는 8개인 셈이다.

 

중요한 것은 이러한 고정된 스레드 갯수를 가지는 ForkJoinPool 이 어플리케이션 내에서 공유되기 때문에 어플리케이션 여러 군대에서 동시 다발적으로 parallelStream과 CompletableFuture 이 사용될 경우 각 연산성능에 영향을 줄 수 있다는 점이다. 

 

여기에서 끝난다면 @Async 와 다를바 없겠지만 위와 같이 공유에 따른 연산성능을 고려해서 별도의 ForkJoinPool을 생성해서 parallelStream 과 CompletableFuture 에서 사용할 수도 있다. 이 경우 스레드 갯수도 상황에 맞게 지정할 수 있다.

 

하지만 스레드풀을 생성하는 것은 많은 자원이 소모되는 작업이다. 일반적으로 기본적으로 생성된 ForkJoinPool 을 그대로 사용하거나 스레드 갯수를 늘려서 새로 생성한다고 해도 bean으로 등록해서 재사용하는 것이 일반적이다.

 

특정 클래스의 private 맴버변수로 정의해서 사용한다고 해도 Spring 에서는 모든 객체가 기본적으로 싱글톤으로 관리되기 때문에 클래스 내부 private 변수도 모든 스레드에서 공통적으로 바라보게 되고 해당 변수값이 변화하면 모든 스레드에 영향을 줄 것이다. 이에 매 호출마다 ForkJoinPool 를 생성해서 사용하려면 매번 new 로 생성해서 사용하는 수 밖에 없다.

 

다만 이 경우 스레드가 빠르게 소진되어 다른 호출에 영향을 줄 수 있다. 예를 들어 Tomcat 을 최대 30개의 스레드를 가질 수 있는 풀로 요청을 처리하도록 설정해놨다고 가정해보자. 일반적으로는 1개의 호출당 1개의 스레드가 사용하는데 호출마다 ForkJoinPool(5) 를 생성하면 매 호출마다 5개의 스레드가 사용되는 꼴이 된다. ForkJoinPool 을 커스텀하게 생성해서 사용하려면 이 부분을 고려하여 적절하게 사용해야 할 것이다.

728x90

'Backend > Java' 카테고리의 다른 글

@JsonCreator  (0) 2021.11.19
AutoBoxing과 AutoUnBoxing  (0) 2021.08.25
Collections.EMPTY_LIST, Collections.EMPTY_MAP  (0) 2021.07.12
Map에 stream 사용하기  (0) 2021.06.18
jackson 역직렬화 시 주의할 점  (0) 2021.04.14