Modern Java in Action - Ch.6(1)

참고: 책 - Modern Java in Action

책 Modern Java in Action을 읽고 정리합니다. 이번 포스트에서는 Ch 6.1 ~ Ch 6.3의 내용을 읽고 정리합니다.

Ch 6. 스트림으로 데이터 수집
- Collection, Collector, collect 구분하기
6.1 컬렉터란 무엇인가?
- 6.1.1 고급 리듀싱 기능을 수행하는 컬렉터
- 6.1.2 미리 정의된 컬렉터
6.2 리듀싱과 요약
- 6.2.1 스트림에서 최댓값과 최솟값 검색
- 6.2.2 요약 연산
- 6.2.3 문자열 연결
- 6.2.4 범용 리듀싱 요약 연산
6.3 그룹화
- 6.3.1 그룹화된 요소 조작
- 6.3.2 다수준 그룹화
- 6.3.3 서브그룹으로 데이터 수집

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

Java 8의 스트림은 데이터 집합을 멋지게 처리하는 게으른 반복자입니다. 스트림의 최종 연산 collect를 통해 다양한 요소 누적 방식(Collector 인터페이스에 정의)을 인수로 받아서 스트림을 최종 결과로 도출하는 리듀싱 연산에 대해 정리하겠습니다.

스트림의 중간 연산과 최종 연산

중간 연산

최종 연산


Collection, Collector, collect 구분하기

Collection

Collector

public interface Collector<T, A, R> {
    Supplier<A> supplier();
    BiConsumer<A, T> accumulator();
    BiOperator<A> combiner();
    Function<A, R> finisher();
    Set<Characteristics> characteristics();
}

Collectors

Collector reducing(Binaryoperator<T> op)
Collector reducing(T identity, BinaryOperator<T> op)
Collector reducing(U identity, Function<T, U> mapper, BinaryOperator<U> op)

collect

Object collect(Collector collector)
Object collect(Supplier supplier, BiConsumer accumulator, BiConsumer combiner)

collect와 Collctor로 구현할 수 있는 예제

통화별로 트랜잭션을 그룹화한 다음에 해당 통화로 일어난 모든 트랜잭션 합계를 계산하시오 (Map<Currency, Integer> 전환)

Before - 명령형 프로그래밍

//그룹화한 트랜잭션을 저장할 맵을 생성
Map<Currency, List<Transaction>> 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);
}

After - 함수형 프로그래밍

Map<Currency, List<Transaction>> transactionsByCurrencies = transactions.stream()
        .collect(groupingBy(Transaction::getCurrency));




6.1 컬렉터란 무엇인가?

위 예제를 통해 명령형 프로그래밍에 비해 함수형 프로그램이 얼마나 편리한지 명확하게 보여줍니다. 함수형 프로그래밍에서는 ‘무엇’을 원하는지 직접 명시할 수 있어서 어떤 방법으로 이를 얻을지는 신경 쓸 필요가 없습니다. 다수준으로 그룹화를 수행할 때 명령형 프로그래밍과 함수형 프로그래밍의 차이점이 더욱 두드러집니다. 또한 훌륭하게 설계된 함수형 API의 또 다른 장점으로 높은 수준의 조합성재사용성이 있습니다. collect로 결과를 수집하는 과정을 간단하면서도 유연한 방식으로 정의할 수 있다는 점이 컬렉터의 최대 장점입니다.


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

스트림에 collect를 호출하면 -> 스트림의 요소에 (컬렉터로 파라미터화된) 리듀싱 연산이 수행됩니다. 내부적으로 리듀싱 연산이 일어나는 모습은 아래 그림과 같습니다.

IMG_612C04FA5412-1

collect에서는 리듀싱 연산을 이용해서 스트림의 각 요소를 방문하면서 컬렉터가 작업을 처리합니다.


6.1.2 미리 정의된 컬렉터

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




6.2 리듀싱과 요약

ex. counting()이라는 팩토리 메서드가 반환하는 컬렉터로 메뉴에서 요리 수를 계산할 수 있습니다.

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


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

ex. 메뉴에서 칼로리가 가장 높은 요리를 찾는 예제입니다.

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


6.2.2 요약 연산

ex. 메뉴 리스트의 총 칼로리를 계산하는 예제입니다.

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


