Java8 - Stream 으로 데이터 수집 (1): Collectors 클래스, Reducing 과 Summary, Grouping


이 포스트에서는 reduce() 처럼 collect() 역시 다양한 요소 누적 방식을 인수로 받아서 스트림을 최종 결과로 도출하는 리듀싱 연산을 수행할 수 있음을 알아본다.
(다양한 요소 누적 방식은 Collector 인터페이스에 정의되어 있음)

  • Collectors 클래스로 컬렉션 생성 및 사용
  • 하나의 값으로 데이터 스트림 리듀스
  • 리듀싱 요약 연산
  • 데이터 그룹화

소스는 github 에 있습니다.


목차


아래는 이번 포스트에서 사용될 Dish 불변형(immutable) 클래스이다.

package com.assu.study.mejava8.chap06;

import java.util.Arrays;
import java.util.HashMap;
import java.util.List;
import java.util.Map;

import static java.util.Arrays.asList;


public final class Dish {
  private final String name;
  private final boolean vegetarian;
  private final int calories;
  private final Type type;

  public Dish(String name, boolean vegetarian, int calories, Type type) {
    this.name = name;
    this.vegetarian = vegetarian;
    this.calories = calories;
    this.type = type;
  }
  public String getName() {
    return name;
  }

  public boolean isVegetarian() {
    return vegetarian;
  }

  public int getCalories() {
    return calories;
  }

  public Type getType() {
    return type;
  }

  public enum Type {
    MEAT, FISH, OTHER
  }

  @Override
  public String toString() {
    return name;
  }

  public static final List<Dish> menu =
          Arrays.asList( new Dish("pork", false, 800, Type.MEAT),
                  new Dish("beef", false, 700, Type.MEAT),
                  new Dish("chicken", false, 400, Type.MEAT),
                  new Dish("french fries", true, 530, Type.OTHER),
                  new Dish("rice", true, 350, Type.OTHER),
                  new Dish("season fruit", true, 120, Type.OTHER),
                  new Dish("pizza", true, 550, Type.OTHER),
                  new Dish("prawns", false, 400, Type.FISH),
                  new Dish("salmon", false, 450, Type.FISH));

  public static final Map<String, List<String>> dishTags = new HashMap<>();

  static {
    dishTags.put("pork", asList("greasy", "salty"));
    dishTags.put("beef", asList("salty", "roasted"));
    dishTags.put("chicken", asList("fried", "crisp"));
    dishTags.put("french fries", asList("greasy", "fried"));
    dishTags.put("rice", asList("light", "natural"));
    dishTags.put("season fruit", asList("fresh", "natural"));
    dishTags.put("pizza", asList("tasty", "salty"));
    dishTags.put("prawns", asList("tasty", "roasted"));
    dishTags.put("salmon", asList("delicious", "fresh"));
  }
}

1. 컬렉터

Java8 - Stream 활용 (2): 리듀싱, 숫자형 스트림, 스트림 생성 에 보면 collect() 메서드로 Collector 인터페이스 구현을 전달하였다.
Collector 인터페이스 구현은 스트림 요소를 어떤 식으로 도출할 지 지정한다. (= Collector 인터페이스의 메서드를 어떻게 구현하느냐에 따라 스트림에 어떤 리듀싱 연산을 수행할 지 결정됨)

여기서 collect() 의 인수로 전달되는 Collector 인터페이스가 바로 컬렉터이다.

// toList() 는 Collector 인터페이스의 구현
collect(Collectors.toList())

스트림에 collect() 를 호출하면 스트림 요소에 컬렉터로 파라메터화된 리듀싱 연산이 수행된다.
collect() 에서는 리듀싱 연산을 이용해서 스트림의 각 요소를 방문하면서 컬렉터가 작업을 처리한다.

보통 함수를 요소로 변환(toList() 처럼 데이터 변환보다는 데이터 저장 구조를 변환하는 경우가 많음) 할때는 컬렉터를 적용하며, 최종 결과를 저장하는 자료구조에 값을 누적한다.

Collectors 유틸리티 클래스는 자주 사용하는 컬렉터 인스턴스를 쉽게 생성할 수 있는 static factory 메서드를 제공한다.
예) Collectors.toList()

