본문 바로가기
자바

[모던 자바 인 액션] 스트림으로 데이터 수집

by __Minnie_ 2025. 4. 25.
컬렉터란

 

스트림 API의 최종 연산 중 collect라는 메소드가 있는데, 이 collect는 컬렉터를 인자로 받아서 내부적으로 리듀싱 연산을 수행한다. 이 책에서 자주 사용하는 toList는 각 요소를 조회하면서 리스트에 값을 추가하는 리듀싱 연산을 하여 리스트를 반환한 것이다. 

 

 

자바에서 미리 정의된 컬렉터

 

1. counting

스트림의 개수를 세는 컬렉터이다. 첫번째 예제는 counting 컬렉터를 사용한 것이고 두번째 예제는 스트림 API에서 제공되는 count 메소드이다. 

long count = menu.stream().collect(Collectors.counting());

long count = menu.stream().count();

 

2. maxBy, minBy

maxBy, minBy는 comparator를 인자로 받는다. 

Comparator<Dish> dishComparator = Comparator.comparingInt(Dish::getCalorise());
Optional<Dish> dish = menu.stream().collect(maxBy(dishComparator));

 

3. summingInt
summingInt는 객체를 int로 매핑하는 함수를 인자로 받는다. 

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

이외에도 summingDouble, averageingInt, averagingLong, averagingDouble, summarizingInt, summarizingDouble 등의 메서드등이 있고, 사용방법은 동일하다.

 

3. joining

joining은 문자열 스트림을 하나로 연결해서 반환한다. 아래 예제처럼 구분자도 전달할 수 있다. 

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

String menu = menu.stream().map(Dish::getMenu).collect(joining(", "));

 

4. reducing

reducing은 기본적으로 아래 예제처럼 초기값, 변환 함수, 두 항목을 하나의 값으로 처리하는 BinaryOperator를 인자로 받는다.

int total = menu.stream().collect(reducing(0, Dish::getCalories, Integer::sum));

위 리듀싱 예제를 보면 2025.04.24 - [자바] - [모던 자바 인 액션] 스트림 활용에서 설명하는 reduce와 결과가 동일한 것을 볼 수 있는데, 이를 통해 동일한 문제라고 하더라도 여러 방식으로 해결할 수 있음을 알 수 있다. 어떤 방법이 더 좋다는 정답이 있는 것은 아니고, 그 때 상황에 맞게 최적의 방법을 찾아서 사용할 수 밖에 없다. 

 

5. groupingBy

groupingBy는 인자로 받은 값을 기준으로 그룹핑하여 Map으로 결과를 정리하여 반환한다.

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

 

우리는 그룹화한 항목에 또 다른 처리를 하고 싶은 경우가 있는데, 이 경우 두번째 인자로 컬렉터를 제공한다. 아래 예제 코드는 그룹화한 항목에 칼로리 500인 경우만을 필터링하고 난 결과를 list 형태로 수집하라는 의미이다. 

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

 

위 예제와 유사하게 두번째 인자로 다시 groupingBy를 전달하여 다수준 그룹화를 달성할 수 있다. 

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

 

 

우리는 두번째 인자에 count, maxBy등 다양한 컬렉터를 통해서 다양한 결과를 만들어낼 수 있다.

 

그중에서도 collectingAndThen을 사용하면 다양한 형식으로 결과를 변환할 수 있다. collectingAndThen은 컬렉터와 변환함수를 인자로 받는다. 그룹화된 스트림에 컬렉터를 적용하고 그 결과에 변환함수를 적용하여 반환한다. 아래 예제에서는 각 그룹별로 가장 칼로리가 가장 높은 메뉴를 찾아서 반화하는 코드이다.

Dish.menu.stream().collect(Collectors.groupingBy(Dish::getType, Collectors.collectingAndThen(Collectors.reducing((d1, d2) -> {
            return d1.getCalories() > d2.getCalories() ? d1 : d2;
        }), Optional::get)));

 

 

6. partitioningBy

partitioningBy는 특수한 그룹핑이라고 할 수 있다. 그룹핑은 그룹의 개수에 제한이 없으나 partitioningBy는 true, false 두개의 그룹으로만 나눠진다. 따라서 인자로 프레디케이트를 받는다. 

Dish.menu.stream().collect(Collectors.partitioningBy(Dish::isVegetarian));

groupingBy처럼 두번째 인자로 컬렉터 형식을 받을 수 있어서 groupingBy, collectingAndThen 등 다양한 컬렉터와 조합해서 사용이 가능하다. 

 

 

커스텀 컬렉터

 

우리는 collector인터페이스를 직접 구현하여 새로운 컬렉터를 만들 수 있다. 컬렉터 인터페이스는 아래와 같다. 

