본문 바로가기

병렬 스트림의 결합 법칙과 성능 측정

병렬 reduce가 순차 실행과 같은 답을 내기 위해 연산이 지켜야 할 결합 법칙과 항등값 조건을 익히고, 공용 풀을 함께 쓸 때의 주의점과 병렬이 정말 빠른지 재는 방법을 알아봅니다.

앞 글에서 본 것처럼 병렬 스트림은 원본을 조각으로 나눠 여러 작업자가 처리한 뒤 조각별 결과를 합칩니다. 코드에는 parallel() 한 단어만 늘었지만, 마지막의 합치는 단계 때문에 연산에 조건이 붙습니다. 어디서 나누고 어떤 순서로 묶어 합치든 같은 결과가 나와야 한다는 조건입니다. 이 조건을 어기면 병렬 결과는 틀리고, 틀리는 방식마저 실행 환경에 따라 달라집니다.

또 하나의 질문은 병렬이 정말 빠르냐는 것입니다. 답은 데이터의 크기, 원소 하나를 처리하는 비용, 원본을 얼마나 쉽게 나눌 수 있는지, 순서 제약, 그리고 같은 풀을 쓰는 다른 작업과의 경쟁에 달려 있어서 재 보기 전에는 알 수 없습니다. 이 글에서는 먼저 병렬 reduce가 지켜야 할 두 법칙을 확인하고, 이어서 공용 풀의 성질과 성능을 재는 방법을 다룹니다.

뺄셈으로 reduce하면 병렬 결과가 달라진다

reduce(0, (left, right) -> left - right)를 순차 스트림에 쓰면 0 - 1 - 2 - ... - 8을 왼쪽부터 계산합니다. 같은 식을 병렬 스트림에 넘기면 어떻게 되는지 비교해 보겠습니다.

lab/NonAssociativeParallelReduceBug.java
import java.util.stream.IntStream;

public final class NonAssociativeParallelReduceBug {
    public static void main(String[] args) {
        int sequential = IntStream.rangeClosed(1, 8).reduce(0, (left, right) -> left - right);
        int parallel = IntStream.rangeClosed(1, 8).parallel().reduce(0, (left, right) -> left - right);

        System.out.println("sequential=" + sequential);
        System.out.println("parallel=" + parallel);
        System.out.println("same=" + (sequential == parallel));
    }
}

두 계산은 같은 범위와 같은 람다를 쓰고, 다른 것은 parallel() 호출 하나뿐입니다. 병렬 쪽 값은 원소를 어떻게 나누느냐에 따라 달라질 수 있으므로, 이 글의 실행 환경에서 얻은 결과를 실었습니다.

sequential=-36
parallel=0
same=false

순차 결과 −36은 0에서 1부터 8까지를 차례로 뺀 값입니다. 병렬 결과가 다른 이유는 계산하는 방식이 다르기 때문입니다. 병렬 reduce는 조각마다 항등값 0에서 출발해 조각 안의 원소를 누산하고, 조각별 결과를 같은 연산으로 합칩니다. 예를 들어 원소가 두 개씩 네 조각으로 나뉘었다면 다음과 같이 계산됩니다.

단계계산결과
조각별 누산0 - 1 - 2, 0 - 3 - 4, 0 - 5 - 6, 0 - 7 - 8−3, −7, −11, −15
첫 번째 합치기(-3) - (-7), (-11) - (-15)4, 4
두 번째 합치기4 - 40

이 모형으로 계산한 값은 실행 결과 0과 같지만, 실제로 몇 조각으로 나뉘는지는 JDK 구현과 풀의 병렬성에 따라 달라집니다. 조각이 달라지면 결과도 달라질 수 있습니다. API는 이런 연산에 대해 어떤 값도 약속하지 않으며, 분할에 따라서는 우연히 순차 결과와 같아질 수도 있으므로 same=false조차 보장된 결과가 아닙니다. 그러니 이 숫자를 기대하는 코드나 테스트를 만들 것이 아니라, 연산이 어떤 조건을 어겼는지를 봐야 합니다.

병렬 reduce가 지켜야 할 두 가지 법칙

Stream.reduce의 API 문서는 reduce(identity, accumulator)에 두 가지를 요구합니다. 첫째, identity는 누산 함수의 항등값이어야 합니다. 모든 t에 대해 accumulator.apply(identity, t)가 t와 같아야 한다는 뜻입니다. 둘째, 누산 함수는 결합 법칙(associativity)을 만족해야 합니다. (a op b) op c와 a op (b op c)가 언제나 같아야 한다는 뜻입니다. 순차 실행만 생각하면 이 조건이 왜 필요한지 잘 보이지 않지만, 병렬에서는 두 조건이 각각 다른 단계를 지켜 줍니다.

항등값은 조각마다 한 번씩 출발점으로 쓰입니다. 그래서 항등값이 값을 바꾸면 그 변화가 조각 수만큼 쌓입니다. 결합 법칙은 합치는 단계를 지켜 줍니다. 순차 실행은 언제나 왼쪽부터 묶지만, 병렬 실행은 조각의 경계마다 괄호를 치는 것과 같습니다. 괄호의 위치가 결과를 바꾸는 연산이라면 분할 방식이 곧 결과를 정하게 됩니다.

뺄셈은 두 법칙을 모두 어깁니다. 아래 단계별 그림처럼 작은 반례 두 개로 확인할 수 있습니다. 0 − 3은 −3이라서 3을 그대로 남기지 못하므로, 0은 뺄셈의 항등값이 아닙니다. 또 같은 1, 2, 3을 (1 - 2) - 3으로 묶으면 −4이고 1 - (2 - 3)으로 묶으면 2입니다. 두 검사는 서로 독립이라서 한 법칙만 고쳐서는 나머지 위반이 그대로 남습니다.

뺄셈 축약이 위반하는 두 법칙

