1. 소개
Stream API로 컬렉션을 다룰 때는 종종 단순히 요소를 그룹화하는 것뿐 아니라, 즉시 무언가를 수행해야 합니다: 변환하거나, 필터링하거나, 다른 타입의 컬렉션으로 수집하는 등. 이를 위해 Java에는 각 그룹이나 데이터 조각에 적용되는 downstream 컬렉터 — 중첩 컬렉터가 있습니다.
downstream 컬렉터란?
그룹화 또는 분할의 결과에 적용되는 컬렉터입니다. 예를 들어, groupingBy로 학생을 코스별로 그룹화하고, 각 그룹 안에서는 이름만 또는 우수 학생만(예: GPA 4.5 이상) 수집할 수 있습니다.
예시
mapping
그룹의 요소를 수집하기 전에 변환할 수 있습니다.
Map<Integer, List<String>> namesByCourse = students.stream()
.collect(Collectors.groupingBy(
Student::getCourse,
Collectors.mapping(Student::getName, Collectors.toList())
));
- 학생을 코스별로 그룹화합니다.
- 각 그룹에서는 전체 객체가 아니라 이름만 수집합니다.
filtering
그룹 내부에서 요소를 필터링할 수 있습니다(예: GPA가 >= 4.5).
Map<Integer, List<Student>> honorsByCourse = students.stream()
.collect(Collectors.groupingBy(
Student::getCourse,
Collectors.filtering(s -> s.getGpa() >= 4.5, Collectors.toList())
));
각 그룹에는 우수 학생만 남습니다.
flatMapping
그룹 내부의 중첩 컬렉션을 “펼쳐서” 평탄화할 수 있습니다.
Map<String, Set<String>> tagsByAuthor = books.stream()
.collect(Collectors.groupingBy(
Book::getAuthor,
Collectors.flatMapping(
book -> book.getTags().stream(),
Collectors.toSet()
)
));
각 저자에 대해 그의 모든 책의 고유 태그를 수집합니다.
partitioningBy와 downstream
groupingBy와 비슷하게 동작하지만, 불리언 조건으로 두 그룹으로 나눕니다.
Map<Boolean, List<String>> namesByPassed = students.stream()
.collect(Collectors.partitioningBy(
s -> s.getGpa() >= 3.0,
Collectors.mapping(Student::getName, Collectors.toList())
));
학생을 합격/불합격으로 나누고, 각 그룹에서는 이름만 수집합니다.
2. teeing: 두 컬렉터로 동시에 집계
가끔 스트림에 대해 여러 집계를 동시에 계산해야 합니다. 예를 들어 합계와 평균, 혹은 최소값과 최대값을 함께 구하고 싶을 때가 있습니다. 이를 위해 Java 12+에 teeing 컬렉터가 도입되었습니다.
teeing은 어떻게 동작하나요?
두 개의 컬렉터와 그 결과를 결합하는 함수를 전달합니다.
구문:
Collectors.teeing(collector1, collector2, (result1, result2) -> ...)
예시
최소 + 최대
Optional<MinMax> minMax = numbers.stream()
.collect(Collectors.teeing(
Collectors.minBy(Integer::compareTo),
Collectors.maxBy(Integer::compareTo),
(min, max) -> min.isPresent() && max.isPresent() ? new MinMax(min.get(), max.get()) : null
));
최소와 최대를 동시에 구합니다.
합계 + 평균
Result result = numbers.stream()
.collect(Collectors.teeing(
Collectors.summingInt(Integer::intValue),
Collectors.averagingInt(Integer::intValue),
(sum, avg) -> new Result(sum, avg)
));
합계와 평균 값을 담은 객체를 얻습니다.
예시: 급여 리포트
SalaryStats stats = employees.stream()
.collect(Collectors.teeing(
Collectors.summingInt(Employee::getSalary),
Collectors.averagingInt(Employee::getSalary),
SalaryStats::new
));
SalaryStats에 합계와 평균을 모두 저장합니다.
3. toUnmodifiableList/Set/Map 및 collectingAndThen으로 “동결”하기
최신 Java 버전에는 생성 후 변경할 수 없는 불변(unmodifiable) 컬렉션이 있습니다. 이는 결과가 실수로 변경되지 않도록 해야 하는 API에 유용합니다.
toUnmodifiableList/Set/Map
- 불변 컬렉션을 반환합니다.
- 요소를 추가/삭제하려는 모든 시도는 UnsupportedOperationException을 발생시킵니다.
예시:
List<String> names = students.stream()
.map(Student::getName)
.collect(Collectors.toUnmodifiableList());
Map<Integer, Student> byId = students.stream()
.collect(Collectors.toUnmodifiableMap(Student::getId, Function.identity()));
collectingAndThen
컬렉터의 결과에 함수를 적용할 수 있습니다 — 예를 들어 컬렉션을 “동결”할 수 있습니다.
List<String> names = students.stream()
.map(Student::getName)
.collect(Collectors.collectingAndThen(
Collectors.toList(),
Collections::unmodifiableList
));
먼저 일반 리스트로 수집하고, 이후 불변으로 만듭니다.
Set 예시:
Set<String> tags = books.stream()
.flatMap(book -> book.getTags().stream())
.collect(Collectors.collectingAndThen(
Collectors.toSet(),
Set::copyOf // Java 10+
));
4. 파이프라인 사례
리포트와 분할 통계
advanced 컬렉터를 사용하면 복잡한 리포트와 통계를 “한 줄”로 구성할 수 있습니다.
예시: 부서별 평균 급여
Map<String, Double> avgSalaryByDept = employees.stream()
.collect(Collectors.groupingBy(
Employee::getDepartment,
Collectors.averagingInt(Employee::getSalary)
));
예시: 카테고리별 최고가 상품 Top 3
Map<String, List<Product>> top3ByCategory = products.stream()
.collect(Collectors.groupingBy(
Product::getCategory,
Collectors.collectingAndThen(
Collectors.toList(),
list -> list.stream()
.sorted(Comparator.comparing(Product::getPrice).reversed())
.limit(3)
.toList()
)
));
API 계약으로서의 불변 결과
메서드가 변경할 수 없는 컬렉션을 반환하면, 우발적인 실수를 방지하고 API를 더 견고하게 만듭니다.
public List<String> getTags() {
return tags.stream()
.collect(Collectors.toUnmodifiableList());
}
사용자는 getTags().add("새 태그")를 호출할 수 없으며 — 예외가 발생합니다.
예시: 여러 집계를 포함한 리포트 (teeing)
public SalaryReport getSalaryReport(List<Employee> employees) {
return employees.stream()
.collect(Collectors.teeing(
Collectors.averagingInt(Employee::getSalary),
Collectors.summingInt(Employee::getSalary),
SalaryReport::new
));
}
SalaryReport에 평균과 총급여를 함께 저장합니다.
5. 흔한 오류와 주의사항
오류 №1: 불변성을 잊음. 일반 리스트를 반환하면 외부에서 변경될 수 있습니다. 결과를 “동결”하려면 toUnmodifiableList/Set/Map 또는 collectingAndThen을 사용하세요.
오류 №2: 잘못된 downstream 컬렉터 선택. 그룹 내부에서 요소를 변환해야 한다면 mapping, 필터링해야 한다면 filtering, 중첩 컬렉션을 “펼쳐야” 한다면 flatMapping을 사용하세요.
오류 №3: UnsupportedOperationException. toUnmodifiableList/Set/Map으로 수집했거나 collectingAndThen으로 “동결”한 컬렉션을 변경하려 할 때 발생합니다.
오류 №4: 유일성 손실/키 충돌. toUnmodifiableSet은 요소가 유일해야 하며, toUnmodifiableMap은 키가 유일해야 합니다. 그렇지 않으면 수집 중 예외가 발생합니다.
GO TO FULL VERSION