Chapter 6. 스트림으로 데이터 수집

Posted by yunki kim on March 1, 2022

  중간 연산은 파이프라인을 구성해 스트림의 요소를 소비(consume)하지 않는다. 반면 최종 연산은 스트림의 요소를 소비해 최종 결과를 도출한다. 최종 연산은 스트림 파이프라인을 최적화해 계산 과정을 짧게 생략하기도 한다.

컬렉터란 무엇인가?

 최종 연산인 collect 메서드는 Collector 인터페이스의 구현을 전달받아 스트림의 요소를 어떤 식으로 도출할지 지정한다. 예를 들어 .collect(Collectors.toList())에서 toList는 스트림을 리스트로 변환해 Collector를 반환한다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
// Stream<T> interface 의 collect
<R, A> R collect(Collector<super T, A, R> collector);
 
// Stream<T>를 implement한 ReferencePiepline의 collect
@Override
public final <R, A> R collect(Collector<super P_OUT, A, R> collector) {
    A container;
    if (isParallel()
            && (collector.characteristics().contains(Collector.Characteristics.CONCURRENT))
            && (!isOrdered() || collector.characteristics().contains(Collector.Characteristics.UNORDERED))) {
        container = collector.supplier().get();
        BiConsumer<A, ? super P_OUT> accumulator = collector.accumulator();
        forEach(u -> accumulator.accept(container, u));
    }
    else {
        container = evaluate(ReduceOps.makeRef(collector));
    }
    return collector.characteristics().contains(Collector.Characteristics.IDENTITY_FINISH)
           ? (R) container
           : collector.finisher().apply(container);
}
 
// Collectors의 toList 메서드
public static <T> Collector<T, ?, List<T>> toList() {
    return new CollectorImpl<>((Supplier<List<T>>) ArrayList::new, List::add,
        (left, right) -> { left.addAll(right); return left; }, CH_ID);
}
cs

  콜렉터는 groupingBy 메서드를 제공하기 때문에 이를 통해 데이털르 다수준(multilevel)으로 그룹화할 수 있다. 예컨대 two level grouping을 다음과 같이 수행할 수 있다.

1
2
3
4
5
6
final Map<String, Map<String, List<Person>>> personsByCountryAndCity = persons.stream().collect(
    groupingBy(Person::getCountry,
        groupingBy(Person::getCity)
    )
);
 
cs

two level grouping 결과

  이런 multilevel gourping에서 명령형과 선언형 프로그래밍의 차이가 극명이 드러난다. 명령형은 이런 동작을 하기 위해서 다중 루프와 조건을 사용해야 하는 반면 함수형 프로그래밍은 필요한 컬렉털르 쉽게 추가할 수 있다.

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

  스트림에서 collect를 호출하면 스트림의 요소에(컬렉터로 파라미터화된) 리듀싱 연산이 수행된다. collect에서는 리듀싱 연산을 이용해 스트림의 각 요소를 방문하면서 컬렉터가 작업을 처리한다.

리듀싱 연산 도식화

미리 정의된 컬렉터

  Collectors 클래스에서 제공하는 팩토리 메서드를 살펴보자. Collectors는 크게 3가지 메서드를 제공한다.

    1. 스트림 요소를 하나의 값으로 리듀스하고 요약

    2. 요소 그룹화

    3. 요소 분할

리듀싱과 요약

  Collector 팩토리 클래스로 만든 컬렉터 인스턴스로 어떤 일을 할 수 있는지 보자.

갯수 카운트

  counting 메서드를 사용해 팩토리 메서드가 반환하는 컬렉터의 갯수를 계산할 수 있다.

1
2
3
4
5
6
long dishes = menu.stream()
    .collect(Collectors.counting());
 
// 또는 다음과 같이 생략할 수 있다.
long dishes = menu.stream().count();
 
cs

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

  Collector.maxBy, Collector.minBy를 사용하면 스트림의 최댓값과 최솟값을 계산할 수 있다.

