Modern Java in Action #6

Taeyong.Hwang·2022년 4월 17일
1

Modern Java in Action

목록 보기
6/6
post-thumbnail

스트림으로 데이터 수집

Collection : 자바에서 제공하는 자료 구조
Collector : 스트림 요소를 어떤 방식으로 뽑아낼지 미리 정의해 둔 인터페이스
collect : 스트림 최종 연산 중 하나 (어떻게 모아서 반환할지)

컬렉터란?

이전 예제들에서 많이 보인 toList() 등이 Collector 인터페이스의 구현체입니다. 이처럼 Collector 인터페이스의 구현은 스트림의 요소를 어떤 식으로 도출할지 지정합니다.

Java8 이전의 명령형 코드에서는 그룹화 등을 위해 데이터를 모을 때 다중 루프와 조건문을 추가하기 때문에 가독성과 유지보수성이 떨어지지만, 선언형 프로그래밍에서는 필요한 컬렉터를 쉽게 추가함으로써 가독성과 유지보수성을 향상시킬 수 있습니다.


// 통화별로 트랜잭션을 그룹화하는 코드

//Java8 이전의 코드
Map<Currency, List<Treansaction>> 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);
    }
}

//Java8 이후의 코드
Map<Currency, List<Transaction>> transactionsByCurrencies = 
	transactions.stream().collect(groupingBy(Transaction::getCurrency));

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

스트림에 collect 메서드를 호출하면 스트림의 요소에 컬렉터로 파라미터화된 리듀싱 연산을 수행해 스트림의 각 요소를 방문하면서 컬렉터가 작업을 처리합니다.

//toList는 스트림의 모든 요소를 리스트로 수집한다.
List<Transaction> transactions = transactionStream.collect(Collectors.toList());

미리 정의된 컬렉터

Java8에서는 java.util.stream.Collectors에서 팩토리 메서드를 제공합니다. 제공하는 메서드의 기능은 크게 세 가지로 구분할 수 있습니다.

  1. 스트림 요소를 하나의 값으로 리듀스하고 요약

    • 다양한 계산을 수행할 때 유용하게 사용
  2. 요소 그룹화

    • 다수준 그룹화
  3. 요소 분할
    - 그룹화의 특별한 연산 (프레디케이트를 그룹화 함수로 사용)


리듀싱과 요약

앞서 설명했듯이, Collector로 스트림의 항목을 컬렉션으로 재구성 할 수 있습니다. 재구성 할 수 있는 형식은 다수준 맵, 단순한 정수 등 다양한 형식으로 도출될 수 있습니다.


//아래 코드는 메뉴에서 요리 개수를 반환합니다.
long howManyDishes = menu.stream().collect(Collectors.counting());
long howManyDishes = menu.stream().count();


//아래 코드는 최댓값을 반환합니다
Comparator<Dish> dishCaloriesComparator = Comparator.comparingInt(Dish::getCalories);
Optional<Dish> mostCalorieDish = menu.stream().collect(maxBy(dishCaloriesComparator));


//아래 코드는 메뉴 리스트의 총 칼로리를 반환합니다.
int totalCalories = menu.stream().collect(summingInt(Dist::getCalories));


//아래 코드는 평균 칼로리를 반환합니다
double avgCalories = menu.stream().collect(averagingInt(Dish::getCalories));


//joing 팩토리 메서드를 이용해 각 객체에 toString 메서드를 호출해서 추출한 모든 문자열을 하나의 문자열로 연결해서 반환할 수 있습니다.
String shortMenu = menu.stream().map(Dish::getName).collect(joining());


위의 모든 컬렉터는 reducing 팩토리 메서드로도 정의할 수 있습니다. 그럼에도 그렇게 하지 않은 이유는 프로그래밍적 편의성과 가독성을 향상시킬 수 있기 때문입니다.

//아래 코드는 위의 칼로리 합계를 계산하는 코드를 reducing으로 변경한 코드입니다.
int totalCalories = menu.stream().collect(reducing(0, Dish:getCalories, (i, j) -> i + j));

Stream 인터페이스의 Collect와 reduce

collect 메서드는 도출하려는 결과를 누적하는 컨테이너를 바꾸도록 설계된 메서드인 반면, reduce 메서드는 두 값을 하나로 도출하는 불변형 연산이라는 의미론적 관점에서 다른 양상을 취합니다.

