ABOUT ME

-

Today
-
Yesterday
-
Total
-
  • CHAP6. 스트림으로 데이터 수집
    book/모던 자바 인 액션 2020. 8. 25. 19:24

    6.1 컬렉터란 무엇인가?

    이전 예제에서는 collect 메서드로 Collector 인터페이스 구현을 전달했다.

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

    toList를 사용해서 리스트로 만들 수도 있고 groupingBy를 이용할 수도 있다.

     

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

    collect로 결과를 수집하는 과정을 간단하면서도 유연한 방식으로 정의할 수 있다는 점이 컬렉터의 최대 강점이다.

    스트림에 collect를 호출하면 스트림의 요소에 리듀싱 연산이 수행된다.

     

    Collector 인터페이스의 메서드를 어떻게 구현하느냐에 따라서 스트림에 어떤 리듀싱 연산을 수행할지 결정된다.

    Collectors 유틸리티 클래스는 자주 사용하는 컬렉터 인스턴스를 손쉽게 생성할 수 있는 정적 팩토리 메서드를 제공한다.

    대표적으로는 toList, toSet 등이 있다.

     

    6.1.2 미리 정의된 컬렉터

    Collectors에서 제공하는 메서드의 기능은 크게 세 가지로 구분할 수 있다.

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

     

    6.2 리듀싱과 요약

    Collectors에서 제공하는 정적 메서드들은 스트림의 항목을 컬렉션으로 재구성하는 최종 연산으로 분류가 되고 리듀스를 하고 만들어진다.

    Collectors에서 제공하는 정적 메서드들 중에 하나는 counting()이다.

    counting()은 요소의 갯수를 세어 반환해주는 메서드이다.

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

    아래와 같이 불필요한 과정을 생략할 수도 있다.

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

     

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

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

    두 컬렉터는 스트림의 요소를 비교하는 데 사용할 Comparator를 인수로 받는다.

    Comparator<Dish> dishCaloriesComparator = 
        Comparator.comparingInt(Dish::getCalories);
        
    Optional<Dish> mostCalorieDish = 
        mene.stream()
            .collect(maxBy(dishCaloriesComparator));

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

    이러한 연산을 요약 연산이라고 부른다.

     

    6.2.2 요약 연산

    Collectors 클래스는 summingInt라는 특별한 요약 팩토리 메서드를 제공한다.

    summingInt는 객체를 int로 매핑하는 함수를 인수로 받고 인수로 전달된 함수는 객체를 int로 매핑한 컬렉터를 반환한다.

    그리고 summingInt가 collect 메서드로 전달되면 요약 작업을 수행한다.

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

    summingInt 뿐만 아니라 long또는 double 형식으로 데이터를 요약하고, 평균값을 구하는 연산도 제공하고 있다.

    • summingInt
    • summingLong
    • summingDouble
    • averagingInt
    • averagingLong
    • averagingDouble
    double avgCalories = menu.stream().collect(averagingInt(Dish::getCalories));

     

    종종 위의 메서들 중 두 개 이상의 연산을 한 번에 수행해야 할 때도 있는데 이때는 summarizingInt가 반환하는 컬렉터를 사용할 수 있다.

    • summarizingInt
    • summarizingDouble
    • summarizingLong
    IntSummaryStatistics menuStatistics = menu.stream().collect(summariaingInt(Dish::getCalories));

    위의 코드는 하나의 요약 연산으로 메뉴에 있는 요소 수, 요리의 칼로리 합계, 평균, 최댓값, 최솟값 등을 계산하는 코드다.

    위 코드를 실행하면 IntSummaryStatistics 클래스로 모든 정보가 수집된다.

    IntSummaryStatistics{count=9, sum=4300, min=120, average=477.777778, max=800}

     

     

    6.2.3 문자열 연결

    Collectors에서 제공하는 joining 메서드를 이용하면 스트림의 각 객체에 toString 메서드를 호출해서 추출한 모든 문자열을 하나의 문자열로 연결해서 반환한다.

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

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

    만약 위의 예제에서 Dish클래스가 요리명을 반환하는 toString 메서드를 포함하고 있다면

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

    처럼 이름을 추출하는 부분을 생략할 수 있다.

     

    위의 코드의 결과물은 해석할 수가 없다. 왜냐하면 문자열들 사이에 구분할 수 있는 구분자가 없기 때문이다.

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

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

     

     

    6.2.4 범용 리듀싱 요약 연산

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

    즉, 범용 Collectors.reducing으로도 구현할 수 있다.

    이전까지 범용 팩토리 메서드 대신 특화된 컬렉터를 사용한 이유는 편의성과 가독성 때문이다.

    reducing은 세 개의 인자를 받는다.

    • 첫 번째 인자는 리듀싱 연산의 시작값이거나 스트림에 인수가 없을 때는 반환값이다.
    • 두 번째 인자는 각 요소에 적용할 변환 함수이다.
    • 세 번째 인자는 같은 종류의 두 항목을 하나의 값으로 더하는 BinaryOperator이다.

    한 개의 인자를 가진 reducing 메서드도 있다.

    이 경우 첫 번째 인자가 스트림 요소의 첫 번째가 되고, 두 번째 인자는 자신을 그대로 반환하는 항등 함수가 된다.

    한 개의 인자를 갖는 reducing은 Optional<T> 객체로 반환한다.

    int totalCalories = menu.stream().collect(Collectors.reducing(
            0, // 초기값
            Dish::getCalories, // 변환 함수
            Integer::sum // 합계 함수
    ));

     

     

    6.3 그룹화

    데이터 집합을 하나 이상의 특성으로 분류해서 그룹화하는 경우가 있다.

    Java8의 함수형을 이용하면 가독성 있는 한 줄의 코드로 그룹화를 구현할 수 있다.

    Collectors에서 제공하는 groupingBy를 통해 그룹화를 할 수 있다.

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

    groupingBy 메서드를 기준으로 스트림이 그룹화되므로 이를 분류 함수라고 부른다.

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

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

    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 그룹화된 요소 조작

    요소를 그룹화한 다음에는 각 결과 그룹의 요소를 조작하는 연산이 필요하다.

    이를 위해서 Collectors의 groupingBy는 추가적으로 Collector 인자를 받을 수 있다.

    첫 번째 인자를 통해 그룹화가 진행이 된 후 두 번째 인자인 Collector를 적용하게 된다.

    Map<Dish.Type, List<Dish>> caloricDishesByType =
            menu.stream().collect(groupingBy(Dish::getType,
                                  filtering(dish -> dish.getCalories() > 500, Collectors.toList())));

    Collectors는 매핑 함수와 각 항목에 적용한 함수를 모으는 데 사용하는 또 다른 컬렉터를 인수로 받는 mapping 메서드를 제공한다.

    Map<Dish.Type, List<String>> dishNamesByType =
            menu.stream().collect(groupingBy(Dish::getType,mapping(Dish::getName, Collectors.toList())));

    이전 예제와 달리 위 코드의 결과는 맵의 각 그룹은 요리가 아니라 문자열 리스트다.

    groupingBy와 연계해 세 번째 컬렉터를 사용해서 일반 맵이 아닌 flatMap 변환을 수행할 수 있다.

    flatMapping을 사용하면 각 형식의 요리의 태그를 간편하게 추출할 수 있다.

    Map<Dish.Type, Set<String>> dishNamesByType =
            menu.stream()
                .collect(groupingBy(Dish::getType,
                         flatMapping(dish -> dishTags.get(dish.getName()).stream(), Collectors.toSet())));

     

    6.3.2 다수준 그룹화

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

    groupingBy는 일반적인 분류 함수와 컬렉터를 인수로 받는다.

    즉, 바깥쪽 groupingBy 메서드에 스트림의 항목을 분류할 두 번째 기준을 정의하는 내부 groupingBy를 전달해서 두 수준으로 스트림의 항목을 그룹화할 수 있다.

    무슨 말인지는 예제로 살펴보자.

    Map<Dish.Type, Map<CaloricLevel, List<Dish>>> dishesByType =
            menu.stream().collect(groupingBy(Dish::getType, //바깥쪽 groupingBy
                                      groupingBy(dish -> { // 안쪽 groupingBy
                                          if (dish.getCalories() <= 400) {
                                              return CaloricLevel.DIET;
                                          } else if (dish.getCalories() <= 700) {
                                              return CaloricLevel.NORMAL;
                                          } else {
                                              return CaloricLevel.FAT;
                                          }
                                      }
                                  )
            ));
    
    // 결과
    {
        MEAT = {
            DIET = [chicken],
            NORMAL = [beef],
            FAT = [pork]
        }, 
        FISH = {
            DIET=[prawns],
            NORMAL=[salmon] 
        }, 
        OTHER = {
            DIET=[rice, season fruit],
            NORMAL=[french fries, pizza]
        }
    }

    n 수준 그룹화의 결과는 n 수준 트리 구조로 표현되는 n 수준 맵이 된다.

     

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

    groupingBy의 두 번째 인자로 Collector의 형식은 제한이 없다.

    counting() 메서드로 그룹별로 요소의 개수를 수집할 수도 있고 maxBy로 그룹별로 주어진 기준의 최댓값을 가진 요소를 수집할 수 있다.

    Map<Dish.Type, Long> typesCount = menu.stream().collect(groupingBy(Dish::getType, counting()));
    
    
    //결과
    {MEAT = 3, FISH = 2, OTHER = 4}
    Map<Dish.Type, Optional<Dish>> mostCaloricByType = 
        menu.stream()
            .collect(groupingBy(Dish::getType,
                                maxBy(comparingInt(Dish::getCalories))));
                                
                                
    //결과
    {FISH = Optional[salmon], OTHER = Optional[pizza], MEAT = Optional[pork]}

    maxBy의 경우 반환값이 Optional로 감싼 형태가 된다.

    Collectors의 collectingAndThen 으로 컬렉터가 반환한 결과를 Optional을 삭제하여 다른 형식으로 활용할 수 있다.

    Map<Dish.Type, Optional<Dish>> mostCaloricByType = 
        menu.stream()
            .collect(groupingBy(Dish::getType,
                     collectingAndThen(
                         maxBy(comparingInt(Dish::getCalories)),
                     Optional::get)));
                     
                                
    //결과
    {FISH = salmon, OTHER = pizza, MEAT = pork}

     

    6.4 분할

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

    분할 함수는 Boolean을 반환하므로 맵의 키 형식은 Boolean이다.

    결과적으로 그룹화 맵은 최대 두 개의 그룹으로 분류된다. 조건에 만족하는지 만족하지 않는지.

    아래는 채식 요리와 채식이 아닌 요리를 분류하는 예제이다.

    Map<Boolean, List<Dish>> partitionedMenu = menu.stream()
                                                   .collect(partitioningBy(Dish::isVegetarian)); // 분할 함수
                                                   
    
    //결과
    {false = [pork, beef, chicken, prawns, salmon],
    true = [french fries, rice, season fruit, pizza]}
    
    
    //채식요리 찾기
    List<Dish> vegetarianDishes = partitionedMenu.get(true);

     

    6.4.1 분할의 장점

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

     

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

    정수 n을 인수로 받아서 2에서 n까지의 자연수를 소수와 비소수로 나누는 예제이다.

    public boolean isPrime(int candidate){
        return IntStream.range(2, candidate) // 2qnxj candidate 미만 사이의 자연수 생성
                        .noneMatch(i -> candidate % i == 0); // 스트림의 모든 정수로 candidate를 나눌 수 없다면 참 반환
    }
    
    public Map<Boolean, List<Integer>> partitionPrimes(int n){
        return IntStream.rangeClosed(2,n).boxed()
                                         .collect(
                                             partitioningBy(candidate -> isPrime(candidate)));

     

    6.5 Collector 인터페이스

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

    지금까지 toList나 groupingBy 등 많은 컬렉터를 살펴보았다.

    Collector 인터페이스를 직접 구현해서 더 효율적으로 문제를 해결하는 컬렉터를 만들 수도 있다.

     

    우선 Collector 인터페이스의 시그니처와 다섯 개의 메서드 정의는 아래와 같다.

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

    위 코드의 제네릭은 아래와 같다.

    • T는 수집될 스트림 항목
    • A는 누적자, 즉 수집 과정에서 중간 결과를 누적하는 객체의 형식
    • R은 수집 연산 결과 객체의 형식

    예를 들어 Stream<T>의 모든 요소를 List<T>로 수집하는 ToListCollector<T>라는 클래스를 구현하게 되면

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

     

     

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

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

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

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

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

     

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

    accumulator 메서드는 리듀싱 연산을 수행하는 함수를 반환한다.

    스트림에서 n번째 요소를 탐색할 대 두 인수, 즉 누적자와 n번째 요소를 함수에 적용한다.

    반환값은 void 즉, 요소를 탐색하면서 적용하는 함수에 의해 누적자 내부 상태가 바뀌므로 누적자가 어떤 값일지 단정할 수 없다.

    public DiConsumer<List<T>, T> accumulator(){
        return (list, item) -> list.add(item);
    }
    
    public DiConsumer<List<T>, T> accumulator(){
        return List::add;
    }

     

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

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

    finisher 메서드는 항등 함수를 반환한다.

    public Function<List<T>, List<T>> finisher() {
        return Function.identity();
    }

     

    • combiner 메서드 : 두 결과 컨테이너 병합

    combiner 메서드는 리듀싱 연산에서 사용할 함수를 반환한다.

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

    toList()의 combiner는 쉽게 구현할 수 있다.

    2개의 리스트를 이어 붙이기만 하면 되기 때문이다.

    public BinaryOperator<List<T>> combiner() {
        return (list1, list2) -> {
            list1.addAll(list2);
            return list1;
        }
    }

     

    • characteristics 메서드

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

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

    Characteristics는 enum 클래스로 3개의 값을 가지고 있다.

    • UNORDERED : 리듀싱 결과는 스트림 요소의 방문 순서나 누적 순서에 영향을 받지 않는다.
    • CONCURRENT : 다중 스레드에서 accumulator 함수를 동시에 호출할 수 있으며 이 컬렉터는 스트림의 병렬 리듀싱을 수행할 수 있다. 컬렉터의 플래그에 UNORDERED를 함께 설정하지 않았다면 데이터 소스가 정렬되어 있지 않은 상황에서만 병렬 리듀싱을 수행할 수 있다.
    • IDENTITY_FINISH : finish 메서드가 반환하는 함수는 단순히 identity를 적용할 뿐이므로 이를 생략할 수 있다.

     

    toList의 경우에는 accumulator로 List::add를 하는데 병렬로 처리할 때 순서가 바뀔 수도 있기 때문에 IDENTITY_FINISH이다.

    'book > 모던 자바 인 액션' 카테고리의 다른 글

    CHAP7. 병렬 데이터 처리와 성능  (0) 2020.08.26
    CHAP5. 스트림 활용  (0) 2020.08.23
    CHAP4. 스트림 소개  (0) 2020.08.11
    CHAP3. 람다 표현식  (0) 2020.07.28
    CHAP2. 동작 파라미터화 코드 전달하기  (0) 2020.07.24
Designed by Tistory.