모던자바인액션 - 6

이건희·2023년 7월 18일
1

모던자바인액션

목록 보기
6/9

이전 챕터에서까지 최종 연산 collect에서 toList로 스트림 요소를 항상 리스트로만 변환했다. 이번 장에서는 collect가 다양한 요소 누적 방식을 인수로 받아, 스트림을 최종결과로 도출하는 리듀싱 연산을 수행할 수 있음을 설명한다.

다양한 요소 누적 방식은 Collector 인터페이스에 정의되어 있다. Collection, Collector, collect를 헷갈리지 않도록 주의하자.


컬렉터란?

collect 메서드

  • 스트림에 collect를 호출하면 스트림의 요소에 리듀싱 연산이 수행된다.

  • collect 메서드(인자)로 Collector 인터페이스의 구현을 전달해야 한다.

  • Collector 인터페이스 구현은 스트림의 요소를 어떤 식으로 도출할지 지정한다. 즉, 어떤 리듀싱 연산을 수행할지 지정한다.
    * 예를 들어 toList() 스트림의 모든 요소를 리스트로 수집하는 Collector 인터페이스의 구현이다.

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

Collectors 클래스

  • 자주 사용하는 컬렉터 인스턴스를 손쉽게 생성할 수 있는 정적 펙토리 메서드 제공

  • 따라서 Collectors의 메서드를 collect에 전달함으로써, Collector 인터페이스 구현 - 헷갈리지 말자


Collectors의 미리 정의된 컬렉터

미리 정의된 컬렉터들의 역할은 크게 3가지로 나뉜다.

  1. 스트림 요소를 하나의 값으로 reduce하고 요약

  2. 요소 그룹화

  3. 요소 분할(boolean)

차근차근 어떤 것들이 있는지 하나씩 알아보자.


1. 리듀싱과 요약

앞서 말했듯, 컬렉터(Stream.collect 메서드의 인수)로 스트림의 항목을 컬렉션으로 재구성 할 수 있다. 즉, 컬렉터로 스트림의 모든 항목을 하나의 결과로 합칠 수 있다.

counting

Collectors의 메서드인 counting은 스트림의 요소 개수를 반환한다.

long howManyDishes = menu.stream().collect(Collectors.counting());
  • collect의 인자로 Collector 인터페이스의 구현인 Collectors.counting을 넘겨주었다.

  • 메뉴의 개수를 세는 리듀스를 구현하였다.

이후 예제에서는 Collectors를 static import 했다고 가정하고 Collectors를 생략한다.

maxBy, minBy

  • 최대값과 최소값을 계산하는 메서드

  • 스트림의 요소를 비교하는데 사용할 Comparator를 인수로 받음

Comparator<Dish> dishCaloriesComparator = 
	Comparator.comparingInt(Dish::getCalories);
    //Comparator 우선적 구현
    
Optional<Dish> mostCaloriDish = menu.stream().collect(maxBy(dishCaloriesComparator));

summingInt

  • 합계를 계산한다.

  • 요약 펙토리 메서드(합계, 평균 등 반환)이다.

  • summingInt의 인수로 반환된 함수는 객체를 int로 매핑하는 함수를 인수로 받고 객체를 int로 매핑한 컬렉터를 반환한다.

  • summingInt가 collect 메서드로 전달되면 요약 작업을 수행한다.

  • summingInt이외에도 summingLong과 summingDouble 같은 메서드도 존재하고 같은 방식으로 동작한다.

int totalCalories = menu.stream().collect(summingInt(Dish::getCalories));
//getCalories는 객체를 int로 mapping

summingInt 이외에도 averagingInt, averagingLong, averagingDouble 등 다양한 요약 기능들이 있다.

summarizingInt

이전에는 스트림의 요소 수를 계산하고, 최대값과 최소값을 찾고, 합계와 평균 등을 계산하였다. 만약 이들 중 두개의 연산을 한번에 수행하려면 어떻게 해야 할까?

summarizingInt은 하나의 요약으로 다음 결과를 반환한다.

  • 요소 수
  • 합계
  • 평균
  • 최대값
  • 최소값 등
IntSummaryStatistics menuStatistics = 
	menu.stream().collect(summarizingInt(Dish::getCalories));
//실행 결과 :
//IntSummaryStatistics{count = 9, sum = 4300, min = 120, average = 477, max = 800}

마찬가지로 summarizingLong, summarizingDouble도 존재한다.

joining

  • 스트림 각 객체에 toString 메서드를 호출해 추출한 모든 문자열을 하나의 문자열로 연결

  • 내부적으로 StringBuilder를 이용해 문자열을 하나로 만듦.

String shortMenu = menu.stream().map(Dish::getName).collect(joining());
//만약 Dish 클래스가 toString 메서드를 포함하고 있다면 map 생략 가능

