본문 바로가기
도서/모던 자바 인 액션

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

by Ahngyuho 2023. 8. 5.

해당 포스팅은 모던 자바  액션이라는 책을 읽고 정리한 것입니다.

 

배울 내용

  • Collectors 클래스로 컬렉션을 만들고 사용하기
  • 하나의 값으로 데이터 스트림 리듀스하기
  • 특별한 리듀싱 요약 연산
  • 데이터 그룹화와 분할
  • 자신만의 커스텀 컬렉터 개발

앞장 간단 정리

스트림으로 데이터베이스 질의와 비슷한 형태의 코드와 연산을 수행할 수 있게 됨

스트림은 중간 연산과 최종 연산이 있고 중간 연산은 파이프라인을 생성할 수 있고, 스트림 요소를 소비하는 시점이 아님

반면 최종 연산은 스트림의 요소를 소비하는 실제로 연산을 실행해 결과를 도출, lazy를 통한 연산 최적화 적용

 

이번장의 핵심은 collect를 이용해 다양한 요소를 누적하여 스트림의 결과로 반환하는 리듀싱 연산을 배워볼 것입니다.

다양한 요소 누적 방식은 Collector 인터페이스에 정의되어 있습니다.

요구사항

Transaction 클래스는 단건 거래 데이터를 의미하고 거래가 이루어진 통화 종류와 통화량을 멤버 변수로 가집니다.

Transaction 리스트가 주어지면 각 통화 종류별 통화량을 계산해주세요.

 

요구사항에 대한 코드를 자바 8 등장 전/후 모두 작성해 보겠습니다.

 

자바 8 등장 이전의 코드

//통화별로 트랜잭션을 그룹화한 코드
        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);
        }

 

자바 8등장 후의 코드

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

코드가 매우 간편하고 한 눈에 알아보기 쉬운 코드가 되었습니다.

 

이제 컬렉터,colletc를 이용해서 복잡해보이는 위 코드를 간단한 코드로 표현할 수 있는 방법에 대해 자세하게 배워봅시다.

 

6.1 컬렉터란 무엇인가?

두 코드의 차이를 명확하게 설명할 수 없었던 이유가 뭐였을까?

위의 코드 예시를 보면서 왜 코드가 더 알기 쉽게 변했을까? 하는 의문이 들었습니다. 알아보기 쉽게 변한건 누가 봐도 알겠으나 두 코드의 차이는 정확히 무엇인지 설명할 수 있는 지식이 부족했던 것이 이유인 것 같았습니다.

 

두 코드의 차이는 프로그래밍 패러다임 입니다.

자바 8 이전의 코드는 명령형 프로그래밍 스타일이고 자바 8이 도입되고 나서는 자바에 함수형 프로그래밍 스타일 입니다!

 

함수형 프로그래밍은 '무엇'을 원하는지를 직접적으로 명시해 줄 수 있으므로 코드가 굉장히 명확해지고, 어떤 방법으로 구현했는지는 신경쓰지 않아도 된다는 장점이 있습니다.

 

 

Collector 인터페이스 구현은 스트림의 요소를 어떤 식으로 도출할지 결정할 수 있습니다.

앞서 작성했던 예제에서 .collect(Collectors.toList())를 본적이 있으셨을 겁니다.

Collectors.toList()는 Collector 인터페이스의 구현을 반환합니다. 구현된 동작의 내용은 '각 요소를 리스트로 만들어라' 입니다.

그리고 여기서 나오는 groupingBy의 동작은 '각 키(통화) 버킷 bucket 그리고 각 키 버킷에 대응하는 요소 리스트를 값으로 포함하는 맵을 만들어라' 입니다.

 

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

이제 스트림에 collect에 리듀싱 연산을 수행하는 동작을 인자로 넣는 방법에 대해서 자세하게 알아볼 것입니다.

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

위 그림은 통화별로 트랜잭션을 그룹화하는 리듀싱 연산 과정입니다.

어떤 연산을 실행해서 리듀싱 할 것인지는 Collector 인터페이스를 구현하여 동작을 collect에 넘겨주면 됩니다!

 

6.1.2 미리 정의된 컬렉터

groupingBy 와 같이 Collectors 클래스에서 제공하는 팩토리 메서드의 기능이 설명되어 있습니다.

Collectors에서 제공하는 메서드의 기능은 크게 3가지로 구분됩니다.

 

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

이제 이 3가지를 차례로 살펴보겠습니다.

 

6.2 리듀싱과 요약

이제 Collector 팩토리 메서드로 만든 Collector 인스턴스로 총 3가지 유형의 동작을 확인해 볼겁니다.

 

 

컬렉터로 스트림의 모든 항목을 하나의 결과로 합칠 수 있음

그 종류는 트리를 구성하는 다수준 맵, 메뉴의 칼로리 합계를 가리키는 단순한 정수 등 여러 종류의 결과가 존재합니다.

 

