본문 바로가기
그룹 스터디 공부(IT 서적)/모던 자바 인 액션

chapter 6 스트림으로 데이터 수집 02

by hanyugyeong 2023. 7. 22.
반응형
SMALL

분할

분할 함수(partitioning function)라 불리는 프레디케이트를 분류 함수로 사용하는 특수한 그룹화 기능

  • 분할 함수는 불리언을 반환하므로 맵의 키 형식은 Boolean
  • 그룹화 맵은 최대 (참 or 거짓을 갖기 때문에) 2개의 그룹으로 분류됨.
Map<Boolean,Map<Dish.Type, List<Dish>>> vegetarianDishesByType =menu.stream().collect(
          partitioningBy(Dish::isVegetarian,// 분할 함수
              groupingBy(Dish::getType) //두 번째 컬렉터 
              )
      );
{false={FISH=[prawns, salmon], MEAT=[pork, beef, chicken]}, true={OTHER=[french fries, rice, season fruit, pizza]}}

분할의 장점

  • 결과에서 확인할 수 있는 것처럼 채식 요리의 스트림과 채식이 아닌 요리의 스트림을 각각 요리 종류로 그룹화해서 두 수준의 맵이 반환되었다.

  • 채식 요리와 채식이 아닌 요리 각각의 그룹에서 가장 칼로리가 높은 요리도 찾을 수 있다.

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

실행 결과

{false=pork, true=pizza}

partitioningBy가 반환한 맵 구현은 참과 거짓 두 가지 키만 포함하므로 더 간결하고 효과적이다. 사실 내부적으로 partitioningBy는 특수한 맵과 두 개의 필드로 구현되었다.

퀴즈 partitioningBy 사용

groupingBy 컬렉터와 마찬가지로 partitioningBy 컬렉터도 다른 컬렉터와 조합해서 사용할 수 있다. 특히 두 개의 partitioningBy 컬렉터를 이용해서 다수준 분할을 수행할 수 있다.

1) 유효한 다수준 분할 코드다

 Map<Boolean, Map<Boolean, List<Dish>>> caloricPartitionedByVegetarian =
        menu.stream().collect(partitioningBy(Dish::isVegetarian,
              partitioningBy(d->d.getCalories()>500)
            ));
{false={false=[chicken, prawns, salmon], true=[pork, beef]}, true={false=[rice, season fruit], true=[french fries, pizza]}}

2) partitioningBy는 불리언을 반환하는 함수, 즉 프레디케이트를 요구하므로 컴파일되지 않는 코드다. Dish::getType은 프레디케이트로 사용할 수 없는 매서드 참조다.

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

3) 분할한 각 항목의 개수를 계산하는 코드로 다음과 같은 맵을 반환한다.

 Map<Boolean, Long> caloricPartitionedByVegetarian =
        menu.stream().collect(partitioningBy(Dish::isVegetarian,counting()));
{false=5, true=4}

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

정수 n을 인수로 받아서 2에서 n까지의 자연수를 소수와 비소수로 나누는 프로그램을 구현하자.

 public static boolean isTestPrime(int candidate){
    return IntStream.range(2,candidate) // 2부터 candidate 미만 사이의 자연수를 생성한다.
    .noneMatch(i-> candidate % i == 0); // 스트림의 모든 정수로 candidate를 나눌 수 없으면 참을 반환한다.
  }

다음처럼 소수의 대상을 주어진 수의 제곱근 이하의 수로 제한할 수 있다.

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

이제 n개의 숫자를 포함하는 스트림을 만든 다음에 우리가 구현한isTestPrime 매서드를 프레디케이트로 이용하고 partitioningBy 컬렉터로 리듀스해서 숫자를 소수와 비소수로 분류할 수 있다.

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

   public static Map<Boolean, List<Integer>> partitionPrimes(int n) {
    return IntStream.rangeClosed(2, n).boxed()
        .collect(partitioningBy(candidate -> isTestPrime(candidate)));
  }
{false=[4, 6, 8, 9, 10, 12, 14, 15, 16, 18, 20, 21, 22, 24, 25, 26, 27, 28, 30, 32, 33, 34, 35, 36, 38, 39, 40, 42, 44, 45, 46, 48, 49, 50, 51, 52, 54, 55, 56, 57, 58, 60, 62, 63, 64, 65, 66, 68, 69, 70, 72, 74, 75, 76, 77, 78, 80, 81, 82, 84, 85, 86, 87, 88, 90, 91, 92, 93, 94, 95, 96, 98, 99, 100], true=[2, 3, 5, 7, 11, 13, 17, 19, 23, 29, 31, 37, 41, 43, 47, 53, 59, 61, 67, 71, 73, 79, 83, 89, 97]}

