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

Junseo Kim·2021년 2월 28일
0

컬렉터란 무엇인가?

함수형 프로그래밍은 무엇을 원하는지 직접 명시할 수 있기 때문에 어떤 방법으로 이를 얻을지는 신경 쓸 필요가 없다.

Collector 인터페이스 구현은 스트림의 요소를 어떤 식으로 도출할지 지정한다.

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

함수형 API의 장점은 높은 수준의 조합성과 재사용성이다. collect로 결과를 수집하는 과정을 간단하면서도 유연한 방식으로 정의할 수 있다는 점이 컬렉터의 최대 강점이다.

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

함수를 요소로 변환할 때 컬렉터를 적용하며 최종 결과를 저장하는 자료구조에 값을 누적한다. Collector 인터페이스의 메서드를 어떻게 구현하냐에 따라 스트림에 어떤 리듀싱 연산을 수행할지 결정된다.

미리 정의된 컬렉터

Collectors에서는 크게 3가지 종류의 메서드를 제공한다.

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

1. 리듀싱과 요약

컬렉터로 스트림의 모든 요소를 하나의 결과로 합칠 수 있다.

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

maxBy(): 스트림의 최댓값 계산. 스트림 요소를 비교하는데 사용할 Comparator를 인수로 받음

minBy(): 스트림의 최솟값 계산. 스트림 요소를 비교하는데 사용할 Comparator를 인수로 받음

요약 연산

스트림에 있는 객체의 숫자 필드의 합계나 평균 등을 반환하는 연산. 요약 연산에도 리듀싱 기능이 자주 사용된다.

summingInt(): 객체를 int로 매핑하는 함수를 인수로 받는다. int로 매핑된 요소들의 총합을 구한다. summingLong()과 summingDouble 도 존재한다.

averagingInt(): 객체를 int로 매핑하는 함수를 인수로 받는다. int로 매핑된 요소들의 평균을 구한다. averagingLong()과 averagingDouble도 존재한다.

summarizingInt(): 하나의 요약 연산으로 수, 합계, 평균, 최댓값, 최솟값 등을 구할 수 있다. IntSummaryStatistics 클래스를 반환한다. summarizingLong, summarizingDouble, LongSummartStatistics, DoubleSummaryStatistics도 존재한다.

IntSummaryStatistics menuStatistics =
        menu.stream().collect(summarizingInt(Dish::getCalories));

문자열 연결

joining(): 스트림의 각 객체에 toString을 호출하여 추출한 모든 문자열을 하나의 문자열로 연결해서 반환한다. 내부적으로 StringBuilder를 이용해서 문자열을 하나로 만든다. 인수로 구분자를 넣어주면 문자열 사이에 구분자를 추가한 하나의 문자열을 반환한다.

범용 리듀싱 요약 연산

위의 메서드들은 reducing() 메서드로 똑같이 정의할 수 있다.

reducing():

  • 첫 번째 인수로 리듀싱 연산의 시작값이거나, 스트림에 인수가 없는 경우 반환값을 받는다.
  • 두 번째 인수로 변환 함수를 받는다.
  • 세 번째 인수로 BinaryOperator를 받는다.(같은 종류의 두 항목을 계산 후 하나의 값으로 리턴한다.)

만약 reducing에 인수로 하나만 넘겨준다면 BinaryOperator를 넘겨줘야한다. 이 경우 자동적으로 reducing의 첫 번째 인수로 스트림의 첫 번째 요소를, 두 번째 인수로 자기자신을 반환하는 항등함수를 받는다.

collect vs reduce

같은 기능을 구현할 수 있지만 collect는 도출하려는 결과를 누적하는 컨테이너를 바꾸도록 설계된 메서드인 반면 reduce는 두 값을 하나로 도출하는 불변형 연산이다. reduce를 활용했을 때 가변이라면 병렬 수행시 문제가 생긴다.

상황에 맞는 해법 선택

함수형 프로그래밍에서는 하나의 연산을 다양한 방법으로 해결할 수 있다.