정수같은 값 형태를 결과로 도출하는 Collector 인스턴스를 살펴봅시다.

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

이렇게 총 합계와 같은 값을 결과로 도출하는 코드를  작성해 봤습니다.

 

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

메뉴 컬렉션에서 칼로리가 가장 높은 요리를 찾는다고 가정할 때의 코드를 작성해볼 예정입니다.

Collectors.maxBy와 이 인스턴스에 전달할 Comparator를 만들어야 합니다.

그리고 maxBy의 반환 형태도 한번 유의깊게 봐봅시다. Collector 인터페이스를 구현한 구현체를 반환하는데 

뒤에 Collector 인터페이스에 대해서도 나오기 때문에 한번 보고 뒤에서 자세하게 배워보면 좋을 것 같습니다.

//Collectors.maxBy에 전달할 Comparator
Comparator<Dish> dishCaloriesComparator = Comparator.comparingInt(Dish::getCalories);

Optional<Dish> mostCaloriesDish = menu.stream().collect(maxBy(dishCaloriesComparator));

리듀싱 연산은 객체에 존재하는 숫자 필드의 합계나 평균 등을 반환하는 연산에도 사용될 수 있고 이를 요약 연산 이라고 부릅니다.

6.2.2 요약 연산

요약하면 summingInt가 객체 -> int 로 변환하는 컬렉터를 반환하고, collect로 이 Collector 인스턴스가 전달되면서 리듀싱 연산이 시작되는 것입니다!

 

Collectors.summingLong, Collectors.summingDouble 메서드도 존재하며 동작은 summingInt와 동일합니다

averagingInt, averagingLong, averagingDouble 메서드로 숫자 집합의 평균도 구할 수 있습니다.

 

종종 이들 중 두 개 이상의 연산을 한 번에 수행해야 하는  상황도 있는데, 팩토리 메서드 summarizingInt가 반환하는 컬렉터를 사용하면 해결할 수 있습니다. 

 

예시 코드를 보겠습니다.

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

//결과
//IntSummaryStatistics{count=9, sum=4300, min=120, average=477.777778, max=800}

IntSummaryStatistics 객체인 summarize를 출력해보면 전달한 숫자 집합의 통계들을 뽑아낼 수 있습니다.

기본 자료형 int,long,double에 해당되는 것들이 각각 존재합니다.

 

6.2.3 문자열 연결

collect에 joining 팩토리 메서드를 전달(이것도 Collector의 인스턴스 입니다.)하면 스트림이 각 객체의 toString 메서드를 호출해서 추출한 모든 문자열을 하나의 문자열로 연결해서 반환해줍니다.

 

// 1
menu.stream().map(Dish::getName).collect(joining());
//결과
//porkbeefchickenfrench friesriceseason fruitpizzaprawnssalmon

-----------------------------------------------------------------------------
// 2 구분 문자열 사용
System.out.println(menu.stream().map(Dish::getName).collect(joining(", ")));
//결과
//pork, beef, chicken, french fries, rice, season fruit, pizza, prawns, salmon
  • 1번 예시는 구분이 되지 않아 알아보기 힘들다는 단점 존재
  • 2번 예시처럼 joining에는 구분자를 넣어줄 수 있어 결과를 알아보기 쉽게 바꿔줌

 

joining 메서드는 내부적으로 StringBuilder를 이용해서 문자열을 하나로 만듭니다. String을 이용하면 성능관련 문제가 발생할 수 있어서 그런 듯 합니다.

 

지금까지 스트림에 존재하는 값을 추출해서 하나의 형태로 만드는 리듀싱 연산을 알아봤습니다.

 

6.2.4 범용 리듀싱 요약 연산

menu.stream().collect(reducing(0, Dish::getCalories, (i, j) -> i + j))

사실 앞서 살펴본 컬렉터 인스턴스들은 reducing 팩토리 메서드를 통해 직접 만들 수 있습니다.

위 코드는 모든 칼로리의 합계를 구하는 코드입니다.

 

reducing은 총 3개의 인수를 받습니다.

  • 첫 번째: 리듀싱 연산의 시작값이거나 스트림에 인수가 없을 땐 반환값
  • 두 번째: Dish 객체를 칼로리라는 int 자료형으로 반환하기 위한 변환 함수 전달
  • 세 번째: 같은 종류의 두 항목을 하나의 값으로 더하기 위한 BinaryOperation 인터페이스의 구현체를 람다 표현식으로 전달

reducing에 하나의 인수를 전달하는 방법도 있습니다.

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

이렇게 인수를 하나만 받는 경우는 첫 번째 인수가 없으므로 인수가 없는 경우이고 두 번째 인수는 자시 자신을 넘겨주는 항등 함수를 변환 함수로 전달합니다. 그리고 지금 넣어준 한개의 인수가 세 번째 인수에 해당됩니다.

