본문으로 건너뛰기
Advertisement

14.4 Collectors 심화

Collectors 클래스는 스트림의 collect() 최종 연산에 사용하는 수집 전략을 제공합니다. 기본적인 toList(), joining()을 넘어, 데이터를 그룹화하고 집계하는 강력한 기능들을 익혀봅니다.

1. 기본 수집기 복습

import java.util.*;
import java.util.stream.*;

List<String> fruits = List.of("apple", "banana", "cherry", "apricot", "blueberry");

// toList() - Java 16+ 간단 버전 vs Collectors.toList()
List<String> list1 = fruits.stream().collect(Collectors.toList()); // 수정 가능
List<String> list2 = fruits.stream().toList(); // 불변 (Java 16+)

// toSet()
Set<String> set = fruits.stream().collect(Collectors.toSet());

// joining - 문자열로 연결
String joined = fruits.stream().collect(Collectors.joining(", "));
// "apple, banana, cherry, apricot, blueberry"

String withBrackets = fruits.stream()
.collect(Collectors.joining(", ", "[", "]"));
// "[apple, banana, cherry, apricot, blueberry]"

2. groupingBy - 그룹화

데이터를 특정 기준으로 Map으로 그룹화합니다.

record Person(String name, int age, String city, double salary) {}

List<Person> people = List.of(
new Person("Alice", 28, "서울", 5000),
new Person("Bob", 35, "부산", 6000),
new Person("Charlie", 28, "서울", 7000),
new Person("Dave", 35, "서울", 4500),
new Person("Eve", 22, "대구", 3500)
);

// 도시별 그룹화
Map<String, List<Person>> byCity = people.stream()
.collect(Collectors.groupingBy(Person::city));
// {서울=[Alice, Charlie, Dave], 부산=[Bob], 대구=[Eve]}

byCity.forEach((city, persons) -> {
System.out.println(city + ": " + persons.stream()
.map(Person::name).collect(Collectors.joining(", ")));
});

// 나이별 그룹화 + 이름만 추출 (다운스트림 수집기)
Map<Integer, List<String>> namesByAge = people.stream()
.collect(Collectors.groupingBy(
Person::age, // 분류 기준
Collectors.mapping(Person::name, Collectors.toList()) // 다운스트림
));
System.out.println(namesByAge);
// {22=[Eve], 28=[Alice, Charlie], 35=[Bob, Dave]}

// 그룹별 카운트
Map<String, Long> countByCity = people.stream()
.collect(Collectors.groupingBy(Person::city, Collectors.counting()));
System.out.println(countByCity); // {서울=3, 부산=1, 대구=1}

// 그룹별 평균 급여
Map<String, Double> avgSalaryByCity = people.stream()
.collect(Collectors.groupingBy(Person::city,
Collectors.averagingDouble(Person::salary)));
System.out.println(avgSalaryByCity); // {서울=5500.0, 부산=6000.0, 대구=3500.0}

다중 레벨 그룹화

// 도시별 → 나이별 중첩 그룹화
Map<String, Map<Integer, List<Person>>> byCityAndAge = people.stream()
.collect(Collectors.groupingBy(Person::city,
Collectors.groupingBy(Person::age)));

byCityAndAge.forEach((city, ageMap) -> {
System.out.println("=== " + city + " ===");
ageMap.forEach((age, persons) ->
System.out.println(" " + age + "세: " + persons.stream()
.map(Person::name).collect(Collectors.joining(", "))));
});

3. partitioningBy - 두 그룹으로 분할

boolean 조건으로 true/false 두 그룹으로 분류합니다.

// 고연봉자(5000 이상)와 저연봉자 분리
Map<Boolean, List<Person>> partitioned = people.stream()
.collect(Collectors.partitioningBy(p -> p.salary() >= 5000));

System.out.println("고연봉: " + partitioned.get(true).stream()
.map(Person::name).collect(Collectors.joining(", ")));
// 고연봉: Alice, Bob, Charlie

System.out.println("저연봉: " + partitioned.get(false).stream()
.map(Person::name).collect(Collectors.joining(", ")));
// 저연봉: Dave, Eve