0−3으로 항등원 실패를 확인하고 같은 1·2·3을 두 괄호 묶음으로 계산하여 −4와 2를 얻습니다. 독립 법칙 점검의 중간값을 보여 주면 병렬 스케줄 없이 위반 원인을 이해할 수 있습니다.

뺄셈 축약이 위반하는 두 법칙

뺄셈 축약이 위반하는 두 법칙

병렬 축약에 필요한 두 법칙

뺄셈 축약이 위반하는 두 법칙

0을 항등값으로 쓰는 뺄셈은 값을 보존하지 못하고, 묶는 위치에 따라 결과가 달라집니다.

항등값과 결합 법칙을 서로 다른 작은 반례로 검사한다원뺄셈reduce의초기값0과연산에두독립검사를적용한다. 0−3은3을유지하지못하고(1−2)−3의−4와1−(2−3)의2도다르다. 5노드4검사→반례관계이며한법칙이실패한뒤다른법칙을실행한다는시간순서가아니다. mobile같은두분기이며실제parallel스케줄또는원main의특정병렬출력숫자를예측하지않는다.reduce 계약 점검초기값0 · 뺄셈항등값 검사0 − 3 = −3원소3을 바꿈항등값 실패결합 방식 검사(1−2)−3 = −41−(2−3) = 2다른 묶음 결과−4 ≠ 2결합 법칙 실패
항등값과 결합 법칙을 서로 다른 작은 반례로 검사한다원뺄셈reduce의초기값0과연산에두독립검사를적용한다. 0−3은3을유지하지못하고(1−2)−3의−4와1−(2−3)의2도다르다. 5노드4검사→반례관계이며한법칙이실패한뒤다른법칙을실행한다는시간순서가아니다. mobile같은두분기이며실제parallel스케줄또는원main의특정병렬출력숫자를예측하지않는다.reduce 계약 점검초기값0 · 뺄셈항등값 검사0 − 3 = −3원소3을 바꿈항등값 실패결합 방식 검사(1−2)−3 = −41−(2−3) = 2다른 묶음 결과−4 ≠ 2결합 법칙 실패

항등값

뺄셈으로 확인
0 − 3 = −3
판정
3을 보존하지 못함

결합 법칙

뺄셈으로 확인
(1 − 2) − 3 = −4 · 1 − (2 − 3) = 2
판정
묶는 위치에 따라 값이 달라짐

원문의 순차 축약 결과는 −36입니다. 병렬 뺄셈 결과는 이 법칙들을 만족하지 않아 하나의 값으로 보장하지 않습니다.

이 예의 조건

  • 뺄셈 reduce의 초깃값 0에 항등원과 결합 법칙의 독립 검사를 같은 관찰 단계마다 적용합니다.
  • 특정 parallel 출력이나 분할 스케줄을 예측하지 않습니다.
관계도의 보충 설명
원 도표의 전체 관계·라벨·조건
구분내용
1항등원과 결합 법칙을 서로 다른 작은 반례로 검사한다
2원 뺄셈 reduce의 초깃값 0과 연산에 두 독립 검사를 적용한다. 0−3은 3을 유지하지 못하고 (1−2)−3의 −4와 1−(2−3)의 2도 다르다. 5개 노드와 4개의 검사→반례 관계이며 한 법칙이 실패한 뒤 다른 법칙을 실행한다는 시간순서가 아니다. mobile도 같은 두 분기이며 실제 parallel 스케줄 또는 원 main의 특정 병렬 출력 숫자를 예측하지 않는다.
4초깃값 0 · 뺄셈
5항등원 검사
7원소 3을 바꿈
8항등원 실패
원 도표의 설명과 주석
구분내용
설명원 뺄셈 reduce의 초깃값 0과 연산에 두 독립 검사를 적용한다. 0−3은 3을 유지하지 못하고 (1−2)−3의 −4와 1−(2−3)의 2도 다르다. 5개 노드와 4개의 검사→반례 관계이며 한 법칙이 실패한 뒤 다른 법칙을 실행한다는 시간순서가 아니다. mobile도 같은 두 분기이며 실제 parallel 스케줄 또는 원 main의 특정 병렬 출력 숫자를 예측하지 않는다.
전체 읽기 정보뺄셈 축약이 위반하는 두 법칙 뺄셈 축약이 위반하는 두 법칙 뺄셈 축약이 위반하는 두 법칙 병렬 축약에 필요한 두 법칙 뺄셈 축약이 위반하는 두 법칙 0을 항등원으로 쓰는 뺄셈은 값을 보존하지 못하고, 묶는 위치에 따라 결과가 달라집니다. 항등원 뺄셈으로 확인 0 − 3 = −3 판정 3을 보존하지 못함 결합 법칙 뺄셈으로 확인 (1 − 2) − 3 = −4 · 1 − (2 − 3) = 2 판정 묶는 위치에 따라 값이 달라짐 원문의 순차 축약 결과는 −36입니다. 병렬 뺄셈 결과는 이 법칙들을 만족하지 않아 하나의 값으로 보장하지 않습니다.
예의 값과 변화
관찰·상황값·보충 정보변화·이유
두 법칙의 검사 후보

초깃값 0과 뺄셈 연산을 두 독립 반례에 적용할 식을 준비합니다.

  • 항등원 후보: 0과 원소 3 → 0−3
  • 결합 후보: 원소 1,2,3 → 두 괄호 방식
  • 관계: 서로 독립인 법칙 검사
  • 항등원은 원소 보존, 결합 법칙은 괄호 위치와 무관한 결과를 요구합니다.
각 반례 계산

항등원 식과 두 결합 식을 각각 계산합니다.

  • 항등원 계산: 0−3=−3
  • 왼쪽 결합: (1−2)−3=−4
  • 오른쪽 결합: 1−(2−3)=2
  • 각 검사는 자신의 비교값을 만들며 다른 검사의 계산 결과를 입력으로 쓰지 않습니다.