이 방법을 온전히 이해하기 위해서는 reducing와 Collerctor 인터페이스에 대해서 자세하게 살펴봐야 할 것 같습니다.

 

collect와 reduce 메서드

collect와 reduce 메서드는 리듀싱 연산을 지원한다는 점이 공통점인 것 같습니다. 그럼 이 두 메서드의 차이는 무엇일까요?

 

의미론적 관점

collect는 도출하려는 결과를 누적하는 컨테이너를 바꾸도록 설계된 메서드이고, reduce는 두 값을 하나로 도출하는 불변형 연산이라는 점에서 다릅니다. 

//Collector의 toList()를 reduce로 구현한 코드
        Stream<Integer> stream = Arrays.asList(1,2,3,4,5,6).stream();
        stream.reduce(
                new ArrayList<>(),(List<Integer> l,Integer e) -> {
                    l.add(e);
                    return l;
                }, (List<Integer> l1,List<Integer> l2) -> {
                    l1.addAll(l2);
                    return l1;
                }
        );

reduce는 불변 객체를 대상으로 하는 연산이고, 이렇게 reduce를 잘못 사용하게 되면 여러 스레드가 동시에 같은 데이터 구조체를 고치게 되면서 리스트 자체가 망가져버려 리듀싱 연산을 병렬로 처리하지 못하게되는 문제도 발생합니다.

그리고 매 연산마다 리스트를 할당하느라 성능마저 떨어지게 됩니다.

그래서 가변 컨테이너가 대상이고 병렬성을 확보하기 위해서는 collect를 쓰는 것이 바람직해 보입니다!

 

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

reducing을 이용해서 Dish의 모든 칼로리 합계를 계산하는 예제를 좀 더 알아보기 쉬운 방식으로 바꿀 수 있습니다.

//이전 예제
menu.stream().collect(reducing(0, Dish::getCalories, (i, j) -> i + j))

//Integer의 sum을 메서드 참조로 전달한 코드 예제
int totalCalories = menu.stream().collect(reducing(0, // <- 초기값
                Dish::getCalories, // <- 변환 함수
                Integer::sum)); //<- 합계 함수

//IntStream을 이용 -> 자동 언박싱, Integer -> int로 변환하는 과정이 필요 없어 가독성과 성능 측면에서 더 좋은 코드
int totalCalories = menu.stream()
                .mapToInt(Dish::getCalories)
                .sum();

아래 그림은 리듀싱 연산 과정을 논리적으로 표현한 것입니다. 누적자를 초기값으로 초기화하고, 합계 함수를 이용해서 각 요소에 변환 함수를 적용한 결과 숫자를 반복적으로 조합합니다.

자신의 상황에 맞는 최적의 방법을 선택하자!

함수형 프로그래밍에서는 하나의 연산도 다양하게 표현할 수 있는 것 같습니다. 주어진 컬렉터를 사용하는 방법도 있고 커스터마이징 해서 좀 더 성능을 얻을 수도 있는 것 같습니다. 그래서 결론은 자신의 상황에 맞게 최적의 방법을 생각해내 코드를 작성하면 되겠습니다.

 

문제를 해결할 다양한 방법을 머리속에 생각해내고 그중 가장 일반적으로 문제에 특화된 해결책을 고르는 것이 올바른 방법인 것 같습니다. 항상 가독성과 성능은 중요!

 

그래서 Dish 컬렉션에서 모든 칼로리의 합계를 구해야 하는 요구사항에서는 IntStream을 이용해서 자동 언방식, Integer -> int로 변환해야 하는 과정을 피할 수 있어 가독성과 성능 측면에서 위의 두개의 코드보다 더 좋은 코드라고 할 수 있습니다.

 

6.3 그룹화

데이터 소스 안의 객체에서 하나의 속성을 뽑아내어 그룹화하는 작업이 필요할 때가 있습니다. 앞서 트랜잭션 예제에서 보셨듯이 명령형 프로그래밍으로 짠 코드와 함수형 프로그래밍으로 짠 코드는 가독성 면에서 상당히 큰 차이가 있었습니다.

 

Dish의 타입을 기준으로 Dish 객체를 그룹화하는 코드를 함수형 프로그래밍으로 짜봅시다.

Map<Dish.Type, List<Dish>> dishesByType = menu.stream().collect(groupingBy(Dish::getType));
  • menu.stream().collect(groupingBy(Dish::getType)); 
  • 'Dish의 Type을 기준으로 grouping 해라' 라는 뜻으로 굉장히 가독성이 좋음
  • 특정 속성을 기준으로 grouping 하기위해 전달된 이 함수를 Dish::getType 분류 함수라고 부름
  • 결과 : Type이 key가 되고 각 key에 대응하는 스트림의 모든 항목 리스트를 갖는 맵을 반환

이번에는 칼로리를 기준으로 분류화 작업을 진행해 봅시다.

 

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 그룹화된 요소 조작

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

