[JAVA] 자바8 Stream Collector의 사용 방법 및 다양한 예제
[JAVA] 자바8 Stream의 Collectors 사용 방법 및 다양한 예제
스트림은 중간연산과 최종연산으로 구분된다
이번 포스팅은 스트림의 최종연산인 collect에 인수로 사용되는
Collectors에 대한 사용 방법 및 다양한 예제이다.
Stream.collect는 최종 연산이 수행되면서 스트림의 요소를 소비해
collect 메서드 Collector 인터페이스 구현을 전달해 스트림의 요소를 각각 다른 결과들로 반환 한다.
따라서 Collector 인터페이스의 메서드를 어떻게 구현하느냐에 따라 어떤 리듀싱 연산을 수행할지 결정 된다.
* Collectors 클래스
"모던 자바 인 액션" 을 보면 Collectors에서 제공하는 메서드의 기능은 크게 세 가지로 구분한다고 한다.
- 스트림 요소를 하나의 값으로 리듀스 하고 요약
- 요소 그룹화
- 요소 분할
리듀싱과 요약은 어떤 수의 총합을 찾는 등 다양한 계산을 수행할 때 이 컬렉터를 유용하게 활용할 수 있다.
요소 그룹화는 어떤 값을 기준으로 그룹화 한 뒤 서브그룹에 추가로 다양한 리듀싱 연산을 진행할 수 있다.
요소 분할은 한 개의 인수를 받아 불리언(boolean)을 반하는 함수, 즉 프레디케이트를 그룹화 함수로 사용한다.
1. 준비
Collectors 유틸리티 클래스의 다양한 메서드를 사용할 예정이기 때문에
가독성을 위해 static으로 Import를 진행 했다.
import static java.util.stream.Collectors.*;
테스트를 위해 음식 객체를 생성해 리스트에 담아주었다.
List<Dish> dishList = Arrays.asList(
new Dish("salad", true, 120, Dish.Type.OTHER)
, new Dish("tuna", false, 250, Dish.Type.FISH)
, new Dish("pork", false, 800, Dish.Type.MEAT)
, new Dish("steak", false, 800, Dish.Type.MEAT)
, new Dish("kimchi", true, 300, Dish.Type.OTHER)
);
(Dish 클래스)
2. 예제
2-1 Collectors.toList
중간연산을 거친 스트림을 순서대로 리스트로 반환하는 메서드이다.
반환된 리스트에 대한 추가 제어를 하기 위해서는 2-3의 toCollection 메서드를 사용해야 한다.
// 2-1 Collectors.toList : 채식인 음식에 대한 Dish의 리스트를 반환
List<Dish> vegetraianDishList =
dishList.stream()
.filter(Dish::isVegetarian) // 중간연산
.collect(toList());
System.out.println(vegetraianDishList);
// 결과
[Dish{name='salad', vegetarian=true, calories=120, type=OTHER},
Dish{name='kimchi', vegetarian=true, calories=300, type=OTHER}]
중간연산을 거쳐 toList를 하면 vegetarian이 true인 Dish들의 리스트만 출력된 것을 확인할 수 있다.
2-2 Collectors.toSet
toSet()은 중간연산된 스트림을 집합(Set)으로 반환한다.
집합은 중복된 값은 저장하지 않는다.
// 2-2 Collectors.toSet : Dish의 타입을 중복 없이 반환
Set<String> dishTypeDistint =
dishList.stream()
.map(v -> v.getType().toString())
.collect(toSet());
System.out.println(dishTypeDistint);
// 결과
[OTHER, FISH, MEAT]
toList와 비슷하지만 toSet의 경우에는 타입이 Set이다
따라서 중복이 제거된 DishType들만 추출되었다.
2-3 Collectors.toCollection
toCollection()은 스트림의 모든 항목을 발행자가 제공하는 컬렉션으로 수집한다.
즉, 원하는 방식으로 컬렉션의 결과를 지정할 수 있다.
// 2-3 Collectors.toCollection
List<Dish> vegetraianDishList2 =
dishList.stream()
.filter(Dish::isVegetarian) // 중간연산
.collect(toCollection(LinkedList::new));
예를들어
LinkedList의 경우 List 인터페이스를 상속받는다.
즉 List를 상속받는 구현체를 정의해 타입을 정할 수 있다.
2-4 Collectors.counting
counting은 스트림의 개수를 long 타입으로 반환해주는 카운팅 메서드이다.
바로 위에서 결과를 뽑은 vegetarian이 true인 결과를 counting 해주면 아래와 같다.
// 2-4 Collectors.counting
long vegetraianDishList3 =
dishList.stream()
.filter(Dish::isVegetarian) // 중간연산
.collect(counting());
System.out.println("결과 : " + vegetraianDishList3);
// 결과 : 2
하지만 Stream Api에 count() 라는 메서드가 있기때문에 collect(counting())을 할 필요가 없이 다음과 같이 하면 된다.
long vegetraianDishList3 =
dishList.stream()
.filter(Dish::isVegetarian) // 중간연산
.count();
2-5 Collectors.summingInt / Long / Double
summingInt / Long / Double은
스트림 요소의 합을 반환해준다.
// 2-5 Collectors.summingInt / Long / Double
long meatDishCaloriesSum =
dishList.stream()
.filter(v -> v.getType().equals(Dish.Type.MEAT))
.collect(summingLong(Dish::getCalories));
System.out.println("Dish Type Meat Sum : " + meatDishCaloriesSum);
// Dish Type Meat Sum : 1600
이 메서드의 경우도 mapToInt, mapToLong, mapToDouble 와 같은
특화된 스트림으로 반환 후
특화 스트림의 sum() 메서드로 대체 가능하다.
( 자바 8에서는 3가지의 특화스트림을 제공한다.
스트림은 숫자형 스트림의 연산을 위해 숫자 타입으로 언박싱 과정이 발생한다.
따라서 숫자형 스트림의 박싱비용을 피하기 위해 아래와 같은 기본형 특화스트림을 제공한다.
IntStream, LongStream, DoubleStream
위와 같은 숫자스트림으로 변환된 스트림의 경우 다시 객체 스트림으로 변환을 해야 collect를 사용할 수 있다.
이때는 boxed() 메서드를 사용하면 원상태로 복구할 수 있다. )
// 2-5 Collectors.summingInt / Long / Double
long meatDishCaloriesSum =
dishList.stream()
.filter(v -> v.getType().equals(Dish.Type.MEAT))
.mapToLong(Dish::getCalories) // 숫자 스트림 LongStream() 반환
.sum(); // 특화 스트림의 sum 메서드 실행
특화스트림을 사용하면 위와 같이 변경하면 된다.
2-6 Collectors.averagingInt / Long / Double
averagingLong 메서드는 스트림 요소의 숫자를 반환하는 ToLongFunction<T> 함수형 인터페이스를 파라미터로 받아
내부에서 연산 후 평균 값을 double 타입으로 반환 해준다.
// 2-5 Collectors.averagingInt / Long / Double
double meatDishCaloriesAvg =
dishList.stream()
.filter(v -> v.getType().equals(Dish.Type.MEAT))
.collect(averagingLong(Dish::getCalories));
System.out.println("Dish Type Meat Avg : " + meatDishCaloriesAvg);
// Dish Type Meat Avg : 800.0
averagingLong 역시 mapToLong 메서드로 특화스트림으로 변환 후 바로 평균을 구하는 함수를 사용해도 된다.
OptionalDouble meatDishCaloriesAvg =
dishList.stream()
.filter(v -> v.getType().equals(Dish.Type.MEAT))
.mapToLong(Dish::getCalories)
.average();
System.out.println("Dish Type Meat Avg : " + meatDishCaloriesAvg.getAsDouble());
다만 LongStream의 average 메서드는 OptionalDouble 타입을 리턴하는데
empty 값이 있는 경우 NPE 방지를 위해 옵셔널로 받게 된다.
옵셔널더블의 경우 getAsDouble() 함수를 사용해 옵셔널을 벗겨낼 수 있다.
2-7 Collectors.summarizingInt
summarizingInt는 함수의 이름 그대로 요약연산 이다.
중간연산을 거친 스트림의 요소의 수, 인수로 주어진 값의 합계, 평균, 최댓, 최솟값 등을 계산하는 함수이다.
// 2-7 Collectors.summarizingInt
LongSummaryStatistics longSummaryStatistics =
dishList.stream()
.filter(v -> !v.getType().equals(Dish.Type.MEAT))
.collect(summarizingLong(Dish::getCalories));
System.out.println("요리 타입이 MEAT이 아닌 요리들의 요약정보 : " + longSummaryStatistics);
// 요리 타입이 MEAT이 아닌 요리들의 요약정보 : LongSummaryStatistics{count=3, sum=670, min=120, average=223.333333, max=300}
summarizingLong의 인수로 Dish의 getCalories를 를 주었다.
따라서 칼로리의 평균, 합, 최대, 최솟값을 추출할 수 있으며
위 filter에 걸린 조건으로 MEAT가 아닌 스트림의 수를 가져온다.
스트림으로 추출한 longSummaryStatistics 변수로 사용할 수 있는 메서드를 살펴보면
위와 같이 평균, 요소 수 등 다양한 값을 추출할 수 있다.
2-8 Collectors.joining
joining 메서드는 스트림의 각 객체에 toString 메서드를 호출해
추출한 모든 문자열을 하나의 문자열로 연결해 반환한다.
joining은 3가지로 오버로딩 되어있는데
첫번째는 파라미터가 없는 메서드이다.
// 2-8-1 Collectors.joining
String strJoinning =
dishList.stream()
.map(Dish::getName)
.collect(joining(""));
System.out.println(strJoinning);
// saladtunaporksteakkimchi
두번째는 파라미터가 1개 (CharSequence delimiter 타입) 있는 메서드이다.
// 2-8 Collectors.joining
String strJoinning =
dishList.stream()
.map(Dish::getName)
.collect(joining("--"));
System.out.println(strJoinning);
// salad--tuna--pork--steak--kimchi
세번째는 파라미터가 3개 (delimiter, prefix, suffix)가 있는 메서드이다.
// 2-8 Collectors.joining
String strJoinning =
dishList.stream()
.map(Dish::getName)
.collect(joining("--", "prefix Area!! >>> ", " <<< suffix Area!!"));
System.out.println(strJoinning);
// prefix Area!! >>> salad--tuna--pork--steak--kimchi <<< suffix Area!!
2-9 Collectors.maxBy, minBy
maxBy, minBy는 스트림의 요소중 최댓값과 최소값을 Optional 인스턴스에 감싸져 반환하는 함수이다.
max, minBy는 파라미터로 함수형 인터페이스 Comparator을 받기 때문에
인수로 함수형 인터페이스를 주어야 한다.
// 2-9 Collectors.maxBy, minBy
Optional<Dish> optionalDish =
dishList.stream()
.filter(v -> v.getCalories() > 200)
.collect(maxBy(Comparator.comparingInt(Dish::getCalories)));
System.out.println("optionalDish : " + optionalDish);
// optionalDish : Optional[Dish{name='pork', vegetarian=false, calories=800, type=MEAT}]
칼로리가 800인 최대값이 출력되었는데
같은 값이 있는 경우 추가 정렬을 하기 위해서는 thenComparing 함수를 사용해야 한다.
함수형 인터페이스를 이용한 객체의 정렬은 아래의 글에 설명되어 있다.
https://anianidindin.tistory.com/6
2-10 Collectors.reducing
collect의 reducing은 Stream의 reduce 메서드와 기능이 같기 때문에 유사하게 보일 수 있다.
Stream의 reduce가 무조건 나쁜건 아니지만, collect와의 차이점은 새로운 리스트의 생성과 객체의 할당이다.
스트림의 리듀스는 누적자로 사용되는 결과를 리턴하고,
collect의 리듀싱은 연산 이후 결과를 누적하는 컨테이너를 바꾼다.
따라서 Stream의 reduce는 여러 스레드가 동시에 같은 데이터 구조체에 개입하면 리스트는 망가지게 되어 병렬 수행이 불가능 하다.
// 2-10 Collectors.reducing
Integer reducingInt =
dishList.stream()
.collect(reducing( 0 // 초기값
, (dish -> dish.getCalories()) // 변환함수
, (acc, cur) -> Integer.sum(acc, cur) // 합계 함수
));
System.out.println("칼로리의 합 : " + reducingInt);
// 칼로리의 합 : 2270
reducing의 첫번째 인수로는 초기값을 받는다
변수 reducingInt의 타입이 Integer이기 때문에 0으로 설정하였다.
두번째 인수는 Function 함수형 인터페이스를 받는다.
값을 변환할 함수를 인수로 주고
마지막 세번째는 연산을 수행할 BinaryOperator 인터페이스를 준다.
* reducing의 문자열 연결
아래는 예시이다.
- 문자열의 누적
// 2-10-1 Collectors.reducing 문자열의 누적
String resucingStr =
dishList.stream()
.map(Dish::getName)
.collect(reducing( ""
, (acc, cur) -> acc + cur + "---"
));
System.out.println("resucingStr : " + resucingStr);
// resucingStr : salad---tuna---pork---steak---kimchi---
map을 사용해 스트림 요소를 String로 변환을 미리 했기 때문에
reducing의 파라미터는 초기화와 구현 람다 함수만 사용했다.
리듀싱을 이용한 문자열 연결이 가능하긴 하지만 joining을 사용하는게 더 간단해 보인다.
2-11 Collectors.collectingAndThen
collectingAndThen 컬렉터가 반환한 결과를 다른 형식으로 활용할 수 있다.
즉 다른 컬렉터를 감싸고 그 결과에 변환함수를 적용할 수 있다.
먼저 간단한 예시이다. 스트림의 요소의 개수를 toList 컬렉터로 List로 받고
그 List를 감싼 뒤 List::Size로 변환함수를 주어 정수타입의 결과를 반환한다.
// 2-11 Collectors.collectingAndThen
int streamSize =
dishList.stream()
.collect(collectingAndThen(toList(), List::size));
System.out.println("streamSize : " + streamSize);
streamSize : 5
두번째 인수 List::size를 메서드 참조가 아닌 람다로 받으면 아래와 같다
(list) -> list.size()
여기서 list는 toList로 반환된 리스트이다.
이 리스트를 가지고 원하는 타입으로 반환하는 함수를 만들면 사용 가능하다.
- 요리의 타입별 최대 칼로리를 가진 요리
Map<Dish.Type, Dish> mostCaloriesDish =
dishList.stream()
.collect(groupingBy(Dish::getType,
collectingAndThen(
maxBy(Comparator.comparingInt(Dish::getCalories)),
Optional::get)));
System.out.println("최대 칼로리 요리 : " + mostCaloriesDish);
// 최대 칼로리 요리 :
{FISH=Dish{name='tuna', vegetarian=false, calories=250, type=FISH}
, MEAT=Dish{name='pork', vegetarian=false, calories=800, type=MEAT}
, OTHER=Dish{name='kimchi', vegetarian=true, calories=300, type=OTHER}}
2-12 Collectors.toMap
toMap은 스트림의 최종연산을 Key와 Value로 이루어진 Map 타입으로 반환 할때 사용된다.
// 2-12 Collectors.toMap
Map<Dish.Type, Dish> mostCaloriesDishToMap =
dishList.stream()
.collect(toMap(Dish::getType,
Function.identity(),
BinaryOperator.maxBy(
Comparator.comparingInt(Dish::getCalories))));
System.out.println(mostCaloriesDishToMap);
바로 위 collectingAndThen와 같이 요리의 타입별 최고 칼로리 요리를 가져오는 스트림 코드이다.
두번째 인수는 함수형 인터페이스 Function을 받는데
Function.identity()는 Value의 값으로 스트림에서 하나의 요소 즉, 자기 자신을 Value로 반환한다는 의미이다.
toMap은 toSet과 달리 중복을 자동으로 필터링 하지 않으며
세번째 인자 BinaryOperator<U> 을 통해 중복에 대한 필터링을 진행 해야한다.
만약 필터링을 하지 않은 상태에서 중복 Key가 생기면 IllegalStateException이 발생하게 된다.
toMap에 대한 추가 설명은 아래에서 확인할 수 있다.
https://anianidindin.tistory.com/7
[JAVA] 자바8 스트림 Map.Entry를 활용해 키(key) 또는 값(value)을 기준으로 Map(맵)정렬하기 1
이번 포스팅은 자바 8 스트림(Stream)을 이용해 HashMap의 키 또는 Value를 기준으로 내림차순, 오름차순 정렬을 해본다. 테스트를 위해 Map의 key,value 타입은 Map 타입으로 설정 했으며, Key는 순번, value 0
anianidindin.tistory.com
2-13 Collectors.groupingBy
groupingBy는 스트림 요소를 지정한 요소별로 그룹화 해 Set 또는 Map에 그룹화 하는 함수이다.
groupingBy 함수에 대한 내용은
https://anianidindin.tistory.com/4
[JAVA] Stream Collectors.groupingBy() (그룹화) 를 사용해 다양한 데이터 그룹화 하기
데이터의 그룹화라 하면 보통 SQL의 Group By를 사용해 수행된다. 그러나 자바 8의 함수형 Collectors.groupingBy를 이용하면 쉽게 데이터를 그룹화 할 수 있다. 이 포스팅에서는 자바 8 스트림의 Collectors
anianidindin.tistory.com
위 글에 자세히 설명되어 있다.
2-14 Collectors.partitioningBy
partitioningBy는 스트림의 요소를 분할하는 컬렉터의 분할함수로
프레디케이트를 분류함수로 사용하는 특수한 그룹화 기능이다.
분할함수는 Boolean을 반환하므로 맵의 키 는 True 또는 False 두개의 그룹으로 분류된다.
// 2-14 Collectors.partitioningBy
Map<Boolean, List<Dish>> partitionByDish =
dishList.stream()
.collect(partitioningBy(Dish::isVegetarian));
System.out.println("partitionByDish : " + partitionByDish);
/*
partitionByDish :
{false=[Dish{name='tuna', vegetarian=false, calories=250, type=FISH}, Dish{name='pork', vegetarian=false, calories=800, type=MEAT}, Dish{name='steak', vegetarian=false, calories=800, type=MEAT}]
, true=[Dish{name='salad', vegetarian=true, calories=120, type=OTHER}, Dish{name='kimchi', vegetarian=true, calories=300, type=OTHER}]}
*/
위 코드는 채식인 Dish를 isVegetarian으로 분류했으며
결과는 true와 false를 Key로 분류된 Dish의 리스트가 반환되었다.
// 2-14-1 Collectors.partitioningBy
Map<Boolean, List<Dish>> partitionByDish2 =
dishList.stream()
.filter(dish -> dish.getCalories() < 400)
.collect(partitioningBy(Dish::isVegetarian));
System.out.println("partitionByDish2 : " + partitionByDish2);
/*
partitionByDish2 :
{false=[Dish{name='tuna', vegetarian=false, calories=250, type=FISH}]
, true=[Dish{name='salad', vegetarian=true, calories=120, type=OTHER}, Dish{name='kimchi', vegetarian=true, calories=300, type=OTHER}]}
*/
이렇게 filter로 스트림 요소를 잘라낸 뒤 isVegetarian으로 분류할 수도 있다.
추가로 partitioningBy의 두번째 인수로 전달할 수 있는 오버로드 된 버전도 있다.
Collector<T, ?, Map<Boolean, D>> partitioningBy(Predicate<? super T> predicate,
Collector<? super T, A, D> downstream)
인수로 Predicate와 Collector을 받는다.
따라서 첫번째 인수에 프레디케이트를, 두번째에 컬렉터를 보내면 다음과 같다.
// 2-14-2 Collectors.partitioningBy
Map<Boolean, Map<Dish.Type, List<Dish>>> vegetarianDishByType =
dishList.stream()
.collect(
partitioningBy(Dish::isVegetarian
, groupingBy(Dish::getType)
)
);
System.out.println("vegetarianDishByType : " + vegetarianDishByType);
/*
vegetarianDishByType :
{false=
{FISH=[Dish{name='tuna', vegetarian=false, calories=250, type=FISH}]
, MEAT=[Dish{name='pork', vegetarian=false, calories=800, type=MEAT}, Dish{name='steak', vegetarian=false, calories=800, type=MEAT}]}
, true=
{OTHER=[Dish{name='salad', vegetarian=true, calories=120, type=OTHER}, Dish{name='kimchi', vegetarian=true, calories=300, type=OTHER}]}}
*/
* Collector 인터페이스
컬렉터 인터페이스에는 다섯 개의 메서드가 정의되어 있다.
이 다섯 개의 메서드를 이용해 커스텀 컬렉터를 만들 수 있다.
Collector의 제네릭인
T는 수집될 스트림 항목을 의미하고
A는 누적자, 즉 수집 과정에서 중간 결과를 누적하는 객체의 타입
R은 수집 연산 결 객체의 타입이다.
위 사진의 앞에 4개의 메서드는
collect 메서드에서 실행하는 함수를 반환하며
마지막 다섯 번째인 characteristics() 메서드는
collect가 어떤 최적화를 이용 연산을 수행할 것인지 결정하도록 돕는 역할을 한다.
public class CustomCollector<T> implements Collector<T, List<T>, List<T>> {
@Override
public Supplier<List<T>> supplier() {
return ArrayList::new;
}
@Override
public BiConsumer<List<T>, T> accumulator() {
return List::add;
}
@Override
public BinaryOperator<List<T>> combiner() {
return ((list, list2) -> {
list.addAll(list2);
return list;
});
}
@Override
public Function<List<T>, List<T>> finisher() {
return Function.identity();
}
@Override
public Set<Characteristics> characteristics() {
return Collections.unmodifiableSet(EnumSet.of(
Characteristics.IDENTITY_FINISH, Characteristics.CONCURRENT
));
}
}
위와 같이 커스텀할 컬렉터 클래스를 만들어 준다.
Collector<T, A, R> 을 implements 했으며 다섯개의 메서드는 재정의 헀다.
* supplier :
supplier 메서드는 빈 결과로 이루어진 supplier을 반환해야 한다. (주로 생성자를 람다로 반환할때 생성된다.)
즉, 데이터의 수집 과정에서 빈 누적자 인스턴스를 만드는 파라미터가 없는 함수로
결과를 받을 컨테이너를 생성해 리턴한다..
* accmulator :
리듀싱 연산을 수행하는 함수를 반환한다.
스트림 리듀스의 두번째 메서드에 사용되는 리듀싱 함수를 작성하며
리스트에 현재 항목(요소)를 추가하는 연산을 수행했다.
* combiner :
두 컨테이너(누산기)의 결과를 병합하는 메서드로
스트림의 두번째 리스트에서 수집한 항목을 첫번째 리스트의 결과에 병합해준다.
* finisher :
스트림의 탐색이 끝난 뒤 요소가 없으면
누적자 객체를 최종 결과로 변환 하며 종료 시 호출 할 함수를 반환한다.
스트림의 요소 소진 후 실행 과정 >> (R result = collector.finisher().apply(accmulator);)
앞에서 이미 누적자의 객체 자체가 최종 결과가 되었기 떄문에
다른 변환 작업 없이 항등함수 Function.identity() 를 사용해 반환한다.
* characteristics:
위 메서드는 컬렉터의 연산을 정의하는 메서드로
어떤 방식으로 내부의 최적화를 진행할지 힌트를 제공한다.
위 소스에서는 IDENTITY_FINISH를 사용했으며
리듀싱 결과의 최종 결과로 누적자 객체를 사용할 때 작성한다.
추가 설명은 모던 자바 인 액션 (p.229) 참고
// CustomCollector
List<Dish> dishes = dishList.stream().collect(new CustomCollector<Dish>());
System.out.println("CustomCollector : ");
for(Dish dish : dishes) System.out.println(dish);
// 결과
CustomCollector :
Dish{name='salad', vegetarian=true, calories=120, type=OTHER}
Dish{name='tuna', vegetarian=false, calories=250, type=FISH}
Dish{name='pork', vegetarian=false, calories=800, type=MEAT}
Dish{name='steak', vegetarian=false, calories=800, type=MEAT}
Dish{name='kimchi', vegetarian=true, calories=300, type=OTHER}
최종적으로 생성한 커스텀 컬렉션을 실행하면 위와 같은 결과가 나온다.
Collectors 유틸리티 클래스는 실무에서도 다양하게 활용 할 수 있을 것으로 보인다.
또한, 커스텀 컬렉션에 대한 추가적인 응용도 테스트 해볼 계획이다.
참고 : 모던 자바 인 액션(라울-게이브리얼 우르마,마리오 푸스코,앨런 마이크로프트)