Collector 인터페이스

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

자주 사용되면서도 구현하기 쉬운 컬렉터인 toList()가 어떻게 구현되었는지 살펴보면서 Collector 인터페이스를 이해해보자.

// Collector 인터페이스의 시그니처
public interface Collector<T, A, R> {
Supplier<A> supplier();
    BiConsumer<A,T> accumulator();
    Function<A,R> finisher();
    BinaryOperator<A> combiner();
    Set<Collector.Characteristics> characteristics();
}
  • T는 수집될 스트림 항목의 제네릭 형식이다.
  • A는 누적자, 즉 수집 과정에서 중간 결과를 누적하는 객체의 형식이다.
  • R은 수집 연산 결과 객체의 형식(대개 컬렉션 형식)이다.

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

Collector 인터페이스에는 다섯 개의 메서드가 정의되어 있다.

  • 먼저 살펴볼 4개의 메서드는 collect 메서드에서 실행하는 함수를 반환한다.
  • 마지막 메서드는 힌트 특성 집합을 제공 → collect 메서드가 어떤 최적화를 이용해서 리듀싱 연산을 수행할 것인지 결정하도록 돕는 역할

supplier() : 새로운 결과 컨테이너 만들기

수집 과정에서 빈 누적자 인스턴스를 만드는 함수로, 파라미터가 없는 함수다.

/**
* A function that creates and returns a new mutable result container.
* @return a function which returns a new, mutable result container
*/
Supplier<A> supplier();
//빈 리스트 반환
@Override
  public Supplier<List<T>> supplier() {
    return () -> new ArrayList<T>();
 }

//생성자 참조를 전달하는 방법
 @Override
  public Supplier<List<T>> supplier() {
    return ArrayList::new;
  }

accumulator() : 결과 컨테이너에 요소 추가하기

리듀싱 연산을 수행하는 함수를 반환한다. 스트림에서 n번째 요소를 탐색할 때 두 인수, 즉 누적자(n-1까지의 항목을 수집한 상태)와 n번째 요소를 함수에 적용한다. 함수의 반환값은 void, 즉 요소를 탐색하면서 적용하는 함수에 의해 누적자 내부상태가 바뀌므로 누적자가 어떤 값일지 단정할 수 없다.

/**
* A function that folds a value into a mutable result container.
* @return a function which folds a value into a mutable result container
*/
BiConsumer<A, T> accumulator();
@Override
public BiConsumer<List<T>, T> accumulator() {
   return (list, item) -> list.add(item);
}
//매서드 참조 
@Override
public BiConsumer<List<T>, T> accumulator() {
  return List::add;
}

finisher() : 최종 변환값을 결과 컨테이너로 적용하기

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

/**
* Perform the final transformation from the intermediate accumulation type
* {@code A} to the final result type {@code R}.
*
* <p>If the characteristic {@code IDENTITY_TRANSFORM} is
* set, this function may be presumed to be an identity transform with an
* unchecked cast from {@code A} to {@code R}.
*
* @return a function which transforms the intermediate result to the final
* result
*/
Function<A, R> finisher();
@Override
  public Function<List<T>, List<T>> finisher() {
    return i -> i;
  }

combiner() : 두 결과 컨테이너 병합

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

/**
* A function that accepts two partial results and merges them.  The
* combiner function may fold state from one argument into the other and
* return that, or may return a new result container.
* @return a function which combines two partial results into a combined
* result
*/
BinaryOperator<A> combiner();
@Override
  public BinaryOperator<List<T>> combiner() {
    return (list1, list2) -> {
      list1.addAll(list2);
      return list1;
    };
  }

스트림의 병렬 리듀싱 수행 과정

  • 스트림으로 분할해야 하는지 정의하는 조건이 거짓으로 바뀌기 전까지 원래 스트림을 재귀적으로 분할한다. (분산된 작업의 크기가 너무 작아지면 병렬 수행 속도는 순차 수행보다 느려지기 때문에 주의. 프로세싱 코어의 개수를 초과하는 병렬 작업은 비효율적임)
  • 모든 서브스트림의 각 요소에 리듀싱 연산을 순차적으로 적용해서 서브스트림을 병렬로 처리할 수 있다.
  • 마지막에는 컬렉터의 combiner 메서드가 반환하는 함수로 모든 부분 결과를 쌍으로 합친다.

characteristics()

스트림을 병렬로 리듀스할 것인지 그리고 병렬로 리듀스한다면 어떤 최적화를 선택해야 할지 힌트를 제공