String shortMenu = menu.stream.collect(joining());

//결과 : porkbeefchickenfrench friesriceseason fruitpizzaprawnssalmon

모든 문자열이 붙어서 나오기 떄문에 해석이 어렵다. 이를 위해 연결된 두 요소 사이에 구분 문자열을 넣을 수 있도록 오버로드 된 joining 펙토리 메서드가 있다.

String shortMenu = menu.stream().map(Dish::getName).collect(joining(", "));
//결과: pork, beef, chicken, french fries, etc...

reducing(reduce 메서드 아님)

지금까지 살펴 본 모든 컬렉터는 reducing 펙토리 메서드로 정의할 수 있다. 그럼에도 이전 예제들에서 범용 펙토리 메서드(reducing) 대신 특화된 컬렉터(앞 예시들)을 사용한 이유는 편의적 때문이다.

reduce와 다른 메서드이다 ! reducing은 Collector 객체를 생성하는 메서드이다.

다음 코드처럼 reducing 메서드로 만들어진 컬렉터로도 메뉴의 모든 칼로리 합계를 구할 수 있다.

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

reducing은 세 개의 인수를 받는다.

  1. 리듀싱 연산의 시작값, 스트림에 인수가 없을땐 반환값

  2. 변환 함수(위 예시에서 요리를 칼로리로 변환)

  3. 같은 종류의 두 항목을 더하는 BinaryOperator

collect와 reduce

collect와 reduce는 비슷한 일을 하는 것 같은데 차이점이 무엇일까?

collect 메서드는 도출하려는 결과를 누적하는 컨테이너를 바꾸도록 설계된 메서드이다.

반면 reduce는 두 값을 하나로 도출하는 불변형 연산이다.


2. 요소 그룹화

데이터 집합을 하나 이상의 특성으로 그룹화 하는 연산들이다. Java 8의 함수형을 이용하면 가독성 있는 한 줄의 코드로 그룹화를 구현할 수 있다.

groupingBy

한가지 기준으로 그룹화

다음은 메뉴의 타입에 따라 고기를 포함하는 그룹, 생선을 포함하는 그룹, 나머지 그룹으로 나눈 예시이다.

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

해당 코드의 결과는 다음과 같다.

{FISH = [prawns, salmon], OTHER = [french fries, rice, season fruit, pizza], MEAT = [pork, beef, chicken]}
  • 각 요리에서 Dish.Type과 일치하는 모든 요리를 추출하는 함수를 groupingBy로 전달

  • 이 함수를 기준으로 스트림이 그룹화 되므로 이를 분류 함수라고 한다.

  • 그룹화 함수(Dish.Type)가 반환하는 키각 키에 대항하는 모든 항목 리스트를 값으로 갖는 맵이 반환

  • 따라서 키는 Dish.Type, 각 키에 해당하는 모든 항목 리스트가 값들이다.

그룹화 된 요소 조작

만약 요소를 그룹화하고 그룹화 된 요소들에 또 다른 기준을 적용하려면 어떻게 해야 할까?? 그룹화 후 기준을 적용 하려면 요소를 그룹화 한 다음, 각 결과 그룹의 요소를 조작하는 연산이 필요하다.

filtering

첫번째로, 오버로드 된 groupingBy 메서드를 사용할 수 있다.

아래 코드처럼 두번째 Collector 안으로 필터 프리디케이트를 이동함으로 이 문제를 해결할 수 있다.

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

filtering 메서드는 Collectors 클래스의 또 다른 정적 페토리 메서드로 프리디케이드를 인수로 받는다. 이 프리디케이트는 각 그룹의 요소와 필터링 된 요소를 재 그룹화한다.

따라서 위 예시에는 Dish::getType을 기준으로 그룹을 나누고, 나뉜 그룹에 filtering 메서드를 적용한다.

mapping

또한 맵핑 함수를 통해 그룹화된 항목을 조작할 수도 있다.

예를 들어, 이 함수를 이용해 그룹의 각 요리를 관련 이름 목록으로 변환할 수 있다.

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

위 예시도 Dish::getType을 기준으로 그룹을 나누고, 각 그룹에 mapping 함수를 적용한다. 적용 결과는 getName을 적용 했으므로 문자열 리스트이다.

++ 또한 이전 챕터들에서 살펴 보았듯이, 두 수준을 한 수준으로 평면화 시켜주는 메서드인 flatMapping도 지원한다.

다수준 그룹화

위 예시들에선 칼로리 같은 한 가지 기준으로 메뉴의 요리를 그룹화 하였는데 두 가지 이상의 기준을 동시에 적용할 수 있을까?

두 인수를 받는 Collectors.groupingBy를 이용해 다수준으로 그룹화 할 수 있다.
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;
            })
      )
);

그룹화의 결과로 다음과 같은 두 수준의 맵이 만들어진다.