// 수로 분할 (소수 vs 합성수)
List<Integer> nums = IntStream.rangeClosed(2, 20).boxed().toList();
Map<Boolean, List<Integer>> primes = nums.stream()
.collect(Collectors.partitioningBy(n -> {
for (int i = 2; i * i <= n; i++)
if (n % i == 0) return false;
return true;
}));
System.out.println("소수: " + primes.get(true));
// 소수: [2, 3, 5, 7, 11, 13, 17, 19]

4. toMap - Map으로 변환

// toMap(keyMapper, valueMapper)
Map<String, Double> nameSalaryMap = people.stream()
.collect(Collectors.toMap(
Person::name, // 키
Person::salary // 값
));
// {Alice=5000.0, Bob=6000.0, ...}

// 충돌 처리 (같은 키 발생 시)
Map<String, Double> avgSalaryMap = people.stream()
.collect(Collectors.toMap(
Person::city,
Person::salary,
(existing, replacement) -> (existing + replacement) / 2 // 평균
));

// 순서 유지 (LinkedHashMap으로 수집)
Map<String, Double> orderedMap = people.stream()
.collect(Collectors.toMap(
Person::name,
Person::salary,
(a, b) -> a, // 충돌 시 첫 번째 값 유지
LinkedHashMap::new // Map 구현체 지정
));

5. 통계 수집기

// summarizingDouble - 한 번에 여러 통계
DoubleSummaryStatistics stats = people.stream()
.collect(Collectors.summarizingDouble(Person::salary));

System.out.println("개수: " + stats.getCount()); // 5
System.out.println("합계: " + stats.getSum()); // 26000.0
System.out.println("평균: " + stats.getAverage()); // 5200.0
System.out.println("최솟값: " + stats.getMin()); // 3500.0
System.out.println("최댓값: " + stats.getMax()); // 7000.0

// 그룹별 통계
Map<String, DoubleSummaryStatistics> statsByCity = people.stream()
.collect(Collectors.groupingBy(Person::city,
Collectors.summarizingDouble(Person::salary)));

6. 커스텀 Collector

직접 수집기를 만들 수 있습니다 (고급 기능).

// 문자열 리스트를 단어 빈도수 Map으로 수집하는 커스텀 수집기
List<String> words = List.of("hello", "world", "hello", "java", "world", "hello");

// 방법 1: Collectors.toMap + merge 함수
Map<String, Long> wordCount = words.stream()
.collect(Collectors.groupingBy(w -> w, Collectors.counting()));
System.out.println(wordCount); // {hello=3, world=2, java=1}

// 방법 2: Collector.of로 커스텀 수집기
Collector<String, Map<String, Integer>, Map<String, Integer>> wordFreqCollector =
Collector.of(
HashMap::new, // supplier
(map, word) -> map.merge(word, 1, Integer::sum), // accumulator
(m1, m2) -> { m1.putAll(m2); return m1; }, // combiner
Collector.Characteristics.IDENTITY_FINISH // characteristics
);

Map<String, Integer> freq = words.stream().collect(wordFreqCollector);
System.out.println(freq); // {hello=3, world=2, java=1}

고수 팁

groupingBy 결과를 정렬된 Map으로 받기:

// TreeMap으로 키를 자동 정렬
Map<String, Long> sorted = people.stream()
.collect(Collectors.groupingBy(
Person::city,
TreeMap::new, // Map 구현체 지정
Collectors.counting()
));
// {대구=1, 부산=1, 서울=3} (사전 순)

Collectors.teeing (Java 12+): 스트림을 두 수집기로 동시에 처리하고 결과를 합칩니다.

// 최솟값과 최댓값을 한 번에
record MinMax(double min, double max) {}

MinMax result = people.stream()
.collect(Collectors.teeing(
Collectors.minBy(Comparator.comparingDouble(Person::salary)),
Collectors.maxBy(Comparator.comparingDouble(Person::salary)),
(min, max) -> new MinMax(
min.map(Person::salary).orElse(0.0),
max.map(Person::salary).orElse(0.0)
)
));
System.out.println(result); // MinMax[min=3500.0, max=7000.0]
Advertisement