각 법칙 판정

원소 보존과 두 묶음의 결과 일치가 모두 성립하지 않습니다.

  • 항등원 판정: −3≠3 → 실패
  • 결합 판정: −4≠2 → 실패
  • 한 법칙만 보완: 다른 법칙의 위반은 남음
  • 독립 반례로 계약 위반을 보이며 특정 병렬 실행값으로 일반화하지 않습니다.

뺄셈 축약이 위반하는 두 법칙

0−3으로 항등원 실패를 확인하고 같은 1·2·3을 두 괄호 묶음으로 계산하여 −4와 2를 얻습니다. 독립 법칙 점검의 중간값을 보여 주면 병렬 스케줄 없이 위반 원인을 이해할 수 있습니다.

뺄셈 축약이 위반하는 두 법칙

뺄셈 축약이 위반하는 두 법칙

병렬 축약에 필요한 두 법칙

뺄셈 축약이 위반하는 두 법칙

0을 항등값으로 쓰는 뺄셈은 값을 보존하지 못하고, 묶는 위치에 따라 결과가 달라집니다.

항등값과 결합 법칙을 서로 다른 작은 반례로 검사한다원뺄셈reduce의초기값0과연산에두독립검사를적용한다. 0−3은3을유지하지못하고(1−2)−3의−4와1−(2−3)의2도다르다. 5노드4검사→반례관계이며한법칙이실패한뒤다른법칙을실행한다는시간순서가아니다. mobile같은두분기이며실제parallel스케줄또는원main의특정병렬출력숫자를예측하지않는다.reduce 계약 점검초기값0 · 뺄셈항등값 검사0 − 3 = −3원소3을 바꿈항등값 실패결합 방식 검사(1−2)−3 = −41−(2−3) = 2다른 묶음 결과−4 ≠ 2결합 법칙 실패
항등값과 결합 법칙을 서로 다른 작은 반례로 검사한다원뺄셈reduce의초기값0과연산에두독립검사를적용한다. 0−3은3을유지하지못하고(1−2)−3의−4와1−(2−3)의2도다르다. 5노드4검사→반례관계이며한법칙이실패한뒤다른법칙을실행한다는시간순서가아니다. mobile같은두분기이며실제parallel스케줄또는원main의특정병렬출력숫자를예측하지않는다.reduce 계약 점검초기값0 · 뺄셈항등값 검사0 − 3 = −3원소3을 바꿈항등값 실패결합 방식 검사(1−2)−3 = −41−(2−3) = 2다른 묶음 결과−4 ≠ 2결합 법칙 실패

항등값

뺄셈으로 확인
0 − 3 = −3
판정
3을 보존하지 못함

결합 법칙

뺄셈으로 확인
(1 − 2) − 3 = −4 · 1 − (2 − 3) = 2
판정
묶는 위치에 따라 값이 달라짐

원문의 순차 축약 결과는 −36입니다. 병렬 뺄셈 결과는 이 법칙들을 만족하지 않아 하나의 값으로 보장하지 않습니다.

이 예의 조건

  • 뺄셈 reduce의 초깃값 0에 항등원과 결합 법칙의 독립 검사를 같은 관찰 단계마다 적용합니다.
  • 특정 parallel 출력이나 분할 스케줄을 예측하지 않습니다.

관계도의 보충 설명

원 도표의 전체 관계·라벨·조건
구분내용
1항등원과 결합 법칙을 서로 다른 작은 반례로 검사한다
2원 뺄셈 reduce의 초깃값 0과 연산에 두 독립 검사를 적용한다. 0−3은 3을 유지하지 못하고 (1−2)−3의 −4와 1−(2−3)의 2도 다르다. 5개 노드와 4개의 검사→반례 관계이며 한 법칙이 실패한 뒤 다른 법칙을 실행한다는 시간순서가 아니다. mobile도 같은 두 분기이며 실제 parallel 스케줄 또는 원 main의 특정 병렬 출력 숫자를 예측하지 않는다.
4초깃값 0 · 뺄셈
5항등원 검사
7원소 3을 바꿈
8항등원 실패
원 도표의 설명과 주석
구분내용
설명원 뺄셈 reduce의 초깃값 0과 연산에 두 독립 검사를 적용한다. 0−3은 3을 유지하지 못하고 (1−2)−3의 −4와 1−(2−3)의 2도 다르다. 5개 노드와 4개의 검사→반례 관계이며 한 법칙이 실패한 뒤 다른 법칙을 실행한다는 시간순서가 아니다. mobile도 같은 두 분기이며 실제 parallel 스케줄 또는 원 main의 특정 병렬 출력 숫자를 예측하지 않는다.
전체 읽기 정보뺄셈 축약이 위반하는 두 법칙 뺄셈 축약이 위반하는 두 법칙 뺄셈 축약이 위반하는 두 법칙 병렬 축약에 필요한 두 법칙 뺄셈 축약이 위반하는 두 법칙 0을 항등원으로 쓰는 뺄셈은 값을 보존하지 못하고, 묶는 위치에 따라 결과가 달라집니다. 항등원 뺄셈으로 확인 0 − 3 = −3 판정 3을 보존하지 못함 결합 법칙 뺄셈으로 확인 (1 − 2) − 3 = −4 · 1 − (2 − 3) = 2 판정 묶는 위치에 따라 값이 달라짐 원문의 순차 축약 결과는 −36입니다. 병렬 뺄셈 결과는 이 법칙들을 만족하지 않아 하나의 값으로 보장하지 않습니다.
예의 값과 변화
관찰·상황값·보충 정보변화·이유
두 법칙의 검사 후보