컬렉터를 이용하면 스트림 인터페이스에서 제공하는 메서드를 이용하는 것 보다 복잡하지만 재사용성과 커스터마이즈 가능성을 제공하는 높은 수준의 추상화와 일반화를 얻을 수 있다.

문제를 해결할 수 있는 다양한 해결 방법을 확인한 다음에 가장 일반적으로 문제에 특화된 해결책을 골라야한다.

2. 그룹화

데이터 집합을 하나 이상의 특성으로 분류해서 그룹화하는 연산.

groupingBy(): 스트림의 요소들을 인수로 넘겨주는 함수를 기준으로 그룹화한다. 분류 함수라고 부른다. 그룹화 연산의 결과로 그룹화 함수가 반환하는 키와 각 키에 대응하는 스트림의 모든 항목 리스트를 값으로 갖는 Map이 반환된다. (groupingBy(F, toList())로 toList()는 생략되어있다.)

Map<Dish.Type, List<Dish>> dishesByType = 
	menu.stream().collect(groupingBy(Dish::getType));
{
	Fish=[prawns, salmon], 
	OTHER=[french fries, rice, season fruit, pizza], 
	MEAT=[pork, beef, chicken]
}

그룹화된 요소 조작

요소를 그룹화 한 후에 각 결과 그룹의 요소를 조작하는 연산이 필요하다.

groupingBy의 두 번째 인수로 filtering(Predicate) 을 주면 스트림 요소 중 Predicate를 만족하는 요소들만 그룹화한다. 단, Map의 키 값에 해당하는 요소가 있는데 이 요소가 Predicate을 만족하지 못한다면, 키 값 자체가 없어지는 것이 아니라 키 값은 Map에 존재하고 내부에 빈 List를 가지게 된다.

Map<Dish.Type, List<Dish>> caloricDishesByType = menu.stream()
    .collect(groupingBy(Dish::getType, 
    	filtering(dish -> dish.getCalories() > 500, toList())));
{
	Fish=[], // 빈 리스트 생성. key값은 존재
	OTHER=[french fries, pizza], 
	MEAT=[pork, beef]
}

mapping(): 매핑 함수와 각 항목에 적용한 함수를 모으는 데 사용하는 또 다른 컬렉터를 인수로 받는 메서드

Map<Dish.Type, List<String>> caloricDishesByType = menu.stream()
    .collect(groupingBy(Dish::getType, 
    	mapping(Dish::getName, toList())));

다수준 그룹화

두 인수를 받는 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 =
        {
            DIET=[prawns],
            NORMAL=[salmon]
        },
        OTHER = 
        {
            DIET=[rice, seasonal fruit],
            NORMAL=[french fries, pizza]
        }
}

서브그룹으로 데이터 수집

groupingBy의 두 번째 인수로 아무 컬렉터를 넣어서 사용할 수 있다.

Map<Dish.Type, Long> typesCount = menu.stream()
	.collect(groupingBy(Dish::getType, counting()));

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

collectingAndThen(): 첫 번째 인수로 컬렉터, 두 번째 인수로 변환 함수를 받아 다른 컬렉터를 반환한다. collect의 마지막 과정에서 변환 함수로 자신이 반환하는 값을 매핑한다.

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

리듀싱 컬렉터는 Optional.empty()를 반환하지 않는다.

3. 분할

분할 함수라 불리는 Predicate를 분류 함수로 사용하는 특수한 그룹화 기능이다. Boolean을 반환하므로 맵의 키 형식은 Boolean이다. Boolean은 true, flase로 나눠지므로 2개의 그룹으로 분류된다.

partitioningBy(): 인수로 분할 함수를 넘겨주면 true / false 두 그룹으로 분류한 Map을 반환한다.

분할은 분할 함수가 반환하는 true / false 요소의 스트림 리스트를 모두 유지한다.

partitioningBy에는 두 번째 인수로 컬렉터를 넘겨줄수도 있다.

Map<Boolean, Map<Dish.Type, List<Dish>>> vegetarianDishesByType = menu.stream()
	.collect(
            partitioningBy(Dish::isVegetarian, groupingBy(Dish::getType)));

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: 수집 연산 결과 객체의 형식(대개 컬렉션 형식. 항상 그런 것은 아님)