static factory 메서드
객체 생성의 역할을 하는 클래스 메서드
Factory Method 패턴의 좀 더 상세한 내용은 Java8 - 리팩토링, 디자인 패턴2.5. 팩토리 패턴 (Factory Pattern) 를 참고하세요.

Collectors 유틸리티 클래스가 제공하는 메서드는 크게 3 가지로 구분할 수 있다.

  • 스트림 요소를 하나의 값으로 리듀스하고 요약
  • 요소 그룹화
  • 요소 분할

2. 리듀싱과 요약

컬렉터(Stream.collect() 의 인수) 로 스트림 항목을 컬렉션으로 제구성할 수 있다.

아래는 Collectors 클래스의 factory 메서드인 counting() 이 반환하는 컬렉터로 요리 수를 계산하는 예시이다.

import static java.util.stream.Collectors.counting;

private static Long calculateCount() {
  return Dish.menu.stream().collect(counting());
}

private static Long calculateCount2() {
  return Dish.menu.stream().collect(counting());
}

System.out.println(calculateCount()); // 9
System.out.println(calculateCount2()); // 9

2.1. 스트림에서 최대값/최소값 검색

Collectors.maxBy()Collectors.minBy() 이 두 컬렉터는 스트림의 요소를 비교하는데 사용할 Comparator 를 인수로 받아 각각 최대값과 최소값을 반환한다.

private static Optional<Dish> calculateMaxCalorie() {
  // 칼로리를 비교할 Comparator 구현
  Comparator<Dish> dishCaloriesComparator = Comparator.comparingInt(Dish::getCalories);
  
  // Collectors.maxBy 로 Comparator 전달
  Optional<Dish> maxCalorieDish = Dish.menu.stream().collect(maxBy(dishCaloriesComparator));
  
  return maxCalorieDish;
}

private static Optional<Dish> calculateMinCalorie() {
  // 칼로리를 비교할 Comparator 구현
  Comparator<Dish> dishCaloriesComparator = Comparator.comparingInt(Dish::getCalories);
  
  // Collectors.maxBy 로 Comparator 전달
  Optional<Dish> maxCalorieDish = Dish.menu.stream().collect(minBy(dishCaloriesComparator));
  
  return maxCalorieDish;
}


// Optional[Dish{name='pork', vegetarian=false, calories=800, type=MEAT}]
System.out.println(calculateMaxCalorie());

// Optional[Dish{name='season fruit', vegetarian=true, calories=120, type=OTHER}]
System.out.println(calculateMinCalorie());

2.2. 요약 연산

  • Collectors.summingInt(), summingLong(), summingDouble()
    • 객체를 int 로 매핑하는 함수를 인수로 받고, 인수로 전달된 함수는 객체를 int 로 매핑한 컬렉터 반환
  • Collectors.averagingInt(), averagingLong(), averagingDouble()
  • Collectors.summarizingInt(), summarizingLong(), summarizingDouble()
    • 하나의 요약 연산으로 수, 합계, 평균 등을 한번에 계산
import static java.util.stream.Collectors.*;

private static Integer calculateTotalCalories() {
  return Dish.menu.stream().collect(summingInt(Dish::getCalories));
}

System.out.println(calculateTotalCalories()); // 4300
import static java.util.stream.Collectors.*;

private static Double calculateAverageCalories() {
  return Dish.menu.stream().collect(averagingInt(Dish::getCalories));
}

System.out.println(calculateAverageCalories()); // 477.77..
import static java.util.stream.Collectors.*;

private static IntSummaryStatistics calculateStatistics() {
  return Dish.menu.stream().collect(summarizingInt(Dish::getCalories));
}

// IntSummaryStatistics{count=9, sum=4300, min=120, average=477.777778, max=800}
System.out.println(calculateStatistics()); 

2.3. 문자열 연결

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

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

private static String getMenuString() {
  return Dish.menu.stream().map(Dish::getName).collect(joining(", "));
}

// pork, beef, chicken, french fries, rice, season fruit, pizza, prawns, salmon
System.out.println(getMenuString());

2.4. 범용 리듀싱 요약 연산: reducing()

위에서 본 counting(), maxBy() 등등의 특화된 컬렉터들은 Collectors.reducing() factory 메서드가 제공하는 범용 리듀싱 컬렉터로도 모두 리듀싱을 재현할 수 있다.

