[모던 자바 인 액션] chap06. 스트림으로 데이터 수집

2023. 3. 7. 23:23Java

내용
Collectors 클래스로 컬렉션을 만들고 사용하기
하나의 값으로 데이터 스트림 리듀스하기
특별한 리듀싱 요약 연산
데이터 그룹화와 분할
자신만의 커스텀 컬렉터 개발
Map<Currency, List<Transaction>> transactionsByCurrencies = new HashMap<>();
for (Transaction transaction : transactions) {
	Currency currency = transaction.getCurrency();
	List<Transaction> transactionsForCurrency = transactionsByCurrencies.get(currency);
    
	if (transactionsForCurrency == null) {
		transactionsForCurrency = new ArrayList<>();
		transactionsByCurrencies.put(currency, transactionsForCurrency);
	}
	transactionsForCurrency.add(transaction);
}
Map<Currency, List<Transaction>> transactionsByCurrencies = transactions.stream()
        .collect(groupingBy(Transaction::getCurrency));
위는 통화별로 트랜잭션을 그룹화하는 코드다. 설명을 해도 코드를 눈으로 읽어야지만 확인할 수 있을것이다. 하지만 stream을 이용하면 손쉽게 짜고, 이해시킬 수 있다.

1. 컬렉터란 무엇인가?

함수형 프로그래밍에는 '무엇'을 원하는지 직접 명시할 수 있어서 어떤 방법으로 이를 얻을지는 신경 쓸 필요가 없다. 다수준으로 그룹화를 수행할 때 명령형 프로그래밍과 함수형 프로그래밍의 차이점이 더욱 두드러진다. 명령형 코드에서는 문제를 해결하는 과정에서 다중 루프와 조건문을 추가하며 가독성과 유지보수성이 크게 떨어진다. 반면 함수형 프로그래밍에서는 필요한 컬렉터를 쉽게 추가할 수 있다.

 

Collectors에서 제공하는 메서드의 기능은 크게 세가지로 구분할 수 있다.

  • 스트림 요소를 하나의 값으로 리듀스하고 요약
  • 요소 그룹화
  • 요소 분할

2. 리듀싱과 요약

1. 스트림 값에서 최댓값과 최솟값 검색

Collectors.maxBy, Collectors.minBy 두개의 메서드를 이용해 스트림의 최댓값과 최솟값을 구할 수 있다.

Comparator<Dish> dishCaloriesComparator = Comparator.comparing(Dish::getCalories);
Optional<Dish> mostCalorieDish = menu.stream().collect(maxBy(dishCaloriesComparator));
// 메뉴에서 가장 높은 칼로리를 찾는 기능

스트림에 있는 객체의 숫자 필드의 합계나 평균 등을 반환하는 연산에도 리듀싱 기능이 자주 사용된다.

이러한 연산을 요약연산이라고 한다.

2. 요약 연산

summingInt는 객체를 int로 매핑하는 함수를 인수로 받는다. 인수로 전달된 함수는 객체를 int로 매핑한 컬렉터를 반환한다. 그리고 summingInt가 collect 메서드로 전달되면 요약 작업을 수행한다. summingLong, summingDouble 메서드는 같은 방식으로 동작하며 long, double 형식의 데이터로 요약한다는 점만 다르다.

int totalCalories = menu.stream().collect(summingInt(Dish::getCalories));

단순 합계 외에 평균값 계산 등의 연산도 요약 기능으로 제공된다. avergingInt, averagingLong, averagingDouble 등으로 다양한 형식으로 이루어진 숫자 집합의 평균을 계산할 수 있다.

double avgCalories = menu.stream().collect(averagingInt(Dish::getCalories));

종종 두 개 이상의 연산을 한번에 수행해야 할 때도 있다. 이런 상황에서는 팩토리 메서드 summarizingInt가 반환하는 컬렉터를 사용할 수 있다. 다음은 하나의 요약 연산으로 메뉴에 있는 요소 수, 요리의 칼로리 합계, 평균, 최댓값, 최솟값 등을 계산하는 코드다.

IntSummaryStatistics menuStatistics = menu.stream().collect(summarizingInt(Dish::getCalories));

// 객체를 출력해보면 정보를 확인할 수 있다.
IntSummaryStatistics{count=9, sum=4300, min=120,
		average=477.777778, max=800}

마찬가지로 int뿐 아니라 long, double에 대응하는 summarizingLong, summarizingDouble 메서드와 관련된 LongSummaryStatistics, DoubleSummaryStatistics 클래스도 있다.

 

3.문자열 연결

