12 min read

모던 자바 인 액션 - Chapter 6 <스트림으로 데이터 수집> 1

지금까지 배운것으로 스트림을 한 마디로 요약하면 데이터 집합을 효율적이고 Lazy하게 처리하는 반복자라고 알 수 있습니다.또한 스트림의 연산들은 filter 또는 map 같은 중간 연산과 count,findFirst,forEach,reduce 등의 최종 연산으로 구분할 수 있습니다.중간 연산과 최종 연산은 다음과 같이 정리 가능합니다.

  • 중간 연산 : 한 스트림을 다른 스트림으로 변환하는 연산으로 여러 중간 연산들을 연결할 수 있다.또한 스트림 내부의 요소를 소비하지 않는다.
  • 최종 연산 : 최종적으로 들어온 스트림의 내부 요소들을 소비하여 최종 연산 결과를 도출한다.

간단하게 지금까지의 내용을 요약해보았습니다.지금부터는 Collector 인터페이스에 대해 알아보겠습니다.

Collector란

Collector 인터페이스의 구현체는 스트림의 요소를 어떤 식으로 도출할지 결정합니다.지난 챕터에서 저희가 자주 사용했던 Collector의 구현은 Collectors.toList()입니다.toList() 구현은 스트림의 요소를 리스트의 형태로 반환합니다.이후Collector 인터페이스의 구현을 collect() 함수를 통해 전달하여 원하는 방식으로 스트림 요소들의 반환 값을 결정할 수 있습니다.

고급 리듀싱 기능을 수행하는 Collector

컬렉터의 장점은 collect 메서드로 결과를 수집하는 과정을 간단하고 유연한 방식으로 정의할 수 있는 것입니다.구체적으로 스트림에서 collect 메서드를 호출하면 각각의 스트림의 요소에 파라미터로 들어온 리듀싱 연산이 수행됩니다.collect의 인자로 들어간 Collector의 구현체는 리듀싱 연산이라고 이해할 수 있습니다.파라미터로 들어간 Collector는 스트림의 각 요소를 방문하면서 작업을 처리합니다.

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

미리 정의된 Collector

이번 장에서는 미리 정의된 컬렉터,toList(),groupingBy와 같은 Collectors 클래스에서 제공하는 팩토리 메서드의 기능을 알아봅니다. Collectors에서 제공하는 구현체들은 크게 아래의 3가지로 구분 가능합니다.

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

지금부터 위에 구분된 각각의 Collector들에 대해 알아봅시다.

리듀싱과 요약

이전 5장에서 사용한 요리 예제를 통해 Collectors 클래스에서 만들 수 있는 컬렉터 인스턴스가 어떤게 있는지 보겠습니다.

counting

아래와 같이 간단하게 현재 스트림 요소들의 개수를 파악할 수 있습니다.

	@Test
    void counting() {
        Long count = menu.stream().collect(Collectors.counting());
    }

실제로 counting은 위와 같이 사용되지않고 다른 컬렉터와 함께 사용되는 경우가 많습니다.

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

메뉴에서 칼로리가 가장 높은 요리를 찾는다고 가정하겠습니다.각각 아래의 컬렉터 구현체를 사용할 수 있습니다.

  • Collectors.maxBy
  • Collectors.minBy

두 컬렉터는 모두 스트림의 요소를 비교하는데 사용할 Comparator를 인수로 받습니다.아래의 예제를 통해 알아보겠습니다.

	Comparator<Dish> comparator1 = (o1, o2) -> Math.max(o1.getCalories(), o2.getCalories());
    Comparator<Dish> comparator2 = Comparator.comparingInt(Dish::getCalories);

    @Test
    void maxBy() {
        Dish dish1 = menu.stream()
                .collect(Collectors.maxBy(comparator1)).get();
        Dish dish2 = menu.stream()
                .collect(Collectors.maxBy(comparator2)).get();
        assertThat(dish1.getName()).isEqualTo(dish2.getName());
    }

우선 간단하게 comparator 인스턴스를 만들어줍니다.저는 두가지 방식을 통해 만들었습니다.이후 해당 comparator를 maxBy의 인자로 넘겨주면 가장 칼로리가 높은 Dish 인스턴스를 스트림으로부터 얻을 수 있습니다.

요약 연산

Collectors 클래스는 summingInt라는 특별한 요약 팩토리 메서드를 제공합니다.해당 메서드는 객체를 int로 매핑하는 함수를 인수로 받습니다.쉽게 말하면 객체의 field 중int값을 가져오는 함수를 필요로합니다.이렇게 인수로 전달된 메서드는 객체를 int로 매핑한 컬렉터를 반환한다.그리고 summingInt가 collect 메서드로 전달되면 합계 작업을 수행합니다.아래의 예제를 통해 이해할 수 있습니다.

	@Test
    void summing() {
        Integer sum1 = menu.stream()
                .collect(Collectors.summingInt(Dish::getCalories));
        int sum2 = 0;
        for (Dish dish : menu) {
            sum2 += dish.getCalories();
        }
        Assertions.assertThat(sum1).isEqualTo(sum2);
    }

