모던 자바 인 액션 - 6장 스트림으로 데이터 수집 정리

Modern Java In Action 정리 - 스트림으로 데이터 수집
목표

  • Collectors 클래스로 컬렉션을 만들고 사용하기
  • 하나의 값으로 데이터 스트림 리듀스 하기
  • 특별한 리듀싱 요약 연산
  • 데이터 그룹화와 분할
  • 자신만의 커스텀 컬렉터 개발

모던 자바 인 액션 책을 보고 정리한 글입니다.

스트림으로 데이터 수집

컬렉터란 무엇인가 ?

  • Collector 인터페이스 구현은 스트림의 요소를 어떤 식으로 도출할지 지정한다.

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

  • collect로 결과를 수집하는 과정을 간단하면서도 유연한 방식으로 정의할 수 있다는 점이 컬렉터의 최대 강점이다.
    구체적으로 스트림에 collect를 호출하면 스트림 요소에 내부적으로 리듀싱 연산이 수행된다. 명령형 프로그래밍에서 직접 구현해야 했던 부분들이 자동으로 수행된다는 점이다.
  • 보통 함수를 요소로 변환(toList처럼 데이터 자체를 변환하는것보다 데이터 저장 구조를 변환하는 작업이 더 빈번하다.)할 때는 컬렉터를 적용하여 최종 결과를 특정 자료구조로 뽑아낸다.
  • Collectors 유틸리티 클래스는 자주 사용하는 컬렉터 인스턴스를 손쉽게 생성할 수 있는 정적 팩토리 메서드를 제공해준다.

미리 정의된 컬렉터

  • groupingBy 와 같이 Collectors 클래스에서 제공하는 메서드의 기능을 설명한다.
    Collectors에서 제공하는 메서드의 기능은 크게 세 가지로 구분할 수 있다.
    1. 스트림의 요소를 하나의 값으로 리듀스 하고 요약
    2. 요소 그룹화
    3. 요소 분할

리듀싱과 요약

  • 컬렉터 인스턴스를 활용해서 어떤 작업을 할 수 있는지 파악
  • 예를 들면 counting() 이라는 팩토리 메서드가 환한하는 컬렉터로 다음과 같은 과정을 생략 할 수 있다.
    • menu.stream().collect(Collectors.counting());
    • menu.stream().counting();

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

  • 메뉴에서 가장 높은 칼로리와 낮은 칼로리의 요리를 찾는다고 가정, Collectors.maxBy, Collectors.MinBy 두 개의 메서드를 활용해서 스트림의 최댓값과 최솟값을 계산할 수 있다.
  • 두 컬렉터는 스트림의 요소를 비교하는데 사용하는 Comparator를 인수로 받는다. ```java Comparator dishComparator = Comparator.comparingInt(Dish::getCalories);

Optional mostCalorieDish = menu.stream() .collect(maxBy(dishComparator));

1
2
3
4
5
6
7
8
* Optional 객체로 반환된 이유를 생각해보자. 만약 menu가 비어 있다면 어떤 요리도 반환되지 않을것이기 때문에 Optional로 반환하는것이다.
* 스트림에 있는 객체의 숫자 필드의 합계나 평균 등을 반환하는 연산에도 리듀싱 기능이 사용된다. 이러한 연산을 **요약**연산이라고 부른다.

### 요약 연산
* Collectors 클래스는 Collectors.summingInt라는 특별한 요약 팩토리 메서드를 제공한다.
```java
// 메뉴 리스트의 총 칼로리를 계산하는 코드
Integer totalCalories = menu.stream().collect(summingInt(Dish::getCalories));
  • 이러한 요약 연산은 내부적으로 reducing 연산이 실행되며 초깃값을 기준으로 스트림을 탐색하여 값을 더하게 된다.
  • Collectors.summingLong, Collectors.summingDouble 메서드는 같은 방식으로 동작하며 long, double 형으로 데이터를 요약하는것만 다르다.
  • 컬렉터를 활용해서 최댓값, 최솟값, 합계, 평균등을 계산하는 방식을 살펴보았는데 두개 이상의 연산을 한번에 수행해야 하는 경우 summarizingInt를 사용하게 된다.
    1
    2
    IntSummaryStatistics menuStatistics = menu.stream().collect(summarizingInt(Dish::getCalories));
    // IntSummaryStatistics[count=4, sum=1200, min=100, average=200, max=300]
    

문자열 연결

  • 컬렉터에 joining 팩토리 메서드를 이용하면 스트림의 각 객체에 toString 메서드를 호출하여 추출된 문자열을 하나로 연결해 반환한다.
    1
    2
    3
    String shortMenu = menu.stream()
                  .map(Dish::getName)
                  .collect(joining());
    
  • joining 메서드는 내부적으로 StringBuilder를 이용해 문자열을 하나로 만든다.
  • Dish 클래스가 toString을 구현하였다면 아래와 같이 생략하여 정의한 toString으로 추출할 수 있다.
    1
    2
    String shortMenu = menu.stream()
                  .collect(joining());
    
  • 연결된 두 요소 사이에 구분 문자열을 넣거나 prefix, suffix를 넣을수도 있다.
    1
    2
    3
    String shortMenu = menu.stream()
                  .map(Dish::getName)
                  .collect(joining(", ", "[", "]"));
    

