Java Stream API: Mastering Reduction Operations
Java에서 Stream을 다루다 보면 stream에 있는 값들을 하나의 결과 값으로 변환하는 작업들이 흔히 발생한다. stream을 하나의 값으로 reducing하거나 모든 값을 더해 하나의 값으로 취합하는 작업들이 여기에 해당한다. Java Stream API는 이런 일련의 작업들을 효율적으로 할 수 있도록 직관적이면서 강력한 툴을 제공한다. 이 글에서는 코드를 깔끔하게 하고 원하는 성과를 달성할 수 있도록 이런 operation들의 활용 방법에 대해 알아본다.

앞으로 다루게 될 모든 Operation들은 Terminal Operation이라고 하며 Stream을 가공해서 반환될 값으로 변형한 다음 Stream을 Close한다는 의미를 갖고 있다.
count method
Stream에서 처리하는 Element의 개수를 알고 싶을 때 count method를 사용한다.
long count();
사용법은 아래와 같이 매우 직관적이다.
long example1 = Stream.of(1, 2, 3).count();
// 3
count() method는 초기 Stream에 속한 총 Element개수가 아니라 중간 단계의 모든 Operation이 종료된 이후의 최종 Element 개수를 반환한다. 아래 샘플에서 count() method는 filter() Operation 이후에 호출된다. 여기서 filter() method는 한 개의 Element를 제외시키므로 최종 결과는 3이 아니라 2가 된다.
long example2 = Stream.of(1, 2, 3)
.filter(n -> n != 1)
.count();
// 2
min method
Stream에서 최소값을 갖는 Element를 찾기 위해 아래의 method를 사용할 수 있다.
Optional<T> min(Comparator<? super T> comparator);
이 method는 파라미터로 받는 Comparator를 활용하여 최소값을 결정한다.
Optional<Integer> example4 = Stream.of(1, 2, 3)
.min(Integer::compare);
// Optional[1]
결과값은 바로 반환되는 것이 아니라 Optional 객체로 감싸여진 상태로 반환된다. 따라서, 빈(empty) Stream을 상대로 호출한다면 결과는 빈 Optional이 된다.
Optional<Integer> example5 = Stream.<Integer>empty()
.min(Integer::compare);
// Optional.empty
또 다른 흥미로운 샘플로 LocalDate 객체들로 이루어진 Stream에 이 method를 적용해 보는 것이다. LocalDate는 Comparator를 implement하지 않지만 LocalDate를 epoch day(long)으로 변환 후 값을 비교할 수 있기 때문에 이슈가 되지 않는다.
Optional<LocalDate> example6 = Stream.of(
LocalDate.of(2024, 8, 25),
LocalDate.of(2024, 8, 26))
.min(Comparator.comparing(LocalDate::toEpochDay));
// Optional[2024-08-25]
이 샘플은 아래와 같이 단순화 시킬 수 있다.
Optional<LocalDate> example7 = Stream.of(
LocalDate.of(2024, 8, 25),
LocalDate.of(2024, 8, 26))
.min(Comparator.naturalOrder());
// Optional[2024-08-25]
Numeric stream 인터페이스는 유사한 method를 지원하지만 Element 타입을 유추할 수 있기 때문에 파라미터를 지정하지 않아도 된다. 추가적으로 이드르 메소드는 Stream 타입에 대응하는 Optional 객체를 반환한다. 즉, IntStream일 경우 OptionalInt, LongStream일 경우 OptionalLong 등..
OptionalInt example8 = IntStream.of(1, 2, 3)
.min();
// OptionalInt[1]
max method
max method는 min method오 대척점에 있다. 즉, 파라미터로 주어지는 Comparator
를 활용하여 최대값을 반환한다.
Optional<T> max(Comparator<? super T> comparator);
아래는 활용 샘플을 보여준다.
Optional<Integer> example9 = Stream.of(1, 2, 3)
.max(Integer::compare);
// Optional[3]
Optional<Integer> example10 = Stream.<Integer>empty()
.max(Integer::compare);
// Optional.empty
Optional<LocalDate> example11 = Stream.of(
LocalDate.of(2024, 8, 25),
LocalDate.of(2024, 8, 26))
.max(Comparator.comparing(LocalDate::toEpochDay));
// Optional[2024-08-26]
Optional<LocalDate> example12 = Stream.of(
LocalDate.of(2024, 8, 25),
LocalDate.of(2024, 8, 26))
.max(Comparator.naturalOrder());
// Optional[2024-08-26]
OptionalInt example13 = IntStream.of(1, 2, 3)
.max();
// OptionalInt[3]
reduce method
Java Stream API의 reduce method는 terminal operation으로 stream의 모든 element를 연산해서 하나의 immutable value로 만들어내도록 한다. 이는 반복적으로 각 element에 binary 연산을 적용하고 그 결과 값을 누적해 나가는 방식으로 진행한다.
Stream 인터페이스는 3가지의 overloading된 reduce method를 제공한다. 그럼 첫 번째 것부터 살펴보자
Optional<T> reduce(BinaryOperator<T> accumulator);
이 method는 BinaryOperator를 전달 받고 Optional객체를 반환한다. 예를 들면, 아래와 같이 Stream의 모든 Element에 대한 합계를 쉽게 구할 수 있다.
Optional<Integer> example14 = Stream.of(0, 1, 2)
.reduce((a, b) -> a + b);
// Optional[3] (0 + 1 + 2 = 3)
// I. a(0) + b(1) = 1
// II. a(1) + b(2) = 3
reduce 연산과정을 보면
- Initial Step: Stream의 처음 Element 2개가
BinaryOperator
로 전달된다. 이때 첫번째 Element는 a, 두번째 Element는 b가 된다. 이 연산의 결과는 또다시 새로운 a가 된다. - Subsequent Iterations: reduce 연산은 이전 연산의 결과 값(a)로 Stream의 다음 Element를 (b)로 해서 연산을 이어간다. 이 과정은 Stream에 남아있는 모든 element에 대해 반복적으로 적용해 나간다.
- Final Result: 모든 Element가 처리되고 나면 최종적으로 누적된 값이 Optional 값으로 반환한다.
만약 Stream이 비어(empty)있다면 이 method는 텅빈(empty) Optional을 반환한다. 따라서 값을 얻어오기 전에 .isPresent()
method를 이용하여 값이 존재여부를 먼저 확인하는 것이 좋은 코딩습관이라 할 수 있다.
Optional<Integer> example15 = Stream.<Integer>empty()
.reduce((a, b) -> a + b);
// Optional.empty
두 번째 reduce method는 다음과 같은 형태를 가진다.
T reduce(T identity, BinaryOperator<T> accumulator);
BinaryOperator
에 대해 추가적으로 identity
로 불리는 파라미터를 추가적으로 받는다. 이는 reduction과정에서의 초기값으로 활용되거나 Stream이 비어있을 경우 default 값으로 사용된다. 이런 동작으로 인해 이 method는 Optional이 아니라 값을 직접 반환하는 형태를 가진다.
Integer example16 = Stream.of( 1, 2, 3)
.reduce(10, (a, b) -> a + b);
// 16 (10 + 1 + 2 + 3 = 16)
// I. a(10) + b(1) = 11
// II. a(11) + b(2) = 13
// II. a(13) + b(3) = 16
Integer example17 = Stream.<Integer>empty()
.reduce(10, (a, b) -> a + b);
// 10
위 샘플에서 identity로 10을 사용했기 때문에 reduce method는 시작할 때 이 값을 적용한다. 만약 Stream이 비어있다면 10을 기본 값으로 반환한다.
마지막으로 설명할 reduce method는 아래와 같다.
<U> U reduce(U identity,
BiFunction<U, ? super T, U> accumulator,
BinaryOperator<U> combiner);
이 method는 추가적으로 combiner
라 불리는 BinaryOperator
를 파라미터로 받는다. combiner는 Stream이 병렬로 처리될 때 부분 결과값을 취합하는데 중요한 역할을 수행한다. 병렬 Stream에서 reduction 연산은 subtask들로 분할되며 이들 subtask들은 동시에 실행된다. 각각의 subtask들은 부분 결과를 도출하며 combiner function은 이들 부분 결과값을 최종 결과값으로 취합하는데 사용된다. 이 과정은 병렬처리에도 불구하고 전체 reduction이 정확하게 산출되도록 한다.
Integer example18 = Stream.of(1, 2, 3)
.parallel()
.reduce(0, (a, b) -> a + b, Integer::sum);
// 6
Integer example19 = Stream.<Integer>empty()
.parallel()
.reduce(0, (a, b) -> a + b, Integer::sum);
// 0
이 샘플에서 combiner는 병렬연산 과정에서 각기 다른 subtask들이 계산한 결과를 취합하는 역할을 한다. Integer::sum
method reference는 (a, b) -> a + b
와 동일한 목적이지만 좀 더 간결한 형태를 가진다.
reduce method에서 identity 값을 사용할 경우 주의를 기울여야 한다. identity 값을 사용할 경우 병렬처리 Stream에서 각 subtask들은 이 값을 초기 값으로 사용한다. 이는 identity값이 각 subtask들의 reduction과정에서 초기값 역할을 한다는 것을 의미한다. 결과적으로 앞 샘플의 identity값을 0에서 10으로 변경한다면 결과값은 16에서 36으로 바뀌게 된다.
Integer example20 = Stream.of(1, 2, 3)
.parallel()
.reduce(10, (a, b) -> a + b, Integer::sum);
// 36
Numeric stream 인터페이스도 reduce method를 제공하지만 3번째 overloaded 버전인 combiner function을 사용하는 형태는 제공하지 않는다. 왜냐하면 이들 reduction operation은 직관적이고 identity와 accumulation function만을 이용하더라도 효과적으로 처리가 가능하기 때문이다.
OptionalInt example21 = IntStream.of(0, 1, 2)
.reduce((a, b) -> a + b);
// Optional[3]
System.out.println("example21 = " + example21);
int example22 = IntStream.of(0, 1, 2)
.reduce(10, (a, b) -> a + b);
// 13
특정 method들은 numeric stream 인터페이스에만 존재하기도 한다. 여기서부터는 이들 특화된 method들에 대해 알아 보도록 하겠다.
sum method
sum method는 아래와 같은 형태를 가진다.
// IntStream
int sum();
// LongStream
long sum();
// DoubleStream
double sum();
내부를 살펴보면 IntStream과 LongStream은 identity값과 더불어 간단한 reduction 연산을 활용해서 이들 sum method를 구현한다. 그러나, DoubleStream은 합계를 산출하기 위해 부동소수점 연산 관련된 좀 더 복잡한 알고리즘을 사용한다.
int example23 = IntStream.of(1, 2, 3)
.sum();
// 6
long example24 = LongStream.of(1L, 2L, 3L)
.sum();
// 6
double example25 = DoubleStream.of(1.1, 2.2, 3.3)
.sum();
// 6.6
average method
아래의 method를 이용하면 numeric stream의 평균값을 계산할 수 있다.
// IntStream
// LongStream
// DoubleStream
OptionalDouble average();
모든 numeric stream 인터페이스들은 동일한 형태의 이 method를 지원하며 아래와 같이 사용할 수 있다.
OptionalDouble example26 = IntStream.of(1, 2, 3)
.average();
// OptionalDouble[2.0]
OptionalDouble example27 = LongStream.of(1L, 2L, 3L)
.average();
// OptionalDouble[2.0]
OptionalDouble example28 = DoubleStream.of(1.1, 2.2, 3.3)
.average();
// OptionalDouble[2.1999999999999997]
summary statistics method
Java Stream API에서 summaryStatistics
method는 Stream의 element에 대한 통계 데이터를 추출하기 위해 numeric stream과 같이 사용한다. 이 메소드는 아래와 같은 정보를 포함하는 요약통계를 반환한다.
- Count: Stream에 포함된 element의 갯수
- Sum: element들의 총 합계
- Min: Stream의 element들 중 최소값
- Max: Stream의 element들 중 최대값
- Average: element들의 평균값
이 method는 한 번의 호출로 numeric stream으로 부터 다양한 통계정보를 추출할 수 있는 편리한 방법을 제공한다.
이 method의 형태는 아래와 같다.
// IntStream
IntSummaryStatistics summaryStatistics();
// LongStream
LongSummaryStatistics summaryStatistics();
// DoubleStream
DoubleSummaryStatistics summaryStatistics();
각 method들은 개별 타입에 대응하는 SummaryStatistics 클래스의 객체를 반환하며 다양한 통계정보 확인을 위한 유용한 method들을 제공한다. 아래의 샘플은 IntStream을 이용하는 방법을 보여주며 다른 numeric stream 들도 이와 유사한 방법으로 동작한다.
IntSummaryStatistics example29 = IntStream.of(1, 2, 3)
.summaryStatistics();
long example29Count = example29.getCount();
// 3
System.out.println("count = " + example29Count);
long example29Sum = example29.getSum();
// 6
System.out.println("example29Sum = " + example29Sum);
int example29Min = example29.getMin();
// 1
System.out.println("example29Min = " + example29Min);
int example29Max = example29.getMax();
// 3
System.out.println("example29Max = " + example29Max);
double example29Average = example29.getAverage();
// 2.0
또한, SummaryStatistics 객체를 다른 통계정보와 결합시킬 수 있다.
example29.combine(IntStream.of(4, 5, 6)
.summaryStatistics());
// {count=6, sum=21, min=1, average=3,500000, max=6}
아래와 같이 SummaryStatistics 객체에 element를 추가시키는 것도 가능하다.
example29.accept(10);
// {count=7, sum=31, min=1, average=4,428571, max=10}
위 샘플에서 보듯이 새로운 element를 추가할 경우 이 element를 수용하기 위해 모든 값들에 대한 재계산이 이루어져야 한다.
텅빈 Stream객체를 대상으로 요약 통계 method를 호출할 경우 그 결과는 아래와 같다.
IntSummaryStatistics example30 = IntStream.empty()
.summaryStatistics();
// {count=0, sum=0, min=2147483647, average=0,000000, max=-2147483648}
갯수와 합계, 그리고 평균은 0이 된다. 반면 min
과 max
값은 각각 Integer.MAX_VALUE와
Integer.MIN_VALUE가
된다. 이들 임계치 값들은 일종의 placeholder 역할을 하며 stream에 element가 추가될 경우 이들 placeholder 값은 갱신된다.
요약하며…
활용가능한 다양한 reduction 연산들이 많아 처음 접할경우 좌절을 맛볼 수도 있다. 한편으로는 이런 다양성이 각기 다른 task에 맞는 적절한 툴을 제공한다는 측면에서 도움이 될 수 있지만 다른 한편으로는 모든 옵션들을 모두 이해하려면 부담스러울 수도 있다. 이 글은 명확한 사용법을 위해 가장 공통적으로 활용되는 reduction 연산의 샘플과 상세 설명을 제공한다.