자바

[모던 자바 인 액션_정리] Chapter 6. 스트림으로 데이터 수집

hee9 2020. 4. 12. 01:09

Chapter 6. 스트림으로 데이터 수집

ToC

  • Colletors 클래스로 컬렉션 만들고 활용
  • 하나의 값으로 데이터 스트림 리듀스하기
  • 특별한 리듀싱 요약 연산
  • 데이터 그룹화의 분할
  • Collector 인터페이스

6.0.

스트림을 이용해서 SQL 질의를 통한 DB 연산과 비슷하게 데이터집합에 대한 연산을 수행하는 것을 보았다. 또한 스트림의 연산은 중간 연산, 최종 연산으로 나눠져 있다는 것도 확인했다. 중간 연산은 스트림 파이프라인을 구성하지만 스트림의 요소를 소비(계산)하지는 않는다. 반면 최종 연산은 스트림 요소에 대한 계산을 수행하고 최종 결과를 도출한다.

최종 결과를 도출하는 collect 메서드를 통해서 이전에 본 reduce처럼 다양한 요소 누적 연산을 최종 결과도 도출할 수 있다.

  • 컬렉션(Collection), 컬렉터(Collector), collect 메서드 에 대한 용어 혼동 조심

 

6.1. 컬렉터란 무엇인가?

스트림 연산에서 최종 연산인 collect메서드에서 인수로 받는 요소 누적 방식으로 Collector 인터페이스에 정의되어 있다 .

ex) Collector 인터페이스에 정의되어 있는 toList, groupingBy 등은 스트림 최종 연산에 대한 특정 동작을 수행한다.

.toList ⇒ 각 요소를 리스트로 만들어라

.groupingBy ⇒ 각 키, 그리고 키에 대응하는 요소 리스트를 값으로 포함하는 Map을 만들어라 
  • 컬렉터에서 제공하는 메서드의 기능
  1. 스트림 요소를 하나의 값으로 리듀스(요소 누적 계산)하고 요약

  2. 요소 그룹화

  3. 요소 분할

 

6.2. 리듀싱과 요약 계산

 컬렉터로 스트림의 모든 항목을 하나의 결과로 합칠 수 있다. 다수의 스트림 요소를 하나의 값 최대 최소, 합계, 평균 등의 값으로 반환하는 연산에 자주 사용된다.

 

* counting()

long howManyDishes = menu.stream.collect(Collectors.counting());

 

 

* 최대 최소값

  • Collectors. maxBy

  • Collectors.minBy

위의 두 컬렉터는 스트림의 요소를 비교하는데 사용할 Comparator를 인수로 받는다.

//Comparator 구현
Comparator caloriesComparator = Comparator.comparingInt(Dish::getCalories);

//스트림 연산
Optional mostCalorieDish = menu.steam()
  .collect(maxBy(caloriesComparator));

 

 

* 요약 연산 (합계, 평균 등)

  • Collectors.summingInt

  • Collectors.averaginLong 등..

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

 해당 코드는 스트림 요소에 대해서 변환함수(Dish::getCalories)를 통해서 칼로리값(Integer)을 구하고 이를 리듀싱연산으로 하나씩 누적시켜서 전체 SUM을 반환한다.

 

 

* 문자열 연결

  • Collectors.joining

    스트림의 각 개체에 toString메서드를 호출하고 이를 하나의 문자열로 연결해서 반환한다.

    //Dish클래스가 메뉴명에 대한 toString()을 구현하고 있다면 map함수는 제외할 수 있음
    String shortMenu = menu.stream().map(Dish::getName).collect(joining( 구분값 ));

    * 범용 리듀싱 연산 (커스터마이징 가능, 재사용성 높아짐)

  • Collectors.reducing

    위에서 나열된 연산을 모두 구현할 수 있다. reducing메서드는 세 개의 인자를 받는데

    • 리듀싱 연산의 시작값 혹은 스트림에 인수가 없을 경우 반환값

    • 요소에 대한 map연산이 필요한 경우

      ex. Dish를 실제 계산에 필요한 칼로리 값으로 바꾸는 연산 getCalories()

    • 같은 종류의 요소 두 항목을 하나의 항목으로 더하는 BinaryOperation

     