{MEAT = {DIET = [chicken], NORMAL = [beef], FAT =[pork]}, 
FISH = {DIET = [prawns], NORMAL = [salmon]}, 
OTHER = {DIET = [rice, seasonal fruit]}}
  • 외부 맵은 첫 번째 수준의 분류 함수에서 분류한 키 값 'fish, meat, other'을 갖는다
  • 외부 맵의 값은 두 번째 수준의 분류 함수의 기준 'normal, diet, fat'을 키값으로 갖는다.
  • 첫번째로 외부 수준에서 그룹화 후, 두번째 수준을 적용한다.

서브그룹으로 데이터 수집

위에서 두 번째 groupingBy 컬렉터를 외부 컬렉터(외부 groupingBy)로 전달해 다수준 그룹화 연산을 구현하였다. 사실 첫 번째 groupingBy로 넘겨주는 컬렉터 형식의 제한이 없다.

따라서 groupingBy 대신 다른 컬렉터 인터페이스를 구현한 메서드를 넘겨줘도 상관 없다 !

아래 코드처럼 Collectors.counting 컬렉터를 전달할 수도 있다.

Map<Dish.Type, Long> typesCount = menu.stream.collect(groupingBy(Dish::getType, counting()));
//groupingBy(getType으로) 분류 후, counting 적용

결과는 다음과 같다.

{MEAT = 3, FISH = 2, OTHER = 2}

한개의 인수를 갖는 groupingBy(f)는 사실 groupingBy(f, toList())의 축약형이다. 그래서 처음 예시에서 리스트로 반환 된 것이다.

이외에도 maxBy등 다양한 컬렉터를 넘길 수 있다.

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

우선 해당 코드를 보자.

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

그룹화의 결과로 요리의 종류를 키로, Optional를 값으로 갖는 맵이 반환된다.

{FISH = Optional[salmon], OTHER = Optional[pizza], MEAT = Optional[pork]}

해당 코드는 maxBy를 적용하기 때문에 Optional로 반환된다. 만약 컬렉터가 반환한 결과를 다른 형식으로 활용하려면 어떻게해야 할까?

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

Map<Dish.Type, Optional<Dish>> mostCaloricByType = 
	menu.stream().collect(groupingBy(Dish::getType,
  											collectingAndThen(maxBy(comparingInt(Dish::getCalories)),
  													   		Optional::get)));
//결과
{Fish = salmon, OTHER = pizza, MEAT = pork}
  • collectingAndThen은 적용할 컬렉터와 변환 함수를 인수로 받아 다른 컬렉터를 반환한다.
  • 반환되는 컬렉터는 기존 컬렉터의 래퍼(Wrapper) 역할을 한다.
  • collect의 마지막 과정에서 변환 함수로 자신이 반환하는 값을 매핑한다.
  • 위 예제에선 maxBy로 만들어진 컬렉터를 감싸며 maxBy를 수행한 결과를 Optional::get으로 Optional 값을 추출한다.

3. 요소 분할

분할 함수란 프리디케이트를 분류 함수로 사용하는 특수한 그룹화 기능이다. 분할 함수는 Boolean을 반환하므로 맵의 키 형식은 Boolean이다.

결과적으로 참 아니면 거짓의 값을 갖는 두개의 그룹으로 분류된다.

이진 분류

partitioningBy

만약 채식인 요리와 채식이 아닌 요리 두가지로 분류한다고 가정하자.

Map<Boolean, List<Dish>> partitionedMenu = menu.stream().collect(partitioningBy(Dish::isVegetarian));
  • isVegetarian은 프리디케이트로 Boolean값을 반환한다.

위 코드 실행 시, 다음과 같은 결과가 반환된다.

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

또한 참값의 키로 맵에서 모든 채식 요리를 얻을 수 있다.

List<Dish> vegetarianDishes = partitionedMenu.get(true);

분할은 참, 거짓 두가지 요소의 스트림 리스트를 어느 하나 버리지 않고 모두 유지한다.

또한 groupingBy와 마찬가지로 컬렉터를 두 번째 인수로 전달할 수 있는 오버로드 된 버전의 partitioningBy 메서드도 존재한다.

Map<Boolean, Map<Dish.Type, List<Dish>>> vegetarianDishesByType = 
		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]}}
  • true, false로 분류 후 그 안에서 groupingBy 메서드를 적용한다.
  • groupingBy와 마찬가지로 컬렉터 형식의 다른 메서드들도 적용할 수 있다. (예를 들어 true, false 중 가장 칼로리가 높은 것 - collectingAndThen 사용)

Collectors의 메서드 이외 따로 Collector 인터페이스를 구현할 수 있지만 조금 더 공부한 다음 이해하고 정리할 계획이다.

profile
백엔드 개발자가 되겠어요

0개의 댓글