가변 컨테이너 관련 작업이면서 병렬성을 확보하기 위해서는 collect 메서드로 리듀싱 연산을 구현하는 것이 바람직합니다.


그룹화

Java8의 함수형을 이용하면 가독성 있는 한 줄의 코드로 그훕화를 구현할 수 있습니다.

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

groupingBy 메서드에 전달된 Dish::getType을 기준으로 그룹화되므로 이를 분류 함수라고 부릅니다.


아래의 코드에서, 메서드 순서를 바꾸는 것만드로도 결과값이 달라지는 것을 확인 할 수 있습니다. 기존 그룹핑 키를 모두 보존하기 위해서는 collect 메서드를 먼저 사용하는 것이 바람직합니다.

// collect를 먼저 호출한 경우
menu.stream()
    .collect(groupingBy(
        Dish::getType,
        filtering(dish → dish.getCalories() > 500, toList()
    )));
// 실행 결과: {OTHER=[french fries, pizza], MEAT=[pork, beef], FISH=[]}
 
 
// 필터링을 먼저한 경우
menu.stream()
    .filter(dish → dish.getCaloires() > 500)
    .collect(groupingBy(Dish::getType)); 
// 실행 결과: {OTHER=[french fries, pizza], MEAT=[pork, beef]}  ( FISH 라는 키는 볼 수 없다.)

이를 통해 알 수 있는 것이 하나 있습니다.

groupingBy 컬렉터는 스트림의 첫 번째 요소를 찾은 이후에야 그룹화 맵에 새로운 키를 lazy한 방식으로 추가합니다


다수준 그룹화

groupingBy의 인자로 다시 groupingBy를 넘겨줌으로써 다수준 그룹화할 수 있습니다.

menu.stream()
    .collect(groupingBy(
        Dish::getType, // 타입으로 1차 분류
        groupingBy(dish -> {
            if (dish.getCalories() <= 400) // 그 안에서 칼로리로 2차 분류
                return CaloricLevel.DIET;
            else if (dish.getCalories() <= 700)
                return CaloricLevel.NORMAL;
            else
                return CaloricLevel.FAT;
        })
    ));
 
// 실행 결과: {MEAT={DIET=[chicken], NORMAL=[beef], FAT=[prok]},
//           FISH={DIET=[prawns], NORMAL=[salmon]},
//           OTHER={DIET=[rice, seasonal fruit], NORMAL=[french fries, pizza]}}

컬렉터 별로 추가 작업하기

Collectors.collectingAndThen 을 써서 컬렉터가 리턴한 결과에 추가 작업을 할 수 있습니다.

menu.stream()
    .collect(groupingBy(
        Dish::getType, // 1. 일단 먼저 type 기준으로 서브스트림 생성 -> 각 요리 타입에 따라 그룹을 나눔. 각 그룹의 요리를 담은 스트림 생성
        collectingAndThen( // 2. 각 서브스트림(그룹)에 대해 아래 내용 실행
            maxBy(comparingInt(Dish::getCalories)), // 3. 칼로리를 구해서 칼로리 최댓값을 가진 음식을 찾고
            Optional::get // 4. Optional<Dish>로 리턴했던 결과를 그냥 Dish로 바꿈.
        )
    )
);

분할

분할은 분할 함수라 불리는 프레디케이트를 분류 함수로 사용하는 특수한 그룹화 기능입니다. 프레디케이트를 분류 함수로 사용하기 때문에 맵의 키 형식을 Boolean입니다.

즉, 그룹화 맵은 참, 거짓의 값을 갖는 최대 두 개의 그룹으로 분류됩니다.

filter메서드의 경우는 참인 요소만 갖고 있게 되지만, 분할 메서드인 partitioningBy 메서드는 참, 거짓을 전부 유지하는 것이 해당 메서드의 장점입니다.

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

아래와 같이 분할 후 다시 분류할 수도 있습니다.

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 인터페이스를 구현해 직접 리듀싱 연산을 만들 수 있습니다.

/*
T: 스트림 안에 들어있는 데이터 하나하나의 타입
A: 수집 과정에서 중간 결과를 누적하는 객체 타입
R: 수집 연산을 끝낸, 결과 객체의 타입
*/

public interface Collector<T, A, R> {
    Supplier<A> supplier();
    BiConsumer<A, T> accumulator();
    Function<A, R> finisher();
    BinaryOperator<A> combiner();
    Set<Characteristics> characteristics();
}
profile
나를 위해 정리하는 개발 블로그

0개의 댓글