이 글은 모던 자바 인 액션라는 책을 읽고 정리한 글입니다.
6장에서는 더 복잡한 데이터 처리 질의를 표현할 수 있도록 스트림 API에서 제공하는 기능인 컬렉터를 살펴봅니다.
이 장의 내용
- Collectors 클래스로 컬렉션을 만들고 사용하기
- 하나의 값으로 데이터 스트림 리듀스하기
- 특별한 리듀싱 요약 연산
- 데이터 그룹화와 분할
- 자신만의 커스텀 컬렉션 개발
자바 8의 스트림이란 데이터 집합을 멋지게 처리하는 게으른 반복자라고 생각할 수 있다.
- 중간 연산
- 스트림을 다른 스트림으로 변환하는 연산으로서, 여러 연산을 연결할 수 있다.
- 스트림 파이프라인을 구성하며, 스트림의 요소를 소비하지 않는다.
- filter 또는 map
- 최종 연산
- 스트림의 요소를 소비해서 최종 결과를 도출한다.(예를 들어 스트림의 가장 큰 값 반환)
- 스트림 파이프라인을 최적화하면서 계산 과정을 짧게 생략하기도 한다.
- count, findfirst, forEach, reduce
컬렉터란 무엇인가?
함수형 프로그래밍에서는 '무엇'을 원하는지 직접 명시할 수 있어서 어떤 방법으로 이를 얻을지는 신경 쓸 필요가 없다. Collector 인터페이스 구현은 스트림의 요소를 어떤 식으로 도출할지 지정한다.
명령형 코드에서는 문제를 해결하는 과정에서 다중 루프와 조건문을 추가하며 가독성과 유지보수성이 떨어지지만 함수형 프로그래밍에서는 컬렉터를 쉽게 추가할 수 있다.
Map<Currency, List<Transaction) transactionsByCurrencies =
transactions.stream().collect(groupingBy (Transaction: :getcurrency));
고급 리듀싱 기능을 수행하는 컬렉터
훌륭하게 설계된 함수형 API의 또 다른 장점으로 높은 수준의 조합성과 재사용성을 꼽을 수 있다. collect로 결과를 수집하는 과정을 간단하면서도 유연한 방식으로 정의할 수 있다는 점이 컬렉터의 최대 강점이다. 스트림에서 collect를 호출하면 collect에서는 리듀싱 연산을 이용해서 스트림의 각 요소를 방문하면서 컬렉터가 작업을 수행한다.
Collectors 유틸리티 클래스는 자주 사용하는 컬렉터 인스턴스를 손쉽게 생성할 수 있는 정적 메서드를 제공한다.
미리 정의된 컬렉터
Collectors에서 제공하는 메서드의 기능은 크게 세 가지로 구분할 수 있다.
- 스트림 요소를 하나의 값으로 리듀스하고 요약
- 요소 그룹화
- 요소 분할
리듀싱과 요약
이미 배웠듯이 컬렉터(Stream.collect 메서드의 인수)로 스트림의 항목을 컬렉션으로 재구성할 수 있다. 좀 더 일반적으로 말해 컬렉터로 스트림의 모든 항목을 하나의 결과로 합칠수 있다.
스트림값에서 최댓값과 최솟값 검색
메뉴에서 칼로리가 가장 높은 요리를 찾는다고 가정하자
Comparator<Dish> dishCaloriesComparator = Comparator.comaringInt(Dish::getCalories);
OPtional<Dish> mostCalorieDish = menu.stream().collect(max(dishCaloriesComparator));
스트림에 있는 객체의 숫자 필드의 합계나 평균 등을 반환하는 연산에도 리듀싱 기능이 자주 사용된다. 이러한 연산을 요약(summarization)연산이라 부른다.
요약 연산
Collectors 클래스는 Collectors.summingInt라는 특별한 요약 팩토리 메서드를 제공한다. summingInt는 객체를 int로 매핑하는 함수를 인수로 받는다. summingInt의 인수로 전달된 함수는 객체를 int 로 매핑한 컬렉터를 반환한다. 그리고 summingInt가 collect 메서드로 전달되면 요약 작업을 수행한다.
다음은 메뉴 리스트의 총 칼로리를 계산하는 코드다.
int totalCalories = menu.stream().collect(summingInt(Dish::getCalroeis));
문자열 연결
컬렉터에 joining 팩토리 메서드를 이용하면 스트림의 각 객체에 toString 메서드를 호출해서 추출한 모든 문자열을 하나의 문자열로 연결해서 반환한다.
String shortMenu = menu.stream().map(Dish::getName).collect(joinging());
joining 메서드는 내부적으로 내부적으로 StringBuilder를 이용해서 문자열을 하나로 만든다.
String shortMenu = menu.stream().map(Dish::getName).collect(joinging(", "));
범용 리듀싱 요약 연산
지금까지 살펴본 모든 컬렉터는 reducing 팩토리 메서드로도 정의할 수 있다. 그럼에도 이전 예제에서 범용 팩토리 메서드 대신 특화된 컬렉터를 사용한 이유는 프로그래밍적 편의성 때문이다. (하지만 프로그래머의 편의성 뿐만 아니라 가독성도 중요하다는 사실을 기억하자)
int totalCalories = menu.stream().collect(reducing(0, Dish::getCalories, (i, j) -> i + j));
reducing은 세 개의 인수를 받는다.
- 첫 번째 인수는 리듀싱 연산의 시작값이거나 스트림에 인수가 없을 때는 반환값이다.
- 두 번째 인수는 요리를 칼로리 정수 변환할 때 사용한 변환 함수다
- 세 번째 인수는 같은 종류의 두 항목을 하나의 값으로 더하는 BinaryOperator다.
컬렉션 프레임워크 유연성 : 같은 연산도 다양한 방식으로 수행할 수 있다.
// reduce 메소드만 사용
int totalCalories = menu.stream().collect(reducing(0, // 초깃값
Dish::getCalories, // 변환 함수
Integer::sum)); // 합계 함수
int totalCalories = menu.stream().map(Dish::getCalories).reduce(Integer::sum).get();
// IntStream으로 매핑
int totalCalories = menu.stream().mapToInt(Dish::getCalories).sum();
그룹화
데이터 집합을 하나 이상의 특성으로 분류해서 그룹화하는 연산도 데이터베이스에서 많이 수행되는 작업이다. 자바 8의 함수형을 이용하면 가독성 있는 한줄의 코드로 그룹화를 구현할 수 있다.
Map<Dish, Type, List<Dish>> dishsByType = menu.stream().collect(groupingBy(Dish::getType));
스트림의 각 요리에서 Dish.Type과 일치하는 모든 요리를 추출하는 함수를 groupbingBy 메서드로 전달했다.
이 함수를 기준으로 스트림이 그룹화되므로 이를 분류 함수라고 부른다.
public enum CaloricLevel { DIET, NORMAL, FAT }
Map<CaloricLevel, List<Dish>> dishesByCaloricLevel = menu.stream().collect(
groupingBy(dish -> {
if (dish.getCalories() <= 400) return CaloricLevel.DIET;
else if (dish.getCalories() <= 700) return CaloricLevel.NORMAL;
else return CaloricLevel.FAT;
}));
그룹화된 요소 조작
요소를 그룹화 한 다음에는 각 결과 그룹의 요소를 조작하는 연산이 필요하다.
Map<Dish, Type, List<Dish>> caloricDishesByType = menu.stream()
.collect(groupingBy(Dish::getType, filtering(dish -> getCalrories() > 500, toList())));
이렇게 해서 아래 결과 맵에서 볼 수 있는 것처럼 목록이 비어있는 FISH도 항목으로 추가된다.
{OTHER=[french fries, pizza], MEAT=[pork, beef], FISH=[]}
다수준 그룹화
두 인수를 받는 팩토리 메서드 Collectors.groupingBy를 이용해서 항목을 다수준으로 그룹화 할 수 있다.
Map<Dish.Type, Map<CaloricLevel, List<Dish>>> dishesByTypeCaloricLevel =
menu.stream().collect(
groupingBy(Dish::getType, // 첫 번째 수준의 분류 함수
groupingBy(dish -> { // 두 번째 수준의 분류 함수
if (dish.getCalories() <= 400)
return CaloricLevel.DIET;
else if (dish.getCalories() <= 700)
return CaloricLevel.NORMAL;
else
return CaloricLevel.FAT;
})));
그룹화의 결과로 다음과 같은 두 수준의 맵이 만들어진다.
{MEAT={DIET=[chicken], NORMAL=[beef], FAT=[pork]},
FISH={DIET=[prawns], NORMAL=[salmon]},
OTHER={DIET=[rice, seasonal fruit], NORMAL=[french fries, pizza]}}
서브그룹으로 데이터 수집
첫 번째 groupingBy로 넘겨주는 컬렉터의 형식은 제한이 없다.
Map<Dish.Type, Long> typesCount = menu.strea().collect(
groupingBy(Dish::getType, counting()));
다음은 결과 맵이다.
{MEAT=3, FISH=2, OTHER=4}
요리의 종류를 분류하는 컬렉터로 메뉴에서 가장 높은 칼로리를 가진 요리를 찾는 프로그램도 구현할 수 있다.
Map<Dish.Type, Optional<Dish>> mostCaloricByType = menu.stream().collect(
groupingBy(
Dish::getType,
maxBy(comparingInt(Dish::getCalories))));
그룹화의 결과로 요리의 종류를 키로, Optional<Dish>를 값으로 갖는 맵이 반환된다.
{FISH= Optional[salmon], OTHER=Optional[pizza], MEAT=Optional[pork]}
컬렉터 결과를 다른 형식에 적용하기
마지막 그룹화 연산에서 맵의 모든 값을 Optional로 감쌀 필요가 없으므로 Optional을 삭제할 수 있다. Collectors.collectingAndThen 팩토리 메서드로 컬렉터가 반환한 결과를 다른 형식으로 활용할 수 있다.
Map<Dish.Type, Optional<Dish>> mostCaloricByType = menu.stream().collect(
groupingBy(
Dish::getType, // 분류 함수
collectingAndThen(
maxBy(comparingInt(Dish::getCalories)), // 감싸인 컬렉터
Optional::get)); // 변환 함수
다음은 맵의 결과다.
{FISH= salmon, OTHER=pizza, MEAT=pork}
- 컬렉터는 점선으로 표시되어 있으며 groupingBy는 가장 바깥쪽에 위치하면서 요리의 종류에 따라 메뉴 스트림을 세 개의 서브스트림으로 그룹화한다.
- groupingBy 컬렉터는 collectingAndThen 컬렉터를 감싼다. 따라서 두 번째 컬렉터는 그룹화된 세 개의 서브스트림에 적용된다.
- collectingAndThen 컬렉터는 세 번째 컬렉터 maxBy를 감싼다.
- 리듀싱 컬렉터가 서브스트림에 연산을 수행한 결과에 collectingAndthen의 Optional::get 변환 함수가 적용된다.
- groupingBy 컬렉터가 반환하는 맵의 분류 키에 대응하는 세 값이 각각의 요리 형식에서 가장 높은 칼로리다.
groupingBy와 함께 사용하는 다른 컬렉터 예제
일반적으로 스트림에서 같은 그룹으로 분류된 모든 요소에 리듀싱 작업을 수행할 때는 팩토리 메서드 groupingBy에 두 번째 인수로 전달한 컬렉터를 사용한다.
Map<Dish.Type, Integer> totalCaloriesByType= menu.stream().collect(
groupingBy(Dish::getType,
summingInt(Dish::getCalories)));
mapping 메서드는 스트림의 인수를 변환하는 함수와 변환 함수의 결과 객체를 누적하는 컬렉터를 인수로 받는다. mapping은 입력 요소를 누적하기 전에 매핑 함수를 적용해서 다양한 형식의 객체를 주어진 형식의 컬렉터에 맞게 변환하는 역할을 한다.
Map<Dish.Type, Set<CaloricLevel>> dishesByTypeCaloricLevel =
menu.stream().collect(
groupingBy(Dish::getType, mapping(dish -> {
if (dish.getCalories() <= 400) return CaloricLevel.DIET;
else if (dish.getCalories() <= 700) return CaloricLevel.NORMAL;
else return CaloricLevel.FAT; },
toSet())));
그룹화 함수로 생성된 서브스트림에 mapping 함수를 적용하면서 다음과 같은 맵 결과가 생성된다.
{ MEAT={DIET, NORMAL, FAT}, FISH={DIET, NORMAL}, OTHER={DIET, NORMAL}}
분할
분할은 분할 함수라 불리는 프레디케이트를 분류 함수로 사용하는 특수한 그룹화 기능이다. 분할 함수는 불리언을 반환하므로 맵의 키 형식은 Boolean이다. 결과적으로 그룹화 맵은 최대(참 아니면 거짓의 값을 갖는) 두 개의 그룹으로 분류된다.
Map<Boolean, List<Dish> partitionedMenu =
menu.stream().collect(partitioningBy(Dish::isVegetarian)); // 분할 함수
위 코드를 실행하면 다음과 같은 맵이 반환된다.
{false = [pork, beef, chicken, prawns, salmon],
true = [french fries, rice, season fruit, pizza]}
이제 참값의 키로 맵에서 모든 채식 요리를 얻을 수 있다.
List<Dish> vegetarianDishes = partitionedMenu.get(true);
물론 메뉴 리스트로 생성한 스트림을 프레디케이트로 필터링한 다음에 별도의 리스트에 결과를 수집해도 같은 결과를 얻을 수 있다.
List<Dish> vegeterianDishes =
menu.stream().filter(Dish::isVegetarian).collect(toList());
분할의 장점
분할 함수가 반환하는 참, 거짓 두 가지 요소의 스트림 리스트를 모두 유지한다는 것이 분할의 장점이다.
다음 코드에서 보여주는 것처럼 컬렉터를 두 번째 인수로 전달할 수 있는 오버로드된 버전의 partitioningBy 메서드도 있다.
Map<Boolean, Map<Dish.Type,List<Dish>>> vegetarianDishesByType = menu.stream().
collect(
partitioningBy(Dish::isVegetarian, // 분할 함수
groupingBy(Dish::getType))); // 두 번째 컬렉터
다음이 위 코드를 실행한 두 수준의 맵 결과다.
{false = {FISH=[prawns, salmon], MEAT=[pork, beef, chicken]},
true = {OTHER=[french fries, rice, season fruit, pizza]}}
Collector 인터페이스
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은 수집 연산 결과 객체의 형식(항상 그런 것은 아니지만 대게 컬렉션 형식)이다.
Collector 인터페이스의 메서드 살펴보기
supplier 메서드 : 새로운 결과 컨테이너 만들기
supplier 메서드는 빈 결과로 이루어진 Supplier를 반환해야 한다. 즉, supplier는 수집 과정에서 빈 누적자 인스턴스를 만드는 파라미터가 없는 함수다. ToListCollector 처럼 누적자를 반환하는 컬렉터에서는 빈 누적자가 비어있는 스트림의 수집 과정의 결과가 될 수 있다.
ToListCollector에서 supplier는 다음처럼 빈 리스트를 반환한다.
public Supplier<List<T>> supplier(){
return () -> new ArrayList<T>();
}
// 생성자 참조를 전달하는 방법
public Supplier<List<T>> supplier(){
return ArrayList::new;
}
accumlator 메서드 : 결과 컨테이너에 요소 추가하기
accumlator 메서드는 리듀싱 연산을 수행하는 함수를 반환합니다. 스트림에서 n 번째 요소를 탐색할 때 두 인수, 즉 누적자와 n번째 요소를 함수에 적용한다. 함수의 반환값은 void, 요소를 탐색하면서 적용하는 함수에 의해 누적자 내부상태가 바뀌므로 누적자가 어떤 값일지 단정할 수 없다. ToListCollector 에서 accumlator가 반환하는 함수는 이미 탐색한 항목을 포함하는 리스트에 현재 항목을 추가하는 연산을 수행한다.
public BiConsumer<List<T>, T> accumlator(){
return (list, item) -> list.add(item);
}
public BiConsumer<List<T>, T> accumlator(){
return List::add;
}
finisher 메서드 : 최종 변환값을 결과 컨테이너로 적용하기
finisher 메서드는 스트림 탐색을 끝내고 누적자 객체를 최종 결과로 반환하면서 누적 과정을 끝낼 때 호출할 함수를 반환해야 한다. 때로는 ToListCollector에서 볼 수 있는 것처럼 누적자 객체가 이미 최종 결과인 상황도 있다.
이런 때는 변환 과정이 필요하지 않으므로 finisher 메서드는 항등 함수를 반환한다.
public Function<List<T>, List<T>> finisher() {
return Function.identity();
}
combiner 메서드 : 두 결과 컨테이너 병합
combiner는 스트림의 서로 다른 서브파트를 병렬로 처리할 때 누적자가 이 결과를 어떻게 처리할지 정의한다. toList의 combiner는 비교적 쉽게 구현할 수 있다. 즉, 스트림의 두 번째 서브파트에서 수집한 항목 리스트를 첫 번째 서브파트 결과 리스트의 뒤에 추가하면 된다.
public BinaryOperator<List<T>> combiner() {
return (list1, list2) -> {
list1.addAll(list2);
return list1;
}
}
Characteristics 메서드
Characteristics 메서드는 컬렉터의 연산을 정의하는 Characteristics 형식의 불변 집합을 반환한다. Characteristics는 스트림을 병렬로 리듀스할 것인지 그리고 병렬로 리듀스한다면 어떤 최적화를 선택해야 할지 힌트를 제공한다.
Characteristics는 다음 세 항목을 포함하는 열거형이다.
- UNORDERED : 리듀싱 결과는 스트림 요소의 방문 순서나 누적 순서에 영향을 받지 않는다.
- CONCURRENT : 다중 스레드에서 accumlator 함수를 동시에 호출할 수 있으며 이 컬렉터는 스트림의 병렬 리듀싱을 수행할 수 있다. 컬렉터의 플래그 UNORDERED를 함께 설정하지 않았다면 데이터 소스가 정렬되어 있지 않은 상황에서만 병렬 리듀싱을 수행할 수 있다.
- IDENTITY_FINISH : finisher 메서드가 반환하는 함수는 단순히 identity를 적용할 뿐 이므로 이를 생략할 수 있다. 따라서 리듀싱 과정의 최종 결과로 누적자 객체를 바로 사용할 수 있다. 또한 누적자 A를 결과 R로 안전하게 형변환 할 수 있다.
'📖 > 모던 자바 인 액션' 카테고리의 다른 글
[모던 자바 인 액션] 5장 스트림 활용 (0) | 2024.04.04 |
---|---|
[모던 자바 인 액션] 4장 스트림 소개 (0) | 2024.04.01 |
[모던 자바 인 액션] 3장 람다 표현식 (0) | 2024.03.28 |
[모던 자바 인 액션] 2장 동작 파라미터화 코드 전달하기 (0) | 2024.03.26 |
[모던 자바 인 액션] 1장 자바 8, 9, 10, 11 : 무슨 일이 일어나고 있는가? (1) | 2024.03.25 |