초깃값 0과 뺄셈 연산을 두 독립 반례에 적용할 식을 준비합니다.

  • 항등원 후보: 0과 원소 3 → 0−3
  • 결합 후보: 원소 1,2,3 → 두 괄호 방식
  • 관계: 서로 독립인 법칙 검사
  • 항등원은 원소 보존, 결합 법칙은 괄호 위치와 무관한 결과를 요구합니다.
각 반례 계산

항등원 식과 두 결합 식을 각각 계산합니다.

  • 항등원 계산: 0−3=−3
  • 왼쪽 결합: (1−2)−3=−4
  • 오른쪽 결합: 1−(2−3)=2
  • 각 검사는 자신의 비교값을 만들며 다른 검사의 계산 결과를 입력으로 쓰지 않습니다.
각 법칙 판정

원소 보존과 두 묶음의 결과 일치가 모두 성립하지 않습니다.

  • 항등원 판정: −3≠3 → 실패
  • 결합 판정: −4≠2 → 실패
  • 한 법칙만 보완: 다른 법칙의 위반은 남음
  • 독립 반례로 계약 위반을 보이며 특정 병렬 실행값으로 일반화하지 않습니다.

항등값이 틀리면 실제로 어떻게 되는지도 실행해 볼 수 있습니다. 덧셈은 결합 법칙을 만족하지만, 항등값 자리에 0 대신 1을 넣은 IntStream.rangeClosed(1, 8).parallel().reduce(1, Integer::sum)은 순차 결과 37과 다른 값을 냅니다. 이 환경에서는 40이 나왔고, 공용 풀의 병렬성을 3으로 바꿔 실행하자 44가 나왔습니다. 조각이 네 개일 때는 1이 네 번, 여덟 개일 때는 여덟 번 더해진 것입니다. 틀린 정도가 조각 수, 즉 실행 환경에 따라 달라진다는 점이 이런 버그를 찾기 어렵게 만듭니다.

법칙을 지키는 연산과 그래도 남는 함정

결합 법칙과 항등값을 함께 갖춘 연산으로는 덧셈과 0, 곱셈과 1, Math::min과 Integer.MAX_VALUE, Math::max와 Integer.MIN_VALUE, 문자열 이어 붙이기와 빈 문자열이 있습니다. 문자열 이어 붙이기는 "a" + "b"와 "b" + "a"가 다르므로 교환 법칙은 성립하지 않지만 reduce에 써도 됩니다. 순서가 있는 스트림에서는 조각별 결과를 만남 순서대로 합치므로, 필요한 것은 결합 법칙뿐이고 교환 법칙은 필요하지 않습니다. 실제로 "a"부터 "h"까지를 병렬로 reduce("", String::concat)하면 언제나 abcdefgh가 나옵니다.

법칙을 만족하는 연산에도 함정이 남습니다. int 덧셈은 범위를 넘으면 값이 한 바퀴 돌아 음수가 되는데, 이렇게 넘친 덧셈도 결합 법칙은 만족합니다. 그래서 순차 결과와 병렬 결과는 같지만, 둘 다 수학적으로는 틀린 값일 수 있습니다. 넘침을 잡아내려고 Math.addExact를 쓰면 이번에는 결합 법칙이 깨집니다. Integer.MAX_VALUE, 1, −1을 왼쪽부터 더하면 MAX_VALUE + 1에서 ArithmeticException이 나지만, MAX_VALUE + (1 + (-1))로 묶으면 예외 없이 MAX_VALUE가 나옵니다. 분할에 따라 예외가 나기도 하고 안 나기도 하는 것입니다. 합계가 int 범위를 넘을 수 있다면 long이나 BigInteger처럼 더 넓은 타입으로 더하는 편이 안전합니다.

부동소수점도 마찬가지입니다. double 덧셈은 반올림 때문에 결합 법칙을 정확히 만족하지 않습니다. (0.1 + 0.2) + 0.3은 0.6000000000000001이고 0.1 + (0.2 + 0.3)은 0.6입니다. 그래서 double 값을 병렬로 더하면 순차 결과와 마지막 자릿수가 다를 수 있습니다. 금액처럼 정확한 값이 필요하면 원 단위 정수를 long으로 더하거나 BigDecimal을 씁니다.

순차 결과와 병렬 결과를 비교해 검사하기

법칙을 지키는지 모든 입력에 대해 증명하기는 어렵지만, 순차 결과와 병렬 결과를 여러 입력에서 비교하면 잘못된 연산이나 항등값을 일찍 찾을 수 있습니다. 다음 프로그램은 덧셈과 뺄셈을 같은 방법으로 검사합니다. 출력의 a/b는 순차 결과와 병렬 결과이고, 둘이 다르면 MISMATCH를 붙입니다.

src/AssociativeReductionContract.java
import java.util.List;
import java.util.function.BinaryOperator;
import java.util.stream.IntStream;

public final class AssociativeReductionContract {
    private static String compare(List<Integer> values, BinaryOperator<Integer> operator) {
        int sequential = values.stream().reduce(0, operator);
        int parallel = values.parallelStream().reduce(0, operator);
        return sequential + "/" + parallel + (sequential == parallel ? "" : " MISMATCH");
    }

    public static void main(String[] args) {
        List<List<Integer>> cases = List.of(
                List.of(),
                List.of(7),
                List.of(3, -2, 8, 1),
                IntStream.rangeClosed(1, 100).boxed().toList());

        for (List<Integer> values : cases) {
            System.out.println("size=" + values.size()
                    + "  sum " + compare(values, Integer::sum)
                    + "  subtract " + compare(values, (left, right) -> left - right));
        }
    }
}

compare는 같은 목록으로 순차 스트림과 병렬 스트림을 하나씩 만들어 같은 항등값 0과 연산을 넘깁니다. 각 줄에서 MISMATCH가 붙은 칸이 어느 연산, 어느 크기의 입력인지 보세요. 이 글의 실행 환경에서 얻은 결과입니다.