/**
* Returns a {@code Set} of {@code Collector.Characteristics} indicating
* the characteristics of this Collector.  This set should be immutable.
* @return an immutable set of collector characteristics
*/
Set<Characteristics> characteristics();

Characteristics는 다음 세 항목을 포함하는 열거형(Enum)이다.

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

응용하기

다섯 가지 메서드를 이용해서 자신만의 커스텀 ToListCollector를 구현할 수 있다.

public class ToListCollector<T> implements Collector<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) -> { // 두 번째 콘텐츠와 합쳐서 첫 번째 누적자를 고친다.
      list1.addAll(list2); //변경된 첫 번째 누적자를 반환한다.
      return list1;
    };
  }

  @Override
  public Set<Characteristics> characteristics() {
    return Collections.unmodifiableSet(
        EnumSet.of(IDENTITY_FINISH, CONCURRENT)); // 컬렉터의 플래그를 IDENTITY_FINISH,CONCURRENT로 설정한다.
  }

}

자바에서 제공하는 API 대신 우리가 만든 컬렉터를 메뉴 스트림의 모든 요리를 수집하는 예제에 사용할 수 있다.

기존 코드

List<Dish> dishes = menuStream.collect(Collectors.toList());

API 코드

List<Dish> dishes = menuStream.collect(new ToListCollector<Dish>());

컬렉터 구현을 만들지 않고도 커스텀 수집 수행하기

IDENTITY_FINISH 수집 연산에서는 Collector 인터페이스를 완전히 새로 구현하지 않고도 같은 결과를 얻을 수 있다.

Stream은 세 함수(발행, 누적, 합침)를 인수로 받는 collect 메서드를 오버로드하며 각각의 메서드는 Collector 인터페이스의 메서드가 반환하는 함수와 같은 기능을 수행한다.

List<Dish> dishes = menu.stream().collect(
                ArrayList::new, // 발행 (supplier)
                List::add,  // 누적 (accumulator)
                List::addAll);  // 합침 (combiner)

→ 이전 코드에 비해 좀 더 간결하고 축약되어 있지만 가독성은 떨어진다. 또한 Characteristics를 전달할 수 없다.(IDENTITY_FINISH, CONCURRENT지만 UNORDERED는 아닌 컬렉터로만 동작)

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

앞서 작성한 소수, 비소수 분할 코드를 커스텀 컬렉터를 이용해서 성능을 개선해보자.

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);
}

소수로 나누어 떨어지는지 확인해서 대상의 범위를 좁힌다. 제수(나누는 수)가 소수가 아니면 소용없기 때문에 제수를 현재 숫자 이하에서 발견한 소수로 제한할 수 있다.

주어진 숫자가 소수인지 판단하기 위해 지금까지 발견한 소수 리스트에 접근해야 한다.

→ 기존의 컬렉터로는 수집 과정에서 부분 결과에 접근할 수 없기 때문에 커스텀 클래스로 문제를 해결한다.

중간 결과 리스트가 있다면 isPrime 메서드로 중간 결과 리스트를 전달하도록 구현한다.

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

대상 숫자의 제곱근보다 작은 소수만 사용하도록 코드를 최적화해야한다. 다음 소수가 대상의 루트보다 크면 소수로 나누는 검사를 중단하는 방식으로 성능을 개선한다.

→ 스트림 API에는 이런 기능을 제공하는 메소드가 없다.

리스트와 프레디케이트를 인수로 받아 프레디케이트를 만족하는 긴 요소로 이루어진 리스트를 반환하는 takeWhile이라는 메소드를 구현한다. (takeWhile은 java9버전에서 지원하므로 직접 구현해야한다.)

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

Stream.takeWhile 과 Stream.filter 차이

Stream.of(1,2,3,4,5,6,7,8,9)
                .filter(n -> n%2 == 0)
                .forEach(System.out::println);

        Stream.of(2,4,3,4,5,6,7,8,9)
                .takeWhile(n -> n%2 == 0)
                .forEach(System.out::println);

filter는 조건에 대해 다 검사하며 참인것만 다음으로 넘어가지만 takeWhile은 조건에 대해 참이 아닐경우 바로 거기서 멈추게 된다 즉 결과는 이런식으로 나오게 된다.

2
4
6
8

2
4

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

Collector 인터페이스 정의를 참고해서 클래스 시그니처를 만들자.

public interface Collector<T, A, R>
  • T : 스트림 요소의 형식
  • A : 중간 결과를 누적하는 객체의 형식
  • R : collect 연산의 최종 결과 형식

