자바/모던 자바 인 액션

[모던 자바 인 액션] 6장. 스트림으로 데이터 수집(2)

Rudtjs 2022. 10. 24. 08:48

6.4 분할

분할은 분할 함수라 불리는 프레디케이트를 분류 함수로 사용하는 특수한 그룹화 기능이다. 

분할 함수의 키 형식은 Boolean이기 때문에 그룹화 맵은 최대 두 개의 그룹으로 분류된다.

Map<Boolean, List<Dish>> partitionedMenu = menu.stream().collect(partitioningBy(Dish::isVegetarian));

결과는 true=[], false=[] 와 같은 형태로 반환된다.

여기서 true의 값만 얻고 싶다면?

List<Dish> vegetarianDishes = partitioneMenu.get(true);

6.4.1 분할의 장점

분할 함수를 이용하면 참, 거짓 두 가지 요소의 스트림 리스트를 유지한 채 얻을 수 있다는 것이다.

예를 들어 채식 요리와 채식 요리가 아닌 것에서 칼로리가 가장 높은 요리를 찾는다면

Map<Boolean, Dish> most = menu.stream()
	partitioningBy(Dish::isVegetarian,
    	collectingAndThen(maxBy(comparingInt(Dish::getCalories)),
        					Optional::get));

{false=aaa, true=xxx} 와 같이 얻을 수 있다. 

6.4.2 숫자를 소수와 비소수로 분할하기

소수와 비소수를 분할을 이용해서 나눠보자.

1. 먼저 주어진 수가 소수인지 비소수인지 판별을 하는 것이 좋다.

  //스트림의 모든 정수로 candidate를 나눌 수 없으면 참을 반환한다.
public boolean isPrime(int candidate) {
  return IntStream.range(2, candidate).noneMatch(i -> candidate % i == 0);
}​

2. 소수를 제곱근 이하의 수로 제한

  //스트림의 모든 정수로 candidate를 나눌 수 없으면 참을 반환
public boolean isPrime(int candidate) {
  int candidateRoot = (int) Math.sqrt((double)candidate);
  return IntStream.rangeClosed(2, candidateRoot).noneMatch(i -> candidate % i == 0);
}

3. n개의 숫자를 포함하는 스트림을 만들고 얻은 수를 소수와 비소수로 분할시키기.

public Map<Boolean, List<Integer>> partitionPrimes(int n) {
  return IntStream.rangeClosed(2, n).boxed().collect(partitioningBy(candidate -> isPrime(candidate)));
}

6.5 Collector 인터페이스

Collector 인터페이스는 리듀싱 연산을 어떻게 구현할지 제공하는 메서드 집합으로 구성된다.

다음 코드는 Collector 인터페이스의 시그니처와 다섯 개의 메서드 정의를 보여준다.

public interface Collector<T, A, R> {
  Supplier<A> supplier();
  BiConsumer<A, T> accumulator();
  Function<A, R> finisher();
  BinaryOperator<A> combiner();
  Set<Characteristics> characteristics();
}
  • T: 수집될 스트림 항목의 제네릭 형식
  • A: 수집 과정에서 중간 결과를 누적하는 객체의 형식
  • R: 수집 연산 결과 객체의 형식

 

6.5.1 Collector 인터페이스의 메서드 살펴보기

supplier 메서드 : 새로운 결과 컨테이너 만들기

supplier 메서드는 빈 결과로 이루어진 Supplier를 반환해야 한다. 즉, 수집 과정에서 빈 누적자 인스턴스를 만드는 파라미터가 없는 함수다.

ToListCollector처럼 누적자를 반환하는 컬렉터에서는 빈 누적자가 비어있는 스트림의 수집 과정의 결과가 될 수 있다.

public Supplier<List<T>> supplier() {
  return() -> new ArrayList<T>();
}

//생성자 참조 방식으로 전달도 가능
public Supplier<List<T>> supplier() {
  return ArrayList::new;
}

accumlator 메서드 : 결과 컨테이너에 요소 추가하기

