on
Modern Java in Action - Ch.6(1)
참고: 책 - Modern Java in Action
책 Modern Java in Action을 읽고 정리합니다. 이번 포스트에서는 Ch 6.1 ~ Ch 6.3의 내용을 읽고 정리합니다.
Ch 6. 스트림으로 데이터 수집
- Collection, Collector, collect 구분하기
6.1 컬렉터란 무엇인가?
- 6.1.1 고급 리듀싱 기능을 수행하는 컬렉터
- 6.1.2 미리 정의된 컬렉터
6.2 리듀싱과 요약
- 6.2.1 스트림에서 최댓값과 최솟값 검색
- 6.2.2 요약 연산
- 6.2.3 문자열 연결
- 6.2.4 범용 리듀싱 요약 연산
6.3 그룹화
- 6.3.1 그룹화된 요소 조작
- 6.3.2 다수준 그룹화
- 6.3.3 서브그룹으로 데이터 수집
Ch 6. 스트림으로 데이터 수집
Java 8의 스트림은 데이터 집합을 멋지게 처리하는 게으른 반복자입니다. 스트림의 최종 연산 collect를 통해 다양한 요소 누적 방식(Collector 인터페이스에 정의)을 인수로 받아서
스트림을 최종 결과로 도출하는 리듀싱 연산에 대해 정리하겠습니다.
스트림의 중간 연산과 최종 연산
중간 연산
filter,map등이 있습니다.- 한 스트림을 다른 스트림으로 변환하는 연산으로서, 여러 연산을 연결할 수 있습니다.
- 스트림 파이프라인을 구성하며 스트림의 요소를 소비(consume)하지 않습니다.
최종 연산
count,findFirst,forEach,reduce등이 있습니다.- 스트림의 요소를 소비(consume)해서 최종 결과를 도출합니다. (ex. 스트림의 가장 큰 값 반환)
- 스트림 파이프라인을 최적화하면서 계산 과정을 짧게 생략하기도 합니다.
Collection, Collector, collect 구분하기
Collection
- 자바에서의 Collection Framework란 다수의 데이터를 쉽고 효과적으로 처리할 수 있는 표준화된 방법을 제공하는 클래스의 집합을 의미합니다.
Collector
- collect에서 필요한 메서드를 정의해놓은 인터페이스입니다.
public interface Collector<T, A, R> {
Supplier<A> supplier();
BiConsumer<A, T> accumulator();
BiOperator<A> combiner();
Function<A, R> finisher();
Set<Characteristics> characteristics();
}
Collectors
- 다양한 기능의 Collector를 구현한 클래스를 제공합니다.
Collector reducing(Binaryoperator<T> op)
Collector reducing(T identity, BinaryOperator<T> op)
Collector reducing(U identity, Function<T, U> mapper, BinaryOperator<U> op)
collect
- Collector를 매개변수로 하는 스트림의 최종연산입니다.
Object collect(Collector collector)
Object collect(Supplier supplier, BiConsumer accumulator, BiConsumer combiner)
collect와 Collctor로 구현할 수 있는 예제
통화별로 트랜잭션을 그룹화한 다음에 해당 통화로 일어난 모든 트랜잭션 합계를 계산하시오 (Map<Currency, Integer> 전환)
Before - 명령형 프로그래밍
//그룹화한 트랜잭션을 저장할 맵을 생성
Map<Currency, List<Transaction>> transactionsByCurrencies = new HashMap<>();
//트랜잭션 리스트를 반복
for (Transaction transaction : transactions) {
//트랜잭션의 통화를 추출
Currency currency = transaction.getCurrency();
List<Transaction> transactionsForCurrency = transactionsByCurrencies.get(currency);
//현재 통화를 그룹화하는 맵에 항목이 없으면 항목 만들기
if (transactionsForCurrency == null) {
transactionsForCurrency = new ArrayList<>();
transactionsByCurrencies.put(currency, transactionsForCurrency);
}
//같은 통화를 가진 트랜잭션 리스트에 현재 탐색 중인 트랜잭션을 추가
transactionsForCurrency.add(transaction);
}
- 위 코드를 collect 메서드를 사용해 아래처럼 아주 간결하게 작성할 수 있습니다.
After - 함수형 프로그래밍
Map<Currency, List<Transaction>> transactionsByCurrencies = transactions.stream()
.collect(groupingBy(Transaction::getCurrency));
- Stream에 toList를 사용하는 대신 더 범용적인 컬렉터 파라미터를 collect 메서드에 전달함으로써 원하는 연산을 간결하게 구현할 수 있습니다.
6.1 컬렉터란 무엇인가?
위 예제를 통해 명령형 프로그래밍에 비해 함수형 프로그램이 얼마나 편리한지 명확하게 보여줍니다. 함수형 프로그래밍에서는 ‘무엇’을 원하는지 직접 명시할 수 있어서 어떤 방법으로 이를 얻을지는 신경 쓸 필요가 없습니다. 다수준으로 그룹화를 수행할 때 명령형 프로그래밍과 함수형 프로그래밍의 차이점이 더욱 두드러집니다. 또한 훌륭하게 설계된 함수형 API의 또 다른 장점으로 높은 수준의 조합성과 재사용성이 있습니다. collect로 결과를 수집하는 과정을 간단하면서도 유연한 방식으로 정의할 수 있다는 점이 컬렉터의 최대 장점입니다.
6.1.1 고급 리듀싱 기능을 수행하는 컬렉터
스트림에 collect를 호출하면 -> 스트림의 요소에 (컬렉터로 파라미터화된) 리듀싱 연산이 수행됩니다. 내부적으로 리듀싱 연산이 일어나는 모습은 아래 그림과 같습니다.

