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

hyelim·2023년 9월 1일
1

모던 자바 인 액션

목록 보기
3/5
post-thumbnail

질문지점
Collector 인터페이스의 characteristics 메서드


6.1 컬렉터란 무엇인가?

Collector 인터페이스 구현은 스트림의 요소를 어떤 식으로 도출할지 지정한다.
예를들어 아래코드에서는 collect 메서드로 Collector 인터페이스 구현을 전달한다.

Map<Currency, List<Transaction) transactionsByCurrencies =
transactions.stream().collect(groupingBy (Transaction: :getcurrency));

6.1.1 고급 리듀싱 기능을 수행하는 컬렉터

컬렉터의 최대 강점은 collect로 결과를 수집하는 과정을 간단하면서도 유연한 방식으로 정의할 수 있다는 점이다.
스트림에서 collect 를 호출하면 collect에서는 리듀싱 연산을 이용해서 스트림의 각 요소를 방문하면서 컬렉터가 작업을 수행한다.

보통 함수를 요소로 변환할 때는 컬렉터를 적용하며 최종 결과를 저장하는 자료구조에 값을 누적한다.

6.1.2 미리 정의된 컬렉터

Collectors 유틸리티 클래스는 자주 사용하는 컬렉터 인스턴스를 손쉽게 생성할 수 있는 정적 메서드를 제공한다.
Collectors에서 제공하는 메서드의 기능은 크게 세 가지로 구분된다.

  • 스트림 요소를 하나의 값으로 리듀스하고 요약: 트랜젝션의 총합 등 다양한 계산을 수행시 유용하게 사용
  • 요소 그룹화: 다수준으로 그룹화 혹은 각각의 결과 서브그룹에 추가로 리듀싱 연산을 적용할 수 있도록 다양한 컬렉터 조합하는 방식
  • 요소 분할: 한 개의 인수를 받아 불리언을 반환하는 함수 즉 프레디케이트를 그룹화 함수로 사용한다.

6.2 리듀싱과 요약

import static java.util.stream.Collectors.* 즉, Collectors 클래스의 정적 팩토리 메서드를 모두 임포트 했다고 가정하자.

첫 번째 예제로 counting() 팩토리 메서드가 반환하는 컬렉터를 사용해보자.

long howManyDishes = menu.stream().collect(Collectors.counting());

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

6.2.1 스트림값에서 최댓값과 최솟값 검색

Collectors.maxBy, Collectors.minBy 메서드를 이용해서 스트림의 최댓값과 최솟값을 계산할 수 있다.

Comparator<Dish> dishCaloriesComparator = Comparator.comparingInt(Dish::getCalories);

Optional<Dish> mostCaloriesDish = menu.stream().collect(maxBy(dishCaloriesComparator));

6.2.2 요약 연산

스트림에 있는 객체의 숫자 필드의 합계나 평균 등을 반환하는 연산에도 리듀싱 기능이 자주 사용된다. 이러한 연산을 요약 연산이라 부른다.

Collectors.summingInt/ Collectors.summingLong/ Collectors.summingDouble 메서드

Collectors 클래스는 Collectors.summingInt라는 특별한 요약 팩토리 메서드를 제공한다.
summingInt는 객체를 int로 매핑하는 함수를 인수로 받으며, 인수로 전달된 함수는 객체를 int로 매핑한 컬렉터를 반환한다. 그리고 summingInt가 collect 메서드로 전달되면 요약 작업을 수행한다.

아래는 메뉴 리스트의 총 칼로리를 계산하는 코드다.

int totalCalories = menu.stream().collect(summingInt(Dish::getCalories));

아래 사진은 위의 코드의 데이터 수집과정을 보여준다. 칼롤리로 매핑된 각 요리의 값을 탐색하면서 초깃값으로 설정되어있는 누적자에 칼로리를 더한다(여기서는 초기값은 0이다)

averagingInt, averagingLong, averagingDouble

double avgCalories=menu.stream().collect(averagingInt(Dish::getCalories));

