hyeonga_code

Project_16_ForkJoinPool 본문

Project_HYEONGARL

Project_16_ForkJoinPool

hyeonga 2024. 7. 12. 05:59
반응형

 

시작.

이전의 카테고리 업데이트에 CompletableFuture 를 적용하여 비동기적으로 코드를 작성했다. 이 때 작성한 코드는 CompleatbleFuture.supplyAsync() 메소드를 사용할 때 따로 스레드를 설정하지 않았었다. 비동기 처리 작업을 테스트하는  코드를 작성할 때에는 Executor를 설정해 작업했었으나 제대로 짚고 넘어가지 않았던 것 같다.

 

 

Async Pool_비동기 풀

주로 CompletableFuture와 같은 비동기 작업을 관리하기 위해 사용되는 스레드풀이다.

비동기 작업을 관리하는 경우 내부적으로 ForkJoinPool.commonPool()을 사용한다.

 

ForkJoin Pool

큰 작업을 작은 작업으로 나누어 병렬로 처리하는데 최적화된 스레드 풀이다.

주로 작업을 분할하고 병합하는 작업에 사용된다.

 

값을 반환하는 작업: RecursiveTask

값을 반환하지 않는 작업: RecursiveAction

 

Common Pool

ForkJoinPool.commonPool()은 모든 애플리케이션에서 공유되는 공용 풀이다.

CompletableFuture와 같은 비동기 작업을 기본적으로 이 풀에서 실행한다.

 

JVM 내에서 모든 애플리케이션 스레드가 공유하는 공유 풀이다.

명시적으로 풀을 생성하지 않아도 JVM이 시작될 때 자동으로 생성된다.

다수의 작업을 병렬로 실행할 수 있는 스레드를 포함하는 병렬 처리를 지원한다.

 

ForkJoinPool의 commonPool()

ForkJoinPool의 commonPool()의 기본 크기는 JVM이 시작될 때 시스템의 가용 프로세서 수를 기반으로 설정된다.

이 값은 Runtime.getRuntime().availableProcessors()를 통해 얻을 수 있는 프로세서 수와 일치한다.

일반적으로 물리적 CPU 코어 수와 동일하거나 하이퍼스레딩이 활성화된 경우 더 많을 수 있다.

 

Work-Stealing 알고리즘을 사용하여 효율적으로 작업을 분배하고 작업이 없는 스레드가 다른 스레드의 작업을 훔쳐 실행한다.

java.util.concurrent.ForkJoinPool.common.parallelism을 설정하여 commonPool의 병렬성 수준을 변경할 수 있다.

 

 

> CategoryService

더보기
    @CachePut(value = "category", key = "#userId")
    public CompletableFuture<Category> updateCategory(Category category, Long userId) {
        return CompletableFuture.supplyAsync(() -> {
            Category existCategory = categoryRepository.findByUserId(userId)
                    .orElseThrow(CategoryNotFoundException::new);
            existCategory.setCategoryTree(category.getCategoryTree() != null ?
                    category.getCategoryTree() : existCategory.getCategoryTree());

            return categoryRepository.save(existCategory);
        });
    }

기본적으로 CompletableFuture.supplyAsync()를 사용하는 경우 ForkJoinPool.commonPool()이 사용된다. 

별도의 설정 없이도 손쉽게 비동기 작업을 실행할 수 있게 한다.

 

스레드 풀을 정의하고 싶은 경우 Executor를 사용하여 두 번째 매개 변수로 넘겨줄 수 있다.

 

 


ForkJoinPool의 동작 원리

작업이 병렬로 처리되며 이 과정에서 작업 도둑질 알고리즘이 사용되어 효율성을 극대화한다.

큰 작업을 작은 작업으로 분할하고, 이러한 작은 작업들을 병렬로 처리한 후 결과를 병합한다. 

 

1. ForkJoinPool은 ForkJoinTask라는 작업 단위를 처리한다.

작업은 RecursiveTask_결과를 반환하는 작업 또는 RecursiveAction_결과를 반환하지 않는 작업으로 정의된다.

작업은 ForkJoinPool에 제출되며 invoke(), submit(), execute() 메소드를 통해 이루어진다.

 

2. ForkJoinTask는 compute() 메소드에서 자신의 작업을 더 작은 작업으로 분할한다.

작은 작업은 fork() 메소드를 통해 별도로 처리할 수 있도록 큐에 넣는다.

분할된 작은 작업들은 독립적으로 병렬 실행된다.

 

