📗 개발자 책 읽기/모던 자바 인 액션

[모던자바인액션] chapter6. Stream Collect 메소드 최종연산에 대하여 - collector, collectors

민돌v 2022. 9. 20. 00:27

 

 

" Modern Java In Action - 모던 자바 인 액션, 전문가를 위한 자바 8, 9, 10 기법 가이드 "
독서 스터디 후, 책 내용을 정리한 글입니다.


1) Chapter 1. 자바 8, 9, 10, 11 : 무슨일이 일어나고 있는가?
2) Chapter 2. 동작 파라미터화 코드 전달하기
3) Chapter 3. 람다 표현식
4) Chapter 4. Stream
5) Chapter 5. Stream 활용
6) Chapter 6. Stream으로 데이터 수집하기 
7) Chapter 7. Stream 병렬 데이터처리와 성능

 

 

Chapter6 에서는, 스트림 최종연산인 collect 사용방법에 대해서 이야기합니다.

 

[목차]

  1. 컬렉터란
  2. 컬렉터 인터페이스 (Stream Collector Interface)
  3. 컬렉터스 메소드  (Stream Collectors Method)

 


 

💡 스트림은 데이터 집합을 멋지게 처리하는 게으른 반복자라고 설명할 수 있습니다.

  • 스트림 중간 연산은 스트림 파이프라인을 구성하며, 스트림의 요소를  소비하지 않습니다.
  • 반면 최종연산은 스트림의 요소를 소비해서 최종 결과를 도출합니다. 
  • 최종 연산은 스트림 파이프라인을 최적화하면서 계산 과정을 짧게 생략하기도 합니다.

 

 

지금까지 collect 로 toList로 변환하기만 했지만,

최종연산인 Collect 는 마찬가지로 최종연산인 reduce 처럼 다양한 누적 요소 방식을 인수로 받아서 스트림을 최종 결과로 도출하는 리듀싱 연산을 수행할 수 있습니다.

 

Stream 최종연산인 collect 로 리듀싱 연산을 할 수 있는 다양한 요소 누적 방식은 Collector 인터페이스에 정의되어있습니다.

➡️ collect 메소드로 Collector  구현을 전달

 

 

 


1. 컬렉터란 (Collector)

Stream 에서 collector 는 종단연산에 해당하는 .collect() 함수의 파라미터에 해당하는 인터페이스 입니다. 

최종연산 collect 에서 collector 추상 메소드의 구현을 받아 어떻게 reduce 할것인지를 결정합니다.

 

Java 에 정의된 Stream 의 collect 형식을 보면, 매겨변수로 컬렉터 인터페이스를 받고있음을 확인할 수 있었습니다. 

👉 이때, Collector 인터페이스를 구현한 클래스에 커스텀 메서드를 구현해도되고, Collector를 구현하는 java.util.stream.Collectors.java 클래스의 정적 메서드를 이용할 수 있습니다.!

 

 

📗 자바 8 이전에는 인터페이스가 정적 메소드를 가질 수 없었기 때문에, 인터페이스 Class에 s를 붙여 (ex collectors) 정적 메소드를 제공하는 관습이 있었다고 합니다. 

그래서 collectors 메소드에서 collector 인터페이스 메소드들을 구현한 정적 메소드들이 보였있는 느낌이랄까..?

(ex. Collection<E> → Collections, Comparator → Comparators, ...)

 

 

java.util.stream.Collectors.java 클래스의 정적 메서드를 보기전에 간단하게 Collector Interface 를 가볍게 보구 넘어갑니다.

 


2. 컬렉터 인터페이스 종류

함수형 프로그래밍에서는 '무엇'을 원하는지 직접 명시할 수 있어서 '어떤 방법'으로 이를 얻을지는 받는쪽에서 신경을 쓰지 않아도 되도록 구성합니다.

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

 

  • T : 수집될 스트림 항목의 제네릭 형식
  • A : 누적자, 즉 수집 과정에서 중간 결과를 누적하는 객체의 형식
  • R : 수집 연산 결과 객체의 형식(항상 그런것은 아니지만 대게 컬렉셕 형식)
public interface Collector<T, A, R> {

    Supplier<A> supplier();

    BiConsumer<A, T> accumulator();

    BinaryOperator<A> combiner();