ex. 메뉴 리스트의 칼로리 평균을 계산하는 예제입니다.

double avgCalories = menu.stream().collect(averagingInt(Dish::getCalories));


ex. 메뉴에 있는 요소 수, 요리의 칼로리 합계, 평균, 최댓값, 최소값 등을 계산하는 예제입니다.

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

// menuStatistics를 출력하면 아래와 같습니다.
IntSummaryStatistics{count=9, sum=4300, min=120, average=477.777778, max=800}


6.2.3 문자열 연결

ex. 메뉴의 모든 요리명을 연결하는 예제입니다.

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

// Dish에 toString 포함되어 있을 경우
String shortMenu = menu.stream().collect(joining());

//',' 구분 문자열을 넣을 경우
String shortMenu = menu.stream().map(Dish::getName).collect(joining(", "));


6.2.4 범용 리듀싱 요약 연산

ex. 모든 칼로리의 합계를 계산하는 예제입니다.

int totalCalories = menu.stream()
                        .collect(reducing(0, Dish::getCalories, (i, j) -> i + j));
  1. 첫 번째 인수는 reducing 연산의 시작값이거나 스트림에 인수가 없을 때는 반환값입니다.
    • 숫자 합계에서는 인수가 없을 때 반환값으로 0이 적합합니다.
  2. 두 번쨰 인수는 변환함수입니다.
  3. 세 번째 인수는 같은 종류의 두 항목을 하나의 값으로 더하는 BinaryOperator입니다.

ex. 가장 높은 칼로리를 가진 요리를 찾는 예제입니다.

Optional<Dish> mostCalorieDish = 
    menu.stream()
        .collect(reducing((d1, d2) -> d1.getCalories() > d2.getCalories () ? d1 : d2));


자신의 상황에 맞는 최적의 해법 선택

함수형 프로그래밍에서는 하나의 연산을 다양한 방법으로 해결할 수 있음을 보여줍니다. 또한 스트림 인터페이스에서 직접 제공하는 메서드를 이용하는 것에 비해 스트림을 이용하는 코드가 더 복잡하다는 사실도 보여줍니다. 코드가 좀 더 복잡한 대신 재사용성커스터마이즈 가능성을 제공하는 높은 수준의 추상화와 일반화를 얻을 수 있습니다. 문제를 해결할 수 있는 다양한 해결 방법을 확인한 다음에 가장 일반적으로 문제에 특화된 해결책을 고르는 것이 바람직한데 이로 인해 가독성과 성능을 모두 잡을 수 있습니다.




6.3 그룹화

ex. 메뉴를 그룹화하는 예제입니다.

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

ex. 칼로리 레벨을 분류하는 예제입니다. (diet - 400 이하, normal - 400 ~ 700, fat - 700 초과)

public enum CaloricLevel { DIET, NORMAL, FAT }

Map<CaloricLevel, List<Dish>> dishesByCaloricLevel = 
    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 그룹화된 요소 조작

ex. 500칼로리가 넘는 요리만 필터링하는 예제입니다.

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


Map<Dish.Type, List<Dish>> dishNamesByType = 
    menu.stream()
        .collect(groupingBy(Dish::getType, mapping(Dish.getName, toList())));


ex. 각 형식의 요리의 태그를 간편하게 추출하는 예제입니다.

Map<Dish.Type, Set<String>> dishNamesByType =
    menu.stream()
        .collect(groupingBy(Dish::getType, flatMapping(dish -> dishTags.get(dish.getName()).stream(), toSet())));


6.3.2 다수준 그룹화

ex. 요리를 형식별, 칼로리 별로 분류하는 예제입니다.

Map<Dish.Type, Map<CaloricLevel, List<Dish>>> dishesByTypeCaloricLevel =
    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 CaloricLevel.FAT;
                    })
                )
        );
//결과

{MEAT={DIET=[chicken], NORMAL=[beef], FAT=[prok]},
FISH={DIET=[prawns], NORMAL=[salmon]},
OTHER={DIET=[rice, seasonal fruit], NORMAL=[french fries, pizza]}}


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

ex. 요리의 종류를 분류하는 컬렉터로 메뉴에서 가장 높은 칼로리를 가진 요리를 찾는 프로그램도 다시 구현할 수 있습니다.

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