모던 자바 인 액션 6장
모던 자바 인 액션 6장 : 스트림으로 데이터 수집
이번장에서는 최종연산 collect로 다양한 요소 누적 방식을 인수로 받아서 최종 결과를 도출하는 방식을 알아본다.
다음은 collect와 컬렉터로 구현할 수 있는 질의 예제이다.
- 통화별로 트랜잭션을 그룹화한 다음에 해당 통화로 일어난 모든 트랜잭션 합계를 계산하시오
(Map<Currency, Integer> 반환) - 트랜잭션을 비싼 트랜잭션과 저렴한 트랜잭션 두 그룹으로 분류하시오
(Map<Boolean, List> 반환) - 트랜잭션을 도시 등 다수준으로 그룹화하시오. 그리고 각 트랜잭션이 비싼지 저렴한지 구분하시오
(Map<String, Map<Boolean, List>> 반환)
6.1 컬렉터란 무엇인가?
6.1.1 고급 리듀싱 기능을 수행하는 컬렉터
스트림에 collect를 호출하면 스트림의 요소에(컬렉터로 파라미터화된) 리듀싱 연산이 수행된다.
collect에서는 리듀싱 연산을 이용해서 스트림의 각 요소를 방문하면서 컬렉터가 작업을 처리한다.

- Collector 인터페이스의 메서드를 어떻게 구현하느냐에 따라 어떤 리듀싱 연산을 수행할지 결정된다.
- Collectors 유틸리티 클래스는 자주 사용하는 컬렉터 인스턴스를 손쉽게 생성할 수 있는 정적 팩터리 메서드를 제공한다.
6.1.2 미리 정의된 컬렉터
6장에서는 groupingBy같이 Collectors 클래스에서 제공하는 팩토리 메서드의 기능을 설명한다.
Collectors에서 제공하는 메서드의 기능은 크게 세 가지로 구분할 수 있다.
- 스트림 요소를 하나의 값으로 리듀스하고 요약
- 요소 그룹화
- 요소 분할
다음절부터 하나씩 알아보자.
6.2 리듀싱과 요약
컬렉터로 스트림의 항목을 컬렉션 혹은 정수 등 다양한 형식으로 결과를 도출할 수 있다.
아래는 예제 코드이다
long howManyDishes = menu.stream().collect(Collectors.counting());
// 불필요한 과정 생략 가능
long howManyDishes - menu.stream().count();
6.2.1 스트림값에서 최댓값과 최솟값 검색
- Collectors.maxBy, Collectors.minBy를 제공한다.
- 두 컬렉터는 요소를 비교할 때 사용할 Comparator를 인수로 받는다.
Comparator<Dish> dishCaloriesComparator = Comparator.comparingInt(Dish::getCalories);
Optional<Dish> mostCalorieDish = menu.stream()
.collect(maxBy(dishCaloriesComparator));
- menu가 비어있을 수 있으므로 Optional로 받는다.
- 최댓값 뿐만 아니라 객체의 숫자 필드의 합계나 평균 등을 반환하는 연산에도 리듀싱 기능이 자주 사용된다. 이러한 연산을 요약 연산이라 부른다.
6.2.2 요약 연산
Collectors를 static import 했다고 가정
- 합계 구하기 :
summingInt,summingLong,summingDoubleint totalCalories = menu.stream().collect(summingInt(Dish::getCalories)); - 평균값 계산 :
averagingInt,averagingLong,averagingDoubledouble avgCalories = menu.stream().collect(averagingInt(Dish::getCalories)) - 두 개 이상의 연산을 한번에 수행 :
summarizingInt
IntSummaryStatistics collected = ch4.menu.stream().collect(summarizingInt(Dish::getCalories));
//출력
IntSummaryStatistics{count=9, sum=4200, min=120, average=466.666667, max=800}
dish.getCalories()의 다양한 연산의 결과가 출력된다.
6.2.3 문자열 연결
- joining : 각 객체의 toString 메서드를 호출해서 모든 문자열 연결
- joining 메서드는 내부적으로
StringBuilder를 이용해서 문자열을 하나로 만듬
- joining 메서드는 내부적으로
String shortMenu = menu.stream().map(Dish::getName).collect(joining());
// 구분자를 요소 사이에 추가 가능
String shortMenu = menu.stream().map(Dish::getName).collect(joining(", "));
6.2.4 범용 리듀싱 요약 연산
지금까지 위에 나온 모든 컬렉터는 reducing 팩토리 메서드로도 정의할 수 있다. 즉, Collectors.reducing으로도 구현할 수 있다.
그러나 가독성과 편의성의 이점을 누리려면 위의 메서드들을 적절하게 사용하는게 중요하겠다.
예를 들어 아래처럼 reducing 메서드로도 summingInt의 기능을 충분히 수행할 수 있다.
int totalCalories = menu.stream().collect(reducing(0, Dish::getCalories, (i,j)-> i+j));
- 첫 번째 인수 : 리듀싱 연산의 시작값 혹은 스트림에 인수가 없을 때는 반환값
- 두 번째 인수 : 요리를 칼로리 정수로 변환하는 변환함수
- 세 번째 인수 : BinaryOperator
아래와 같이 칼로리를 가장 높은 요리를 찾는 maxBy 의 기능도 reducing으로 가능하다.
Optional<Dish> mostCalorieDish =
menu.stream().collect(reducing(
(d1,d2) -> d1.getCalories() > d2.getCalories() ? d1 : d2
));
- 한개의 인수를 받는 reducing 컬렉터는 시작값이 없으므로
Optional<Dish>를 반환한다.
6.3 그룹화
- 그룹화 : 데이터 집합을 하나 이상의 특성으로 분류하는 것
ex) 고기를 포함하는 그룹, 생선 포함 그룹, 나머지 그룹
Map<Dish.Type, List<Dish>> dishesByType =
menu.stream().collect(groupingBy(Dish::getType));
// 출력
{MEAT=[pork, beef, chicken], OTHER=[french fries, rice, season fruit, pizza], FISH=[prawns, salmon]}
이 함수를 기준으로 스트림이 그룹화되므로 이를 분류 함수라고 부른다.
위 예제에서는 단순한 속성 접근자를 기준으로 분류하였지만 더 복잡한 분류 기준이 필요한 상황에서는 메서드 참조를 분류 함수로 사용할 수 없다.
따라서 이런 경우에는 람다 표현식으로 로직을 구현해야 한다.
public enum CaloricLevel { DIET, NORMAL, FAT}
Map<CaloricLevel, List<Dish>> dishesByCaloricLevel = ch4.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칼로리가 넘는 요리만 필터링한다.
Map<Dish.Type, List<Dish>> caloricDishesByType =
menu.stream().filter(dish -> dish.getCalories() > 500)
.collect(groupingBy(Dish::getType))
아래는 출력 결과이다.
{OTHER=[french fries, pizza], MEAT=[port, beef]}
결과를 보면 FISH 종류의 요리는 500칼로리가 넘는것이 없어서 결과 맵에서 키 자체가 사라져버렸다.
이를 해결하기위해 Collectors 클래스는 일반적인 분류 함수에 Collector 형식의 두 번째 인수를 갖도록 groupingBy를 오버로딩해 두었다.
아래 코드로 위와같은 문제를 해결할 수 있다.
Map<Dish.Type, List<Dish>> caloricDishesByType =
menu.stream().collect(groupingBy(Dish::getType,
filtering(dish -> dish.getCalories() > 500, toList())));
//출력
{OTHER=[french fries, pizza], MEAT=[port, beef], FISH=[]}
- filtering 메소드는 Collectors의 또다른 정적 팩토리 메소드로 predicate를 인수로 받는다.
서브그룹으로 데이터 수집
Map<Dish.Type, Long> typesCount = menu.stream().collect(
groupingBy(Dish::getType, counting()));
// 결과
{MEAT=3, FISH=2, OTHER=4}