    Function<A, R> finisher();

    Set<Characteristics> characteristics();
}
  • 앞에 4가지는 collect 메소드에서 실행하는 함수를 반환하고
  • 마지막 characteristics() 는 collect() 가 어떤 최적화 연산으로 리듀싱을 진행할것인지 결정하는 힌트 특성 집합을 반환합니다.

 

 

1. Supplier<A> supplier(); - 새로운 결과 컨테이너 만들기

📌 새로운 변경가능한 결과 컨테이너를 생성하고 반환하는 함수입니다.

  •  수집 과정에서 빈 누적자 인스턴스를 만드는 파라미터가 없는 함수이다. 컬렉터에서 결과에 대한 처리를 할 때 빈 객체로 대응하기 위함입니다.
  • supplier 메서드는 빈 결과로 이루어진  Supplier 를 반환해야합니다. 즉 supplier 는 수집 과정에서 빈 누적자 인스턴스르 만드는 파라미터가 없는 함수 입니다.

 

2. BiConsumer<A, T> accumulator(); - 결과 컨테이너에 요소 추가하기

📌  값을 변경 가능한 결과 컨테이너로 접는 함수

  • 리듀싱 연산을 수행하는 함수를 반환합니다. 스트림에서 n번째 요소를 탐색할 때, 두 개의 인수(리듀싱된 누적자, 해당 요소)를 받습니다.
  • accmulator 메소드는 리듀싱 연산을 수행하는 BiConsummer 함수를 반환합니다.

  1. 누적자 (스트림의 n-1개 항목을 수집한 상태 )
  2. n번째 요소return (list, item) -> list.add(item);
    • finisher() : 최종 변환값을 결과 컨테이너로 적용하기
    finisher 메서드는 스트림 탐색을 끝내고 누적자 객체를 최종 결과로 변환하고, 누적 계산이 완료될 때 호출할 함수를 반환해야 한다. 이미 누적자 객체 자체가 최종 결과일 경우 finisher는 항등함수를 반환한다.
  3. 위의 인수로 n번째 요소에 함수를 적용한다.

 

3. BinaryOperator<A> combiner(); - 두 결과 컨테이너의 병합

📌 두 개의 부분 결과를 받아 병합하는 함수

  • combiner는 스트림의 서로 다른 서브파트를 각각 처리하고 누적자가 이 결과를 어떻게 병합할지 정의합니다.
  • 예를 들면, toList()의 경우, 나눠서 계산된 서브파트A, B가 있을 때, 누적자는 A의 결과에 B의 결과를 붙이기만 하면 됩니다.
  • 해당 메서드를 이용함으로써 스트림의 리듀싱을 병렬로 처리할 수 있습니다.


4. Function<A, R> finisher(); - 최종 변환값을 결과 컨테이너에 적용

📌 중간 누적 유형 A에서 최종 결과 유형 R로 최종 변환을 수행합니다.

  • finisher 메서드는 스트림 탐색을 끝내고 누적자 객체를 최종 결과로 변환하면서 누적 과정을 끝낼 때 호출할 함수를 반환해야 합니다.
  • ToListCollector 처럼 누적자 객체가 이미 최종 결과인 상황에는 변환과정이 필요하지 않으므로 항등함수를 반환합니다.


5. Set<Characteristics> characteristics(); - 컬렉터의 연산을 정의

📌 Collector의 특성을 나타내는 Collector.Characteristics Set 을 반환합니다. 이 집합은 변경될 수 없습니다.

  • UNORDERED -  리듀싱 결과가 스트림 요소의 방문 순서나 누적 순서에 영향을 받지 않는다.
  • CONCURRENT - 다중 스레드에서 accumulator 함수를 동시에 호출할 수 있고 병렬 리듀싱을 수행할 수 있다.
  • IDENTITY_FINISH - 리듀싱 과정의 최종 결과로 누적자 객체를 바로 사용할 수 있게하고, 누적자 A를 결과 R로 안전하게 형변환

public class test implements Collector {

    @Override
    public Supplier supplier() {
        return ArrayList::new;
//        return () -> new ArrayList<>();
    }

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

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

    @Override
    public Function finisher() {
        //항상 입력 인수를 반환하는 함수
        return Function.identity();
    }

    @Override
    public Set<Characteristics> characteristics() {
        return null;
    }

}

 

 

 


