[6장] 스트림으로 데이터 수집

2023. 9. 1. 23:50

라울 가브리엘 우르마 등 3명, 모던 자바 인 액션, 한빛미디어 표지

 

[5장]에서는 스트림 연산에 대해 알아보았다. 스트림 연산을 복습해보자면 아래와 같다.

  • 스트림 연산은 중간 연산과 최종 연산으로 구분할 수 있다.
  • 중간 연산은 스트림을 다른 스트림으로 변환하는 연산이다. 여러 연산을 연결할 수 있다.
  • 중간 연산은 스트림 파이프라인을 구성하며, 스트림 요소를 소비하지 않는다.
  • 최종 연산은 스트림의 요소를 소비해 최종 결과를 도출한다.
  • 최종 연산은 스트림 파이프라인을 최적화하면서 계산 과정을 짧게 생략하기도 한다.

[6장]에서는 컬렉터와 리듀싱 요약, 데이터 그룹화와 분할, 커스텀 컬렉터 개발에 관해 알아볼 것이다.

 

Collector(컬렉터)란 무엇일까?


컬렉터는 자바 스트림에서 쓰이는 중요한 인터페이스다. 컬렉터 인터페이스는 스트림의 요소들을 수집하고 그룹화하고 집계하는데 사용한다. 즉 컬렉터 인터페이스 구현은 스트림의 요소를 어떤 식으로 도출할지 지정한다.

다수준(multilevel)으로 그룹화를 수행할 때 함수형 프로그래밍에서는에서는 필요한 컬렉터를 쉽게 추가할 수 있다. 이는 가독성, 유지보수성을 높여주기도 한다. 또 높은 수준의 조합성과 재사용성 역시 함수형 프로그래밍, 함수형 API의 장점으로 들 수 있다.

컬렉터에서는 collect로 결과를 수집하는 과정을 간단하면서 유연한 방식으로 정의할 수 있다. 스트림에서 collect를 호출하면 스트림의 요소에 컬렉터로 파라미터화된 리듀싱 연산이 수행된다.

[그림 1] 예제 도식화, 통화별로 트랜잭션을 그룹화하는 리듀싱 연산

Map<Currency, List<Transaction>> transactionsByCurrencies = 
    transactions.stream().collect(groupingBy(Transaction::getCurrency));

그림 1은 위의 코드를 도식화한 것이다.

 

미리 정의된 컬렉터들

자바에는 이미 많은 내장 컬렉터 구현체를 제공한다.  Collectors 클래스에서 제공하는 기능은 다음과 같이 크게 세 가지로 구분할 수 있다.

  • 스트림 요소를 하나의 값으로 리듀스하고 요약 (트랜잭션 리스트에서 트랜잭션 총합을 찾는 등 다양한 계산 수행)
  • 요소 그룹화 (다수준으로 그룹화 혹은 각각의 결과 서브그룹에 추가로 리듀싱 연산을 적용할 수 있게 함)
  • 요소 분할 (한 개의 인수를 받아 불리언을 반환하는 Predicate를 그룹화 함수로 사용)

위 링크되어 있는 Collectors를 클릭하면 여러가지 기능을 확인해 볼 수 있다. 

 

리듀싱과 요약


컬렉터 인스턴스로 많은 일을 할 수 있다. 다양한 메서드로 스트림의 항목을 컬렉션으로 재구성할 수 있다. 즉, 컬렉터로 스트림의 모든 항목을 하나의 결과로 합칠 수 있다.

// Collectors.countings() 예
import static java.util.stream.Collectors.*;

long howManyDishes = menu.stream().collect(counting());
// Colloctors.maxBy() 예
// 칼로리로 요리를 비교한 후 칼로리가 가장 높은 요리를 찾는다.
import java.util.*;
import static java.util.stream.Collectors.*;

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

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

Optional로 받는 이유는 반환되는 요리가 없을 수도 있기 때문이다. 11장에서 자세히 설명하므로 지금은 그냥 넘어가자.

 

요약 연산

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

도식화하면 reduce()와 동일한 기능을 하는 것을 알 수 있다. 리듀스 직전에 .collect(summingInt(Dish::getCalories)) 와 같이 메서드 참조 후 리듀스 연산을 실행하는 것이 차이점이다.

 

문자열 연결

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

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

// toString이 있다면 map() 생략 가능
String shortMenu = menu.stream().collect(joining());

// joining()에 구분 문자열 넣을 수 있음
String shortMenu = menu.stream().collect(joining(", "));
// pork, beef, ... , prawns, salmon

joining 메서드는 내부적으로 StringBuilder를 이용해서 문자열을 하나로 만든다. String을 반환하는 toString 메서드를 포함하고 있다면 map으로 각 요리의 이름을 추출하는 과정을 생략할 수 있다.

 

범용 리듀싱 요약 연산

앞서 본 모든 컬렉터는 reducing 팩토리 메서드로도 정의할 수 있다. 참고로 summingInt, averagingInt 등 특화된 컬렉터는 가독성 면에서나 편의성 면에서 함수형 프로그래밍의 장점을 살릴 수 있기 때문에 사용한다.