우리가 구현해야할 컬렉터는 Map<Boolean, List>로, 참과 거짓을 키로 갖소 소수와 소수가 아닌 수를 값으로 가져야 한다.

  public static class PrimeNumbersCollector
      implements Collector<Integer, // 스트림 요소의 형식 
      Map<Boolean, List<Integer>>, //누적자 형식 
      Map<Boolean, List<Integer>>> //수집 연산의 결과 형식 

2단계: 리듀싱 연산 구현

Collector 인터페이스에 선언된 다섯 메서드를 구현해야 한다.

supplier()

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

→ 누적자로 사용할 맵을 만들면서 true, false 키와 빈 리스트로 초기화

accumulator()

스트림의 요소를 어떻게 수집할지 결정하는 것은 accumulator 매서드이므로 우리 컬렉터에서 가장 중요한 메서드라 할 수 있다.

  @Override
    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를 알맞은 리스트에 추가한다. 
      };
    }

→ 스트림의 요소를 어떻게 수집할지 결정. 지금까지 발견한 소수 리스트와 candidate를 인수로 isPrime을 호출. 결과에 따라 알맞는 리스트로 candidate를 추가

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

병렬 수집 과정에서 두 부분 누적자를 합칠 수 있는 메서드를 만든다. (combiner)

@Override
    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));
        return map1;
      };
    }

→ 사실 해당 기능은 알고리즘 자체가 순차적이어서 컬렉터를 실제 병렬로 사용할 수 없다. 사용될 일이 없기 때문에 학습 목적으로 구현한 것임. (빈 구현으로 남겨두거나, UnsupportedOperationException을 던지도록 구현)

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

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

 @Override
    public Function<Map<Boolean, List<Integer>>, Map<Boolean, List<Integer>>> finisher() {
      return i -> i;
    }

또한, 이 커스텀 컬렉터는 CONCURRENT도 아니고 UNORDERED도 아니지만 IDENTITY_FINISH이므로 다음처럼 characteristics 메서드를 구현할 수 있다.

 @Override
    public Set<Characteristics> characteristics() {
      return Collections.unmodifiableSet(EnumSet.of(IDENTITY_FINISH));
    }

PrimeNumbersCollector의 최종 구현 코드

  public static class PrimeNumbersCollector implements Collector<Integer, // 스트림 요소의 형식
      Map<Boolean, List<Integer>>, //누적자 형식
      Map<Boolean, List<Integer>>> //수집 연산의 결과 형식
  {

    @Override
    public Supplier<Map<Boolean, List<Integer>>> supplier() {
      return () -> new HashMap<>() {{ // 두 개의 빈 리스트를 포함하는 맵으로 수집동작을 시작한다.
        put(true, new ArrayList<Integer>());
        put(false, new ArrayList<Integer>());
      }};
    }

    @Override
    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); //isPrime 메서드의 결과에 따라 맵에서 알맞은 리스트를 받아 candidate를 추가한다.
      };
    }

    @Override
    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));
        return map1;
      };
    }

    @Override
    public Function<Map<Boolean, List<Integer>>, Map<Boolean, List<Integer>>> finisher() {
      return Function.identity(); //최종 수집 과정에서 데이터 변환이 필요하지 않으므로 항등 함수를 반환한다.
    }

    @Override
    public Set<Characteristics> characteristics() {
      return Collections.unmodifiableSet(EnumSet.of(IDENTITY_FINISH)); //발견한 소수의 순서에 의미가 있으므로 컬렉터는 IDENTITY_FINISH이다
    }

  }

컬렉터 성능 비교

private static long execute(Consumer<Integer> primePartitioner) {
    long fastest = Long.MAX_VALUE;
    for (int i = 0; i < 10; i++) { // 테스트를 10번 반복한다.
      long start = System.nanoTime();
      primePartitioner.accept(1_000_000); // 밴만 개의 숫자를 소수와 비소수로 분할한다.
      long duration = (System.nanoTime() - start) / 1_000_000; //duration을 밀리초 단위로 측정한다.
      if (duration < fastest) { // 가장 빨리 실행되었는지 확인한다.
        fastest = duration;
      }
      System.out.println("done in " + duration);
    }
    return fastest;
  }

ToListCollector에서 했던 것처럼 오버로드된 버전의 collect 메서드로 PrimeNumbersCollector의 핵심 로직을 구현하는 세 함수를 전달해서 같은 결과를 얻을 수 있다.

public static Map<Boolean, List<Integer>> partitionPrimesWithInlineCollector(int n) {
    return Stream.iterate(2, i -> i + 1).limit(n)
        .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));
            }
        );
  }

위 코드에서 볼 수 있는 것처럼 Collector 인터페이스를 구현하는 새로운 클래스를 만들 필요가 없다. 결과 코드는 간결하지만 가독성과 재사용성은 떨어진다.

반응형
LIST