3. Collectors  메소드 

미리 구현된 컬렉터 구현들의 집합인, collectors 메소드들을 살펴보겠습니다.

요약

  • counting
  • maxBy, minBy
  • summingInt, summingLong, summingDouble
  • averagingInt, averagingLong, averagingDouble
  • summarizingInt, summarizingLong, summarizingDouble
  • joining
  • toList, toSet, toCollection

다수준 그룹화

  • groupingBy, collectingAndThen

분할

  • partitioningBy

 


 

1) 리듀싱과 요약

컬렉터(Stream.collect 메소드의 인수) 로 스트림의 항복을 컬렉션으로 재구성 할 수 있습니다.

조금 더 일반적으로 말하자면, 컬렉터로 스트림의 모든 항목을 하나의 결과로 합칠 수  있습니다.

이러한 연산을 리듀싱이라 하며, 혹은 요약 연산이라고도 합니다

 

(1) counting - 개수

long menuCount = menu.stream().count();
//long menuCount = menu.stream().collect(Collectors.counting());
  1. 위 코드의 주석처럼 스트림의 .count()로 가볍게 처리할 수도 있지만, 다른 Collector와 counting을 섞어서 쓸 때 조금 더 유연하게 쓸 수 있어서 좋을 때가 있다고 합니다.
  2. 결과적으로 Collectors.counting()의 경우 스트림의 개수로 요약합니다.

(2) maxBy, minBy - 최댓값과 최솟값

//1. Comparator 구현
Comparator<User> userCareerComparator = Comparator.comparingInt(User::getCareer);

//2. 직원(User)의 경력(Career)을 기준으로 비교/정렬하는 Comparator 전달
Optional<User> longestCareerUser = users.stream().collect(Collectors.maxBy(userCareerComparator));
  1. maxBy(Comparator comparator) 는 파라미터로 비교/정렬을 위한 기준을 제공하는 Comparator를 받고, Comparator를 기준으로 최댓값을 갖는 객체로 요약해서 리턴합니다.
  2. 마찬가지로 minBy(Comparator comparator) 는 Comparator를 기준으로 최솟값을 갖는 객체로 요약해서 리턴합니다.

 


 

(3) summingInt, averagingInt, summarizingInt - 숫자 타입 요약 연산 (합계, 평균, 통계)

  • 숫자 합계 (sum)
int totalSalary = users.stream().collect(Collectors.summingInt(User::getSalary));
  1. summingInt() 는 스트림에 있는 객체를 int 타입으로 매핑하는 함수를 파라미터로 받습니다.
  2. 위 코드에서는 각 User 객체의 급여(int로 가정)의 총합계로 요약합니다.
  3. Collectors.summingLong(), Collectors.summingDouble()은 스트림에 있는 객체가 매핑되는 타입만 int에서 long, double로 바뀔 뿐 "합계"로 요약하는 것은 같습니다.

 

  • 숫자 평균 (avg)
double avgSalary = users.stream().collect(Collectors.averagingInt(User::getSalary));
  1. averagingInt() 는 스트림에 있는 객체를 int 타입으로 패밍하는 함수를 파라미터로 받습니다.
  2. 위 코드에서는 각 User 객체의 급여(int 로 가정)의 평균으로 요약합니다.
  3. Collectors.averagingLong(), Collectors.avaragingDouble()은 마찬가지로 매핑되는 타입만 다를 뿐 "평균"으로 요약합니다.

 

  • 숫자 통계 
IntSummaryStatistics salaryStatistics = users.stream().collect(Collectors.summarizingInt(User::getSalary));
//결과 값
IntSummaryStatistics{
	count=10, sum=40000000, min 2980000,
        average=4000000, max=5840000
}
  1. 여러 통계를 종합해놨습니다. 그래서 리턴 타입도 IntSummaryStatistics 인 것을 확인할 수 있습니다. (객체를 콘솔로 찍어보면 아래와 같이 나옵니다.)
  2. 위의 2개 연산에 대한 결과값이 필요할 때 가있습니다.
  3. 그런 상황을 위해, 통계 합의 결과들을 출력해주는 통계함수 summarizing 함수가 존재합니다.

 


(4) joining - 문자열 연결

