Contents
Java 8 (리듀싱)
   Sep 28, 2022     7 min read

리듀싱

최종 연산 정리

  • boolean(allMatch 등)
  • void(forEach)
  • Optional 객체(findAny 등)

리듀스(Reduce)연산을 이용해서 할 수 있는 것들

예)

  • 메뉴의 모든 칼로리의 합계를 구하시오.
  • 메뉴에서 칼로리가 가장 높은 요리 ?

위와 같이 리듀스 연산을 이용해서 스트림 요소를 조합해서 더 복잡한 질의를 표현하는 방법을 설명한다.

이러한 질의를 수행하려면 Integer 같은 결과가 나올 때까지 스트림의 모든 요소를 반복적으로 처리해야 한다.

이런 질의를 리듀싱 연산(모든 스트림 요소를 처리해서 값으로 도출하는)이라고 한다.

함수형 프로그래밍 언어 용어로는 이 과정이 마치 종이(우리의 스트림)를 작은 조각이 될때까지 반복해서 접는 것과 비슷하다는 의미로 폴드(fold) 라고 한다.


요소의 합

  • for-each 예시

      int sum = 0;
      for(int x : numbers){
      	sum += x;
      }
    
    • 코드에서 파라미터 두 개 사용
      • sum 변수의 초깃값 0
      • 리스트의 모든 요소를 조합하는 연산(+)

위 코드를 복사 & 붙여넣기하지 않고 모든 숫자를 곱하는 연산을 구현할 수 있다면 좋을 것이다.

이런 상황에서 reduce 를 이용하면 애플리케이션의 반복된 패턴을 추상화할 수 있다.

reduce를 이용해서 스트림의 모든 요소를 더할 수 있다.

int sum = numbers.stream().reduce(0, (a, b) -> a + b);
  • reduce는 두 개의 인수를 갖는다.
    • 초기값 0
    • 두 요소를 조합해서 새로운 값을 만드는 BinaryOperator. 예제에서는 람다 표현식 (a, b) → a + b를 사용했다.

reduce로 다른 람다, 즉 (a, b) → a * b를 넘겨주면 모든 요소에 곱셈을 적용할 수 있다.

int product = numbers.stream().reduce(1, (a, b) -> a * b);

➡️ reduce를 이용해서 스트림의 모든 자 더하기 → 그림으로 보면

Untitled

안에 구조를 더 자세히 보면 …

reduce(0, (a, b) -> a + b)
  1. 람다의 첫 번째 파라미터(a)에 0이 사용됨

  2. 스트림에서 4를 사용해서 두 번째 파라미터(b)로 사용했다.

  3. 0 + 4 = 4가 새로운 누적값(accumulated value)이 되었다.

    Untitled 1

  4. 누적값으로 다시 람다를 호출 → 다음 요소 5를 사용한다. 결과 9가 된다.

  5. 이런식으로 다음 요소 3을 사용하면 누적값은 12가 된다.

  6. 마지막으로 누적값 12와 요소 9로 담라를 호출하면 최종적으로 21이 도출된다.

자바 8에서는 Integer 클래스에 두 숫자를 더하는 정적 sum 메서드를 제공한다. 직접 람다 코드를 구현할 필요가 없다.

int sum = numbers.stream().reduce(0, Integer::sum);

초깃값 없을 경우

초기값을 받지않는 오버로드된 reduce도 있다. 하지만 reduce는 Optional 객체를 반환한다.

Optional<Integer> sum= numbers.stream().reduce((a,b) -> (a + b));
  • 왜 Optional를 반환할까?

스트림에 요소가 없을 경우 reduce는 합계를 반환할 수 없다. 따라서 합계가 없음을 가리킬 수 있도록 Optional 객체로 감싼 결과를 반환한다.


최대값과 최솟값

➡️ 리듀싱 연산 : 최대값 계산

Untitled 2

  • 최대값찾는 방법

reduce는 두 인수를 받는다

  1. 초깃값
  2. 스트림의 두 요소를 합쳐서 하나의 값으로 만드는 데 사용할 람다
Optional<Integer> max = numbers.stream().reduce(Integer::max);

Integer.max대신 Integer.min을 reduce로 넘겨주면 최솟값을 찾을 수 있다.

Optional<Integer> min = numbers.stream()reduce(Integer::min);

Integer::min 대신 람다 표현식 (x, y ) → x < y ? x : y 를 사용해도 된다.

하지만 메소드 참조를 쓰면 더 간결해진다.

메소드 참조는 간결해지기 위해서 쓰는 것이다.. 자꾸 사용하려고 기억해내자.


reduce 퀴즈

  • map과 reduce 메서드를 이용해서 스트림의 요리 개수를 계산하시오.

힌트

  • 스트림의 각 요소를 1로 매핑한 다음에 reduce로 이들의 합계를 계산하는 방식으로 문제를 해결할 수 있다. 즉, 스트림에 저장된 숫자를 차례로 더한다 .
int count = menu.stream().map(d -> 1).reduce(0 (a, b) -> a + b);

※참고 map과 reduce를 연결하는 기법을 맵 리듀스(map - reduce)패턴이라고 한다. 쉽게 병렬화하는 특징 때문에 구글이 웹 검색에 적용하면서 유명해졌다.

하지만 위의 문제는 map과 reduce를 사용해서 하는 답을 원해서 밑에는 다시 리마인드한다는 심정으로 적어봤다.

➡️스트림 요소를 세는 방법 2

long count = menu.stream().count();

reduce 메서드의 장점과 병렬화