size=0  sum 0/0  subtract 0/0
size=1  sum 7/7  subtract -7/-7
size=4  sum 10/10  subtract -10/2 MISMATCH
size=100  sum 5050/5050  subtract -5050/0 MISMATCH

덧셈은 네 입력 모두에서 두 결과가 같습니다. 빈 목록에서는 원소가 없으므로 항등값 0이 그대로 결과가 됩니다. 뺄셈은 크기가 4와 100인 입력에서 두 결과가 달라 검사에 걸렸습니다. 눈여겨볼 것은 빈 목록과 원소 하나짜리 목록에서는 뺄셈도 통과했다는 점입니다. 합치는 단계가 일어나지 않으면 결합 법칙을 어겨도 드러나지 않기 때문입니다. 그래서 검사 입력에는 빈 입력과 원소 하나짜리 입력뿐 아니라, 여러 조각으로 나뉠 만큼 큰 입력도 섞어야 합니다. 뺄셈의 병렬 값 자체는 분할에 따라 달라지므로 다른 환경에서는 다른 숫자가 나올 수 있습니다.

이런 비교로 법칙을 증명할 수는 없지만 흔한 실수는 대부분 걸러 낼 수 있습니다. 실제 테스트라면 출력 대신 두 결과가 다를 때 실패하도록 단언(assertion)하고, 무작위 입력을 쓴다면 실패를 재현할 수 있게 난수의 시드(seed)를 기록해 둡니다.

공용 풀은 프로세스 전체가 함께 쓴다

현재 JDK 구현에서 병렬 스트림은 보통 ForkJoinPool.commonPool()에서 실행됩니다. 다음 프로그램은 병렬 스트림을 한 번 실행한 뒤 공용 풀의 상태를 출력합니다.

src/CommonPoolInventory.java
import java.util.concurrent.ForkJoinPool;
import java.util.stream.IntStream;

public final class CommonPoolInventory {
    public static void main(String[] args) {
        ForkJoinPool common = ForkJoinPool.commonPool();
        int result = IntStream.rangeClosed(1, 1_000).parallel().map(value -> value * 2).sum();

        System.out.println("processors=" + Runtime.getRuntime().availableProcessors());
        System.out.println("parallelism=" + common.getParallelism());
        System.out.println("pool-size=" + common.getPoolSize());
        System.out.println("queued-submissions=" + common.getQueuedSubmissionCount());
        System.out.println("checksum=" + result);
    }
}

getPoolSize()와 getQueuedSubmissionCount()는 호출한 순간의 상태를 돌려주므로, 아래 값은 병렬 스트림이 끝난 직후에 읽은 것입니다. 이 글의 실행 환경에서 얻은 결과는 다음과 같습니다.

processors=2
parallelism=1
pool-size=1
queued-submissions=0
checksum=1001000

마지막 줄의 checksum은 1부터 1000까지의 합 500500을 두 배 한 값이므로 어디서 실행해도 같습니다. 나머지 줄은 환경과 측정 시점에 따라 달라집니다. processors는 JVM이 쓸 수 있다고 보는 프로세서 수이며, 컨테이너에 CPU 제한을 걸면 그 제한이 반영됩니다. parallelism은 공용 풀이 목표로 하는 작업자 수입니다. API 문서는 공용 풀의 기본 병렬성을 정해 두지 않았지만, 현재 OpenJDK는 따로 설정하지 않으면 프로세서 수보다 하나 적게(최소 1) 잡습니다. 이 환경은 프로세서가 2개라서 1이 나왔습니다.

pool-size는 시작된 작업자 스레드의 수입니다. API 문서에 따르면 작업자 일부가 블로킹돼서 병렬성을 유지하려고 스레드를 더 만들면 parallelism과 달라질 수 있습니다. queued-submissions는 풀에 제출됐지만 아직 시작하지 않은 작업 수의 추정치이고, 이 프로그램에서는 병렬 스트림이 끝난 뒤에 읽었으므로 0입니다.

공용 풀을 쓰는 것은 병렬 스트림만이 아닙니다. ForkJoinPool 바깥에서 풀을 지정하지 않고 실행한 ForkJoinTask는 공용 풀로 가고, CompletableFuture의 비동기 메서드도 실행기를 지정하지 않으면 공용 풀을 씁니다. JDK 21의 API 문서에는 공용 풀의 병렬성이 2보다 작으면 작업마다 새 스레드를 만든다는 예외가 있었지만, JDK 25의 문서에는 이 예외가 없습니다. 내가 쓰는 라이브러리가 내부에서 병렬 스트림을 쓸 수도 있습니다. 그래서 병렬 스트림으로 요청 하나가 빨라졌는지만 볼 것이 아니라, 같은 프로세스의 다른 작업과 같은 작업자를 두고 경쟁하지 않는지 살펴야 합니다. 공용 풀의 작업자가 블로킹 I/O나 긴 계산에 묶이면 전혀 상관없는 기능까지 느려집니다.

공용 풀의 병렬성은 JVM 시스템 속성 java.util.concurrent.ForkJoinPool.common.parallelism으로 바꿀 수 있습니다. 하지만 이 값은 프로세스 안의 모든 사용자에게 한꺼번에 적용되므로, 코드 한 곳을 빠르게 하려는 지역적인 조정처럼 쓰면 안 됩니다. JDK 25의 API 문서는 이 속성 대신 setParallelism 메서드를 쓰라고 권하는데, 어느 쪽이든 프로세스 전체에 영향을 준다는 점은 같습니다.

병렬 스트림을 실제로 처리하는 스레드

병렬 스트림을 실제로 어떤 스레드가 처리하는지 확인해 보겠습니다. 다음 프로그램은 같은 병렬 스트림을 한 번은 main에서 그냥 실행하고, 한 번은 작업자가 3개인 전용 ForkJoinPool에 제출해 실행하면서, 원소를 처리한 스레드의 이름을 모읍니다.

