Collectors로 List·Map 만들고 그룹으로 묶기
collect가 원소를 모으는 방식을 이해하고, toList·toMap·groupingBy·partitioningBy와 통계 수집기로 원하는 모양의 결과를 만드는 법을 배웁니다.
게시글 목록을 스트림으로 걸러 냈다면 이제 결과를 어딘가에 담아야 합니다. 화면에 보여 줄 List가 필요할 때도 있고, 제목으로 찾아볼 Map이나 분류별로 묶은 목록, 조회 수 통계가 필요할 때도 있습니다. 최종 연산과 기본형 스트림에서 만난 최종 연산 collect가 이 일을 합니다. collect는 무엇을 어떻게 모을지 정한 수집기(collector)를 인수로 받는데, 자주 쓰는 수집기는 java.util.stream.Collectors 클래스에 미리 만들어져 있습니다.
수집기를 고를 때는 결과 타입만 정하면 끝나지 않습니다. 같은 키가 두 번 나오면 어떻게 할지, 원소가 하나도 없는 그룹을 결과에 남길지, 결과를 받은 쪽이 그것을 고쳐도 되는지를 함께 정해야 합니다. 이 글은 collect가 원소를 모으는 방식부터 살펴본 뒤 List·Map·그룹·통계로 모으는 수집기를 차례로 다루면서, 예제마다 이 질문들에 답해 봅니다.
collect가 원소를 모으는 네 단계
List로 모으는 일을 직접 한다면 순서는 이렇습니다. 빈 ArrayList를 하나 만들고, 원소가 올 때마다 add로 넣고, 다 넣었으면 그 리스트를 돌려줍니다. collect도 같은 순서로 일하는데, 각 단계에서 할 일을 함수로 받아 둔다는 점이 다릅니다. Stream에는 이 함수들을 직접 넘기는 collect도 있으니 먼저 그것으로 확인해 보겠습니다.
import java.util.ArrayList;
import java.util.List;
public final class ManualCollect {
public static void main(String[] args) {
List<String> titles = List.of("welcome", "event", "question");
ArrayList<String> longTitles =
titles.stream()
.filter(title -> title.length() > 5)
.collect(ArrayList::new, ArrayList::add, ArrayList::addAll);
System.out.println(longTitles);
}
}다섯 글자보다 긴 제목만 리스트에 담아 출력하는 프로그램입니다. 실행하면 다음과 같이 나옵니다.
[welcome, question]welcome(7글자)과 question(8글자)만 filter를 통과했고, 다섯 글자인 event는 length() > 5를 만족하지 못했습니다. collect에 넘긴 세 함수는 각각 다른 일을 합니다. ArrayList::new는 원소를 담을 빈 그릇, 즉 결과 컨테이너(result container)를 만듭니다. ArrayList::add는 원소 하나를 컨테이너에 넣고, ArrayList::addAll은 컨테이너 두 개를 하나로 합칩니다.
마지막 함수는 이 예제 같은 순차 스트림에서는 호출되지 않습니다. 병렬 스트림은 원본을 여러 조각으로 나눠 조각마다 따로 컨테이너를 채운 뒤 마지막에 그 컨테이너들을 합쳐야 하는데, 그때 이 함수가 쓰입니다. 조각마다 자기 컨테이너를 쓰므로 여러 스레드가 같은 리스트를 동시에 고칠 일이 없습니다. forEach 안에서 바깥 리스트에 add하는 방식과 가장 크게 다른 점이 이것이며, 그 차이는 글 뒤쪽의 '더 깊이'에서 직접 확인합니다.
Collectors가 돌려주는 수집기는 Collector 인터페이스의 객체입니다. 위의 세 함수에 마무리 함수 하나를 더해 네 함수를 한데 묶어 둔 것이라고 보면 됩니다.
| 함수 | 하는 일 | ArrayList로 모을 때 |
|---|---|---|
| 공급자(supplier) | 빈 결과 컨테이너를 만든다 | new ArrayList<>() |
| 누산 함수(accumulator) | 원소 하나를 컨테이너에 반영한다 | list.add(element) |
| 결합 함수(combiner) | 두 컨테이너를 하나로 합친다 | left.addAll(right) |
| 마무리 함수(finisher) | 다 채운 컨테이너를 최종 결과로 바꾼다 | 리스트를 그대로 돌려준다 |
마무리 함수가 따로 있는 이유는 모으는 동안 다루기 편한 형태와 최종 결과의 형태가 다를 수 있기 때문입니다. 예를 들어 문자열을 이어 붙이는 joining은 모으는 동안 글자를 덧붙일 수 있는 가변 객체(JDK 구현에서는 StringBuilder나 StringJoiner)를 쓰다가, 마지막에 그것을 String으로 바꿉니다. 리스트처럼 컨테이너가 곧 결과라면 마무리 함수는 아무것도 하지 않습니다. 이런 수집기는 IDENTITY_FINISH라는 특성(characteristic)을 붙여 두어, 스트림이 마무리 단계를 건너뛸 수 있게 합니다.
List·Set·문자열로 모으기
가장 자주 쓰는 수집기 세 가지를 한 원본에 적용해 보겠습니다. 중복을 어떻게 다루는지 비교하려고 원본에 notice를 두 번 넣었습니다.
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);
}
}세 결과 가운데 joined만 distinct()와 sorted()를 거친 스트림에서 모았다는 점을 기억해 두세요. 세 결과를 한 줄에 이어 출력하므로, 실행 결과도 한 줄입니다.
list=[notice, event, notice], set=[notice, event], joined=[event, notice]toList()는 원소를 만난 순서 그대로, 중복까지 모두 담습니다. toCollection(LinkedHashSet::new)는 우리가 넘긴 LinkedHashSet::new를 공급자로 써서 그 컬렉션에 원소를 담습니다. LinkedHashSet은 중복을 버리면서 처음 넣은 순서를 기억하므로 notice와 event 두 개만 이 순서로 남습니다. 세 번째 joined는 리스트처럼 보이지만 String입니다. distinct()로 중복을 지우고 sorted()로 사전순 정렬한 뒤, joining(", ", "[", "]")이 원소 사이에 쉼표와 공백을 넣고 앞뒤에 대괄호를 붙였습니다.
세 결과는 같은 source에서 스트림을 세 번 새로 만들어 얻은 것이지, 한 파이프라인의 중간 결과가 아닙니다. 스트림은 한 번만 소비할 수 있으므로 결과마다 source.stream()을 다시 호출했습니다.
어떤 List가 돌아오는지 정해 두기
List를 얻는 방법은 여러 가지이고, 돌아온 리스트를 고칠 수 있는지가 서로 다릅니다. 아래 표의 동작은 JDK 21에서 직접 확인한 것입니다.
| 방법 | add로 고칠 수 있나 | null 원소 |
|---|---|---|
Collectors.toList() | 명세가 정하지 않음 (JDK 21은 ArrayList를 돌려줘서 가능) | 허용 |
Collectors.toCollection(ArrayList::new) | 가능 | 허용 |
Collectors.toUnmodifiableList() (Java 10+) | 불가능 (UnsupportedOperationException) | NullPointerException |
Stream.toList() (Java 16+) | 불가능 (UnsupportedOperationException) | 허용 |
눈여겨볼 것은 첫 줄입니다. Collectors.toList()의 Javadoc은 돌려주는 List의 타입과 수정 가능 여부를 보장하지 않는다고 적고, 더 많은 제어가 필요하면 toCollection을 쓰라고 안내합니다. 지금 JDK에서 add가 된다고 그 동작에 기대면, 구현이 바뀌었을 때 코드가 깨질 수 있습니다. 그러니 결과에 원소를 더 넣을 계획이면 toCollection(ArrayList::new)로 의도를 드러내고, 읽기만 할 결과라면 Stream.toList()처럼 고칠 수 없는 리스트를 받는 편이 안전합니다.
toMap으로 Map 만들기
toMap은 원소마다 키와 값을 하나씩 만들어 Map에 넣습니다. 키를 만드는 함수와 값을 만드는 함수만 주면 되니 간단해 보이지만, 같은 키가 두 번 나오는 순간 문제가 생깁니다.
같은 키가 두 번 나오면 예외가 난다
제목의 첫 글자를 키로, 제목을 값으로 하는 Map을 만들어 보겠습니다. 문제는 notice와 news가 둘 다 n으로 시작한다는 점입니다.
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);
}
}이 프로그램은 Map을 출력하지 못하고 예외로 끝납니다. 스택 트레이스의 중간 부분을 생략하면 다음과 같습니다.
Exception in thread "main" java.lang.IllegalStateException: Duplicate key n (attempted merging values notice and news)
at java.base/java.util.stream.Collectors.duplicateKeyException(Collectors.java:135)
at java.base/java.util.stream.Collectors.lambda$uniqKeysMapAccumulator$1(Collectors.java:182)
...
at DuplicateKeyCollectorFailure.main(DuplicateKeyCollectorFailure.java:9)인수가 두 개인 toMap(keyMapper, valueMapper)는 원소마다 키 매퍼와 값 매퍼를 호출해 항목을 넣습니다. notice가 키 n으로 들어간 뒤 news도 키 n을 만들자, 이미 있는 키를 어떻게 처리할지 정한 규칙이 없으므로 IllegalStateException을 던졌습니다. 예외 메시지에는 충돌한 키 n과 두 값 notice, news가 함께 나옵니다. 한쪽 값을 조용히 덮어쓰지 않고 예외를 던지는 이유는, 데이터가 몰래 사라지는 쪽보다 프로그램이 멈추는 쪽이 문제를 찾기 훨씬 쉽기 때문입니다.
이 예외를 없애는 방법은 요구 사항에 따라 다릅니다. 첫 글자별로 제목을 모두 보존해야 하면 groupingBy로 묶습니다. 하나만 남겨야 하면 toMap의 세 번째 인수인 병합 함수(merge function)로 무엇을 남길지 정하고, 개수가 필요하면 값을 1로 만들어 더합니다. 세 방법을 한 번에 비교해 보겠습니다.
import java.util.List;
import java.util.Map;
import java.util.stream.Collectors;
public final class DuplicateKeyChoices {
public static void main(String[] args) {
List<String> titles = List.of("notice", "news", "question");
Map<Character, List<String>> all =
titles.stream().collect(Collectors.groupingBy(value -> value.charAt(0)));
Map<Character, String> firstOnly =
titles.stream()
.collect(
Collectors.toMap(
value -> value.charAt(0),
value -> value,
(first, second) -> first));
Map<Character, Integer> counts =
titles.stream()
.collect(
Collectors.toMap(
value -> value.charAt(0), value -> 1, Integer::sum));
System.out.println("모두 보존: " + all);
System.out.println("첫 값만: " + firstOnly);
System.out.println("개수: " + counts);
}
}세 수집기는 모두 제목의 첫 글자를 키로 쓰므로, notice와 news가 같은 키 n에서 만납니다. 세 수집 결과를 한 줄씩 출력합니다.
모두 보존: {q=[question], n=[notice, news]}
첫 값만: {q=question, n=notice}
개수: {q=1, n=2}첫 줄은 n 아래에 두 제목이 모두 남았습니다. 두 번째 줄의 병합 함수 (first, second) -> first는 이미 있던 값과 새 값을 받아 이미 있던 값을 돌려주므로, news는 결과 어디에도 남지 않습니다. 이 Map만 봐서는 news가 있었다는 사실조차 알 수 없으니, 정말 버려도 되는 데이터일 때만 이렇게 씁니다. 세 번째 줄은 원소마다 값 1을 만들고 키가 겹칠 때마다 Integer::sum으로 더해, 첫 글자별 개수를 셉니다. 세 줄 모두 q가 n보다 먼저 나왔지만, 이것은 HashMap의 내부 배치에서 나온 순서일 뿐 정해진 순서가 아닙니다.
중복 키가 입력이 잘못됐다는 뜻이라면 예외를 그대로 두는 것도 올바른 선택입니다. 그때는 수집하기 전에 입력을 검사해, 어떤 데이터가 왜 잘못됐는지 알려 주는 메시지를 따로 만드는 편이 사용자에게 친절합니다.
병합 함수와 Map 구현 고르기
toMap은 인수를 네 개까지 받습니다. 키 매퍼와 값 매퍼, 병합 함수, 그리고 결과로 쓸 Map을 만드는 공급자입니다. 아래 예제는 같은 제목의 조회 수를 합치고, 키가 처음 나온 순서를 지키는 LinkedHashMap에 담습니다.
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이 두 번 나오므로, 이번에는 병합 함수가 실제로 한 번 호출됩니다. 제목별 조회 수 합계를 출력하면 다음과 같습니다.
{welcome=75, question=20}첫 welcome의 30이 들어간 뒤 두 번째 welcome의 45가 오면, 병합 함수 Integer::sum이 기존 값 30과 새 값 45를 받아 75를 만듭니다. question은 다른 키라서 20이 그대로 들어갑니다. LinkedHashMap은 키가 처음 들어온 순서를 기억하므로 welcome이 앞에 출력됩니다. 네 번째 인수를 생략하면 Map의 구현도, 출력 순서도 정해지지 않습니다. 키 순서대로 정렬된 결과가 필요하면 TreeMap::new를 넘깁니다.
같은 키를 만났을 때 합칠지, 마지막 값으로 덮을지는 업무 요구로 정할 일입니다. 조회 수를 모두 더해야 하는데 (oldValue, newValue) -> newValue로 덮어쓰면 welcome은 45가 됩니다. 예외도 나지 않으니 잘못을 알아채기 어렵습니다.
병합 함수에는 조건이 하나 더 있습니다. 병렬로 수집하면 같은 키의 값들이 조각별로 먼저 합쳐진 뒤 다시 합쳐지므로, 값을 묶는 순서가 순차 실행과 달라집니다. 덧셈처럼 결합 법칙(associativity)을 만족하는 함수는 어떻게 묶어도 같은 값을 내지만, 뺄셈 같은 함수는 실행 방식에 따라 결과가 달라집니다. Collector의 Javadoc이 결합 함수에 결합 법칙을 요구하는 것도 같은 이유입니다.
null도 주의해야 합니다. JDK 21에서 toMap의 값 매퍼가 null을 돌려주면 NullPointerException이 나고, 뒤에서 볼 groupingBy의 분류 함수가 null을 돌려주면 NullPointerException: element cannot be mapped to a null key가 납니다. null이 섞일 수 있는 데이터라면 수집하기 전에 걸러 내거나 대체값으로 바꿔 둡니다.
groupingBy와 partitioningBy로 나누기
groupingBy는 분류 함수(classifier)가 돌려준 값을 키로 삼아 원소를 묶습니다. partitioningBy는 조건식(predicate)의 결과에 따라 원소를 true 쪽과 false 쪽으로 나눕니다. 둘 다 결과는 Map이지만 키가 어디서 오는지가 다릅니다. 아래 코드는 같은 게시글 세 개를 분류별로 묶고, 조회 수 40 이상인지로도 나눕니다. 끝의 세 줄은 원소가 하나도 없는 입력에 같은 두 수집기를 적용합니다.
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());
List<PostMetric> none = List.of();
System.out.println(none.stream().collect(Collectors.groupingBy(PostMetric::category)));
System.out.println(
none.stream().collect(Collectors.partitioningBy(post -> post.viewCount() >= 40)));
}
}첫 줄은 게시글 세 개를 나눈 결과의 크기이고, 나머지 두 줄은 빈 입력을 나눈 결과입니다. 빈 입력에서도 두 수집기가 같은 모양의 결과를 내는지 보세요.
notice-count=2, popular=1, regular=2
{}
{false=[], true=[]}첫 줄을 보면 notice 그룹에 welcome(60)과 event(35) 두 개가 모였습니다. 조회 수 40 이상 조건은 welcome만 통과하므로 true 쪽이 1개이고, 나머지 두 개는 false 쪽에 들어갔습니다.
빈 입력의 결과가 두 수집기의 차이를 분명하게 보여 줍니다. groupingBy의 키는 분류 함수가 실제로 돌려준 값들이므로, 원본에 나타나지 않은 분류는 키가 되지 않습니다. 그래서 빈 입력에서는 키가 하나도 없는 {}가 나옵니다. 반면 partitioningBy의 키는 언제나 true와 false 두 개이고, Javadoc은 결과 Map에 두 키가 항상 들어 있다고 명시합니다. 빈 입력에서도 두 키가 빈 리스트를 값으로 가진 채 남는 이유입니다.
GroupingAndPartitioning에서 분류별 그룹과 조회 수 40 이상 분할의 키를 비교합니다.
분류 키와 두 분할 키
빈 입력에서도 두 분할 키는 남지만 분류 키는 생기지 않는다
groupingBy는 원본에 등장한 분류를 만듭니다. partitioningBy는 입력이 없어도 true·false 두 키를 남깁니다.
groupingBy(category)
- main의 게시글 세 개
- notice 2개 · free 1개
- 게시글이 없는 경우
- 키 없음
partitioningBy(조회 수 ≥ 40)
- main의 게시글 세 개
- true 1개 · false 2개
- 게시글이 없는 경우
- true 빈 목록 · false 빈 목록
첫 행은 원문 입력의 계산, 둘째 행은 표준 수집기의 빈 입력 계약입니다. Map의 키 출력 순서는 이 표의 순서를 보장하지 않습니다.
이 차이는 결과를 꺼낼 때 드러납니다. popularPosts.get(true)는 절대 null이 아니지만, byCategory.get("archive")처럼 원본에 없던 분류를 조회하면 null이 돌아옵니다. 거기에 바로 size()를 호출하면 NullPointerException이 나므로, 없는 분류를 빈 목록으로 다루려면 byCategory.getOrDefault("archive", List.of())처럼 조회합니다.
groupingBy가 돌려주는 Map과 그룹 List의 구현도 명세가 정하지 않습니다. 키를 정렬된 순서로 받고 싶으면 Map을 만드는 공급자를 받는 세 인수 형태 groupingBy(PostMetric::category, TreeMap::new, Collectors.toList())를 씁니다. 이 형태의 마지막 인수는 그룹 안의 원소를 무엇으로 모을지 정하는 수집기입니다. 인수가 하나인 groupingBy는 이 자리에 toList()를 넣은 것과 같고, 이 자리를 바꾸면 그룹마다 리스트 대신 개수나 합계를 만들 수 있습니다.
그룹마다 합계·평균·최댓값 구하기
groupingBy의 마지막 인수 자리에 들어가는 수집기를 하위 수집기(downstream collector)라고 합니다. 숫자를 모으는 수집기는 결과 타입이 저마다 다르므로 먼저 표로 정리해 두겠습니다. 모두 단독으로도, 하위 수집기로도 쓸 수 있습니다.
| 수집기 | 결과 타입 | 원소가 없을 때 |
|---|---|---|
counting() | Long | 0 |
summingInt(mapper) | Integer | 0 |
averagingInt(mapper) | Double | 0.0 |
summarizingInt(mapper) | IntSummaryStatistics | 개수 0 (최솟값은 Integer.MAX_VALUE, 최댓값은 Integer.MIN_VALUE) |
minBy(comparator), maxBy(comparator) | Optional<T> | Optional.empty() |
summarizingInt는 개수·합계·최솟값·평균·최댓값을 원소를 한 번 훑는 동안 모두 모아 IntSummaryStatistics 객체에 담습니다. 아래 예제는 분류별 통계와 분류별 최고 조회 수 게시글을 구합니다.
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());
}
}notice의 통계 객체와 free의 최고 조회 수 게시글 제목을 출력합니다. maxBy는 그룹마다 결과를 Optional에 담아 주므로, 제목을 꺼내기 전에 orElseThrow()로 게시글을 꺼냈습니다.
notice=IntSummaryStatistics{count=2, sum=90, min=30, average=45.000000, max=60}
free-most-viewed=questionnotice에는 조회 수 60과 30이 모였으므로 개수 2, 합계 90, 최솟값 30, 최댓값 60, 평균 45입니다. 평균이 45.000000으로 찍힌 것은 IntSummaryStatistics의 toString이 평균을 소수점 아래 여섯 자리로 출력하기 때문입니다. free에는 question 하나뿐이니 그것이 최고 조회 수 게시글입니다.
maxBy가 Optional을 돌려주는 이유는 원소가 없을 수 있기 때문입니다. 빈 스트림에 단독으로 쓰면 최댓값이 없으니 Optional.empty()가 나옵니다. groupingBy 안에서는 원소가 하나라도 있어야 그룹이 생기므로 모든 그룹의 Optional에 값이 들어 있지만, 타입은 여전히 Optional이라 예제는 orElseThrow()로 값을 꺼냈습니다. 이때 Optional.empty()와 키 자체가 없는 경우는 다릅니다. mostViewed.get("archive")는 빈 Optional이 아니라 null을 돌려줍니다. 그룹마다 Optional을 벗겨 내는 방법은 다음 글에서 다룹니다.
합계의 타입도 확인해야 합니다. summingInt는 int로 더하다가 범위를 넘으면 조용히 음수로 넘어가지만, summarizingInt의 합계(getSum())는 long이라 훨씬 큰 값까지 담습니다. 합계가 커질 수 있다면 summingLong이나 summarizingInt를 쓰되, long의 범위도 무한하지는 않다는 점은 기억해 둡니다. 연습 문제 2에서 실제로 넘쳐 보겠습니다.
이 예제는 통계와 최고 게시글을 구하려고 원본을 두 번 훑었습니다. 대부분은 이렇게 나눠 쓰는 편이 읽기 쉽습니다. 원본을 한 번만 읽어야 한다면 두 수집기의 결과를 합쳐 주는 Collectors.teeing(Java 12+)을 쓸 수 있으며, 다음 글의 '더 깊이'에서 그 예를 봅니다.
reducing은 언제 쓰는가
Collectors.reducing은 최종 연산 reduce를 수집기로 만든 것이고, 주로 하위 수집기 자리에서 그룹마다 값을 하나로 줄일 때 씁니다. 아래 예제는 분류별 조회 수 합계를 reducing과 summingInt로 각각 구합니다. 두 결과를 비교하기 쉽도록 둘 다 TreeMap::new를 넘겨 키 순서를 고정했습니다.
import java.util.List;
import java.util.Map;
import java.util.TreeMap;
import java.util.stream.Collectors;
public final class ReducingVersusSumming {
private record PostMetric(String category, int viewCount) {}
public static void main(String[] args) {
List<PostMetric> posts =
List.of(
new PostMetric("notice", 60),
new PostMetric("notice", 30),
new PostMetric("free", 45));
Map<String, Integer> byReducing =
posts.stream()
.collect(
Collectors.groupingBy(
PostMetric::category,
TreeMap::new,
Collectors.reducing(
0, PostMetric::viewCount, Integer::sum)));
Map<String, Integer> bySumming =
posts.stream()
.collect(
Collectors.groupingBy(
PostMetric::category,
TreeMap::new,
Collectors.summingInt(PostMetric::viewCount)));
System.out.println(byReducing);
System.out.println(bySumming);
}
}두 Map 모두 TreeMap이므로 키는 사전순으로 찍힙니다. 두 방법의 결과가 같은지 확인해 보면 다음과 같습니다.
{free=45, notice=90}
{free=45, notice=90}reducing(0, PostMetric::viewCount, Integer::sum)은 항등값 0에서 시작해, 원소마다 조회 수를 꺼내 더합니다. 결과는 같지만 summingInt(PostMetric::viewCount) 쪽이 '합계'라는 의도를 바로 드러냅니다. 개수는 counting, 문자열 연결은 joining, 최댓값은 maxBy처럼 목적이 분명한 수집기가 있으면 그것을 쓰고, reducing은 이런 수집기로 표현할 수 없는 결합에만 씁니다. 항등값 없이 reducing(BinaryOperator)만 쓰면 reduce와 마찬가지로 결과가 Optional이 됩니다.
반대로 리스트나 맵을 만들 때는 reduce가 아니라 collect를 써야 합니다. reduce의 항등값은 객체 하나를 모든 부분 계산이 함께 쓰므로, 가변 리스트를 항등값으로 주면 병렬 실행에서 여러 스레드가 같은 리스트를 동시에 고치게 됩니다. collect는 조각마다 공급자를 호출해 새 컨테이너를 만들기 때문에 이런 문제가 없습니다.
모은 결과를 고쳐도 되는가
Collectors.toList(), toMap, groupingBy는 모두 결과 컬렉션을 고칠 수 있는지 보장하지 않습니다. 메서드가 수집 결과를 반환하는데 호출한 쪽이 그것을 고치면 안 된다면, 고칠 수 없는 결과를 명시적으로 만들어 돌려주는 편이 좋습니다. 리스트와 맵에는 Stream.toList(), Collectors.toUnmodifiableList(), Collectors.toUnmodifiableMap(...)을 쓸 수 있습니다. 이미 있는 수집기의 결과를 바꾸려면 마무리 단계를 덧붙이는 collectingAndThen을 씁니다.
Map<String, List<PostMetric>> locked =
posts.stream()
.collect(
Collectors.collectingAndThen(
Collectors.groupingBy(PostMetric::category), Map::copyOf));이렇게 만든 locked에 put을 호출하면 UnsupportedOperationException이 나지만, locked.get("notice").add(...)는 여전히 성공합니다(앞에서 본 것처럼 그룹 리스트의 종류는 명세가 정하지 않았고, 지금 JDK에서는 고칠 수 있는 리스트입니다). Map.copyOf는 바깥 Map만 고칠 수 없게 만들 뿐, 그 안의 그룹 리스트까지 바꾸지는 않기 때문입니다. 그룹 리스트까지 잠그려면 하위 수집기에서 리스트를 고칠 수 없게 만들어야 하며, 다음 글에서 그 방법을 봅니다.
크기도 생각해야 합니다. 그룹화는 모든 원소를 담은 Map을 메모리에 만들기 때문에, 키가 많거나 그룹이 크면 메모리를 많이 씁니다. 필요한 원소만 남기는 filter를 파이프라인 앞쪽에 두고, 데이터가 데이터베이스에 있다면 애플리케이션으로 모두 가져와 묶기보다 집계 함수와 GROUP BY에서 다루는 SQL의 GROUP BY로 집계하는 편이 낫습니다.
더 깊이: 병렬 스트림에서 결합 함수가 하는 일
결과를 모으려면 forEach로 바깥 리스트에 add해도 되지 않을까요? 순차 스트림에서는 같은 결과가 나오지만, 스트림을 병렬로 바꾸는 순간 차이가 드러납니다. 아래 프로그램은 1부터 10,000까지를 병렬 스트림으로 세 번 모읍니다. 첫 번째는 forEach로 공유 ArrayList에 넣고, 두 번째는 결합 함수가 오른쪽 결과를 버리는 잘못된 수집기를 쓰고, 세 번째는 Collectors.toList()를 씁니다. Collector.of는 공급자·누산 함수·결합 함수를 받아 수집기를 만드는 정적 메서드입니다.
import java.util.ArrayList;
import java.util.List;
import java.util.stream.Collector;
import java.util.stream.Collectors;
import java.util.stream.IntStream;
public final class ParallelCollectCheck {
public static void main(String[] args) {
List<Integer> shared = new ArrayList<>();
try {
IntStream.rangeClosed(1, 10_000).parallel().forEach(shared::add);
System.out.println("forEach + 공유 리스트: " + shared.size());
} catch (RuntimeException e) {
System.out.println("forEach + 공유 리스트: " + e.getClass().getSimpleName());
}
Collector<Integer, List<Integer>, List<Integer>> dropsRight =
Collector.of(ArrayList::new, List::add, (left, right) -> left);
List<Integer> broken =
IntStream.rangeClosed(1, 10_000).boxed().parallel().collect(dropsRight);
System.out.println("오른쪽을 버리는 결합 함수: " + broken.size());
List<Integer> collected =
IntStream.rangeClosed(1, 10_000).boxed().parallel().collect(Collectors.toList());
System.out.println("Collectors.toList(): " + collected.size());
}
}결과는 실행할 때마다, 그리고 컴퓨터마다 다릅니다. 아래는 2코어 환경에서 나온 한 예입니다.
forEach + 공유 리스트: 9875
오른쪽을 버리는 결합 함수: 2500
Collectors.toList(): 10000첫 줄은 같은 컴퓨터에서도 매번 달라집니다. 2코어 환경에서 25번 실행했을 때 크기는 2,500부터 10,000 사이의 여러 값으로 나왔고, 두 번은 ArrayIndexOutOfBoundsException으로 끝났으며, 올바른 값 10,000이 나온 것은 네 번뿐이었습니다. ArrayList는 여러 스레드가 동시에 add하는 상황을 고려하지 않은 클래스입니다. 두 스레드가 같은 칸에 쓰면 원소 하나가 사라지고, 내부 배열을 늘리는 작업과 쓰는 작업이 엇갈리면 작은 옛 배열에 큰 인덱스로 쓰려다 예외가 납니다. 가끔 올바른 값이 나온다는 점이 오히려 이 버그를 찾기 어렵게 만듭니다.
두 번째 줄은 병렬 스트림이 원본을 조각으로 나눈다는 것을 보여 줍니다. 이 환경에서는 10,000개를 2,500개씩 네 조각으로 나눠 조각마다 리스트를 채웠는데, 결합 함수가 left만 돌려주는 바람에 맨 왼쪽 조각만 남았습니다. 몇 조각으로 나눌지는 사용할 수 있는 코어 수에 따라 정해지므로, 코어가 많은 컴퓨터일수록 더 작은 값이 나옵니다. 순차 스트림이었다면 결합 함수가 호출되지 않아 10,000이 나왔을 것입니다. 순차 실행만 시험해서는 결합 함수의 잘못을 찾을 수 없다는 뜻입니다.
세 번째 줄의 Collectors.toList()는 몇 번을 실행해도 10,000입니다. 조각마다 공급자가 새 리스트를 만들고, 결합 함수가 리스트를 빠짐없이 이어 붙이기 때문입니다. 공유하는 가변 상태가 없으니 동기화도 필요 없습니다. 병렬 스트림이 작업을 나누고 배분하는 방식은 작업 훔치기와 병렬 스트림에서 자세히 다룹니다.
수집기의 특성도 병렬 실행과 관련이 있습니다. CONCURRENT는 여러 스레드가 결과 컨테이너 하나에 동시에 누산해도 된다는 뜻이고, toConcurrentMap과 groupingByConcurrent가 이런 수집기를 만듭니다. UNORDERED는 결과가 원소의 만남 순서(encounter order)를 지키지 않아도 된다는 뜻이고, IDENTITY_FINISH는 앞에서 본 것처럼 컨테이너를 그대로 결과로 써도 된다는 뜻입니다. 스트림 라이브러리는 이 표시를 믿고 실행 방식을 고르므로, 사실과 다른 특성을 붙인 수집기는 틀린 결과를 낼 수 있습니다. 그래서 직접 만들기 전에 표준 Collectors에서 먼저 찾고, 직접 만들었다면 병렬 실행으로 결합 함수를 꼭 시험합니다.
연습 문제
Member의 부서와 점수 목록을 부서별IntSummaryStatistics로 모으고, 부서마다 인원·총점·최고점을 출력하세요. 원본에 실제로 있는 부서만 결과의 키가 되어야 합니다.- 1번에서
platform의 두 점수를 모두Integer.MAX_VALUE로 바꾸고web의 70은 그대로 둡니다.platform의 인원·총점·최고점은 어떻게 나오나요? 같은 점수를summingInt로 더한 결과와 비교하세요. - 조회 수가 20과 30인
notice게시글 두 개만 있을 때,groupingBy(PostMetric::category)와partitioningBy(post -> post.viewCount() >= 40)의 결과에는 각각 어떤 키가 있고, 각 값에는 원소가 몇 개 있나요?
힌트
groupingBy의 하위 수집기 자리에summarizingInt를 넣습니다.IntSummaryStatistics의 합계는long이고,summingInt의 결과는Integer입니다.- 분류 키는 원본에서 나오고, 분할 키는 원본과 상관없이 정해져 있습니다.
해설
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()));
}
}web: count=1, sum=70, max=70
platform: count=2, sum=175, max=95groupingBy는 원본에 나온 platform과 web만 키로 만들고, 키마다 통계를 따로 모읍니다. platform은 80과 95가 모여 인원 2, 총점 175, 최고점 95입니다. web이 먼저 출력된 것은 HashMap의 배치 때문이므로, 부서 순서가 중요하다면 TreeMap::new를 넘깁니다.
-
platform: count=2, sum=4294967294, max=2147483647이 나옵니다. 합계가long이라 2,147,483,647의 두 배가 그대로 담겼습니다. 반면summingInt로 더하면int의 범위를 넘으면서 값이 한 바퀴 돌아platform의 합계가-2가 됩니다. 예외도 경고도 없으니, 입력이int라고 해서 합계도int에 담길 거라고 가정하면 안 됩니다. 원본에 없는 부서는 이번에도 키가 생기지 않습니다. -
groupingBy의 결과에는notice키 하나만 있고, 그 리스트에는 게시글 두 개가 들어 있습니다.partitioningBy의 결과에는true와false키가 모두 있습니다.true는 빈 리스트이고false에 두 게시글이 들어갑니다.true쪽에 원소가 없다고 해서true키까지 사라지지는 않습니다.
정리
collect는 공급자로 빈 컨테이너를 만들고, 누산 함수로 원소를 하나씩 담고, 병렬일 때는 결합 함수로 컨테이너를 합친 뒤, 마무리 함수로 최종 결과를 만듭니다. 조각마다 자기 컨테이너를 쓰므로 공유 리스트에add하는 방식과 달리 병렬에서도 안전합니다.Collectors.toList()의 수정 가능 여부는 정해져 있지 않습니다. 고칠 리스트가 필요하면toCollection(ArrayList::new), 읽기 전용이면Stream.toList()를 씁니다.toMap은 키가 겹치면IllegalStateException을 던집니다. 모두 보존(groupingBy), 하나만 선택(병합 함수), 개수 세기 가운데 요구에 맞는 방법을 고릅니다.groupingBy는 원본에 나온 키만 만들고,partitioningBy는 빈 입력에서도true와false키를 모두 남깁니다.- 숫자 수집기마다 결과 타입이 다릅니다.
summingInt는int로 더하므로 넘칠 수 있고,maxBy는Optional을 돌려줍니다.
다음 글
groupingBy 하위 수집기로 그룹별 값 만들기에서는 하위 수집기를 본격적으로 조합합니다. 그룹 안의 원소를 바꾸는 mapping, 거르는 filtering, 펼치는 flatMapping, 그룹의 결과를 마지막에 바꾸는 collectingAndThen을 차례로 다룹니다.