1. Question

Collectors => [Java] Collectors 클래스란?

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

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

이 중에서 두 번째 기능에 대해서 알아보자.

2. Answer

A. 메서드 참조로 그룹화

Java 8의 함수형을 이용하면 가독성 있는 한 줄의 코드로 그룹화를 구현할 수 있다. 메뉴를 그룹화한다고 가정해본다. 예를 들어 고기를 포함하는 그룹, 생선을 포함하는 그룹, 나머지 그룹으로 메뉴를 그룹화할 수 있다. 다음처럼 팩토리 메서드 Collectors.groupingBy를 이용해서 쉽게 메뉴를 그룹화할 수 있다.

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

스트림의 각 요리에서 Dish.Type과 일치하는 모든 요리를 추출하는 함수를 groupingBy 메서드로 전달했다. 이 함수를 기준으로 스트림이 그룹화되므로 이를 분류 함수(classification function)라고 부른다.

그룹화 연산의 결과로 그룹화 함수가 반환하는 키 그리고 각 키에 대응하는 스트림의 모든 항목 리스트를 값으로 갖는 맵이 반환된다. 메뉴 그룹화 예제에서 키는 요리 종류고, 값은 해당 종류에 포함되는 모든 요리다.

B. 람다 표현식으로 그룹화

단순한 속성 접근자 대신 더 복잡한 분류 기준이 필요한 상황에서는 메서드 참조를 분류 함수로 사용할 수 없단. 예를 들어 400칼로리 이하를 ‘diet’로, 400~700칼로리를 ‘normal’로, 700칼로리 초과를 ‘fat’ 요리로 분류한다고 가정하자. Dish 클래스에는 이러한 연산에 필요한 메서드가 없으므로 메서드 참조를 분류 함수로 사용할 수 없다. 따라서 다음 예제에서 보여주는 것처럼 메서드 참조 대신 람다 표현식으로 필요한 로직을 구현할 수 있다.

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

3. Detail

A. 그룹화된 요소 조작 - Collector 타입의 추가 인수

groupingBy 메서드는 스트림의 요소들을 특정 기준에 따라 그룹화하는 기능을 제공하며, Collector 타입의 추가 인수를 받아 더 복잡한 집계 연산을 가능하게 한다. 그 중 하나가 filtering 메서드를 통한 조건부 필터링이다.

다음은 Collectors.groupingByCollectors.filtering 메서드를 사용한 코드이다. 이 코드는 메뉴 아이템을 타입별로 그룹화하고, 각 타입별로 500칼로리가 넘는 요리만을 필터링하여 목록화한다.

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

filtering 메서드는 Collectors 클래스의 또 다른 정적 팩토리 메서드로, 각 그룹 내에서 주어진 프레디케이트에 따라 요소를 필터링한다. 이 메서드는 특히 그룹화된 결과에서 빈 리스트를 포함하는 것이 중요한 경우 유용하다. 예를 들어, 위 코드에서 ‘FISH’ 타입의 요리 중 500칼로리가 넘는 요리가 없더라도, 결과 맵에는 다음과 같이 ‘FISH’ 키와 빈 리스트가 포함된다.

B. 그룹화된 요소 조작 - 맵핑 함수 타입의 추가 인수

mapping 메서드는 그룹화된 각 요소에 함수를 적용한 후, 그 결과를 수집하는 데 사용된다. 예를 들어, 각 요리 타입별로 요리 이름의 리스트를 수집하고 싶다면 다음과 같이 작성할 수 있다.

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

결과적으로, 각 요리 타입별로 요리 이름의 리스트가 맵으로 수집된다.

flatMapping 메서드는 각 요소에서 스트림을 생성하고, 생성된 모든 스트림을 하나의 스트림으로 평면화한 후, 평면화된 스트림의 요소를 수집하는 데 사용된다. 이 메서드는 중첩된 컬렉션 구조를 평면화하고 결과를 수집할 때 유용하다.

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

결과적으로, 각 요리 타입별로 요리 태그의 집합이 맵으로 수집된다. 이때 flatMapping은 중첩된 리스트 구조를 단일 리스트로 평면화하고, toSet은 중복 태그를 제거한다.

C. 다수준 그룹화

Collectors.groupingBy는 일반적인 분류 함수와 컬렉터를 인수로 받는다. 즉, 바깥쪽 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;
      })
    )
  );

D. 서브그룹으로 데이터 수집

groupingBy 컬렉터에 두 번째 인수로 counting 컬렉터를 전달해서 메뉴에서 요리의 수를 종류별로 계산할 수 있다.

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

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

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

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

D에서 맵의 모든 값을 Optional로 감쌀 필요가 없으므로 Optional을 삭제할 수 있다. 즉, 다음처럼 팩토리 메서드 Collectors.collectingAndThen으로 컬렉터가 반환한 결과를 다른 형식으로 활용할 수 있다.

Map<Dish.Type, Dish> mostCaloricByType =
  menu.stream()
    .collect(groupingBy(Dish::getType,  // 분류 함수
      collectingAndThen(
        maxBy(comparingInt(Dish::getCalories)), // 감싸인 컬렉터
        Optional::get))); // 변환 함수

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

일반적으로 스트림에서 같은 그룹으로 분류된 모든 요소에 리듀싱 작업을 수행할 때는 팩토리 메서드 groupingBy에 두 번째 인수로 전달한 컬렉터를 사용한다. 예를 들어 메뉴에 있는 모든 요리의 칼로리 합계를 구하려고 만든 컬렉터를 재사용할 수 있다. 물론 여기서는 각 그룹으로 분류된 요리에 이 컬렉터를 활용한다.

Map<Dish.Type, Integer> totalCaloriesByType =
  menu.stream()
    .collect(groupingBy(Dish::getType,
      summingInt(Dish::getCalories)));

mapping 메서드로 만들어진 컬렉터도 groupingBy와 자주 사용된다. mapping 메서드는 ‘스트림의 인수를 변환하는 함수’와 ‘변환 함수의 결과 객체를 누적하는 컬렉터’를 인수로 받는다. mapping은 입력 요소를 누적하기 전에 매핑 함수를 적용해서 다양한 형식의 객체를 주어진 형식의 컬렉터에 맞게 변환하는 역할을 한다. 예를 들어 각 요리 형식에 존재하는 모든 CaloricLevel 값을 알고 싶다고 가정하자. 다음 코드처럼 groupingBymapping 컬렉터를 합쳐서 이 기능을 구현할 수 있다.

Map<Dish.Type, Set<CaloricLevel>> caloricLevelsByType =
  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() )));

4. Reference

  • “모던 자바 인 액션” (저자: 라울-게이브리얼 우르마, 마리오 푸스코, 앨런 마이크로프트)

태그:

카테고리:

업데이트:

댓글남기기