합계 함수뿐만 아니라 평균 및 통계정보까지도 활용할 수 있는 메서드 또한 제공합니다.

  • averagingInt
  • summarizingInt

int 타입 뿐만 아니라 double과 long 타입까지도 지원합니다.

범용 리듀싱 요약 연산

지금까지 예제를 통해 사용한 모든 컬렉터들은 Collectors.reducing을 통해 다른 형태로 정의될 수 있습니다.그럼에도 reducing을 활용한 범용 팩토리 메서드보다 특화된 팩토리 메서드를 사용하는 이유는 사용자가 동작을 이해하기 쉽다는 점 때문입니다.예를 들어 아래의 코드로 메뉴의 모든 칼로리를 구할 수 있습니다.

Integer sumByReducing = menu.stream()
                .collect(Collectors.reducing(0, Dish::getCalories, (a, b) -> a + b));

앞서 배운 reduce() 함수와 비슷한 부분도 보이는 것 같습니다.하지만 Collectors.summingInt에 비하면 코드를 이해하는것이 직관적이지 않다고 생각합니다.

reducing 함수는 아래의 3가지 인자를 필요로 합니다.

  1. 리듀싱 연산의 시작값이거나 스트림에 인수가 없을 때는 반환값(숫자 합계를 계산하는 경우 인수가 없으면 0이 반환값이여야함)
  2. 계산할 대상 타입을 객체로부터 가져오는 메서드,즉 변환함수가 두번째 인자
  3. 같은 종류의 두 항목을 하나의 값으로 더하는 BinaryOperator가 세번째 인자

다음 예제처럼 한개의 인수만을 받아 가장 칼로리가 높은 dish 인스턴스를 찾을 수 있다.

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

한 개의 인수를 갖는 reducing 메서드는 시작값이 없으므로 빈 스트림이 넘어 올 경우 시작값을 설정할 수 없는 문제가 발생합니다.이러한 문제를 해결하기 위해 Optional을 반환해줍니다.

그룹화

데이터 집합을 하나 이상의 특성으로 분류해서 그룹화하는 연산도 DB에서 많이 수행되는 작업입니다.사용하는 메뉴 예제를 한번 그룹화 시켜보겠습니다.예를 들어 고기를 포함하는 그룹,생선을 포함하는 그룹,그 외 나머지 그룹으로 나눠보겠습니다.Collectors의 groupingBy를 이용해서 쉽게 그룹화 할 수 있습니다.

	@Test
    void groupingMenu() {
        Map<Dish.Type, List<Dish>> listMap = menu.stream()
                .collect(Collectors.groupingBy(Dish::getType));
        
        for (Map.Entry<Dish.Type, List<Dish>> entry : listMap.entrySet()) {
            System.out.println("entry.getKey() = " + entry.getKey());
            entry.getValue().stream().map(Dish::getName).forEach(System.out::println);
        }
    }

스트림의 각 요리에서 Dish.Type과 일치하는 모든 요리를 추출하는 함수를 groupingBy 메서드로 전달합니다.이 함수를 기준으로 스트림이 그룹화 되었으며 이러한 역할을 수행하는 groupingBy 메서드를 분류 함수라고 부릅니다.

groupingBy는 반환 타입으로 Map을 리턴합니다.해당 맵은 <그룹화의 기준이 되는 타입, 해당 타입의 인스턴스 요소들>로 key-value 쌍이 구성됩니다.위 예제에서는 키는 요리 종류이고 값은 해당 요리에 포함되는 모든 Dish 인스턴스들입니다.

두번째 예제는 메뉴들을 칼로리를 기준으로 그룹화해보겠습니다.예를 들어 400칼로리 이하이면 diet로 400에서 700칼로리면 normal,그 이상은 fat으로 분류해보겠습니다.

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

        for (Map.Entry<CaloricLevel, List<Dish>> entry : listMap.entrySet()) {
            System.out.println("entry.getKey() = " + entry.getKey());
            entry.getValue().stream().map(Dish::getName).forEach(System.out::println);
        }
    }

위 예제들은 하나의 기준으로 요리들은 그룹화 하는 방법입니다.그러면 두가지 이상의 기준을 통해 요리들을 그룹화하는 법을 알아보겠습니다.

그룹화된 요소 조작