그런데 사실 요소를 그룹화 하기 전에 요소를 조작하고 그룹화 하는 것이 더 편해보이고 그게 맞는거 같아 보이기도 합니다. 만약 500 칼로리가 넘는 음식들을 필터링 해서 Dish의Type을 기준으로 그룹화 한다고 하면

Map<Dish.Type, List<Dish>> caloricDishesByType = menu.stream()
                .filter(dish -> dish.getCalories() < 500)
                .collect(groupingBy(Dish::getType));
                
// 결과
//{MEAT=[pork, beef], OTHER=[french fries, pizza]}

 문제는 사실 Dish의 타입은 MEAT, OTHER 말고도 FISH 라는 타입도 존재하는데 filter에서 걸러지는 바람에 분류화 할 때 key로 생성되지 않았습니다...

그래서 분류화 후에 필터링을 해야하기 때문에 그룹화된 요소 조작이 필요합니다.

 

이 문제를 해결하기 위해서 groupingBy가 분류 함수와 Collector 인스턴스를 전달할 수 있도록 오버로드가 되어 있습니다.

menu.stream().collect(groupingBy(Dish::getType,
                        filtering(dish -> dish.getCalories() > 500, toList())));

//결과
//{FISH=[], MEAT=[pork, beef], OTHER=[french fries, pizza]}

filtering 메서드는 Collectors 클래스의 또 다른 정적 팩토리 메서드로 Predicate와 Collector 인스턴스를 인수로 받습니다.

이 Predicate로 그룹화된 요소를 재그룹화 하는 것입니다.

 

filtering 메서드 말고 mapping 이라는 메서드로 그룹화된 요소들을 변환하는 작업을 할 수 있습니다.

//이전 예제
Map<Dish.Type, List<Dish>> caloricDishesByType = menu.stream()
                .filter(dish -> dish.getCalories() < 500)
                .collect(groupingBy(Dish::getType));
                


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

이전 예제와는 다르게 결과가 List의 String으로 되었습니다.

 

groupingBy에는 3번째 인수를 전달할 수도 있습니다. 이 인수에 세 번째 컬렉터를 사용해서 일반 맵이 아닌 flatMap 변환을 수행할 수 있습니다. 

 

public static final Map<String, List<String>> dishTags = new HashMap<>();
    static {
        dishTags.put("pork", asList("greasy", "salty"));
        dishTags.put("beef", asList("salty", "roasted"));
        dishTags.put("chicken", asList("fried", "crisp"));
        dishTags.put("french fries", asList("greasy", "fried"));
        dishTags.put("rice", asList("light", "natural"));
        dishTags.put("season fruit", asList("fresh", "natural"));
        dishTags.put("pizza", asList("tasty", "salty"));
        dishTags.put("prawns", asList("tasty", "roasted"));
        dishTags.put("salmon", asList("delicious", "fresh"));
    }

여기 태그 목록을 가진 각 요리로 구성된 맵이 있습니다. key는 Dish의 name이고 value는 tag 리스트 입니다.

이제 이 tag들을 Type을 기준으로 분류해 보겠습니다.

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

value가 Set이므로 중복된 결과는 반환되지 않습니다.

 

앞서 본 예제들은 칼로리나 Type 같은 한 가지 기준으로 메뉴의 요리를 그룹화 했습니다. 이제 두 가지 이상의 기준을 가지고 그룹화를 배워보겠습니다.

 

6.3.2 다수준 그룹화

groupingBy에 두개의 인수를 전달해서 다수준 그룹화를 할 수 있습니다.

첫 번째 인수에 첫 번째 수준의 분류 함수를 넣어주고, 두 번째 인수에 분류할 두 번째 기준을 정의하기 위한 groupingBy를 전달해주면 됩니다.

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;
                })));

//결과
//{FISH={NORMAL=[salmon], DIET=[prawns]}, 
//MEAT={FAT=[pork], NORMAL=[beef], DIET=[chicken]}, 
//OTHER={NORMAL=[french fries, pizza], DIET=[rice, season fruit]}}

빨간색: 첫 번째 수준의 분류 함수

파란색: 두 번째 수준의 분류 함수 기준을 위한 컬렉터 인스턴스

 

외부의 groupingBy에 전달된 분류 함수에 의해 첫 번째 분류 키값 'FISH,MEAT,OTHER'를 가지게 되었고,

내부의 groupingBy에 전달된 분류 함수에 의해 두 번째 분류 키값 'DIET,NORMAL,FAT'를 가지게 되는 것입니다.

 

n수준의 그룹화의 결과는 n수준 트리 구조로 표현되는 n수준 맵이 됩니다.

groupingBy의 연산을 '버킷 bucket' 개념으로 생각하시면 이해하시기 수월할 것 같습니다.

첫 번째 groupingBy는 각 키의 버킷을 만들고, 준비된 각각의 버킷에 서브스트림 컬렉터로 채워가기를 반복하면서 n수준 그룹화를 달성합니다.

 

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

