다운스트림 Collector
groupingBy의 그룹 List를 후처리하지 않고 downstream Collector를 조합해 원하는 값으로 직접 수집합니다.
groupingBy의 기본 하위 수집기는 toList()이므로 Map의 값은 원본 원소의 List입니다.
그룹별 count, 게시글 제목 Set, 통계, 불변 요약값이 필요하다면 먼저 List를 만든 뒤 다시 순회할 이유가 없습니다.
하위 수집기는 분류 함수가 정한 각 그룹 안에서 곧바로 원하는 결과 타입을 만듭니다.
collectingAndThen의 불변 결과
collectingAndThen(toList(), List::copyOf)의 마무리 함수는 수정할 수 없는 List를 반환합니다.
아래 코드의 List.copyOf 결과에 add를 호출하면 API 계약상 UnsupportedOperationException을 던집니다.
import java.util.List;
import java.util.stream.Collectors;
public final class FinishedListMutationFailure {
public static void main(String[] args) {
List<String> result =
List.of("welcome", "event").stream()
.collect(Collectors.collectingAndThen(Collectors.toList(), List::copyOf));
result.add("draft");
}
}마무리 함수는 단순한 장식이 아니라 결과의 소유권 규칙을 바꿉니다.
수정 가능한 결과가 필요하면 toCollection(ArrayList::new), 목록 구조를 수정할 수 없는 스냅샷이 필요하면 List.copyOf를 사용합니다. List.copyOf는 null 원소를 거절하며 원소 객체 자체를 깊게 복사하지는 않습니다.
목록에 가변 객체가 들어 있다면 add를 막는 것과 그 객체의 필드 변경을 막는 것은 별개입니다. 이 절의 String 제목은 자체로 불변이지만 뒤 보고서의 IntSummaryStatistics는 accept로 누산할 수 있습니다. record가 참조를 고정한다는 사실만으로 모든 결과가 깊은 불변 스냅샷이 되는지 판단하지 마세요.
하위 수집기가 그룹 안에서 작동하는 위치
분류 함수가 키를 정하면 groupingBy는 해당 키의 하위 수집기 누산기에 원소를 전달합니다.
각 키에 쌓인 값이 누산 상태이고, accumulator는 원소 하나를 그 상태에 반영하는 함수입니다. 생성·축적·결합·마무리의 역할은 앞 절의 Collector 설명에서 복습할 수 있습니다.
기본 toList 대신 counting을 주면 값은 Long, mapping과 toSet을 조합하면 Set, summarizingInt를 주면 통계 객체가 됩니다.
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.stream.Collectors;
public final class DownstreamCollectorShapes {
private record PostMetric(String category, String title, int viewCount) {}
public static void main(String[] args) {
List<PostMetric> posts =
List.of(
new PostMetric("notice", "welcome", 40),
new PostMetric("notice", "welcome", 20),
new PostMetric("free", "question", 30));
Map<String, Long> counts =
posts.stream()
.collect(
Collectors.groupingBy(
PostMetric::category, Collectors.counting()));
Map<String, Set<String>> titles =
posts.stream()
.collect(
Collectors.groupingBy(
PostMetric::category,
Collectors.mapping(
PostMetric::title, Collectors.toSet())));
Map<String, Integer> totals =
posts.stream()
.collect(
Collectors.groupingBy(
PostMetric::category,
Collectors.summingInt(PostMetric::viewCount)));
System.out.println(counts);
System.out.println(titles);
System.out.println(totals);
}
}같은 원본에서도 하위 수집기에 따라 값 타입이 달라집니다.
예시 예제의 notice의 welcome40과 welcome20은 게시글 두 개이므로 counts는 2입니다. mapping(PostMetric::title,toSet())은 동일한 welcome 제목을 하나만 남기며 totals는 40+20=60입니다. free는 count 1·제목 question·합 30입니다. 게시글 수와 고유 제목 수를 같은 count로 보고하지 않도록 각 결과의 원소 단위를 적어 보세요.
mapping·collectingAndThen 위치
mapping(mapper, downstream)은 그룹에 들어오는 각 원소를 바꾼 뒤 하위 수집기로 보냅니다.
collectingAndThen(downstream, finisher)는 그룹의 완성 결과 하나를 후처리합니다.
아래 예제에서 mapping은 Post를 제목 String으로, collectingAndThen은 완성된 제목 목록을 Group으로 바꿉니다.
import java.util.List;
import java.util.Map;
import java.util.stream.Collectors;
public final class MappingAndFinishingDifference {
private record Post(String category, String title) {}
private record Group(int count, List<String> titles) {}
public static void main(String[] args) {
List<Post> posts =
List.of(
new Post("notice", "welcome"),
new Post("notice", "event"),
new Post("free", "question"));
Map<String, Group> result =
posts.stream()
.collect(
Collectors.groupingBy(
Post::category,
Collectors.collectingAndThen(
Collectors.mapping(
Post::title, Collectors.toList()),
titles ->
new Group(
titles.size(),
List.copyOf(titles)))));
System.out.println(result);
}
}이 수집에서 매퍼는 원소를 제목으로 바꾸고, 마무리 함수는 완성된 각 그룹 목록으로 Group을 만듭니다. List.copyOf로 누산 목록과 결과 목록의 구조 변경을 분리합니다.
notice 게시글 둘이 같은 그룹의 제목 상태에 쌓일지 먼저 예상해 보세요. 아래 그림은 위 posts의 세 원소를 순서대로 따라간 값의 풀이입니다.
제목 세 개를 모아 Group 두 개를 만든다
notice 제목 둘과 free 제목 하나가 각 누산 목록에 쌓인 후 finisher가 count와 복사한 titles로 Group을 만듭니다. 누산 중 가변 목록과 최종 결과를 구분할 실제 수집입니다.
notice의 welcome과 event는 같은 그룹에 쌓이고 free의 question은 다른 그룹에 쌓입니다. 축적 뒤 각 제목 목록을 count와 titles가 있는 Group으로 마무리합니다.
원소 처리와 그룹 마무리
제목 세 개를 모아 Group 두 개를 만든다
MappingAndFinishingDifference의 같은 Post 세 개를 따라갑니다. 표와 목록은 각 키에 축적된 제목의 논리적 상태를 보여 줍니다.
원소를 읽을 때 같은 키의 제목 상태에 더한다
입력 전에는 두 키 모두 없습니다. Post::category로 키를 정하고, Post::title이 넘긴 제목을 그 그룹에 모읍니다.
| 읽은 Post (분류 / 제목) | 분류 키 | 매핑한 제목 | notice에 쌓인 제목 | free에 쌓인 제목 |
|---|---|---|---|---|
notice / welcome | notice | welcome | [welcome] | 키 없음 |
notice / event | notice | event | [welcome, event] | 키 없음 |
free / question | free | question | [welcome, event] | [question] |
읽은 Post:
notice / welcome분류 키
notice· 매핑한 제목welcomenotice:
[welcome]· free: 키 없음읽은 Post:
notice / event분류 키
notice· 매핑한 제목eventnotice:
[welcome, event]· free: 키 없음읽은 Post:
free / question분류 키
free· 매핑한 제목questionnotice:
[welcome, event]· free:[question]
축적이 끝나면 완성된 목록을 Group으로 바꾼다
mapping의 완성 결과는 List<String>입니다. collectingAndThen의 함수는 그 목록의 크기와 List.copyOf 결과로 Group을 만듭니다.
notice의 예상 Group- count
2· titles[welcome, event] free의 예상 Group- count
1· titles[question]
최종 결과는 Map<String, Group>입니다. 제목 List는 만남 순서를 유지하며, Map 키의 출력 순서는 이 목록의 순서와 별개입니다.
원본 원소는 Post, 분류 키와 매핑한 제목은 각각 String입니다. 중간 축적 타입 A는 API의 ?로 숨겨져 있습니다. 위 목록은 축적된 제목의 풀이이며 특정 ArrayList·HashMap 구현을 뜻하지 않습니다.
이 그림은 값의 관계와 완료 경계를 설명합니다. 함수의 정확한 호출 횟수·시각이나 Map 키 순서를 지정하지 않습니다. 일반 수집기는 IDENTITY_FINISH이면 마무리 함수를 생략할 수 있으며, 이 예제에는 목록을 Group으로 바꾸는 추가 변환이 있습니다.
이 예의 조건
- Map 키 반복 순서·함수 호출의 정확한 횟수 보장이 아닙니다.
관계도의 보충 설명
| 구분 | 내용 |
|---|---|
| 2 | 원 MappingAndFinishingDifference의 Post 3개를 키 notice/free로 분류한 뒤 각 Post의 title을 각 그룹 List에 누산한다. notice의 welcome,event는 같은 누산 상태이고 free의 question은 별개다. 수집 완료 뒤 각 List를 count와 List.copyOf의 titles를 가진 Group으로 마무리한다. 7개 노드와 6개 관계는 mobile도 동일하고 함수 호출의 정확한 횟수나 맵 키의 반복 순서는 보장하지 않는다. |
| 구분 | 내용 |
|---|---|
| 설명 | 원 MappingAndFinishingDifference의 Post 3개를 키 notice/free로 분류한 뒤 각 Post의 title을 각 그룹 List에 누산한다. notice의 welcome,event는 같은 누산 상태이고 free의 question은 별개다. 수집 완료 뒤 각 List를 count와 List.copyOf의 titles를 가진 Group으로 마무리한다. 7개 노드와 6개 관계는 mobile도 동일하고 함수 호출의 정확한 횟수나 맵 키의 반복 순서는 보장하지 않는다. |
| 전체 읽기 정보 | 제목 세 개를 모아 Group 두 개를 만든다 제목 세 개를 모아 Group 두 개를 만든다 notice의 welcome과 event는 같은 그룹에 쌓이고 free의 question은 다른 그룹에 쌓입니다. 축적 뒤 각 제목 목록을 count와 titles가 있는 Group으로 마무리합니다. 원소 처리와 그룹 마무리 제목 세 개를 모아 Group 두 개를 만든다 MappingAndFinishingDifference의 같은 Post 세 개를 따라갑니다. 표와 목록은 각 키에 축적된 제목의 논리적 상태를 보여 줍니다. 원소를 읽을 때 같은 키의 제목 상태에 더한다 입력 전에는 두 키 모두 없습니다. Post::category로 키를 정하고, Post::title이 넘긴 제목을 그 그룹에 모읍니다. 원본 List의 만남 순서로 풀이한 축적 상태 읽은 Post (분류 / 제목) 분류 키 매핑한 제목 notice에 쌓인 제목 free에 쌓인 제목 notice / welcome notice welcome [welcome] 키 없음 notice / event notice event [welcome, event] 키 없음 free / question free question [welcome, event] [question] 읽은 Post: notice / welcome 분류 키 notice · 매핑한 제목 welcome notice: [welcome] · free: 키 없음 읽은 Post: notice / event 분류 키 notice · 매핑한 제목 event notice: [welcome, event] · free: 키 없음 읽은 Post: free / question 분류 키 free · 매핑한 제목 question notice: [welcome, event] · free: [question] 축적이 끝나면 완성된 목록을 Group으로 바꾼다 mapping의 완성 결과는 List<String>입니다. collectingAndThen의 함수는 그 목록의 크기와 List.copyOf 결과로 Group을 만듭니다. notice의 예상 Group count 2 · titles [welcome, event] free의 예상 Group count 1 · titles [question] 최종 결과는 Map<String, Group>입니다. 제목 List는 만남 순서를 유지하며, Map 키의 출력 순서는 이 목록의 순서와 별개입니다. 원본 원소는 Post, 분류 키와 매핑한 제목은 각각 String입니다. 중간 축적 타입 A는 API의 ?로 숨겨져 있습니다. 위 목록은 축적된 제목의 풀이이며 특정 ArrayList·HashMap 구현을 뜻하지 않습니다. 이 그림은 값의 관계와 완료 경계를 설명합니다. 함수의 정확한 호출 횟수·시각이나 Map 키 순서를 지정하지 않습니다. 일반 수집기는 IDENTITY_FINISH이면 마무리 함수를 생략할 수 있으며, 이 예제에는 목록을 Group으로 바꾸는 추가 변환이 있습니다. |
| 관찰·상황 | 값·보충 정보 | 변화·이유 |
|---|---|---|
| 키 분류 세 Post를 notice 2개와 free 1개로 묶습니다. |
|
|
| 그룹 누산 mapping으로 title을 각 그룹 List에 모읍니다. |
|
|
| 완성 후 변환 각 완성 List를 count와 복사한 titles를 담은 Group으로 만듭니다. |
|
|
제목 세 개를 모아 Group 두 개를 만든다
notice 제목 둘과 free 제목 하나가 각 누산 목록에 쌓인 후 finisher가 count와 복사한 titles로 Group을 만듭니다. 누산 중 가변 목록과 최종 결과를 구분할 실제 수집입니다.
notice의 welcome과 event는 같은 그룹에 쌓이고 free의 question은 다른 그룹에 쌓입니다. 축적 뒤 각 제목 목록을 count와 titles가 있는 Group으로 마무리합니다.
원소 처리와 그룹 마무리
제목 세 개를 모아 Group 두 개를 만든다
MappingAndFinishingDifference의 같은 Post 세 개를 따라갑니다. 표와 목록은 각 키에 축적된 제목의 논리적 상태를 보여 줍니다.
원소를 읽을 때 같은 키의 제목 상태에 더한다
입력 전에는 두 키 모두 없습니다. Post::category로 키를 정하고, Post::title이 넘긴 제목을 그 그룹에 모읍니다.
| 읽은 Post (분류 / 제목) | 분류 키 | 매핑한 제목 | notice에 쌓인 제목 | free에 쌓인 제목 |
|---|---|---|---|---|
notice / welcome | notice | welcome | [welcome] | 키 없음 |
notice / event | notice | event | [welcome, event] | 키 없음 |
free / question | free | question | [welcome, event] | [question] |
읽은 Post:
notice / welcome분류 키
notice· 매핑한 제목welcomenotice:
[welcome]· free: 키 없음읽은 Post:
notice / event분류 키
notice· 매핑한 제목eventnotice:
[welcome, event]· free: 키 없음읽은 Post:
free / question분류 키
free· 매핑한 제목questionnotice:
[welcome, event]· free:[question]
축적이 끝나면 완성된 목록을 Group으로 바꾼다
mapping의 완성 결과는 List<String>입니다. collectingAndThen의 함수는 그 목록의 크기와 List.copyOf 결과로 Group을 만듭니다.
notice의 예상 Group- count
2· titles[welcome, event] free의 예상 Group- count
1· titles[question]
최종 결과는 Map<String, Group>입니다. 제목 List는 만남 순서를 유지하며, Map 키의 출력 순서는 이 목록의 순서와 별개입니다.
원본 원소는 Post, 분류 키와 매핑한 제목은 각각 String입니다. 중간 축적 타입 A는 API의 ?로 숨겨져 있습니다. 위 목록은 축적된 제목의 풀이이며 특정 ArrayList·HashMap 구현을 뜻하지 않습니다.
이 그림은 값의 관계와 완료 경계를 설명합니다. 함수의 정확한 호출 횟수·시각이나 Map 키 순서를 지정하지 않습니다. 일반 수집기는 IDENTITY_FINISH이면 마무리 함수를 생략할 수 있으며, 이 예제에는 목록을 Group으로 바꾸는 추가 변환이 있습니다.
이 예의 조건
- Map 키 반복 순서·함수 호출의 정확한 횟수 보장이 아닙니다.
관계도의 보충 설명
| 구분 | 내용 |
|---|---|
| 2 | 원 MappingAndFinishingDifference의 Post 3개를 키 notice/free로 분류한 뒤 각 Post의 title을 각 그룹 List에 누산한다. notice의 welcome,event는 같은 누산 상태이고 free의 question은 별개다. 수집 완료 뒤 각 List를 count와 List.copyOf의 titles를 가진 Group으로 마무리한다. 7개 노드와 6개 관계는 mobile도 동일하고 함수 호출의 정확한 횟수나 맵 키의 반복 순서는 보장하지 않는다. |
| 구분 | 내용 |
|---|---|
| 설명 | 원 MappingAndFinishingDifference의 Post 3개를 키 notice/free로 분류한 뒤 각 Post의 title을 각 그룹 List에 누산한다. notice의 welcome,event는 같은 누산 상태이고 free의 question은 별개다. 수집 완료 뒤 각 List를 count와 List.copyOf의 titles를 가진 Group으로 마무리한다. 7개 노드와 6개 관계는 mobile도 동일하고 함수 호출의 정확한 횟수나 맵 키의 반복 순서는 보장하지 않는다. |
| 전체 읽기 정보 | 제목 세 개를 모아 Group 두 개를 만든다 제목 세 개를 모아 Group 두 개를 만든다 notice의 welcome과 event는 같은 그룹에 쌓이고 free의 question은 다른 그룹에 쌓입니다. 축적 뒤 각 제목 목록을 count와 titles가 있는 Group으로 마무리합니다. 원소 처리와 그룹 마무리 제목 세 개를 모아 Group 두 개를 만든다 MappingAndFinishingDifference의 같은 Post 세 개를 따라갑니다. 표와 목록은 각 키에 축적된 제목의 논리적 상태를 보여 줍니다. 원소를 읽을 때 같은 키의 제목 상태에 더한다 입력 전에는 두 키 모두 없습니다. Post::category로 키를 정하고, Post::title이 넘긴 제목을 그 그룹에 모읍니다. 원본 List의 만남 순서로 풀이한 축적 상태 읽은 Post (분류 / 제목) 분류 키 매핑한 제목 notice에 쌓인 제목 free에 쌓인 제목 notice / welcome notice welcome [welcome] 키 없음 notice / event notice event [welcome, event] 키 없음 free / question free question [welcome, event] [question] 읽은 Post: notice / welcome 분류 키 notice · 매핑한 제목 welcome notice: [welcome] · free: 키 없음 읽은 Post: notice / event 분류 키 notice · 매핑한 제목 event notice: [welcome, event] · free: 키 없음 읽은 Post: free / question 분류 키 free · 매핑한 제목 question notice: [welcome, event] · free: [question] 축적이 끝나면 완성된 목록을 Group으로 바꾼다 mapping의 완성 결과는 List<String>입니다. collectingAndThen의 함수는 그 목록의 크기와 List.copyOf 결과로 Group을 만듭니다. notice의 예상 Group count 2 · titles [welcome, event] free의 예상 Group count 1 · titles [question] 최종 결과는 Map<String, Group>입니다. 제목 List는 만남 순서를 유지하며, Map 키의 출력 순서는 이 목록의 순서와 별개입니다. 원본 원소는 Post, 분류 키와 매핑한 제목은 각각 String입니다. 중간 축적 타입 A는 API의 ?로 숨겨져 있습니다. 위 목록은 축적된 제목의 풀이이며 특정 ArrayList·HashMap 구현을 뜻하지 않습니다. 이 그림은 값의 관계와 완료 경계를 설명합니다. 함수의 정확한 호출 횟수·시각이나 Map 키 순서를 지정하지 않습니다. 일반 수집기는 IDENTITY_FINISH이면 마무리 함수를 생략할 수 있으며, 이 예제에는 목록을 Group으로 바꾸는 추가 변환이 있습니다. |
| 관찰·상황 | 값·보충 정보 | 변화·이유 |
|---|---|---|
| 키 분류 세 Post를 notice 2개와 free 1개로 묶습니다. |
|
|
| 그룹 누산 mapping으로 title을 각 그룹 List에 모읍니다. |
|
|
| 완성 후 변환 각 완성 List를 count와 복사한 titles를 담은 Group으로 만듭니다. |
|
|
첫 게시글의 welcome을 모은 뒤 두 번째 게시글의 event도 같은 notice 그룹에 모읍니다. free의 question은 별도 그룹에 들어갑니다. 모든 원소를 축적한 뒤, 각 그룹의 완성된 제목 목록을 마무리 함수에 넘깁니다.
원본 원소는 Post, 분류 키와 매핑한 제목은 각각 String입니다. mapping의 완성 결과는 List<String>이고, 여기에 마무리 함수를 적용한 값은 Group입니다. 그래서 최종 Map<String, Group>의 값에 제목 목록만 남는 것이 아니라 개수와 제목 목록을 가진 Group이 들어갑니다.
축적에 사용하는 중간 타입 A는 표준 API에서 ?로 숨겨져 있습니다. 그림의 목록은 그 상태에 쌓인 제목을 나타내며, 특정 ArrayList나 HashMap 구현을 지정하지 않습니다.
이 입력에서 예상하는 값은 notice의 count 2·titles [welcome, event], free의 count 1·titles [question]입니다. 제목 목록은 원본 List의 만남 순서를 유지하지만, Map의 두 키가 출력되는 순서는 정해져 있지 않습니다. 키별 값을 대조해 보세요.
그림은 원소 처리와 완료의 관계를 설명하며 함수가 호출되는 정확한 횟수나 시각을 보장하지 않습니다. 일반 수집기에서 IDENTITY_FINISH 특성이 있으면 마무리 함수를 생략할 수 있습니다. 이 예제에는 목록을 Group으로 바꾸는 추가 마무리 함수가 있습니다.
개수나 제목이 예상과 다르면 두 notice 게시글의 분류 키가 같은지, mapping이 제목을 넘기는지부터 확인합니다. Group을 만드는 함수는 원소별 mapping이 아니라 완성된 목록을 받는 collectingAndThen에 둡니다.
독립 확인: MappingAndFinishingDifference에서 notice의 두 제목을 모두 welcome으로 바꿉니다. 현재 Group의 count와 titles는 무엇인가요? 먼저 mapping(...,toSet())으로 수집한 고유 제목 수와 같은가요?
풀이 확인
예제의 List 누산은 중복도 보존해 Group(2,[welcome,welcome])이고 고유 제목 Set은 [welcome] 하나입니다. List 크기를 저장한 count 2를 고유 제목 수 1로 읽으면 안 됩니다. List.copyOf는 목록 구조의 수정을 막는 마무리이며 중복 제거 연산은 아닙니다. 마무리 함수를 원소마다 호출하는 형태로 바꾸면 두 제목을 모아 만든 Group이라는 요구도 달라집니다.
filtering과 빈 그룹 키
보고서에서 조건을 통과한 게시글만 있는 분류를 보여 줄지, 원본에 등장한 모든 분류를 보여 줄지 먼저 결정합니다.
이번 예제는 앞의 제목 수집과 별개로 notice 조회 수 20과 free 조회 수 70인 두 게시글을 입력으로 사용합니다.
import java.util.List;
import java.util.Map;
import java.util.stream.Collectors;
public final class DownstreamFilteringKeys {
private record PostMetric(String category, int viewCount) {}
public static void main(String[] args) {
List<PostMetric> source =
List.of(new PostMetric("notice", 20), new PostMetric("free", 70));
Map<String, List<PostMetric>> upstream =
source.stream()
.filter(post -> post.viewCount() >= 60)
.collect(Collectors.groupingBy(PostMetric::category));
Map<String, List<PostMetric>> downstream =
source.stream()
.collect(
Collectors.groupingBy(
PostMetric::category,
Collectors.filtering(
post -> post.viewCount() >= 60,
Collectors.toList())));
System.out.println(
"upstream-keys="
+ upstream.keySet()
+ ", downstream-notice-size="
+ downstream.get("notice").size());
}
}앞쪽 filter에서는 notice의 조회 수 20이 탈락해 분류 함수까지 도달하지 않습니다. 하위 filtering에서는 먼저 notice 그룹으로 나눈 뒤 원소 축적을 거절하므로, 키는 남고 그 값은 빈 List가 됩니다. free의 조회 수 70은 두 경로에서 모두 원소 하나로 남습니다.
notice 키가 있는지와 그 목록의 크기가 0인지 비교해 보세요. 결과가 예상과 다르면 filter가 groupingBy 앞에 있는지, filtering이 하위 수집기에 있는지 먼저 확인합니다. 원본에 등장하지 않은 분류는 하위 filtering도 만들어 주지 않습니다.
조건을 통과하지 못한 분류를 키에서 지울지 남길지 고른다
notice 20을 앞에서 제거하면 키가 생기지 않고 먼저 그룹을 만들면 빈 notice 목록이 남습니다. 같은 입력에 대해 분류 전/후 선택의 생성 상태를 비교할 수 있습니다.
notice 20과 free 70에 조회 수 60 이상 조건을 적용한 두 수집 결과입니다.
선별을 어디에 놓을까
조건을 통과하지 못한 분류를 키에서 지울지 남길지 고른다
원본은 notice 20·free 70이고 조건은 조회 수 60 이상입니다. 선별 위치에 따라 notice 키가 사라지거나 빈 목록으로 남습니다.
groupingBy 앞 filter
- notice 키
- 키 자체가 없음
- free 키
- 조회 수 70인 원소 한 개
groupingBy 안 filtering
- notice 키
- 키 유지 · 빈 List
- free 키
- 조회 수 70인 원소 한 개
원문 입력과 API 규칙으로 풀이했습니다. 하위 filtering도 원본에 전혀 없던 분류의 키까지 생성하지는 않습니다.
이 예의 조건
- 앞쪽 filter와 하위 filtering은 독립 실행이다.
관계도의 보충 설명
| 구분 | 내용 |
|---|---|
| 2 | 원 notice 20/free 70과 60 이상 조건이다. 왼쪽 filter는 분류 전 notice를 제거해 free만 키가 되고 오른쪽 filtering은 분류한 notice 키에 빈 List를 남긴다. 같은 순차 원본을 두 독립 Stream으로 소비한 값의 관계를 5개 노드와 4개 간선으로 나타내며 하나의 Stream을 재사용하는 것이 아니다. 원본에 없는 archive 키는 어느 쪽도 만들지 않는다. |
| 4 | notice 20 · free 70 |
| 구분 | 내용 |
|---|---|
| 설명 | 원 notice 20/free 70과 60 이상 조건이다. 왼쪽 filter는 분류 전 notice를 제거해 free만 키가 되고 오른쪽 filtering은 분류한 notice 키에 빈 List를 남긴다. 같은 순차 원본을 두 독립 Stream으로 소비한 값의 관계를 5개 노드와 4개 간선으로 나타내며 하나의 Stream을 재사용하는 것이 아니다. 원본에 없는 archive 키는 어느 쪽도 만들지 않는다. |
| 전체 읽기 정보 | 조건을 통과하지 못한 분류를 키에서 지울지 남길지 고른다 조건을 통과하지 못한 분류를 키에서 지울지 남길지 고른다 notice 20과 free 70에 조회 수 60 이상 조건을 적용한 두 수집 결과입니다. 선별을 어디에 놓을까 조건을 통과하지 못한 분류를 키에서 지울지 남길지 고른다 원본은 notice 20·free 70이고 조건은 조회 수 60 이상입니다. 선별 위치에 따라 notice 키가 사라지거나 빈 목록으로 남습니다. groupingBy 앞 filter notice 키 키 자체가 없음 free 키 조회 수 70인 원소 한 개 groupingBy 안 filtering notice 키 키 유지 · 빈 List free 키 조회 수 70인 원소 한 개 원문 입력과 API 규칙으로 풀이했습니다. 하위 filtering도 원본에 전혀 없던 분류의 키까지 생성하지는 않습니다. |
| 관찰·상황 | 값·보충 정보 | 변화·이유 |
|---|---|---|
| 공통 입력 두 category와 조건 ≥60을 받습니다. |
|
|
| notice 처리 앞쪽은 분류 전에 버리고 하위는 그룹을 만든 후 원소를 버립니다. |
|
|
| free 처리와 완성 통과하는 70은 두 실행 모두 free에 남습니다. |
|
|
조건을 통과하지 못한 분류를 키에서 지울지 남길지 고른다
notice 20을 앞에서 제거하면 키가 생기지 않고 먼저 그룹을 만들면 빈 notice 목록이 남습니다. 같은 입력에 대해 분류 전/후 선택의 생성 상태를 비교할 수 있습니다.
notice 20과 free 70에 조회 수 60 이상 조건을 적용한 두 수집 결과입니다.
선별을 어디에 놓을까
조건을 통과하지 못한 분류를 키에서 지울지 남길지 고른다
원본은 notice 20·free 70이고 조건은 조회 수 60 이상입니다. 선별 위치에 따라 notice 키가 사라지거나 빈 목록으로 남습니다.
groupingBy 앞 filter
- notice 키
- 키 자체가 없음
- free 키
- 조회 수 70인 원소 한 개
groupingBy 안 filtering
- notice 키
- 키 유지 · 빈 List
- free 키
- 조회 수 70인 원소 한 개
원문 입력과 API 규칙으로 풀이했습니다. 하위 filtering도 원본에 전혀 없던 분류의 키까지 생성하지는 않습니다.
이 예의 조건
- 앞쪽 filter와 하위 filtering은 독립 실행이다.
관계도의 보충 설명
| 구분 | 내용 |
|---|---|
| 2 | 원 notice 20/free 70과 60 이상 조건이다. 왼쪽 filter는 분류 전 notice를 제거해 free만 키가 되고 오른쪽 filtering은 분류한 notice 키에 빈 List를 남긴다. 같은 순차 원본을 두 독립 Stream으로 소비한 값의 관계를 5개 노드와 4개 간선으로 나타내며 하나의 Stream을 재사용하는 것이 아니다. 원본에 없는 archive 키는 어느 쪽도 만들지 않는다. |
| 4 | notice 20 · free 70 |
| 구분 | 내용 |
|---|---|
| 설명 | 원 notice 20/free 70과 60 이상 조건이다. 왼쪽 filter는 분류 전 notice를 제거해 free만 키가 되고 오른쪽 filtering은 분류한 notice 키에 빈 List를 남긴다. 같은 순차 원본을 두 독립 Stream으로 소비한 값의 관계를 5개 노드와 4개 간선으로 나타내며 하나의 Stream을 재사용하는 것이 아니다. 원본에 없는 archive 키는 어느 쪽도 만들지 않는다. |
| 전체 읽기 정보 | 조건을 통과하지 못한 분류를 키에서 지울지 남길지 고른다 조건을 통과하지 못한 분류를 키에서 지울지 남길지 고른다 notice 20과 free 70에 조회 수 60 이상 조건을 적용한 두 수집 결과입니다. 선별을 어디에 놓을까 조건을 통과하지 못한 분류를 키에서 지울지 남길지 고른다 원본은 notice 20·free 70이고 조건은 조회 수 60 이상입니다. 선별 위치에 따라 notice 키가 사라지거나 빈 목록으로 남습니다. groupingBy 앞 filter notice 키 키 자체가 없음 free 키 조회 수 70인 원소 한 개 groupingBy 안 filtering notice 키 키 유지 · 빈 List free 키 조회 수 70인 원소 한 개 원문 입력과 API 규칙으로 풀이했습니다. 하위 filtering도 원본에 전혀 없던 분류의 키까지 생성하지는 않습니다. |
| 관찰·상황 | 값·보충 정보 | 변화·이유 |
|---|---|---|
| 공통 입력 두 category와 조건 ≥60을 받습니다. |
|
|
| notice 처리 앞쪽은 분류 전에 버리고 하위는 그룹을 만든 후 원소를 버립니다. |
|
|
| free 처리와 완성 통과하는 70은 두 실행 모두 free에 남습니다. |
|
|
독립 확인: 기존 조건 >=60에 notice20·free70을 주되, 하위 filtering 뒤에는 maxBy를 결합합니다. notice의 키가 남나요? 그 값에 orElseThrow를 호출하면 무엇이 일어나나요? 별도로 원본에 없는 archive 분류의 값도 생기나요?
풀이 확인
notice 키는 남지만 maxBy에 도달한 원소가 0개여서 값은 Optional.empty()입니다. 여기에 orElseThrow를 호출하면 NoSuchElementException이 발생합니다. free에는 70이 들어 있는 Optional이 남고, archive는 원본에 없어 키가 없습니다. 키 존재·빈 하위 결과·존재하지 않는 키의 조회를 세 상태로 나눠야 합니다. 하위 filtering의 API 설명은 앞쪽 filter와 빈 그룹 유지의 차이를 설명합니다.
flatMapping과 중첩 컬렉션
PostMetric이 태그 List를 가진다면 groupingBy의 하위 수집기로 flatMapping(post -> post.tags().stream(), toSet())을 사용해 분류별 고유 태그 Set을 바로 만들 수 있습니다.
mapping을 쓰면 List를 원소로 가진 Set이 되어 결과 형태가 달라집니다.
import java.util.IntSummaryStatistics;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.stream.Collectors;
public final class CategoryDownstreamReport {
private record PostMetric(String category, int viewCount, List<String> tags) {}
private record Summary(Set<String> tags, IntSummaryStatistics views) {}
public static void main(String[] args) {
List<PostMetric> source =
List.of(
new PostMetric("notice", 40, List.of("welcome", "admin")),
new PostMetric("notice", 60, List.of("event", "admin")),
new PostMetric("free", 30, List.of("question")));
Map<String, Set<String>> tags =
source.stream()
.collect(
Collectors.groupingBy(
PostMetric::category,
Collectors.flatMapping(
post -> post.tags().stream(),
Collectors.toSet())));
Map<String, IntSummaryStatistics> views =
source.stream()
.collect(
Collectors.groupingBy(
PostMetric::category,
Collectors.summarizingInt(PostMetric::viewCount)));
Map<String, Summary> report =
tags.entrySet().stream()
.collect(
Collectors.toUnmodifiableMap(
Map.Entry::getKey,
entry ->
new Summary(
Set.copyOf(entry.getValue()),
views.get(entry.getKey()))));
System.out.println(report);
}
}CategoryDownstreamReport의 세 결과 계층에서 수정 가능한 대상을 구분합니다.
각 결과 계층의 변경 권한
바깥 Map과 태그 집합을 잠가도 통계 객체는 바뀔 수 있다
바깥 Map과 tags 집합은 수정할 수 없습니다. views는 기존 IntSummaryStatistics 객체 참조여서 통계 값을 바꿀 수 있습니다.
바깥 report Map
- 만드는 방법
- toUnmodifiableMap
- 허용되는 변경
- 키 추가·삭제·값 교체 불가
Summary의 tags
- 만드는 방법
- Set.copyOf
- 허용되는 변경
- 집합 원소 추가·삭제 불가
Summary의 views
- 만드는 방법
- IntSummaryStatistics 참조
- 허용되는 변경
- accept·combine으로 통계 변경 가능
record는 필드 참조를 고정하지만 참조 대상의 가변성을 없애지 않습니다. 원문 main은 생성된 보고서를 변경하지 않습니다.
두 그룹 결과를 키로 결합했습니다.
예제의 notice 태그의 Set은 welcome·event·admin 세 값이며 admin이 두 입력에 있어도 한 번 남습니다. 조회 수 통계는 40·60을 따로 모아 count 2·sum 100입니다. report.get("notice").views()와 기존 views.get("notice")는 같은 통계 객체를 가리킵니다. toUnmodifiableMap과 Set.copyOf가 이 객체를 복사한 것은 아닙니다.
독립 확인: 예제의 보고서를 만든 뒤 report.get("notice").views().accept(100)을 한 번 실행했다고 정합니다. report와 기존 views Map에서 notice의 count·sum·max는 각각 무엇인가요? report 키 추가와 tags.add를 시도한 별도 두 호출은 허용되나요? 예제의 main이 이미 이런 변경을 수행한 출력은 아닙니다.
풀이 확인
두 경로 모두 같은 객체를 읽어 count 3·sum 200·max 100입니다. 통계에 원소 하나를 더 누산했으며 두 객체를 따로 수정한 것이 아닙니다. 바깥 Map 키 추가와 tags의 원소 추가는 각각 UnsupportedOperationException으로 거절됩니다. 변경 불가 Map·Set과 변경 가능한 참조 대상이 함께 있을 수 있습니다. 불변 보고서가 필요하다면 통계 객체 자체를 노출하는 대신 필요한 스칼라 값을 별도 불변 record에 복사하는 반환 설계를 검토합니다.
원본을 한 번만 읽어야 하는 대량 파이프라인이라면 사용자 정의 누산기나 teeing을 검토하되, 두 표준 수집기가 더 명확하다면 단순성을 우선합니다.
reducing으로 대표값 선택
분류별 최고 조회 수 PostMetric은 maxBy, 합산 조회 수는 reducing으로 구할 수 있습니다.
항등값 없는 reducing은 Optional 값을 만듭니다.
실제 그룹에는 원소가 있더라도 반환 타입이 Optional인 이유를 숨기려고 무조건 get을 호출하지 않습니다.
collectingAndThen(maxBy(...), optional -> optional.orElseThrow())로 그룹 값에서 Optional을 제거할 수 있습니다.
이때는 maxBy에 적어도 한 원소가 도달한다는 불변식이 필요합니다. 앞에 하위 filtering을 결합하면 키는 남아도 maxBy 결과는 비어 있을 수 있으므로, 키가 있다는 이유만으로 orElseThrow가 안전하다고 볼 수 없습니다.
연습 문제
Post의 분류, 제목, 조회 수, 공개 여부를 분류별 Summary로 만드세요.
공개된 제목만 불변 List에 담고 전체 게시글의 평균 조회 수는 별도 값으로 계산합니다.
공개된 게시글이 없는 분류도 빈 List로 남아야 합니다.
정답과 두 하위 수집기 결과 결합
filtering 하위 수집기로 키를 유지하고 averagingInt로 전체 평균을 구한 뒤 동일한 키의 결과를 Summary로 합칩니다.
import java.util.List;
import java.util.Map;
import java.util.stream.Collectors;
public final class CategoryPublicationSummarySolution {
private record Post(String category, String title, int viewCount, boolean published) {}
private record Summary(List<String> publishedTitles, double averageViews) {}
public static void main(String[] args) {
List<Post> source =
List.of(
new Post("notice", "welcome", 40, true),
new Post("notice", "event", 20, false),
new Post("free", "question", 30, false));
Map<String, List<String>> done =
source.stream()
.collect(
Collectors.groupingBy(
Post::category,
Collectors.filtering(
Post::published,
Collectors.mapping(
Post::title,
Collectors.collectingAndThen(
Collectors.toList(),
List::copyOf)))));
Map<String, Double> averages =
source.stream()
.collect(
Collectors.groupingBy(
Post::category,
Collectors.averagingInt(Post::viewCount)));
Map<String, Summary> result =
done.entrySet().stream()
.collect(
Collectors.toUnmodifiableMap(
Map.Entry::getKey,
entry ->
new Summary(
entry.getValue(),
averages.get(entry.getKey()))));
System.out.println(result);
}
}free 키의 공개된 제목은 빈 List이고 평균 조회 수는 30입니다.
notice 키에는 welcome과 평균 조회 수 30이 들어갑니다.
free에도 분류 키가 생기므로, 공개 제목이 하나도 축적되지 않은 상태를 List.copyOf가 빈 수정 불가 목록으로 마무리합니다. 평균 조회 수는 공개 여부로 선별하지 않은 별도 파이프라인의 결과입니다.
독립 확인: 마지막 공개 제목 연습의 기존 세 Post는 유지하고 notice·welcome·60·공개true를 하나 더 추가합니다. notice와 free의 publishedTitles·averageViews를 구하세요. 평균 파이프라인 앞에 published filter를 추가하면 어떤 요구를 잃나요?
풀이 확인
notice는 [welcome,welcome]과 (40+20+60)/3=40.0, free는 빈 목록과 30.0입니다. 공개 제목 List는 같은 이름 두 게시글을 모두 보존하며, 평균은 미공개 event20까지 포함합니다. 평균 앞에 공개 필터를 넣으면 notice 평균 50.0이 되고 free 키도 사라져 전체 게시글 평균 및 공개 제목이 없는 분류 유지 요구와 달라집니다. 공개 제목 수집과 전체 조회 수 수집의 입력 범위를 따로 확인하세요.
하위 수집기를 고를 때는 원소 변환, 그룹별 filter, 중첩 구조 평탄화, 스칼라 값 축약, 결과 마무리 가운데 어느 단계의 변화인지 구분합니다.
단계를 정확히 고르면 중간 List와 후처리 반복문 없이 그룹 값의 규칙을 바로 만들 수 있습니다.