10 min read

모던 자바 인 액션 - Chapter 6 <스트림으로 데이터 수집> 2

분할

분할은 분할 함수라 불리는 프리디케이트를 분류 함수로 사용하는 특수한 그룹화 기능입니다.분할 함수는 불리언 값을 반환하기 때문에 그룹화된 Map의 Key의 Type은 Boolean입니다.결과적으로 그룹화된 Map의 Key는 True or False만을 가지는 두 개의 그룹으로 분류됩니다.

간단한 예제를 통해 분할을 사용해봅시다.

	@Test
    void partitioning() {
        Map<Boolean, List<Dish>> vegi = menu.stream()
                .collect(Collectors.partitioningBy(Dish::isVegetarian));

        vegi.get(true).stream().map(Dish::getName).forEach(System.out::println);
    }

partitioningBy이라는 새로운 분류 함수를 통해 아주 간단하게 채식 주의자를 위한 요리 목록을 가져올 수 있습니다.

물론 해당 예제는 filter() 메서드를 사용해도 동일한 결과를 얻을 수 있습니다.

분할의 장점

분할 함수의 경우,반환하는 두 가지 타입의 리스트를 유지하는것이 가장 큰 장점입니다.쉽게 말해 프리디케이트 기준 참인 요소들의 리스트와 거짓인 요소들의 리스트들을 각각 유지시켜줍니다.이번 예제에서는 partitioningBy의 두번째 인수로 Collector를 전달하는 예제를 살펴보겠습니다.이전 예제와 마찬가지로 채식용 Dish로 그룹화한 다음 그룹화된 Map내에서 두번째 그룹화로 Dish.Type을 기준으로 그룹화 시켜보겠습니다.

	@Test
    void partitioningWithType() {
        Map<Boolean, Map<Dish.Type, List<Dish>>> vegi = menu.stream()
                .collect(Collectors.partitioningBy(Dish::isVegetarian, Collectors.groupingBy(Dish::getType)));

        for (Map.Entry<Boolean, Map<Dish.Type, List<Dish>>> entry : vegi.entrySet()) {
            System.out.println("entry.getKey() = " + entry.getKey());
            for (Map.Entry<Dish.Type, List<Dish>> entry1 : entry.getValue().entrySet()) {
                System.out.println("entry1.getKey() = " + entry1.getKey());
                entry1.getValue().stream().map(Dish::getName).forEach(System.out::println);
            }
            System.out.println("==========================");
        }
    }

결과에서 확인 가능하듯이 채식 요리인 스트림과 채식이 아닌 요리의 스트림 내에서 각각 Dish.TYPE을 통해 그룹화하여 두개의 수준을 가지는 Map이 반환됩니다.

마지막 예제로 채식 요리와 채식 요리가 아닌 스트림 요소들 중 가장 칼로리가 높은 요리를 뽑아내 보겠습니다.

@Test
    void partitioningWithMaxCalories() {
        Map<Boolean, Dish> max = menu.stream()
                .collect(Collectors.partitioningBy(Dish::isVegetarian,
                        Collectors.collectingAndThen(Collectors.maxBy(Comparator.comparingInt(Dish::getCalories)),
                                Optional::get)));

        for (Map.Entry<Boolean, Dish> entry : max.entrySet()) {
            System.out.println("entry.getKey() = " + entry.getKey());
            System.out.println("entry.getValue() = " + entry.getValue().getName());
        }
    }

이렇게 partitioningBy 활용하여 두가지 기준을 통해 스트림을 그룹화하는 예제를 알아보았습니다.추가적으로 그룹화된 요소들의 개수를 Map으로 반환한다거나 프리디케이트를 통해 그룹화된 요소들을 한번 더 일정 칼로리를 기준으로 그룹화하는 예제 또한 만들어 볼 수 있습니다.

소수 판별을 통한 분할

