devseop08 님의 블로그

[API] 람다와 스트림 : Mordern Java - 6. 스트림으로 데이터 수집 본문

Language/Java

[API] 람다와 스트림 : Mordern Java - 6. 스트림으로 데이터 수집

devseop08 2025. 6. 1. 01:40
  • 스트림 최종 연산 reduce가 그랬던 것처럼 collect 역시 다양한 요소 누적 방식을 인수로 받아서 스트림을 최종 결과로 도출하는 리듀싱 연산을 수행할 수 있다.
  • 다양한 요소 누적 방식은 Collector 인터페이스에 정의되어 있다.

6.1 컬렉터란 무엇인가?

  • Collectors 클래스의 toList 메서드는 스트림의 각 요소를 리스트로 만드는 작업을 수행하는 Collector 인터페이스의 구현을 반환한다.
  • collect 메서드의 인수로 주어진 Collector 인터페이스 구현은 스트림의 요소를 어떤 식으로 도출할 지 결정한다.

  • Collectors 클래스의 groupingBy 메서드는 각 키 버킷 그리고 각 키 버킷에 대응하는 요소 리스트를 값으로 포함하는 맵을 만드는 작업을 수행하는 Collector 인터페이스의 구현을 반환한다.

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

  • collect로 결과를 수집하는 과정을 간단하면서도 유연한 방식으로 정의할 수 있다
  • collect에서는 리듀싱 연산을 이용해서 스트림의 각 요소를 방문하면서 작업을 처리한다.
  • 특정 데이터 별로 스트림 요소를 그룹화하는 리듀싱 연산

  • Collector 인터페이스의 메서드를 어떻게 구현하느냐에 따라 스트림에 어떤 리듀싱 연산을 수행할 지 결정된다.
  • Collectors 유틸리티 클래스는 자주 사용되는 컬렉터 인스턴스를 손쉽게 생성할 수 있는 정적 팩토리 메서드를 제공한다.(ex. Collectors.toList())

6.1.2 미리 정의된 컬렉터

  • Collectors에서 제공하는 메서드의 기능 3가지
    • 스트림 요소를 하나의 값으로 리듀스하고 요약
    • 요소 그룹화
    • 요소 분할

6.2 리듀싱과 요약

  • Collector 인터페이스 구현체로 스트림의 모든 항목을 하나의 결과로 합칠 수 있다.
  • counting() 팩토리 메서드로 생성한 컬렉터
import static java.util.stream.Collectors.*;

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

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

  • maxBy, minBy 두 개의 팩토리 메서드로 생성한 컬렉터로 스트림의 최댓값과 최솟값을 구할 수 있다.
import static java.util.stream.Collectors.*;

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

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

6.2.2 요약 연산

int totalCalories = menu.stream().collect(summingInt(Dish::getCalories))
  • summingInt 컬렉터 누적 과정

  • averagingInt, averagingLong, averagingDouble 등으로 다양한 형식으로 이루어진 숫자 집합의 평균을 계산할 수 있다.
double avgCalories =
    menu.stream().collect(averagingInt(Dish::getCalories));
  • 팩토리 메서드 summarizingInt가 반환하는 컬렉터로 스트림의 요소 수, 합계와 평균, 최솟값과 최댓값을 한 번에 계산할 수 있다
IntSummaryStatistics menuStatistics = 
    menu.stream().collect(summarizingInt(Dish::getCalories));

// menuStatistics 객체를 출력하면 
// IntSummaryStatistics{count=9, sum=4300, min=120, average=477.77778, max=800}과 같은 정보 출력

6.2.3 문자열 연결

  • joining 팩토리 메서드
String shortMenu = menu.stream().map(Dish::getName).collect(joining());
  • 결과에 구분자를 추가하기 위해 joining 팩토리 메서드에 인자로 구분자 전달
String shortMenu = menu.stream().map(Dish::getName).collect(joining(", "));

6.2.4 범용 리듀싱 요약 연산

  • 지금까지 살펴본 모든 컬렉터는 reducing 팩토리 메서드로도 정의할 수 있다.
