1. 서론
이번 포스팅에서는 Chapter6의 스트림으로 데이터 수집 에 대해 진행하도록 하겠습니다.
2. 컬렉터란 무엇인가?
Collector 는 java.util.stream 에 있는 인터페이스로, 리듀싱 연산을 어떻게 구현할지 제공하는 메서드 집합입니다.
앞장에서 많이 사용한 스트림 함수 중 collect는 이 Collector 인터페이스를 인수로 받습니다.
그럼 앞장에서 자주 인수로 전달한 Collectors는 Collector의 구현체일까요? 아쉽지만 아닙니다.
코드를 깊게 파보시면 바로 아시겠지만 Collector의 구현체는 CollectorImpl 입니다.
그럼 대체 Collectors는 왜 가능한걸까요?
코드를 자세히 보시면 Collectors는 일종의 유틸 클래스로 inner class로 CollectorImpl를 가지고 있습니다.
그리고 toList와 같은 정적 메서드가 호출될때마다 이 CollectorImpl를 만들어 반환하는 형태입니다.
1) 고급 리듀싱 기능을 수행하는 컬렉터
스트림 함수인 collect의 동작방식은 인자로 받은 Collector로 리듀싱 연산을 수행하여 결과를 반환합니다.
일반적으로 많이 사용하는 toList, toSet 등등을 모아둔 유틸 클래스가 Collectors로 보시면 됩니다.
일반적으로 Collectors는 앞절에서 살펴본 sum, max와 같은 연산이 아닌
요소를 어떤 자료구조에 담아 반환할지의 리듀싱 기능을 제공합니다.
책에서는 이를 고급 리듀싱기능으로 분류하였습니다.
2) 미리 정의된 컬렉터
Collectors는 정적 팩토리 메서드 패턴을 통해 자주 사용하는 CollectorImpl를 제공하고 있습니다.
책에서는 이를 미리 정의된 컬렉터라고 일컫습니다.
Collectors는 아래와 같이 크게 3가지를 제공합니다.
- 스트림 요소를 하나의 값으로 리듀스하고 요약
- 요소 그룹화
- 요소 분할
3. 리듀싱과 요약
Collectors에서 제공하는 정적 메서드들은 결국 스트림의 최종 연산으로 취급이 되며, reduce 작업을 하여 만들어지게 됩니다.
Collectors에서 제공하는 reduce 역할을 수행하는 정적 메소드들에 대해 소개하겠습니다.
1) counting
이 메서드는 Collectors가 제공하는 컬렉터로 요소의 갯수를 세어 반환합니다.
아래는 예제입니다.
long howManyDishes = menu.stream().collect(Collectors.counting());
2) maxBy, minBy
maxBy, minBy는 Collectors가 제공하는 컬렉터로 요소 중 최댓값, 최솟값을 찾을때 사용합니다.
최대인지 최소인지 구별하기 위해 인자로는 Comparator를 받습니다.
아래는 예제입니다.
Comparator<Dish> dishCaloriesComparator = Comparator.comparingInt(Dish::getCalories);
Optional<Dish> mostCalorieDish = menu.stream().collect(Collectors.maxBy(dishCaloriesComparator));
3) 요약 연산
Collectors는 합계, 평균을 위한 요약 연산도 제공한다.
메서드는 아래와 같습니다.
단, 인수는 언박싱인 int, long, double 타입으로 반환하는 함수형 인터페이스의 구현체 혹은 람다여야 합니다.
- summingInt
- summingDouble
- summingLong
- averagingInt
- averagingDouble
- averagingLong
사용 예제는 아래와 같습니다.
int totalCalories = menu.stream().collect(Collectors.summingInt(Dish::getCalories));
만약 한번의 collect API로 합계, 평균 등의 값을 한번에 수행해야 할 경우에는 아래와 같은 메서드를 사용하면 됩니다.
- summarizingInt
- summarizingDouble
- summarizingLong
아래는 예제 및 결과입니다.
IntSummaryStatistics menuStatistics = menu.stream()
.collect(Collectors.summarizingInt(Dish::getCalories));
System.out.println(menuStatistics);
/*
* 결과
*/
IntSummaryStatistics{count=?, sum=?, average=?, min=?, max=?}
4) 문자열 연결
Collectors에서 제공하는 joining 메서드를 사용하면,
스트림에 각 요소에 toString 메서드를 호출하여 하나의 문자열로 연결하여 반환합니다.
추가로, joining 메서드는 문자열을 연결할 때 사용할 구분자를 인자로 받을 수있습니다.
아래는 예제입니다.
String shortMenu = menu.stream().map(Dish::getName).collect(Collectors.joining(", "));
// pork, beef, chicken, ...
5) 범용 리듀싱 요약 연산
지금까지 알아본 메서드는 사실 Collectors.reducing의 메서드를 이용하여 특화된 값을 도출하도록 만들은 것입니다.
이는 단순히, 개발 편의와 가독성을 위해 제공한 것입니다.
그럼, 특화된것이 아니라 커스텀을 해야한다면 스스로 reducing 메서드를 사용해야 합니다.
때문에 reducing 메서드에 대해 알아보겠습니다.
reducing 메서드는 인자로 3개를 받고 있습니다.
- 첫번째 인자 = 리듀싱 연산의 초기값입니다.
- 두번째 인자 = 각 요소에 적용할 변환 함수입니다.
- 세번째 인자 = 같은 종류의 요소를 하나의 요소로 합칠 BinaryOperator입니다.
reducing 메서드는 인자를 한개만 받을 수도 있습니다.
이 경우는 첫번째 인자가 스트림 요소의 첫번째가 되고, 두번째 인자는 자신을 그대로 반환하는 Function.identity()가 됩니다.
단, 스트림이 비어있는 경우가 있기 때문에 결과값으로는 Oprional이 씌어지게 됩니다.
아래는 summingInt 를 reducing을 통해 구현한 예제입니다.
int totalCalories = menu.stream().collect(Collectors.reducing(
0, // 초기값
Dish::getCalories, // 변환 함수
Integer::sum // 누적 함수
));
4. 그룹화
데이터 처리를 하다보면 그룹핑이 필요한 경우가 있습니다.
이를 위해 Collectors 는 groupingBy와 같은 그룹핑을 할수 있도록 제공합니다.
아래는 각 Dish의 타입별로 그룹핑하는 예제입니다.
Map<Dish.Type, List<Dish>> dishesByType =
menu.stream().collect(Collectors.groupingBy(Dish::getType));
// 결과
{
FISH=[prawns, salmon],
OTHER=[french fries, rice, season fruit, pizza],
MEAT=[pork, beef, chicken]
}
Dish::getType과 같이 그룹화되는 기준을 제공하는 함수를 분류함수라고 합니다.
만약 분류함수가 복잡하다면 코드블록을 사용하여 커스텀하면 됩니다.
아래는 각 칼로리값에 따라 분류를 나누는 예제입니다.
public enum CaloricLevel {DIET, NORMAL, FAT}
Map<Dish.Type, List<Dish>> dishesByType =
menu.stream().collect(Collectors.groupingBy(
dish -> {
if (dish.getCalories() <= 400) return CaloricLevel.DIET;
else if (dish.getCalories() <= 700) return CaloricLevel.NORMAL;
else return CaloricLevel.FAT;
}));
1) 그룹화된 요소 조작
그룹화 진행 후에 각 그룹화된 요소를 조작해야 하는 경우가 있습니다.
이를 위해 Collectors.groupingBy는 추가로 Collector 인자를 받을수 있습니다.
이는, 첫번째 인자를 통해 그룹핑이 진행 된 후, 두번째 인자인 Collector를 적용하게 됩니다.
Collectors는 또 미리 자주 사용하는 mapping 함수를 제공합니다.
java 9 부터는 Collectors 에 filtering, flatMapping이 추가되었습니다.
[java docs 9] 참조
아래는 타입별로 그룹핑한 요리 리스트가 아닌 요리명 리스트로 바꾼 예제입니다.
Map<Dish.Type, List<String>> dishesByType =
menu.stream().collect(Collectors.groupingBy(
Dish::getType,
Collectors.mapping(Dish::getName, Collectors.toList())
));
2) 다수준 그룹화
위에서 설명했듯이 groupingBy는 두번째 인수로 Collecto를 받습니다.
그 말은, 두번째 인수로 또 groupingBy를 받을 수 있습니다.
결국, n차원의 그룹화가 가능하다는 말로 직결됩니다.
아래는 2 수준으로 그룹핑하는 예제입니다.
Map<Dish.Type, Map<CaloricLevel, List<Dish>>> dishesByType =
menu.stream().collect(Collectors.groupingBy(
Dish::getType,
Collectors.groupingBy(
dish -> {
if (dish.getCalories() <= 400) {
return CaloricLevel.DIET;
} else if (dish.getCalories() <= 700) {
return CaloricLevel.NORMAL;
} else {
return CaloricLevel.FAT;
}
}
)
));
// 결과
{
FISH = {
NORMAL=[salmon],
DIET=[prawns]
},
MEAT = {
FAT=[pork],
NORMAL=[beef],
DIET=[chicken]
},
OTHER = {
NORMAL=[french fries, pizza],
DIET=[rice, season fruit]
}
}
3) 서브그룹으로 데이터 수집
groupingBy 의 두번째 인자로 Collector를 인자로 받는다는 것은 원하는 데이터 수집도 가능하다는 말이 됩니다.
대표적으로 아래와 같습니다.
- counting = 그룹별 요소의 갯수를 수집
- maxBy = 그룹별 주어진 기준의 최댓값을 가진 요소 수집
- summingInt = 그룹별 합계 값 수집
maxBy의 경우 반환값이 optional로 감싸게 됩니다.
만약 empty가 없는 경우에는 Collectors.collectingAndThen 을 통해 Optional.get을 적용하여 감싸지 않은 형태로도 가능합니다.
5. 분할
스트림에서의 분할은 프레디케이트를 분류 함수로 사용하는 특수한 그룹화 기능으로 분할 함수로 일컫습니다.
프레디케이트가 분류함수이기 때문에 키는 Boolean 값으로 그룹핑은 최대 2개가 됩니다.
Collectors에서는 partitioningBy 메서드로 이를 제공합니다.
아래는 Collectors.partitioningBy 예제입니다.
Map<Boolean, List<Dish>> partitionedMenu =
menu.stream().collect(Collectors.partitioningBy(Dish::isVegetarian));
Collectors.partitioningBy 또한 groupingBy와 같이 두번째 인수로 Collector 를 받을 수 있어 다양한 커스텀 연산이 가능합니다.
6. 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 = 수집 연산의 결과 객체 타입 (대게 컬렉션 타입입니다.)
각 메서드의 용도는 아래와 같습니다.
1) supplier 메서드 : 새로운 결과 컨테이너 만들기
supplier 메서드는 빈 결과로 이루어진 Supplier를 반환해야 합니다.
즉, supplier는 수집 과정에서 빈 누적자 인스턴스를 만드는 파라미터 함수입니다.
toList와 같은 컬렉터는 ArrayList::new 가 supplier입니다.
2) accumulator 메서드 : 결과 컨테이너에 요소 추가하기
acuumulator 메서드는 리듀싱 연산을 수행하는 함수를 반환합니다.
함수의 반환값은 void입니다.
toList 컬렉터의 acuumulator는 List::add입니다.
3) finisher 메서드 : 최종 변환값을 결과 컨테이너로 적용하기
finisher 메서드는 스트림 탐색이 끝나고 누적자 객체를 최종 결과로 변환하면서 누적 과정을 끝낼 때 호출할 함수를 반환해야 합니다.
toList 컬렉터의 finisher는 누적자 객체가 이미 반환 형태이기 때문에 Function.identity() 입니다.
사실상, toList는 위 3가지로도 기능 구현이 가능합니다.
다만, 병렬성을 통한 최적화 판단 및 구현이 있어 내부적으로는 더욱 복잡하며
이를 위해 combiner 메서드와 characteristics 가 있습니다.
4) combiner 메서드 : 두 결과 컨테이너 병합
combiner는 스트림의 서로 다른 서브 파트를 병렬로 처리할때 누적자가 이 결과를 어떻게 처리할지 정의합니다.
toList 컬렉터에서는 비교적 간단합니다.
왜냐하면, 2개의 리스트를 이으기만 하면 되기 때문입니다.
아래는 toList 컬렉터의 combiner 입니다.
(left, right) -> { left.addAll(right); return left;
이 메서드를 통해 병렬로 수행이 가능하게 됩니다.
아래는 병렬화 리듀싱 과정을 간단하게 보여주는 그림입니다.
5) Characteristics 메서드
characteristics 메서드는 컬렉터의 연산을 정의하는 Characteristics 형식의 불변 집합을 반환합니다.
characteristics는 스트림을 병렬로 리듀스 할 것인지, 병렬로 한다면 어떤 최적화를 선택해야 하는지 힌트를 제공합니다.
Characteristics는 enum 클래스로 아래 3개의 값을 가지고 있습니다.
- CONCURRENT = 다중 스레드에서 accumulator 함수를 동시에 호출할 수 잇으며 이 컬렉터는 병렬 리듀싱이 가능함.
- 컬렉터 플래그에 UNORDERED를 함께 설정하지 않은 경우, 데이터 소스가 정렬되어 있지 않은 상황에서만 병렬 리듀싱 가능
- UNORDERED = 리듀싱 결과는 스트림 요소의 방문 순서나 누적순서에 영향을 받지 않음.
- IDENTITY_FINISH = finisher 메서드가 반환하는 함수는 단순히 identify 이므로 생략 가능.
toList의 경우에는 IDENTITY_FINISH 입니다.
그 이유는 accumulator로 List::add를 사용하는데 병렬로 처리 시 순서가 엉킬수 있기 때문입니다.
그 말은, 누적 순서에도 영향이 있다는 말이 됨으로 CONCURRENT와 UNORDERED는 해당 사항이 되지 않습니다.
7. 마무리
이번 포스팅에서는 Chapter 6인 스트림으로 데이터 수집에 대해 진행하였습니다.
다음에는 Chapter 7인 병렬 데이터 처리와 성능에 대해 포스팅하겠습니다.
'Programming > ModernJavaInAction' 카테고리의 다른 글
(8) 컬렉션 API 개선 (0) | 2020.04.13 |
---|---|
(7) 병렬 데이터 처리와 성능 (0) | 2020.04.11 |
(5) 스트림 활용 (0) | 2020.04.04 |
(4) 스트림 소개 (0) | 2020.03.28 |
(3) 람다 표현식 (0) | 2020.03.28 |