[Java] Stream - Collector 와 Colletors

푸드테크·2022년 9월 19일
0

안녕하세요 푸드테크팀 백엔드 개발자 박형민 입니다

이번 포스팅에서는
1. Stream 에서 제공하는 최종연산인 collect() 와
2. 그 파라미터로 들어가는 collector 인터페이스,
3. collector를 구현한 collectors에 대해서 정리해보도록 하겠습니다!


1. 컬렉터란 (Collector)

  • Stream 에서 collector 는 종단연산에 해당하는 .collect() 함수의 파라미터에 해당하는 인터페이스 입니다. 
  • 최종연산 collect 에서 collector 추상 메소드의 구현을 받아 어떻게 reduce 할것인지를 결정합니다.

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

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



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();

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

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

2. accumulator();

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

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

3. combiner();

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

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

4.finisher();

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

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

5. 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 메소드들을 살펴보겠습니다.

1) 리듀싱과 요약

  • 컬렉터(Stream.collect 메소드의 인수) 로 스트림의 항복을 컬렉션으로 재구성 할 수 있습니다.
  • 조금 더 일반적으로 말하자면, 컬렉터로 스트림의 모든 항목을 하나의 결과로 합칠 수  있습니다.
  • 이러한 연산을 리듀싱이라 하며, 혹은 요약 연산이라고도 합니다

(1) counting - 개수

long menuCount = menu.stream().count();
//long menuCount = menu.stream().collect(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));
  • maxBy(Comparator comparator) 는 파라미터로 비교/정렬을 위한 기준을 제공하는 Comparator를 받고, Comparator를 기준으로 최댓값을 갖는 객체로 요약해서 리턴합니다.

  • 마찬가지로 minBy(Comparator comparator) 는 Comparator를 기준으로 최솟값을 갖는 객체로 요약해서 리턴합니다.

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

//숫자 합계 (sum)
int totalSalary = users.stream().collect(Collectors.summingInt(User::getSalary));

 
//숫자 평균 (avg)
double avgSalary = users.stream().collect(Collectors.averagingInt(User::getSalary));

 
//숫자 통계 
IntSummaryStatistics salaryStatistics = users.stream().collect(Collectors.summarizingInt(User::getSalary));

//결과 값
IntSummaryStatistics{
	count=10, sum=40000000, min 2980000,
        average=4000000, max=5840000
}

(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

joining은 내부적으로 StringBuilder를 이용해서 객체들의 toString() 메서드를 호출한 결과(String)를 연결하여 요약된 문자열을 만듭니다.


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

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

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

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

int totalSalary = users.stream().collect(reducing(0, User::getSalary, (i,j) -> i+j));
  • 위 코드처럼 작성하면 0을 초기값으로하고 스트림으로 들어오는 객체(User)를 급여로 변환하고,
  • 그 변환된 급여 두 항목을 더하여 하나로 만드는 함수를 통해 급여의 총합을 구하는 것을 확인할 수 있습니다.



✨ 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] }
*/

groupingBy의 파라미터로 온 분류함수를 기준에 의해 Map의 key로가고 객체들은 value로 그룹화되었습니다.

위에서는 단순히 getType으로 타입에 따라 그룹핑하기 때문에 분류 함수 느낌이 안 납니다.
그리고 여러 조건에 의해서 분류할 수 도 있습니다


//ex. 칼로리로 분류하려면?
groupingBy(dish -> {
	if(dish.getCalories() <= 400) return CaloricLevel.DIET;
	else if(dish.getCalories() <=700) return CaloricLevel.NORMAL;
	else return CaloricLevel.FAT;
}));

(2) 그룹화된 요소 조작

스트림 요소를 그룹핑한 이후, 각 결과 그룹 요소를 조작하는 연산을 가하고 싶을 때가 분명히 있습니다.

이를 위해  groupingBy 메서드 이전에 Predicate을 이용해서 fileter 등 과 같은 메서드를 걸어주어도 되지만
기준에 따라 삭제되는 요소가 없게 하고 싶다면, 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) 다수준 그룹화 (중첩 그룹화)

위에서 사용한 것은 그룹화가 한 단계만 되어있습니다만, 다 단계로 그룹화할 수도 있습니다.

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)별로 그룹화한 결과로 맵 안에 맵을 갖는 자료형태를 갖게 된 것을 확인할 수 있습니다. 😀


(3) 분할

  • 분할은 분할 함수라 불리는 프리디케이트를 분류 함수로 사용하는 특수한 그룹화 기능입니다.
  • 분할 함수는 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]}
*/



👏 이상으로 Stream 에서 제공하는 collect메소드에 대해 알아보았습니다.

collect() 를 조금 더 잘! 다양하게 알아둔다면, 많은 데이터 연산을 통계적으로 처리할 때, 마치 데이터베이스의 질의문처럼 명려형적으로 처리할 수 있고, 그러한 연산을 자바 내부적으로 최적화가 되어있기 때문에 성능 최적화에 있어 어느정도 영향을 줄 수 있을 것 같다는 생각이 들었습니다!

감사합니다!

profile
푸드 테크 기술 블로그

0개의 댓글