지금까지 컬렉터로 스트림의 요소 수를 계산하고, 최댓값과 최솟값을 찾고, 합계와 평균을 계산하는 방법을 살펴봤다.
하지만 종종 이들 중 두개 이상의 연산을 한 번에 수행해야될 때도 있는데 이때는 팩토리 메서드 summarizingInt가 반환하는 컬렉터를 사용할 수 있다.

IntSummaryStatistics menuStatistics = menu.stream().collect(summarizingInt(Dish::getCalories));
// menuStatistics : IntSummaryStatistics{count=9, sum=4300, min=120, average=477.778, max=800}

하나의 요약연산으로 메뉴에 있는 요소 수, 요리의 칼로리 합계, 평균, 최댓값, 최솟값 등을 계산하는 코드이다.
코드를 실행시 IntSummaryStatistics 클래스로 모든 정보가 수집된다.

6.2.3 문자열 연결

컬렉터에 joining 팩토리 메서드를 이용하면 스트림의 각 객체에 toString 메서드를 호출해서 추출한 모든 문자열을 하나의 문자열로 연결해서 반환한다.

String shortMenu = menu.stream().map(Dish::getName).collect(joining());

joining 메서드는 내부적으로 StringBuilder 를 이용해서 문자열을 하나로 만든다.

연결된 두 요소 간에 구분 문자열을 넣을 수 있도록 오버로드된 joining 팩토리 메서드도 있다.

String shortMenu = menu.stream().map(Dish::getName).collect(joining(","));

6.2.4 범용 리듀싱 요약 연산

지금까지 살펴본 모든 컬렉터는 reducing 팩토리 메서드로도 정의할 수 있다.

예를 들어 다음 코드처럼 reducing 메서드로 만들어진 컬렉터로도 메뉴의 모든 칼로리 합계를 게산할 수 있다.

  • 세 개의 인수를 갖는 reducing 메서드
int totalCalrories = menu.stream().collect(reducing(0, Dish::getCalories, (i, j) -> i + j);
  • 한 개의 인수를 갖는 reducing 메서드
Optional<Dish> mostCaloriesDish = menu.stream().collect(reducing(d1, d2)
  -> d1.getCalories() > d2.getCalories() ? d1 : d2));

한편 한 개의 인수를 갖는 reducing 메서드는 시작값이 없으므로 빈 스트림이 넘겨졌을때, 시작값이 설정되지 않는 상황이 벌어져 Optional<T> 를 사용했다

collect와 reduce

아래 코드에서 처럼 collect(toList()) 대신 reduce 를 사용하여 리스트를 반환할 수 있다.

collect: 도출하려는 결과를 누적하는 컨테이너를 바꾸도록 설계가 되어있는 메서드
reduce: 두 값을 하나로 도출하는 불변형 연산하는 메서드
따라서 위의 reduce 는 누적자로 사용된 리스트를 변환시키므로 잘못 활용한 예이다.

  • 여러 쓰레드가 동시에 데이터 구조체를 고치면 리스트 자체가 망가져 리듀싱 연산을 병렬로 수행할 수 없다
  • 위의 문제를 해결하기 위해수는 매번 새로운 리스트를 할당해야하고 그렇게 된다면 성능저하

결론: 가변 컨테이너 관련 작업 이면서 병렬성을 확보하려면 collect 메서드로 리듀싱연산을 구현하는 것이 바람직하다

자신에 상황에 맞는 최적의 해법 선택

함수형 프로그래밍에서는 하나의 연산을 다양한 방법으로 해결할 수 있음을 보여준다.
컬렉터를 이용하면 스트림 인터페이스에서 직접 제공하는 메서드를 이용하는 것에 비해 코드가 복잡해지지만, 재사용성과 커스터마이즈 가능성을 제공하는 높은 수준의 추상화와 일반화를 얻을 수 있다.

6.3 그룹화

자바8의 함수형을 이용하면 가독성 있는 한 줄 코드로 그룹화를 구현할 수 있다.
다음처럼 팩토리 메서드 Collectors.groupingBy를 이용해서 쉽게 메뉴를 그룹화 할 수 있다.

