본문 바로가기
Language/Java

Java - 스트림으로 데이터 수집

by 밍상 2022. 3. 3.

1. 컬렉터란 무엇인가?

1.1 고급 리듀싱 기능을 수행하는 컬렉터

훌륭하게 설계된 함수형 API의 또 다른 장점으로 높은 수준의 조합성과 재사용성을 꼽을 수 있다. collect로 결과를 수집하는 과정을 간단하면서도 유연한 방식으로 정의할 수 있다는 점이 컬렉터의 최대 장점이다. 스트림에 collect를 호출하면 스트림의 요소에 리듀싱 연산이 수행된다. collect에서는 리듀싱 연산을 이용해서 스트림의 각 요소를 방문하면서 컬렉터가 작업을 처리한다. 보통 함수를 요소로 변환할 때는 컬렉터를 적용하며 최종 결과를 저장하는 자료구조에 값을 누적한다.

 

1.2 미리 정의된 컬렉터

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

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

트랜잭션 리스트에서 트랜잭션 총합을 찾는 등의 다양한 계산을 수행할 때 리듀싱과 요약 관련 기능을 하는 컬렉터를 유용하게 사용할 수 있다. 또한 스트림 요소를 그룹화할 수도 있다.

 

2. 리듀싱과 요약

컬렉터(Stream.collect 메서드의 인수)로 스트림의 항목을 컬렉션으로 재구성할 수 있다. 컬렉터로 스트림의 모든 항목을 하나의 결과로 합칠 수 있다. 트리를 구성하는 다수준 맵, 메뉴의 칼로리 합계를 가리키는 단순한 정수 등 다양한 형식으로 결과가 도출될 수 있다.

 

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

메뉴에서 칼로리가 가장 높은 요리를 찾는다고 가정하자. Collectors.maxBy, Collectors.minBy 두 개의 메서드를 이용해서 스트림의 최댓값과 최솟값을 계산할 수 있다. 두 컬렉터는 스트림의 요소를 비교하는데 사용할 Comparator를 인수로 받는다. 다음은 칼로리로 요리를 비교하는 Comparator를 구현한 다음에 Collectors.maxBy로 전달하는 코드다.

Comparator<Dish> dishCaloriesComparator =
	Comparator.comparingInt(Dish::getCalories);
   
Optional<Dish> mostCalorieDish =
	menu.stream()
    	.collect(maxBy(dishCaloriesComparator));

2.2 요약 연산

Collectors 클래스는 Collectors.summingInt라는 특별한 요약 팩토리 메서드를 제공한다. summingInt는 객체를 int로 매핑하는 함수를 인수로 받는다. sumingInt의 인수로 전달된 함수는 객체를 int로 매핑한 컬렉터를 반환한다. 그리고 summingInt가 collect 메서드로 전달되면 요약 작업을 수행한다.

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

단순 합계 외에 평균값 계산 등의 연산도 요약 기능으로 제공된다. 이러한 연산들 중 두 개 이상을 한 번에 수행해야할 때도 있다. 이런 상황에서는 팩토리 메서드 summarizingInt가 반환하는 컬렉터를 사용할 수 있다.

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

2.3 문자열 연산

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

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

joining 메서드는 내부적으로 StringBuilder를 이용해서 문자열을 하나로 만든다.

 

2.4 범용 리듀싱 요약 연산

collect와 reduce

collect와 reduce는 메서드로 같은 기능을 구현할 수 있다. 그렇다면 차이점은 무엇일까?

 collect 메서드는 도출하려는 결과를 누적하는 컨테이너를 바꾸도록 설계된 메서드이고, reduce메서드는 불변형 연산이다.

reduce 메서드를 잘못 사용하면 실용성 문제가 발생하고, 병렬적인 수행도 힘들어진다.

 

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

 

3. 그룹화

데이터 집합을 하나 이상의 특성으로 분류해서 그룹화하는 연산도 데이터베이스에서 많이 수행되는 작업이다. 팩토리 메서드 Collectors.groupingBy를 이용해서 쉽게 메뉴를 그룹화할 수 있다.

 

Map<Dish.Type, List<Dish>> dishesByType =
	menu.stream().collect(groupingBy(Dish::getType));
    
public enum CaloricLevel {DIET, NORMAL, FAT}

Map<CaloricLevel, List<Dish>> dishesByCaloricLevel = menu.steram().collect(
	groupingBy(dish -> {
    	if(dish.getCalories() <= 400) return CaloricLevel.DIET;
    	else if(dish.getCalories() <= 700) return CaloricLevel.NORMAL;
        else return CaloricLevel.FAT;
    }));

3.1 그룹화된 요소 조작

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

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

{0THER=[french fries, pizza], MEAT=[pork, beef], FISH=[]}

이런 식으로 코드를 짜면 비어 있는 리스트도 맵을 만들 수 있다.

 

3.2 다수준 그룹화

두 인수를 받는 팩토리 메서드 Collectors.groupingBy를 이용해서 항목을 다수준으로 그룹화할 수 있다. Collectors.groupingBy는 일반적인 분류 함수와 컬렉터를 인수로 받는다. 즉, 바깥쪽 groupingBy 메서드에 스트림의 항목을 분류할 두 번째 기준을 정의하는 내부 groupingBy를 전달해서 두 수준으로 스트림의 항목을 그룹화할 수 있다.

