본문 바로가기
JAVA

[JAVA] 스트림으로 데이터 수집 (컬렉터)

by 케로베로 2020. 8. 24.

컬렉터란 무엇인가?

 Collector 인터페이스 구현은 스트림의 요소를 어떤 식으로 도출할지 지정한다. collect로 결과를 수집하는 과정을 간단하면서도 유연한 방식으로 정의할 수 있다는 점이 컬렉터의 최대 강점이다. 스트림에 collect를 호출하면 스트림의 요소에 리듀싱 연산이 수행된다. 보통 함수를 요소로 변환 할 때는 컬렉터를 적용하며 최종 결과를 저장하는 자료구조에 값을 누적한다.

 

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

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

- 요소 그룹화

- 요소 분할

 

리듀싱과 요약

 컬렉터로 스트림의 모든 항목을 하나의 결과로 합칠 수 있다. counting 컬렉터는 다른 스트림의 요소 개수를 반환하며 minBy, maxBy 메서드는 각각 최대값과 최솟값을 계산할 수 있다. 두 컬렉터는 스트림의 요소를 비교하는 데 사용할 Comparator를 인수로 받는다.

 /** counting 팩토리 메서드를 이용해서 개수를 계산 **/
long howManyDishes = menu.stream().collect(Collectors.counting());
long howManyDishes2 = menu.stream().count();    //  count 로 간단하게 표현이 가능하다.

/** maxBy를 이용해서 최댓값 검색 **/
Comparator<Dish> dishCaloriesComparator = Comparator.comparingInt(Dish::getCalories);
Optional<Dish> mostCalorieDish =
        menu.stream()
        .collect(maxBy(dishCaloriesComparator));

 

또한 스트림에 있는 객체의 숫자 필드의 합계나 평균 등을 반환하는 연산에도 리듀싱 기능이 자주 사용되며 이러한 연산을 요약 연산이라 부른다. summingInt라는 요약 팩토리 메서드는 객체를 int로 매핑하는 함수를 인수로 받는다. 인수로 전달된 함수는 객체를 int로 매핑한 컬렉터를 반환 한 후 collect 메서드로 전달되면 요약 작업을 수행한다. 이 외에도 summingDouble averaingInt, evaragingLong, everagingDouble 등의 평균값 계산 등의 연산도 요약 기능으로 제공된다.

 

또한 IntSummaryStatistics 클래스 summarizingInt 연산을 이용해서 하나의 요약연산으로 모든 정보들을 수집할 수 도 있다. int 뿐만 아니라 long double에서도 각각 SymmaryStatistics클래스와 summarizing 연산을 사용할 수 있다.

/** summing, averaging 을 이용해서 스트림에서 추출된 값의 합계, 평균 구하기 **/
int totalCalories = menu.stream().collect(summingInt(Dish::getCalories));
double avgCalories = menu.stream().collect(averagingDouble(Dish::getCalories));

/** summarizing 메서드를 이용해서 요약 연산 한번에 구하기 **/
IntSummaryStatistics menuStatistics =
        menu.stream().collect(summarizingInt(Dish::getCalories));
System.out.println(menuStatistics);

 

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

 /** joining 팩토리 메서드를 이용해서 문자열 합치기 **/
String shortMenu = menu.stream().map(Dish::getName).collect(joining(", "));
System.out.println(shortMenu);

 

 지금까지 위의 모든 컬렉터는 reducing 팩토리 메서드로도 정의할 수 있다. 즉 범용 Collectors.reducing으로도 구현할 수 있다. reducing은 세개의 인수를 받는다.

- 리듀싱 연산의 시작값이거나 스트림에 인수가 없을 때 반환값

- 형 변환에 사용되는 변환 함수

- 같은 종류의 두 항목을 하나의 값으로 더하는 BinaryOperator

한 개의 인수를 갖는 reducing 메서드는 스트림의 첫 번째 요소를 시작 요소로 정하고 자신을 그대로 반환하는 항등함수를 두 번째 인수로 받는 상황이다. 따라서 시작값이 없으므로 Optinal 객체를 반환한다.

/** reducing 팩토리 메서드를 이용해서 범용 리듀싱 요약 연산 **/
int totalCalories2 = menu.stream()
        .collect(reducing(0, Dish::getCalories, (i, j) -> i + j));