src/PoolThreadNames.java
import java.util.Set;
import java.util.concurrent.ConcurrentSkipListSet;
import java.util.concurrent.ForkJoinPool;
import java.util.stream.IntStream;

public final class PoolThreadNames {
    private static Set<String> threadsUsed() {
        Set<String> names = new ConcurrentSkipListSet<>();
        IntStream.range(0, 200_000)
                .parallel()
                .forEach(value -> names.add(Thread.currentThread().getName()));
        return names;
    }

    public static void main(String[] args) throws Exception {
        System.out.println("common: " + threadsUsed());

        try (ForkJoinPool pool = new ForkJoinPool(3)) {
            System.out.println("dedicated: " + pool.submit(PoolThreadNames::threadsUsed).get());
        }
    }
}

스레드 이름은 ConcurrentSkipListSet에 모으므로 중복 없이 이름순으로 정렬되어 찍힙니다. 이 글의 실행 환경에서 얻은 결과는 다음과 같습니다.

common: [ForkJoinPool.commonPool-worker-1, main]
dedicated: [ForkJoinPool-1-worker-1, ForkJoinPool-1-worker-2, ForkJoinPool-1-worker-3]

첫 줄에는 공용 풀의 작업자와 함께 main이 들어 있습니다. 최종 연산을 호출한 스레드는 결과를 가만히 기다리지 않고 직접 일부 조각을 처리합니다. 그래서 공용 풀의 작업자 수와 실제로 계산에 참여하는 스레드 수는 같지 않습니다. 앞에서 공용 풀의 병렬성이 프로세서 수보다 하나 적다고 했는데, 호출한 스레드까지 더하면 이 환경에서는 두 스레드가 프로세서 2개를 나눠 쓰는 셈입니다.

둘째 줄에는 전용 풀의 작업자만 보입니다. pool.submit으로 넘긴 작업은 그 풀의 작업자가 실행하고, 작업자 스레드 안에서 시작된 병렬 스트림은 하위 작업도 같은 풀에 넣습니다. main은 get()에서 결과를 기다리기만 합니다. 이 방법은 병렬 스트림을 공용 풀에서 떼어 내는 요령으로 널리 쓰이지만, Stream API가 약속하는 동작이 아니라 현재 구현의 동작이라는 점을 알고 써야 합니다. 스트림 API에는 실행할 풀을 받는 매개변수가 없습니다. 이름이 몇 개 보이는지도 프로세서 수와 타이밍에 따라 달라질 수 있습니다.

실행할 풀을 분명히 정하는 것이 목적이라면, 실행기를 인수로 받는 CompletableFuture나 직접 만든 ForkJoinTask, 실행기에 제출하는 분할 작업이 의도를 더 잘 드러냅니다. 전용 풀을 둔다면 누가 만들고 언제 닫는지, 여러 요청이 함께 쓰는지를 정해 두어야 합니다. 요청마다 풀을 만들고 닫으면 생성 비용이 반복되고 스레드 수가 요청 수만큼 늘어나기 때문입니다. 여러 요청이 동시에 풀을 쓰는 상황은 다음 글에서 이어서 다룹니다.

병렬이 빠른지는 재 봐야 안다

병렬이 빠른지 확인하려고 System.nanoTime()으로 한 번 재고 끝내면 믿기 어려운 숫자가 나옵니다. 처음 몇 번의 실행에는 클래스 로딩과 JIT 컴파일 시간이 섞이고, 그 뒤에도 CPU 주파수 변화나 GC, 같은 머신의 다른 작업 때문에 값이 크게 흔들립니다. 아래 프로그램은 벤치마크 도구를 대신하지는 못하지만, 측정할 때 갖춰야 할 최소한의 조건을 보여 줍니다. 순차와 병렬에 같은 입력을 쓰고, 시간을 재지 않는 예열을 먼저 돌리고, 여러 번 측정하고, 측정할 때마다 결과를 기대값과 비교합니다.

src/ParallelMeasurementPrerequisites.java
import java.util.Arrays;
import java.util.stream.IntStream;

public final class ParallelMeasurementPrerequisites {
    private static long work(int value) {
        long result = value;
        for (int round = 0; round < 20; round++) {
            result = (result * 1_664_525L + 1_013_904_223L) & 0x7fff_ffffL;
        }
        return result;
    }

    private static long sequential(int[] values) {
        return Arrays.stream(values).mapToLong(ParallelMeasurementPrerequisites::work).sum();
    }

    private static long parallel(int[] values) {
        return Arrays.stream(values).parallel().mapToLong(ParallelMeasurementPrerequisites::work).sum();
    }

    public static void main(String[] args) {
        int[] values = IntStream.range(0, 50_000).toArray();
        long expected = sequential(values);

        for (int warmup = 0; warmup < 10; warmup++) { // 예열: 시간을 재지 않고 JIT 컴파일을 유도한다
            sequential(values);
            parallel(values);
        }

        for (int round = 1; round <= 3; round++) {
            long start = System.nanoTime();
            long sequentialResult = sequential(values);
            long sequentialMicros = (System.nanoTime() - start) / 1_000;

            start = System.nanoTime();
            long parallelResult = parallel(values);
            long parallelMicros = (System.nanoTime() - start) / 1_000;

            if (sequentialResult != expected || parallelResult != expected) {
                throw new AssertionError("checksum mismatch in round " + round);
            }
            System.out.println("round " + round + ": sequential=" + sequentialMicros
                    + "us, parallel=" + parallelMicros + "us");
        }
        System.out.println("checksum=" + expected);
    }
}

결과를 기대값과 비교하는 데는 두 가지 이유가 있습니다. 하나는 병렬 경로가 순차 경로와 같은 답을 내는지 측정할 때마다 확인하는 것입니다. 다른 하나는 결과를 실제로 사용해서, JIT 컴파일러가 쓰이지 않는 계산을 통째로 없애 버리는 죽은 코드 제거(dead code elimination)를 막는 것입니다. 결과를 버리는 측정은 계산이 아니라 빈 반복을 재고 있을 수도 있습니다.