위에서 본 첫 번째 groupingBy로 넘겨주는 컬렉터의 형식은 제한이 없습니다. 첫 번째 인수에는 분류 함수, 두 번째 인수에는 counting 컬렉터를 전달해서 메뉴에서 요리의 수를 종류별로 계산할 수 있습니다.

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

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

groupingBy(f) 이런 식으로 하나의 인수를 받는 경우는 사실 groupingBy(f,toList())의 축약형입니다.

 

아래 코드는 두 번째 인수에  maxBy 컬렉터를 전달해서 각 요리 타입 별 가장 칼로리가 높은 음식을 찾는 프로그램 입니다.

 

Map<Dish.Type, Optional<Dish>> mostCaloricByType = menu.stream()
                .collect(groupingBy(Dish::getType, maxBy(Comparator.comparingInt(Dish::getCalories))));
//결과
//{FISH=Optional[salmon], MEAT=Optional[pork], OTHER=Optional[pizza]}

그룹화의 결과로 요리의 종류를 key, Optional<Dish>를 value으로 갖는 맵이 반환됩니다.

 

그런데 사실 위 결과는 살짝 어색합니다. 실제 메뉴의 요리 중 Optional.empty()를 값으로 갖는 요리는 존재하지 않기 때문입니다.

groupingBy 컬렉터는 스트림의 첫 번째 요소를 찾은 이후에야 그룹화 맵에 새로운 키를(게으르게) 추가합니다.

그래서 처음부터 존재하지 않는 요리의 키는 맵에 추가되지 않습니다. 그러므로 Optional일 필요가 없습니다.

하지만 maxBy가 생성하는 컬렉터의 결과에 의해 Optional이 value의 형태로 들어가게 돼버렸습니다...

실제로 TEST 라는 Type을 추가해서 결과를 확인해보면 TEST 라는 Type은 생성되지 않았습니다. 그렇기 때문에 어떤 Type 존재는 하더라도 menu에 존재하는 list에 해당 Type을 가지는 Dish가 없다면 분류되기 위한 key는 생성되지 않습니다. 하지만 위의 결과는 value가 Optional 이므로 깔끔하지 않습니다.

 

그래서 다음에 배울 내용으로 위의 문제를 해결해 보겠습니다.

 

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

이제 위의 나온 결과에서 Optional을 제거하는 작업을 해볼겁니다.

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

Collectors.collectingAndThen으로 컬렉터가 반환한 결과를 다른 형식으로도 활용할 수 있습니다.

 

인수와 결과

  • 첫 번째 인수는 적용될 컬렉터(감싸인 컬렉터) 두 번째 인수는 변환 함수를 전달
  • 결과는 첫 번째 인수에 들어온 컬렉터를 감싸주는 래퍼 역할을 하는 컬렉터!
  • 위 예제에서는 maxBy로 만들어진 컬렉터를 감싸는 컬렉터가 Optional::get이라는 변환 함수를 적용해 결과 도출
  • 리듀싱 컬렉터는 앞서 설명했듯이 절대 Optional.empty()를 반환하지 않아 안전함!

이런 중첩 컬렉터는 앞으로도 많이  사용될 겁니다. 그러므로 중첩 컬렉터가 동작하는 방식을 자세하게 배워 제대로 적용해 봅시다.

 

  • 맨 바깥쪽 groupingBy에 의해 Type을 기준으로 key가 생성, 그리고 각 key 마다 메뉴 스트림을 서브 스트림으로 그룹화
  • groupingBy에 전달된 컬렉터인 collectingAndThen는 위에서 그룹화된 서브 스트림에 각각 적용
  • 각각의 서브 스트림에 감싸질 maxBy 컬렉터를 감싸게 됨
  • 리듀싱 컬렉터가 서브 스트림에 연산을 수행한 결과에 collectingAndThen의 Optional::get 변환 함수 적용
  • 이제 groupingBy 컬렉터가 key에 대응하는 value 값을 최종적으로 만들어 내고 그 value는 maxBy에 각 Type의 최대 요리의 이름임

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

일반적으로 groupingBy에 의해 그룹화된 요소에 리듀싱 연산을 적용할 때는 groupingBy의 두번째 인수에 컬렉터를 전달합니다. 

 

mapping 메서드

mapping 메서드는 그룹화된 요소를 변환하는 함수와 변환 함수의 결과 객체를 누적하는 컬렉터를 인수로 받음

mapping은 입력 요소를 누적하기 전에 매핑함수다양한 형식의 객체를 주어진 형식의 컬렉터에 맞게 변환

 

Map<Dish.Type, Set<CaloricLevel>> caloricLevelByType = menu.stream()
                .collect(groupingBy(Dish::getType, mapping(
                        dish -> {
                            if (dish.getCalories() <= 400) return CaloricLevel.DIET;
                            else if (dish.getCalories() <= 700) return CaloricLevel.NORMAL;
                            else return CaloricLevel.FAT;
                        }
                        , toSet())));