Map<Dish, Type, List<Dish>> dishsByType = menu.stream().collect(groupingBy(Dish::getType));
//dishsByType : {FISH=[prawns, salmon], OTHERS=[french fries, rice, pizza], MEAT[pork, beef, chicken]}

스트림의 각 요리에서 Dish.Type과 일치하는 모든 요리를 추출하는 함수를 groupbingBy 메서드로 전달했다.
이 함수를 기준으로 스트림이 그룹화되므로 이를 분류함수라고 부른다.

한편, 단순한 속성 접근자 대신 더 복잡한 분류 기준이 필요한 상황에서는 메서드 참조를 분류 함수로 사용할 수 없다.
이 경우 메서드 참조 대신 람다 표현식으로 필요한 로직을 구현할 수 있다.

public enum CaloricLevel { DIET, NORMAL, FAT }

Map<CaloricLevel, List<Dish>> dishesByCaloricLevel = menu.stream().collect(
groupingBy(dish -> {
  if (dish.getCalories() <= 400 ) return CaloricLevel.DIET;
  else if (dish.getCalories() <= 700 ) return CaloricLevel.NORMAL;
  else return CaloricLevel.FAT;
}));

6.3.1 그룹화된 요소 조작

예를 들어 500칼로리가 넘는 요리만 필터한다고 가정하자. 다음 코드처럼 그룹화를 하기 전에 프레디케이트로 필터를 적용해 문제를 해결할 수 있다고 생각할 것이다.

Map<Dish, Type, List<Dish>> caloricDishesByType = menu.stream()
.filter(dish -> dish.getCalories() > 500)
.collect(groupingBy(Dish::getType));
//caloricDishesByType : {OTHER=[french fries, pizza], MEAT=[pork, beef]}

위의 결과를 보면, 필터 프레디케이트를 만족하는 FISH 종류 요리는 없으므로 결과 맵에서 해당 키 자체가 사라진다.
Collectors 클래스는 일반적인 분류함수에 Collector 형식의 두번째 인수를 갖도록 groupingBy 팩토리 메서드를 오버로드해 이 문제를 해결한다.
두번째 Collector 안으로 필터 프레디케이트를 이동함으로써 이 문제를 해결한다.

Map<Dish, Type, List<Dish>> caloricDishesByType = menu.stream()
.collect(groupingBy(Dish::getType, filtering(dish -> getCalrories() > 500, toList())));
//caloricDishesByType : {OTHER=[french fries, pizza], MEAT=[pork, beef], FISH=[]}

그룹화된 항목을 조작하는 다른 유용한 기능 중 하나는 매핑 함수를 이용해 요소를 변환하는 작업이다.

Map<Dish, Type, List<Sting>> dishNamesByTypes = menu.stream()
  .collect(groupingBy(Dish::Type, mapping(Dish::getName, toList())));

6.3.2 다수준 그룹화

두 인수를 받는 팩토리 메서드 Collectors.groupingBy를 이용해서 항목을 다수준으로 그룹화할 수 있다

Map<Dish.Type, Map<CalricLevel, List<Dish>>> dishesByTypeCaloricLevel = menu.stream()
.collect(groupingBy(Dish::getType,
  groupingBy(dish -> {
    if (dish.getCalories() <= 400) return CaloricLevel.DIET;
    else if (dish.getCalories() <= 700) return CaloricLevel.NORMAL;
    else return CaloricLevel.FAT;
  })
)
};

보통 groupingBy의 연산을 '버킷(물건을 담을 수 있는 양동이)' 개념으로 생각하면 쉽다.
첫 번째 groupingBy는 각 키의 버킷을 만든다. 그리고 준비된 각각의 버킷을 서브스트림 컬렉터로 채워가기를 반복하면서 n수준 그룹화를 달성한다.

6.3.3 서브그룹으로 데이터 수집

groupingBy 메서드의 두번째 인수로 전달받는 컬렉터의 형식은 제한이 없다.

분류 함수 한개의 인수를 갖는 groupingBy(f)는 groupingBy(f, toList())의 축약형일 뿐이며, 다양한 컬렉터를 전달받을 수 있다.

  Map<Dish.Type, Long> typesCount = menu.stream().collect(groupingBy(Dish::getType, counting()));