실행 결과(예, 프로세서 2개 환경):

round 1: sequential=594us, parallel=845us
round 2: sequential=1489us, parallel=677us
round 3: sequential=567us, parallel=632us
checksum=53686062385048

checksum은 언제나 같고 시간은 실행마다 다릅니다. 이 환경에서 여섯 번 실행해 얻은 18쌍을 보면, 순차는 대부분 0.56–0.65 ms였지만 1.0 ms, 1.5 ms, 16 ms로 튄 경우가 있었습니다. 병렬은 0.6 ms에서 13 ms까지 오르내렸습니다. 병렬이 더 빨랐던 쌍은 세 번뿐이었는데, 세 번 모두 순차 쪽 측정값이 튄 경우였습니다. 즉 이 크기의 입력에서 병렬은 빠르지 않았고, 몇 번 빨라 보인 것은 측정의 흔들림이었습니다. 원소 5만 개에 원소당 계산이 아주 작아서 전체 일이 1 ms도 걸리지 않는데, 조각을 나누고 작업자를 깨우고 결과를 합치는 비용은 그대로 들기 때문입니다. 게다가 이 환경의 공용 풀에는 작업자가 하나뿐이라서, 잘해야 두 스레드가 일을 나눌 수 있습니다.

실제로 결정을 내릴 때는 JMH(Java Microbenchmark Harness) 같은 벤치마크 도구를 씁니다. JMH는 측정마다 JVM을 따로 띄우고, 예열과 측정 반복을 나눠 관리하며, 결과를 Blackhole에 넘겨 죽은 코드 제거를 막는 장치를 제공합니다. 결과를 볼 때는 평균만 보지 말고 백분위수와 분산을 함께 보고, 운영 환경과 같은 코어 제한과 동시 부하에서 측정해야 합니다.

병렬이 한 번 더 빨랐으면 바로 적용해도 되나요?

아닙니다. 앞의 측정에서 보듯 한 번의 차이는 흔들림일 수 있습니다. 먼저 순차와 병렬의 결과가 같은지 확인하고, 별도 JVM 프로세스에서 충분히 예열한 뒤 반복 측정해야 합니다. 입력도 운영에서 실제로 다루는 크기여야 하고, 동시에 들어오는 요청 부하도 포함해야 합니다. 적용한 뒤에도 처리량만 보지 말고 꼬리 지연 시간(가장 느린 일부 요청의 응답 시간), CPU 사용률, 공용 풀의 대기 작업, 다른 기능의 지연을 함께 지켜봅니다.

병렬 스트림이 잘 맞는 작업과 맞지 않는 작업

지금까지 본 내용을 바탕으로, 작업의 성질에 따라 병렬 스트림이 얼마나 잘 맞는지 정리하면 다음과 같습니다.

작업의 성질병렬 스트림이유
큰 배열이나 ArrayList, IntStream.range처럼 크기를 알고 반씩 나누기 쉬운 원본잘 맞음조각을 고르고 싸게 나눌 수 있다
원소 하나에 드는 CPU 계산이 충분히 큰 작업잘 맞음나누고 합치는 비용보다 나눠서 아끼는 시간이 크다
LinkedList나 Stream.iterate처럼 앞에서부터 따라가야 하는 원본맞지 않음나누려면 원소를 차례로 따라가야 해서 분할이 비싸고 고르지 않다
원소가 적거나 원소당 계산이 아주 값싼 작업맞지 않음나누고 깨우고 합치는 비용이 계산보다 크다
만남 순서를 지켜야 하는 limit, findFirst, 전체를 모아야 하는 sorted비용 확인 필요조각별 결과를 붙잡아 두거나 서로 맞춰야 한다
파일, 데이터베이스, 원격 호출 같은 블로킹 I/O맞지 않음공용 풀의 작업자를 붙잡아 다른 CPU 작업까지 늦춘다
외부 API 호출, 데이터베이스 쓰기, 로그 추가 같은 부수 효과맞지 않음순서, 재시도, 일부만 실패한 경우를 다룰 규칙이 따로 필요하다

병렬 스트림은 CPU 계산을 여러 코어에 나누는 도구입니다. 기다리는 시간이 대부분인 I/O를 동시에 많이 처리하는 문제는 성격이 다르므로, 가상 스레드와 동시 실행 수를 제한하는 세마포어, 비동기 클라이언트, I/O 전용 실행기처럼 자원에 맞는 도구를 고릅니다. CPU 병렬성과 블로킹 동시성을 같은 병렬 스트림 설정으로 해결하려 하지 않는 것이 중요합니다.

정리

  • 병렬 reduce는 조각마다 항등값에서 출발해 누산하고 조각별 결과를 합칩니다. 그래서 항등값은 어떤 값과 결합해도 그 값을 바꾸지 않아야 하고, 누산 함수는 결합 법칙을 만족해야 합니다. 교환 법칙은 필요하지 않습니다.
  • 뺄셈처럼 법칙을 어기는 연산은 병렬에서 틀린 값을 내고, 그 값은 분할 방식에 따라 달라집니다. 틀린 항등값의 오차도 조각 수만큼 쌓입니다.
  • int 넘침, Math.addExact의 예외, double의 반올림은 법칙을 만족하는 듯한 연산에도 남는 함정입니다.
  • 순차 결과와 병렬 결과를 비교하는 검사에는 빈 입력과 원소 하나짜리 입력뿐 아니라 여러 조각으로 나뉠 만큼 큰 입력을 섞습니다.
  • 공용 풀은 병렬 스트림, CompletableFuture의 기본 비동기 실행, 풀 바깥에서 풀을 지정하지 않고 실행한 ForkJoinTask가 함께 씁니다. 호출한 스레드도 계산에 참여하며, 전용 풀 안에서 병렬 스트림을 돌리는 방법은 API가 아니라 구현에 기댄 것입니다.
  • 성능은 같은 입력, 예열, 반복 측정, 결과 확인을 갖춘 측정으로 판단하고, 결정에는 JMH 같은 도구를 씁니다.

