Chap06. 스트림으로 데이터 수집

728x90

중간연산은 스트림 파이프라인을 구성하며, 스트림의 요소를 소비하지 않는다. 반면 최종 연산은 스트림의 요소를 소비해서 최종 결과를 도출한다. reduce가 그랬던 것처럼 collect 역시 다양한 요소 누적 방식을 인수로 받아서 스트림을 최종 결과로 도출하는 리듀싱 연산을 수행할 수 있다. 다양한 요소 누적 방식은 Collector 인터페이스에 정의되어 있다. 지금부터 컬렉션, 컬렉터, collect를 헷갈리지 않도록 주의해야한다.

컬렉터란?

함수형 프로그래밍에서는 '무엇'을 원하는지 직접 명시할 수 있어서 어떤 방법으로 이를 얻을지는 신경 쓸 필요가 없다. 이전 예제에서 collect 메서드로 Collector 인터페이스 구현을 전달했다. Collector 인터페이스 구현은 스트림의 요소를 어떤 식으로 도출할지 지정한다.

다수준으로 그룹화를 수행할 때 명령형 프로그래밍과 함수형 프로그래밍의 차이점이 더욱 두드러진다. 명령형 코드에서는 문제를 해결하는 과정에서 다중 루프와 조건문을 추가하여 가독성과 유지보수성이 크게 떨어진다. 반면 함수형 프로그래밍에서는 필요한 컬렉터를 쉽게 추가할 수 있다.

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

함수형 API의 또 다른 장점으로 높은 수준의 조합성과 재사용성을 꼽을 수 있다. collect로 결과를 수집하는 과정을 간단하면서도 유연한 방식으로 정의할 수 있다는 점이 컬렉터의 최대 강점이다. 스트림에 collect를 호출하면 스트림의 요소에 리듀싱 연산이 수행된다. collect에서는 리듀싱 연산을 이용해서 스트림의 각 요소를 방문하면서 컬렉터가 작업을 처리한다. 보통 함수를 요소로 변환할 때는 컬렉터를 적용하며 최종 결과를 저장하는 자료구조에 값을 누적한다. Collector 인터페이스의 메서드를 어떻게 구현하느냐에 따라, 스트림에 어떤 리듀싱 연산을 수행할지 결정된다. Collectors 유티리티 클래스는 자주 사용하는 컬렉터 인스턴스를 손쉽게 생성할 수 있는 정적 팩토리 메서드를 제공한다. (ex. toList)

미리 정의된 컬렉터

groupingBy와 같이 Collectors 클래스에서 제공하는 팩토리 메서드의 기능을 설명한다. Collectors에서 제공하는 메서드의 기능은 크게 세 가지로 구분될 수 있다.

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

리듀싱과 요약

  • counting() : 개수를 계산한다. (collect를 통해 호출하지 않고 바로 최종연산에서 바로 count() 호출로도 가능하다)

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

Collectors.maxBy, Collectors.minBy 두 개의 메서드를 이용해서 스트림의 최댓값과 최솟값을 계산할 수 있다. 두 컬렉터는 스트림의 요소를 비교하는 데 사용할 Comparator를 인수로 받는다.

Comparator<Dish> dishCaloriesComparator = Comparator.comparingInt(Dish::getCalories);

Optional<Dish> mostCalorieDish = menu.stream()
                                     .collect(maxBy(dishCaloriesComparator));

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

요약 연산

Collectors 클래스는 Collectors.summingInt라는 특별한 요약 팩토리 메서드를 제공한다. summingInt는 객체를 int로 매핑하는 함수를 인수로 받는다. summingInt의 인수로 전달된 함수는 객체를 int로 매핑한 컬렉터를 반환한다. 그리고 summingInt가 collect 메서드로 전달되면 요약 작업을 수행한다.

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

여기서 초기값은 자동으로 '0'으로 설정되고 누적자에 칼로리를 더한다.

Collectros.summingLong, Collectors.summingDouble 메서드도 같은 방식으로 각각 long, double 형식의 데이터로 요약한다.

단순 합계 외에도 평균값 계산 등의 연산도 제공된다. Collectors.averagingInt, averagingLong, averagingDouble 등으로 다양한 형식으로 이루어진 평균을 계산할 수 있다.

또한, summarazingInt로 요소 수, 요소 합계, 평균, 최댓값, 최솟값 등을 반환하는 콜렉터를 얻을 수도 있다.

IntSummaryStatistics menuStatistics = menu.stream()
                                          .collect(summarizingInt(Dish::getCalories));

IntSummaryStatistics는 아래의 정보를 가지고 있다.

IntSummaryStatistics{count=?, sum=?, min=?, average=?, max=?}

마찬가지로 int뿐 아니라 long이나 double에 대응하는 summarizingLong, summarizingDouble 메서드도 있다.

문자열 연결

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

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

오버로드 된 joining 팩토리 메서드도 있다. joinging의 파라미터로 받은 문자열을 각 사이에 추가해준다.

그룹화

데이터 집합을 하나 이상의 특성으로 분류해서 그룹화하는 연산도 데이터베이스에서 많이 수행되는 작업이다. 트랜잭션 통화 그룹화 예제에서 확인했듯이 명령형으로 그룹화를 구현하려면 까다롭고, 할일이 많으면, 에러도 많이 발생한다. 하지만 자바 8의 함수형을 이용하면 가독성 있는 한 줄의 코드로 그룹화를 구현할 수 있다.

Map<Dish.Type, List<Dish>> dishesByType = menu.stream()
                                              .collect(groupingBy(Dish::getType));