컬렉터 결과를 다른 형식에 적용하기

팩토리 메서드 Collectors.collectingAndThen 로 컬렉터가 반환한 결과를 다른 형식으로 활용할 수 있다.

  Map<Dish.Type, Optional<Dish>> mostCaloricByType = menu.stream()
  .collect(groupingBy(Dish::getType, // 분류함수
    collectingAndThen(maxBy(CompaingInt(Dish::getCalories)), //감싸인 컬렉터
  Optional::get)));//변환함수

팩토리 메서드 collectingAndThen 는 적용할 컬렉터와 변환함수를 인수로 받아 다른 컬렉터를 반환한다.

groupingBy 컬렉터는 스트림의 첫번째 요소를 찾은 이후에야(Optional.get()) 그룹화 맵에 새로운 키를 게으르게 추가한다. 따라서 리듀싱 컬렉터는 Optional 래퍼를 사용할 필요가 없다.

6.4 분할

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

분할 함수는 불리언을 반환하므로 맵의 키 형식은 Boolean이며, 참 또는 거짓을 갖는 두 개의 그룹으로 분류된다.

6.4.1 분할의 장점

분할 함수가 반환하는 참, 거짓 두 가지 요소의 스트림 리스트를 모두 유지한다는 것이 분할 함수의 장점이다.

Collectors 클래스의 정적 팩토리 메서드 정리

6.5 Collector 인터페이스

Collector 인터페이스는 리듀싱 연산(즉, 컬렉터)을 어떻게 구현할지 제공하는 메서드 집합으로 구성된다.
toList 가 어떻게 구현되었는지 살펴보면서 Collector 는 어떻게 정의되어 있고, 내부적으로 collect 메서드는 tolist 가 반환하는 함수를 어떻게 활용했는지 알아보자.

public interface Collector<T, A, R> {
  Supplier<A> supplier();
  BiConsumer<A, T> accumulator();
  Function<A, R> finisher();
  BinaryOperator<A> combiner();
  Set<Characteristics> characteristics();
}

위의 코드는 Collector 인터페이스의 구조인데 다음처럼 설명할 수 있다.

  • T는 수집될 스트림 항목의 제네릭 형식이다.
  • A는 누적자. 즉 수집 과정에서 중간 결과를 누적하는 객체의 형식이다.
  • R은 수집 연산 결과 객체의 형식(항상 그런것은 아니지만 대게 컬렉션 형식)이다.

또한 위의 4가지 메서드는 collect 메서드에서 실행하는 함수를 반환하고, 다섯번째 메서드는 collect 메서드가 어떤 최적화를 이용해서 리듀싱 연산을 수행할 것인지 결정하도록 돕는 힌트 특성 집합을 제공한다.

예를 들어 Stream의 모든 요소를 List로 수집하는 ToListCollector라는 클래스를 구현할 수 있다.

  public class ToListCollector<T> implements Collector<T, List<T>, List<T>>

누적과정에서 사용되는 객체가 수집과정의 최종 결과로 사용되는 점을 알고가자

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

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

supplier 메서드는 빈 결과로 이루어진 Supplier를 반환해야 한다.

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

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

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

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

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


public BiConsumer<List<T>, T> accumulator() {
	  return List::add;
}

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

finisher 메서드는 스트림 탐색을 끝내고 누적자 객체를 최종 객체로 변환하면서 누적 과정을 끝낼 때 호출할 함수를 반환해야 한다.
때로는 ToListCollector 에서 볼 수 있는 것처럼 누적자 객체가 이미 최종 결과인 상황이 있어서 이런때는 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;
  }
}

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

병렬 리듀싱 수행 과정

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

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

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

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 커스텀 컬렉터를 구현해서 성능 개선하기

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

public interface Collector<T, A, R>

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

2단계 : 리듀싱 연산 구현

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

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

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

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

최종: partitioningBy 에 비해 코드가 간결하지만 가독성과 재사용성이 떨어진다 ✔️

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));
  });
}
profile
기록용

0개의 댓글