int total Calories = menu.stream()
                         .collect(reducing(
                                    0, Dish::getCalories, (i, j) -> i + j));
  • 예제의 reducing은 인수 세 개를 받는다.
    • 첫 번째 인수는 리듀싱 연산의 시작값이거나 스트림에 인수가 없을 때는 반환값이다.
    • 두 번째 인수는 요리를 칼로리 정수로 변환할 때 사용한 변환 함수
    • 세 번째 인수는 같은 종류의 두 항목을 하나의 값으로 더하는 BinaryOperator
  • 한 개의 인수를 갖는 reducing 팩토리 메서드는 세 개의 인수를 갖는 reducing 팩토리 메서드에서 스트림의 첫 번째 요소를 시작 요소, 즉 첫 번째 인수로 받으며, 자신을 그대로 반환하는 항등 함수를 두 번째 인수로 받는 상황에 해당
  • 한 개의 인수를 갖는 reducing 컬렉터는 시작값이 없으므로 빈 스트림이 넘겨졌을 때 시작값이 설정되지 않는 상황이 발생한다. 그래서 reducing 컬렉터는 Optional< T > 타입의 객체를 반환
    Optional<Dish> mostCaloriesDish = 
        menu.stream().collect(reducing(
           (d1, d2) -> d1.getCalories() > d2.getCalories() ? d1 : d2));

collect와 reduce

Stream<Integer> stream = Arrays.asList(1, 2, 3, 4, 5, 6).stream();

List<Integer> numbers = stream.reduce(
                                new ArrayList<Integer>(),
                                (List<Integer> l, Integer e) -> {
                                    l.add(e);
                                    return l; },    
                                (List<Integer> l1, List<Integer> l2) -> {
                                    l1.add(l2);
                                    return l1; });
  • 위 코드에는 의미론적인 문제와 실용적인 문제가 발생한다.
    • collect 메서드는 도출하려는 결과를 누적하는 컨테이너를 바꾸도록 설계된 메서드지만
      reduce 메서드는 두 값을 하나로 도출하는 불변형 연산이라는 점에서 의미론적으로 문제
    • 위 예제의 reduce 메서드는 누적자로 사용된 리스트를 변환시키므로 reduce를 잘못 활용한 예이다.
    • 여러 스레드가 동시에 같은 데이터 구조체를 고치면 리스트 자체가 망가져버리므로 리듀싱
      연산을 병렬로 수행할 수 없다는 실용성 문제 => 매번 새로운 리스트를 할당해야하므로 객체를 할당하느라 성능 저하
  • 가변 컨테이너 관련 작업이면서 병렬성을 확보하려면 collect 메서드로 리듀싱 연산을 구현하는 것이 바람직하다.

6.3 그룹화

  • 팩토리 메서드 groupingBy를 이용한 그룹화로 스트림의 항목을 분류하는 과정
Map<Dish.Type, List<Dish>> dishesByType = 
        menu.stream().collect(groupingBy(Dish::getType));
// dishesByType Map에 포함된 결과

{FISH=[prawns, salmon], OTHER=[french fries, rice, season fruit, pizza], 
 MEAT=[pork, beef, chicken]}

6.3.1 그룹화된 요소 조작

Collectors 클래스의 filtering 메서드

  • 요소를 그룹화 한 다음에는 각 결과 그룹의 요소를 조작하는 연산이 필요하다.
  • 만약 500 칼로리가 넘는 요리만 필터한다고 가정하면 그룹화하기 전에 프레디케이트로 필터를 적용해 문제를 해결할 수 있다고 생각할 수 있다.
Map<Dish.Type, List<Dish>> caloricDishesByType =
        menu.stream().filter(dish->dish.getCaloric() > 500)
                     .collect(groupingBy(Dish::getType));
// 그룹화하기 전에 프레디케이트로 필터를 적용한 결과

{OTHER=[french fries, pizza], MEAT=[pork, beef]} // FISH 그룹이 결과에서 사라짐
  • 그룹화하기 전에 프레티케이트로 먼저 필터를 적용하면 필터를 만족하지 않는 그룹은 결과에서 배제되는 문제가 발생한다.
  • Collectors 클래스는 일반적인 분류 함수에 Collector 형식의 구현을 두 번째 인수를 갖도록 groupingBy 팩토리 메서드를 오버로드해 이 문제를 해결한다.
