Collector 수집과 그룹화
Collector의 누산 규칙을 이해하고 중복 키, 그룹화, partitioning, `min`·최대값·통계·`reducing` 결과를 안전하게 수집합니다.
Stream 최종 연산인 collect는 원소를 가변 결과 컨테이너에 축적하고 필요하면 부분 결과를 결합·마무리합니다.
Collectors 팩터리는 List, Set, Map, 문자열, 그룹, 통계를 만드는 표준 Collector를 제공합니다.
원하는 컨테이너 모양뿐 아니라 중복 키와 빈 그룹 정책을 먼저 정해야 합니다.
toMap 중복 키에 대한 잘못된 가정
주제 첫 글자를 키로 쓰면 스트림과 상태가 같은 s에 충돌합니다.
merge 함수 없는 toMap은 실제 IllegalStateException을 던집니다.
import java.util.List;
import java.util.Map;
import java.util.stream.Collectors;
public final class DuplicateKeyCollectorFailure {
public static void main(String[] args) {
Map<Character, String> topics =
List.of("stream", "state", "lock").stream()
.collect(Collectors.toMap(value -> value.charAt(0), value -> value));
System.out.println(topics);
}
}중복이 도메인 오류라면 예외를 유지하되 메시지와 사전 입력 검사를 추가합니다.
여러 값을 보존해야 하면 groupingBy, 하나를 선택해야 하면 merge 함수, 발생 횟수를 구해야 하면 counting을 사용합니다.
조용히 (first, second) -> first로 덮으면 데이터 손실 정책이 숨겨집니다.
Collector의 네 함수와 생명 주기
공급자는 빈 누산기를 만들고, 누산기는 원소 하나를 담고, 결합 함수는 병렬 부분 결과를 합치며, 마무리 함수는 최종 타입으로 바꿉니다.
IDENTITY_FINISH 특성을 가진 수집기는 누산기 타입과 결과 타입이 같습니다.
순차 실행만 보고 결합 함수를 빈 구현으로 두면 병렬 실행에서 결과가 사라집니다.
수집기 특성인 CONCURRENT, UNORDERED, IDENTITY_FINISH를 실제 규칙과 다르게 표시해서는 안 됩니다.
표준 Collectors를 우선하고 사용자 정의 수집기는 결합 법칙을 검증합니다.
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Set;
import java.util.stream.Collectors;
public final class BasicCollectionCollectors {
public static void main(String[] args) {
List<String> source = List.of("stream", "lock", "stream");
List<String> list = source.stream().collect(Collectors.toList());
Set<String> insertionOrder =
source.stream().collect(Collectors.toCollection(LinkedHashSet::new));
String joined =
source.stream().distinct().sorted().collect(Collectors.joining(", ", "[", "]"));
System.out.println("list=" + list + ", set=" + insertionOrder + ", joined=" + joined);
}
}Collectors.toList()가 특정 가변 구현을 영구적으로 보장한다고 가정하지 않습니다.
가변 ArrayList가 필요하면 toCollection(ArrayList::new)로 규칙을 명시합니다.
Stream.toList()는 수정할 수 없는 결과를 반환합니다.
toMap 구성 요소
기본 형태는 키 매퍼와 값 매퍼를 받습니다.
세 번째 merge 함수는 같은 키의 기존 값과 새 값을 합치고, 네 번째 공급자는 LinkedHashMap·TreeMap 같은 구현을 선택합니다.
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.stream.Collectors;
public final class MapCollectorPolicies {
private record Session(String topic, int minutes) {}
public static void main(String[] args) {
List<Session> source =
List.of(
new Session("stream", 30),
new Session("lock", 20),
new Session("stream", 45));
Map<String, Integer> totals =
source.stream()
.collect(
Collectors.toMap(
Session::topic,
Session::minutes,
Integer::sum,
LinkedHashMap::new));
System.out.println(totals);
}
}스트림 값은 75로 합쳐지고 최초 키의 처리 순서가 유지됩니다.
merge 함수는 결합 법칙을 만족해야 병렬 그룹화 순서가 달라져도 안정적인 결과를 냅니다.
null 키와 값의 허용 여부는 수집기와 Map 구현의 요구 사항을 확인합니다.
groupingBy·partitioningBy
groupingBy는 분류 함수 결과마다 List를 기본값으로 만듭니다.
partitioningBy는 false와 true 두 boolean 키를 가지며 어느 한쪽이 빈 목록일 수도 있습니다.
이진 분류가 본질이라면 partitioningBy가 의도를 더 분명하게 드러냅니다.
import java.util.List;
import java.util.Map;
import java.util.stream.Collectors;
public final class GroupingAndPartitioning {
private record Entry(String track, String topic, int minutes) {}
public static void main(String[] args) {
List<Entry> entries =
List.of(
new Entry("java", "stream", 60),
new Entry("web", "css", 20),
new Entry("java", "lock", 35));
Map<String, List<Entry>> byTrack =
entries.stream().collect(Collectors.groupingBy(Entry::track));
Map<Boolean, List<Entry>> longStudy =
entries.stream().collect(Collectors.partitioningBy(entry -> entry.minutes() >= 40));
System.out.println(
"java-count="
+ byTrack.get("java").size()
+ ", long="
+ longStudy.get(true).size()
+ ", short="
+ longStudy.get(false).size());
}
}그룹화 결과 List를 외부에서 변경할 수 있게 할지는 API 경계에서 결정합니다.
불변 스냅샷이 필요하면 하위 수집기 collectingAndThen을 사용합니다.
minBy·maxBy와 Optional 값
그룹이 비어 있을 가능성이 있으므로 minBy와 maxBy는 Optional을 반환합니다.
groupingBy가 실제 원소가 있는 키만 만들 때 각 그룹의 Optional에는 보통 값이 있지만, 수집기를 단독으로 사용하거나 하위 수집기를 조합할 때도 반환 타입의 의미를 그대로 이해해야 합니다.
summarizingInt는 count, sum, min, 평균, 최대값을 한 순회에서 모읍니다.
averagingInt는 Double, summingInt는 Integer를 반환합니다.
합이 클 수 있다면 long의 오버플로 범위와 매퍼 타입을 확인합니다.
import java.util.Comparator;
import java.util.IntSummaryStatistics;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.stream.Collectors;
public final class CollectorStatisticsReport {
private record Session(String track, String topic, int minutes) {}
public static void main(String[] args) {
List<Session> sessions =
List.of(
new Session("java", "stream", 60),
new Session("java", "lock", 30),
new Session("web", "css", 45));
Map<String, IntSummaryStatistics> stats =
sessions.stream()
.collect(
Collectors.groupingBy(
Session::track,
Collectors.summarizingInt(Session::minutes)));
Map<String, Optional<Session>> longest =
sessions.stream()
.collect(
Collectors.groupingBy(
Session::track,
Collectors.maxBy(
Comparator.comparingInt(Session::minutes))));
System.out.println("java=" + stats.get("java"));
System.out.println("web-longest=" + longest.get("web").orElseThrow().topic());
}
}그룹별 통계와 최댓값을 별도 파이프라인으로 만들었습니다.
둘을 한 번의 순회에서 동시에 구해야 한다면 teeing 또는 사용자 정의 집계를 검토하되 단순성과 비용을 비교합니다.
reducing 수집기의 역할과 과용
Collectors.reducing은 groupingBy의 하위 수집기에서 각 그룹 값을 축약할 때 유용합니다.
단순 합은 summingInt, 문자열은 joining처럼 목적이 분명한 수집기가 더 읽기 쉽습니다.
항등값 없는 reducing은 Optional을 반환합니다.
가변 컨테이너를 reduce로 합치면 항등값 인스턴스의 공유와 병렬 결합 문제가 생길 수 있습니다.
컬렉션 구축에는 collect가 적합하고 불변 스칼라 값을 결합할 때 reduce를 씁니다.
Collector 결과 소유권과 API
수집기가 내부에 가변 Map을 만들었다고 해서 호출자에게 수정 권한이 자동으로 생기는 것은 아닙니다.
마무리 함수에서 Map.copyOf 또는 수정 불가능한 래퍼를 적용할 수 있습니다.
중첩된 List까지 불변인지도 확인합니다.
대량 그룹화에서는 키 수와 각 그룹이 차지하는 메모리가 커집니다.
파이프라인 앞쪽의 filter, 키 공간의 상한 설정, 스트리밍 집계, 데이터베이스의 GROUP BY 가운데 데이터 위치에 맞는 해법을 선택합니다.
연습 문제
Member의 부서와 점수 목록을 부서별 IntSummaryStatistics로 수집하고 인원, 총점, 최고점을 출력하세요.
원본에 실제 존재한 부서만 결과 키로 둡니다.
정답과 통계 수집기
groupingBy의 하위 수집기에 summarizingInt를 전달하면 각 그룹마다 통계 누산기가 하나 생깁니다.
import java.util.IntSummaryStatistics;
import java.util.List;
import java.util.Map;
import java.util.stream.Collectors;
public final class DepartmentScoreSummarySolution {
private record Member(String department, int score) {}
public static void main(String[] args) {
List<Member> members =
List.of(
new Member("platform", 80),
new Member("web", 70),
new Member("platform", 95));
Map<String, IntSummaryStatistics> result =
members.stream()
.collect(
Collectors.groupingBy(
Member::department,
Collectors.summarizingInt(Member::score)));
result.forEach(
(department, stats) ->
System.out.println(
department
+ ": count="
+ stats.getCount()
+ ", sum="
+ stats.getSum()
+ ", max="
+ stats.getMax()));
}
}플랫폼은 count 2, sum 175, 최대값 95이고 web은 count 1, sum 70, 최대값 70입니다.
Map 반복 순서는 기능 규칙에 포함하지 않았습니다.
Collector 선택은 최종 컨테이너 이름보다 중복 키, 그룹 키 공간, 하위 수집기의 값 타입, 변경 가능성, 결합 함수의 법칙을 정하는 일입니다.
표준 수집기로 이 규칙을 표현할 수 있다면 사용자 정의 누산기를 만들지 않습니다.