int totalCalories3 = menu.stream()
        .collect(reducing(0, Dish::getCalories, Integer::sum)); //  sum 메소드 참조를 이용해서 단순화
        
/** 한 개의 인수를 갖는 reducing 함수 사용 **/
Optional<Dish> mostCalorieDish2 = menu.stream()
        .collect(reducing((d1, d2) -> d1.getCalories() > d2.getCalories() ? d1 : d2));

 

 함수형 프로그래밍에서는 하나의 연산을 다양한 방법으로 해결 할 수 있다. 스트림 인터페이스에서 직접 제공하는 메서드를 이용하는 것에 비해 컬렉터를 이용하는 코드가 더욱 복잡하지만 대신 재사용성과 커스티마이즈 가능성을 제공하는 높은 수준의 추상화와 일반화를 얻을 수 있다.

 

그룹화

 자바 8의 함수형을 이용하면 가독성 있는 한 줄의 코드로 그룹화를 구현할 수 있다. 팩토리 메서드 Collectors.groupingBy를 이용하면 쉽게 메뉴를 그룹화할 수 있다. 스트림의 각 요소에서 일치하는 모든 요소를 추출하는 함수를 groupingBy 메서드로 전달하면 이 함수를 기준으로 스트림이 그룹화되므로 이를 분류함수라고 부른다. 그리고 각 키에 대응하는 스트림의 모든 항목 리스트를 값으로 갖는 맵이 반환된다.

/** grupingBy 팩토리 메서드를 이용해서 이름별로 그룹화 **/
Map<Dish.Type, List<Dish>> dishByType =
        menu.stream().collect(groupingBy(Dish::getType));

/** 메서드 참조 대신 람다 표현식을 이용하여 분류 기준이 복잡한 상황에서의 그룹화 **/
Map<Dish.CaloricLevel, List<Dish>> dishesByCaloricLevel = menu.stream().collect(
        groupingBy(dish -> {
            if (dish.getCalories() <= 400) return Dish.CaloricLevel.DIET;
            else if (dish.getCalories() <= 700) return Dish.CaloricLevel.NORMAL;
            else return Dish.CaloricLevel.FAT;
        }));

 

 요소를 그룹화 한 다음에는 각 결과 그룹의 요소를 조작하는 연산이 필요하다. 특정 조건을 만족하는 요소들만 그룹화 할 때에 그룹화 하기 전 필터를 적용하면 프레디케이트를 만족하는 요소가 없는 키는 사라지게 된다. 따라서 groupingBy 팩토리 메서드에 Collector 형식의 두 번째 인수를 갖도록 오버로드해 이 문제를 해결할 수 있다. filtering 메소드를 이용하면 프레디케이트를 인수로 받는다. 이 프레디케이트로 각 그룹의 요소와 필터링 된 요소를 재그룹화 하여 문제를 해결 할 수 있다. 또한  groupingBy 내에서 mappingflatMapping 함수를 이용하여 요소를 변환할 수도 있다.

/** filtering 함수를 이용해서 필터를 groupingBy 인자로 받기 **/
Map<Dish.Type, List<Dish>> caloricDishesByType =
        menu.stream().collect(groupingBy(Dish::getType,
                filtering(dish -> dish.getCalories() > 500, toList())));

/** mapping 함수를 이용해서 groupingBy 안에서 매핑하기 **/
Map<Dish.Type, List<String>> dishNamesByType =
        menu.stream().collect(groupingBy(Dish::getType,
                mapping(Dish::getName, toList())));

/** flatMapping 함수를 이용해서 groupingBy 안에서 평면화해서 중복태그 제거하기 **/
Map<Dish.Type, Set<String>> dishNamesByType2 =
        menu.stream()
                .collect(groupingBy(Dish::getType,
                        flatMapping(dish -> dishTags.get(dish.getName()).stream(), toSet())));

 

 groupingBy 메서드의 두번째 인자 컬렉터로 두 번째 기준을 정의하는 내부 groupingBy를 전달해서 두 수준으로 스트림의 항목을 그룹화하는 다수준 그룹화도 할 수 있다. 다수준 그룹화는 다양한 수준으로 확장할 수 있다. 즉 n수준 그룹화의 결과는 n수준 트리 구조로 표현되는 n수준 맵이 된다.