범용 factory 메서드 대신 특화된 컬렉터를 사용한 이유는 편의성 때문이지만 가독성도 중요!

summingInt() 로 전체 합을 구하는 대신 reducing() 로 만들어진 컬렉터로도 구하는 예시이다.

// summingInt() 컬렉터로 합계 구하기
private static int calculateTotalCalories() {
  return Dish.menu.stream().collect(summingInt(Dish::getCalories));
}
// reducing() 컬렉터와 람다 표현식으로 합계 구하기
private static int calculateTotalCaloriesByReducing() {
  return Dish.menu.stream().collect(reducing(0, Dish::getCalories, (i, j) -> i+j));
}

// reducing() 컬렉터와 메서드 레퍼런스로 합계 구하기
private static int calculateTotalCaloriesByReducingAndMethodReference() {
  return Dish.menu.stream().collect(reducing(
            0 // 초기값
            , Dish::getCalories // 변환 함수
            , Integer::sum  // 합계 함수
  ));
}

// 컬렉터를 이용하지 않고 합계 구하기
private static int calculateTotalCaloriesWithoutCollectors() {
  return Dish.menu.stream() // Stream<Dish> 반환
          .map(Dish::getCalories) // Stream<Integer> 반환
          .reduce(Integer::sum) // Optional<Integer> 반환
          .get(); // get() 으로 Optional 객체 내부의 값 추출 / Integer 반환
}

// sum() 으로 합계 구하기
private static int calculateTotalCaloriesBySum() {
  return Dish.menu.stream() // Stream<Dish> 반환
          .mapToInt(Dish::getCalories)  // IntStream 반환
          .sum();
}

위에선 get() 으로 Optional 객체 내부의 값을 추출했지만 일반적으로는 기본값을 제공할 수 있는 orElse(), orElseGet() 등을 이용하는 것이 좋다.

위를 보면 5 가지 방법으로 합계를 구할 수 있다.
mapToInt() 를 사용하여 가독성도 가장 좋고 Integer 를 int 로 변환하는 언박싱 작업이 일어나지 않으므로 마지막 방법이 가장 효율적이다.

reducing() 은 3개의 인수를 받는다.

  • 첫 번째
    • 리듀싱 연산의 시작값 혹은 스트림에 인수가 없을 때는 반환값
  • 두 번째
    • 변환 함수
  • 세 번째
    • 같은 종류의 두 항목을 하나의 값으로 리턴하는 BinaryOperator (T,T) -> T

아래처럼 한 개의 인수를 가진 reducing() 을 이용하여 최대값을 구할 수도 있다.

private static Optional<Dish> calculateMaxCalorie() {
  // 칼로리를 비교할 Comparator 구현
  Comparator<Dish> dishCaloriesComparator = Comparator.comparingInt(Dish::getCalories);

  // Collectors.maxBy 로 Comparator 전달
  Optional<Dish> maxCalorieDish = Dish.menu.stream().collect(maxBy(dishCaloriesComparator));

  return maxCalorieDish;
}
private static Optional<Dish> calculateMaxCalorieByReducing() {
  return Dish.menu.stream()
          .collect(reducing((r1, r2) -> r1.getCalories() > r2.getCalories() ? r1 : r2));
}

한 개의 인수를 갖는 reducing() factory 메서드는 3 개의 인수를 갖는 reducing() 메서드에서 스트림의 첫 번째 요소를 시작 요소(= 첫 번째 인수)로 받으며, 자기 자신을 그대로 반환하는 항등 함수(identity function) 를 두 번째 인수로 받는 상황에 해당한다.


2.5. collect()reduce()

collect() 도출하려는 결과를 누적하는 컨테이너를 바꾸도록 설계된 메서드이고,
reduce() 는 두 값을 하나로 도출하는 불변형 연산이다.

reduce() 메서드를 잘못 사용하면 실용성 문제도 발생하는데, 여러 스레드가 동시에 같은 데이터 구조체를 고치면 리스트 자체가 망가져버려 리듀싱 연산을 병렬로 수행할 수 없다는 문제점이 있다.
이 문제를 해결하려면 매번 새로운 리스트를 할당해야 하는데 그러면 객체를 할당하느라 성능 저하가 발생한다.