collect에서는 리듀싱 연산을 이용해서 스트림의 각 요소를 방문하면서 컬렉터가 작업을 처리합니다.
6.1.2 미리 정의된 컬렉터
Collectors에서 제공하는 메서드의 기능은 크게 세 가지로 구분할 수 있습니다.
- 6.2 스트림 요소를 하나의 값으로 리듀스하고 요약
- 6.3 요소 그룹화
- 6.4 요소 분할
6.2 리듀싱과 요약
- 컬렉터(Stream.collect 메서드의 인수)로 스트림의 항목을 컬렉션으로 재구성할 수 있습니다. 즉 컬렉터로 스트림의 모든 항목을 하나의 결과로 합칠 수 있습니다.
- 이후 모든 예제는
import static java.util.stream.Collectors.*;를 import 했다고 가정합니다.
ex.
counting()이라는 팩토리 메서드가 반환하는 컬렉터로 메뉴에서 요리 수를 계산할 수 있습니다.
long howManyDishes = menu.stream().collect(Collectors.counting());
long howManyDishes = menu.stream().count();
6.2.1 스트림에서 최댓값과 최솟값 검색
ex. 메뉴에서 칼로리가 가장 높은 요리를 찾는 예제입니다.
Comparator<Dish> dishCaloriesComparator = Comparator.comparingInt(Dish::getCalories);
Optional<Dish> mostCalorieDish = menu.stream()
.collect(maxBy(dishCaloriesComparator));
6.2.2 요약 연산
- Collectors 클래스는
Collectors.summingInt라는 특별한 요약 팩토리 메서드를 제공합니다.
ex. 메뉴 리스트의 총 칼로리를 계산하는 예제입니다.
int totalCalories = menu.stream().collect(summingInt(Dish::getCalories));
- 단순 합계 외에 평균값 계산 등의 연산도 요약 기능으로 제공됩니다.
Collectors.averagingInt를 활용할 수 있습니다.
ex. 메뉴 리스트의 칼로리 평균을 계산하는 예제입니다.
double avgCalories = menu.stream().collect(averagingInt(Dish::getCalories));
- 하나의 요약 연산으로 두 개 이상의 연산을 한 번에 수행할 수도 있습니다. 이런 상황에서는 팩토리 메서드
summarizingInt가 반환하는 컬렉터를 사용할 수 있습니다.
ex. 메뉴에 있는 요소 수, 요리의 칼로리 합계, 평균, 최댓값, 최소값 등을 계산하는 예제입니다.
IntSummaryStatistics menuStatistics = menu.stream().collect(summarizingInt(Dish::getCalories));
// menuStatistics를 출력하면 아래와 같습니다.
IntSummaryStatistics{count=9, sum=4300, min=120, average=477.777778, max=800}
6.2.3 문자열 연결
joining팩토리 메서드를 이용하면 스트림의 각 객체에toString()메서드를 호출해서 추출한 모든 문자열을 하나의 문자열로 연결해서 반환합니다.
ex. 메뉴의 모든 요리명을 연결하는 예제입니다.
String shortMenu = menu.stream().map(Dish::getName).collect(joining());
// Dish에 toString 포함되어 있을 경우
String shortMenu = menu.stream().collect(joining());
//',' 구분 문자열을 넣을 경우
String shortMenu = menu.stream().map(Dish::getName).collect(joining(", "));
joining메서드는 내부적으로StringBuilder를 이용하여 문자열을 하나로 만듭니다.
6.2.4 범용 리듀싱 요약 연산
- 지금까지의 모든 컬렉터는
reducing팩토리 메서드로도 정의할 수 있습니다.
ex. 모든 칼로리의 합계를 계산하는 예제입니다.
int totalCalories = menu.stream()
.collect(reducing(0, Dish::getCalories, (i, j) -> i + j));
reducing은 인수 세개를 받습니다.
- 첫 번째 인수는 reducing 연산의 시작값이거나 스트림에 인수가 없을 때는 반환값입니다.
- 숫자 합계에서는 인수가 없을 때 반환값으로 0이 적합합니다.
- 두 번쨰 인수는 변환함수입니다.
- 세 번째 인수는 같은 종류의 두 항목을 하나의 값으로 더하는 BinaryOperator입니다.
ex. 가장 높은 칼로리를 가진 요리를 찾는 예제입니다.
Optional<Dish> mostCalorieDish =
menu.stream()
.collect(reducing((d1, d2) -> d1.getCalories() > d2.getCalories () ? d1 : d2));
- 한 개의 인수를 가진
reducing도 있습니다. - 세 개의 인수를 갖는
reducing메서드에서 스트임의 첫 번째 요소를 첫 번째 인수로 받으며 자신을 그대로 반환하는 항등함수를 두 번째 인수로 받는 상황에 해당됩니다.
자신의 상황에 맞는 최적의 해법 선택
함수형 프로그래밍에서는 하나의 연산을 다양한 방법으로 해결할 수 있음을 보여줍니다. 또한 스트림 인터페이스에서 직접 제공하는 메서드를 이용하는 것에 비해
스트림을 이용하는 코드가 더 복잡하다는 사실도 보여줍니다. 코드가 좀 더 복잡한 대신 재사용성과 커스터마이즈 가능성을 제공하는 높은 수준의 추상화와 일반화를 얻을 수 있습니다.
문제를 해결할 수 있는 다양한 해결 방법을 확인한 다음에 가장 일반적으로 문제에 특화된 해결책을 고르는 것이 바람직한데 이로 인해 가독성과 성능을 모두 잡을 수 있습니다.
6.3 그룹화
- 함수형을 이용하면 가독성 있는 한 줄의 코드로 그룹화를 구현할 수 있습니다.
ex. 메뉴를 그룹화하는 예제입니다.
Map<Dish.Type, List<Dish>> dishesByType = menu.stream().collect(groupingBy(Dish::getType));
- 단순한 속성 접근자 대신 더 복잡한 분류 기준이 필요한 상황에서는 메서드 참조를 분류 함수로 사용할 수 없습니다. 이런 경우 메서드 참조 대신 람다 표현식으로 필요한 로직을 구현할 수 있습니다.
ex. 칼로리 레벨을 분류하는 예제입니다. (diet - 400 이하, normal - 400 ~ 700, fat - 700 초과)
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 그룹화된 요소 조작
- 요소를 그룹화 한 다음에 각 결과 그룹의 요소를 조작하는 연산의 경우도 있습니다.
ex. 500칼로리가 넘는 요리만 필터링하는 예제입니다.
Map<Dish.Type, List<Dish>> caloricDishesByType =
menu.stream()
.collect(groupingBy(Dish::getType, filtering(dish -> dish.getCalories() > 500, toList())));
- 그룹화된 항목을 조작하는 다른 유용한 기능 중 또 다른 하나로
mapping을 이용해 요소를 변환하는 작업이 있습니다.ex. 그룹의 각 요리를 관련 이름 목록으로 변환하는 예제입니다.
Map<Dish.Type, List<Dish>> dishNamesByType =
menu.stream()
.collect(groupingBy(Dish::getType, mapping(Dish.getName, toList())));
ex. 각 형식의 요리의 태그를 간편하게 추출하는 예제입니다.
Map<Dish.Type, Set<String>> dishNamesByType =
menu.stream()
.collect(groupingBy(Dish::getType, flatMapping(dish -> dishTags.get(dish.getName()).stream(), toSet())));
flatMapping컬렉터를 이용해 두 수준의 리스트를 한 수준으로 평면화하고 해당 연산 결과를 수집해서 리스트가 아니라 집합으로 그룹화해 중복 태그를 제거합니다.
6.3.2 다수준 그룹화
- 두 인수를 받는 팩토리 메서드
Collectors.groupBy를 이용해서 항목을 다수준으로 그룹화 할 수 있습니다. Collectors.groupBy는 일반적인 분류 함수와 컬렉터를 인수로 받습니다. 바깥쪽 groupBy 메서드에 스트림의 항목을 분류할 두 번째 기준을 정의하는 내부 groupingBy를 전달해서 두 수준으로 스트림의 항목을 그룹화할 수 있습니다.
ex. 요리를 형식별, 칼로리 별로 분류하는 예제입니다.
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=[prok]},
FISH={DIET=[prawns], NORMAL=[salmon]},
OTHER={DIET=[rice, seasonal fruit], NORMAL=[french fries, pizza]}}
6.3.3 서브그룹으로 데이터 수집
ex. 요리의 종류를 분류하는 컬렉터로 메뉴에서 가장 높은 칼로리를 가진 요리를 찾는 프로그램도 다시 구현할 수 있습니다.
Map<Dish.Type, Optional<Dish>> mostCaloricByType =
menu.stream()
.collect(groupingBy(Dish::getType, maxBy(comparingInt(Dish::getCalories))));
- 그룹화의 결과로 요리의 종류를 key로, Optional
를 value로 갖는 맵이 반환됩니다. - 실제로 메뉴의 요리 중
Optional.empty()를 값으로 갖는 요리는 존재하지 않습니다. 처음부터 존재하지 않는 요리의 키는 맵에 추가되지 않기 떄문입니다. groupBy컬렉터는 스트림의 첫 번째 요소를 찾은 이후에야 그룹화 맵에 새로운 키를 (게으르게) 추가합니다.reducing컬렉터가 반환하는 형식을 사용하는 상황이므로 굳이 Optional wrapper를 사용할 필요가 없습니다.
Map<Dish.Type, Optional<Dish>> mostCaloricByType =
menu.stream()
.collect(groupingBy(Dish::getType,
collectingAndThen(maxBy(comparingInt(Dish::getCalories)), Optional::get)));
- 맵의 모든 값을
Optional로 감쌀 필요가 없으므로Collectors.collectingAndThen으로 컬렉터가 반환한 결과를 다른 형식으로 활용할 수 있습니다.