컬렉터에 joining 팩토리 메서드를 이용하면 스트림의 각 객체에 toString 메서드를 호출해서 추출한 모든 문자열을 하나의 문자열로 연결해서 반환한다. 다음은 메뉴의 모든 요리명을 연결하는 코드다.

String shortMenu = menu.stream().map(Dish::getName).collect(joining());

String shortMenu menu.stream().collect(joining());

// 위 코드는 모두 다음과 같은 결과를 도출한다.
// porkbeefchickenfrench friesricesaeson fruitpizzaprawnssalmon

joining 메서드는 StringBuilder를 이용해서 문자열을 하나로 만든다. 문자열 조이닝을 할때 ,같은 구분자로 구분을 만들수도 있다.

String shortMenu = menu.stream().map(Dish::getName).collect(joining(", "));

// pork, beef, chicken, french fries, rice, season fuit, pizza, prawns, salmon

 

4.  범용 리듀싱 요약 연산

지금까지 살펴본 모든 컬렉터는 reducing 팩토리 메서드로도 정의할 수 있다.

int totalCalories = menu.stream().collect(reducing(0, Dish::getCalories, (i, j) -> i+j));

이렇게 reducing메서드로 만들어진 컬렉터로도 메뉴의 모든 칼로리 합계를 계산할 수 있다.

reducing은 인수 세 개를 받는다.

  • 첫 번째 인수는 리듀싱 연산의 시작값이거나 스트림에 인수가 없을 때는 반환값이다. (숫자 합계에서는 인수가 없을 때 반환값으로 0이 적절하다.)
  • 두 번째 인수는 요리를 칼로리 정수로 변환할 때 사용한 변환 함수다.
  • 세 번째 인수는 같은 종류의 두 항목을 하나의 값으로 더하는 BinaryOperator다.
Optional<Dish> mostCalorieDish = menu.stream().collect(reducing(
	(d1, d2) -> d1.getCalories() > d2.getCalories() ? d1 : d2));
    
    // 이렇게 한개의 인수를 가진 reducing을 이용할수도 있다.

 

컬렉션 프레임워크 유연성 : 같은 연산도 다양한 방식으로 수행할 수 있다.

int totalCalories = menu.stream().collect(reducing(0, Dish::getCalories, Integer::sum));
// Integer의 sum 메서드를 이용해 람다 표현식보다 더 간결하게 표현할 수 있다.
public static <T> Collector<T, ?, Long> counting() {
	return reducing(0L, e -> 1L, Long::sum);
}

// counting 컬렉터도 세 개의 인수를 갖는 reducing 팩토리 메서드를 이용해 구현할 수 있다.
int totalCalories = menu.stream().mapToInt(Dish::getCalories).sum();

// 값이 비어있다고 생각한다면 일반적으로는 orElse, orElseGet을 이용해서
// Optional의 값을 얻어오는 것이 좋다.

3. 그룹화

데이터 집합을 하나 이상의 특성으로 분류해서 그룹화하느 연산도 데이터베이스에서 수행되는 작업이다. 트랜잭션 통화 그룹화 예제에서 확ㅇ니했듯이 명령형으로 그룹화를 구현하려면 까다롭고, 할일이 많으며, 에러도 많이 발생한다. 하지만 자바 8의 함수형을 이용하면 가독성 있는 한 줄의 코드로 그룹화를 구현할 수 있다.

Map<Dish.Type, List<Dish>> dishesByType = menu.stream().collect(gorupingBy(Dish::getType));

스트림의 각 요리에서 Dish.Type과 일치하는 모든 요리를 추출하는 함수를 groupingBy 메서드로 전달했다. 이 함수를 기준으로 스트림이 그룹화되므로 이를 분류함수라고 부른다.

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;
    }));
    
 // Dish 클래스에는 칼로리를 계산하는 연산이 없으므로 참조자 분류함수를 사용할 수 없다.
 // 다음처럼 메서드 참조 대신 람다 표현식으로 필요한 로직을 구현할 수 있다.

 

1. 그룹화된 요소 조작 - 요소를 그룹화 한 다음에는 각 결과 그룹의 요소를 조작하는 연산이 필요하다.

500 칼로리가 넘는 요리만 필터한다면 어떻게 해야할까?

Map<Dish.Type, List<Dish>> caloricDishesByType = 
	menu.stream().filter(dish -> dish.getCalories() > 500)
    	.collect(groupingBy(Dish::getType));
        
// 이 코드처럼 그룹화 하기전에 프레디케이트로 필터를 적용할 수도 있을것이다.
// {OTHER=[french fires, pizza], MEAT=[pork, beef]}

하지만 이 경우는 fish가 프레디케이트에 걸리지 않기때문에, 맵에서 해당 키 자체가 사라진다.