연습 문제

1. 뺄셈을 병렬로 바르게 계산하기

0 - 1 - 2 - ... - 8을 병렬 스트림으로 계산하면서도 언제나 −36을 얻으려면 어떻게 바꿔야 할까요?

풀이 확인

a - b - c는 a - (b + c)와 같으므로, 빼야 할 값들을 먼저 더한 뒤 한 번만 빼면 됩니다. 0 - IntStream.rangeClosed(1, 8).parallel().sum()처럼 쓰면 병렬로 계산하는 부분은 덧셈뿐이고, 덧셈은 결합 법칙과 항등값 0을 모두 갖추고 있습니다. 각 값의 부호를 먼저 바꿔 IntStream.rangeClosed(1, 8).parallel().map(value -> -value).sum()으로 써도 됩니다. 두 방법 모두 실행하면 −36이 나옵니다. 법칙을 어기는 연산을 그대로 병렬화하려 하지 말고, 법칙을 만족하는 연산으로 문제를 바꿔 쓰는 것이 요령입니다.

2. 항등값을 1로 바꾸면

덧셈 누산 함수는 그대로 두고 항등값만 1로 바꿉니다. [1, 2, 3]을 한 조각으로 계산한 값과, [1, 2]와 [3] 두 조각으로 나눠 각각 1에서 출발해 계산한 뒤 합친 값을 구하세요. 덧셈이 결합 법칙을 만족하니 이 항등값도 괜찮을까요?

풀이 확인

한 조각이면 1 + 1 + 2 + 3 = 7입니다. 두 조각이면 1 + 1 + 2 = 4와 1 + 3 = 4를 합쳐 8입니다. 원소의 합 6에 출발값 1이 조각마다 한 번씩 더해졌기 때문에 조각 수에 따라 결과가 달라집니다. 결합 법칙은 합치는 단계만 지켜 줄 뿐이고, 항등값 조건은 따로 만족해야 합니다. 덧셈의 항등값은 0뿐입니다. 본문에서 reduce(1, Integer::sum)이 37 대신 40이나 44를 낸 것도 같은 이유입니다. 실제 스트림이 꼭 이 두 조각으로 나눈다는 뜻은 아니고, 분할 모형으로 법칙 위반을 확인한 문제입니다.

3. 크기에 따라 순차와 병렬 고르기

입력이 10,000개 미만이면 순차로, 그 이상이면 병렬로 각 값을 Integer.toUnsignedLong(value * 31 + 17)로 바꾼 뒤 합계를 구하세요. 두 경로는 같은 함수를 써야 하고 결과가 같아야 합니다.

해답 보기
exercise/ThresholdParallelChecksumSolution.java
import java.util.Arrays;
import java.util.stream.IntStream;

public final class ThresholdParallelChecksumSolution {
    private static final int PARALLEL_THRESHOLD = 10_000;

    private static long normalize(int value) {
        return Integer.toUnsignedLong(value * 31 + 17);
    }

    static long checksum(int[] values, boolean parallel) {
        IntStream stream = Arrays.stream(values);
        if (parallel) {
            stream = stream.parallel();
        }
        return stream.mapToLong(ThresholdParallelChecksumSolution::normalize).sum();
    }

    static long checksum(int[] values) {
        return checksum(values, values.length >= PARALLEL_THRESHOLD);
    }

    public static void main(String[] args) {
        int[] small = IntStream.range(0, 100).toArray();
        int[] large = IntStream.range(0, 20_000).toArray();
        System.out.println("small=" + checksum(small));
        System.out.println("large=" + checksum(large));
        System.out.println("same=" + (checksum(large, false) == checksum(large, true)));
    }
}

실행 결과:

small=155150
large=6200030000
same=true

두 경로가 같은 normalize와 같은 sum을 쓰고, 다른 점은 parallel()을 붙이느냐뿐입니다. long 덧셈은 결합 법칙과 항등값 0을 갖추고 있으므로 두 경로의 결과가 같아야 하며, 마지막 줄은 같은 입력을 두 경로로 계산해 이를 확인합니다. 원소가 100개인 small은 순차 경로를 타고 31 × 4950 + 17 × 100 = 155150이 됩니다. 원소가 20,000개인 large는 병렬 경로를 타고 31 × 199990000 + 17 × 20000 = 6200030000이 됩니다. 이 입력에서 가장 큰 int 계산은 19999 × 31 + 17 = 619986이라 범위를 넘지 않습니다. 다만 일반 입력에서는 value * 31 + 17을 int로 먼저 계산한 뒤 부호 없는 값으로 읽으므로, 예를 들어 −1은 −14를 거쳐 4294967282가 됩니다.

조건이 >=이므로 길이가 9,999인 배열은 순차, 10,000인 배열은 병렬 경로를 탑니다. 하지만 여기서 10,000은 분기를 보여 주려고 정한 예시 값일 뿐, 이 크기부터 병렬이 빠르다는 측정 결과가 아닙니다. 실제 기준값은 대표적인 작업과 운영 하드웨어에서 앞 절의 방법으로 측정해 정해야 합니다.

다음 글

다음 글에서는 서버처럼 여러 요청이 동시에 병렬 스트림을 쓰는 상황으로 넘어갑니다. 요청들이 공용 풀을 두고 경쟁할 때 무슨 일이 생기는지 보고, 전용 ForkJoinPool과 CompletableFuture, 블로킹 I/O용 실행기를 어떻게 나눠 쓰는지 다룹니다.