Java 병렬 스트림과 ForkJoinPool

 

목차

    0. 서론 : 병렬 처리의 필요성과 Java의 대응 방식

    하드웨어의 발전과 함께 현대 애플리케이션은 점점 더 많은 연산을 병렬로 처리할 수 있는 환경에 놓이게 되었다. 특히 멀티코어 CPU의 보급은 병렬 처리를 선택이 아닌 필수로 만들었다. 연산 집약적인 작업, 대용량 데이터 처리, 실시간 응답성이 요구되는 시스템 등에서는 병렬 처리를 통한 성능 향상이 중요한 요소가 된다.

     

    Java는 이러한 흐름에 대응하기 위해 다양한 병렬 처리 도구를 제공해왔다. Java 7에서는 작업을 작은 단위로 나누어 병렬로 실행할 수 있도록 설계된 ForkJoinPool 프레임워크가 도입되었고, Java 8부터는 함수형 스타일의 데이터 처리와 함께 병렬 처리를 간단하게 적용할 수 있는 Stream API가 추가되었다. 특히 parallelStream()은 병렬 처리에 대한 복잡한 구현을 추상화하여, 선언형으로 병렬 연산을 적용할 수 있도록 도와준다.

     

    본 글에서는 병렬 스트림과 ForkJoinPool의 개념과 차이를 정리하고, 내부 동작 방식과 성능 특성을 비교함으로써, 병렬 처리가 필요한 상황에서 어떤 방식을 선택할지에 대한 기준을 마련해보고자 한다.

     

    1. 병렬 스트림과 ForkJoinPool의 개념 비교

    병렬 처리를 위한 Java의 대표적인 도구인 병렬 스트림(parallelStream)ForkJoinPool은 서로 다른 추상화 수준과 목적을 가진다. 겉보기에는 유사해 보일 수 있지만, 실제로는 사용 방식, 제어 수준, 그리고 적합한 용도에 있어 뚜렷한 차이를 가진다.

     

    병렬 스트림 (Parallel Stream)

    • Java 8의 Stream API 일부로, 선언형 데이터 처리 방식 지원
    • stream()과 동일한 연산 체이닝(map, filter, reduce 등)에 병렬성을 추가한 형태
    • 내부적으로 ForkJoinPool을 사용하지만, 세부 제어가 불가능하며, 개발자는 연산 로직에만 집중
    • 데이터 소스가 컬렉션(List, Set 등)인 경우에 적합하며, 자동으로 작업 분할 및 병합 수행
    • 사용 예시:
    List<Integer> numbers = Arrays.asList(1, 2, 3, 4, 5);
    numbers.parallelStream()
           .map(n -> n * n)
           .forEach(System.out::println);

     

    ForkJoinPool

    • Java 7에서 도입된 프레임워크로, 작업을 분할하여 재귀적으로 처리하는 구조
    • RecursiveTask<T> 혹은 RecursiveAction을 상속받아 직접 분할 전략을 구현
    • 작업 단위를 명시적으로 정의하고 병합하는 방식으로, 정교한 병렬 처리 구현 가능
    • 데이터가 명확하게 분할 가능하고, 계산량이 큰 재귀적 처리에 적합
    • 사용 예시:
    ForkJoinPool pool = new ForkJoinPool();
    int result = pool.invoke(new CustomRecursiveTask(data));

     

    이처럼 병렬 스트림은 고수준 API로써 사용이 간편하지만 제어 범위가 제한되며, ForkJoinPool은 저수준 도구로 더 많은 제어권을 제공하지만 그만큼 복잡도도 높아진다. 각각의 도구는 목적에 맞게 선택해야 하며, 단순한 데이터 변환과 필터링에는 병렬 스트림이, 정교한 분할이 필요한 계산에는 ForkJoinPool이 적합하다.

     

    2. 병렬 스트림 내부의 ForkJoinPool 사용 구조

    병렬 스트림은 내부적으로 ForkJoinPool.commonPool()을 사용하여 작업을 병렬로 수행한다. parallelStream()을 호출하면 Java는 자동으로 컬렉션 데이터를 여러 개의 작업 단위로 분할하고, 각각을 병렬로 실행한 후 결과를 병합한다. 이 모든 과정은 개발자가 명시적으로 분할 전략을 지정하지 않아도 되도록 추상화되어 있다.

     

    작업 분할에는 Spliterator가 사용된다. Spliterator는 Stream의 소스를 적절한 크기로 쪼개고, 각 조각을 병렬로 처리할 수 있도록 한다. 예를 들어, 리스트가 1000개의 요소를 갖는다면, 이를 4개로 나누어 250개씩 각 스레드에서 처리하는 방식이다. 이때 각 분할 작업은 ForkJoinTask 형태로 common pool에 제출되며, 이를 통해 자동으로 스레드가 할당되고 실행된다.

     

    ForkJoinPool.commonPool()은 JVM 전역에서 공유되는 공용 풀로, 기본적으로 시스템의 가용 프로세서 수에서 1을 뺀 만큼의 쓰레드를 사용한다. 예를 들어 8코어 시스템이라면 최대 7개의 스레드가 병렬 작업에 사용된다. 이는 시스템 자원을 보호하면서도 병렬 처리 성능을 최대한 활용할 수 있도록 설계된 구조다.

     

    다만, 모든 병렬 스트림이 항상 성능을 향상시키는 것은 아니다. 작업량이 작거나, 스레드 생성/컨텍스트 전환의 오버헤드가 큰 경우에는 오히려 직렬 처리보다 느릴 수 있다. 또한, 공용 풀을 공유하기 때문에 다른 병렬 작업과 리소스를 경합할 가능성도 고려해야 한다.

    '

     

    위 그림은 parallelStream()을 호출했을 때 내부적으로 어떻게 작업이 분할되고 병렬로 처리되는지를 시각화한 구조이다. 각 요소는 Spliterator에 의해 나누어지고, 각각의 작업은 ForkJoinTask 형태로 변환되어 ForkJoinPool.commonPool()에 제출된다. 이후 공용 스레드 풀의 워커 스레드들이 각 작업을 병렬로 수행한다. 이 흐름은 자동으로 처리되며, 개발자는 별도의 병렬 제어 코드를 작성하지 않아도 된다.

     

    3. ForkJoinPool의 직접 사용: RecursiveTask를 활용한 병렬화

    ForkJoinPool을 직접 사용하는 방식은 병렬 스트림보다 더 낮은 수준에서 병렬 처리를 제어할 수 있게 해준다. 특히 재귀적으로 분할 가능한 작업에 적합하며, 개발자가 병렬화 전략을 직접 정의할 수 있다는 점에서 유연성이 높다.

     

    직접 사용하려면 RecursiveTask<T> 또는 RecursiveAction을 상속받는 클래스를 구현해야 한다. 전자는 결과값을 반환하고, 후자는 반환값이 없는 작업에 사용된다. 분할 조건, 분할 방식, 병합 방식 등을 모두 명시적으로 작성하게 된다.

    예를 들어, 배열의 합을 구하는 작업을 ForkJoinPool을 통해 구현할 수 있다:

    class SumTask extends RecursiveTask<Long> {
        private static final int THRESHOLD = 1000;
        private final long[] arr;
        private final int start, end;
    
        public SumTask(long[] arr, int start, int end) {
            this.arr = arr;
            this.start = start;
            this.end = end;
        }
    
        @Override
        protected Long compute() {
            if (end - start <= THRESHOLD) {
                long sum = 0;
                for (int i = start; i < end; i++) {
                    sum += arr[i];
                }
                return sum;
            } else {
                int mid = (start + end) / 2;
                SumTask left = new SumTask(arr, start, mid);
                SumTask right = new SumTask(arr, mid, end);
    
                left.fork(); // 비동기 실행
                long rightResult = right.compute();
                long leftResult = left.join(); // 결과 대기
    
                return leftResult + rightResult;
            }
        }
    }

     

    이 클래스를 실행하려면 아래와 같이 ForkJoinPool을 생성하여 invoke() 하면 된다:

    ForkJoinPool pool = new ForkJoinPool();
    long result = pool.invoke(new SumTask(array, 0, array.length));

     

    이 방식은 분할 조건을 세밀하게 설정할 수 있기 때문에, 데이터 특성에 따라 병렬 처리의 효율을 극대화할 수 있다. 반면 병렬 스트림과는 달리 코드량이 많아지고, 구현자가 병렬 처리 흐름을 명확히 이해하고 있어야 한다는 점에서 진입 장벽은 다소 높은 편이다.

     

    4. 성능 분석: 병렬 스트림의 효율성과 한계

    병렬 스트림과 ForkJoinPool은 모두 병렬 처리를 지원하지만, 무조건적으로 성능이 향상되는 것은 아니다. 어떤 작업에서는 오히려 병렬 처리로 인해 성능이 저하될 수도 있다. 따라서 병렬 처리를 적용하기 전에 그 특성과 한계를 이해하는 것이 중요하다.

     

    병렬 스트림의 장점

    • 코드가 간결하며 선언형 스타일로 병렬 처리 구현 가능
    • 컬렉션 기반 데이터 처리에 최적화되어 있음
    • 내부적으로 적절한 작업 분할 및 스레드 할당을 자동 수행

     

    병렬 스트림의 한계 및 주의사항

    • 작업량이 적은 경우 오히려 느려질 수 있음
      • 병렬화 자체에 드는 오버헤드(스레드 분배, 컨텍스트 스위칭 등)가 존재함
      • 예: 1000개 미만의 요소를 처리하는 경우에는 직렬 처리보다 느릴 수 있음
    • 공용 풀 리소스 경쟁
      • ForkJoinPool.commonPool()은 애플리케이션 전체에서 공유되므로, 다른 병렬 작업과 자원을 경합할 수 있음
      • 예: 웹 서버 내에서 여러 사용자가 동시에 parallelStream()을 사용하는 경우 성능 저하 발생 가능
    • 순서 보장이 필요한 작업에서의 주의
      • forEach()는 작업 순서를 보장하지 않음 → 예측 불가능한 결과 발생 가능
      • 필요한 경우 forEachOrdered()를 사용할 수 있으나, 이 경우 병렬 처리의 이점이 감소함

     

    ForkJoinPool 직접 사용 시 고려사항

    • 병렬 처리 범위와 분할 단위를 개발자가 조절할 수 있어 고성능 구현 가능
    • 다만, 잘못된 분할 전략은 병렬성의 장점을 살리지 못하고 오히려 성능 저하를 유발할 수 있음
    • 쓰레드 풀 사이즈를 직접 지정할 수 있으므로, 시스템 환경에 맞는 최적화 가능

     

    요약

    병렬 처리는 상황에 따라 이점이 크지만, 무조건적으로 적용할 수 있는 만능 도구는 아니다. 특히 parallelStream()은 내부 구조가 간단해 보여도, 실제로는 ForkJoinPool을 사용하는 만큼 시스템 리소스와의 연관 관계를 이해한 후 사용하는 것이 좋다. 실험적으로 처리 대상의 크기와 연산 특성에 따라 직렬 처리와의 성능 차이를 측정해보는 것이 가장 현실적인 접근 방식이다.

     

    6. 적용 전략: 병렬 스트림과 ForkJoinPool의 선택 기준

    병렬 스트림과 ForkJoinPool은 모두 병렬 처리 도구이지만, 선택 기준은 명확히 나뉜다. 두 방식은 추상화 수준, 유연성, 구현 난이도 등에서 차이를 보이기 때문에, 프로젝트나 작업의 특성에 맞게 적절히 선택하는 것이 중요하다.

     

    병렬 스트림을 선택해야 할 때

    • 코드 간결성과 개발 생산성이 중요한 경우
    • 컬렉션 기반 데이터에서 map/filter/reduce 등의 연산을 수행할 때
    • 작업의 개별 단위가 비교적 균일하고, 순차적 흐름이 중요하지 않을 때
    • 순서 보장이 필요 없는 집계 또는 변환 작업 (e.g. 로그 분석, 통계 처리)

    ForkJoinPool을 직접 사용해야 할 때

    • 작업의 분할 전략을 직접 제어할 필요가 있는 경우
    • 불균형한 작업 또는 재귀적 구조를 가진 복잡한 문제를 처리할 때
    • 공용 쓰레드 풀이 아닌 별도의 풀을 구성하여 독립적인 병렬 처리가 필요할 때
    • 알고리즘 최적화를 위해 작업 흐름을 정밀하게 조정하고자 할 때

    혼합 전략의 가능성

     

    실제 프로젝트에서는 두 방식을 혼용하는 경우도 많다. 예를 들어, 데이터 처리에는 병렬 스트림을 사용하고, 특정 계산 집약적 연산은 ForkJoinPool로 분리하여 수행할 수 있다. 핵심은 작업의 특성과 시스템 자원의 상황을 고려하여 적절한 병렬화 수단을 선택하는 것이다.

     

    병렬 처리는 개발자의 개입을 줄이는 고수준 추상화와, 더 나은 성능을 위한 세밀한 제어 사이에서 균형을 잡아야 하는 영역이다. 상황에 맞는 전략적인 선택이 병렬 처리의 진정한 효과를 이끌어낸다. 병렬 처리는 상황에 따라 이점이 크지만, 무조건적으로 적용할 수 있는 만능 도구는 아니다. 특히 parallelStream()은 내부 구조가 간단해 보여도, 실제로는 ForkJoinPool을 사용하는 만큼 시스템 리소스와의 연관 관계를 이해한 후 사용하는 것이 좋다. 실험적으로 처리 대상의 크기와 연산 특성에 따라 직렬 처리와의 성능 차이를 측정해보는 것이 가장 현실적인 접근 방식이다.

     

    마치며

    병렬 스트림ForkJoinPool은 Java에서 병렬 처리를 지원하는 두 핵심 도구다. 둘은 내부적으로 연관되어 있지만, 사용 방법과 목적, 그리고 제어 범위에서 뚜렷한 차이를 가진다. 병렬 스트림은 선언형으로 빠르게 병렬 처리를 적용할 수 있다는 장점이 있고, ForkJoinPool은 더 세밀한 제어와 맞춤형 분할 전략을 통해 복잡한 계산 작업에 적합하다.

     

    이번 글에서는 단순한 사용법을 넘어서, 내부 구조와 실제 성능 특성까지 살펴보았다. 병렬 처리 도구는 도입 그 자체보다, 어떤 상황에서 어떤 방식으로 활용하느냐가 훨씬 중요하다. 잘못 사용된 병렬 처리 코드는 오히려 성능 저하를 불러올 수 있다.

     

    병렬 스트림과 ForkJoinPool을 단순히 대체 가능한 기술로 보지 않고, 각자의 역할과 강점을 명확히 이해하는 것이 병렬 프로그래밍을 효과적으로 활용하는 첫걸음이 될 것이다.