질문

  • 기존의 단계적 반복(for-each)으로 합계를구하는 것과 reduce를 이용해서 합계를 구하는 것은 어떤 차이가 있을까 ?

우선 reduce를 사용하면 내부 반복이 추상화되면서 내부 구현에서 병렬로 reduce를 실행할 수 있게 된다.

반복적인 합계에서는 sum 변수를 공유해야 하므로 쉽게 병렬화하기 어렵다. 강제적으로 동기화시키더라도

결국 병렬화로 얻어야 할 이득이 스레드 간의 소모적인 경쟁 때문에 상쇄되어 버린다.

이 작업을 병렬화하려면 입력을 분할하고, 분할된 입력을 더한 다음에, 더한 값을 합쳐야 한다.

지금까지 살펴본 코드와는 조금 다른 코드가 나타난다.

중요한 사실은 가변 누적자 패턴(mutable accumulator pattren) 은 병렬화와 거리가 너무 먼 기법이라는 점이다.

나중에 스트림의 모든 요소를 더하는 코드를 병렬로 만드는 방법을 배울 것이다. 잠깐 맛만 보면 stream()을 parallelStream()으로 바꾸면 된다고 한다.

int sum = numbers.parallelStream().reduce(0, Integer::sum);

병렬로 실행하려면 대가를 지불해야한다.

  1. reduce에 넘겨준 람다의 상턔(인스턴스 변수 같은)가 바뀌지 말아야한다.
  2. 연산이 어떤 순서로 실행되더라고 결과가 바뀌지 않는 구조여야 한다.

스트림 연산 : 상태 없음과 상태 있음

요리 리스트를 스트림으로 변환할 수 있고, filter로 원하는 종류의 요리만 선택할 수 있으며, map을 이용해서 칼로리를 추가한 다음에, reduce로 요리의 칼로리 총합을 계산한다. 또한 이런 계산을 병렬로 실행할 수 있다.

하지만 이들은 각각 다양한 연산을 수행하기때문에 각각의 연산은 내부적인 상태를 고려해야한다.

map, filter 등은 입력 스트림에서 각 요소를 받아 0 또는 결과를 출력 스트림으로 보낸다.

따라서 (사용자가 제공한 람다나 메서드 참조가 내부적인 가변 상태를 갖지 않는다는 가정하에) 이들은 보통 상태가 없는, 즉 내부 상태를 갖지 않는 연산(stateless operation) 이다.

reduce, sum, max 같은 연산은 결과를 누적할 내부 상태가 필요하다. 예제의 내부 상태는 작은 값이다.

스트림에서 처리하는 요소 수와 관계없이 내부 상태의 크기한정(bounded)되어 있다

sorted, distinct 같은 연산은 filter나 map처럼 스트림을 입력으로 받아 다른 스트림을 출력하는 것처럼 보일 수 있다. 하지만 sorteddintinct 는 다르다. 스트림의 요소를 정렬하거나 중복을 제거하려면 과거의 이력을 알고 있어야 한다.

예를 들어 어떤 요소를 출력 스트림으로 추가하려면 모든 요소가 버퍼에 추가되어 있어야 한다.

연산을 수행하는 데 필요한 저장소 크기는 정해져있지 않다. 따라서 데이터 스트림의 크기가 크거나 무한이라면 문제가 생길 수 있다.

이러한 연산을 내부 상태를 갖는 연산(stateful operation)이라 한다.

  • 예를 들면 모든 소수를 포함하는 스트림을 역순으로 만들면 어떻게 될까?
    • 첫 번째 요소로 가장 큰 소수를 반환해야하는데 세상에 존재하지 않는 수를 반환해야 한다. 이러면 연산을 내부 상태는 갖는 연산이라 한다.

➡️지금까지 배운 중간 연산, 최종 연산 정리

연산형식반환 형식사용된 함수형 인터페이스 형식함수 디스크립터
filter중간 연산StreamPredicateT → boolean
distinct중간 연산(상태 있는 언 바운드)Stream  
takeWhile중간 연산StreamPredicateT → boolean
dropWhile중간 연산StreamPredicateT → boolean
skip중간 연산(상태 있는 바운드)Streamlong 
limit중간 연산(상태 있는 바운드)Streamlong 
map중간 연산StreamFunction<T, R>T → R
flatMap중간 연산StreamFounction<T, Stream>T → Stream
sorted중간 연산(상태 있는 언바운드)StreamComparator(T, T) → Stream
anyMatch최종 연산booleanPredicate(T, T) → int
noneMatch최종 연산booleanPredicateT → boolean
allMatch최종 연산booleanPredicateT → boolean
findAny최종 연산Optional T → boolean
findFirst최종 연산Optional  
forEach최종 연산voidConsumerT → void
collect최종 연산RCollector<T, A, R> 
reduce최종 연산(상태 있는 바운드)OptionalBinaryOperator(T, T) → T
count최종 연산long  

리듀싱 편을 마치며

프로그래머스 알고리즘 문제를 풀 때 람다로 푸는 분들이 답을 제출한 것을 몇번 봤다.
그것을 보면서 ‘나도 언젠간 람다로 풀어보겠어 !‘라고 마음 속으로 다짐했던 기억이 있다.
아직도 다짐은 유효하다. 이번에 스트림 활용편을 공부하면서 프로그래머스 알고리즘 문제를 람다로 풀 수 있는 윤곽이 보이기 시작했다.
얼른 실전 연습을 통해서 익혀서 활용해봐야지 ~

giphy-2