//결과
//{FISH=[NORMAL, DIET], MEAT=[FAT, NORMAL, DIET], OTHER=[NORMAL, DIET]}

mapping의 두 번째 인수는 변환될 객체를 누적할 컬렉터를 전달해 줄 수 있습니다. 

그래서 이 인수에 toCollection이라는 컬렉터를 넣어 Set의 형식까지 지정해 줄 수 있습니다.

 

6.4 분할

분할을 Predicate를 사용해서 true/false 두 개의 집합을 가지는 결과를 반환합니다.

 

menu에 채식요리인지 아닌지 구분할 수 있는 코드를 작성해보면

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

//결과
//{false=[pork, beef, chicken, prawns, salmon], true=[french fries, rice, season fruit, pizza]}{false=[pork, beef, chicken, prawns, salmon], true=[french fries, rice, season fruit, pizza]}

 

6.4.1 분할의 장점

분할은 참 거짓 두 가지 요소의 스트림 리스트를 유지할 수 있다는 것이 장점입니다. 

partitionedMenu.get(true);
partitionedMenu.get(false);

이런 식으로 true,false 만으로 채식요리와 비채식요리 리스트를 바로 반환받을 수 있습니다.

 

partitioningBy 메서드의 두 번째 인수에 컬렉터를 전달할 수 있습니다. 

Map<Boolean, Map<Dish.Type, List<Dish>>> vegetarianDishes = 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]}}

채식 요리 스트림과 비채식 요리 스트림을 각각 요리 종류로 그룹화해서 두 수준의 맵 반환

 

Map<Boolean, Dish> mostCaloricPartitionedByVegetarian = menu.stream()
                .collect(partitioningBy(Dish::isVegetarian, collectingAndThen(maxBy(Comparator.comparingInt(Dish::getCalories)), Optional::get)));

//결과
//{false=pork, true=pizza}

 

6.4.2 숫자를 소수와 비소수로 분할하기

//1-n을 까지의 수 중 소수와 비소수를 구분하는 함수
    public Map<Boolean, List<Integer>> partitionPrimes(int n) {
        return IntStream.rangeClosed(1, n).boxed()
                .collect(partitioningBy(i -> isPrime(i)));
    }

    //Predicate 로 전달할 함수
    public boolean isPrime(int candidate) {
        int candidateRoot = (int) Math.sqrt((double) candidate);
        return IntStream.rangeClosed(2, candidateRoot)
                .noneMatch(i -> candidate % i == 0);
    }

 

6.5 Collector 인터페이스

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

toList, groupingBy 등 스트림 요소를 리듀싱해서 결과를 도출하는 메서드들을 살펴봤었습니다.

 

 

사실 Collector 인터페이스가 어떻게 생겼고, 이런 리듀싱 연산이 어떻게 동작하는지는 아직 저도 잘 몰랐습니다.

Collector를 사용하는 부분에서는 너무 추상화 수준이 높아져서 논리적으로 생각하기에는 매우 편하나 구체적으로 어떤 방식으로 돌아가는 건지 궁금할 때가 많았습니다.

이번 장을 통해 이 고민이 해결될 것 같고, 저도 최대한 제가 이해한 내용을 전달하도록 노력하겠습니다.

 

내 생각

함수형 프로그래밍의 장점은 어떻게 코드를 구현할지가 아닌 코드가 무엇을 해야하는지에 좀 더 집중하기 때문에 코드를 작성하는 입장에서는 매우 쉽게 코드를 작성할 수 있습니다. 자바 8 이전의 자바는 명령형 프로그래밍 언어로 보는 것이 좀 더 맞는 것 같다고 생각합니다. 자바 8이 등장하면서 동작 파라미터화가 가능해지고, 람다식, 스트림이 도입되면서 함수형 프로그래밍이 자바 내에서 가능해졌습니다...

 

하지만 최하위 추상화 레벨까지 내려가면, 그러니까 실제로 코드가 돌아가는 아주 구체적인 코드 레벨까지 내려가본다면 거기에는 명령형 프로그래밍으로 작성된 자바 코드가 있을거라고 생각합니다. 

나중에는 이런 부분까지 분석을 해보는게 맞겠지만 현재는 자바 8에서 제공한 기능들을 잘 사용할 수 있도록 연습하는 것만으로도 만족해야 할 것 같습니다...

 

본론으로 돌아와서 Collector 인터페이스를 분석해보고 toList라는 구현체를 분석해 보겠습니다.

 

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은 수집 연산 결과 객체의 형식(보통 대개 컬렉션)

그래서 예시로 아래와 같은 Collector 를 만들어 볼 수 있습니다.

public class ToListCollector<T> implements Collector<T,List<T>,List<T>>

Stream<T>의 모든 요소를 List<T>로 수집하는 Collector 클래스 입니다.

 