// summingInt와 같음
int totalCal = menu.stream()
                    .collect(reducing(0, Dish::getCalories, (i, j) -> i + j));
                    
// 이렇게도 가능
int totalCal = menu.stream()
                    .collect(reducing(0,      // 초깃값
                        Dish::getCalories,    // 변환 함수
                        Integer::sum));       // 합계 함수

reducing은 인수 세 개를 받는다.

  • 첫 번째 인수는 리듀싱 연산의 시작값 or 스트림에 인수가 없을 경우 반환값이다. (숫자 합계에서는 인수가 없을 때 반환값으로 0이 적합하다.)
  • 두 번째 인수는 변환 함수이다. (예시에서는 getCalories 메서드 참조로 넣음)
  • 세 번째 인수는 같은 종류의 두 항목을 하나의 값으로 더하는 BinaryOperator이다. (에시에선 int i, j 두 개 사용)

이 외에도 한 개의 인수를 가진 reducing 버전을 이용해 가장 칼로리가 높은 요리를 찾는 방법도 있다.

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

한 개의 인수를 갖는 reducing 팩토리 메서드는 시작값이 없으므로 빈 스트림이 넘겨진다면 시작값이 설정되지 않는다. 따라서 한 개의 인수를 갖는 reducing 팩토리 메서드는 Optional 객체를 반환한다.

참고: collect와 reduce

collect 메서드는 도출하려는 결과를 누적하는 컨테이너를 바꾸도록 설계된 메서드인 반면 reduce는 두 값을 하나로 도출하는 불변형 연산이라는 점에서 의미론적인 문제가 일어난다. 의미론적으로 reduce를 잘못 사용하면서 실용성 문제도 발생한다. 이는 7장에서 자세히 알아보자.

여러 스레드가 동시에 같은 구조체를 고치면(toList()를 사용하는 collect대신 reduce를 사용했다는 가정하에) 리스트 자체가 망가져버리므로 리듀싱 연산을 병렬로 수행할 수 없다는 점도 문제로 볼 수 있다. 이 역시 7장에서 자세히 알아보겠지만 가변 컨테이너 관련 작업이면서 병렬성을 확보하려면 collect메서드로 리듀싱 연산을 구현하는 것이 바람직하다.

 

그룹화


데이터 집합을 하나 이상의 특성으로 분류해서 그룹화 하는 연산도 DB에서 많이 수행되는 작업이다. 예를 들어 고기를 포함하는 그룹, 생선을 포함하는 그룹, 나머지 그룹으로 메뉴를 그룹화 할 수 있다. 다음은 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]}

스트림의 각 요리에서 Dish.Type과 일치하는 모든 요리를 추출하는 함수를 groupingBy 메서드로 전달했다. 이 함수를 기준으로 스트림이 그룹화되므로 이를 분류 함수(Classification Function)라고 부른다. 

[그림 2] 시각적으로 나타낸 그룹화

단순한 속성 접근자 대신 더 복잡한 분류 기준이 필요한 상황에서는 메서드 참조를 분류 함수로 사용할 수 없다. 예를 들어 400칼로리 이하를 'diet'로, 400~700칼로리를 'normal'로, 700칼로리 초과를 'fat'요리로 분류한다고 가정하자. Dish 클래스에는 이러한 연산에 필요한 메서드가 없으므로 메서드 참조를 분류 함수로 사용할 수 없다. 따라서 메서드 참조 대신 람다 표현식으로 필요한 로직을 구현해야 한다.

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

 

그룹화된 요소 조작

위 예제에서 요리 종류와 칼로리 두 가지 기준으로 동시에 그룹화 할 수 있을까? 요소를 그룹화 한 다음에는 각 결과 그룹의 요소를 조작하는 연산이 필요하다. 

// FISH에는 500칼로리가 넘는 음식이 없다고 가정
// 문제 상황
Map<Dish.Type, List<Dish>> caloricDishesByType =
    menu.stream().filter(dish -> dish.getCalories() > 500)
                .collect(groupingBy(Dish::getType));
// {OTHER=[pizza], MEAT=[pork, beef]} FISH 키 자체가 사라지는 문제를 가짐

// 해결 방안
Map<Dish.Type, List<Dish>> caloricDishesByType =
    menu.stream().collect(groupingBy(Dish::getType,
                        filtering(dish -> dish.getCalories() > 500, toList())));
// {OTHER=[pizza], MEAT=[pork, beef], FISH=[]} 목록이 비어있는 FISH 키가 생성됨 (문제 해결)

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

filtering 컬렉터와 같은 이유로 Collectors 클래스는 매핑 함수와 각 항목에 적용한 함수를 모으는 데 사용하는 mapping 메서드를 제공한다. (맵의 각 그룹이 리스트로 되어 있다면 flatMapping도 수행 가능하다.)

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

이 링크에 이해하기 쉽게 설명되어 있으니 한 번 참고해보면 좋을 것이다. ('나는 학생이다' 네이버 블로그, 23년 9월 2일 확인)

 

다수준 그룹화

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