public interface Collector<T, A, R> {
    /**
     * A function that creates and returns a new mutable result container.
     *
     * @return a function which returns a new, mutable result container
     */
    Supplier<A> supplier();

    /**
     * A function that folds a value into a mutable result container.
     *
     * @return a function which folds a value into a mutable result container
     */
    BiConsumer<A, T> accumulator();

    /**
     * A function that accepts two partial results and merges them.  The
     * combiner function may fold state from one argument into the other and
     * return that, or may return a new result container.
     *
     * @return a function which combines two partial results into a combined
     * result
     */
    BinaryOperator<A> combiner();

    /**
     * Perform the final transformation from the intermediate accumulation type
     * {@code A} to the final result type {@code R}.
     *
     * <p>If the characteristic {@code IDENTITY_FINISH} is
     * set, this function may be presumed to be an identity transform with an
     * unchecked cast from {@code A} to {@code R}.
     *
     * @return a function which transforms the intermediate result to the final
     * result
     */
    Function<A, R> finisher();

    /**
     * Returns a {@code Set} of {@code Collector.Characteristics} indicating
     * the characteristics of this Collector.  This set should be immutable.
     *
     * @return an immutable set of collector characteristics
     */
    Set<Characteristics> characteristics();
}

 

Collector<T, A, R>애서 T는 수집될 요소의 타입이고, A는 누적자의 형식, R은 연산 결과 객체의 형식을 의미한다.

 

우리는 한번 스트림의 모든 요소를 리스트로 변환해서 반환하는 ToListCollector<T> implements Collector<T, List<T>, List<T>>를 만들어보자. 

 

커스텀 collector를 구현하려면 총 5개의 메서드를 구현해야 하는데, 첫번째 Supplier<A> supplier();는 빈 누적자를 반환하는 메서드이다. 우리는 리스트에 요소들을 누적할 것이기 때문에 누적자는 빈 리스트를 반환하면 된다.

public Supplier<List<T>> supplier() {
    return () -> {
        return new ArrayList();
    };
}

 

 

두번째 BiConsumer<A, T> accumulator();는 요소에 리듀싱 연산을 수행하는 메서드를 말한다. 우리는 각 요소가 들어오면 리스트에 추가하는 연산만 수행하면 된다. 

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

 

 

Function<A, R> finisher();는 누적자 객체를 최종 객체 타입으로 변환하는 메서드를 말한다. 우리는 리스트에 누적하고 해당 누적자를 그대로 최종 타입으로 반환하면 된다. 만약에 중복을 제거해야 한다는 조건이 따로 붙는다면 누적자는 set으로 사용하고 최종 반환할 때 타입을 리스트로 바꾸는 작업이 추가될 수도 있을 것이다.

public Function<List<T>, List<T>> finisher() {
    return (i) -> {
        return i;
    };
}

 

 

BinaryOperator<A> combiner();는 병렬로 나누어져서 처리가 될 때 두 파트를 합치는 메서드이다. 우리는 단순히 리스트에 요소를 넣는 작업이기 때문에 두 서브파트를 합칠 때에는 두 리스트를 병합을 해주기만 하면 된다.

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

 

 

Set<Characteristics> characteristics();는 컬렉터 연산을 정의하는 characteristics 형식의 불변 집합을 반환한다. 이는 최적화 작업시 힌트를 제공한다.

 

UNORDERED: 방문 순서나 누적 순서에 영향을 받지 않음

CONCURRENT: 병령 리듀싱을 수행할 수 있음

IDENTITY_FINISH: finisher메서드는 단순히 identity를 적용할 뿐이므로 생략 가능

 

우리의 경우에는 모든 경우에 해당된다. 

 

지금까지 구현한 메소드들을 모아서 클래스를 만들면 아래처럼 생성할 수 있다. 

public class ToListCollector<T> implements Collector<T, List<T>, List<T>> {
    public ToListCollector() {
    }

    public Supplier<List<T>> supplier() {
        return () -> {
            return new ArrayList();
        };
    }

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

    public Function<List<T>, List<T>> finisher() {
        return (i) -> {
            return i;
        };
    }

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

    public Set<Collector.Characteristics> characteristics() {
        return Collections.unmodifiableSet(EnumSet.of(Characteristics.IDENTITY_FINISH, Characteristics.CONCURRENT));
    }
}

 

 

이는 아래처럼 사용할 수 있다. 

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

 

 

그런데, IDENTITY_FINISH의 경우 아래처럼 컬렉터 인터페이스를 구현하지 않고도 간단하게 커스텀 컬렉터를 구현할 수 있다. (단, 가독성은 다른 문제이니 각 상황에 맞게 사용해야 한다.)

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