가변 컨테이너 관련 작업이면서 병렬성을 확보할 때는 reduce() 가 아닌 collect() 로 리듀싱 연산을 구현하는 것이 좋다.

reduce() 를 잘못 사용하면 여러 스레드가 동시에 같은 데이터 구조체를 고치게 되는 경우 리스트 자체가 망가져러서 리듀싱 연산을 병렬로 수행할 수 없다는 문제도 있다.

좀 더 자세한 내용은 Java8 - Stream 으로 병렬 데이터 처리 (1): 병렬 스트림, 포크/조인 프레임워크1.3. 병렬 스트림의 올바른 사용 를 참고하세요.


3. 그룹화: groupingby()

데이터 그룹화 시 Collectors.groupingby() 로 쉽게 그룹화가 가능하다.

public static Map<Dish.Type, List<Dish>> groupByType() {
  return Dish.menu.stream()
          .collect(groupingBy(Dish::getType));  // Dish.Type 과 일치하는 모든 요리를 추출하는 함수를 groupingBy 로 전달
}
{MEAT=[pork, beef, chicken], FISH=[prawns, salmon], OTHER=[french fries, rice, season fruit, pizza]}

groupingBy() 로 전달되는 함수를 기준으로 스트림이 그룹화되기 때문에 groupingBy()분류 함수라 한다.

그룹화 연산의 결과는 그룹화 함수가 반환하는 키, 그리고 각 키에 대응하는 모든 항목 리스트를 값으로 갖는 맵이다.

메서드 레퍼런스를 사용할 수 없는 복잡한 그룹화의 경우 람다 표현식으로도 분류 함수를 만들어 전달할 수 있다.

아래는 400 이하는 DIET, 400~700 은 NORMAL, 700 초과는 FAT 으로 분류하는 예시이다.

public static Map<CaloricLevel, List<Dish>> groupByCaloricLevel() {
  return Dish.menu.stream()
          .collect(
                  groupingBy(dish -> {
                    if (dish.getCalories() <= 400) {
                      return CaloricLevel.DIET;
                    } else if (dish.getCalories() <= 700) {
                      return CaloricLevel.NOMAL;
                    } else {
                      return CaloricLevel.FAT;
                    }
                  })
          );
}
{FAT=[pork], NOMAL=[beef, french fries, pizza, salmon], DIET=[chicken, rice, season fruit, prawns]}

3.1. 다수준 그룹화

여러 개의 인수를 받는 factory 메서드 Collectors.groupingBy() 를 이용하여 두 가지 이상의 기준으로 그룹화도 가능하다.
Collectors.groupingBy() 는 분류 함수와 컬렉터를 인수로 받는다.

public static Map<Dish.Type, Map<CaloricLevel, List<Dish>>> groupByTypeAndCaloricLevel() {
  return Dish.menu.stream().collect(
          groupingBy(Dish::getType, // 첫 번째 수준의 분류 함수 (두 번째 수준의 분류 함수를 두 번째 인자로 받음)
                  groupingBy(dish -> {  // 두 번째 수준의 분류 함수
                    if (dish.getCalories() <= 400) {
                      return CaloricLevel.DIET;
                    } else if (dish.getCalories() <= 700) {
                      return CaloricLevel.NOMAL;
                    } else {
                      return CaloricLevel.FAT;
                    }
                  }))
  );
}
// {
// MEAT={FAT=[pork], NOMAL=[beef], DIET=[chicken]}, 
// FISH={NOMAL=[salmon], DIET=[prawns]}, 
// OTHER={NOMAL=[french fries, pizza], DIET=[rice, season fruit]}
// }

외부 맵은 첫 번째 수준의 분류 함수에서 분류한 키 값인 FISH, MEAT, OTHER 이고,
외부 맵의 값은 두 번째 수준의 분류 함수의 기준인 NORMAL, DIET, FAT 이다.

이런 식으로 n 수준까지 그룹화가 가능하다.


3.2. 서브그룹으로 데이터 수집

위에서 두 번째 groupingBy() 컬렉터를 외부 컬렉터로 전달해서 다수준 그룹화 연산을 했지만 사실 첫 번째 groupingBy() 로 넘겨주는 컬렉터 형식의 제한은 없다.