6.5.1 Collector 인터페이스의 메서드 살펴보기

이제 위에서 살펴본 인터페이스에 정의된 추상 메서드 5개를 자세하게 살펴보겠습니다.

위의 4개 메서드는 collect 메서드에서 실행할 함수를 반환하고, 다섯 번째 메서드는 collect 메서드가 어떤 최적화를

이용해서 리듀싱 연산을 수행할 것인지 결정하도록 돕는 힌트 특성 집합을 제공합니다.

 

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

Supplier 인터페이스

이 메서드는 컬렉터가 결과를 수집하기 위한 빈 누적자 인스턴스를 반환하는 함수입니다. 

그래서 toListCollector에서 supplier는 새로운 ArryaList<T>를 반환할 예정입니다.

@Override
    public Supplier<List<T>> supplier() {
        return () ->  new ArrayList<T>();
    }

메서드 참조도 이용 가능합니다.

@Override
    public Supplier<List<T>> supplier() {
        return ArrayList::new;
    }

 

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

accumulator 메서드는 리듀싱 연산을 수행할 함수를 반환합니다. 

BiConsumer 인터페이스

스트림에서 n번째 요소를 탐색할 때 두개의 인수를 받아 void를 반환합니다.

누적자에 의해 내부 상태가 바뀌어 누적자가 어떤 상태인지 단정할 수 없습니다.

@Override
    public BiConsumer<List<T>, T> accumulator() {
        return (list,item) -> list.add(item);
    }

 

메서드 참조를 이용한 코드입니다.

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

 

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

Function 인터페이스

finisher 메서드는 스트림 탐색을 끝내고 누적자 객체를 최종 결과로 반환하면서 누적 과정을 끝낼 때 호출할 함수를 반환해야 합니다. 

 

이번에 구현하는 toListCollector의 경우는 누적자 객체가 그대로 최종 결과로 도출되어야 하는 상황이므로 항등 함수를 반환합니다.

@Override
    public Function<List<T>, List<T>> finisher() {
        return Function.identity();
    }

 

위의 세가지 연산을 그림을 그려보면서 리듀싱 연산에 대해 더 자세하고 간단하게 알아봅시다.

 

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

마지막으로 리듀싱 연산에서 사용할 함수를 반환하는 네 번째 메서드 combiner를 살펴보겠습니다.

combiner는 스트림의 서로 다른 서브파트를 병렬로 처리할 때 누적자가 이 결과를 어떻게 처리할지 정의합니다.

toList의 combiner는 비교적 쉽게 구현이 가능합니다. 각 서브파트에서 수집한 결과를 모두 더하면 됩니다.

@Override
    public BinaryOperator<List<T>> combiner() {
        return (list1,list2) -> {
            list1.addAll(list2);
            return list1;
        };
    }

이 네 번째 메서드를 이용하면 스트림의 리듀싱을 병렬로 처리할 수 있습니다.

수행 과정은 아래와 같습니다.

  • 스트림을 분할해야 하는지 정의하는 조건이 거짓으로 바뀌기 전까지 원래 스트림을 재귀적으로 분할
  • 모든 서브스트림의 각 요소에 리듀싱 연산을 순차적으로 적용해 서브스트림을 병렬로 처리
  • 컬렉터의 combiner 메서드가 반환하는 함수로 모든 부분결과를 쌍으로 합쳐 분할된 모든 서브스트림의 결과를 반환

 

Characteristics 메서드

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

Characteristics는 스트림을 병렬로 리듀스할 것인지 그렇다고 한다면 어떤 최적화를 선택해야 할지 힌트를 제공하는 역할을 합니다.

 

Characteristics는 다음 3가지 항목을 포함하는 열거형 입니다.

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

 

CONCURRENT : 다중 스레드에서 accumulator 함수를 동시에 호출할 수 있고, 이 컬렉터는 스트림의 병렬 리듀싱 수행 가능, 컬렉터의 플래그에 UNORDERED를 함께 설정하지 않았다면 데이터 소스에 있는 요소의 순서가 무의미한 상황에서만 병렬 리듀싱 가능

 

IDENTITY_FINISH : finisher 메서드가 반환하는 함수는 단순히 identity를 적용할 뿐이므로 생략가능

따라서 리듀싱 과정의 최종 결과로 누적자 객체를 바로 사용 가능, 또한 누적자 A를 R로 안전하게 형변환 가능

 

@Override
    public Set<Characteristics> characteristics() {
        return Collections.unmodifiableSet(EnumSet.of(
                IDENTITY_FINISH,CONCURRENT
        ));
    }

 

6.5.2 응용하기

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,item) -> list.add(item);
//        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
        ));
    }
}

최종 코드이고 최적화 빼고는 얼추 비슷하게 다 완성 되었습니다. 

※자바 API에서 제공하는 컬렉터는 싱클턴 Collections.emptyList() 로 빈 리스트를 반환합니다.

 

