모던자바인액션 Chaptrt 6_스트림으로 데이터 수집

woply·2022년 10월 10일
0

Modern Java In Action

목록 보기
6/7

스트림으로 데이터 수집

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

1. 컬렉터란 무엇인가 ?

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

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

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

미리 정의된 컬렉터

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

2. 리듀싱과 요약

  • 컬렉터 인스턴스를 활용해서 어떤 작업을 할 수 있는지 파악
    예를 들면 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));

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

요약 연산

  • Collectors 클래스는 Collectors.summingInt 라는 특별한 요약 팩토리 메서드를 제공한다.
  • 메뉴 리스트의 총 칼로리를 계산하는 코드
    Integer totalCalories = menu.stream().collect(summingInt(Dish::getCalories));

이러한 요약 연산은 내부적으로 reducing 연산이 실행되며 초깃값을 기준으로 스트림을 탐색하여 값을 더하게 된다. Collectors.summingLong , Collectors.summingDouble 메서드는 같은 방식으로 동작하며 long, double 형으로 데이터를 요약하는것만 다르다. 컬렉터를 활용해서 최댓값, 최솟값, 합계, 평균등을 계산하는 방식을 살펴보았는데 두개 이상의 연산을 한번에 수행해야 하는 경우 summarizingInt를 사용하게 된다.

IntSummaryStatistics menuStatistics = menu.stream().collect(summarizingInt(Dish::getCalories));
// IntSummaryStatistics[count=4, sum=1200, min=100, average=200, max=300]

문자열 연결

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

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

joining 메서드는 내부적으로 StringBuilder를 이용해 문자열을 하나로 만든다. Dish 클래스가 toString을 구현하였다면 아래와 같이 생략하여 정의한 toString으로 추출할 수 있다.

String shortMenu = menu.stream()
              .collect(joining());

연결된 두 요소 사이에 구분 문자열을 넣거나 prefix, suffix를 넣을수도 있다.

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

범용 리듀싱 요약 연산

컬렉터는 reducing 팩토리 메서드로도 정의할 수 있다.

Integer total = menu.stream()
              .collect(reducing(0, Dish::getCalories, (i, j) -> i + j));

리듀싱 연산은 세가지 인수를 받는다.

  • 첫 째, 시작값이거나 스트림이 비었을 때 반환하는 값
  • 변환 함수
  • BinaryOperator

다음 처럼 한 개의 인수를 가진 reducing 버전을 이용해 가장 칼로리가 높은 요리를 찾을 수도 있다.

Optional<Dish> collect1 = menu.stream()
              .collect(reducing((d1, d2) -> d1.getCalories() > d2.getCalories() ? d1 : d2));

3. 그룹화

데이터를 하나 이상의 집합으로 분류하여 그룹화 하는것도 데이터 베이스에서 많이 사용되는 작업이다. 스트림에서 제공하는 팩토리 메서드 (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; }));

그룹화된 요소 조작

  • 500 칼로리가 넘는 요리들만 필터링 한다고 가정할때 아래와 같은 코드를 작성할 수 있다.
Map<DishType, List<Dish>> dishByType = menu.stream()
                .filter(dish -> dish.getCalories() > 500)
                .collect(groupingBy(Dish::getDishType));

위와 같이 작성시 조건에 맞는 요소가 하나도 존재하지 않을시 Map의 key가 존재 하지 않는다. 이러한 경우 아래와 같이 작성한다.

Map<DishType, List<Dish>> dishByType = menu.stream()
              .collect(groupingBy(Dish::getDishType, filtering(dish -> dish.getCalories() > 500, toList())));

그룹화된 항목을 조작하는 다른 유용한 기능 중 하나로 맵핑 함수를 이용해 요소를 변환하는 작업이 있다. filtering 컬렉터와 같은 이유로 Collectors 클래스는 매핑 함수와 각 항목에 적용한 함수를 모으는 데 사용하는 또 다른 컬렉터를 인수로 받는 mapping 메서드를 제공한다

Map<DishType, List<String>> dishByName = menu.stream()
              .collect(groupingBy(Dish::getDishType, mapping(Dish::getName, toList())));

컬렉터를 사용하면 일반 맵이 아닌 flatMap 변환을 수행할 수 있다.

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는 일반적인 분류 함수와 컬렉터를 인수로 받는다.

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 컬렉터를 전달해 메뉴에서 요리의 수를 종류별로 계산 가능하다.

Map<DishType, Long> dishTypeLongMap = menu.stream()
              .collect(groupingBy(Dish::getDishType, counting()));

분류 함수 한개의 인수를 받는 groupingBy(f)는 groupingBy(f, toList())의 축약형이다. 가장 높은 칼로리를 가지는 메뉴도 구현 가능하다.

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을 활용하는 것이다.

Map<DishType, Dish> dishTypeDishMap = menu.stream()
              .collect(groupingBy(Dish::getDishType, collectingAndThen(maxBy(Comparator.comparingInt(Dish::getCalories)), Optional::get)));

groupingBy와 함께 사용하는 다른 컬렉터 예제

일반적으로 스트림에서 같은 그룹으로 분류된 모든 요소에 리듀싱 작업을 수행할 때는 팩토리 메서드 groupingBy에 두 번째 인수로 전달한 컬렉터를 이용한다. 예를들어 모든 요리의 칼로리합을 구할때는 아래와 같이 사용한다.

Map<DishType, Integer> dishTypeIntegerMap = menu.stream()
              .collect(groupingBy(Dish::getDishType, summingInt(Dish::getCalories)));

4. 분할

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

Map<Boolean, List<Dish>> partitionedMenu = menu.stream()
                .collect(partitioningBy(Dish::isVegetarian)); <- 분할 함수
// filter를 사용할 수도 있다.
List<Dish> vegetarianDishes = menu.stream()
                .filter(Dish::isVegetarian)
                .collect(toList()); 

분할의 장점

분할 함수를 사용하면 참, 거짓 두가지 요소의 스트림 리스트를 모두 유지할 수 있다는것이 장점이다. 컬렉터를 두번째 인수로 전달할 수 있는 오버로드된 partioningBy 메서드도 존재한다.

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)));
profile
7년간 마케터로 일했고, 현재는 헤렌에서 백엔드 개발자로 일하고 있습니다. 고객 가치를 설계하는 개발자를 지향하며, 개발, 독서, 글쓰기를 좋아합니다. 업이 심오한 놀이이길 바라는 덕업일치 주의자입니다.

0개의 댓글