1
2
3
4
5
6
7
8
Comparator<Dish> dishCaloriesComparator = 
    Comparator.comparingInt(Dish::getCalories);
 
// menu가 비어 있다면 값을 반환하지 않으므로
// Optional을 반환한다.
Optional<Dish> mostCaloriesDish = menu.stream()
        .collect(maxBy(dishCaloriesComparator)); // maxBy인자로 Comparator를 사용
 
cs

요약 연산

  summingInt는 객체를 int로 매핑하는 함수를 인수로 받고 객체를 int로 매핑한 컬렉터를 반환한다. 그리고 summingInt가 collect 메서드로 전달되면 요약 작접을 수행한다.

1
2
3
int totalCalories = menu.stream()
    .collect(summingInt(Dish::getCalories));
 
cs

  이런 합을 구하는 summing은 summingLong, summingDouble 메서드 역시 존재한다.

  평균을 구하고 싶다면 averagingInt, averagingLong, averagingDouble을 사용하면 된다.

  만약 하나의 요약 연산 결과가 아닌 다수의 요약 연산 결과를 얻고 싶다면 summarizingInt가 반환하는 컬렉터를 사용하면 된다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
IntSummaryStatistics menuStatistics = menu.stream()
    .collect(summarizingInt(Dish::getCalories));
 
// IntSummaryStatistics를 통해 평균, 갯수, 최소, 최대, 합을 얻을 수 있다
// IntSummaryStatistics의 일부:
public class IntSummaryStatistics implements IntConsumer {
    ...
 
    public final long getCount() {
        return count;
    }
 
    public final long getSum() {
        return sum;
    }
 
    public final int getMin() {
        return min;
    }
 
    public final int getMax() {
        return max;
    }
    
    public final double getAverage() {
        return getCount() > 0 ? (double) getSum() / getCount() : 0.0d;
    }
    ...
}
 
cs

문자열 연결

  joining 메서드를 사용하면 스트림의 각 객체에 toString 메서드를 호출해 추출한 모든 문자열을 하나의 문자열로 연결해 반환한다. joining 메서드는 내부적으로 StringBuilder를 이용해 문자열을 하나로 만든다.

1
2
3
4
String shortMenu = menu.stream()
    .map(Dish::getName)
    .collect(joining());
 
cs

범용 리듀싱 요약 연산

  위에서 서술한 컬렉터는 reducing 팩토리 메서드로 정의할 수 있다. 그럼에도 위에서 처럼 특화된 컬렉터를 사용한 이유는 프로그래밍적 편의성 때문이다(가독성이 높아지는 등). 예를 들어 같은 연산을 리듀싱을 사용하면 다음과 같이 가독성이 떨어진다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
// 컬랙터 사용
int totalCalories = menu.stream()
    .collect(summingInt(Dish::getCalories));
 
// 리듀싱 사용 1
// reducing(스트림 인수가 없을 때 반환값, 변환 함수, BinaryOperator)
int totalCalories = menu.stream()
    .collect(reducing(0, Dish::getCalories, (i, j) -> i + j));
 
// 리듀싱 사용 2
// 인자로 하나만 받는 리듀싱은
// reducing(스트림의 시작 요소, 항등 함수, BinaryOperator)
// 와 같이 된다.
// 따라서 인지가 없을 수 있으므로 Optional 반환.
Optional<Dish> totalCalorieDish = menu.stream()
    .collect(reducing((i, j) -> i + j));
 
cs

자신 상황에 맞는 최적의 해법 선택

  위에서 볼 수 있듯 함수형 프로그래밍에서는 하나의 연산을 다양한 방법으로 해결할 수 있다. 또 한 스트림 인터페이스에서 직접 제공하는 메서드를 이용하는 것에 비해 컬렉터를 이용하는 코드가 더 복잡하다. 대신 재상용성과 커스터마이즈 가능성을 제공하는 높은 수준의 추상화와 일반화를 얻을 수 있다. 

  문제를 해결할 수 있는 다양한 해결 방법을 확인한 다음에 가장 일반적으로 문제에 특화된 해결책을 고르는 것이 좋다.