/** groupingBy 함수 내에 컬렉터를 groupingBy로 받아서 2수준 그룹화 구현하기 **/
        Map<Dish.Type, Map<Dish.CaloricLevel, List<Dish>>> dishesByTypeCaloricLevel =
                menu.stream().collect(
                        groupingBy(Dish::getType,
                                groupingBy(dish -> {
                                    if (dish.getCalories() <= 400)
                                        return Dish.CaloricLevel.DIET;
                                    else if (dish.getCalories() <= 700)
                                        return Dish.CaloricLevel.NORMAL;
                                    else
                                        return Dish.CaloricLevel.FAT;
                                })
                        )
                );

 

 다수준 그룹화 연산에서 첫 번째 groupingBy로 넘겨주는 컬렉터의 형식은 제한이 없다. 분류 함수 한 개의 인수를 갖는 groupingBy(f)는 사실 groupingBy(f, toList())의 축약형이다. 요소가 없는 key는 맵에 추가되지 않으므로 (게으르기 때문) 따라서 maxBy 등의 컬렉터를 사용할 때에 굳이 값을 Optinal로 받지 않고 Collectors.collectingAndThen 팩토리 메서드를 이용해 다른 형식으로 활용할 수 있다. collectingAndThen 메서드는 적용할 컬렉터와 변환 함수를 인수로 받아 다른 컬렉터를 반환한다. 반환되는 컬렉터는 기존 컬렉터의 래퍼 역할을 하며 collect의 마지막 과정에서 변환 함 자신이 반환하는 값을 매핑한다.

/** groupingBy에 컬렉터로 counting 을 전달해서 음식의 종류별로 요리 수를 계산하기 **/
Map<Dish.Type, Long> typesCount = menu.stream().collect(groupingBy(Dish::getType, counting()));

/** groupingBy에 컬렉터로 maxBy 를 전달해서 음식의 종료별 가장 높은 칼로리의 음식을 찾기 **/
Map<Dish.Type, Optional<Dish>> mostCaloricByType =  //  요리가 없는 Key는 맵에 추가되지 않으므로 굳이 Optional 래퍼를 사용 할 필요는 없음
        menu.stream()
                .collect(groupingBy(Dish::getType,
                        maxBy(comparingInt(Dish::getCalories))));

/** collctingAndThen 팩토리 메서드로 변환 함수를 감싸서 Optional 값을 String으로 변환 **/
Map<Dish.Type, Dish> mostCaloricByType2 =
        menu.stream()
                .collect(groupingBy(Dish::getType,
                        collectingAndThen(
                                maxBy(comparingInt(Dish::getCalories)),
                                Optional::get
                        )));

 

분할

분할은 분할 함수라 불리는 프레디케이트를 분류 함수로 사용하는 특수한 그룹화 기능이다. 분할 함수는 불리언을 반환하므로 맵의 키 형식은 Boolean이다. 결과적으로 그룹화 맵은 최대 두 개의 그룹으로 분류된다. 분할 함수는 partitioningBy 메서드로 정의 되어있다. 또한 컬렉터를 두 번째 인수로 전달할 수 있는 오버로드 된 버전의 partitioningBy 메서드도 있다. 다수준으로 결과를 얻을 수 있으며 다수준 분할 또한 가능하다.

/** partitioningBy 메서드를 이용해서 분할하기 **/
Map<Boolean, List<Dish>> partitionedMenu =
        menu.stream().collect(partitioningBy(Dish::isVegetarian));
List<Dish> vegetatianDishes = partitionedMenu.get(true);

/** partitioningBy 메서드에 두 번째 인수로 컬렉터를 전달해서 분할하기 **/
Map<Boolean, Map<Dish.Type, List<Dish>>> vegetarianDishesByType = menu.stream().collect(
        partitioningBy(Dish::isVegetarian,
                groupingBy(Dish::getType)));

/** partitioning 메서드에 두 번째 인수로 CollectingAndThen 으로 감싼 maxBy 전달해서 분할하기 **/
Map<Boolean, Dish> mostCaloricPartitioninedByVegetarian =
        menu.stream().collect(
                partitioningBy(Dish::isVegetarian,
                        collectingAndThen(maxBy(comparing(Dish::getCalories)),
                                Optional::get)));

'JAVA' 카테고리의 다른 글

[JAVA] 스트림(stream)이란?  (0) 2020.08.05