빌드노트
parallelStream의 reduce(10, sum)는 왜 합계가 더 커질까
reduce의 identity에는 덧셈의 중립 원소 0을 넣어야 한다. 작은 목록에서 순차·병렬 실행과 빈 입력을 비교하고, 합계 보정값 10을 환원 뒤 한 번 적용하는 수정과 실제 오답이 실행 조건에 따라 달라지는 이유를 설명한다.
stream().reduce(10, Integer::sum)을 parallelStream()으로 바꾼 뒤 합계가 커졌다면 첫 인자 10부터 확인한다. 이 자리는 환원을 시작할 때 쓰는 값이지만, 연산에 참여해도 상대 값을 바꾸지 않는 중립 원소여야 한다. 덧셈의 중립 원소는 0이다. 합계에 보정값 10을 한 번 더하려면 reduce(0, Integer::sum) + 10으로 보정을 종료 연산 밖에 둔다.
작은 정수 목록으로 차이를 확인해 보자. 다음은 운영 장애 기록이 아니라 이 설명을 위해 따로 실행한 비교다. 외부 라이브러리 없이 JDK 17의 java와 jshell을 사용한다. 두 명령이 PATH에 있는 PowerShell 7에서 버전을 확인하고 새 JShell을 시작한다.
java -version
jshell --no-startup --feedback silent -R-XX:ActiveProcessorCount=2 "-R-Djava.util.concurrent.ForkJoinPool.common.parallelism=2"
위 명령은 예제 JVM의 인식 프로세서 수를 2로 제한하고 common pool의 목표 병렬성을 2로 지정한 실행 조건이다. JShell의 -R 옵션 (새 탭에서 열림)은 입력 코드를 실행하는 JVM에 옵션을 전달한다. PowerShell에 붙여 넣을 때는 긴 -R-D 옵션 전체를 감싼 큰따옴표도 포함한다. JShell이 시작되기 전에 파일 없음 오류가 나면 먼저 이 옵션의 인용부호를 확인한다. 이 설정을 해결책으로 바꿀 필요는 없다. 먼저 같은 목록에서 스트림 실행 방식만 바꿔 결과를 읽는다.
import java.util.List;
var values = List.of(1, 2, 3, 4, 5, 6, 7, 8);
System.out.println("seq10=" + values.stream().reduce(10, Integer::sum));
System.out.println("par10=" + values.parallelStream().reduce(10, Integer::sum));
Windows 11, PowerShell 7.6.6, OpenJDK 17.0.2에서 직접 출력한 두 줄이다. 뒤의 코드도 같은 JShell 세션에서 이어 실행한다.
seq10=46
par10=116
목록 자체의 합은 36이다. 보정값을 한 번 더한 46을 원했는데 병렬 실행에서는 116이 나왔다. 두 종료 연산마다 목록에서 새 스트림을 만들었고, 입력 목록이나 공유 합계 변수를 수정하지 않았다.
10에서 시작할 수 있어도 덧셈의 identity는 아니다
Stream.reduce(identity, accumulator)의 Java 17 계약 (새 탭에서 열림)은 모든 원소 t에 대해 accumulator.apply(identity, t)가 t와 같아야 한다고 정한다. 덧셈에 대입하면 0 + 7은 7을 유지하지만 10 + 7은 17로 바꾼다. Integer::sum을 써도 첫 인자가 10이면 이 조건을 만족하지 못한다.
순차 실행에서는 10에서 시작해 여덟 수를 차례로 더했으므로 원하는 46과 맞았다. 병렬 환원은 입력을 나누고 각 부분의 결과를 계산한 뒤 합칠 수 있다. Stream 패키지의 환원 설명 (새 탭에서 열림)이 설명하는 방식이다. 각 부분이 10에서 시작하면 부분 결과들을 더할 때 그 10들도 함께 더해진다. 0이라면 여러 부분에 들어가도 합계를 바꾸지 않는다.
116은 이번 실행의 관찰값이다. 같은 입력을 별도 JVM에서 common pool 목표 병렬성 1로 실행했을 때는 76이었다. 두 실행 모두 인식 프로세서 수는 2였다. 목표 병렬성은 ForkJoinPool의 시스템 속성 (새 탭에서 열림)으로 지정했으며, 이 비교에서 실제 부분 작업 수를 추적하지는 않았다. 분할 수를 CPU 수나 목표 병렬성으로 계산하거나, 항상 116이 나온다고 가정하면 안 된다.
자기 환경에서 다른 값이나 심지어 46이 나와도 10은 덧셈의 중립 원소가 아니다. 특정 오답을 재현해야만 고칠 수 있는 문제가 아니다. 환원 함수에는 결합법칙과 상태를 갖지 않는 동작도 요구되지만, 이 예제의 Integer::sum에서 바꿔야 할 것은 중립 원소 자리에 넣은 보정값이다.
먼저 0으로 합계를 구한 다음 한 번 보정한다
앞의 values를 그대로 두고 중립값으로 구한 합계와 보정 후 결과를 나란히 확인한다. 실제 코드의 수정 지점도 다음 seqTotal, parTotal 두 줄과 같다.
System.out.println("seq0=" + values.stream().reduce(0, Integer::sum));
System.out.println("par0=" + values.parallelStream().reduce(0, Integer::sum));
System.out.println("seqTotal=" + (values.stream().reduce(0, Integer::sum) + 10));
System.out.println("parTotal=" + (values.parallelStream().reduce(0, Integer::sum) + 10));
같은 환경에서 출력은 다음과 같았다.
seq0=36
par0=36
seqTotal=46
parTotal=46
reduce(0, Integer::sum)이 부분 합계를 모두 합쳐 반환한 뒤, 바깥의 + 10이 실행된다. 따라서 보정값은 전체 결과에 한 번 적용된다. 앞서 목표 병렬성을 1로 바꾼 별도 실행에서도 중립값 합계는 36, 보정 후 결과는 46이었다. 수정 후 이 목록에서 결과가 다르면 입력이나 중간 연산이 달라졌는지, 보정이 다른 호출 지점에서도 추가되는지 확인한다. 순차 실행으로 되돌려 숫자만 맞추는 것으로 identity 조건을 충족한 것은 아니다.
빈 목록에서는 잘못된 identity도 티가 나지 않는다
빈 입력까지 같은 세션에서 확인한다. values를 빈 목록으로 바꾼 뒤 기존 호출과 수정한 호출의 반환값을 읽는다.
values = List.of();
System.out.println("emptySeq10=" + values.stream().reduce(10, Integer::sum));
System.out.println("emptyPar10=" + values.parallelStream().reduce(10, Integer::sum));
System.out.println("emptyPar0=" + values.parallelStream().reduce(0, Integer::sum));
System.out.println("emptyTotal=" + (values.parallelStream().reduce(0, Integer::sum) + 10));
관찰한 출력은 다음과 같다.
emptySeq10=10
emptyPar10=10
emptyPar0=0
emptyTotal=10
빈 목록에서는 더할 원소가 없어 기존 호출도 10을 반환했다. 이 결과만으로 10이 올바른 identity인지 판정할 수는 없다. 수정한 호출은 빈 합계 0을 구한 뒤 보정값을 한 번 적용한다. 여기서는 입력이 없어도 보정값 10을 반환하는 요구를 가정했다. 빈 입력일 때 최종값이 0이어야 한다면 보정을 적용할 조건을 별도로 정해야 한다. 확인을 마치면 /exit로 JShell을 종료한다.
직접 실행한 범위는 OpenJDK 17.0.2의 작은 정수 목록과 빈 목록, common pool 목표 병렬성 1·2다. Java 25의 reduce 문서 (새 탭에서 열림)에서도 같은 중립 원소 계약을 확인했지만 Java 25 실행은 하지 않았다. 정수 범위를 넘는 합계, 부동소수점 오차와 성능은 이 비교로 검증하지 않았다.