accumlator 메서드는 리듀싱 연산을 수행하는 함수를 반환한다. 스트림에서 n번째 요소를 탐색할 때 두 인수, 즉 누적자(n-1번째 항목까지 수집한 상태)와 n번째 요소를 함수에 적용한다.

함수의 반환값은 void, 즉 요소를 탐색하면서 적용하는 함수에 의해 누적자 내부 상태가 바뀌므로 누적자가 어떤 값 일지 단정할 수 없다.

public BiConsumer<List<T>, T> acuumulator() {
  return (list, item) -> list.add(item);
}

//다음처럼 메서드 참조를 이용하면 코드가 더 간결해진다.
public BiConsumer<List<T>, T> accumulator() {
  return List::add;
}

finisher 메서드 : 최종 변환값을 결과 컨테이너로 적용하기

finisher 메서드는 스트림 탐색을 끝내고 누적자 객체를 최종 객체로 변환하면서 누적 과정을 끝낼 때 호출할 함수를 반환해야 한다.

public Function<List<T> List<T>> finisher() {
  return Function.identity();
}

combiner 메서드 : 두 결과 컨테이너 병합

combiner는 스트림의 서로 다른 서브파트를 병렬로 처리할 때 누적자가 이 결과를 어떻게 처리할지 정의한다.

toList의 combiner는 비교적 쉽게 구현할 수 있으며, 스트림의 두 번째 서브 파트에서 수집한 항목 리스트를 첫 번째 서브 파트 결과 리스트의 뒤에 추가하면 된다.

public BinaryOperator<List<T>> combiner() {
  return (list1, list2) -> {
    liat.addAll(list2);
    return list1;
  }
}

이 메서드를 이용하면 스트림의 리듀싱을 병렬로 처리할 수 있다. 

병렬 리듀싱 수행 과정

  • 스트림을 분할해야 하는지 정의하는 조건이 거짓으로 바뀌기 전까지 원래 스트림을 재귀적으로 분할한다.
  • 모든 서브스트림의 각 요소에 리듀싱 연산을 순차적으로 적용해서 병렬로 처리한다.
  • 컬렉터의 combiner 메서드가 반환하는 함수로 모든 부분 결과를 쌍으로 합친다.

Characteristics 메서드

characteristics 메서드는 컬렉터의 연산을 정의하는 Characteristics 형식의 불변 집합을 반환한다.

Characteristics는 스트림을 병렬로 리듀스 할지와 병렬로 리듀스 한다면 어떤 최적화를 선택해야 할지 힌트를 제공한다.

 

Characteristics의 항목

  • UNORDERED : 리듀싱 결과는 스트림 요소의 방문 순서나 누적 순서에 영향을 받지 않는다.
  • CONCURRENT : 다중 스레드에서 accumulator 함수를 호출할 수 있으며 이 컬렉터는 스트림의 병렬 리듀싱을 수행할 수 있다. 컬렉터의 플래그에 UNORDERED를 함께 설정하지 않았다면 데이터 소스가 정렬되어있지 않은 상황에서만 병렬 리듀싱을 수행할 수 있다.
  • IDENTITY_FINISH : finisher 메서드가 반환하는 함수는 단순히 identity를 적용할 뿐이므로 이를 생략할 수 있다. 따라서 리듀싱 과정의 최종 결과로 누적자 객체를 바로 사용할 수 있으며, 누적자 A를 결과 R로 안전하게 형변환할 수 있다.

6.5.2 응용하기

인터페이스를 커스텀하여 다양하게 구현할 수 있다.

public class ToListCollector<T> implements Collect<T, List<T>, List<T>> {
  @Override
  public Supplier<List<T>> supplier() {
    return ArrayList::new;
  }
  
  @Override
  public BiConsumer<List<T>, T> accumulator() {
    return List::add;
  }
  
  @Override
  public Function<List<T> List<T>> finisher() {
    return Function.identity();
  }
  
