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

sameul__choi·2022년 4월 4일
0

[모던 자바인 액션]

목록 보기
8/11
post-thumbnail

스트림은 데이터 집합을 멋지게 처리하는 게으른 반복자 이 장에서는 reduce가 그랬던 것처럼 collect 역시 다양한 요소 누적 방식을 인수로 받아 스트림을 최종결과과로 도출하는 리듀싱 연산을 수행할 수 있음을 설명한다.

00 컬렉터란?

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

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

.toList // 각 요소를 리스트로 만들어라.
.groupingBy // 각 키, 그리고 키에 대응하는 요소 리스트를 값으로 포함하는 Map을 만들어라

Collector 인터페이스 메서드를 어떻게 구현하느냐에 따라 스트림에 어떤 리듀싱 연산을 수행할 지 결정된다. Collectors 유틸리티 클래스는 자주 사용하는 컬렉터 인스턴스를 손쉽게 생성할 수 있는 정적 팩토리 메서드를 제공한다.

메서드의 기능을 크게 세가지로 나눌 수 있다.

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

01 리듀싱과 요약

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

  • counting()
long howManyDishes = menu.stream.collect(Collectors.counting());
  • min / max 값
    .maxBy
    .minBy
//Comparator 구현
Comparator caloriesComparator = Comparator.comparingInt(Dish::getCalories);

//스트림 연산
Optional mostCalorieDish = menu.steam()
  .collect(maxBy(caloriesComparator));
  • 요약 연산 (합계, 평균)
    .summingInt
    .averageingInt
int totalCalories = menu.steam().collect(summingInt(Dish::getCalories));

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

  • 문자열 연결

joining

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

스트림의 각 개체에 toString메서드를 호출하고 이를 하나의 문자열로 연결해서 반환한다. (Dish 클래스가 메뉴명에 대한 toString()을 구현하고 있으면 map함수는 제외할 수 있다.)

collect와 reduce

collect와 reduce를 이용하면 동일한 기능을 구현할 수 있다. 하지만 의미론적인 문제와 실용성 문제 등에 대하여 차이가 존재한다. collect 메서드는 도출하려는 결과를 누적하는 컨테이너를 바꾸도록 설계된 메서드인 반면, reduce는 두 값을 하나로 도출하는 불변형 연산이라는 점에서 의미론적인 차이가 존재한다.

여러 스레드가 동시에 같은 데이터 구조체를 고치면 리스트 자체가 망가져버리므로 리듀싱 연산을 병렬로 수행할 수 없다. 이럴 때 가변 컨테이너 관련 작업이면서 병렬성을 확보하려면 collect 메서드로 리듀싱 연산을 구현하는 것이 바람직하다.

02 그룹화

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

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

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

분류 함수를 통해 Map의 키 값을 정하고 스트림 요소를 해당 키의 value로 넣는다. 위의 예시에서 사용된 Dish::getType 등의 함수가 없으면 직접 람다 표현식으로 로직을 구현할 수 있다.

groupingBy(dish -> {
 if(dish.getCalories() <= 400) return CaloricLevel.DIET;
 else if(dish.getCalories() <=700) return CaloricLevel.NORMAL;
 else return CaloricLevel.FAT;
}));

그룹화된 요소 조작

그룹핑 후엔 각 결과 그룹 요소를 조작하는 연산이 필요한데, 이는 groupingBy 메서드 이전에 predicate을 이용하여 filter 메서드를 걸어주면 된다. 하지만 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() )
     ));

03 분할

분할은 분할 함수라 불리는 프리디케이트를 분류 함수로 사용하는 특수한 그룹화 기능이다. 분할 함수는 불리언을 반환하므로 맵의 키 형식은 Boolean이다. 결과적으로 그룹화 맵은 최대 두 개의 구룹으로 분류된다. 분류 함수로 사용했을 때 결과 Map의 키는 true, false로 나온다.

Map<Boolean, List> partitionedMenu =
	menu.stream.collect(partitioningBy(Dish::isVegetarian));

//{false = [pork, beef, chicken, salmon],
//true = [french fries, rice, fruit]}

예시 ) 소수 비소수 분할하기

  • 소수 판단 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)));

04 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은 수집 연산 결과 객체의 형식이다.
예를 들어 Stream의 모든 요소를 List로 수집하는 ToListCollector라는 클래스는 아래와 같이 만들 수 있다.

public class ToListCollector<T> implements Collector<T, List<T>, List<T>>
  • supplier 메서드 : 새로운 결과 컨테이너 만들기
    supplier 메서드는 수집 과정에서 빈 누적자 인스턴스를 만드는 파라미터가 없는 함수이다.

  • accumulator 메서드 : 결과 컨테이너에 요소 추가하기
    accumulator 메서드는 리듀싱 연산을 수행하는 함수를 반환한다. 즉 누적자(스트림의 첫 n-1개 항목을 수집한 상태)와 n번째 요소를 함수에 적용한다 (그렇기에 제네릭 형식도 <A, T>이다).

  • finisher 메서드 : 최종 변환값을 결과 컨테이너로 적용하기
    finisher 메서드는 스트림 탐색을 끝내고 누적자 객체를 최종 결과로 반환하면서 누적 과정을 끝낼 때 호출할 함수를 반환해야 한다. ToListCollector와 같이 누적자 객체가 이미 최종 결과인 상황도 있다. 이럴 경우 finisher함수는 항등 함수를 반환한다.

  • combiner 메서드 : 두 결과 컨테이너 병합
    combiner는 스트림의 서로 다른 서브 파트를 병렬로 처리할 때 누적자가 이 결과를 어떻게 처리할지 정의한다 (그렇기에 BinaryOperator이다).

  • characteristics 메서드
    characteristics 메서드는 컬렉터의 연산을 정의하는 Characteristics 형식의 불변 집합을 반환한다.

  enum Characteristics {
        CONCURRENT,
        UNORDERED,
        IDENTITY_FINISH
    }

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

CONCURRENT : 다중 스레드에서 accumulator 함수를 동시에 호출할 수 있으며 병렬 리듀싱을 수행할 수 있다. 컬렉터의 플래그에 UNORDERED를 함께 설정하지 않았다면 데이터 소스가 정렬되어 있지 않은 상황에서만 병렬 리듀싱을 수행할 수 있다.

IDENTITY_FINISH : finisher 메서드가 반환하는 함수는 단순히 identity를 적용할 뿐이므로 이를 생략할 수 있다. 따라서 리듀싱 과정의 최종 결과로 누적자 객체를 바로 사용할 수 있다. 또한 누적자 A를 결과 R로 안전하게 형 변환할 수 있다.

ToListCollector 구현 예시

  public class ToListCollector<T> implements Collector<T, List<T>, List<T>> {
	
	@Override
	public Supplier<List<T>> supplier() {
		return ArrayList::new;
	}

	@Override
	public BiConsumer<List<T>, T> accumulator() {
		return List::add;
	}

	@Override
	public BinaryOperator<List<T>> combiner() {
		return (list1, list2) -> {
				list1.addAll(list2);
				return list1;			
		};
	}
	
	@Override
	public Function<List<T>, List<T>> finisher() {
		return Function.identity();
	}
	
	@Override
	public Set<Characteristics> characteristics() {
		return Collections.unmodifiableSet(EnumSet.of(
				IDENTITY_FINISH, CONCURRENT));
	}
}

0개의 댓글