컬렉터(Collector)란 무엇인가?
컬렉터(Collector) 인터페이스 구현은 스트림 요소를 어떤 식으로 도출할지 지정한다.
스트림에 collect를 호출하면 컬렉터가 스트림의 요소에 리듀싱 연산을 수행하여 필요한 데이터 구조로 간단하게 도출할 수 있다.
만약 아래처럼 통화 별 주문 목록 데이터가 있다고 가정해보자.
@ToString
@Getter
@AllArgsConstructor
public class Order {
private String orderNumber;
private String orderProductName;
private Integer payPrice;
private Currency currency;
}
List<Order> orders = new ArrayList<>() {{
add(new Order("0000001", "자전거", 1000000, Currency.getInstance(Locale.KOREA)));
add(new Order("0000002", "컴퓨터", 1000000, Currency.getInstance(Locale.KOREA)));
add(new Order("0000003", "에어컨", 3000000, Currency.getInstance(Locale.US)));
add(new Order("0000004", "냉장고", 5000000, Currency.getInstance(Locale.CHINA)));
}};
만약 이 주문 목록을 통화 별로 그룹화하여 리스트로 반환한다고 할 때, 스트림을 사용하지 않으면 아래처럼 코드를 구현할 수 있다.
Map<Currency, List<Order>> ordersByCurrencies = new HashMap<>();
for (Order order : orders) {
Currency currency = order.getCurrency();
List<Order> ordersByCurrency = ordersByCurrencies.get(currency);
if (ordersByCurrency == null) {
ordersByCurrency = new ArrayList<>();
ordersByCurrencies.put(currency, ordersByCurrency);
}
ordersByCurrency.add(order);
}
간단한 작업임에도 코드가 너무 길뿐만 아니라 한눈에 이해하기 어려운 코드라는 것을 부정하기 어렵다.
그럼 스트림을 사용하면 어떻게 구현할까?
Map<Currency, List<Order>> ordersByCurrencies = orders.stream()
.collect(Collectors.groupingBy(Order::getCurrency));
이처럼 스트림을 사용하면 Collectors가 지원하는 메서드를 사용해서 간단하게 변환하는 코드를 작성할 수 있다.
Collectors 유틸리티 클래스는 자주 사용하는 컬렉터 인스턴스를 손쉽게 생성할 수 있는 정적 팩토리 메서드를 제공한다.
에 for문을 사용한 명령형 코드에서는 다중 루프와 조건문을 추가하며 가독성과 유지보수성이 크게 떨어지지만, 반면 함수형 프로그래밍에서는 필요한 컬렉터를 쉽게 추가할 수 있다.
훌륭하게 설계된 함수형 API의 또 다른 장점으로 높은 수준의 조합 성과 재사용성을 꼽을 수 있다.
collect로 결과를 수집하는 과정을 간단하면서도 유연한 방식으로 정의할 수 있다는 점이 컬렉터의 최대 강점이다.
Collectors에서 제공하는 메서드의 기능은 크게 세 가지로 구분할 수 있다.
- 스트림 요소를 하나의 값으로 리듀싱 하고 요약
- 요소 그룹화
- 요소 분할
이러한 특징들을 아래에서 하나씩 살펴보자.
1. 스트림 요소를 하나의 값으로 리듀싱하고 요약
위에서 스트림에 collect를 호출하면 내부에서는 컬렉터가 리듀싱 연산을 수행한다고 하였다.
내부에서 리듀싱 연산을 수행하지만 우리가 보는 코드에는 리듀싱 연산을 실행하는 코드가 보이지 않는다.
이러한 연산을 요약(summarization) 연산이라 부른다. 그럼 아래에 연산 요약의 메서드들을 알아보자.
▶ 스트림 개수 검색
스트림의 개수를 Collectors의 counting 메서드를 사용해서 도출할 수 있고, 더욱 간단하게 생략할 수도 있다.
long orderCount = orders.stream().collect(Collectors.counting());
// 생략 ver
long orderCount = orders.stream().count();
▶ 스트림 값에서 최댓값과 최솟값 검색
Collectors.minBy, Collectors.maxBy 메서드를 사용해서 스트림 값에 최솟값과 최댓값을 구할 수 있다.
두 메서드는 요소를 비교하는 데 사용할 Comparator를 인수로 받는다.
Optional<Order> minOrder = orders.stream()
.collect(Collectors.minBy(Comparator.comparing(Order::getPayPrice)));
// 생략 ver
Optional<Order> minOrder = orders.stream().min(Comparator.comparing(Order::getPayPrice));
위 예제처럼 orders가 빈 값이라면 당연히 minOrder도 빈 값이기 때문에 Optional으로 반환한다.
▶ 스트림 값의 합계, 평균 구하기
Collectors 클래스는 Collectors.summingInt라는 요약 팩토리 메서드를 제공한다.
summingInt는 객체를 int로 매핑하는 함수를 인수로 받는다. 그리고 summingInt가 collect 메서드로 전달되면 요약 작업을 수행한다.
int totalPayPrice = orders.stream().collect(Collectors.summingInt(Order::getPayPrice));
// 생략 ver
int totalPayPrice = orders.stream().mapToInt(Order::getPayPrice).sum();
summingInt뿐만 아니라 summingLong, summingDouble 메서드도 제공한다.
값의 평균은 Collectors.averagingInt라는 메서드를 사용하면 쉽게 도출할 수 있다.
double averagePayPrice = orders.stream().collect(Collectors.averagingInt(Order::getPayPrice));
물론 averagingLong, averagingDouble 메서드도 제공해주고 있다.
▶ 두 개 이상의 연산을 한 번에 수행
종종 두 개 이상의 연산을 한 번에 수행해야 할 때도 있다.
이런 상황에서는 팩토리 메서드 summarizingInt가 반환하는 컬렉터를 사용할 수 있다.
summarizingInt 메서드를 사용하면 요소의 수, 합계, 최솟값, 평균, 최댓값을 한 번에 도출할 수 있다.
IntSummaryStatistics orderStatistics = orders.stream().collect(Collectors.summarizingInt(Order::getPayPrice));
System.out.println(orderStatistics);
// IntSummaryStatistics{count=4, sum=10000000, min=1000000, average=2500000.000000, max=5000000}
마찬가지로 summarizingLong, summarizingDouble 메서드와 관련된 LongSummaryStatistics, DoubleSummaryStatistics 클래스도 있다.
▶ 문자열 연결
컬렉터에 joining 팩토리 메서드를 이용하면 스트림의 각 객체에 toString 메서드를 호출해서 추출한 모든 문자열을 하나의 문자열로 연결해서 반환한다.
String productNames = orders.stream().map(Order::getOrderProductName).collect(Collectors.joining(", "));
// 자전거, 컴퓨터, 에어컨, 냉장고
위에서 살펴본 모든 컬렉터는 리듀싱 연산을 요약한 컬렉터이다.
그러므로 Collectors.reducing 메서드로도 정의할 수 있다.
하지만 편의성과 가독성, 성능 최적화 위해 특화된 컬렉터가 만들어진 것이기 때문에 특화된 컬렉터를 사용하는 것을 권장한다.
2. 요소 그룹화
맨 위에 소개했던 주문을 통화 별로 그룹화하는 예제를 보면 알 수 있듯이 스트림을 사용하여 데이터 소스를 하나 이상의 특정한 속성 값으로 분류해서 그룹화하는 연산도 가능하다.
Map<Currency, List<Order>> ordersByCurrencies = orders.stream()
.collect(Collectors.groupingBy(Order::getCurrency));
// {CNY=[Order{orderNumber='0000004', orderProductName='냉장고', payPrice=5000000, currency=CNY}], USD=[Order{orderNumber='0000003', orderProductName='에어컨', payPrice=3000000, currency=USD}], KRW=[Order{orderNumber='0000001', orderProductName='자전거', payPrice=1000000, currency=KRW}, Order{orderNumber='0000002', orderProductName='컴퓨터', payPrice=1000000, currency=KRW}]}
위 코드처럼 Collectors.groupingBy 메서드를 사용해 특정한 속성 값을 기준으로 스트림을 그룹화한다.
이처럼 그룹화되는 함수를 분류 함수(classification function)이라고 부른다.
하지만 이러한 분류 함수는 단순한 속성 접근자 대신 복잡한 분류 기준이 필요한 상황에서는 사용할 수 없다.
예를 들어 결재 금액이 삼백만원 이상되는 주문을 분류한다고 한다면 groupingBy 메서드를 사용할 수 없는 것이다.
이럴 때는 아래처럼 메서드 참조 대신 람다 표현식으로 필요한 로직을 구현해야 한다.
Map<String, List<Order>> expensiveOrders = orders.stream().collect(Collectors.groupingBy(order -> {
if (order.getPayPrice() >= 3000000) {
return "expensiveOrder";
} else {
return "inexpensiveOrder";
}
}));
▶ 그룹화된 요소 조작
요소를 그룹화 한 다음에는 각 결과 그룹의 요소를 조작하는 연산이 필요하다.
만약 삼백만원 이상의 주문만 필터링하고 통화 별로 그룹화한다고 가정해보면, 아래처럼 filter 메서드를 활용해 문제를 해결할 수 있다고 생각할 것이다.
List<Order> orders = new ArrayList<>() {{
add(new Order("0000001", "자전거", 1000000, Currency.getInstance(Locale.KOREA)));
add(new Order("0000002", "컴퓨터", 1000000, Currency.getInstance(Locale.KOREA)));
add(new Order("0000003", "에어컨", 3000000, Currency.getInstance(Locale.US)));
add(new Order("0000004", "냉장고", 5000000, Currency.getInstance(Locale.CHINA)));
}};
Map<Currency, List<Order>> expensiveOrders = orders.stream()
.filter(order -> order.getPayPrice() >= 3000000)
.collect(Collectors.groupingBy(Order::getCurrency));
// {CNY=[Order{orderNumber='0000004', orderProductName='냉장고', payPrice=5000000, currency=CNY}], USD=[Order{orderNumber='0000003', orderProductName='에어컨', payPrice=3000000, currency=USD}]}
위 코드로 문제를 해결할 수 있지만 단점도 존재한다.
결과를 보면 통화가 Locale.KOREA인 주문은 필터링 되므로 KRW의 키 자체가 사라진다.
Collector 클래스는 일반적인 분류 함수에 Collector 형식의 두 번째 인수(filtering)를 갖도록 groupingBy 메서드를 overload 해 이 문제를 해결한다.
Map<Currency, List<Order>> expensiveOrders = orders.stream()
.collect(Collectors.groupingBy(
Order::getCurrency,
Collectors.filtering(order -> order.getPayPrice() >= 3000000, Collectors.toList())
));
// {KRW=[], CNY=[Order{orderNumber='0000004', orderProductName='냉장고', payPrice=5000000, currency=CNY}], USD=[Order{orderNumber='0000003', orderProductName='에어컨', payPrice=3000000, currency=USD}]}
Collectors.filtering 메서드는 Predicate를 인수로 받아 필터링한다. 출력 결과에서 볼 수 있듯이 KRW 항목도 추가된다.
두 번째 인수에 Collectors.mapping 메서드를 사용하면 스트림의 특정한 값을 그룹화할 수 있다.
Map<Currency, List<String>> productNames = orders.stream()
.collect(Collectors.groupingBy(
Order::getCurrency,
Collectors.mapping(Order::getOrderProductName, Collectors.toList())
));
// {KRW=[자전거, 컴퓨터], CNY=[냉장고], USD=[에어컨]}
▶ 다수준 그룹화
Collectors.groupingBy는 일반적인 분류 함수와 컬렉터를 인수로 받는다.
groupingBy 메서드에 두 번째 인수값에 다른 groupingBy를 전달해서 두 수준으로 스트림의 항목을 그룹화할 수 있다.
Map<Currency, Map<OrderExpensiveType, List<Order>>> expensiveOrdersByCurrencies = orders.stream()
.collect(Collectors.groupingBy( // 1차 groupingBy
Order::getCurrency,
Collectors.groupingBy(order -> { // 2차 groupingBy
if (order.getPayPrice() >= 3000000) {
return OrderExpensiveType.EXPENSIVE;
} else {
return OrderExpensiveType.INEXPENSIVE;
}
})
));
// {
// KRW={
// INEXPENSIVE=[
// Order{orderNumber='0000001', orderProductName='자전거', payPrice=1000000, currency=KRW},
// Order{orderNumber='0000002', orderProductName='컴퓨터', payPrice=1000000, currency=KRW}
// ]
// },
// CNY={
// EXPENSIVE=[
// Order{orderNumber='0000004', orderProductName='냉장고', payPrice=5000000, currency=CNY}
// ]
// },
// USD={
// EXPENSIVE=[
// Order{orderNumber='0000003', orderProductName='에어컨', payPrice=3000000, currency=USD}
// ]
// }
// }
위 코드에서 볼 수 있듯이 첫 번째로 그룹화하는 groupingBy 메서드의 두 번째 인자의 형식은 제한이 없기 때문에 아래 코드처럼 두 번째 인자를 통해 통화 별 결제 금액의 합계도 구할 수 있다.
이처럼 다수준 그룹화 연산은 다양한 수준으로 확장할 수 있다.
Map<Currency, Integer> totalPayPriceByCurrencies = orders.stream()
.collect(Collectors.groupingBy(
Order::getCurrency,
Collectors.summingInt(Order::getPayPrice)
));
▶ 컬렉터 결과를 다른 형식에 적용하기
Collectors.collectingAndThen은 적용할 컬렉터와 변환 함수를 인수로 받아 다른 컬렉터를 반환한다.
위에 Collectors.maxBy를 사용한 예제에서는 결과가 Optional로 반환되지만, 아래 예제에서는 Collectors.collectingAndThen을 사용해 Optional.get()의 결과 값을 도출한다.
Map<Currency, Order> mostExpensiveOrder = orders.stream()
.collect(Collectors.groupingBy(
Order::getCurrency,
Collectors.collectingAndThen(
Collectors.maxBy(Comparator.comparing(Order::getPayPrice)),
Optional::get
)
));
reducing 컬렉터는 절대 Optional.empty()를 반환하지 않으므로 안전한 코드가 된다.
3. 요소 분할
분할은 분할 함수(partitioning function)라 불리는 Predicate를 분류 함수로 사용하는 특수한 그룹화 기능이다.
분할 함수는 boolean을 반환하므로 맵의 키 형식은 Boolean이기 때문에 결과적으로 그룹화 맵은 최대 두 개(true, false)의 그룹으로 분류되고 true, false 두 가지 요소의 스트림 리스트를 모두 유지한다는 것이 요소 분할의 장점이다.
@ToString
@Getter
@AllArgsConstructor
public class Order {
private String orderNumber;
private String orderProductName;
private Integer payPrice;
private Currency currency;
private Boolean isPayComplete;
}
List<Order> orders = new ArrayList<>() {{
add(new Order("0000001", "자전거", 1000000, Currency.getInstance(Locale.KOREA)), true);
add(new Order("0000002", "컴퓨터", 1000000, Currency.getInstance(Locale.KOREA)), false);
add(new Order("0000003", "에어컨", 3000000, Currency.getInstance(Locale.US)), true);
add(new Order("0000004", "냉장고", 5000000, Currency.getInstance(Locale.CHINA)), true);
}};
위 예제 코드에 isPayComplete(결제 완료 여부)라는 필드를 하나 추가하였다.
결제가 완료된 주문과 결제가 완료되지 않은 주문을 분류한다면 아래처럼 사용할 수 있다.
Map<Boolean, List<Order>> payCompletedOrders = orders.stream()
.collect(Collectors.partitioningBy(Order::isPayComplete));
// {false=[Order{orderNumber='0000002', orderProductName='컴퓨터', payPrice=1000000, currency=KRW, isPayComplete=false}], true=[Order{orderNumber='0000001', orderProductName='자전거', payPrice=1000000, currency=KRW, isPayComplete=true}, Order{orderNumber='0000003', orderProductName='에어컨', payPrice=3000000, currency=USD, isPayComplete=true}, Order{orderNumber='0000004', orderProductName='냉장고', payPrice=5000000, currency=CNY, isPayComplete=true}]}
물론 filter 메서드를 사용해 완료된 주문을 얻을 순 있지만 결제가 완료되지 않은 주문을 얻기 위해선 한번 더 연산이 필요하므로 참과 거짓의 스트림을 모두 기억해야 할 때는 Collectors.partitioningBy를 사용하는 것이 더 효율적이다.
Collectors.partitioningBy는 컬렉터를 두 번째 인수로 전달할 수 있는 overload된 버전의 partitioningBy도 제공한다.
Map<Boolean, Map<Currency, List<Order>>> payCompletedOrdersByCurrencies = orders.stream()
.collect(Collectors.partitioningBy(
Order::isPayComplete,
Collectors.groupingBy(Order::getCurrency)
));
// {false={KRW=[Order{orderNumber='0000002', orderProductName='컴퓨터', payPrice=1000000, currency=KRW, isPayComplete=false}]}, true={KRW=[Order{orderNumber='0000001', orderProductName='자전거', payPrice=1000000, currency=KRW, isPayComplete=true}], USD=[Order{orderNumber='0000003', orderProductName='에어컨', payPrice=3000000, currency=USD, isPayComplete=true}], CNY=[Order{orderNumber='0000004', orderProductName='냉장고', payPrice=5000000, currency=CNY, isPayComplete=true}]}}
위에서 확인해본 요소 그룹화랑 같아 보인다면 정확히 본 것이다.
분할은 참과 거짓 두 가지 키만 포함하는 특수한 종류의 그룹화라고 할 수 있다.
Collectors 클래스의 정적 팩토리 메서드
팩토리 메서드 | 반환 형식 | 설명 |
toList | List<T> | 스트림의 모든 항목을 리스트로 수집 |
toSet | Set<T> | 스트림의 모든 항목을 중복이 없는 집합으로 수집 |
toCollection | Collection<T> | 스트림의 모든 항목을 발행자가 제공하는 컬렉션으로 수집 |
counting | Long | 스트림의 항목 수 계산 |
summingInt | Integer | 스트림의 항목에서 정수 프로퍼티값을 더함 |
averagingInt | Double | 스트림 항목의 정수 프로퍼티의 평균값 계산 |
summarizingInt | IntSummaryStatistics | 스트림 내 항목의 최댓값, 최솟값, 합계, 평균 등의 정수 정보 통계 수집 |
joining | String | 스트림의 각 항목에 toString 메서드를 호출한 결과 문자열 연결 |
maxBy | Optional<T> | 주어진 비교자를 이용해서 스트림의 최댓값 요소를 Optional로 감싼 값을 반환 |
minBy | Optional<T> | 주어진 비교자를 이용해서 스트림의 최솟값 요소를 Optional로 감싼 값을 반환 |
reducing | The type produced by the reduction operation | 누적자를 초깃값으로 설정한 다음에 BinaryOperator로 스트름의 각 요소를 반복적으로 누적자와 합쳐 스트림을 하나의 값으로 리듀싱 |
collectingAndThen | The type returned by the transforming function | 다른 컬렉터를 감싸고 그 결과에 반환 함수 적용 |
groupingBy | Map<K, List<T>> | 하나의 프로퍼티값을 기준으로 스트림의 항목을 그룹화하며 기준 프로퍼티값을 결과 맵의 키로 사용 |
partitioningBy | Map<Boolean, List<T>> | Predicate를 스트림의 각 항목에 적용한 결과로 항목 분할 |
Collectors 클래스의 정적 팩토리 메서드는 아래에서 더욱 자세히 확인할 수 있다.
https://docs.oracle.com/javase/8/docs/api/java/util/stream/Collectors.html
'Java' 카테고리의 다른 글
[모던 자바] 병렬 데이터 처리와 성능 측정 (0) | 2022.03.08 |
---|---|
[모던 자바] Collector 인터페이스 소개 및 구현 예제 (0) | 2022.03.08 |
[모던 자바] 스트림(Stream) 만들기 (0) | 2022.03.04 |
[모던 자바] 스트림(Stream) 활용하기 (0) | 2022.03.03 |
[모던 자바] 스트림(Stream)이란 무엇인가? (0) | 2022.03.02 |