int totalCalories = menu.stream().collect( reducing(0, Dish::getCalories, (i,j) -> i + j) );
//위에 람다로 표현한 합계함수를 Interger::sum으로 처리 가능

 

6.3. 그룹화

컬렉터의 두 번째 기능인 그룹화는 데이터 집합을 하나 이상의 특성으로 분류해서 그룹핑 하는 기능이다. 아래 콛는 분류함수인 groupingBy메서드 사용 예시이다.

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

/* Map 결과
{FISH=[prawns, salmon], MEAT=[pork,beef,chicken] }
*/

분류함수를 통해서 Map의 키 값을 정하고 스트림요소를 해당 키의 value로 넣는다. 위의 예시에서 사용된Dish::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 메서드를 걸어주면 된다.

Map<Dish.Type, List> dishesByType = 
 menu.stream().filter(dish -> dish.getCalories() > 500)
   .collect(groupingby(Dish::getType));

하지만 위와 같은 경우, filter메서드에서 제외한 요소는 결과 Map에서 key값이 아예 사라진다. 이런 경우 필터 Predicate을 groupingBy 메서드에 두 번째 인자로 주면 해당 문제를 해결할 수 있다.

Map<Dish.Type, List> dishesByType = 
 menu.stream()
   .collect(groupingby(Dish::getType), filtering(dish -> dish.getCalories() > 500), toList());

 

 

* 다수준 그룹화 (두 개 이상의 기준을 적용한 그룹화)

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;
 		})
 	)
); 

 

 

* 컬렉터 결과를 원하는 형식으로 변환하기

 

 - Collectors.collectingAndThen

 

 

 groupingBy(분류함수, 컬렉터) 의 형식에서 요리의 종류를 분류하고 각 key별로 가장 높은 칼로리를 가진 요리를 value에 추가할 수 있다.