Map<Dish.Type, Map<CalricLevel, 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;
                    })
                 )
              );
// {MEAT={DIET=[chicken], NORMAL=[beef], FAT=[pork]},
//  FISH={DIET=[prawns], NORMAL=[salmon]},
//  OTHER={DIET=[rice, seasonal fruit], NORMAL=[french fries, pizza]}}

 

서브그룹으로 데이터 수집

바깥쪽(첫 번째) groupingBy로 넘겨주는 컬렉터의 형식은 제한이 없다. groupingBy 컬렉터에 두 번째 인수로 counting 컬렉터, maxBy 컬렉터 등을 전달할 수 있다. (maxBy는 Optional<T>를 반환하는 등 반환 타입을 체크해야 한다.)

Map<Dish.Type, Long> typesCount = men.stream().collect(
        groupingBy(Dish::getType, counting()));
// {MEAT=3, FISH=2, OTHER=4}
groupBy 컬렉터는 스트림의 첫 번째 요소를 찾은 이후에야 그룹화 맵에 새로운 키를 (게으르게) 추가한다. 리듀싱 컬렉터가 반환하는 형식을 사용하는 상황이므로 굳이 Optional 래퍼를 사용할 필요가 없다.
- 216p.

 

컬렉터 결과를 다른 형식에 적용하기

위 코드에 counting 대신 maxBy 연산을 수행하면 Map<Dish,Type, Optional<Dish>> 타입으로 반환한다. 이 연산에서 맵의 모든 값을 Optional로 감쌀 필요가 없으므로 Optional을 삭제할 수 있다. Collectors.collectingAndThen을 사용하면 컬렉터가 반환한 결과를 다른 형식으로 활용할 수 있다.

Map<Dish.Type, Long> typesCount = men.stream().collect(
        groupingBy(Dish::getType,  // 분류 함수
            collectingAndThen(
                maxBy(comparingInt(Dish::getCalories)),  // 컬렉터 감싸기
            Optional::get)));  // 변환 함수
// {FISH=salmon, OTHER=pizza, MEAT=pork}

collectingAndThen은 적용할 컬렉터와 변환 함수를 인수로 받아 다른 컬렉터를 반환한다. 반환되는 컬렉터는 기존 컬렉터의 래퍼 역할을 하며 collect의 마지막 과정에서 변환 함수로 자신이 반환하는 값을 매핑한다.

처음부터 존재하지 않는 요리(데이터)는 맵에 key로 추가되지 않기 때문에 리듀싱 컬렉터는 Optional.empty()를 반환하지 않는다. 그렇기 때문에 위의 코드는 안전한 코드다. 그렇다면 위의 코드가 어떤 과정을 거치는지 그림으로 보자.

[그림 3] 모던 자바 인 액션 217페이지의 그림 6-6 캡쳐사진

  • 컬렉터는 점선으로 표시되어 있으며 groupingBy는 가장 바깥쪽에 위치하면서 요리의 종류에 따라 메뉴 스트림을 세 개의 서브스트림으로 그룹화한다.
  • groupingBy 컬렉터는 collectingAndThen 컬렉터를 감싼다. 따라서 두 번째 컬렉터는 그룹화된 세 개의 서브스트림에 적용된다.
  • collectingAndThen 컬렉터는 세 번째 컬렉터 maxBy를 감싼다
  • 리듀싱 컬렉터가 서브스트림에 연산을 수행한 결과에 collectingAndthen의 Optional::get 변환 함수가 적용된다.
  • groupingBy 컬렉터가 반환하는 맵의 분류 키에 대응하는 세 값이 각각의 요리 형식에서 가장 높은 칼로리다.

 

분할


분할은 분할 함수(Partitioning Function)라 불리는 Predicate를 분류 함수로 사용하는 특수한 그룹화 기능이다. 결과적으로 그룹화 맵은 최대 (참 혹은 거짓 값을 갖는) 두 개의 그룹으로 분류된다.

Map<Boolean, List<Dish>> partMenu = 
    menu.stream().collect(partitioningBy(Dish::isVegetarian));  // 분할 함수
// {false=[pork, beef, chicken, prawns, salmon],
//  true=[french fries, rice, season fruit, pizza]}

// 이후 참값의 키로 맵에서 모든 채식 요리 얻을 수 있음
List<Dish> vegetarianDishes = partMenu.get(true);

// filter() 이용해서 해도 됨
List<Dish> vegetarianDishes =
    menu.stream().fliter(Dish::isVegetarian).collect(toList());
// [french fries, rice, season fruit, pizza]

 

분할의 장점

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

또한, 컬렉터를 두 번째 인수로 전달할 수 있는 오버로드된 버전의 partitioningBy 메서드도 존재한다. 해당 링크를 들어가면 Collectors 클래스의 static 팩토리 메서드를 보여준다. (책에 정리된 표 있음)

 

요약


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

'' 카테고리의 다른 글

[10장] 유효성 검사와 예외 처리  (0) 2023.08.26
[5장] 스트림 활용  (1) 2023.08.26
[9장] 연관관계 매핑하기  (0) 2023.08.23

BELATED ARTICLES

more