String employeeNames = users.stream().map(User::getName).collect(joining());
//MeganAddisonAmeliaElla
String employeeNames = users.stream().map(User::getName).collect(joining(", "));
//Megan, Addison, Amelia, Ella
  1. joining은 내부적으로 StringBuilder를 이용해서 객체들의 toString() 메서드를 호출한 결과(String)를 연결하여 요약된 문자열을 만듭니다.
  2. 오버로딩된 joining메소드도 있어서 중간에 연결할 때 사이에 들어갈 문자열을 지정할 수도 있습니다.

 


(5) reducing - 기본에 충실한 요약 (범용 리듀싱 요약)

  1. 지금까지 살펴본 모든 컬레거터는 reducing 팩토리 메소드로도도 정의할 수 있습니다.
  2. 그럼에도 불구하고 collectors 메소드를 사용하는 이유는 프로그래밍적인 편의성과 가독성을 위해서입니다.

 

아래는 예시입니다.

오버로드된 reducing()메소드는 잘 참고해서 사용해야합니다.

기본적으로 파라미터를 받는데, (초기값[또는 스트림이 비었을 때 값], 변환 함수, 같은 종류의 두 항목을 하나로 만드는 함수) 이렇게 3개를 파라미티로 받습니다.

int totalSalary = users.stream().collect(reducing(0, User::getSalary, (i,j) -> i+j));

위 코드처럼 작성하면 0을 초기값으로하고 스트림으로 들어오는 객체(User)를 급여로 변환하고, 그 변환된 급여 두 항목을 더하여 하나로 만드는 함수를 통해 급여의 총합을 구하는 것을 확인할 수 있습니다.
  • 그렇다면, 왜 같은 기능을 수행할 수 있는 collect 와 reduce를 나누어서 사용할까요?
  • 2 메소드의 결과를 도출하는 과정이 다르기 때문입니다.

 

✨ collect vs reduce

  • collect : 도출하려는 결과를 누적하는 컨테이너를 바꾸도록 설계된 메서드
  • reduce : 두 값을 하나로 도출하는 불변형 연산이라는 점에서  두 메서드의 의미가 다릅니다.

 

병렬 처리에서, reduce는  누적자로 사용된 리스트를 변환시키기 때문에 여러 스레드에서 리스트를 동시에 고쳐 리스트 자체가 망가질 수 있는 문제가 발생합니다.

이 문제를 해결하기 위해서는 매번 새로운 리스트를 할당해주어야하고, 병렬처리에서 성능저하로 이어지게 됩니다.

📌 따라서 이러한, 가변 컨테이너 관련 작업이면서 병렬성을 확보하려면 collect 메서드로 리듀싱 연산을 구현하는 것이 바람직합니다.

 


 

(2) 그룹화

  • stream의 요소를 마치 데이터베이스 연산처럼, 특정 기준으로 그룹핑하는 작업을 자바 스트림에서 간단하게 명령형으로 구현할 수 있도록 제공합니다!
  • 파라미터로 전달하는 메소드로 스트림이 그룹화되므로 이를 분류함수라고도 합니다

 

(1) groupingBy

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

/* Map 결과
{FISH=[prawns, salmon], MEAT=[pork,beef,chicken] }
*/
  1. groupingBy의 파라미터로 온 분류함수를 기준에 의해 Map의 key로가고 객체들은 value로 그룹화되었습니다.
  2. 위에서는 단순히 getType으로 타입에 따라 그룹핑하기 때문에 분류 함수 느낌이 안 납니다.
  3. 그리고 여러 조건에 의해서 분류할 수 도 있습니다
//ex. 칼로리로 분류하려면?
groupingBy(dish -> {
	if(dish.getCalories() <= 400) return CaloricLevel.DIET;
	else if(dish.getCalories() <=700) return CaloricLevel.NORMAL;
	else return CaloricLevel.FAT;
}));

 

(2) 그룹화된 요소 조작

  1. 스트림 요소를 그룹핑한 이후, 각 결과 그룹 요소를 조작하는 연산을 가하고 싶을 때가 분명히 있습니다.
  2. 이를 위해  groupingBy 메서드 이전에 Predicate을 이용해서 fileter 등 과 같은 메서드를 걸어주어도 되지만
  3. 기준에 따라 삭제되는 요소가 없게 하고 싶다면, grouping 후 연산도 가능합니다!