Map<Dish.Type, Map<CaloricLevel, List<Dish>>> dishedByTypeCaloricLevel =
	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 CaloricLeverl.FAT;
            })
        )
    );

 

3.3 서브그룹으로 데이터 수집

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

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

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

마지막 그룹화 연산에서 맵의 모든 값을 Optional로 감쌀 필요가 없으므로 Optional을 삭제할 수 있다. 즉, 다음처럼 팩토리 메서드 Collectors.collectingAndThen으로 컬렉터가 반환한 결과를 다른 형식으로 활용할 수 있다.

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

 

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

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

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

4. 분할

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

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

4.1 분할의 장점

분할 함수가 반환하는 참, 거짓 두 가지 요소의 스트림 리스트를 모두 유지한다는 것이 분할의 장점이다.

컬렉터를 두 번째 인수로 전달할 수 있는 오버로드된 버전의 partitioningBy 메서드도 있다.

Map<Boolean, Map<Dish.Type, List<Dish>>> vegetarianDishesByType = menu.stream()
	.collect(
    	partitioningBy(Dish::isVegetarian,
       		groupingBy(Dish::getType)));

5. Collector 인터페이스

Collector 인터페이스는 리듀싱 연산을 어떻게 구현할지 제공하는 메서드 집합으로 구성된다.

public interface Collector<T, A, R> {
	Suppllier<A> supp.ier();
    BiConsumer<A, T> accumulator();
    Function<A, R> finisher();
    BinaryOperator<A> combiner();
    Set<Characteristics> characteristics();
}
  • T는 수집될 스트림 항목의 제네릭 형식이다.
  • A는 누적자, 즉 수집 과정에서 중간 결과를 누적하는 객체의 형식이다.
  • R은 수집 연산 결과 객체의 형식이다.

5.1 Collector 인터페이스의 메서드

 

supplier 메서드: 새로운 결과 컨테이너 만들기

supplier 메서드는 빈 결과로 이루어진 Supplier를 반환해야 한다. 즉, supplier는 수집 과정에서 빈 누적자 인스턴스를 만드는 파라미터가 없는 함수다. ToListCollector처럼 누적자를 반환하는 컬렉터에서는 빈 누적자가 비어있는 스트림의 수집 과정의 결과가 될 수 있다.

 

accumulator 메서드: 결과 컨테이너에 요소 추가하기

accumulator 메서드는 리듀싱 연산을 수행하는 함수를 반환한다. 스트림에서 n번째 요소를 탐색할 때 두 인수, 즉 누적자와 n 번째 요소를 함수에 적용한다. 함수의 반환값은 void, 즉 요소를 탐색하면서 적용하는 함수에 의해 누적자 내부 상태가 바뀌므로 누적자가 어떤 값일지 단정할 수 없다.

 

finisher 메서드: 최종 변환값을 결과 컨테이너로 적용하기

finisher 메서드는 스트림 탐색을 끝내고 누적자 객체를 최종 결과로 변환하면서 누적 과정을 끝낼 때 호출할 함수를 반환해야 한다. 누적자 객체가 이미 최종 결과인 상황도 있다. 그런 상황에는 변환 과정이 필요하지 않으므로 finisher 메서드는 항등 함수를 반환한다.

 

combiner 메서드: 두 결과 컨테이너 병합

combiner는 스트림의 서로 다른 서브파트를 병렬로 처리할 때 누적자가 이 결과를 어떻게 처리할지 정의한다. combiner를 이용하면 스트림의 리듀싱을 병렬로 수행할 수 있다. 스트림의 리듀싱을 병렬로 수행할 때 자바 7의 포크/조인 프레임워크와 Spliterarator를 사용한다.

 

characteristics 메서드

characteristics 메서드는 컬렉터의 연산을 정의하는 characteristics 형식의 불변 집합을 반환한다. characteristics는 스트림을 병렬로 리듀스할 것인지 그리고 병렬로 리듀스한다면 어떤 최적화를 선택해야 할지 힌트를 제공한다. characteristics는 다음 세 항목을 포함하는 열거형이다.

  • UNORDERED: 리듀싱 결과는 스트림 요소의 방문 순서나 누적 순서에 영향을 받지 않음
  • CONCURRENT: 다중 스레드에서 accumulator 함수를 동시에 호출할 수 있으며 이 컬렉터는 스트림의 병렬 리듀싱을 수행할 수 있다. 컬렉터의 플래그에 UNORDERED를 함께 설정하지 않았다면 데이터 소스가 정렬되어 있지 않은 상황에서만 병렬 리듀싱을 수행할 수 있다.
  • IDENTITY_FINISH: finisher 메서드가 반환하는 함수는 단순히 identity를 적용할 뿐이므로 이를 생략할 수 있다. 따라서 리듀싱 과정의 최종 결과로 누적자 객체를 바로 사용할 수 있다. 또한 누적자 A를 결과 R로 안전하게 형변환할 수 있다.

'Language > Java' 카테고리의 다른 글

Java - Record란?  (0) 2022.06.24
Wrapper Class vs Primitive Type  (0) 2022.03.29
Java - 스트림 활용  (0) 2022.03.03
Java - 스트림(stream)  (0) 2022.03.03
Java - 테스트  (0) 2022.01.28