모던 자바 인 액션 6장

모던 자바 인 액션 6장 : 스트림으로 데이터 수집

이번장에서는 최종연산 collect로 다양한 요소 누적 방식을 인수로 받아서 최종 결과를 도출하는 방식을 알아본다.
다음은 collect와 컬렉터로 구현할 수 있는 질의 예제이다.

  • 통화별로 트랜잭션을 그룹화한 다음에 해당 통화로 일어난 모든 트랜잭션 합계를 계산하시오
    (Map<Currency, Integer> 반환)
  • 트랜잭션을 비싼 트랜잭션과 저렴한 트랜잭션 두 그룹으로 분류하시오
    (Map<Boolean, List> 반환)
  • 트랜잭션을 도시 등 다수준으로 그룹화하시오. 그리고 각 트랜잭션이 비싼지 저렴한지 구분하시오
    (Map<String, Map<Boolean, List>> 반환)


6.1 컬렉터란 무엇인가?

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

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

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

6.1.2 미리 정의된 컬렉터

6장에서는 groupingBy같이 Collectors 클래스에서 제공하는 팩토리 메서드의 기능을 설명한다.
Collectors에서 제공하는 메서드의 기능은 크게 세 가지로 구분할 수 있다.

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

다음절부터 하나씩 알아보자.

6.2 리듀싱과 요약

컬렉터로 스트림의 항목을 컬렉션 혹은 정수 등 다양한 형식으로 결과를 도출할 수 있다.

아래는 예제 코드이다

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

// 불필요한 과정 생략 가능
long howManyDishes - menu.stream().count();

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

  • Collectors.maxBy, Collectors.minBy를 제공한다.
  • 두 컬렉터는 요소를 비교할 때 사용할 Comparator를 인수로 받는다.
Comparator<Dish> dishCaloriesComparator = Comparator.comparingInt(Dish::getCalories);

Optional<Dish> mostCalorieDish = menu.stream()
                                    .collect(maxBy(dishCaloriesComparator));
  • menu가 비어있을 수 있으므로 Optional로 받는다.
  • 최댓값 뿐만 아니라 객체의 숫자 필드의 합계나 평균 등을 반환하는 연산에도 리듀싱 기능이 자주 사용된다. 이러한 연산을 요약 연산이라 부른다.

6.2.2 요약 연산

Collectors를 static import 했다고 가정

  • 합계 구하기 : summingInt, summingLong, summingDouble
    int totalCalories = menu.stream().collect(summingInt(Dish::getCalories));
    
  • 평균값 계산 : averagingInt,averagingLong,averagingDouble
    double avgCalories = menu.stream().collect(averagingInt(Dish::getCalories))
    
  • 두 개 이상의 연산을 한번에 수행 : summarizingInt
IntSummaryStatistics collected = ch4.menu.stream().collect(summarizingInt(Dish::getCalories));

//출력
IntSummaryStatistics{count=9, sum=4200, min=120, average=466.666667, max=800}

dish.getCalories()의 다양한 연산의 결과가 출력된다.

6.2.3 문자열 연결

  • joining : 각 객체의 toString 메서드를 호출해서 모든 문자열 연결
    • joining 메서드는 내부적으로 StringBuilder를 이용해서 문자열을 하나로 만듬
String shortMenu = menu.stream().map(Dish::getName).collect(joining());

// 구분자를 요소 사이에 추가 가능
String shortMenu = menu.stream().map(Dish::getName).collect(joining(", "));

6.2.4 범용 리듀싱 요약 연산

지금까지 위에 나온 모든 컬렉터는 reducing 팩토리 메서드로도 정의할 수 있다. 즉, Collectors.reducing으로도 구현할 수 있다.
그러나 가독성과 편의성의 이점을 누리려면 위의 메서드들을 적절하게 사용하는게 중요하겠다.

예를 들어 아래처럼 reducing 메서드로도 summingInt의 기능을 충분히 수행할 수 있다.

int totalCalories = menu.stream().collect(reducing(0, Dish::getCalories, (i,j)-> i+j));
  • 첫 번째 인수 : 리듀싱 연산의 시작값 혹은 스트림에 인수가 없을 때는 반환값
  • 두 번째 인수 : 요리를 칼로리 정수로 변환하는 변환함수
  • 세 번째 인수 : BinaryOperator


아래와 같이 칼로리를 가장 높은 요리를 찾는 maxBy 의 기능도 reducing으로 가능하다.

Optional<Dish> mostCalorieDish = 
    menu.stream().collect(reducing(
        (d1,d2) -> d1.getCalories() > d2.getCalories() ? d1 : d2
    ));
  • 한개의 인수를 받는 reducing 컬렉터는 시작값이 없으므로 Optional<Dish>를 반환한다.

6.3 그룹화

  • 그룹화 : 데이터 집합을 하나 이상의 특성으로 분류하는 것
    ex) 고기를 포함하는 그룹, 생선 포함 그룹, 나머지 그룹
Map<Dish.Type, List<Dish>> dishesByType = 
    menu.stream().collect(groupingBy(Dish::getType));

// 출력
{MEAT=[pork, beef, chicken], OTHER=[french fries, rice, season fruit, pizza], FISH=[prawns, salmon]}

이 함수를 기준으로 스트림이 그룹화되므로 이를 분류 함수라고 부른다.


위 예제에서는 단순한 속성 접근자를 기준으로 분류하였지만 더 복잡한 분류 기준이 필요한 상황에서는 메서드 참조를 분류 함수로 사용할 수 없다.
따라서 이런 경우에는 람다 표현식으로 로직을 구현해야 한다.

public enum CaloricLevel { DIET, NORMAL, FAT}

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

요리 종류와 칼로리 두 가지 기준으로 동시에 그룹화할 수는 없을까?

6.3.1 그룹화된 요소 조작

아래 코드는 500칼로리가 넘는 요리만 필터링한다.

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

아래는 출력 결과이다.

{OTHER=[french fries, pizza], MEAT=[port, beef]}

결과를 보면 FISH 종류의 요리는 500칼로리가 넘는것이 없어서 결과 맵에서 키 자체가 사라져버렸다.
이를 해결하기위해 Collectors 클래스는 일반적인 분류 함수에 Collector 형식의 두 번째 인수를 갖도록 groupingBy를 오버로딩해 두었다.
아래 코드로 위와같은 문제를 해결할 수 있다.

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

//출력
{OTHER=[french fries, pizza], MEAT=[port, beef], FISH=[]}
  • filtering 메소드는 Collectors의 또다른 정적 팩토리 메소드로 predicate를 인수로 받는다.

서브그룹으로 데이터 수집

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

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