그룹화

  데이터 집합을 하나 이상의 특성으로 분류해서 그룹화하는 연산은 DB에서 많이 수행된다. 이런 연산을 명령형 프로그래밍으로 수행하기는 까다롭지만 함수형을 이용하면 손쉽게 수행할 수 있다. 예를 들어 다음과 같이 그룹화가 가능하다.

1
2
3
4
5
Map<Dish.Type, List<Dish>> dishesByType = memnu.strea()
    .collect(groupingBy(Dish::getType));
// 결과:
// {FIST=[prawns, slmon], MEAT=[port, beef, chicken]}
 
cs

    만약 단순 속성 접근자가 아닌 복잡한 기준으로 데이터를 그룹화 하고 싶다면 람다를 사용해 필요한 로직을 구현할 수 있다.

1
2
3
4
5
6
7
Map<Dish.Type, List<Dish>> dishesByType = memnu.strea()
    .collect(groupingBy(dish -> {
        if (dish.getCalories() <= 400return CaloricLevel.DIET;
        else if (dish.getCalories() <= 700return CaloricLevel.NORMAL;
        else return CaloricLevel.FAT;
    }));
 
cs

그룹화된 요소 조작

  그룹화된 요소에 대해 특정 작업을 하는 법을 알아보자.

  그룹화된 요소에 대해 특정 조건에 만족하는 요소만 필터링을 할 경우 그룹화 전에 중간 연산으로 filter를 적용하면 특정 그룹에 요소가 없을 경우 해당 그룹은 Map에서 해당 키 자체가 없어지게 된다. 따라서 이런 상황을 위해 Collectors 클래스는 일반적인 분리 함수에 Collector 형식의 두 번째 인수를 갖는 groupingBy 팩토리 메서드를 오버로드해 이 문제를 해결한다.

1
2
3
4
5
Map<Dish.Type, List<Dish>> dishesByType = memnu.strea()
    .collect(groupingBy(Dish::getType, filtering(dish -> dish.getCalories(), toList())));
// 결과:
// {MEAT=[FORK, beef], FISH=[]}
 
cs

  오버라이드된 gourpingBy 메서드 로직은 다음과 같다.

1
2
3
4
5
6
public static <T, K, A, D> Collector<T, ?, Map<K, D>> groupingBy(Function<super T, ? extends K> classifier,
   Collector<super T, A, D> downstream) {
      return groupingBy(classifier, HashMap::new, downstream);
}
 
 
cs

  그룹화된 요소들을 변환하는 작업이 필요하다면 map또는 필요에 따라 flatMap을 사용하면 된다.

1
2
3
Map<Dish.Type, List<String>> dishesByType = memnu.strea()
    .collect(groupingBy(Dish::getType, mapping(Dish::getName, toList())));
 
cs

서브그룹으로 데이터 수집

  첫 번째 groupingBy로 넘겨주는 컬렉터의 형식은 제한이 없다. 그때문에 counting이나 maxBy 등을 사용해 데이터를 수집할 수 있다.

1
2
3
4
5
Map<Dish.Type, Long> typesCount = menu.stream()
    .collect(groupBy(Dish::getType, counting()));
// 결과
// {MEAT=3, FISH=2}
 
cs

분할

  분할 함수(partitionig function)이라 불리는 프리디케이트를 분류 함수로 사용해 특수한 그룹화가 가능하다. 이는 불리언을 반환하기 때문에 키다 Boolean이다. 따라서 앱은 최대 두 개의 그룹을 갖는다.

1
2
3
Map<Boolean, List<Dish>> partitionedMenu = menu.stream()
    .collect(partitioningBy(Dish::isVegetarian));
 
cs

  partitioningBy는 특수한 맵과 두 개의 필드로 구현되있다. 또 한 groupingBy 처럼 다수준으로 분할할 수 있다.

Collectors 클래스의 정적 팩토리 메서드

팩토리 메서드 반환 형식 설명 예시
toList List<T> 스트림의 모든 항목을 리스트로 수집 List<Dish> dishes = menuStream.collect(toList());
toSet Set<T> 스트림의 모든 항목을 중복없는 집합으로 수집 Set<Dish> dishes = menuStream.collect(toSet());
toCollection Collection<T> 스트림의 모든 항목을 발행자가 제공하는 컬렉션으로 수집 Collection<Dish> dishes = menu.collect(toCollection(), ArrayList::new);
counting Long 스트림의 항목 수 계산 long howManyDishes = menuStream.collect(counting());
summingInt Integer 스트림의 항목에서 정수 프로퍼티값을 더함 int totalCalories = menuStream.collect(summingInt(Dish::getCalories));
averagingInt Double 스트림 항목의 정수 프로퍼티의 평균값 계산 double avgCalories = menuStream.collect(averagingInt(Dish::getCalories));
summarizingInt IntSummaryStatistics 스트림 내 항목의 최댓값, 최솟값, 합계, 평균 등의 정수 정보 통계 수집 IntSummaryStatistics menuStatistics = menuStream.collect(summarizingInt(Dish::getCalories));
joining String 스트림의 각 항목에 toString 메서드를 호출한 결과 문자열 연결 String shortMenu = menuStream.map(Dish::getName).collect(joining(", "));
maxBy Optional<T> 주어진 비교자를 이용해 스트림의 최댓값 요소를 Optional로 감싼 값을 반환, 스트림에 요소가 없으면 Optional.empty() 반환 Optional<Dish> fattest = menuStream.collect(maxBy(comparingInt(Dish::getCalories)));
minBy Optional<T> 주어진 비교자를 이용해 스트림의 최솟값 요소를 Optional로 감싼 값을 반환, 스트림에 요소가 없으면 Optional.empty() 반환 Optional<Dish> lightest = menuStream.collect(minBy(comparingInt(Dish::getCalories)));
reducing The type produced by the reduction operaiton 누적자를 초깃값으로 설정한 다음 BinaryOperator로 스트림의 각 요소를 반복적으로 누적자와 함쳐 스트림을 하나의 값으로 리듀싱 int totalCalories = menuStream.collect(reducing(0, Dish::getCalories, Integer::sum));
collectingAndThen The type returned by the trasforming function 다른 컬렉터를 감싸고 그 결과에 변환 함수 적용 int howManuDishes = menuStream.collect(collectingAndThne(toList(), List::size));
groupingBy Map<K, List<T>> 하나의 프로퍼티값을 기준으로 스트림의 항목을 그룹화하며 기준 프로퍼티값을 결과 맵의 키로 사용 Map<Dish.Type, List<Dish>> dishesByType = menuStream.collect(groupingBy(Dish::getType));
partitionBy Map<Boolean, List<T>> 프리디케이트를 스트림의 각 항목에 적용한 결과로 항목 분할 Map<Boolean, List<Dish>> vegetarianDishes = menuStream.collect(partitioningBy(Dish::isVegetarian));

Collector 인터페이스

  Collector 인터페이스는 리듀싱 연산을 어떻게 구현할지 제공하는 메서드 집합으로 구성된다. 이런 Collector 인터페이스를 직접 구현해 더 효율적으로 문제를 해결하는 컬렉털르 만들 수 있다.

  다음 코드는 Collector 인터페이스의 시그니처와 다선 개의 메서드 정의를 보여준다.(Collector는 다섯 개의 메서드 외에도 두 개의 of static 메서드와 하나의 Characteristics enum으로 구성되 있다)

1
2
3
4
5
6
7
8
public interface Collector<T, A, R> {
    Supplier<A> supplier();
    BiConsumer<A, T> accumulator();
    Function<A, R> finisher();
    BinaryOperator<A> combiner();
    Set<Characteristics> characteristics();
}
 
cs

  위 코드의 Collector<T, A, R>의 각 타입은 다음과 같다:

    Collecor<수집될 스트림 항목의 제네릭 형식(T), 수집 과정에서 결과를 누적하는 객체 형식(A), 수집 연산 결과 객체의 형식(R)>

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

  Collector 인터페이스에 정의 된 5 개의 메서드 중 characteristics를 제외한 나머지 메서드는 collect 메서드에서 실행하는 함수를 반환한다. 반변 characteristics 메서드는 collect 메서드가 어떤 최적화(병렬화 같은)를 이용해 리듀싱 연산을 수행할 것인지 결정하게 돕는 힌트 특성 집합을 제공한다.

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

  supplier는 수집 과정에서 빈 누적자 인스턴스를 만드는 파라미터가 없는 함수다. 따라서 다음과 같은 로직을 수행할 수 있다.

1
2
3
public Supplier<List<T>> supplier() {
    return () -> new ArraysList<T>();
}
cs

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

  accumulator 메서드는 리듀싱 연산을 수행하는 함수를 반환한다. 스트림에서 n번째 요소를 탐색할 때 두 인수, 즉 누적자와 n 번째 요소를 함수에 적용한다. 요소를 탐색하면서 적용하는 함수에 의해 누적자 내부상태가 바뀌므로 누적자가 어떤 값일지 단정할 수 없으므로 반환값은 void이다.

1
2
3
public BiConsumer<List<T>, T> accumulator() {
    return (list, item) -> list.add(item);
}
cs

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

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

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

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

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

1
2
3
4
5
6
public BinaryOperator<List<T>> combiner() {
    return (list1, list2) -> {
        list1.addAll(list2);
        return list1;
    }
}
cs

characteristics 메서드

  characteristics 메서드는 컬렉터의 연산을 정의하는 Characteristics 형식의 불변 집합을 반환한다. Characteristics는 스트림으 병렬로 리듀스할 것인지, 병렬로 리듀스한다면 어떤 최적화를 선택 할지 힌트를 제공한다.

  Characteristics는 다음 세 항목을 포함하는 열거형이다.

    UNORIDERED: 리듀싱 결과는 스트림 요소의 방문 순서나 누적 순서에 영향을 받지 않는다.

    CONCURRENT: 다중 스레드에서 accumulator 함수를 동시에 호출할 수 있으며 이 컬렉터는 스트림의 병렬

                           리듀싱을 수행할 수있다. 컬렉터의 플래그에 UNORDERED를 함께 설정하지 않았다면 데이터 소스가

                           정렬되어 있지 않은 상황에서만 병렬 리듀싱을 수행할 수 있다.

    IDENTITY_FINISH: finisher 메서드가 반환하는 함수는 단순히 identity를 적용할 뿐이므로 이를 생략할 수 있다.

                           따라서 리듀싱 과정의 최종 결과로 누적자 객체를 바로 사용할 수 있다. 또한 누적자 A를 결과 R로

                           안정히 형변환할 수 있다.

응용하기

  위 내용들을 사용해 toList 메서드를 비슷하게 구현하면 다음과 같다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
public class ToListCollector<T> implements Collector<T, List<T>, List<T>> {
    @Override
    public Supplier<List<T>> supplier() {
        return ArraysList::new;
    }
 
    @Override
    public BiConsumer<List<T>, T> accumulator() {
        return List::add;
    }
 
    @Override
    public Function<List<T>, List<T>> finisher() {
        return Function.identity();
    }
 
    @Override
    public BinaryOperator<List<T>> combiner() {
        return (list1, list2) -> {
            list1.addAll(list2);
            return list1;
        }
    }
 
    @Override
    public Set<Characteristics> characteristics() {
        return Collections.unmodifiableSet(EnumSet.of(IDENTITY_FINISH, CONCURRENT));
    }
}
cs

  위 구현은 다음과 같이 사용할 수 있다.

1
List<Dish> dishes = menuStream.collect(new ToListCollector<Dish>());
cs

 

출처 - 모던 자바 인 액션