  @Override
  public BinaryOperator<List<T>> combiner() {
    return (list1, list2) -> {
      liat.addAll(list2);
      return list1;
    }
  }
    
  @Override
  public Set<Characteristics> characteristics() {
    return Collections.unmodifiableSet(EnumSet.of(IDENTITY_FINISH, CONCURRENT));
  }
}

 

6.6 커스텀 컬렉터를 구현해서 성능 개선하기

이전 예제에서 n까지의 자연수를 소수와 비소수로 분할하는 커스텀 컬렉터를 만들었다.

public Map<Boolean, List<Integer>> partitionPrimes(int n) {
  return IntStream.rangeClosed(2, n).boxed().collect(partitioningBy(candidate -> isPrime(candidate)));
}

public boolean isPrime(int candidate) {
  int candidateRoot = (int) Math.sqrt((double)candidate);
  return IntStream.rangeClosed(2, candidateRoot).noneMatch(i -> candidate % i == 0);
}

커스텀 컬렉터를 이용해서 성능을 더 개선해보자.

 

6.6.1 소수로만 나누기

우선 소수로 나누어떨어지는지 확인해서 대상의 범위를 좁힐 수 있다.

제수가 소수가 아니면 소용없으므로 제수를 현재 숫자 이하에서 발견한 소수로 제한할 수 있으며, 주어진 숫자가 소수인지 확인하기 위해 지금까지 발견한 소수 리스트에 접근해야 한다.

우리가 살펴본 컬렉터로는 컬렉터 수집 과정에서 부분 결과에 접근할 수 없지만, 커스텀 컬렉터 클래스를 사용해서 해결할 수 있다.

 

public boolean isPrime(List<Integer> primes, int candidate) {
  return primes.stream().noneMatch(i -> candidate % i == 0);
}

이번에도 대상 숫자의 제곱근보다 작은 소수만 사용하도록 코드를 최적화해야 한다.

filter(p -> p <= candidtaRoot)를 이용해서 대상의 루트보다 작은 소수를 필터링할 수 있지만, filter는 전체 스트림을 처리한 다음 결과를 반환해야 한다.

대상의 제곱보다 큰 소수를 찾으면 검사를 중단하기 위해서는 talkWhile 메서드를 사용해야 한다.

public boolean isPrime(List<Integer> primes, int candidate) {
  int candidateRoot = (int) Math.sqrt((double)candidate);
  return primes.stream()
    .talkWhile(i -> i <= candidateRoot);
    .noneMatch(i -> candidate % i == 0);
}

 

talkWhile 메서드는 자바 9부터 지원하는 기능이다. 자바 8에서는 직접 구현해야 한다.

public static <A> List<A> talkWhile(List<A> list, Predicate<A> p) {
  int i = 0;
  for (A item : list) {
    if(!p.test(item)) {
      return list.subList(0, i); //프레디케이트를 만족하지 않으면 이전 하위항목 리스트를 반환
    }
    i++;
  }
  return list;
}

게으른 버전의 스트림 API와 달리 직접 구현한 talkWhile 메서드는 적극적으로 동작한다.

 

새로운 isPrime 메서드를 구현했으니 본격적으로 커스텀 컬렉터를 구현하자. Collector 클래스를 선언하고 Collector 인터페이스에서 요구하는 메서드 다섯 개를 구현해야 한다. 

 

1단계 : Collector 클래스 시그니처 정의

public interface Collector<T, A, R>

T는 스트림 요소의 형식, A는 중간 결과를 누적하는 객체의 형식, R은 collect 연산의 최종 결과 형식을 의미한다.

소수와 비소수를 참과 거짓으로 나누기 위해 정수로 이루어진 스트림에서 누적자와 최종 결과 형식이 Map<Boolean, List<Integer>>인 컬렉터를 구현해야 한다.

public class PrimeNumbersCollector
  implements Collect<Integer,
  Map<Boolean, List<Integer>>,
  Map<Boolean, List<Integer>>>

2단계 : 리듀싱 연산 구현

먼저 supplier 메서드로 누적자를 만드는 함수를 반환해야 한다.