Map<Dish.Type, Optional> mostCaloricByType =
	menu.stream().collect(groupingby(Dish::getType), maxBy(comparingInt(Dish::getCalories));
// {FISH=Optional[salmon], MEAT=Optional=[pork]}

 

 

 

 하지만 위의 결과에서 결과값을 optional이 실제 값으로 반환받고 싶다면 collectingAndThen 메서드를 이용해서 컬렉터의 결과 요소를 다른 타입으로 활용할 수 있다.

menu.stream().collect(groupingby(Dish::getType), collectingAndThen(
	maxBy(comparingInt(Dish::getCalories), Optional::get))); // 반환된 Optional의 값을 추출

 

groupingBy 메서드와 같이 자주 사용되는 mapping 컬렉터에 대한 추가 예제

menu.stream().collect(
	groupingBy(Dish::getType, mapping(dish -> {
		if(dish.getCalories() <= 400) return CaloricLevel.DIET;
		else{ return CaloricLevel.FAT; }, toSet() )
     ));

 

6.4. 분할

분할은 분할 함수라 불리는 Predicate를 분류 함수로 사용하는 특수한 그룹화 기능이다. 분할 함수는 boolean을 반환하기 때문에 이를 분류 함수로 사용했을때 결과 Map의 키는 true, false 로 나온다.

 

 - partitioningBy 메서드

Map<Boolean, List> partitionedMenu =
	menu.stream.collect(partitioningBy(Dish::isVegetarian));
/*
{false = [pork, beef, chicken, salmon],
true = [french fries, rice, fruit]}
*/

 

 * 숫자를 소수와 비소수로 분할하기

 정수 n을 인수로 받아서 2에서 n까지의 자연수를 소수와 비소수로 나누기

 

  - 소수 판단 Predicate 구현

public boolean isPrime(int candidate){
	int candidateRoot = (int) MAth.sqrt((double)candidate); // n의 제곱근 이하 수까지만 확인
	return IntStream.range(2, candidateRoot).noneMatch(i -> candidate % i == 0);
}

 

  

  - 소수 판단 후 분류

Map<Boolean, List> result =
	IntStream.rangeClosed(2, n).boxed()
    		 .collect(partitioningBy(candidate -> isPrime(candidate)));

 

6.5. Collector 인터페이스

리듀싱 연산(컬렉터, 누적 계산)을 어떻게 구현할지 제공하는 메서드 집합으로 구성되어있다.

ex. toList(), groupingBy()

public interface Collector<T, A, R> {
    /*
 T -> 수집될 스트림 항목의 제네릭 타입
    A -> 누적자, 즉 수집 과정에서 중간 결과를 누적하는 객체의 형식
    R -> 수집 연산 결과 객체의 형식
 */
    Supplier supplier();

    BiConsumer<A, T> accumulator();

    BinaryOperator combiner();

    Function<A, R> finisher();

    Set characteristics();

* supplier() : 새로운 결과 컨테이너 만들기

수집 연산의 시작값으로 빈 객체를 반환한다. 수집 과정에서 빈 누적자 인스턴스를 만드는 파라미터가 없는 함수이다. 컬렉터에서 결과에 대한 처리를 할 때 빈 객체 대응.

 

 

* accumulator() : 결과 컨테이너에 요소 추가하기

리듀싱 연산을 수행하는 함수를 반환한다. 스트림에서 n번째 요소를 탐색할 때, 두 개의 인수를 받는다.

  1. 누적자 (스트림의 n-1개 항목을 수집한 상태 )

  2. n번째 요소

    위의 인수로 n번째 요소에 함수를 적용한다.

    return (list, item) -> list.add(item);

    • finisher() : 최종 변환값을 결과 컨테이너로 적용하기

    finisher 메서드는 스트림 탐색을 끝내고 누적자 객체를 최종 결과로 변환하고, 누적 계산이 완료될 때 호출할 함수를 반환해야 한다. 이미 누적자 객체 자체가 최종 결과일 경우 finisher는 항등함수를 반환한다.

 

* combiner() : 두 결과 컨테이너 병합

combiner는 스트림의 서로 다른 서브파트를 각각 처리하고 누적자가 이 결과를 어떻게 병합할지 정의한다. 예를 들면, toList()의 경우, 나눠서 계산된 서브파트A, B가 있을 때, 누적자는 A의 결과에 B의 결과를 붙이기만 하면 된다.

해당 메서드를 이용함으로써 스트림의 리듀싱을 병렬로 처리할 수 있다.

public BinaryOperator<List> combiner(){
 return (listA, listB) -> { listA.addAll(listB); return listA; }
}
  • Characteristrics : 컬렉터의 연산을 정의

    • UNORDERED

      리듀싱 결과가 스트림 요소의 방문 순서나 누적 순서에 영향을 받지 않는다.

    • CONCURRENT

      다중 스레드에서 accumulator 함수를 동시에 호출할 수 있고 병렬 리듀싱을 수행할 수 있다.

    • IDENTITY_FINISH

      리듀싱 과정의 최종 결과로 누적자 객체를 바로 사용할 수 있게하고, 누적자 A를 결과 R로 안전하게 형변환

** 전체적인 플로우**

//1. 새로운 결과 컨테이너 생성
A accumulator = collector.supplier().get();

//2. 결과 컨테이너에 누적 계산 결과 추가
collector.accumulator().accept(accumulator, next);

//3. 누적자 객체를 최종 결과로 반환
R result = collector.finisher().apply(accumulator);

return result;

이 장의 마지막 부분인 커스텀 컬렉터 구현은 시간이 날 때 직접 해보면 좋을 것 같다.