Map<Dish.Type, List> dishesByType = 
 menu.stream().filter(dish -> dish.getCalories() > 500)
   .collect(groupingby(Dish::getType));
{OTHER=[A], MEAT = [B, C]}

⬇️

 

Map<Dish.Type, List> dishesByType = 
 menu.stream()
   .collect(groupingby(Dish::getType), filtering(dish -> dish.getCalories() > 500), toList());
{OTHER=[A], MEAT = [B, C], FISH=[]}

 


(3) 다수준 그룹화 (중첩 그룹화)

  1. 위에서 사용한 것은 그룹화가 한 단계만 되어있습니다만, 다 단계로 그룹화할 수도 있습니다.
  2. groupingBy는 일반적으로 분류 함수와 컬렉터를 인수로 받는데 groupingBy 메서드가 컬렉터를 반환하는 특성에 따라서 groupingBy 메서드를 중첩시킬 수 있습니다
menu.stream.collect(
	groupingBy(Dish::getType,    //첫 번째 분류함수
 		groupingBy(dish -> {       //두 번째 분류함수
 			if(dish.getCalories() <= 400) return CaloricLevel.DIET;
 			else if(dish.getCalories() <= 700) return CaloricLevel.NORMAL;
 		})
 	)
); 

 

//결과 -- 대충 적은 것
{
 	OTHER={DIET=[A], GOOD=[B,D], BAD=[C]},
	STAFF=={DIET=[A], GOOD=[B,D], BAD=[C]},
 	CEO={A=[C]}
}

바깥에 있는 groupingBy에 의해서 타입(getType)별로 그룹화를 한 후에, 안에 있는 groupingBy에 의해서 다시 칼로리(calories)별로 그룹화한 결과로 맵 안에 맵을 갖는 자료형태를 갖게 된 것을 확인할 수 있습니다. 😀

 

어떻게 이렇게 할 수 있었는지는 groupingBy의 메소드 시그니처를 보면 알 수 있습니다.

public static <T, K, A, D>
Collector<T, ?, Map<K, D>> groupingBy(Function<? super T, ? extends K> classifier,
                                      Collector<? super T, A, D> downstream) {
    return groupingBy(classifier, HashMap::new, downstream);
}
  1. 위와 같이 groupingBy 의 첫 번째 파라미터는 분류 함수를 받고, 두 번째 파라미터는 Collector를 받습니다.
  2. 그래서 이 두 번째 파라미터에 있는 Collector로 그루핑의 연계가 가능합니다.

 

이것을 이용하면 2개 이상 수준의 다단계 그룹화도 가능합니다.

 

앞에서 1단계 그룹화를 했던 것은 Collector 부분에 toList()를 사용한 오버로딩된 정적 메소드를 사용한 것을 확인할 수 있습니다.

public static <T, K> Collector<T, ?, Map<K, List<T>>>
    groupingBy(Function<? super T, ? extends K> classifier) {
    return groupingBy(classifier, toList());
}

 


(3) 분할

  1. 분할은 분할 함수라 불리는 프리디케이트를 분류 함수로 사용하는 특수한 그룹화 기능입니다.
  2. 분할 함수는 Boolean을 반환하므로 맵의 키 형식은 Boolean( true, false) 입니다.

 

  partitioningBy 메서드

Map<Boolean, List> partitionedMenu =
	menu.stream.collect(partitioningBy(Dish::isVegetarian));
/*
{false = [pork, beef, chicken, salmon],
true = [french fries, rice, fruit]}
*/
  • 분할은 그룹화랑 다른 게 하나 밖에 없습니다.
  • 그룹화는 N개의 그룹으로 그룹화할 수 있는 반면, 분할은 무조건 2개의 그룹으로 그룹화할 수 있는 것입니다. (2분할!)
  • 이걸 뭐하러 쓰지? 할 수도 있는데 더 명확하게 표현할 수 있는 장점이 있습니다.
  • 정규직이냐 비정규직이냐 나눌 때를 예로 든다면, 이와 같은 분할이 훨씬 명확합니다.
  • 그룹화로 시도한다면 코드를 읽다가 오해를 살 수도 있습니다. (정규직, 무기계약직, 계약직, ...)

 

 

 

 

 

 

 

 


*참고