정수 n을 인수로 받아서 2부터 n까지의 수들을 소수와 비소수로 나누는 예제를 분할을 통해 구현해보겠습니다.우선 소수인지 판단하는 프리디케이트 함수부터 구현합니다.인수로 들어온 값이 소수인지 아닌지를 판별하기에 sqrt(루트)를 활용해 시간 복잡도를 최적화합니다.

 	/*
    * 소수 판별 할 대상을 제곱근 이하의 수로 제한하는 방법
    * */
    private boolean isPrime(int candidate) {
        int candidateRoot = (int) Math.sqrt((double) candidate);
        return IntStream.rangeClosed(2, candidateRoot)
                .noneMatch(i -> candidate % i == 0);
    }

이후 partitioningBy를 통해 분할을 해보겠습니다.

	@Test
    void predicatePrimeNumberWithCollector() {
        Map<Boolean, Long> primeNum = IntStream.rangeClosed(2, 100)
                .boxed()
                .collect(Collectors.partitioningBy(this::isPrime, Collectors.counting()));

        for (Map.Entry<Boolean, Long> entry : primeNum.entrySet()) {
            System.out.println("entry.getKey() = " + entry.getKey());
            System.out.println("entry.getValue() = " + entry.getValue());
        }
    }

아주 간단하게 특정 수까지의 소수와 비소수의 개수 정보를 얻어 올 수 있습니다.

Collector 인터페이스

컬렉터 인터페이스는 리듀싱 연산을 어떻게 구현할지 제공하는 메서드 집합으로 구성됩니다.지금까지 toList,groupingBy 등의 Collector 인터페이스를 구현하는 많은 컬렉터를 사용해봤습니다.이번에는 가장 간단한 컬렉터인 toList를 살펴보면서 어떻게 Collector 인터페이스가 구현되는지 살펴보겠습니다.

다음 코드는 Collector 인터페이스의 시그니처와 추상 메서드 정의입니다.

public interface Collector<T, A, R> {
    Supplier<A> supplier();
    BiConsumer<A, T> accumulator();
    BinaryOperator<A> combiner();
    Function<A, R> finisher();
    Set<Characteristics> characteristics();
}

우선 제네릭 형식들부터 설명하겠습니다.

  • T는 수집될 스트림 항목의 제네릭 형식입니다.
  • A는 누적자,즉 수집과정에서 중간 결과를 누적하는 객체의 형식입니다.
  • R은 수집 연산 결과 객체의 형식입니다.(대게 컬렉션 타입)

예를 들어 Stream<T>의 모든 요소를 List<T>로 수집하는 컬렉터를 구현하면 아래와 같습니다.

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

누적자,즉 수집 과정에서 사용되는 객체가 곧 수집 과정의 최종 객체로 사용됩니다.

지금부터는 Collector 인터페이스에 청의된 각각의 추상메서드들에 대해 알아보겠습니다.

supplier() : 새로운 결과 컨테이너 만들기

supplier 메서드는 빈 결과로 이루어진 Supplier를 반환해야 합니다.즉,supplier는 요소들은 수집하는 과정에서 비어있는 누적자 인스턴스를 만드는 파라미터가 없는 함수입니다.만약 위의 ToListCollector처럼 누적자를 반환하는 컬렉터에서는 비어있는 누적자가 스트림 요소가 존재하지 않는 스트림의 수집과정의 결과가 될 수 있습니다.참고로 ToListCollector에서 supplier 메서드는 빈 리스트를 반환합니다.

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

accumulator() : 결과 컨테이너에 스트림 요소 추가하기

accumulator 메서드는 리듀싱 연산을 수행하는 함수를 반환합니다. 스트림에서 n번째 요소를 탐색할 때 두개의 인수를(누적자(스트림 내에서 n-1번째 요소들까지 수집한 상태),n번째 요소) 함수에 적용한다.함수의 반환값은 추상 메서드에는 BiConsumer<A, T>라고 되어있다. BiConsumer라는 함수형 인터페이스는 앞서 말한 두개의 인수를 받고 누적자에 해당 연산을 적용시키는 accept()라는 추상 메서드를 가집니다.즉,요소를 탐색하면서 적용하는 함수에 의해 누적자의 내부상태를 변화시킵니다.이러한 변화로 인해 누적자가 어떤 값인지 모르기에 반환값이 void입니다. ToListCollector에서는 accumulator가 반환하는 함수는 이미 탐색한 항목을 포함하는 리스트에 해당 항목을 추가하는 연산을 수행합니다.