public Supplier<Map<Boolean, List<Integer>>> supplier() {
  return () -> new HashMap<Boolean, List<Integer>>() {{
    put(true, new ArrayList<Integer>());
    put(false, new ArrayList<Integer>());
  }};
}

위 코드에서는 누적자로 만들 맵을 만들면서 true, false 키와 빈 리스트로 초기화했다.

 

다음으로 스트림 요소를 어떻게 수집할지 결정하는 accumulator 메서드를 만든다.

이제 언제든지 원할 때 수집 과정의 중간 결과, 즉 지금까지 발견한 소수를 포함하는 누적자에 접근할 수 있다.

public BiConsumer<Map<Boolean, List<Integer>>, Integer> accumulator() {
  return (Map<Boolean, List<Integer>> acc, Integer candidate) -> {
    acc.get( isPrime(acc.get(true), candidate) ) //isPrime 결과에 따라 소수/비소수 리스트를 만든다.
      .add(candidate); //candidate를 알맞은 리스트에 추가한다.
  };
}

지금까지 발견한 소수 리스트 acc.get(true)와 소수 여부를 확인할 수 있는 candidate를 인수로 isPrime 메서드를 호출했다. 그리고 호출 결과를 알맞은 리스트에 추가한다.

 

3단계 : 병렬 실행할 수 있는 컬렉터 만들기(가능하다면)

이번에는 병렬 수집 과정에서 두 부분 누적자를 합칠 수 있는 메서드를 만든다. 예제에서는 두 번째 맵의 소수 리스트와 비소수 리스트의 모든 수를 첫 번째 맵에 추가하는 연산이면 충분하다.

public BinaryOperator<Map<Boolean, List<Integer>>> combiner() {
  return (Map<Boolean, List<Integer>> map1, Map<Boolean, List<Integer>> map2) -> {
    map1.get(true).addAll(map2.get(true));
    map1.get(false).addAll(map2.get(false));
  };
}

알고리즘 자체가 순차적이어서 컬렉터를 실제로 병렬로 사용할 순 없으므로 combiner 메서드는 빈 구현으로 남겨두거나 exception을 던지도록 구현하면 된다.

 

4단계 : finisher 메서드와 컬렉터의 characteristics 메서드

accumulator의 형식은 컬렉터 결과 형식과 같으므로 변환 과정은 필요 없다. 따라서 항등 함수 identity를 반환하도록 finisher 메서드를 구현한다.

public Function<Map<Boolean, List<Integer>>, Map<Boolean, List<Integer>>> finisher() {
  return Function.identity();
}

 

6.6.2 컬렉터 성능 비교

간단한 하니스를 만들어서 컬렉터 성능을 확인할 수 있다.

public class CollectorHarness {
  public static void main(String[] args) {
    long fastest = Long.MAX_VALUE;
    for (int i = 0; i < 10; i++) {
      long start = System.nanoTime();
      partitionPrimes(1_000_000); //백만 개의 숫자를 소수와 비소수로 분할
      long duration = (System.nanoTime() - start) / 1_000_000;
      if(duration < fastest) fastest = duration;
    }
    System.out.println"FASTEST EXECUTION DONE IN " + fastest + " msecs");
  }

collect 메서드로 PrimeNumbersCollector의 핵심 로직을 구현하는 세 함수를 전달해서 같은 결과를 얻을 수 있다.

public Map<Booelan, List<Integer>> partitionPrimesWithCustomCollector(int n) {
  IntStream.rangeClosed(2, n).boxed().collect(
    () -> new HashMap<Boolean, List<Integer>>() {{ // 발행
      put(true, new ArrayList<Integer>());
      put(false, new ArrayList<Integer>());
    }},
    (acc, candidate) -> { // 누적
      acc.get( isPrime(acc.get(true), candidate) )
        .add(candidate);
    },
    (map1, map2) -> { // 합침
      map1.get(true).addAll(map2.get(true));
      map1.get(false).addAll(map2.get(false));
    });
}