groupingBy와 같은 함수를 분류 함수라고 부른다.

단순한 속성 접근자 대신 더 복잡한 분류 기준이 필요한 상황에서는 메서드 참조를 분류 함수로 사용할 수 없다. 따라서 다음 예제에서 보여주는 것 처럼 메서드 참조 대신 람다 표현식으로 피룡한 로직을 구현할 수 있다.


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;
          }));

그룹화된 요소 조작

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

그룹핑을 할 때, filter로 인해 요소가 필터링되면서 특정 key의 요소가 하나도 없어지게 되면, key값 까지 없어지는 경우가 있다. 이런 경우에는 Collector 형식의 두 번째 인수를 갖도록 groupingBy 팩토리 메서드를 오버로드해서 이 문제를 해결한다.
중간연산자의 필터 프레디케이트 조건을 Collector 안으로 이동함으로써 문제를 해결할 수 있다.

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

filtering 메서드는 Collectors 클래스의 또 다른 정적 팩토리 메서드로 프레디케이트를 인수로 받는다. 이 프레디케이트로 각 그룹의 요소와 필터링 된 요소를 재그룹화 한다.

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

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

다수준 그룹화

두 인수를 받는 팩토리 메서드 Collectors.groupingBy를 이용해서 항목을 다수준으로 그룹화할 수 있다. Collectors.groupingBy는 일반적인 분류 함수와 컬렉터를 인수로 받는다. 즉 바깥쪽 groupingBy 메서드에 스트림의 항목을 분류할 두 번째 기준을 정의하는 내부 groupingBy를 전달해서 두 수준으로 스트림의 항목을 그룹화할 수 있다.

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

최종적으로 두 수준의 맵은 첫 번째 키와 두 번째 키의 기준에 부합하는 요소 리스트를 값으로 갖는다. 다수준 그룹화 연산은 다양한 수준으로 확장할 수 있다. 즉 n수준 그룹화의 결과는 n수준 트리 구조로 표현되는 n수준 맵이 된다.

서브그룹으로 데이터 수집

마지마그 그룹화 연산에서 맵의 모든 값을 Optional로 감쌀 필요가 없으므로 Optional을 삭제할 수 있다. CollectorAndThen으로 컬렉터라 반환한 결과를 다른 형식으로 활용할 수 있다.

Map<Dish.Type, Dish> mostCaloricByType = 
    menu.stream()
        .collect(groupingBy(Dish::getType, 
            maxBy(comparingInt(Dish::getCalories)),
        Optional::get)));

팩토리 메서드 collectAndThen은 적용할 컬렉터와 변환 함수를 인수로 받아 다른 컬렉터를 반환한다. 반환되는 컬렉터는 기존 컬렉터의 래퍼 역할을 하며 collect의 마지막 과정에서 변환 함수로 자신이 반환하는 값을 매핑한다. (리듀싱 컬렉터는 절대 Optional.empty()를 반환하지 않으므로 안전한 코드다)

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

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

Map<Dish.Type, Integer> totalCaloriesByType = 
    menu.stream().collect(groupingBy(Dish::getType,
        summingInt(Dish::getCalories)));

분할

partitioningBy

분할은 분할 함수라 불리는 프레디케이트를 분류 함수로 사용하는 특수한 그룹화 기능이다. 분할 함수는 불리언을 반환하므로 맵의 키 형식은 Boolean이다. 결과적으로 그룹화 맵은 최대 두 개의 그룹으로 분류된다.

Map<Boolean, List<Dish>> partitionMenu = 
    menu.stream().collect(partitioningBy(Dish::isVegetarian));

분할의 장점

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

Collector 인터페이스

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

Collector 인터페이스

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

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

  • supplier 메서드: 새로운 결과 컨테이너 만들기
  • accumulator 메서드: 결과 컨테이너에 요소 추가하기
  • finisher 메서드: 최종 변환값을 결과 컨테이너로 적용하기
  • combiner 메서드: 두 결과 컨테이너 병합
  • Characteristics 메서드
    • UNORDERED : 리듀싱 결과는 스트림 요소의 방문 순서나 누적 순서에 영향을 받지 않는다.
    • CONCURRENT : 다중 스레드에서 accumulator 함수를 동시에 호출할 수 있으며 이 컬렉터는 스트림의 병렬 리듀싱을 수행할 수 있다.
    • IDENTITY_FINISH : 리듀싱 과정의 최종 결과로 누적자 객체를 바로 사용할 수 있다. 또한 누적자 A를 결과 R로 안전하게 형변환할 수 있다.

컬렉터 구현을 만들지 않고도 커스텀 수집 수행하기

IDENTITY_FINISH 수집 연산에서는 Collector 인터페이스를 완전히 새로 구현하지 않고도 같은 결과를 얻을 수 있다. Stream은 세 함수를 인수로 받는 colect 메서드를 오버로드하며 각각의 메서드는 Collector 인터페이스의 메서드가 반환하는 함수와 같은 기능을 수행한다.

List<Dish> dishes = menuStream.collect(
        ArrayList::new,
        List::add,
        List::addAll);
728x90

'개발서적 > 모던 자바 인 액션' 카테고리의 다른 글

Chap 12 - 새로운 날짜와 시간 API  (0) 2021.03.19
Chap09. 리팩터링, 테스팅, 디버깅  (0) 2021.03.14
Chap05. 스트림 활용  (0) 2021.03.05
Chap04. 스트림 소개  (0) 2021.02.26
Chap03. 람다 표현식  (0) 2021.02.25