2025. 2. 25. 22:20ㆍBook/모던 자바 인 액션
목차.
1. 데이터 그룹화와 분할
2. 자신만의 커스텀 컬렉터 개발
1. 그룹화
트랜잭션 통화 그룹화 예제에서 확인했듯이 명령형으로 그룹화를 구현하려면 까다롭고, 할일이 많으며, 에러도 많이 발생한다.
Map<Currency, List<Transaction>> transactionsByCurrency = new HashMap<>();
for (Transaction transaction : TransactionExample.transactions) {
Currency currency = transaction.getCurrency();
List<Transaction> transactionsForCurrency = transactionsByCurrency.get(currency);
if (transactionsForCurrency == null) {
transactionsForCurrency = new ArrayList<>();
transactionsByCurrency.put(currency, transactionsForCurrency);
}
transactionsForCurrency.add(transaction);
}
하지만 자바 8의 함수형을 이용하면 가독성 있는 한 줄의 코드로 그룹화를 구현할 수 있다. 이번에는 메뉴를 그룹화한다고 가정하자. 예를 들어 고기를 포함하는 그룹, 생선을 포함하는 그룹, 나머지 그룹으로 메뉴를 그룹화할 수 있다.
Map<Dish.Type, List<Dish>> dishesByType = DishExample.menu.stream()
.collect(Collectors.groupingBy(Dish::getType));
=>
{
MEAT=[chicken, beef, pork],
OTHER=[season fruits, rice, french fries, pizza],
FISH=[prawns, salmon]
}
스트림의 각 요리에서 Dish.Type과 일치하는 모든 요리를 추출하는 함수를 groupingBy 메서드로 전달했다. 이 함수를 기준으로 스트림이 그룹화되므로 이를 분류 함수라고 부른다.
단순한 속성 접근자 대신 더 복잡한 분류 기준이 필요한 상황에서는 메서드 참조를 분류 함수로 사용할 수 없다.
예를 들어
400칼로리 이하를 'diet'로,
400~700칼로리를 'normal'로,
700칼로리 초과를 'fat' 요리로
분류한다고 가정하자. Dish 클래스에는 이러한 연산에 필요한 메서드가 없으므로 메서드 참조를 분류 함수로 사용할 수 없다. 따라서 메서드 참조 대신 람다 표현식으로 필요한 로직을 구현할 수 있다.
Map<CaloricLevel, List<Dish>> dishesByCaloricLevel = DishExample.menu.stream().collect(
Collectors.groupingBy((dish) ->{
if (dish.getCalories() < 400){
return CaloricLevel.DIET;
} else if (400 < dish.getCalories() && dish.getCalories() < 700){
return CaloricLevel.NORMAL;
} else {
return CaloricLevel.FAT;
}
})
);
지금까지 메뉴의 요리를 종류 또는 칼로리로 그룹화하는 방법을 살펴봤다. 그러면 요리 종류와 칼로리 두 가지 기준으로 동시에 그룹화할 수 있을까?
1.1 고급 리듀싱 기능을 수행하는 컬렉터
요소를 그룹화 한 다음에는 각 결과 그룹의 요소를 조작하는 연산이 필요하다. 예를 들어 500칼로리가 넘는 요리만 필터한다고 가정하자. 다음 코드처럼 그룹화를 하기 전에 프레디케이트로 필터를 적용해 문제를 해결할 수 있다고 생각할 수 있다.
Map<Dish.Type, List<Dish>> caloricDishesByType =
DishExample.menu.stream()
.filter(dish -> dish.getCalories() < 500)
.collect(Collectors.groupingBy(Dish::getType));
=>
{MEAT=[chicken, beef, pork], OTHER=[rice, french fries, pizza]}
위 코드로 문제를 해결할 수 있지만 단점도 존재한다. 메뉴 요리는 FISH, OTHER, MEAT 세 가지가 있지만 위 코드는 OTHER, MEAT 요리만 반환한다. 필터를 만족하는 FISH 요리가 없으므로 결과 맵에서 해당 키 자체가 사라진다.
Collectors 클래스는 일반적인 분류 함수에 Collector 형식의 두 번째 인수를 갖도록 groupingBy 팩토리 메서드를 오버로드해 이 문제를 해결한다. 아래 코드에서 보여주는 것처럼 두 번째 Collector 안으로 필터 프레디케이트를 이동함으로 이 문제를 해결할 수 있다.
Map<Dish.Type, List<Dish>> caloricDishesByType2 =
DishExample.menu.stream()
.collect(Collectors.groupingBy(Dish::getType,
Collectors.filtering(dish -> dish.getCalories() > 300,
Collectors.toList())));
=>
{MEAT=[chicken, beef, pork], OTHER=[rice, french fries, pizza], FISH=[]}
filtering 메소드는 Collectors 클래스의 또 다른 정적 팩토리 메서드로 프레디케이트를 인수로 받는다. 이 프레디케이트로 각 그룹의 요소와 필터링 된 요소를 재그룹화 한다. 이렇게 해서 빈 FISH도 항목으로 추가된다.
그룹화된 항목을 조작하는 다른 유용한 기능 중 또 다른 하나로 맵핑 함수를 이용해 요소를 변환하는 작업이 있다. Filtering 컬렉터와 같은 이유로 Collectors 클래스는 매핑 함수와 각 항목에 적용한 함수를 모으는 데 사용하는 또 다른 컬렉터를 인수로 받는 mapping 메서드를 제공한다.(이후 코드에서 Collectors는 static import 해서 사용하겠다.)
Map<Dish.Type, List<String>> dishNamesByType =
DishExample.menu.stream()
.collect(groupingBy(Dish::getType, mapping(Dish::getName, toList())));
=>
{MEAT=[chicken, beef, pork],
OTHER=[season fruits, rice, french fries, pizza],
FISH=[prawns, salmon]}
이전 예제와 달리 결과맵의 각 그룹은 요리가 아니라 문자열 리스트다. groupingBy와 연계해 세 번째 컬렉터를 사용해서 일반 맵이 아닌 flatMap 변환을 수행할 수 있다. 다음처럼 태그 목록을 가진 각 요리로 구성된 맵이 있다고 가정하자.
1.2 다수준 그룹화
두 인수를 받는 팩토리 메서드 Collectors.groupingBy를 이용해서 항목을 다수준으로 그룹화할 수 있다. Collectors.groupingBy는 일반적인 분류 함수와 컬렉터를 인수로 받는다. 즉, 바깥쪽 groupingBy 메서드에 스트림의 항목을 분류할 두 번째 기준을 정의하는 내부 groupingBy를 전달해서 두 수준으로 스트림의 항목을 그룹화할 수 있다.
Map<Dish.Type, Map<CaloricLevel, List<Dish>>> dishesByTypeCaloricLevel =
DishExample.menu.stream()
.collect(groupingBy(Dish::getType, groupingBy(dish -> {
if (dish.getCalories() < 300) return CaloricLevel.DIET;
else if (700 < dish.getCalories()) return CaloricLevel.NORMAL;
else return CaloricLevel.FAT;
})));
=>
{
MEAT={
NORMAL=[pork],
FAT=[chicken, beef]
},
OTHER={
DIET=[season fruits],
FAT=[rice, french fries, pizza]
},
FISH={
DIET=[salmon],
FAT=[prawns]
}
}
외부 맵은 첫 번째 수준의 분류 함수에서 분류한 키값 'meat, other, fish'를 갖는다.
그리고 외부 맵의 값은 두 번째 수준의 분류 함수의 기준 'normal', 'diet', 'fat'을 키값으로 갖는다.
최종적으로 두 수준의 맵은 첫 번째 키와 두 번째 키의 기준에 부합하는 요소 리스트를 값(salmon, pizza 등)으로 갖는다.
1.3 서브그룹으로 데이터 수집
위 예제에서 두 번째 groupingBy 컬렉터를 첫 번째 gruopingBy 컬렉터의 외부 컬렉터로 전달해서 다수준 그룹화 연산을 구현했다.
groupingBy 컬렉터로 넘겨주는 컬렉터의 형식은 제한이 없다. 아래 예제처럼 groupingBy 컬렉터의 두 번째 인수로 counting 컬렉터를 전달해서 메뉴에서 요리의 수를 종류별로 계산할 수 있다.
Map<Dish.Type, Long> countByType = DishExample.menu.stream()
.collect(groupingBy(Dish::getType, counting()));
=>
{MEAT=3, OTHER=4, FISH=2}
요리의 종류를 분류하는 컬렉터로 메뉴에서 가장 높은 칼로리를 가진 요리를 찾는 프로그램도 다시 구현할 수 있다.
Map<Dish.Type, Optional<Dish>> maxCaloricDishByType = DishExample.menu.stream()
.collect(groupingBy(Dish::getType, maxBy(Comparator.comparing(Dish::getCalories))));
=>
{MEAT=Optional[pork], OTHER=Optional[pizza], FISH=Optional[prawns]}
그룹화의 결과로 요리의 종류를 키로, Optional<Dish>를 값으로 갖는 맵이 반환된다.
Optional<Dish>는 해당 종류의 음식 중 가장 높은 칼로리를 반환한다.
*NOTE
groupingBy 컬렉터는 스트림의 첫 번째 요소를 찾은 이후에야 그룹화 맵에 새로운 키를 추가하기 때문에 Optional을 굳이 사용할 필요가 없다.
컬렉터 결과를 다른 형식에 적용하기
마지막 그룹화 연산에서 맵의 모든 값을 Optional로 감쌀 필요가 없으므로 Optional을 삭제할 수 있다. 즉, 다음처럼 팩토리 메서드 Collectors.collectingAndThen으로 컬렉터가 반환환 결과를 다른 형식으로 활용할 수 있다.
Map<Dish.Type, Dish> maxCaloricDishByType = DishExample.menu.stream()
//분류 함수
.collect(groupingBy(Dish::getType,
collectingAndThen(
//감싸인 컬렉터
maxBy(Comparator.comparingInt(Dish::getCalories)),
//변환 함수
Optional::get)));
=>
{MEAT=pork, OTHER=pizza, FISH=prawns}
팩토리 메서드 collectionAndThen은 적용할 컬렉터와 변환 함수를 인수로 받아 다른 컬렉터를 반환한다. 반환되는 컬렉터는 기존 컬렉터의 래퍼 역할을 하며 collect의 마지막 과정에서 변환 함수로 자신이 변환하는 값을 매핑한다. 이 예제에서는 maxBy로 만들어진 컬렉터가 감싸지는 컬렉터며 변환 함수 Optional::get 으로 변환된 Optional에 포함된 값을 추출한다.
중첩 컬렉터는 앞으로도 자주 등장할 것이다. 하지만 처음에는 중첩 컬렉터가 어떤 식으로 작동하는지 명확하게 파악하기 어려울 때도 있다.
- 컬렉터는 점선으로 표시되어 있으며 groupingBy는 가장 바깥쪽에 위치하면서 요리의 종류에 따라 메뉴 스트림을 세 개의 서브스트림으로 그룹화한다.
- gruopingBy 컬렉터는 collectionAndThen 컬렉터를 감싼다. 따라서 두 번째 컬렉터는 그룹화된 세 개의 서브스트림에 적용된다.
- collectingAndThen 컬렉터는 세 번째 컬렉터 maxBy를 감싼다.
- 리듀싱 컬렉터가 서브스트림에 연산을 수행한 결과에 collectionAndThen의 Optional::get 변환 함수가 적용된다.
- groupingBy 컬렉터가 반환하는 맵의 분류 키에 대응하는 세 값이 각각의 요리 형식에서 가장 높은 칼로리다.
2. 분할
분할은 분할 함수라 불리는 프레디케이트를 분류 함수로 사용한 특수한 그룹화 기능이다. 분할 함수는 불리언을 반환하므로 맵의 키 형식은 Boolean이다. 결과적으로 그룹화 맵은 최대 두 개의 그룹으로 분류된다. 예를 들어 메뉴를 채식 요리와 채식이 아닌 요리로 분류해야 한다고 하자
Map<Boolean, List<Dish>> menuPartitionedByVegetarian = DishExample.menu.stream()
.collect(Collectors.partitioningBy(Dish::isVegetarian));
=>
{false = [prawns, chicken, salmon, beef, pork],
true = [season fruits, rice, french fries, pizza]}
이제 참값의 키로 맵에서 모든 채식 요리를 얻을 수 있다.
List<Dish> dishes = menuPartitionedByVegetarian.get(true);
=>
[season fruits, rice, french fries, pizza]
2.1 분할의 장점
분할 함수가 반환하는 참, 거짓 두 가지 요소의 스트림 리스틀 모두 유지한다는 것이 분할의 장점이다.
(필터링을 통해 채식 요리만 얻을 수 있지만 채식이 아닌 요리가 필요하다면 다시 스트림을 추출해 필터링을 해야 할 것이다.)
또한 아래 예제처럼 컬렉터를 두 번째 인수로 전달할 수 있는 오버로드된 버전은 partitionBy 메서드도 있다.
Map<Boolean, Map<Dish.Type, List<Dish>>> menuPartitionedByVegetarianAndType =
DishExample.menu.stream()
.collect(partitioningBy(Dish::isVegetarian, groupingBy(Dish::getType)));
=>
{false={FISH=[prawns, salmon], MEAT=[chicken, beef, pork]},
true={OTHER=[season fruits, rice, french fries, pizza]}}
2.1 숫자를 소수와 비소수로 분할하기
정수 n을 인수로 받아서 2에서 n까지의 자연수를 소수와 비소수로 나누는 프로그램을 구현해보자. 먼저 수가 소수인지 비소수인지 구분하는 predicate를 구현한다.
public static boolean isPrime(int num) {
int numRoot = (int) Math.sqrt(num);
return IntStream.rangeClosed(2, numRoot)
.noneMatch(i -> num % i == 0);
이제 n개의 숫자를 포함하는 스트림을 만든 다음에 isPrime 메서드를 프레디케이트로 이용하고 partitionBy 컬렉터로 리듀스해서 숫자를 소수와 비소수로 분류할 수 있다.
public static Map<Boolean, List<Integer>> partitionPrime(int n) {
return IntStream.rangeClosed(2, n)
.boxed()
.collect(partitioningBy(Partition_example::isPrime));
}
3. 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 : 수집 연산 결과의 객체의 형식
3.1 Collector 인터페이스의 메서드 살펴보기
앞 네 개의 메서드는 collect 메서드에서 실행하는 함수를 반환하는 반면, 다섯 번재 메서드는 collct 메서드가 어떤 최적화를 이용해서 리듀싱 연산을 수행할 것인지 결정하도록 돕는 힌트 특성 집합을 제공한다. Collectors.toList() 를 구현해보며 각 메서드를 살펴보자
supplier: 새로운 결과 컨테이너 만들기
supplier 메서드는 빈 결과로 이뤄진 Supplier를 반환해야 한다. 즉, supplier는 수집 과정에서 빈 누적자 인스턴스를 만드는 파라미터가 없는 함수다. 스트림이 비어있다면 supplier 가 반환한 빈 누적자가 결과값이 된다.
@Override
public Supplier<List<T>> supplier() {
return ArrayList::new;
}
accumulator: 결과 컨테이너에 요소 추가하기
accumulator 메서드는 리듀싱 연산을 수행하는 함수를 반환한다. 스트림에서 n번째 요소를 탐색할 때 두 인수, 즉 누적자(스트림의 첫 n-1개 항목을 수집한 상태)와 n번째 요소를 함수에 적용한다. 함수의 반환값은 void, 즉 요소를 탐색하면서 적용하는 함수에 의해 누적자 내부상태가 바뀌므로 누적자가 어떤 값일지 단정할 수 없다. ToListCollector에서 accumulator가 반환하는 함수는 이미 탐색한 항목을 포함하는 리스트에 현재 항목을 추가하는 연산을 수행한다.
@Override
public BiConsumer<List<T>, T> accumulator() {
return List::add;
}
finisher: 최종 변환값을 결과 컨테이너로 적용하기
finisher 메서드는 스트림 탐색을 끝내고 누적자 객체를 최종 결과로 반환하면서 누적 과정을 끝낼 때 호출할 함수를 반환해야 한다. ToListCollector에서는 누적자 객체가 이미 최종 결과이다. 이런 때는 변환 과정이 필요하지 않으므로 finisher 메서드는 항등 함수를 반환한다.
@Override
public Function<List<T>, List<T>> finisher() {
return Function.identity();
}
위 세 가지 메서드로 순차적 리듀싱 기능을 수행할 수 있다. 실제적으로 collect가 동작하기 전에 다른 중간 연산과 파이프라인을 구성할 수 있게 해주는 게으른 특성, 병렬 실행등은 고려하려면 리듀싱 기능은 더욱 복잡해진다.
//시작
List<Integer> integers = collector.supplier().get();
//스트림에 남아있는 요소가 없을때 까지
collector.accumulator().accept(integers, 1);
//결과
List<Integer> result = collector.finisher().apply(integers);
combiner: 두 결과 컨테이너 병합
리듀싱 연산에서 사용할 함수를 반환하는 네 번째 메서드이다. combiner는 스트림의 서로 다른 서브파트를 병렬로 처리할 때 누적자가 이 결과를 어떻게 처리할지 정의한다. toList의 combiner는 비교적 쉽게 구현할 수 있다. 스트림의 두 번째 서브 파트에서 수집한 항목 리스트를 첫 번째 서브파트 결과 리스트의 뒤에 추가하면 된다.
@Override
public BinaryOperator<List<T>> combiner() {
return (list1, list2) -> {
list1.addAll(list2);
return list1;
};
}
//서브 스트림1
List<Integer> result1 = collector.finisher().apply(List.of(1, 2, 3));
//서브 스트림2
List<Integer> result2 = collector.finisher().apply(List.of(4, 5, 6));
List<Integer> combinedResults = collector.combiner().apply(result1, result2);
네 번째 메서드를 이용하면 스트림의 리듀싱을 병렬로 수행할 수 있다. 스트림의 리듀싱을 병렬로 수행할 때 자바 7의 포크/조인 프레임워크와 이후 배울 Spliterator를 사용한다.
- 스트림을 분할해야 하는지 정의하는 조건이 거짓으로 바뀌기 전까지 원래 스트림을 재귀적으로 분할한다.(너무 쪼개면 병렬 수행의 속도가 순차 수행의 속도보다 느려진다.)
- 분할된 모든 서브스트림의 각 요소에 리듀싱 연산을 순차적으로 적용해서 서브스트림을 병렬로 처리할 수 있다.
- 마지막에 컬렉터의 combiner 메서드가 반환하는 함수로 모든 부분겨로가를 쌍으로 합치며 연산이 완료된다.
characteristics
characteristics 메서드는 컬렉터의 연산을 정의하는 Characteristics 형식의 불변 집합을 반환한다. Characteristics는 스트림을 병렬로 리듀스할 것인지 그리고 병렬로 리듀스한다면 어떤 최적화를 선택해야 할지 힌트를 제공한다. Characteristics는 다음 세 항목을 포함하는 열거형이다.
- UNORDERED : 리듀싱 결과는 스트림 요소의 방문 순서나 누적 순서에 영향을 받지 않는다.
- CONCURRENT : 다중 스레드에서 accumulator 함수를 동시에 호출할 수 있으며 이 컬렉터는 스트림의 병렬 리듀싱을 수행할 수 있다. 컴퓨터의 플래그에 UNORDERED를 함께 설정하지 않았다면 데이터 소스가 정렬되어 있지 않은 상황에서만 병렬 리듀싱을 수행할 수 있다.
- IDENTITY_FINISH : finisher 메서드가 반환하는 함수는 단순히 identity를 적용할 뿐이므로 이를 생략할 수 있다. 따라서 리듀싱 과정의 최종 결과로 누적자 객체를 바로 사용할 수 있다. 또한 누적자 A를 결과 R로 안전하게 형변환할 수 있다.
위에서 개발한 ToListCollector에서 스트림의 요소를 누적하는 데 사용한 리스트가 최정 결과 형식이므로 추가 변환이 필요 없다. 따라서 ToListCollector는 IDENTITY_FINISHI다. ToListCollector는 CONCURRENT다. 하지만 리스트의 순서는 상관이 있으므로 UNORDERED가 아니다.
@Override
public Set<Characteristics> characteristics() {
return EnumSet.of(IDENTITY_FINISH, CONCURRENT);
}
위 구현이 Collectors.toList 메서드가 반환하는 결과와 완전히 같은 것은 아니지만 대체로 비슷하다.
List<Dish> dishes = DishExample.menu.stream()
.collect(new ToListCollector<>());
컬렉터 구현을 만들지 않고도 커스텀 수집 수행하기
IDENTITY_FINISH 수집 연산(누적 객체가 결과인 연산)에서는 Collector 인터페이스를 완전히 새로 구현하지 않고도 같은 결과를 얻을 수 있다. Stream은 세 함수(발행, 누적, 합침)를 인수로 받는 collect 메서드를 오버로드하며 각각의 메서드는 Collector 인터페이스의 메서드가 반환하는 함수과 같은 기능을 수행한다.
List<Dish> dishes = DishExample.menu.stream()
//발행
.collect(ArrayList::new
//누적
,ArrayList::add
//합침
,ArrayList::addAll);
위 두 번째 코드는 가독성이 조금 떨어진다. 또한 Characteristics를 전달할 수 없다.
4. 커스텀 컬렉터를 구현해서 성능 개선하기
이전 분할을 설명하면서 Collectors 클래스가 제공하는 다양한 팩토리 메서드 중 하나를 이용해서 커스텀 컬렉터를 만들었다. 아래 예제처럼 커스텀 컬렉터로 n까지의 자연수를 소수와 비소수로 분할할 수 있다.
public static Map<Boolean, List<Integer>> partitionPrime(int n) {
return IntStream.rangeClosed(2, n)
.boxed()
.collect(partitioningBy(Partition_example::isPrime));
}
public static boolean isPrime(int num) {
int numRoot = (int) Math.sqrt(num);
return IntStream.rangeClosed(2, numRoot)
.noneMatch(i -> num % i == 0);
}
위 기능의 성능을 개선해보자
4.1 소수로만 나누기
우선 소수로 나누어떨어지는지 확인해서 대상의 범위를 좁힐 수 있다. 제수가 소수가 아니면 소용없으므로 제수를 현재 숫자 이하에서 발견한 소수로 제한할 수 있다. 주어진 숫자가 소수인지 아닌지 판단해야 하는데, 그러려면 지금까지 발견한 소수 리스트에 접근해야 한다. 하지만 우리가 살펴본 컬렉터로는 컬렉터 수집 과정에서 부분결과에 접근할 수 없다. 이때 커스텀 컬렉터 클래스로 이 문제를 해결할 수 있다.
중간 결과 리스트가 있다면 isPrime 메서드로 중간 결과 리스트를 전달하도록 다음과 같이 코드를 구현할 수 있다.
*제수 : 어떤 수를 나눌 때 사용하는 수
*제수를 소수로만 하는 이유 : 4(2x2), 6(2x3), 8(2x2x2), 9(3x3), 10(2x5) 으로 굳이 나눠볼 필요가 없다. 앞의 다섯 숫자는 2, 3, 4, 5 로 나눠보면 소수인지 비소수인지 확인 가능하다.
public static boolean isPrimeV2(List<Integer> primes, int num) {
return primes.stream().noneMatch(i -> num % i == 0);
}
소수도 그냥 소수만 사용하는 것이 아닌 num의 루트보다 크면 소수로 나누는 검사를 멈춰야 한다. 스트림 API에는 이런 기능을 제공하는 메서드가 없다. filter(p -> p <= numRoot) 를 이용해서 대상의 루트보다 작은 소수를 필터링할 수 있지만 결국 filter는 전체 스트림을 처리한 다음에 결과를 반환하게 된다. 대상의 제곱보다 큰 소수를 찾으면 검사를 중단함으로써 성능 문제를 없앨 수 있다.
따라서 다음 코드처럼 정렬된 리스트와 프레디케이트를 인수로 받아 리스트의 첫 요소에서 시작해서 프레디케이트를 만족하는 가장 긴 요소로 이루어진 리스트를 반환하는 takeWhile이라는 메서드를 구현한다.
public static boolean isPrimeV3(List<Integer> primes, int num) {
int numRoot = (int) Math.sqrt(num);
return primes.stream()
.takeWhile(i -> i <= numRoot)
.noneMatch(i -> num % i == 0);
}
새로운 isPrime 메서드를 구현했으니 커스텀 컬렉터를 구현하자.
1단계 : Collector 클래스 시그니처 정의
다음의 Collector 인터페이스 정의를 참고해서 클래스 시그니처를 만들자.
public interface Collector<T, A, R>
T => 스트림 요소의 형식
A => 중간 결과를 누적하는 객체의 형식
R => collect 연산의 최종 결과 형식
지금 구현하는 컬렉터는 정수 요소(T)로 이뤄진 스트림에서 누적자와 최종 결과의 형식이 Map<Boolean, List<Integer>>인 컬렉터다.
public class PrimeNumbersCollector
implements Collector<Integer,
Map<Boolean, List<Integer>>,
Map<Boolean, List<Integer>>>
2단계 : 리듀싱 연산 구현
이 단계에서는 Collector 인터페이스에 선언된 다섯 메서드를 구현한다. supplier 메서드는 누적자를 만드는 함수를 반환해야 한다.
@Override
public Supplier<Map<Boolean, List<Integer>>> supplier() {
return () -> new HashMap<>() {{
put(true, new ArrayList<>());
put(false, new ArrayList<>());
}};
}
위 코드에서 누적자로 사용할 맵을 만들면서 true, false 키와 빈 리스트로 초기화를 했다. 수집 과정에서 빈 리스트에 각각 소수와 비소수를 추가할 것이다.
스트림에서 요소를 어떻게 수집할지 결정하는 것은 accumulator 메서드이므로 현재 구현중인 컬렉터에서 가장 중요한 메서드라 할 수 있다.
@Override
public BiConsumer<Map<Boolean, List<Integer>>, Integer> accumulator() {
return (acc, num) -> {
acc.get(isPrime(acc.get(true), num))
.add(num);
};
}
acc.get(true) => isPrime의 첫 번째 인수로 소수로만 이뤄진 List<Integer>
isPrime(소수리스트, 소수 여부 대상 숫자) => 소수(ture)/비소수(false)
누적자에 isPrime으로 소수 비소수를 구분한 뒤 소수 여부 대상 숫자를 누적
3단계 : 병렬 실행할 수 있는 컬렉터 만들기
병렬 수집 과정에서 두 부분 누적자를 합칠 수 있는 메서드를 만들어보자 예제에서는 두 번재 맵의 소수 리스트와 비소수 리스트의 모든 수를 첫 번재 맵에 추가하는 연산이면 충분하다.
@Override
public BinaryOperator<Map<Boolean, List<Integer>>> combiner() {
return (acc1, acc2) -> new HashMap<>() {{
acc1.get(true).addAll(acc2.get(true));
acc1.get(false).addAll(acc2.get(false));
}};
}
*사실 알고리즘이 순차적이어서 컬렉터를 실제 병렬로 사용할 순 없다.
4단계 : finisher 메서드와 컬렉터의 characteristics 메서드
나머지 두 메서드는 구현이 쉽다. accumulator의 형식이 컬렉터 결과 형식과 같아 변환 과정이 필요 없다. 따라서 항등 함수를 반환하도록 구현한다.
@Override
public Function<Map<Boolean, List<Integer>>, Map<Boolean, List<Integer>>> finisher() {
return Function.identity();
}
현재 구현한 컬렉터는 IDENTITY_FINISH만 해당하므로 characteristics 메서드는 아래처럼 구현한다.
@Override
public Set<Characteristics> characteristics() {
return EnumSet.of(Characteristics.IDENTITY_FINISH);
}
이제 partitioningBy를 이용했던 예제를 커스텀 컬렉터로 교체할 수 있다.
public static Map<Boolean, List<Integer>> partitionPrime(int n) {
return IntStream.rangeClosed(2, n)
.boxed()
.collect(partitioningBy(Partition_example::isPrime));
}
//소수 비소수 커스텀 컬렉터
public static Map<Boolean, List<Integer>> partitionPrimeCustom(int n) {
return IntStream.rangeClosed(2, n)
.boxed()
.collect(new PrimeNumbersCollector());
}
'Book > 모던 자바 인 액션' 카테고리의 다른 글
CHAPTER7 - 병렬 데이터 처리와 성능(1) (0) | 2025.02.28 |
---|---|
CHAPTER6 - 스트림으로 데이터 수집(1) (0) | 2025.02.21 |
CHAPTER5 - 스트림 활용(2) (0) | 2025.02.17 |
CHAPTER5 - 스트림 활용(1) (1) | 2025.02.14 |
CHAPTER4 - 스트림 (1) | 2025.02.12 |