Map<Dish.Type, List<Dish>> caloricDishesByType = menu.stream()
	.collect(groupingBy(Dish::getType,
    filtering(dish -> dish.getCalories() > 500, toList())));
    
// groupingBy 팩토리 메서드를 오버로드해 문제를 해결했다.
// {OTHER=[french fires, pizza], MEAT=[pork, beef], FISH=[]}

filtering 메서드는 Collectors 클래스의 또 다른 정적 팩토리 메서드로 프레디케이트를 인수로 받는다. 이 프레디케이트로 각 그루브이 요소와 필터링 된 요소를 재그룹화 한다.

filtering 컬렉터와 같은 이유로 Collectors 클래스는 매핑 함수와 각 항목에 적용한 함수를 모으는 데 사용하는 또 다른 컬렉터를 인수로 받는 mapping 메서드를 제공한다.

Map<Dish.Type, List<String>> = dishNamesByType = menu.stream()
	.collect(groupingBy(Dish::getType, mapping(Dish::getName, toList()));

 

 

groupingBy를 이용해 일반 맵이 아닌 flatMap화시킬 수도 있다.

Map<String, List<String>> dishTags = new HashMap<>();
dishTags.put("pork", asList("greasy", "salty"));
dishTags.put("beef", asList("salty", "roasted"));
dishTags.put("chicken", asList("fried", "crisp"));
dishTags.put("french fries", asList("greasy", "fried"));
dishTags.put("rice", asList("light", "natural"));
dishTags.put("season fruit", asList("fresh", "natural"));
dishTags.put("pizza", asList("tasty", "salty"));
dishTags.put("prawns", asList("tasty", "roasted"));
dishTags.put("salmon", asList("delicious", "fresh"));

Map<Dish.Type, Set<String>> dishNamesByType = 
	menu.stream()
    	.collect(groupingBy(Dish::getType,
        	flatMapping(dish -> dishTags.get(dish.getName()).stream(), toSet())));

// {MEAT=salty, greasy, roasted, fried, crisp], FISH=[roasted, tasty, fresh, delicious],
// OTHER=[salty, greasy, natural, light, tasty, fresh, fried]}

 

2. 다수준 그룹화

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]}}

 

3. 서브그룹으로 데이터 수집

사실 첫 번째 groupingBy로 넘겨주는 컬렉터의 형식은 제약이 없다.

Map<Dish.Type, Long> typesCount = menu.stream().collect(
	groupingBy(Dish::getType, counting()));

// {MEAT=3, FISH=2, OTHER=4}

 

분류 함수 한 개의 인수를 갖는 groupingBy(f)는 사실 groupingBy(f, toList())의 축약형이다.

Map<Dish.Type, Optional<Dish>> mostCaloricByType =
	menu.stream().collect(groupingBy(Dish::getType,
    	maxBy(comparingIntDish::getCalories))));

// {FISH=Optional[salmon], OTHER=Optional[pizza], MEAT=Optional[pork]}

 

컬렉터 결과를 다른 형식에 적용하기

마지막 그룹화 연산에서 맵의 모든 값을 Optional로 감쌀 필요가 없으므로 Optional을 삭제할 수 있다.

Map<Dish.Type, Dish> mostCaloricByType =
	menu.stream().collect(groupingBy(Dish::getType,
    	collectingAndThen(maxBy(comparingInt(Dish::getCalories)),
        Optional::get)));

// {FISH=salmon, OTHER=pizza, MAET=pork}

 

groupingBy와 함께 사용하는 다른 컬렉터 예제

일반적으로 스트림에서 같은 그룹으로 분류된 모든 요소에 리듀싱 작업을 수행할 때는 팩토리 메서드 groupingBy에 두 번째 인수로 전달한 컬렉터를 사용한다.

Map<Dish.Type, Integer> totalCaloriesByType =
	menu.stream().collect(groupingBy(Dish::getType, summingInt(Dish::getCalories)));

이 외에도 mapping 메서드로 만들어진 컬렉터도 groupingBy와 자주 사용된다. mapping 메서드는 스트림의 인수를 변환하는 함수와 변환 함수의 결과 객체를 누적하는 컬렉터를 인수로 받는다.

Map<Dish.Type, Set<CaloricLevel>> caloricLevelsByType =
	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())));

// {OTHER=[DIET, NORMAL], MEAT=[DIET, NORMAL, FAT], FISH=[DIET, NORMAL]}

예제에서는 Set의 형식이 정해져 있지 않았다. 이때 toCollection을 이용하면 원하는 방식으로 결과를 제어할 수 있다.

Map<Dish.Type, Set<CaloricLevel>> caloricLevelByType =
	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;}, toCollection(HashSet::new)
        )));

 

219페이지 분할부터