Java

모던 자바 인 액션 - chapter 6 스트림 데이터 수집

뽀루피 2024. 11. 29. 16:09

스트림으로 데이터 수집

컬렉터

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

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

리듀싱과 요약

  • counting: 개수를 카운트한다
  • maxBy, minBy: 최대 혹은 최소를 만족하는 요소를 찾는다
  • summingInt: 객체를 int로 매핑하는 인수를 받아 합 계산
  • averagingInt: 객체를 int로 매핑하는 인수를 받아 평균을 계산한다
  • summarizingInt: 요소 수, 합계, 평균, 최대값, 최소값 등을 계산한다.
  • joining: 내부적으로 StringBuilder를 이용해서 문자열을 하나로 만든다.

collect와 reduce

collect와 reduce는 둘다 최종 연산 즉, stream API에서 데이터를 처리하는데 사용되는 메서드로 유사한 기능을 수행할 수 있지만 의미적, 실용적 차이가 있다.
collect는 가변 컨테이너에 데이터를 수집한다. List, Map, Set과 같은 자료구조에 데이터를 저장할 때 주로 사용된다. 가변 컨테이너를 사용하기 때문에 병렬 스트림에서도 안전하게 사용할 수 있다.
reduce는 불변성을 유지하며 두 값을 하나로 병합하는 연산을 반복적으로 수행한다. 합계, 곱샘, 최대값, 최소값 등 누적 연산을 수행할 때 사용된다.
collect는 결과를 누적할 때 컨테이너를 사용하므로 상태를 가진다. 반면 reduce는 상태를 가지지 않고 최종 값만 반환한다.
여러 스레드가 동시에 데이터 구조체를 고치면 컬렉션이 망가지므로 리듀싱 연산을 병렬로 수행할 수 없다. 이럴 때 가변 컨테이너 관련 작업이면서 병렬성을 확보하려면 collect 메서드로 리듀싱 연산을 구현하는 것이 바람직하다.

그룹화

그룹화 함수는 스트림을 분류하는 속성을 지녔기에 분류 함수라고 부른다.

  • groupingBy: 그룹핑에 핵심적인 메서드이며 많은 오버로딩된 메서드를 가진다.
    groupingBy -> Map<K, List> 형태의 결과 반환
    • K: 그룹화 기준이 되는 키
    • List<T>: 해당 키에 해당하는 원소들의 리스트

컬렉터 중첩

Map<Dish.Type, Dish> mostCaloricByType = 
    menu.stream()
        .collect(groupingBy(Dish::Type, // 분류 함수
                collectingAndThen(
                    maxBy(comparingInt(Dish::getCalories)), // 감싸인 컬렉터
                Optional::get)); // 변환 함수

 

컬렉터를 중첩할 시 다음과 같은 작업이 수행된다.

  1. groupingBy에서 분류하는 요소(Dish.Type)별로 그룹화한다. 예를 들어, Dish.Type이 MEAT, FISH, OTHER라면, 각 타입별로 음식들을 나눈다.
  2. collectingAndThen는 그룹화된 데이터에 추가적인 변환 작업을 수행한다. 첫번째 파라미터인 maxBy의 실행 결과를 받아 변환 함수(Optional::get)의 결과로 변환한다.
  3. maxBy는 그룹화된 각 타입에서 가장 비교 값이 높은 Dish를 선택한다.
  4. maxBy는 Optional을 반환하므로 Optional::get을 통해 실제 값을 꺼낸다.

분할

partitioningBy는 특정 조건(Predicate)에 따라 데이터를 두 그룹으로 분할하는 그룹화 메서드이다. 결과적으로 True 그룹과 False 그룹으로 나뉜 Map<Boolean, List<T>> 형태의 데이터를 반환한다.

  • partitioningBy(Predicate<? super T> predicate) -> Map<Boolean, List>
List<Integer> numbers = List.of(1, 2, 3, 4, 5, 6);

Map<Boolean, List<Integer>> partitioned = numbers.stream()
    .collect(Collectors.partitioningBy(num -> num % 2 == 0));
// false = [1, 3, 5], true = [2, 4, 6]

Collector 인터페이스

리듀싱 연산을 사용자 정의 방식으로 구현하기 위한 메서드 집합.
이를 통해 기본 제공 컬렉터(Collectors.toList(), Collectors.groupingBy() 등) 대신 맞춤형 컬렉터를 만들 수 있다.

  • Collector<T, A, R>의 구성
    • T: 스트림에서 처리하는 입력 요소 타입
    • A: 리듀싱 작업 중 중간 결과를 저장할 누적기 타입
    • R: 최종 결과 타입

커스텀 컬렉터 만들기 예시

public class ToListCollector<T> implements Collector<T, List<T>, List<T>> {

    @Override
    public Supplier<List<T>> supplier() {
        return ArrayList::new; // 새로운 리스트 생성
    }

    @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(Collector.Characteristics.IDENTITY_FINISH));
    }
}

사용 방법

List<Integer> numbers = List.of(1, 2, 3, 4, 5);

List<Integer> result = numbers.stream()
    .filter(n -> n % 2 == 0) // 짝수 필터
    .collect(new ToListCollector<>()); // 커스텀 컬렉터 사용

System.out.println(result); // [2, 4]