JAVA

모던 자바 인 액션 - CH06. 스트림으로 데이터 수집(1)

사랑박 2023. 9. 4. 17:58

들어가기 전에...

이 장에서는 reduce가 그랬던 것처럼 collect 역시 다양한 요소 누적 방식을 인수로 받아서 스트림을 최종 결과로 도출하는 리듀싱 연산을 수행할 수 있음을 설명한다.

Stream에 toList를 사용하는 대신 더 범용적인 컬렉터 파라미터를 collect 메서드에 전달함으로써 원하는 연산을 간결하게 구현할 수 있음을 지금부터 배우게 될 것이다.

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

 

6.1 컬렉터란 무엇인가?

이전 예제엇 collect 메서드로 Collector 인터페이스 구현을 전달했다. Collector 인터페이스의 구현은 스트림의 요소를 어떤 식으로 도출할지 지정한다.

여기서는 groupingBy를 이용해서 `각 키 (통화) 버킷 그리고 각 키 버킷에 대응하는 요소 리스트를 값으로 포함하는 맵(Map)을 만들라`는 동작을 수행한다.

 

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

  • 스트림에 collect를 호출하면 스트림의 요소에 (컬렉터로 파라미터화된) 리듀싱 연산이 수행된다.
  • 명령형 프로그래밍에서는 우리가 직접 구현해야 했던 작업이 자동으로 수행된다.
  • collect에서는 리듀싱 연산을 이용해서 스트림의 각 요소를 방문하면서 컬렉터가 작업을 처리한다.
  • 보통 함수를 요소로 변환(toList처럼 데이터 자체를 변환하는 것보다는 데이터저장 구조를 변활할 때가 많다)할 때는 컬렉터를 적용하며 최종 결과를 저장하는 자료구조에 값을 누적한다.
  • Collector 인터페이스의 메서드를 어떻게 구현하느냐에따라 스트림에 어떤 리듀싱 연산을 수행할지 결정된다.
  • Collectors 유틸리티클래스는 자주 사용하는 컬렉터 인스턴스를 손쉽게 생성할 수 있는 정적 팩토리 메서드를 제공한다.(예를들어 가장 많이 사용하는 직관적인 정적 메서드로 toList를 꼽을 수 있다.
  • toList는 스트림의 모든 요소를리스트로 수집한다.

 

6.1.2 미리 정의된컬렉터

6장에서는 미리 정의된 컬렉터,즉 groupingBy와 같이 Collecors 클래스에서 제공하는 팩토리메서드의 기능을 설명한다.

 

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

  • 스트림 요소를 하나의 값으로 리듀스하고 요약
  • 요소 그룹화 - 다수준으로 그룹화 하거나 각각의 결과 서브그룹에 추가로 리듀싱 연산을 적용할 수 있도록 컬렉터를 조합하는 방법
  • 요소 분할 - 한 개의인수를 받아 불리언을 반환하는 함수, 프레디케이트를 그룹화 함수로 사용

 

6.2 리듀싱과 요약

스트림의 모든 항목을 하나의 결과로 합칠 수 있다. 트리를 구성하는 다수준 맵, 메뉴의 칼로리 합계를 가리키는 단순한 정수 등 다양한 형식으로 결과가 도출될 수 있다.

첫 번째 예제로 counting()이라는 팩토리 메서드가 반환하는 컬렉터로 메뉴에서 요리 수를 계산한다.

Long howManyCars = carList.stream().collect(Collectors.counting());

// 다음처럼 불필요한 과정을 생략할 수 있다.
Long howManyCars = carList.stream().count();

 

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

Collectors.maxBy, Collectors.minBy 두 개의 메서드를 이용해서 스트림의 최댓값과 최솟값을 계산할 수 있다.

두 컬렉터는 스트림의 요소를 비교하는 데 사용할 Comparator를 인수로 받는다.

// Comparator를 구현한다음에 Collectors.maxBy로 전달하는 코드이다.
Comparator<Dish> dishCaloriesComparator = Comparator.comparingInt(Dish::getCalories);

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

스트림에 있는 객체의 숫자 필드의 합계나 평균 등을 반환하는 연산에도 리듀싱 기능이 자주 사용된다. 이러한 연산을 요약 연산이라 부른다.

 

6.2.2 요약 연산

Collectors 클래스는 Collectors.summingInt라는 특별한 요약 팩토리 메서드를 제공한다.

summingInt는 객체를 int로 매핑하는 함수를 인수로 받는다.
summingInt의 인수로 전달된 함수는 객체를 int로 매핑한 컬렉터를 반환한다. 그리고 summingInt가 collect 메서드로 전달되면 요약 작업을 수행한다.

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

 

평균값 계산 등의 연산도 요약 기능으로 제공된다.

Collectors.averagingInt, averagingLong, averagingDouble 등으로 다양한 형식으로 이루어진 숫자 집합의 평균을 계산할 수 있다.

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

 

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

// 메뉴에 있는 요소 수, 요리의 칼로리 합계, 평균, 최댓값, 최솟값 등을 계산하는 코드
IntSummaryStatistics menuStatistics = 
	menu.stream().collect(summarizingInt(Dish::getCalories));
    
// munuStatistics 출력 결과
IntSummaryStatistics{count=9, sum=4300, min=120, average=477.777778, max=800}

 

6.2.3 문자열 연결

컬렉터에 joinig 팩토리 메서드를 이용하면 스트림의 각 객체에 toString 메서드를 호출해서 추출한 모든 문자열을 하나의 문자열로 연결해서 반환한다. joining메서드는 내부적으로 StringBuilder를 이용해서 문자열을 하나로 만든다.

String shortMenu = menu.stream().map(Dish::getName).collect(joining());
// Dish 클래스가 요리명을 반환하는 toString 메서드를 포함하고 있다면 map으로 각 요리의 이름을 추출하는 과정을 생략 가능
String shortMenu = menu.stream().collect(joining());
// 결과
porkbeefchickenfrench friesriceseason fruitpizzaprawnssalmon

// 콤마로 구분 가능
String shortMenu = menu.stream().map(Dish::getName).collect(joining(", "));
// 결과
pork, beef, chicken, french, fries, rice, season, fruit, pizza, prawns, salmon

 

6.2.4 범용 리듀싱 요약 연산

지금까지 살펴본 모든 컬렉터는 reducint 팩토리 메서드로도 정의할 수 있다. 즉, 범용 Collectors.reducing으로도 구현할 수 있다.

// 인수 3개
int totalCalories = menu.stream().collect(reducing(0, Dish::getCalories, (i, j) -> i + j));
// 인수 1개
Optional<Dish> mostCalorieDish = 
	menu.stream().collect(reducing(
    		(d1, d2) -> d1.getCalories() > d2.getCalories() ? d1 : d2));

 

 

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

reducing 컬렉터를 사용한 이전 예제에서 람다 표현식 대신 Integer 클래스의 sum 메서드 참조를 이용하면 코드를 좀 더 단순화할 수 있다.

// 같은 결과
int totalCalories = menu.stream().collect(reducing(0, Dish::getCalories, Integer::sum));
int totalCalories = menu.stream().map(Dish::getCalories).reduce(Integer::sum).get
int totalCalories = menu.stream().mapToInt(Dish::getCalories).sum();

 

++ 제네릭 와일드카드 '?' 사용법

?는 컬렉터의 누적자 형식이 알려지지 않았음을, 즉 누적자의 형식이 자유로움을 의미한다.

 

 

6.3 그룹화

팩토리 메서드 Collectors.groupingBy를 이용해서 쉽게 메뉴를 그룹화할 수 있다.

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

 

단순한 속성 접근자 대신 더 복잡한 분류 기준이 필요한 상황에서는 메서드 참조를 분류 함수로 사용할 수 없다.

따라서 메서드 참조 대신 람다 표현식으로 필요한 로직을 구현할 수 있다.

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

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

FISH 종류 요리는 없으므로 결과 맵에서 해당 키 자체가 사라진다. Collectors 클래스는 일반적인 분류 함수에 Collector 형식의 두 번째 인수를 갖도록 groupingBy 팩토리 메서드를 오버로드해 이 문제를 해결한다.

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

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

 

그룹화된 항목을 조작하는 다른 유용한 기능 중 또 다른 하나로 맵핑 함수를 이용해 요소를 변환하는 작업이 있다.

Collectors 클래스는 매핑 함수와 각 항목에 적용한 함수를 모으는 데 사용하는 또 다른 컬렉터를 인수로 받는 mapping 메서드를 제공한다.

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

 

각 그룹이 리스트 형태라면 flatMap 변환을 사용해서 추출할 수도 있다.

Map<String, List<String>> dishTags = new HashMap<>();
dishTag.push("pork", asList("greasy", "salty"));
dishTag.push("beef", asList("salty", "roasted"));
dishTag.push("chicken", asList("fried", "crisp"));
dishTag.push("rice", asList("light", "natural"));

Map<Dish.Type, Set<String>> dishNamesByType = menu.stream()
  .collect(groupingBy(Dish::getType,
    flatMapping(dish -> dishTags.get(dish.getName()).stream(),
    toSet())));
    
// 결과 : {MEAT=[salty, greasy, roasted, fried, crisp], FISH=[roasted, tasty, fresh, delicious], OTHER=[salty, greasy, natural, light, tasty, fresh, fried]}

 

6.3.2 다수준 그룹화

바깥쪽 groupingBy 메서드에 스트림의 항목을 분류할 두 번째 기준을 정의하는 내부 groupingBy를 전달해서 두 수준으로 스트림의 항목을 그룹화할 수 있다.

Map<Dish.Type, Map<CalricLevel, 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=[pork]}, FISH={DIET=[prawns], NORMAL=[salmon]}, OTHER={...}}

 

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

groupingBy 메서드의 두번째 인수로 전달받는 컬렉터의 형식은 제한이 없다.

분류 함수 한 개의 인수를 갖는 groupingBy(f)는 사실 groupingBy(f, toList())의 축양형이다.

// 메뉴에서 요리의 수를 종류별로 계산
Map<Dish.Type, Long> typesCount = menu.stream().collect(groupingBy(Dish::getType, counting()));
// 결과 : {MEAT=3, FISH=2, OTHER=4}

// 메뉴에서 가장 높은 칼로레를 가진 요리를 찾는 프로그램
Map<Dish.Type, Optional<Dish>> mostCaloricByType = menu.stream()
  .collect(groupingBy(Dish::getType, maxBy(CompaingInt(Dish::getCalories))));
// 결과 : {FISH=Optional[salmon], OTHER=Optional[pizza], MEAT=Optional[pork]}

 

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

마지막 그룹화 연산에서 맵의 모든 값을 Optional로 감쌀 필요가 없으므로 Optional을 삭제할 수 있다.

다음처럼 팩토리 메서드 Collectors.collectingAndThen으로 컬렉터가 반환한 결과를 다른 형식으로 활용할 수 있다.

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