Modern Java In Action 정리 - 스트림으로 데이터 수집
목표
- Collectors 클래스로 컬렉션을 만들고 사용하기
- 하나의 값으로 데이터 스트림 리듀스 하기
- 특별한 리듀싱 요약 연산
- 데이터 그룹화와 분할
- 자신만의 커스텀 컬렉터 개발
모던 자바 인 액션 책을 보고 정리한 글입니다.
스트림으로 데이터 수집
컬렉터란 무엇인가 ?
- Collector 인터페이스 구현은 스트림의 요소를 어떤 식으로 도출할지 지정한다.
고급 리듀싱 기능을 수행하는 컬렉터
- collect로 결과를 수집하는 과정을 간단하면서도 유연한 방식으로 정의할 수 있다는 점이 컬렉터의 최대 강점이다.
구체적으로 스트림에 collect를 호출하면 스트림 요소에 내부적으로 리듀싱 연산이 수행된다. 명령형 프로그래밍에서 직접 구현해야 했던 부분들이 자동으로 수행된다는 점이다. - 보통 함수를 요소로 변환(toList처럼 데이터 자체를 변환하는것보다 데이터 저장 구조를 변환하는 작업이 더 빈번하다.)할 때는 컬렉터를 적용하여 최종 결과를 특정 자료구조로 뽑아낸다.
- Collectors 유틸리티 클래스는 자주 사용하는 컬렉터 인스턴스를 손쉽게 생성할 수 있는 정적 팩토리 메서드를 제공해준다.
미리 정의된 컬렉터
- groupingBy 와 같이 Collectors 클래스에서 제공하는 메서드의 기능을 설명한다.
Collectors에서 제공하는 메서드의 기능은 크게 세 가지로 구분할 수 있다.- 스트림의 요소를 하나의 값으로 리듀스 하고 요약
- 요소 그룹화
- 요소 분할
리듀싱과 요약
- 컬렉터 인스턴스를 활용해서 어떤 작업을 할 수 있는지 파악
- 예를 들면 counting() 이라는 팩토리 메서드가 환한하는 컬렉터로 다음과 같은 과정을 생략 할 수 있다.
- menu.stream().collect(Collectors.counting());
- menu.stream().counting();
스트림 값에서 최댓값과 최솟값 검색
- 메뉴에서 가장 높은 칼로리와 낮은 칼로리의 요리를 찾는다고 가정, Collectors.maxBy, Collectors.MinBy 두 개의 메서드를 활용해서 스트림의 최댓값과 최솟값을 계산할 수 있다.
- 두 컬렉터는 스트림의 요소를 비교하는데 사용하는 Comparator를 인수로 받는다.
```java
Comparator
dishComparator = Comparator.comparingInt(Dish::getCalories);
Optional
1 |
|
- 이러한 요약 연산은 내부적으로 reducing 연산이 실행되며 초깃값을 기준으로 스트림을 탐색하여 값을 더하게 된다.
- Collectors.summingLong, Collectors.summingDouble 메서드는 같은 방식으로 동작하며 long, double 형으로 데이터를 요약하는것만 다르다.
- 컬렉터를 활용해서 최댓값, 최솟값, 합계, 평균등을 계산하는 방식을 살펴보았는데 두개 이상의 연산을 한번에 수행해야 하는 경우 summarizingInt를 사용하게 된다.
1
2IntSummaryStatistics menuStatistics = menu.stream().collect(summarizingInt(Dish::getCalories)); // IntSummaryStatistics[count=4, sum=1200, min=100, average=200, max=300]
문자열 연결
- 컬렉터에 joining 팩토리 메서드를 이용하면 스트림의 각 객체에 toString 메서드를 호출하여 추출된 문자열을 하나로 연결해 반환한다.
1
2
3String shortMenu = menu.stream() .map(Dish::getName) .collect(joining());
- joining 메서드는 내부적으로 StringBuilder를 이용해 문자열을 하나로 만든다.
- Dish 클래스가 toString을 구현하였다면 아래와 같이 생략하여 정의한 toString으로 추출할 수 있다.
1
2String shortMenu = menu.stream() .collect(joining());
- 연결된 두 요소 사이에 구분 문자열을 넣거나 prefix, suffix를 넣을수도 있다.
1
2
3String shortMenu = menu.stream() .map(Dish::getName) .collect(joining(", ", "[", "]"));
범용 리듀싱 요약 연산
- 컬렉터는 reducing 팩토리 메서드로도 정의할 수 있다.
1
2Integer total = menu.stream() .collect(reducing(0, Dish::getCalories, (i, j) -> i + j));
- 리듀싱 연산은 세가지 인수를 받는다.
- 첫번쨰 인수는 시작값이거나 스트림이 비엇을 때 반환값
- 변환 함수
- BinaryOperator
- 다음 처럼 한 개의 인수를 가진 reducing 버전을 이용해 가장 칼로리가 높은 요리를 찾을 수도 있다.
1
2Optional<Dish> collect1 = menu.stream() .collect(reducing((d1, d2) -> d1.getCalories() > d2.getCalories() ? d1 : d2));
그룹화
- 데이터를 하나 이상의 집합으로 분류하여 그룹화 하는것도 데이터 베이스에서 많이 사용되는 작업이다.
- 스트림에서 제공하는 팩토리 메서드 (Collectors.gruopingBy)를 사용하여 메뉴를 그룹화 해보자.
```java
Map<DishType, List
> dishByType = menu.stream() .collect(groupingBy(Dish::getDishType));
Map<CaloricLevel, List
1 |
|
- 위와 같이 작성시 조건에 맞는 요소가 하나도 존재하지 않을시 Map의 key가 존재 하지 않는다. 이러한 경우 아래와 같이 작성
1
2Map<DishType, List<Dish>> dishByType = menu.stream() .collect(groupingBy(Dish::getDishType, filtering(dish -> dish.getCalories() > 500, toList())));
- 그룹화된 항목을 조작하는 다른 유용한 기능 중 하나로 맵핑 함수를 이용해 요소를 변환하는 작업이 있다.
filtering 컬렉터와 같은 이유로 Collectors 클래스는 매핑 함수와 각 항목에 적용한 함수를 모으는 데 사용하는 또 다른 컬렉터를 인수로 받는 mapping 메서드를 제공1
2
3// Name List 로 그룹화 Map<DishType, List<String>> dishByName = menu.stream() .collect(groupingBy(Dish::getDishType, mapping(Dish::getName, toList())));
- 컬렉터를 사용하면 일반 맵이 아닌 flatMap 변환을 수행할 수 있다.
1
2
3
4
5
6Map<String, List<String>> dishTags = new HashMap<>(); dishTags.put("pork", Arrays.asList("greasy", "salty")); Map<DishType, Set<String>> dishByDishTag = menu.stream() .collect(groupingBy(Dish::getDishType, flatMapping(dish -> dishTags.get(dish.getName()).stream(), toSet())));
다수준 그룹화
- 두 인수를 받는 팩토리 메서드 Collectors.groupingBy 를 이용해 항목을 다수준으로 그룹화 할 수 있다.
- Collectors.groupingBy는 일반적인 분류 함수와 컬렉터를 인수로 받는다.
1
2
3
4
5Map<DishType, Map<CaloricLevel, List<Dish>>> dishTypeMapMap = menu.stream() .collect(groupingBy(Dish::getDishType, groupingBy(dish -> { if (dish.getCalories() > 400) return CaloricLevel.DIET; else return CaloricLevel.FAT; })));
서브그룹으로 데이터 수집
- groupBy로 넘겨주는 컬렉터의 형식은 제한이 없다. 아래와 같이 두번째 인수로 counting 컬렉터를 전달해 메뉴에서 요리의 수를 종류별로 계산 가능하다.
1
2Map<DishType, Long> dishTypeLongMap = menu.stream() .collect(groupingBy(Dish::getDishType, counting()));
- 분류 함수 한개의 인수를 받는 groupingBy(f)는 groupingBy(f, toList())의 축약형이다.
- 가장 높은 칼로리를 가지는 메뉴도 구현 가능하다.
1
2Map<DishType, Optional<Dish>> dishTypeOptionalMap = menu.stream() .collect(groupingBy(Dish::getDishType, maxBy(Comparator.comparingInt(Dish::getCalories))));
- 팩토리 메서드 maxBy 가 생성하는 컬렉터의 형식에 따라 Optional 형식으로 바인딩 되었다. 실제 메뉴의 요리중 Optional.empty()를 값으로 가지는 메뉴는 없으나 groupingBy 컬렉터는
스트림의 첫번째 요소를 찾은 이후에 그룹화 맵에 새로운 키를 추가한다. (lazy binging) - Optional로 값을 감쌀 필요가 없으므로 Optional을 삭제 할 수있다. CollectingAndThen을 활용하는 것이다.
1
2Map<DishType, Dish> dishTypeDishMap = menu.stream() .collect(groupingBy(Dish::getDishType, collectingAndThen(maxBy(Comparator.comparingInt(Dish::getCalories)), Optional::get)));
- groupingBy와 함께 사용하는 다른 컬렉터 예제
- 일반적으로 스트림에서 같은 그룹으로 분류된 모든 요소에 리듀싱 작업을 수행할 때는 팩토리 메서드 groupingBy에 두 번째 인수로 전달한 컬렉터를 이용한다.
- 예를들어 모든 요리의 칼로리합을 구할때는 아래와 같이 사용한다.
1
2Map<DishType, Integer> dishTypeIntegerMap = menu.stream() .collect(groupingBy(Dish::getDishType, summingInt(Dish::getCalories)));
- 이 외에도 mapping 메서드로 만들어진 컬렉터도 groupingBy와 자주 사용된다.
- mapping은 다양한 형식의 객체를 주어진 형식의 컬렌터에 맞게 변환하는 역할을 한다.
- 각 요리 형식에 존재하는 모든 CaloricLevel값을 구할때는 아래와 같이 사용한다.
```java
Map<DishType, Set
> dishTypeSetMap = menu.stream() .collect(groupingBy(Dish::getDishType, mapping(dish -> { if (dish.getCalories() > 400) return CaloricLevel.DIET; else return CaloricLevel.FAT; }, toSet())));
// toCollection을 사용하면 원하는 SetCollection으로 반환할 수 있다.
Map<DishType, HashSet
1 |
|
분할의 장점
- 분할 함수를 사용하면 참, 거짓 두가지 요소의 스트림 리스트를 모두 유지할 수 있다는것이 장점이다.
- 컬렉터를 두번째 인수로 전달할 수 있는 오버로드된 partioningBy 메서드도 존재한다.
1 |
|
정리
- collect는 스트림의 요소를 요약 결과로 누적하는 다양한 방법을 인수로 갖는 최종연산이다.
- 스트림의 요소를 하나의 값으로 리듀스 하고 요약하는 컬렉터뿐 아니라 최솟값, 최댓값, 평균값을 계산하는 컬렉터 등이 미리 정의되어 있다.
- 미리 정의된 컬렉터인 groupingBy로 스트림의 요소를 그룹화 하거나, partitioningBy로 스트림의 요소를 분할 할 수 있다.
- 컬렉터는 다수준의 그룹화, 분할, 리듀싱 연산에 적합하게 설계되어 있다.
- collector 인터페이스에 정의된 메서드를 구현해서 커스텀 컬렉터를 개발할 수 있다.