안녕하세요 푸드테크팀 백엔드 개발자 박형민 입니다
이번 포스팅에서는
1. Stream 에서 제공하는 최종연산인 collect() 와
2. 그 파라미터로 들어가는 collector 인터페이스,
3. collector를 구현한 collectors에 대해서 정리해보도록 하겠습니다!
Java 에 정의된 Stream 의 collect 형식을 보면, 매겨변수로 컬렉터 인터페이스를 받고있음을 확인할 수 있었습니다.
👉 이때, Collector 인터페이스를 구현한 클래스에 커스텀 메서드를 구현해도되고, Collector를 구현하는 java.util.stream.Collectors.java 클래스의 정적 메서드를 이용할 수 있습니다.!
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() 가 어떤 최적화 연산으로 리듀싱을 진행할것인지 결정하는 힌트 특성 집합을 반환합니다.
📌 새로운 변경가능한 결과 컨테이너를 생성하고 반환하는 함수입니다.
📌 값을 변경 가능한 결과 컨테이너로 접는 함수
📌 두 개의 부분 결과를 받아 병합하는 함수
📌 중간 누적 유형 A에서 최종 결과 유형 R로 최종 변환을 수행합니다.
📌 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;
}
}
미리 구현된 컬렉터 구현들의 집합인, collectors 메소드들을 살펴보겠습니다.
long menuCount = menu.stream().count();
//long menuCount = menu.stream().collect(Collectors.counting());
//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를 기준으로 최솟값을 갖는 객체로 요약해서 리턴합니다.
//숫자 합계 (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
}
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)를 연결하여 요약된 문자열을 만듭니다.
오버로드된 reducing()메소드는 잘 참고해서 사용해야한다고 합니다.
기본적으로 파라미터를 받는데, (초기값[또는 스트림이 비었을 때 값], 변환 함수, 같은 종류의 두 항목을 하나로 만드는 함수) 이렇게 3개를 파라미티로 받습니다.
int totalSalary = users.stream().collect(reducing(0, User::getSalary, (i,j) -> i+j));
collect : 도출하려는 결과를 누적하는 컨테이너를 바꾸도록 설계된 메서드
reduce : 두 값을 하나로 도출하는 불변형 연산이라는 점에서 두 메서드의 의미가 다릅니다.
병렬 처리에서, reduce는 누적자로 사용된 리스트를 변환시키기 때문에 여러 스레드에서 리스트를 동시에 고쳐 리스트 자체가 망가질 수 있는 문제가 발생합니다.
이 문제를 해결하기 위해서는 매번 새로운 리스트를 할당해주어야하고, 병렬처리에서 성능저하로 이어지게 됩니다.
📌 따라서 이러한, 가변 컨테이너 관련 작업이면서 병렬성을 확보하려면 collect 메서드로 리듀싱 연산을 구현하는 것이 바람직합니다.
stream의 요소를 마치 데이터베이스 연산처럼, 특정 기준으로 그룹핑하는 작업을 자바 스트림에서 간단하게 명령형으로 구현할 수 있도록 제공합니다!
파라미터로 전달하는 메소드로 스트림이 그룹화되므로 이를 분류함수라고도 합니다
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;
}));
스트림 요소를 그룹핑한 이후, 각 결과 그룹 요소를 조작하는 연산을 가하고 싶을 때가 분명히 있습니다.
이를 위해 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=[]}
위에서 사용한 것은 그룹화가 한 단계만 되어있습니다만, 다 단계로 그룹화할 수도 있습니다.
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)별로 그룹화한 결과로 맵 안에 맵을 갖는 자료형태를 갖게 된 것을 확인할 수 있습니다. 😀
Map<Boolean, List> partitionedMenu =
menu.stream.collect(partitioningBy(Dish::isVegetarian));
/*
{false = [pork, beef, chicken, salmon],
true = [french fries, rice, fruit]}
*/
👏 이상으로 Stream 에서 제공하는 collect메소드에 대해 알아보았습니다.
collect() 를 조금 더 잘! 다양하게 알아둔다면, 많은 데이터 연산을 통계적으로 처리할 때, 마치 데이터베이스의 질의문처럼 명려형적으로 처리할 수 있고, 그러한 연산을 자바 내부적으로 최적화가 되어있기 때문에 성능 최적화에 있어 어느정도 영향을 줄 수 있을 것 같다는 생각이 들었습니다!
감사합니다!