Collector 수집과 그룹화
Collector의 누산 규칙을 이해하고 중복 키·그룹화·partitioning·min·최댓값·통계·reducing 결과를 안전하게 수집합니다.
Stream 최종 연산인 collect는 원소를 가변 결과 컨테이너에 축적하고 필요하면 부분 결과를 결합·마무리합니다.
Collectors 팩터리는 List, Set, Map, 문자열, 그룹, 통계를 만드는 표준 Collector를 제공합니다.
원하는 컨테이너 모양뿐 아니라 중복 키와 빈 그룹 정책을 먼저 정해야 합니다.
toMap 중복 키에 대한 잘못된 가정
제목 첫 글자를 키로 쓰면 notice와 news가 같은 n에 충돌합니다.
merge 함수 없는 toMap은 중복 키를 만나면 API 계약상 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> titles =
List.of("notice", "news", "question").stream()
.collect(Collectors.toMap(value -> value.charAt(0), value -> value));
System.out.println(titles);
}
}중복이 도메인 오류라면 예외를 유지하되 메시지와 사전 입력 검사를 추가합니다.
여러 값을 보존해야 하면 groupingBy, 하나를 선택해야 하면 merge 함수, 발생 횟수를 구해야 하면 counting을 사용합니다.
조용히 (first, second) -> first로 덮으면 데이터 손실 정책이 숨겨집니다.
예시 예제의 순차 main은 notice의 키 n을 만든 뒤 news도 n으로 매핑합니다. 값 문자열이 서로 달라도 키가 같아 두 번째 값에서 수집이 실패합니다. 제목 전체를 키로 바꾸면 이 입력의 충돌은 없지만, 첫 글자별 모든 제목을 찾으려는 요구에는 맞지 않습니다. 오류를 없애는 코드와 원하는 결과 모양을 먼저 나눠 보세요.
독립 확인: 같은 세 제목에서 첫 글자를 키로 유지합니다. (first, second) -> first로 수집할 때 n의 값과, groupingBy로 수집할 때 n의 목록을 구하세요. 앞의 방식으로 news를 다시 찾아낼 수 있나요?
풀이 확인
첫 방식은 n에 notice 하나만 남고 q에는 question이 남습니다. 그룹화는 n에 [notice,news] 두 제목, q에 [question] 한 제목을 둡니다. 첫 값 선택은 news를 버리는 정책이므로 이 결과 Map만으로 두 값을 되찾을 수 없습니다. 병렬 실행이나 키 출력 순서의 별도 보장으로 확대하지 않고 기존 순서의 값 선택을 비교한 문제입니다.
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("notice", "event", "notice");
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()는 수정할 수 없는 결과를 반환합니다.
예제의 BasicCollectionCollectors의 list는 [notice,event,notice] 세 값을 유지합니다. LinkedHashSet은 처음 만난 notice·event 두 값만 같은 삽입 순서로 남기고, joined는 중복 제거 뒤 사전순으로 정렬해 [event, notice]입니다. List·Set·문자열이 세 단계의 중간 결과가 아니라, 같은 원본을 별도 Stream으로 소비한 세 결과라는 점도 확인하세요.
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 PostMetric(String title, int viewCount) {}
public static void main(String[] args) {
List<PostMetric> source =
List.of(
new PostMetric("welcome", 30),
new PostMetric("question", 20),
new PostMetric("welcome", 45));
Map<String, Integer> totals =
source.stream()
.collect(
Collectors.toMap(
PostMetric::title,
PostMetric::viewCount,
Integer::sum,
LinkedHashMap::new));
System.out.println(totals);
}
}welcome의 조회 수는 30 + 45 = 75로 합쳐지고, LinkedHashMap은 키가 처음 삽입된 순서를 유지합니다.
merge 함수는 결합 법칙을 만족해야 병렬 그룹화 순서가 달라져도 안정적인 결과를 냅니다.
예제의 합산에는 welcome을 처음 만난 30에 다시 만난 45를 더해 75로 갱신하는 단계가 있습니다. question20은 다른 키의 독립 값이며, 예제의 LinkedHashMap에서 welcome이 첫 키로 남습니다. 숫자 합산을 원하는 요구와 마지막 값 45로 덮는 요구는 서로 다릅니다. Collector의 identity·associativity 조건은 빈 부분 결과를 합치거나 입력을 나눠 계산해도 결과가 같아야 한다는 범위를 설명합니다.
null 키와 값의 허용 여부는 수집기와 Map 구현의 요구 사항을 확인합니다.
groupingBy·partitioningBy
분류 이름별 목록과 조회 수 조건의 참·거짓 분할은 결과 키의 공간이 다릅니다. 아래 코드는 같은 게시글을 두 방식으로 수집합니다.
import java.util.List;
import java.util.Map;
import java.util.stream.Collectors;
public final class GroupingAndPartitioning {
private record PostMetric(String category, String title, int viewCount) {}
public static void main(String[] args) {
List<PostMetric> posts =
List.of(
new PostMetric("notice", "welcome", 60),
new PostMetric("free", "question", 20),
new PostMetric("notice", "event", 35));
Map<String, List<PostMetric>> byCategory =
posts.stream().collect(Collectors.groupingBy(PostMetric::category));
Map<Boolean, List<PostMetric>> popularPosts =
posts.stream()
.collect(Collectors.partitioningBy(post -> post.viewCount() >= 40));
System.out.println(
"notice-count="
+ byCategory.get("notice").size()
+ ", popular="
+ popularPosts.get(true).size()
+ ", regular="
+ popularPosts.get(false).size());
}
}GroupingAndPartitioning에서 분류별 그룹과 조회 수 40 이상 분할의 키를 비교합니다.
분류 키와 두 분할 키
빈 입력에서도 두 분할 키는 남지만 분류 키는 생기지 않는다
groupingBy는 원본에 등장한 분류를 만듭니다. partitioningBy는 입력이 없어도 true·false 두 키를 남깁니다.
groupingBy(category)
- main의 게시글 세 개
- notice 2개 · free 1개
- 게시글이 없는 경우
- 키 없음
partitioningBy(조회 수 ≥ 40)
- main의 게시글 세 개
- true 1개 · false 2개
- 게시글이 없는 경우
- true 빈 목록 · false 빈 목록
첫 행은 원문 입력의 계산, 둘째 행은 표준 수집기의 빈 입력 계약입니다. Map의 키 출력 순서는 이 표의 순서를 보장하지 않습니다.
예제의 category 분류에서 welcome60·event35는 notice에, question20은 free에 모입니다. 조회 수 >=40 분할에서는 welcome만 true이고 다른 둘은 false입니다. 분류 이름과 boolean 결과를 같은 종류의 키로 읽지 않습니다.
독립 확인: 같은 두 수집기에 빈 입력을 주었을 때 키와 각 값은 무엇인가요? 별도로 notice 게시글 두 개의 조회 수가 모두 40 미만이면 groupingBy(category)와 partitioningBy(>=40)의 키는 어떻게 달라지나요?
풀이 확인
빈 groupingBy 결과에는 키가 없고 partitioningBy에는 true·false 키가 모두 있으며 두 값은 빈 목록입니다. 별도 입력의 분류 결과에는 notice 하나가 있고 목록 크기는 2입니다. 분할 결과는 true 빈 목록·false 원소 2개로 두 키가 유지됩니다. true 쪽이 비었다는 이유로 true 키가 없다고 읽지 않습니다. Map의 키 반복 순서를 요구한 문제가 아니며 partitioningBy의 두 키 계약은 빈 분할의 결과를 명시합니다.
그룹화 결과 List를 외부에서 변경할 수 있게 할지는 API 경계에서 결정합니다.
목록 구조를 수정할 수 없게 반환하려면 하위 수집기에 collectingAndThen(toList(), List::copyOf)처럼 마무리 정책을 명시합니다.
minBy·maxBy와 Optional 값
그룹이 비어 있을 가능성이 있으므로 minBy와 maxBy는 Optional을 반환합니다.
groupingBy가 실제 원소가 있는 키만 만들 때 각 그룹의 Optional에는 보통 값이 있지만, 수집기를 단독으로 사용하거나 하위 수집기를 조합할 때도 반환 타입의 의미를 그대로 이해해야 합니다.
summarizingInt는 count, sum, min, 평균, 최댓값을 한 순회에서 모읍니다.
averagingInt는 Double, summingInt는 Integer를 반환합니다.
summingInt의 합은 int, summarizingInt의 합은 long입니다. 필요한 범위에 맞게 summingLong 등을 선택하되 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 PostMetric(String category, String title, int viewCount) {}
public static void main(String[] args) {
List<PostMetric> posts =
List.of(
new PostMetric("notice", "welcome", 60),
new PostMetric("notice", "event", 30),
new PostMetric("free", "question", 45));
Map<String, IntSummaryStatistics> stats =
posts.stream()
.collect(
Collectors.groupingBy(
PostMetric::category,
Collectors.summarizingInt(PostMetric::viewCount)));
Map<String, Optional<PostMetric>> mostViewed =
posts.stream()
.collect(
Collectors.groupingBy(
PostMetric::category,
Collectors.maxBy(
Comparator.comparingInt(PostMetric::viewCount))));
System.out.println("notice=" + stats.get("notice"));
System.out.println(
"free-most-viewed=" + mostViewed.get("free").orElseThrow().title());
}
}그룹별 통계와 최댓값을 별도 파이프라인으로 만들었습니다.
둘을 한 번의 순회에서 동시에 구해야 한다면 teeing 또는 사용자 정의 집계를 검토하되 단순성과 비용을 비교합니다.
예제의 통계에서 notice는 60·30 두 값이라 count 2·sum 90·min 30·max 60·average 45.0입니다. free는 45 하나이므로 최댓값의 제목은 question입니다. 통계 객체와 Optional 게시글은 서로 다른 하위 수집기의 반환 타입입니다. 별도로 빈 Stream에 maxBy를 적용하면 Optional.empty()이며, 존재하지 않는 category의 Map 조회가 null인 상황과 구별해야 합니다.
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 반복 순서는 기능 규칙에 포함하지 않았습니다.
독립 확인: 부서별 통계 연습에서 platform 점수 두 개를 각각 Integer.MAX_VALUE로 바꾸고 web70은 유지합니다. platform의 count·sum·max를 구하고, 같은 두 점수를 summingInt로 합산한 값과 비교하세요. 원본에 없는 sales 키도 생기나요?
풀이 확인
summarizingInt의 platform은 count 2·sum 4294967294·max 2147483647입니다. long인 sum에 두 int 값을 모은 결과입니다. summingInt의 int 합은 범위를 넘어서 -2로 순환하므로 동일한 총점이 아닙니다. sales 키는 원본에 없어 생기지 않습니다. 입력 값의 타입이 int라는 사실만으로 합산 결과 타입과 충분한 범위를 보장하지 않습니다.
Collector 선택은 최종 컨테이너 이름보다 중복 키, 그룹 키 공간, 하위 수집기의 값 타입, 변경 가능성, 결합 함수의 법칙을 정하는 일입니다.
표준 수집기로 이 규칙을 표현할 수 있다면 사용자 정의 누산기를 만들지 않습니다.