정리하자면,accumulator는 누적자와 다음번째 스트림의 요소가 수행해야할 동작이 담겨있는 void를 반환하는 메서드를 리턴합니다.

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

finisher() : 최종 변환값을 결과 컨테이너로 적용하기

finisher 메서드는 스트림 요소들의 탐색을 마치고 누적자 객체를 최종 결과로 변환하면서 누적 과정을 끝낼 때 호출할 함수를 반환해야합니다.때로는 ToListCollector처럼 누적자 객체 자체가 이미 반환할 최종 결과인 경우도 있습니다.이러한 경우에는 변환 과정이 따로 필요하지 않으므로 finisher 메서드는 항등 함수를 반환합니다.

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

combiner() : 두 결과 컨테이너 병합

마지막으로 리듀싱 연산에서 사용할 함수를 반환하는 4번째 메서드인 combiner()입니다.combiner는 스트림의 서로 다른 서브파트를 병렬로 처리할 때 누적자가 이 결과를 어떻게 처리할지 정의합니다. ToListCollector의 combiner를 간단합니다.스트림의 두 번째 서브파트에서 수집한 항목 리스트를 첫 번째 서브파트의 리스트에 뒤에 이어 추가하면 됩니다.

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

combiner 메서드를 활용하면 스트림의 리듀싱을 병렬로 수행할 수 있습니다.

characteristics() : 컬렉터의 연산을 정의

자바 공식 문서에 의하면 해당 함수를 아래와 같이 설명하고 있습니다.

Returns a Set of Collector. Characteristics indicating the characteristics of this Collector. This set should be immutable.

해당 컬렉터의 연산을 정의하는 Characteristics 타입의 Set 객체를 반환합니다. Characteristics는 아래와 같은 3가지의 항목을 가지는 enum입니다.아래는 실제 정의된 enum입니다.

각각 항목에 대해 알아보겠습니다.

  • CONCURRENT : 다중 스레드에서 accumulator 메서드를 동시에 호출할 수 있으며,이 컬렉터는 스트림의 병렬 리듀싱을 수행할 수 있습니다.만약 UNORDERED 항목과 함께 설정 되지 않으면 데이터 소스가 정렬되어 있지 않은 상황(set처럼 내부 요소들의 순서가 상관없는 상황)에서만 병렬 리듀싱을 수행할 수 있습니다.
  • UNORDERED : 리듀싱 결과는 스트림 요소의 방문 순서나 누적 순서에 영향을 받지 않습니다.공식 문서의 내용과 함께 이해해봅시다.컬렉터의 연산은 컬렉션의 스트림 요소들에 조작을 가하게 됩니다.만약 UNORDERED를 Set에 포함시키는 경우 연산이 가하는 이러한 조작이 입력 요소의 발견 순서를 보존해주는 연산이라는 의미입니다.
  • IDENTITY_FINISH : finisher 메서드가 반환하는 함수는 단순히 identity를 적용하는 경우 사용가능합니다.즉,리듀싱 과정에서의 최종 결과를 곧바로 누적자 객체로 바로 적용할 수 있는 메서드입니다.

앞서 저희가 구현한 ToListCollectorIDENTITY_FINISH을 가질 수 있습니다.또한 CONCURRENT이여야 하고,리스트의 순서가 상관 없기에 UNORDERED입니다.하지만 요소의 순서가 상관이 없어야 병렬로 실행 가능합니다.

지금까지의 내용을 토대로 ToListCollector를 완성시켜 보시길 바랍니다.