이제 만들어진 Collector를 사용해서 메뉴 스트림에서 Dish 리스트를 반환받을 수 있게 되었습니다.

List<Dish> dishes = menu.stream().collect(new toListCollector<Dish>());

 

컬렉터 구현을 만들지 않고도 커스텀 수집 수행하기

앞서 구현한 컬렉터는 finisher가 항등 함수를 반환하는 IDENTITY_FINISH 연산이었습니다.

이럴 경우 Collector 인터페이스를 완전히 새로 만들어 구현하지 않고도 바로 위의 코드와 비슷한 결과를 만들 수 있습니다.

ArrayList<Object> dishes = menu.stream()
                .collect(
                        ArrayList::new,
                        List::add,
                        List::addAll
                );

이렇게 되면 가독성이 떨어지고 재사용성이 줄어든다는 점과 Characteristics를 전달할 수 없다는 단점이 있습니다.

그래서 IDENTITY_FINISH와 CONCURRENT에 해당되고 UNORDERED는 아닌 컬렉터로만 동작할 수 있습니다.

 

6.6 커스텀 컬렉터를 구현해서 성능 개선하기

6.4 절에서 만든 소수 비소수 나누기 커스텀 컬렉터의 성능을 개선해보려고 합니다.

 

소스코

public class PrimeNumberCollector implements Collector<Integer, Map<Boolean, List<Integer>>,Map<Boolean,List<Integer>>> {
    @Override
    //리듀싱 연산 구현
    //supplier 메서드는 누적자를 만드는 함수를 반환
    public Supplier<Map<Boolean, List<Integer>>> supplier() {
        return () -> new HashMap<Boolean,List<Integer>>() {{
        put(true,new ArrayList<>());
        put(false,new ArrayList<>());
        }};
    }

    @Override
    public BiConsumer<Map<Boolean, List<Integer>>, Integer> accumulator() {
        return (Map<Boolean,List<Integer>> acc,Integer candidate) -> {
            acc.get(isPrime(acc.get(true),candidate)).add(candidate);
        };
    }

    @Override
    //병렬 실행할 수 있는 컬렉터 만들기(가능하다면)
    //코드를 보면서 작성하는데 UNORDERED가 아니라 병렬 처리가 가능할까 했는데 역시 무리...
    //그래도 combiner를 구현하는 코드 작성
    public BinaryOperator<Map<Boolean, List<Integer>>> combiner() {
        return (Map<Boolean, List<Integer>> map1,Map<Boolean,List<Integer>> map2) -> {
            map1.get(true).addAll(map2.get(true));
            map1.get(false).addAll(map2.get(false));
            return map1;
        };
    }


	//UNORDERDE, CONCURRENT는 아니지만 IDENTITY_FINISH이므로 아래 두 메서드는 다음과 같이 작성
    @Override
    public Function<Map<Boolean, List<Integer>>, Map<Boolean, List<Integer>>> finisher() {
        return Function.identity();
    }

    @Override
    public Set<Characteristics> characteristics() {
        return Collections.unmodifiableSet(EnumSet.of(IDENTITY_FINISH));
    }
}

 

직접 만든 컬렉터 사용

public Map<Boolean, List<Integer>> partitionPrimes(int n) {
        return IntStream.rangeClosed(2, n).boxed()
                .collect(new PrimeNumberCollector());
    }

 

성능 비교

public class CollectorHarness {

    public static void main(String[] args) {
        System.out.println("Partitioning done in: " + execute(PartitionPrimeNumbers::partitionPrimes) + " msecs");
        System.out.println("Partitioning done in: " + execute(PartitionPrimeNumbers::partitionPrimesWithCustomCollector) + " msecs");
    }

    private static long execute(Consumer<Integer> primePartitioner) {
        long fastest = Long.MAX_VALUE;
        for (int i = 0; i < 10; i++) {
            long start = System.nanoTime();
            primePartitioner.accept(1_000_000);
            long duration = (System.nanoTime() - start) / 1_000_000;
            if (duration < fastest) {
                fastest = duration;
            }
//            System.out.println("done in " + duration);
        }
        return fastest;
    }

}

 

정리

  • collect는 스트림의 요소를 요약 결과로 누적하는 다양한 방법(컬렉터)을 인수로 갖는 최종 연산
  • 스트림의 요소를 하나의 값으로 리듀스하고 요약하는 컬렉터뿐 아니라 최솟값, 최댓값, 평균값을 계산하는 컬렉터 등이 미리 정의되어 있음
  • 미리 정의된 groupingBy로 스트림의 요소를 그룹화하거나, partitioningBy로 스트림의 요소를 분할 가능
  • 컬렉터는 다수준의 그룹화, 분할, 리듀싱 연산에 적합하게 설계
  • Collector 인터페이스에 정의된 메서드를 구현해서 커스텀 컬렉터 개발 가능