스트림 요소들을 그룹화한 다음에는 각 결과 그룹의 요소를 조작하는 연산이 필요합니다.예를 들어 Dish.Type으로 요리들을 그룹화하는 동시에 그룹화 되는 요소들의 칼로리가 500보다 큰 요리만 그룹화하려면 어떻게 해야할까요? Colletors는 일반적인 분류 함수(여기서는 groupingBy)에 Collector 형식의 두번째 인수를 갖도록 groupingBy 메서드를 오버로딩하여 해결 방법을 제공합니다.쉽게 말하면 Collector의 구현체를 분류함수의 두번째 인자로 사용할 수 있도록 오버로딩하여 한 개 이상의 기준으로 요소들을 그룹화 할 수 있습니다.

	@Test
    void groupingBy500Cal() {
        Map<Dish.Type, List<Dish>> map = menu.stream()
                .collect(Collectors.groupingBy(
                        Dish::getType,
                        Collectors.filtering(
                                dish -> dish.getCalories() > 500,
                                Collectors.toList())));

        for (Map.Entry<Dish.Type, List<Dish>> entry : map.entrySet()) {
            System.out.println("entry.getKey() = " + entry.getKey());
            entry.getValue().stream().map(Dish::getName).forEach(System.out::println);
        }
    }

그룹화된 요소를 조작하는 방법은 또 한가지 있습니다.이는 매핑함수를 이용하여 요소를 변환하는 작업입니다.Collectors 클래스는 매핑 함수와 해당 매핑 함수가 적용된 스트림 요소를 모으는 데 사용하는 mapping 메서드를 제공합니다.예제를 통해 사용해봅시다.

	@Test
    void groupingByWithMapping() {
        Map<Dish.Type, List<String>> collect = menu.stream()
                .collect(Collectors.groupingBy(
                        Dish::getType,
                        Collectors.mapping(
                                Dish::getName,
                                Collectors.toList()
                        )
                ));

        for (Map.Entry<Dish.Type, List<String>> entry : collect.entrySet()) {
            System.out.println("entry.getKey() = " + entry.getKey());
            entry.getValue().stream().forEach(System.out::println);
        }
    }

스트림의 결과로 나온 map을 보면 value가 List<String>으로 mapping 함수가 스트림의 요소들에 적용된것을 확인할 수 있습니다.

다수준 그룹화

두 가지 인수를 받는 groupingBy를 이용해서 다수준의 그룹화를 구현 할 수 있습니다.앞서 소개된 groupingBy 또한 두 가지 인수를 받습니다.다만 이번 절의 groupingBy 메서드와의 차이점은 두 번째인자로 groupingBy가 메서드가 사용된다는 점입니다.아래 예제를 통해 다수준이 무엇인지 인자로 groupingBy가 들어온다는것이 무엇인지 알아봅시다.

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

        for (Map.Entry<Dish.Type, Map<CaloricLevel, List<Dish>>> entry : listMap.entrySet()) {
            System.out.println("entry.getKey() = " + entry.getKey());
            for (Map.Entry<CaloricLevel, List<Dish>> entry1 : entry.getValue().entrySet()) {
                System.out.println("entry1.getKey() = " + entry1.getKey());
                entry1.getValue().stream().map(Dish::getName).forEach(System.out::println);
            }
            System.out.println("==========================");
        }
    }

위 테스트 코드를 실행 시켜보면 먼저 Dish.Type에 의해서 Map이 생성됨을 확인 할 수 있고,그리고 해당 Map 내에서 칼로리를 기준으로 DIET,NORMAL,FAT이라는 key로 가지는 새로운 Map이 생깁니다.즉,두 가지 기준에 의해 스트림의 요소들을 계층적으로 분류시켜주고 있습니다.다수준이라고 함은 아마 groupingBy의 기준에 의해 계층적으로 요소들을 분류하는 것으로 이해할 수 있습니다.

결론적으로 n수준 그룹화의 결과는 n수준의 트리구조로 표현되는 n수준의 맵이 됩니다.

이해를 돕기 위해 위 예제의 기준을 reverse한 예제도 첨부합니다.즉,칼로리 기준으로 먼저 그룹화를 하고 생성된 Map 내에서 Dish.TYPE에 의해 그룹화하는 예제입니다.

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

        for (Map.Entry<CaloricLevel, Map<Dish.Type, List<Dish>>> entry : reverse.entrySet()) {
            System.out.println("entry.getKey() = " + entry.getKey());
            for (Map.Entry<Dish.Type, List<Dish>> entry1 : entry.getValue().entrySet()) {
                System.out.println("entry1.getKey() = " + entry1.getKey());
                entry1.getValue().stream().map(Dish::getName).forEach(System.out::println);
            }
            System.out.println("==========================");
        }
    }

다음 포스팅으로 Chapter6 내용 이어가겠습니다.