누적 과정에서 사용되는 객체가 수집 과정의 최종 결과로 사용된다.

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

빈 결과로 이루어진 Supplier를 반환해야 한다. 즉, 수집 과정에서 빈 누적자 인스턴스를 만드는 파라미터가 없는 함수다. collect 메서드에서 실행하는 함수를 반환.

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

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

리듀싱 연산을 수행하는 함수를 반환한다. n번째 요소 탐색시, 누적자(n-1번째 요소까지 연산한 상태)와 n번째 요소를 함수에 적용한다. 함수의 반환값은 void로 요소를 탐색하면서 적용하는 함수에 의해 누적자 내부상태가 바뀌므로 누적자가 어떤 값일지 단정할 수 없다. 최적화의 핵심이다. collect 메서드에서 실행하는 함수를 반환.

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

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

스트림 탐색을 끝내고 누적자 객체를 최종 결과로 변환하면서 누적 과정을 끝낼 때 호출할 함수를 반환해야 한다. 누적자 객체가 이미 최종 결과인 상황도 존재할 수 있다. 이런 경우는 변환 과정이 필요없으므로 항등함수를 반환한다. collect 메서드에서 실행하는 함수를 반환.

public Function<List<T>, List<T>> finisher() {
    return Function.identity(); // 항등 함수
}

combiner: 두 결과 컨테이너 병합

리듀싱 연산에서 사용할 함수를 반환한다. 스트림의 서로 다른 서브 파트를 병렬로 처리할 때 누적자가 이 결과를 어떻게 처리할지 정의한다. collect 메서드에서 실행하는 함수를 반환.

스트림을 서브 스트림으로 분할한다.(일반적으로 프로세싱 코어의 개수를 초과하지 않도록) 그후, 각 서브 스트림에 리듀싱 연산을 적용하고 분할된 모든 서브스트림의 결과를 합치면서 연산이 완료된다.

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

알고리즘 자체가 순차적이라면 컬렉터를 실제 병렬로 사용할 수 없다. 따라서 그 경우는 combiner는 호출되지 않는다. 이 경우는 빈 구현으로 남겨놓거나, UnsupportedOperationException을 던지도록 구현한다.

characteristics

컬렉터의 연산을 정의하는 Characteristics 형식의 불변 집합을 반환한다. 스트림을 병렬로 리듀스할 것인지, 만약 병렬로 리듀스한다면 어떤 최적화를 선택해야 할지 힌트를 제공한다.(collect 메서드가 어떤 최적화(ex. 병렬화)를 이용해서 리듀싱 연산을 수행할 것인지 결정하도록 돕는 힌트 특성 집합 제공.) 아래의 세 항목을 포함하는 열거형이다.

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

  • CONCURRENT: 다중 스레드에서 accumulator 함수를 동시에 호출할 수 있으며 이 컬렉터는 스트림의 병렬 리듀싱을 수행할 수 있다. 컬렉터의 플래그에 UNORDERED를 함께 설정하지 않았다면 데이터 소스가 정렬되어 있지 않은 상황에서만 병렬 리듀싱을 수행할 수 있다.

  • IDENTITY_FINISH: finisher 메서드가 반환하는 함수는 단순히 identity를 적용할 뿐이므로 이를 생략할 수 있다. 따라서 리듀싱 과정의 최종 결과로 누적자 객체를 바로 사용할 수 있다. 또한 누적자 A를 결과 R로 안전하게 형변환할 수 있다.

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

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

IDENTITY_FINISH 수집 연산에서는 완전히 Collector 인터페이스를 구현하지 않고, collect 메서드에 발행 함수, 누적 함수, 합침 함수를 인수로 주어 연산할수도 있다.

List<Dish> dishes = menuStream.collect(
    ArrayList::new, // 발행 함수
    List::add, // 누적 함수
    List::addAll // 합침 함수
);

Collector 인터페이스를 구현하는 것 보다 간결하지만 가독성이 떨어진다. 적절한 클래스로 커스텀 컬렉터를 구현하는 것이 중복을 피하고 재사용성을 높이는 데 도움이 된다.

0개의 댓글