Map<Dish.Type, List<Dish>> caloricDishesByType =
    menu.stream()
        .collect(groupingBy(
                    Dish::getType,
                    filtering(dish->dish.getCaloric() > 500, toList())    
                    ));
  • Collectors 클래스의 filtering 메서드는 Collectors 클래스의 또 다른 정적 팩토리 메서드로 프레디케이트를 인수로 받아 각 그룹의 요소와 필터링된 요소를 재그룹화한다.
// filtering 메서드로 각 그룹의 요소와 필터링된 요소를 재그룹화한 결과
{OTHER=[french fries, pizza], MEAT=[pork, beef], FISH=[]}

Collectors 클래스의 mapping 메서드

  • 그룹화된 항목을 조작하는 다른 유용한 기능 중 하나로 맵핑 함수를 이용해 요소를 변환하는 작업이 있다.
  • Collectors 클래스는 매핑 함수와 각 항목에 적용한 함수를 모으는 데 사용하는 또 다른 컬렉터를 인수로 받는 mapping 메서드를 제공한다.
Map<Dish.Type, List<String>> dishNamesByType =
    menu.stream()
        .collect(groupingBy(Dish::getType, mapping(Dish::getName, toList())));

Collectors 클래스의 flatMapping 메서드

  • 메뉴 그룹에 속한 메뉴에 달린 태그들이 메뉴 그룹과 매핑되는 Map을 만들어보자
  • 우선 다음과 같이 각 메뉴의 이름에 대해 태그 리스트를 매핑시킨 맵이 있다고 하자
Map<String, List<String>> dishTags = new HashMap<>();

dishTags.put("pork", asList("salty", "greasy"));
dishTags.put("beef", asList("salty", "roasted"));
dishTags.put("chicken", asList("fried", "crisp"));
dishTags.put("french fries", asList("greasy", "fried"));
dishTags.put("rice", asList("light", "natural"));
dishTags.put("season fruit", asList("fresh", "natural"));
dishTags.put("pizza", asList("tasty", "salty"));
dishTags.put("prawns", asList("greasy", "roasted"));
dishTags.put("salmon", asList("delicious", "fresh"));
  • grouping 메서드를 통해 얻은 Collector 인터페이스의 grouping 컬렉터를 사용하면 각 메뉴들이 자신이 속한 메뉴 그룹으로 그룹화가 될 것이다
  • 그 다음 각 메뉴 그룹에 속한 메뉴들로부터 태그들을 얻어내는 연산을 해야하는데, 이 때 Collectors 클래스의 mapping 메서드를 이용하면, 각 메뉴 그룹에 속한 각 메뉴들에 대해서 각 메뉴마다 그것에 달린 태그가 아닌 태그 '리스트'를 얻어오게 된다.
  • 즉, mapping 메서드를 이용해 재그룹화한 결과를 보면, 메뉴 그룹마다 대응되는 요소가 그 메뉴 그룹에 속한 메뉴들에 달린 태그들이 아닌, 태그 '리스트'들이 된다는 것이다.
  • 이러한 문제를 해결하기 위해선 mapping 메서드를 이용하는 것이 아닌, 각 그룹마다 그 그룹에 속하는 각 메뉴들에 각각 매핑된 태그 리스트들을 그 그룹에 대응되는 하나의 리스트로 추리는, 즉 하나의 리스트로 평면화하는 flatMapping 메서드를 이용해야 한다.
  • 추가로 태그들이 중복되는 것을 방지하기 위해서 재그룹화를 위한 flatMapping 연산 결과를 담는 저장 공간은 Set 형식으로 한다. 집합으로 그룹화해 중복 태그를 제거해야 한다.
Map<Dish.Type, Set<String>> dishTagsByType = 
    menu.stream()
        .collect(
            groupingBy(
                Dish::getType,
                flatMapping(dish->dishTags.get(dish.getName()).stream(),
                // dishTags.get(dish.getName())은 태그 리스트 반환환
                // 매핑된 리스트들을 스트림 평면화 
                toSet())
            )
        );