아래처럼 두 번째 인수로 counting() 컬렉터를 전달해서 그룹화 내의 요소수를 계산할 수 있다.

public static Map<Dish.Type, Long> countInGroups() {
  return Dish.menu.stream().collect(groupingBy(Dish::getType, counting()));
}
{MEAT=3, FISH=2, OTHER=4}

아래는 타입별로 가장 높은 칼로리를 요리를 찾는 예시이다.

public static Map<Dish.Type, Optional<Dish>> maxCaloricByType() {
  return Dish.menu.stream().collect(
          groupingBy(Dish::getType, maxBy(comparing(Dish::getCalories))));
}
{MEAT=Optional[pork], FISH=Optional[salmon], OTHER=Optional[pizza]}  

만일 결과가 항상 값이 있는 상황이라면 Collectors.collectingAndThen() 으로 Optional 을 삭제할 수 있다.

// maxBy() 를 이용해서..
public static Map<Dish.Type, Dish> maxCaloricByTypeWithoutOptional() {
  return Dish.menu.stream().collect(
          groupingBy(Dish::getType, // 분류 함수
                  collectingAndThen(
                          maxBy(comparingInt(Dish::getCalories)), // 감싸인 컬렉터
                          Optional::get // 변환 함수
                  )
  ));
}

// reducing() 컬렉터를 이용해서..
public static Map<Dish.Type, Dish> maxCaloricByTypeWithReduce() {
  return Dish.menu.stream().collect(
    groupingBy(Dish::getType,
            collectingAndThen(
                reducing(((d1, d2) -> d1.getCalories() > d2.getCalories() ? d1:d2)),
                Optional::get
            ))
  );
}
{MEAT=pork, FISH=salmon, OTHER=pizza}

factory 메서드 collectingAndThen() 은 적용할 컬렉터와 변환 함수를 인수로 받아 다른 컬렉터로 변환한다.
반환되는 컬렉터는 기존 컬렉터의 래퍼 역할을 하여, collect() 의 마지막 과정에서 변환 함수로 자신이 반환하는 값을 매핑한다.
위 예시에서는 maxBy() 로 만들어진 컬렉터를 변환 함수 Optional::get 을 이용하여 반환된 Optional 에 포함된 값 추출하여 리턴한다.


일반적으로 같은 그룹으로 분류된 모든 요소에 리듀싱 작업을 할 때 factory 메서드 groupingBy() 에 두 번째 인수로 전달한 컬렉터를 사용한다.

아래는 타입별 칼로리의 합 예시이다.

public static Map<Dish.Type, Integer> sumCaloriesByType() {
  return Dish.menu.stream().collect(
          groupingBy(Dish::getType,
                      summingInt(Dish::getCalories))
  );
}
{MEAT=1900, FISH=850, OTHER=1550}

Collectors.mapping() factory 메서드도 자주 사용된다.
mapping() 함수는 스트림의 인수를 변환하는 함수와 그 변환 함수의 결과 객체를 누적하는 컬렉터를 인수로 받는다.

아래는 각 타입에 존재하는 CaloricLevel 을 찾는 예시이다.

public static Map<Dish.Type, Set<CaloricLevel>> caloricLevelsByType() {
  return Dish.menu.stream().collect(
          groupingBy(Dish::getType, mapping(dish -> {
            if (dish.getCalories() <= 400) {
              return CaloricLevel.DIET;
            } else if (dish.getCalories() <= 700) {
              return CaloricLevel.NOMAL;
            } else {
              return CaloricLevel.FAT;
            }
          },
          // toSet()
        toCollection(HashSet::new)
        )));
}
{MEAT=[FAT, NOMAL, DIET], FISH=[NOMAL, DIET], OTHER=[NOMAL, DIET]}

toSet() 으로 할 경우 Set 의 형식이 정해지지 않은 상태이므로 메서드 레퍼런트 HashSet::new 를 toCollection 에 전달할 수도 있다.


참고 사이트 & 함께 보면 좋은 사이트

본 포스트는 라울-게이브리얼 우르마, 마리오 푸스코, 앨런 마이크로프트 저자의 Java 8 in Action을 기반으로 스터디하며 정리한 내용들입니다.






© 2020.08. by assu10

Powered by assu10