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
으로 컬렉터가 반환한 결과를 다른 형식으로 활용할 수 있습니다.