3. 작은 작업들이 완료되면 join() 메소드로 결과를 병합하여 최종 결과를 생성한다.

join() 메소드는 분할된 작업이 완료될 때까지 대기하고, 완료된 작업의 결과를 반환한다.

 

4. 작업이 없는 스레드는 다른 스레드의 작업 큐에서 작업을 훔쳐 실행하는 작업 도둑질 알고리즘을 실행한다.

이를 통해 스레드의 유휴 시간을 최소화하고 전체 처리 성능을 향상시킨다.

 

 

 

> Executor를 명시적으로 지정하는 것이 일반적이다.

CompletableFuture.supplyAsync()에서 Executor를 명시적으로 지정하지 않으면 기본적으로 ForkJoinPool의 commonPool을 사용하게 되어 편리하지만 특정 상황에서는 문제가 될 수 있다.

 

commonPool을 사용하게 되면 request가 들어올 때마다 스레드가 생성되어 제한이 없어 시스템 리소스를 과도하게 사용하게 된다.

또한 고부하 시스템에서 모든 요청을 받아들이며 리소스의 한계를 초과하게 되는 문제가 발생할 수 있다.

작업이 과도하게 쌓이는 경우 처리 시간이 늘어나며, 응답 시간의 지연으로 이어질 수 있는 문제가 있어 사용자 경험에 부정적인 영향을 미칠 수 있다.

 

Executor를 명시적으로 지정하여 스레드 수를 제어하고, 시스템 리소스를 효과적으로 관리할 수 있게 된다.

 

사용자 정의 스레드 풀을 사용하면 

1. 고정 크기의 스레드 풀로 시스템 리소스 사용을 예측 가능하게 관리할 수 있다.

2. 적절한 스레드 수를 설정하여 작업 처리 속도를 최적화할 수 있다.

3. 스레드 수를 제한하여 시스템 과부하를 방지하고 안정성을 높일 수 있다.

 

스레드 풀을 구성할 때에는 작업의 성격과 시스템 리소스를 고려해야 한다.

1. 고정된 개수의 스레드로 구성된 스레드 풀 (Executors.newFixedThreadPool(10))

2. 필요에 따라 스레드를 생성하고 유휴 스레드를 재사용하는 캐시된 스레드 풀 (Executors.newCachedThreadPool())

3. 주기적이거나 지연된 작업을 실행하는 스케줄링이 가능한 스레드 풀 (Executors.newScheduledThreadPool(5))

 

 

 

테스트 코드를 활용하여 Sync와 Async의 실행 속도 차이를 비교해 봤는데 차이가 거의 나지 않았다.

동기와 비동기적으로 작성한 코드를 비교할 때, CompletableFuture로 감싸는 부분을 제외하고는 코드의 로직이 동일했다.

비동기 로직의 경우 특정 임계점을 넘어가게 되면 빠르지도 않고 느리지도 않은 애매한 부분이 생기게 되어 섣불리 비동기 로직으로 변경하지 않는 부분이 있다.

동기적으로 흐르는 로직은 성능이 올라가지는 않으나 요청이 많아진다해서 성능이 이상해지지 않는다.

 

하여 특정 메소드만 어싱크로 작업하는 방법을 사용한다고 한다.

많은 API 중에서는 가벼운 것과 무거운 것들이 있을 것이다. 하여 로직별로 특성을 분리하여 스레드 풀을 따로 구분하는 방법을 사용한다. 가벼운 로직을 묶어 스레드 풀을 사용하고, 무거운 로직을 처리하는 스레드 풀을 별도로 두어 처리하는 방식이다. 가벼운 로직만 돌아가는 스레드 풀의 경우 무수히 많은 요청이 들어온다 해도 모든 요청이 가볍기 때문에 병목 현상이 많이 발생하지 않는다. 그러나 무거운 작업을 처리하는 경우 작업이 완료되는 시간이 오래 걸린다는 것을 이미 알고있는 상황이므로 조금 더 시간이 소요된다고 해도 크게 문제가 되지 않는 것이다. 

 

이렇게 작업하게 된다면 하나의 스레드 풀을 사용하여 처리하는 과정에서 모든 스레드에서 무거운 작업을 돌리는 경우 가벼운 작업들이 처리되기까지 지연 시간이 발생하는 것을 방지할 수 있다.

 

더 나아가 가벼운 로직은 처리하는 데 시간이 많이 소요되지 않으므로 스레드 풀의 크기가 작아도 무수히 많은 요청을 처리할 수 있어 자원을 효율적으로 사용할 수 있게 되는 것이다.

반응형