범용 리듀싱 요약 연산

  • 컬렉터는 reducing 팩토리 메서드로도 정의할 수 있다.
    1
    2
    Integer total = menu.stream()
                  .collect(reducing(0, Dish::getCalories, (i, j) -> i + j));
    
  • 리듀싱 연산은 세가지 인수를 받는다.
    1. 첫번쨰 인수는 시작값이거나 스트림이 비엇을 때 반환값
    2. 변환 함수
    3. BinaryOperator
  • 다음 처럼 한 개의 인수를 가진 reducing 버전을 이용해 가장 칼로리가 높은 요리를 찾을 수도 있다.
    1
    2
    Optional<Dish> collect1 = menu.stream()
                  .collect(reducing((d1, d2) -> d1.getCalories() > d2.getCalories() ? d1 : d2));
    

그룹화

  • 데이터를 하나 이상의 집합으로 분류하여 그룹화 하는것도 데이터 베이스에서 많이 사용되는 작업이다.
  • 스트림에서 제공하는 팩토리 메서드 (Collectors.gruopingBy)를 사용하여 메뉴를 그룹화 해보자. ```java Map<DishType, List> dishByType = menu.stream() .collect(groupingBy(Dish::getDishType));

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

1
2
3
4
5
6
7

### 그룹화된 요소 조작
* 500 칼로리가 넘는 요리들만 필터링 한다고 가정할때 아래와 같은 코드를 작성할 수 있다.
```java
Map<DishType, List<Dish>> dishByType = menu.stream()
                .filter(dish -> dish.getCalories() > 500)
                .collect(groupingBy(Dish::getDishType));
  • 위와 같이 작성시 조건에 맞는 요소가 하나도 존재하지 않을시 Map의 key가 존재 하지 않는다. 이러한 경우 아래와 같이 작성
    1
    2
    Map<DishType, List<Dish>> dishByType = menu.stream()
                  .collect(groupingBy(Dish::getDishType, filtering(dish -> dish.getCalories() > 500, toList())));
    
  • 그룹화된 항목을 조작하는 다른 유용한 기능 중 하나로 맵핑 함수를 이용해 요소를 변환하는 작업이 있다.
    filtering 컬렉터와 같은 이유로 Collectors 클래스는 매핑 함수와 각 항목에 적용한 함수를 모으는 데 사용하는 또 다른 컬렉터를 인수로 받는 mapping 메서드를 제공
    1
    2
    3
    // Name List 로 그룹화
    Map<DishType, List<String>> dishByName = menu.stream()
                  .collect(groupingBy(Dish::getDishType, mapping(Dish::getName, toList())));
    
  • 컬렉터를 사용하면 일반 맵이 아닌 flatMap 변환을 수행할 수 있다.
    1
    2
    3
    4
    5
    6
    Map<String, List<String>> dishTags = new HashMap<>();
          dishTags.put("pork", Arrays.asList("greasy", "salty"));
    
          Map<DishType, Set<String>> dishByDishTag = menu.stream()
                  .collect(groupingBy(Dish::getDishType,
                          flatMapping(dish -> dishTags.get(dish.getName()).stream(), toSet())));
    

다수준 그룹화

  • 두 인수를 받는 팩토리 메서드 Collectors.groupingBy 를 이용해 항목을 다수준으로 그룹화 할 수 있다.
  • Collectors.groupingBy는 일반적인 분류 함수와 컬렉터를 인수로 받는다.
    1
    2
    3
    4
    5
    Map<DishType, Map<CaloricLevel, List<Dish>>> dishTypeMapMap = menu.stream()
                  .collect(groupingBy(Dish::getDishType, groupingBy(dish -> {
                      if (dish.getCalories() > 400) return CaloricLevel.DIET;
                      else return CaloricLevel.FAT;
                  })));
    