6.3.2 다수준 그룹화

  • 한 기준이 아닌 두 가지 이상의 기준을 동시에 적용해 리스트를 그룹화해보자
  • 두 인수를 받는 팩토리 메서드 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;    
        })
    )
);
{MEAT={DIET=[chicken], NORMAL=[beef], FAT=[pork]},
 FISH={NORMAL=[salmon], DIET=[prawns]},
 OTHER={NORMAL=[pizza, fries], DIET=[fruit,rice]}}

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

  • 사실 첫 번째 groupingBy로 넘겨주는 컬렉터의 형식은 제한이 없다.
  • 분류 함수 한 개의 인수를 갖는 groupingBy(f)는 사실 groupingBy(f, toList())의 축약형이다.
Map<Dish.Type, Long> typesCount = 
    menu.stream().collect(
        groupingBy(Dish::getType, counting()) // 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]}
  • Optional< Dish >는 해당 종류의 음식 중 가장 높은 칼로리를 래핑한다.
  • 처음부터 존재하지 않는 요리의 키는 맵에 추가되지 않기 때문에 실제로 메뉴의 요리 중 Optional.empty() 를 값으로 갖는 요리는 존재하지 않는다.
  • 마지막 그룹화 연산에서 맵의 모든 값을 Optional로 감쌀 필요가 없으므로 Optional을 삭제할 수 있다.

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

  • Collectors.collectingAndThen으로 컬렉터가 반환한 결과를 다른 형식으로 활용 가능
Map<Dish.Type, Dish> mostCaloricByType =
    menu.stream()
        .collect(
            groupingBy(Dish::getType, // 분류 함수
                collectingAndThen(
                    maxBy(comparingInt(Dish::getCalories)), // 감싸인 컬렉터
                    Optional::get // 변환 함수
                )
            )
        )

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

  • 일반적으로 스트림에서 같은 그룹으로 분류된 모든 요소에 리듀싱 작업을 수행할 때는 팩토리 메서드 groupingBy 메서드에 두 번째 인수로 전달한 컬렉터를 사용한다.
Map<Dish.Type, Integer> totalCalorieByType =
    menu.stream().collect(groupingBy(Dish::getType,
        summingInt(Dish:getCalories) // summing 리듀싱 연산
    ));
Map<Dish.Type, Set<CaloricLevel>> caloricLevelsByType =
    menu.stream().collect(
        groupingBy(Dish::getType, mapping(dish-> {
            if(dish.getCalories() <= 400) return CaloricLevel.DIET;
            else if(dish.getCalories() <= 700) return CaloricLevel.NORMAL;
            else return CaloricLevel.FAT; 
        }, toSet() )));
Map<Dish.Type, Set<CaloricLevel>> caloricLevelsByType =
    menu.stream().collect(
        groupingBy(Dish::getType, mapping(dish-> {
            if(dish.getCalories() <= 400) return CaloricLevel.DIET;
            else if(dish.getCalories() <= 700) return CaloricLevel.NORMAL;
            else return CaloricLevel.FAT; 
        }, toCollection(HashSet::new) )));

6.4 분할

  • 분할은 분할 함수라 불리는 프레디케이트를 분류 함수로 사용하는 특수한 그룹화 기능이다.
  • 분할 함수는 불리언을 반환하므로 그룹화 결과 맵의 키 형식은 Boolean이다.
  • 결과적으로 그룹화 맵은 참 아니면 거짓의 값을 갖는 최대 두 개의 그룹으로 분류된다
Menu<Boolean, List<Dish>> partitionedMenu = 
    menu.stream().collect(partitioningBy(
        Dish::isVegetarian
    ));
{false=[pork, beef, chicken, prawns, salmon],
 true=[french fries, rice, season fruit, pizza]}

6.4.1 분할의 장점

  • 분할 함수가 반환하는 참, 거짓 두 가지 요소의 스트림 리스트를 모두 유지한다는 것이 분할 함수의 장점이다.
Map<Boolean, Map<Dish.Type, List<Dish>>> vegetarianDishesByType = menu.stream().
collect(
    partitioningBy(
        Dish:isVegetarian,
        groupingBy(
            Dish::getType
        )
    )
);
{false=[FISH=[prawns, salmon], MEAT=[pork, beef, chicken]],
 true=[OTHER=[french fries, rice, season fruit, pizza]
 }
Map<Boolean, Dish> mostCaloricPartitionedByVegetarian =
menu.stream().collect(
    partitioningBy(
        Dish::isVegetarian,
        collectingAndThen(
            maxBy(comparingInt(Dish::getCalories)),
            Optional::get
        )
    )
);

// {false=pork, true=pizza}

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

  • 정수 n을 인수로 받아서 2에서 n까지의 자연수를 소수와 비소수로 나누는 프로그램
public boolean isPrime(int candidate){
    int candidateRoot = (int) Math.sqrt((double)candidate);
    return IntStream.rangeClosed(2, candidateRoot)
                    .noneMatch(i -> candidate % i == 0);
}

public Map<Boolean, List<Integer>> partitionPrimes(int n) {
    return IntStream.rangeClosed(2,n).boxed()
                .collect(
                    partitioningBy(candidate -> isPrime(candidate)));
}

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 = menuStream.collect(toCollection(), ArrayList::new);
counting Long 스트림의 항목 수 계산 long howManyDishes = menuStream.collect(counting());
summingInt Integer 스트림의 항목에서 정수 프로퍼티 값을 더함 int totalCalories = menuStream.collect(summingInt( Dish::getCalories));
avaragingInt Double 스트림 항목의 정수 프로퍼티의 평균값 계산 double avgCalories = menuStream.collect(averaginInt( Dish::getCalories));
summarizingInt IntSummaryStatistics 스트림 내 항목의 최댓값, 최솟값, 합계, 평균 등의 정수 정보 통계 수집 IntSummaryStatistics = menuStream.collect( summarizingInt( Dish::getCalories));
joining String 스트림의 각 항목에 toString 메서드를 호출한 결과 문자열 연결 String shortMenu = menuStream.map(Dish::getName) .collect( joining(", "));
maxBy Optional< T > 주어진 비교자를 이용, 스트림의 최댓값 요소를 Optional로 감싼 값을 반환, 스트림에 요소가 없을 때는 Optional.empty() 반환 Optional< Dish > fattestMenu = menuStream.collect( maxBy(comparingInt( Dish::getCalories )));
minBy Optional< T > 주어진 비교자를 이용, 스트림의 최솟값 요소를 Optional로 감싼 값을 반환, 스트림에 요소가 없을 때는 Optional.empty() 반환 Optional< Dish > fattestMenu = menuStream.collect( minBy(comparingInt( Dish::getCalories )));
reducing 리듀싱 연산 타입에 따른다 누적자를 초기값으로 설정, 스트림의 각 요소를 BinaryOperator로 반복적으로 누적자와 합쳐 스트림을 하나의 값으로 리듀싱 int totalCalories = menuStream.collect( reducing( 0, Dish::getCalories, Integer::sum));
collectingAndThen 변환 함수의 반환 타입에 따른다 다른 컬렉터를 감싸고 그 결과에 변환 함수 적용 int howManyDishes = menuStream.collect( collectingAndThen( toList(), List::size ) );
groupingBy Map< K, List < T >> 하나의 프로퍼티 값을 기준으로 스트림의 항목을 그룹화, 기준 프로퍼티 값을 맵의 키로 사용 Map< Dish.Type, List< Dish >> dishesByType = menuStream.collect( groupingBy(Dish::getType) );
partitioningBy Map <Boolean, List< T>> 프레디케이트를 스트림의 각 항목에 적용한 결과로 항목 분할 Map< Boolean, List< Dish >> vegetarianDishes = menuStream.collect( partitioningBy( Dish::isVegetarian));

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

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

  • supplier 메서드는 빈 결과로 이루어지는 Supplier를 반환해야 한다.
public Supplier<List<T>> supplier() {
    return ArrayList::new;
}

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

  • accumulator 메서드는 리듀싱 연산을 수행하는 함수를 반환한다.
public BiConsumer<List<T>, T> accumulator(){
    return List::add;
}

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

  • finisher 메서드는 스트림 탐색을 끝내고 누적자 객체를 최종 결과로 변환하면서 누적 과정을 끝낼 때 호출할 함수를 반환해야 한다.
  • 때로는 누적자 객체가 이미 최종 결과인 상황도 있다.
  • 이런 때는 변환 과정이 필요하지 않으므로 finisher 메서드는 항등 함수를 반환한다.
public Function<List<T>, List<T>> finisher(){
      return Function.identity
}

순차 리듀싱 과정의 논리적 순서

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

  • combiner 매서드는 스트림의 서로 다른 서브 파트를 병렬로 처리할 때 누적자가 이 결과를 어떻게 처리할 지 정의한다.
  • toList의 combiner의 경우, 스트림의 두 번째 서브 파트에서 수집한 항목 리스트를 첫 번째 서브파트 결과 리스트의 뒤에 추가하면 된다.
public BinaryOperator<List<T>> combiner() {
    return (list1, list2) -> {
        list1.addAll(list2);
        return list1;
    }
}

  • 컬렉터의 combiner 메서드를 이용하면 스트림의 리듀싱을 병렬로 수행할 수 있다.
  • 위의 도식은 스트림의 병렬 리듀싱 수행 과정을 보여준다.
    • 스트림을 분할해야 하는지 정의하는 조건이 거짓으로 바뀌기 전까지 스트림을 재귀적으로 분할한다.(분산된 작업의 크기가 너무 작아지면 병렬 수행 속도는 순차 속도보다 느려질 수 있다. 프로세싱 코어의 개수를 초과하는 병렬 작업은 효율적이지 않다.)
    • 모든 서브스트림의 각 요소에 리듀싱 연산을 순차적으로 적용해서 서브스트림을 병렬로 처리할 수 있다.
    • 마지막에는 컬렉터의 combiner 메서드가 반환하는 함수로 모든 부분결과를 쌍으로 합친다. 분할된 모든 서브스트림의 결과를 합치면서 연산이 완료되는 것이다.

characteristics 메서드

  • characteristics 메서드는 컬렉터의 연산을 정의하는 Characteristics 형식의 불변 집합을 정의한다.
  • Characteristics는 스트림을 병렬로 리듀스할 것인지 그리고 병렬로 리듀스한다면 어떤 최적화를 선택해야 할지 힌트를 제공한다.
  • Characteristics 열거형이다.
    • UNORDERED : 리듀싱 결과는 스트림의 요소 방문 순서나 누적 순서에 영향을 받지 않는다.
    • CONCURRENT: 다중 스레드에서 accumulator 함수를 동시에 호출할 수 있으며, 해당 컬렉터는 스트림의 병렬 리듀싱을 수행할 수 있다. 다만 컬렉터 플래그에 UNORDERED를 함께 설정하지 않았다면 집합처럼 데이터 소스가 정렬되어 있지 않은 상황에서만(순서가 무의미한 상황에서만) 병렬 리듀싱을 수행할 수 있다.
    • IDENTITY_FINISH: finisher 메서드가 반환하는 함수는 단순히 identity를 적용할 뿐이므로 이를 생략할 수 있다. 따라서 리듀싱 과정의 최종 결과로 누적자 객체를 바로 사용할 수 있다. 또한 누적자 A를 결과 R로 안전하게 형변환할 수 있다.

6.5.2 응용하기

  • 지금까지의 다섯 가지 메서를 이용해서 커스텀 ToListCollector를 구현
import java.util.*;  
import java.util.function.BiConsumer;  
import java.util.function.BinaryOperator;  
import java.util.function.Function;  
import java.util.function.Supplier;  
import java.util.stream.Collector;  


public class ToListCollector<T> implements Collector<T, List<T>, List<T>> {  
    @Override  
    public Supplier<List<T>> supplier() {  
        return () -> new ArrayList<T>();  
    }  

    @Override  
    public BiConsumer<List<T>, T> accumulator() {  
        return List::add;  
    }  

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

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

    @Override  
    public Set<Characteristics> characteristics() {  
        return 
            Collections.unmodifiableSet
                (EnumSet.of(Characteristics.IDENTITY_FINISH, 
                    Characteristics.CONCURRENT));   
    }  
}
List<Dish> dishes = menuStream.collect(new ToListCollector<Dish>());

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

  • Stream은 세 함수(발행, 누적, 합침(combine))를 인수로 받는 collect 메서드를 오버로드하며, 이 때의 인수로 주어지는 각각의 메서드는 Collector 인터페이스의 메서드가 반환하는 함수와 같은 기능을 수행한다.
List<Dish> dishes = memuStream.collect(
    ArrayList::new, // 발행  
    List::add,      // 누적 
    List::addAll    // 합침
);

요약

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