서브그룹으로 데이터 수집

  • groupBy로 넘겨주는 컬렉터의 형식은 제한이 없다. 아래와 같이 두번째 인수로 counting 컬렉터를 전달해 메뉴에서 요리의 수를 종류별로 계산 가능하다.
    1
    2
    Map<DishType, Long> dishTypeLongMap = menu.stream()
                  .collect(groupingBy(Dish::getDishType, counting()));
    
  • 분류 함수 한개의 인수를 받는 groupingBy(f)는 groupingBy(f, toList())의 축약형이다.
  • 가장 높은 칼로리를 가지는 메뉴도 구현 가능하다.
    1
    2
    Map<DishType, Optional<Dish>> dishTypeOptionalMap = menu.stream()
                  .collect(groupingBy(Dish::getDishType, maxBy(Comparator.comparingInt(Dish::getCalories))));
    
  • 팩토리 메서드 maxBy 가 생성하는 컬렉터의 형식에 따라 Optional 형식으로 바인딩 되었다. 실제 메뉴의 요리중 Optional.empty()를 값으로 가지는 메뉴는 없으나 groupingBy 컬렉터는
    스트림의 첫번째 요소를 찾은 이후에 그룹화 맵에 새로운 키를 추가한다. (lazy binging)
  • Optional로 값을 감쌀 필요가 없으므로 Optional을 삭제 할 수있다. CollectingAndThen을 활용하는 것이다.
    1
    2
    Map<DishType, Dish> dishTypeDishMap = menu.stream()
                  .collect(groupingBy(Dish::getDishType, collectingAndThen(maxBy(Comparator.comparingInt(Dish::getCalories)), Optional::get)));
    
  • groupingBy와 함께 사용하는 다른 컬렉터 예제
  • 일반적으로 스트림에서 같은 그룹으로 분류된 모든 요소에 리듀싱 작업을 수행할 때는 팩토리 메서드 groupingBy에 두 번째 인수로 전달한 컬렉터를 이용한다.
  • 예를들어 모든 요리의 칼로리합을 구할때는 아래와 같이 사용한다.
    1
    2
    Map<DishType, Integer> dishTypeIntegerMap = menu.stream()
                  .collect(groupingBy(Dish::getDishType, summingInt(Dish::getCalories)));
    
  • 이 외에도 mapping 메서드로 만들어진 컬렉터도 groupingBy와 자주 사용된다.
  • mapping은 다양한 형식의 객체를 주어진 형식의 컬렌터에 맞게 변환하는 역할을 한다.
  • 각 요리 형식에 존재하는 모든 CaloricLevel값을 구할때는 아래와 같이 사용한다. ```java Map<DishType, Set> dishTypeSetMap = menu.stream() .collect(groupingBy(Dish::getDishType, mapping(dish -> { if (dish.getCalories() > 400) return CaloricLevel.DIET; else return CaloricLevel.FAT; }, toSet())));

// toCollection을 사용하면 원하는 SetCollection으로 반환할 수 있다. Map<DishType, HashSet> dishTypeHashSetMap = menu.stream() .collect(groupingBy(Dish::getDishType, mapping(dish -> { if (dish.getCalories() > 400) return CaloricLevel.DIET; else return CaloricLevel.FAT; }, toCollection(HashSet::new))));

1
2
3
4
5
6
7
8
9
10
11

## 분할
* 분할은 분할함수라 불리는 Predicate를 분류 함수로 사용하는 특수한 그룹화 기능이다.
```java
Map<Boolean, List<Dish>> partitionedMenu = menu.stream()
                .collect(partitioningBy(Dish::isVegetarian)); <- 분할 함수

// filter를 사용할 수도 있다.
List<Dish> vegetarianDishes = menu.stream()
                .filter(Dish::isVegetarian)
                .collect(toList()); 

분할의 장점

  • 분할 함수를 사용하면 참, 거짓 두가지 요소의 스트림 리스트를 모두 유지할 수 있다는것이 장점이다.
  • 컬렉터를 두번째 인수로 전달할 수 있는 오버로드된 partioningBy 메서드도 존재한다.
1
2
3
4
5
6
7
Map<Boolean, Map<DishType, List<Dish>>> vegetarianDishesByType = menu.stream()
                .collect(partitioningBy(Dish::isVegetarian, groupingBy(Dish::getDishType)));

// 채식 중 가장 높은 칼로리의 음식과 채식이 아닌 음식중 가장 높은 칼로리의 음식
Map<Boolean, Dish> mostCaloricPartitionedByVegetarian = menu.stream()
                .collect(partitioningBy(Dish::isVegetarian,
                        collectingAndThen(maxBy(Comparator.comparingInt(Dish::getCalories)), Optional::get)));

정리

  • collect는 스트림의 요소를 요약 결과로 누적하는 다양한 방법을 인수로 갖는 최종연산이다.
  • 스트림의 요소를 하나의 값으로 리듀스 하고 요약하는 컬렉터뿐 아니라 최솟값, 최댓값, 평균값을 계산하는 컬렉터 등이 미리 정의되어 있다.
  • 미리 정의된 컬렉터인 groupingBy로 스트림의 요소를 그룹화 하거나, partitioningBy로 스트림의 요소를 분할 할 수 있다.
  • 컬렉터는 다수준의 그룹화, 분할, 리듀싱 연산에 적합하게 설계되어 있다.
  • collector 인터페이스에 정의된 메서드를 구현해서 커스텀 컬렉터를 개발할 수 있다.