본문 바로가기

groupingBy 하위 수집기로 그룹별 값 만들기

groupingBy가 나눈 그룹마다 개수·합계·고유 제목 같은 값을 곧바로 만들도록 counting·mapping·filtering·flatMapping·collectingAndThen을 조합하는 법을 배웁니다.

앞 글에서 본 groupingBy(PostMetric::category)는 Map<String, List<PostMetric>>을 돌려줍니다. 그런데 보고서에 필요한 것은 대개 게시글 목록 자체가 아니라 분류별 게시글 수, 제목 집합, 조회 수 합계 같은 값입니다. 목록을 먼저 만든 뒤 Map을 다시 돌면서 계산할 수도 있지만, 그러면 그룹마다 곧 버릴 리스트를 만들고 데이터를 두 번 훑게 됩니다.

groupingBy의 두 번째 인수로 하위 수집기(downstream collector)를 넘기면 이 계산을 수집하는 동안 바로 할 수 있습니다. 하위 수집기는 그룹 하나 안에서만 동작하는 수집기이고, 그것이 만든 결과가 그대로 Map의 값이 됩니다. 이 글에서는 하위 수집기가 동작하는 방식을 먼저 본 다음, 원소를 바꾸는 mapping, 그룹 결과를 바꾸는 collectingAndThen, 원소를 거르는 filtering, 중첩 목록을 펼치는 flatMapping을 차례로 조합해 봅니다.

하위 수집기는 그룹마다 하나씩 생긴다

groupingBy는 원소를 하나 받을 때마다 분류 함수로 키를 구합니다. 그 키를 처음 보면 하위 수집기의 공급자를 호출해 그 그룹만의 컨테이너를 만들고, 원소를 그 컨테이너에 누산합니다. 같은 키가 다시 나오면 이미 있는 컨테이너에 이어서 누산합니다. 원소를 모두 처리하고 나면 그룹마다 하위 수집기의 마무리 함수를 적용해 최종 값을 만듭니다. 그러니 Map 값의 타입은 하위 수집기가 무엇을 만드느냐로 정해집니다.

같은 게시글 세 개를 하위 수집기만 바꿔 세 번 묶어 보겠습니다. notice 게시글 두 개는 일부러 제목을 똑같이 welcome으로 두었습니다.

src/DownstreamCollectorShapes.java
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);
    }
}

세 Map은 키가 같고, 값의 타입만 Long, Set<String>, Integer로 서로 다릅니다. 세 결과를 한 줄씩 출력하면 다음과 같습니다.

{free=1, notice=2}
{free=[question], notice=[welcome]}
{free=30, notice=60}

세 줄은 같은 그룹을 서로 다른 단위로 셉니다. counting()은 게시글 수를 세므로 notice가 2입니다. mapping(PostMetric::title, toSet())은 게시글마다 제목을 꺼내 Set에 넣는데, 두 제목이 모두 welcome이라 하나만 남습니다. 게시글 수 2와 고유 제목 수 1은 다른 값이므로, 보고서에 '개수'라고만 적으면 무엇을 셌는지 헷갈립니다. summingInt는 조회 수 40과 20을 더해 60을 만듭니다. free는 게시글이 하나라서 개수 1, 제목 question, 합계 30입니다.

하위 수집기Map 값의 타입notice의 값
생략 (toList()와 같음)List<PostMetric>게시글 2개의 리스트
counting()Long2
mapping(PostMetric::title, toSet())Set<String>[welcome]
summingInt(PostMetric::viewCount)Integer60

세 줄 모두 free가 notice보다 먼저 나온 것은 groupingBy가 기본으로 쓰는 HashMap의 배치 때문이며, 정해진 순서가 아닙니다.

mapping은 원소마다, collectingAndThen은 그룹 결과에 한 번

하위 수집기를 감싸는 두 수집기는 이름이 비슷해 보이지만 동작하는 시점이 다릅니다. mapping(mapper, downstream)은 그룹에 들어오는 원소 하나하나를 mapper로 바꾼 뒤 downstream에 넘깁니다. collectingAndThen(downstream, finisher)는 downstream이 그룹의 원소를 모두 모아 결과를 완성한 뒤, 그 결과 하나에 finisher를 한 번 적용합니다.

아래 예제는 둘을 함께 씁니다. mapping은 Post를 제목 String으로 바꾸고, collectingAndThen은 완성된 제목 목록으로 개수와 제목을 담은 Group을 만듭니다.

src/MappingAndFinishingDifference.java
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);
    }
}

결과 Map을 통째로 출력합니다. 값 자리의 Group은 record라서 개수와 제목 목록이 구성 요소 이름과 함께 찍힙니다.

{free=Group[count=1, titles=[question]], notice=Group[count=2, titles=[welcome, event]]}

notice 게시글 두 개가 같은 제목 목록에 쌓이는지 먼저 예상해 본 다음, 아래 그림으로 posts의 세 원소를 순서대로 따라가 보세요.

제목 세 개를 모아 Group 두 개를 만든다

notice 제목 둘과 free 제목 하나가 각 누산 목록에 쌓인 후 finisher가 count와 복사한 titles로 Group을 만듭니다. 누산 중 가변 목록과 최종 결과를 구분할 실제 수집입니다.

제목 세 개를 모아 Group 두 개를 만든다

notice의 welcome과 event는 같은 그룹에 쌓이고 free의 question은 다른 그룹에 쌓입니다. 축적 뒤 각 제목 목록을 count와 titles가 있는 Group으로 마무리합니다.

원소 처리와 그룹 마무리

제목 세 개를 모아 Group 두 개를 만든다

MappingAndFinishingDifference의 같은 Post 세 개를 따라갑니다. 표와 목록은 각 키에 축적된 제목의 논리적 상태를 보여 줍니다.

같은 키에 제목을 모은 뒤 각 완성 목록을 Group으로 만든다원MappingAndFinishingDifference의Post3개를키notice/free로분류한뒤각Post의title을각그룹List에누산한다. notice의welcome,event는같은누산상태고freequestion는별개다. 수집완료뒤각List를count와List.copyOf의titles를가진Group으로마무리한다. 7노드6관계로mobile도동일하고함수호출정확횟수나맵키반복순서는보장하지않는다.같은 Post 3개notice 2 · free 1notice 키mapping: 제목누산 제목 Listwelcome · event완성 뒤 Groupcount 2titles 복사free 키mapping: 제목누산 제목 Listquestion완성 뒤 Groupcount 1titles 복사
같은 키에 제목을 모은 뒤 각 완성 목록을 Group으로 만든다원MappingAndFinishingDifference의Post3개를키notice/free로분류한뒤각Post의title을각그룹List에누산한다. notice의welcome,event는같은누산상태고freequestion는별개다. 수집완료뒤각List를count와List.copyOf의titles를가진Group으로마무리한다. 7노드6관계로mobile도동일하고함수호출정확횟수나맵키반복순서는보장하지않는다.같은 Post 3개notice 2 · free 1notice 키mapping: 제목누산 제목 Listwelcome · event완성 뒤 Groupcount 2titles 복사free 키mapping: 제목누산 제목 Listquestion완성 뒤 Groupcount 1titles 복사

원소를 읽을 때 같은 키의 제목 상태에 더한다

입력 전에는 두 키 모두 없습니다. Post::category로 키를 정하고, Post::title이 넘긴 제목을 그 그룹에 모읍니다.

원본 List의 만남 순서로 풀이한 축적 상태
읽은 Post (분류 / 제목)분류 키매핑한 제목notice에 쌓인 제목free에 쌓인 제목
notice / welcomenoticewelcome[welcome]키 없음
notice / eventnoticeevent[welcome, event]키 없음
free / questionfreequestion[welcome, event][question]
  1. 읽은 Post: notice / welcome

    분류 키 notice · 매핑한 제목 welcome

    notice: [welcome] · free: 키 없음

  2. 읽은 Post: notice / event

    분류 키 notice · 매핑한 제목 event

    notice: [welcome, event] · free: 키 없음

  3. 읽은 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으로 바꾸는 추가 변환이 있습니다.

이 예의 조건

  • 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개로 묶습니다.

  • notice: welcome,event
  • free: question
  • 같은 키의 제목은 같은 누산 상태에 속합니다.
그룹 누산

mapping으로 title을 각 그룹 List에 모읍니다.

  • notice 목록: welcome,event
  • free 목록: question
  • 분류 키에 따라 서로 다른 목록을 사용합니다.
완성 후 변환

각 완성 List를 count와 복사한 titles를 담은 Group으로 만듭니다.

  • notice: count 2, titles 복사
  • free: count 1, titles 복사
  • finishing은 각 그룹 누산이 끝난 뒤 완성 표현을 만듭니다.

제목 세 개를 모아 Group 두 개를 만든다

notice 제목 둘과 free 제목 하나가 각 누산 목록에 쌓인 후 finisher가 count와 복사한 titles로 Group을 만듭니다. 누산 중 가변 목록과 최종 결과를 구분할 실제 수집입니다.

제목 세 개를 모아 Group 두 개를 만든다

notice의 welcome과 event는 같은 그룹에 쌓이고 free의 question은 다른 그룹에 쌓입니다. 축적 뒤 각 제목 목록을 count와 titles가 있는 Group으로 마무리합니다.

원소 처리와 그룹 마무리

제목 세 개를 모아 Group 두 개를 만든다

MappingAndFinishingDifference의 같은 Post 세 개를 따라갑니다. 표와 목록은 각 키에 축적된 제목의 논리적 상태를 보여 줍니다.

같은 키에 제목을 모은 뒤 각 완성 목록을 Group으로 만든다원MappingAndFinishingDifference의Post3개를키notice/free로분류한뒤각Post의title을각그룹List에누산한다. notice의welcome,event는같은누산상태고freequestion는별개다. 수집완료뒤각List를count와List.copyOf의titles를가진Group으로마무리한다. 7노드6관계로mobile도동일하고함수호출정확횟수나맵키반복순서는보장하지않는다.같은 Post 3개notice 2 · free 1notice 키mapping: 제목누산 제목 Listwelcome · event완성 뒤 Groupcount 2titles 복사free 키mapping: 제목누산 제목 Listquestion완성 뒤 Groupcount 1titles 복사
같은 키에 제목을 모은 뒤 각 완성 목록을 Group으로 만든다원MappingAndFinishingDifference의Post3개를키notice/free로분류한뒤각Post의title을각그룹List에누산한다. notice의welcome,event는같은누산상태고freequestion는별개다. 수집완료뒤각List를count와List.copyOf의titles를가진Group으로마무리한다. 7노드6관계로mobile도동일하고함수호출정확횟수나맵키반복순서는보장하지않는다.같은 Post 3개notice 2 · free 1notice 키mapping: 제목누산 제목 Listwelcome · event완성 뒤 Groupcount 2titles 복사free 키mapping: 제목누산 제목 Listquestion완성 뒤 Groupcount 1titles 복사

원소를 읽을 때 같은 키의 제목 상태에 더한다

입력 전에는 두 키 모두 없습니다. Post::category로 키를 정하고, Post::title이 넘긴 제목을 그 그룹에 모읍니다.

원본 List의 만남 순서로 풀이한 축적 상태
읽은 Post (분류 / 제목)분류 키매핑한 제목notice에 쌓인 제목free에 쌓인 제목
notice / welcomenoticewelcome[welcome]키 없음
notice / eventnoticeevent[welcome, event]키 없음
free / questionfreequestion[welcome, event][question]
  1. 읽은 Post: notice / welcome

    분류 키 notice · 매핑한 제목 welcome

    notice: [welcome] · free: 키 없음

  2. 읽은 Post: notice / event

    분류 키 notice · 매핑한 제목 event

    notice: [welcome, event] · free: 키 없음

  3. 읽은 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으로 바꾸는 추가 변환이 있습니다.

이 예의 조건

  • 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개로 묶습니다.

  • notice: welcome,event
  • free: question
  • 같은 키의 제목은 같은 누산 상태에 속합니다.
그룹 누산

mapping으로 title을 각 그룹 List에 모읍니다.

  • notice 목록: welcome,event
  • free 목록: question
  • 분류 키에 따라 서로 다른 목록을 사용합니다.
완성 후 변환

각 완성 List를 count와 복사한 titles를 담은 Group으로 만듭니다.

  • notice: count 2, titles 복사
  • free: count 1, titles 복사
  • finishing은 각 그룹 누산이 끝난 뒤 완성 표현을 만듭니다.

첫 게시글의 분류는 notice이므로 notice 그룹이 생기고 그 제목 목록에 welcome이 들어갑니다. 두 번째 게시글도 notice라서 같은 목록에 event가 이어 붙고, 세 번째 게시글의 question은 새로 생긴 free 그룹의 목록에 들어갑니다. 원소를 모두 처리한 뒤에야 각 그룹의 완성된 목록이 마무리 함수에 넘어가 Group이 됩니다. 마무리 함수가 이보다 일찍 호출될 수는 없습니다. 어떤 원소가 그 그룹의 마지막인지는 스트림이 끝나야 알 수 있기 때문입니다.

값의 타입이 바뀌는 과정을 정리하면 이렇습니다. 원본 원소는 Post이고, mapping이 이것을 제목 String으로 바꿉니다. toList()는 그 제목들을 모아 List<String>을 완성하고, 마무리 함수가 그 목록을 Group으로 바꿉니다. 그래서 최종 결과는 Map<String, List<String>>이 아니라 Map<String, Group>입니다. 제목 목록은 원본 리스트의 순서를 지켜 [welcome, event]가 되지만, 앞 절과 마찬가지로 Map의 두 키가 출력되는 순서는 정해져 있지 않습니다.

Group을 만드는 코드를 어디에 두느냐가 중요합니다. 이것을 mapping 쪽에 두면 게시글마다 Group이 하나씩 생겨서, 같은 분류의 제목을 모은 그룹 하나라는 요구와 맞지 않게 됩니다. 그룹 전체를 보고 만들어야 하는 값은 완성된 결과를 받는 collectingAndThen에 둡니다. 개수나 제목이 예상과 다르다면 두 notice 게시글의 분류 키가 정말 같은지, mapping이 제목을 넘기는지부터 확인합니다.

더 깊이: 감춰진 누산 타입

Collectors의 메서드 시그니처를 보면 Collector<T, ?, R>처럼 가운데 타입 인수가 ?로 되어 있습니다. 이 자리가 누산하는 동안 쓰는 컨테이너의 타입인데, API가 일부러 감춰 두었습니다. 그래서 toList()가 안에서 ArrayList를 쓰는지 알 필요도 없고, 그것에 기대서도 안 됩니다. 그림의 목록도 누산된 제목의 논리적인 상태를 보여 줄 뿐 특정 구현을 뜻하지 않습니다.

JDK 21의 groupingBy는 키마다 이 감춰진 컨테이너를 값으로 둔 Map을 만들어 두고, 키를 처음 만날 때 하위 수집기의 공급자를 호출합니다. 원소가 모두 들어오면, 하위 수집기에 IDENTITY_FINISH 특성이 없을 때만 각 값에 마무리 함수를 적용해 컨테이너를 최종 값으로 바꿉니다. 공급자·누산 함수·마무리 함수에 출력문을 넣은 수집기를 Collector.of로 만들어 확인해 보겠습니다.

src/DownstreamCallTrace.java
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import java.util.stream.Collector;
import java.util.stream.Collectors;

public final class DownstreamCallTrace {
    private record Post(String category, String title) {}

    public static void main(String[] args) {
        Collector<Post, List<String>, Integer> traced =
                Collector.of(
                        () -> {
                            System.out.println("공급자 호출");
                            return new ArrayList<>();
                        },
                        (titles, post) -> {
                            System.out.println("누산: " + post.title());
                            titles.add(post.title());
                        },
                        (left, right) -> {
                            left.addAll(right);
                            return left;
                        },
                        titles -> {
                            System.out.println("마무리: " + titles);
                            return titles.size();
                        });
        List<Post> posts =
                List.of(
                        new Post("notice", "welcome"),
                        new Post("notice", "event"),
                        new Post("free", "question"));
        Map<String, Integer> counts =
                posts.stream().collect(Collectors.groupingBy(Post::category, traced));
        System.out.println(counts);
    }
}

세 함수의 출력문은 각각 공급자, 누산, 마무리로 시작하므로, 줄마다 어느 함수가 불렸는지 구별할 수 있습니다. JDK 21에서 실행하면 호출 순서가 이렇게 찍힙니다.

공급자 호출
누산: welcome
누산: event
공급자 호출
누산: question
마무리: [question]
마무리: [welcome, event]
{free=1, notice=2}

공급자는 notice와 free를 처음 만날 때 한 번씩 불렸고, 마무리 함수는 원소 세 개를 모두 누산한 뒤에 그룹마다 한 번씩 불렸습니다. free가 먼저 마무리된 것은 내부 Map의 반복 순서를 따랐기 때문입니다. 다만 이 호출 횟수와 순서는 구현의 동작일 뿐 명세가 보장하는 것이 아니므로, 공급자나 마무리 함수가 몇 번, 어떤 순서로 불리는지에 기대는 코드는 쓰지 않습니다.

마무리 함수로 수정할 수 없는 목록 만들기

마무리 함수는 결과의 모양만 바꾸는 것이 아니라, 결과를 받은 쪽이 그것을 고칠 수 있는지도 바꿀 수 있습니다. 앞 예제의 List.copyOf가 그런 역할을 합니다. 아래 코드는 collectingAndThen(toList(), List::copyOf)로 만든 리스트에 원소를 더하려고 합니다.

lab/FinishedListMutationFailure.java
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");
    }
}

collect까지는 문제없이 끝나므로, 결과를 받는 것만으로는 이 리스트를 고칠 수 없다는 사실이 드러나지 않습니다. 실행하면 add를 호출하는 순간 예외가 납니다.

Exception in thread "main" java.lang.UnsupportedOperationException
	at java.base/java.util.ImmutableCollections.uoe(ImmutableCollections.java:142)
	at java.base/java.util.ImmutableCollections$AbstractImmutableCollection.add(ImmutableCollections.java:147)
	at FinishedListMutationFailure.main(FinishedListMutationFailure.java:9)

List.copyOf는 주어진 리스트를 복사해 수정할 수 없는 리스트를 돌려주므로, 그 결과에 add를 호출하면 UnsupportedOperationException이 납니다. 스택 트레이스의 마지막 줄이 가리키는 9번째 줄이 result.add("draft")입니다. 호출한 쪽이 결과를 고치면 안 될 때는 이렇게 막아 두고, 반대로 원소를 더 넣어야 한다면 toCollection(ArrayList::new)로 모읍니다. Java 10부터는 toUnmodifiableList() 하나로 같은 결과를 얻을 수도 있습니다.

List.copyOf에는 주의할 점이 두 가지 있습니다. 첫째, 원소 가운데 null이 있으면 NullPointerException을 던집니다. 둘째, 복사는 얕은 복사라서 리스트 구조만 잠글 뿐 원소 객체는 그대로 공유합니다. 이 절의 원소는 String이라 그 자체로 바꿀 수 없으니 문제가 없지만, 원소가 가변 객체라면 리스트에 add를 못 하게 막아도 원소의 상태는 여전히 바뀔 수 있습니다. 뒤의 flatMapping 절에서 그런 경우를 봅니다.

filter와 filtering: 조건에 맞는 원소가 없는 분류를 남길까

보고서에 조건을 통과한 게시글이 있는 분류만 보여 줄지, 원본에 나온 분류를 모두 보여 주면서 통과한 게시글이 없으면 빈 목록으로 표시할지 먼저 정해야 합니다. 이 선택에 따라 조건을 거는 위치가 달라집니다. 아래 예제는 조회 수 20인 notice 게시글과 70인 free 게시글에 '조회 수 60 이상' 조건을 두 위치에 걸어 봅니다.

src/DownstreamFilteringKeys.java
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());
    }
}

앞쪽에서 거른 결과의 키 목록과, 하위 수집기에서 거른 결과의 notice 목록 크기를 출력합니다. get은 키가 없으면 null을 돌려주므로, 둘째 값이 예외 없이 찍힌다면 downstream에는 notice 키가 있다는 뜻입니다.

upstream-keys=[free], downstream-notice-size=0

groupingBy 앞에 둔 filter는 분류하기 전에 원소를 버립니다. notice의 20은 여기서 탈락해 분류 함수에 닿지도 못하므로, upstream에는 notice 키가 아예 없습니다. 반면 하위 수집기 자리의 filtering은 먼저 notice 그룹을 만든 뒤 그 안에서 원소를 거절합니다. 그래서 downstream에는 notice 키가 남고, 그 값은 원소가 하나도 들어가지 않은 빈 리스트입니다. free의 70은 조건을 통과하므로 두 결과 모두에 원소 하나로 남습니다.

조건을 통과하지 못한 분류를 키에서 지울지 남길지 고른다

notice 20을 앞에서 제거하면 키가 생기지 않고 먼저 그룹을 만들면 빈 notice 목록이 남습니다. 같은 입력에 대해 분류 전/후 선택의 생성 상태를 비교할 수 있습니다.

조건을 통과하지 못한 분류를 키에서 지울지 남길지 고른다

notice 20과 free 70에 조회 수 60 이상 조건을 적용한 두 수집 결과입니다.

선별을 어디에 놓을까

조건을 통과하지 못한 분류를 키에서 지울지 남길지 고른다

원본은 notice 20·free 70이고 조건은 조회 수 60 이상입니다. 선별 위치에 따라 notice 키가 사라지거나 빈 목록으로 남습니다.

선별 위치가 같은 분류의 빈 키 보존을 바꾼다원notice20/free70와60이상조건이다. 왼쪽filter는분류전notice를제거해free만키가되고오른쪽filtering은분류한notice키에빈List를남긴다. 같은순차원본을두독립Stream으로소비한값관계5노드4간선이며한Stream재사용아니다. 원본에없는archive키는어느쪽도만들지않는다.같은 원본 · 조건 ≥60notice20 · free70filter → groupingBynotice는 분류 전 탈락앞쪽 선별 결과free: [70]notice: 키 없음groupingBy → filteringnotice 분류 후 거절하위 선별 결과notice: []free: [70]
선별 위치가 같은 분류의 빈 키 보존을 바꾼다원notice20/free70와60이상조건이다. 왼쪽filter는분류전notice를제거해free만키가되고오른쪽filtering은분류한notice키에빈List를남긴다. 같은순차원본을두독립Stream으로소비한값관계5노드4간선이며한Stream재사용아니다. 원본에없는archive키는어느쪽도만들지않는다.같은 원본 · 조건 ≥60notice20 · free70filter → groupingBynotice는 분류 전 탈락앞쪽 선별 결과free: [70]notice: 키 없음groupingBy → filteringnotice 분류 후 거절하위 선별 결과notice: []free: [70]

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 키는 어느 쪽도 만들지 않는다.
4notice 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 20, free 70
  • 그룹: 아직 없음
  • 선택 위치만 다르게 두어 키 생성 차이를 비교한다.
notice 처리

앞쪽은 분류 전에 버리고 하위는 그룹을 만든 후 원소를 버립니다.

  • 앞쪽: notice 키 없음
  • 하위: notice:[]
  • 값을 거절하는 시점이 키 존재 여부를 바꾼다.
free 처리와 완성

통과하는 70은 두 실행 모두 free에 남습니다.

  • 앞쪽 결과: free:[70]
  • 하위 결과: notice:[], free:[70]
  • 동일한 통과 값이어도 그룹 키의 결과 계약은 다르다.

조건을 통과하지 못한 분류를 키에서 지울지 남길지 고른다

notice 20을 앞에서 제거하면 키가 생기지 않고 먼저 그룹을 만들면 빈 notice 목록이 남습니다. 같은 입력에 대해 분류 전/후 선택의 생성 상태를 비교할 수 있습니다.

조건을 통과하지 못한 분류를 키에서 지울지 남길지 고른다

notice 20과 free 70에 조회 수 60 이상 조건을 적용한 두 수집 결과입니다.

선별을 어디에 놓을까

조건을 통과하지 못한 분류를 키에서 지울지 남길지 고른다

원본은 notice 20·free 70이고 조건은 조회 수 60 이상입니다. 선별 위치에 따라 notice 키가 사라지거나 빈 목록으로 남습니다.

선별 위치가 같은 분류의 빈 키 보존을 바꾼다원notice20/free70와60이상조건이다. 왼쪽filter는분류전notice를제거해free만키가되고오른쪽filtering은분류한notice키에빈List를남긴다. 같은순차원본을두독립Stream으로소비한값관계5노드4간선이며한Stream재사용아니다. 원본에없는archive키는어느쪽도만들지않는다.같은 원본 · 조건 ≥60notice20 · free70filter → groupingBynotice는 분류 전 탈락앞쪽 선별 결과free: [70]notice: 키 없음groupingBy → filteringnotice 분류 후 거절하위 선별 결과notice: []free: [70]
선별 위치가 같은 분류의 빈 키 보존을 바꾼다원notice20/free70와60이상조건이다. 왼쪽filter는분류전notice를제거해free만키가되고오른쪽filtering은분류한notice키에빈List를남긴다. 같은순차원본을두독립Stream으로소비한값관계5노드4간선이며한Stream재사용아니다. 원본에없는archive키는어느쪽도만들지않는다.같은 원본 · 조건 ≥60notice20 · free70filter → groupingBynotice는 분류 전 탈락앞쪽 선별 결과free: [70]notice: 키 없음groupingBy → filteringnotice 분류 후 거절하위 선별 결과notice: []free: [70]

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 키는 어느 쪽도 만들지 않는다.
4notice 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 20, free 70
  • 그룹: 아직 없음
  • 선택 위치만 다르게 두어 키 생성 차이를 비교한다.
notice 처리

앞쪽은 분류 전에 버리고 하위는 그룹을 만든 후 원소를 버립니다.

  • 앞쪽: notice 키 없음
  • 하위: notice:[]
  • 값을 거절하는 시점이 키 존재 여부를 바꾼다.
free 처리와 완성

통과하는 70은 두 실행 모두 free에 남습니다.

  • 앞쪽 결과: free:[70]
  • 하위 결과: notice:[], free:[70]
  • 동일한 통과 값이어도 그룹 키의 결과 계약은 다르다.

filtering도 원본에 전혀 나오지 않은 분류까지 만들어 주지는 않습니다. 이 원본에는 archive 게시글이 없으므로 두 결과 모두 archive 키가 없습니다. 게시글이 없어도 정해진 분류를 모두 보여 줘야 한다면, 수집 결과와 별개로 전체 분류 목록에서 출발해 빠진 키를 채워야 합니다. 결과가 예상과 다르면 조건이 groupingBy 앞의 filter에 있는지, 하위 수집기의 filtering에 있는지부터 확인하세요. Collectors.filtering의 Javadoc도 같은 예로 두 방식의 차이를 설명합니다.

그룹의 최댓값에서 Optional 벗기기

분류별로 조회 수가 가장 많은 게시글은 maxBy로 구하고, 그 결과는 Optional입니다. 앞 글에서 봤듯이 groupingBy 안에서는 그룹마다 원소가 적어도 하나 있으니 이 Optional은 늘 값을 가집니다. 그렇다면 collectingAndThen으로 Optional을 벗겨 내, Map의 값을 바로 쓸 수 있게 만들 수 있습니다. 그런데 이 가정은 filtering과 함께 쓰는 순간 깨집니다. 아래 예제가 두 경우를 차례로 보여 줍니다.

src/TopPostPerCategory.java
import java.util.Comparator;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.stream.Collectors;

public final class TopPostPerCategory {
    private record PostMetric(String category, String title, int viewCount) {}

    public static void main(String[] args) {
        List<PostMetric> source =
                List.of(
                        new PostMetric("notice", "welcome", 20),
                        new PostMetric("free", "question", 70));
        Comparator<PostMetric> byViews = Comparator.comparingInt(PostMetric::viewCount);

        Map<String, String> topTitles =
                source.stream()
                        .collect(
                                Collectors.groupingBy(
                                        PostMetric::category,
                                        Collectors.collectingAndThen(
                                                Collectors.maxBy(byViews),
                                                top -> top.orElseThrow().title())));
        System.out.println(topTitles);

        Map<String, Optional<PostMetric>> popularTop =
                source.stream()
                        .collect(
                                Collectors.groupingBy(
                                        PostMetric::category,
                                        Collectors.filtering(
                                                post -> post.viewCount() >= 60,
                                                Collectors.maxBy(byViews))));
        System.out.println(popularTop);
        System.out.println(popularTop.get("notice").orElseThrow().title());
    }
}

두 Map은 출력되지만, 마지막 줄에서 예외가 납니다. 이 줄은 try 없이 orElseThrow()를 부르므로, 값이 비어 있으면 프로그램이 그 자리에서 끝납니다.

{free=question, notice=welcome}
{free=Optional[PostMetric[category=free, title=question, viewCount=70]], notice=Optional.empty}
Exception in thread "main" java.util.NoSuchElementException: No value present
	at java.base/java.util.Optional.orElseThrow(Optional.java:377)
	at TopPostPerCategory.main(TopPostPerCategory.java:36)

첫 줄에서는 maxBy의 결과에 마무리 함수 top -> top.orElseThrow().title()을 적용해, 분류마다 최고 조회 수 게시글의 제목만 남겼습니다. 그룹마다 원소가 있으므로 여기서 orElseThrow()가 예외를 던질 일은 없습니다.

두 번째 줄은 maxBy 앞에 '조회 수 60 이상' filtering을 넣은 결과입니다. notice 그룹은 만들어졌지만 조회 수 20인 게시글이 걸러져 maxBy에 원소가 하나도 닿지 않았으므로, 값이 Optional.empty입니다. 그래서 마지막 줄에서 notice 값에 orElseThrow()를 호출하자 NoSuchElementException이 났습니다. 키가 있다고 해서 그 값의 Optional에 내용이 있다고 볼 수는 없다는 뜻입니다.

이런 결과를 조회할 때는 세 가지 상태를 구별해야 합니다. 키가 있고 값도 있는 경우(free), 키는 있지만 값이 빈 경우(notice), 그리고 키 자체가 없어 get이 null을 돌려주는 경우(archive)입니다. 하위 filtering을 쓴다면 orElseThrow() 대신 orElse나 map으로 빈 경우를 처리하거나, 빈 그룹을 결과에서 빼는 것이 맞는지 다시 생각해 봅니다.

flatMapping으로 중첩 목록 펼치기

게시글마다 태그 목록이 있고, 분류별로 쓰인 태그를 중복 없이 모으고 싶다고 합시다. mapping(post -> post.tags(), toSet())을 쓰면 게시글의 태그 List가 통째로 Set의 원소가 되어 Set<List<String>>이 만들어집니다. notice라면 [welcome, admin]과 [event, admin] 두 리스트를 담은 집합이라서, admin이 두 번 들어 있고 태그를 하나씩 셀 수도 없습니다. flatMapping은 원소마다 스트림을 받아 그 안의 값을 하나씩 하위 수집기에 넘기므로, 태그가 하나의 Set<String>으로 펼쳐집니다. 중간 연산 flatMap이 하는 일을 그룹 안에서 하는 셈입니다.

아래 예제는 분류별 태그 집합과 조회 수 통계를 따로 모은 뒤, 같은 키끼리 묶어 수정할 수 없는 보고서 Map을 만듭니다.

app/CategoryDownstreamReport.java
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);
    }
}

보고서를 출력한 한 예입니다. 분류와 태그의 순서는 실행할 때마다 달라질 수 있습니다.

{notice=Summary[tags=[welcome, event, admin], views=IntSummaryStatistics{count=2, sum=100, min=40, average=50.000000, max=60}], free=Summary[tags=[question], views=IntSummaryStatistics{count=1, sum=30, min=30, average=30.000000, max=30}]}

notice의 태그 집합에는 welcome, event, admin 세 개가 들어 있습니다. admin은 두 게시글에 모두 붙어 있지만 Set이라 한 번만 남았습니다. 조회 수 통계는 40과 60을 모아 개수 2, 합계 100입니다. 순서가 실행마다 바뀌는 이유는 toUnmodifiableMap과 Set.copyOf가 만드는 컬렉션의 반복 순서가 정해져 있지 않기 때문입니다. JDK 21에서 이 프로그램을 여러 번 실행하면 notice와 free의 순서도, 태그의 순서도 실제로 바뀝니다.

바깥 Map과 태그 집합을 잠가도 통계 객체는 바뀔 수 있다

CategoryDownstreamReport의 세 결과 계층에서 수정 가능한 대상을 구분합니다.

각 결과 계층의 변경 권한

바깥 Map과 태그 집합을 잠가도 통계 객체는 바뀔 수 있다

바깥 Map과 tags 집합은 수정할 수 없습니다. views는 기존 IntSummaryStatistics 객체 참조여서 통계 값을 바꿀 수 있습니다.

변경 불가 컨테이너 안에도 같은 가변 통계 참조가 남는다원CategoryDownstreamReport의notice만펼친참조관계다. 변경불가reportMap이Summary를가리키고Summary는변경불가tagsSet과가변IntSummaryStatistics참조를보관한다. 원viewsMap의notice도같은통계객체를가리킨다. 실제메모리주소나호출시간이아니고5노드4참조이며mobile에서도둘의참조가하나의통계객체에합류한다. record는필드참조를고정할뿐통계의accept/combine을막지않는다.바깥 report Map키·값 교체 불가notice Summary record참조를 보관tags Set.copyOf원소 추가 불가원 views Mapnotice 통계 참조같은 통계 객체accept·combine 가능
변경 불가 컨테이너 안에도 같은 가변 통계 참조가 남는다원CategoryDownstreamReport의notice만펼친참조관계다. 변경불가reportMap이Summary를가리키고Summary는변경불가tagsSet과가변IntSummaryStatistics참조를보관한다. 원viewsMap의notice도같은통계객체를가리킨다. 실제메모리주소나호출시간이아니고5노드4참조이며mobile에서도둘의참조가하나의통계객체에합류한다. record는필드참조를고정할뿐통계의accept/combine을막지않는다.바깥 report Map키·값 교체 불가notice Summary record참조를 보관tags Set.copyOf원소 추가 불가원 views Mapnotice 통계 참조같은 통계 객체accept·combine 가능

바깥 report Map

만드는 방법
toUnmodifiableMap
허용되는 변경
키 추가·삭제·값 교체 불가

Summary의 tags

만드는 방법
Set.copyOf
허용되는 변경
집합 원소 추가·삭제 불가

Summary의 views

만드는 방법
IntSummaryStatistics 참조
허용되는 변경
accept·combine으로 통계 변경 가능

record는 필드 참조를 고정하지만 참조 대상의 가변성을 없애지 않습니다. 원문 main은 생성된 보고서를 변경하지 않습니다.

그림은 이 보고서에서 무엇을 고칠 수 있는지 보여 줍니다. 바깥 report는 toUnmodifiableMap으로 만들었으니 키를 더하거나 값을 바꿀 수 없고, 태그 집합도 Set.copyOf로 복사했으니 원소를 더할 수 없습니다. 그러나 Summary의 views는 복사본이 아니라 views 맵에 들어 있던 IntSummaryStatistics 객체 그 자체입니다. IntSummaryStatistics는 accept로 값을 더 누산할 수 있는 가변 객체이므로, report.get("notice").views().accept(100)을 호출하면 보고서의 통계가 바뀌고, 같은 객체를 가리키는 views.get("notice")도 함께 바뀝니다.

record는 필드가 가리키는 객체를 다른 객체로 바꾸지 못하게 할 뿐, 그 객체의 상태 변화까지 막지는 않습니다. 정말로 바뀌지 않는 보고서가 필요하다면 통계 객체를 그대로 넣지 말고, 개수·합계·최댓값처럼 필요한 값을 꺼내 별도의 record에 복사해서 돌려줍니다.

이 예제는 원본을 두 번 훑고, 두 결과를 키로 다시 맞춰 붙였습니다. 하위 수집기가 각각 단순하니 읽기는 쉽지만, 원본을 한 번만 읽어야 하는 상황이라면 아래의 teeing을 검토합니다.

더 깊이: teeing으로 그룹마다 두 결과를 한 번에 만들기

Collectors.teeing(first, second, merger)(Java 12+)는 원소 하나하나를 두 수집기에 함께 넘기고, 두 수집기의 결과를 merger로 합칩니다. 이것을 하위 수집기로 쓰면 앞 예제의 태그 집합과 조회 수 통계를 그룹마다 한 번에 만들 수 있습니다.

src/TeeingCategoryReport.java
import java.util.IntSummaryStatistics;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.stream.Collectors;

public final class TeeingCategoryReport {
    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, Summary> report =
                source.stream()
                        .collect(
                                Collectors.groupingBy(
                                        PostMetric::category,
                                        Collectors.teeing(
                                                Collectors.flatMapping(
                                                        post -> post.tags().stream(),
                                                        Collectors.toUnmodifiableSet()),
                                                Collectors.summarizingInt(PostMetric::viewCount),
                                                Summary::new)));
        System.out.println(report.get("notice"));
        System.out.println(report.get("free"));
    }
}

두 분류의 Summary를 차례로 출력합니다. 태그 순서는 이번에도 실행마다 다를 수 있습니다.

Summary[tags=[welcome, event, admin], views=IntSummaryStatistics{count=2, sum=100, min=40, average=50.000000, max=60}]
Summary[tags=[question], views=IntSummaryStatistics{count=1, sum=30, min=30, average=30.000000, max=30}]

원소가 하나 들어올 때마다 flatMapping은 태그를 집합에 넣고, summarizingInt는 조회 수를 통계에 누산합니다. 그룹의 원소를 다 모으면 teeing이 두 결과를 Summary::new, 즉 Summary의 생성자에 넘깁니다. 원본을 한 번만 훑고, 키를 맞춰 붙이는 단계도 사라졌습니다. 다만 views가 가변 IntSummaryStatistics라는 점은 그대로이므로, 앞 절에서 본 수정 문제는 이 방법으로도 사라지지 않습니다. 하위 수집기가 세 겹, 네 겹으로 겹치면 오히려 읽기 어려워지니, 한 번만 읽어야 할 이유가 없다면 앞 예제처럼 나누는 편이 낫습니다.

연습 문제

  1. 게시글 Post의 분류, 제목, 조회 수, 공개 여부로 분류별 Summary를 만드세요. Summary에는 공개된 게시글의 제목만 담은 수정할 수 없는 List와, 공개 여부와 상관없이 그 분류의 모든 게시글로 계산한 평균 조회 수가 들어갑니다. 공개된 게시글이 없는 분류도 빈 List로 결과에 남아야 합니다.
  2. MappingAndFinishingDifference에서 notice 게시글 두 개의 제목을 모두 welcome으로 바꾸면 notice의 Group은 어떻게 나오나요? 같은 입력을 mapping(Post::title, toSet())으로 모은 결과와 비교하세요.
  3. CategoryDownstreamReport의 main 끝에 report.get("notice").views().accept(100);을 추가하면, report와 views 맵에서 notice 통계의 개수·합계·최댓값은 각각 어떻게 되나요? report에 키를 추가하거나 notice의 태그 집합에 원소를 추가하는 호출은 성공할까요?

힌트

  1. 공개 제목은 filtering 안에 mapping을 넣어 모으고, 평균은 averagingInt로 따로 모은 뒤 두 결과를 같은 키로 합칩니다.
  2. List는 중복을 그대로 두고, List.copyOf는 중복을 지우지 않습니다.
  3. report의 views와 views.get("notice")가 같은 객체인지 생각해 보세요.

해설

  1. filtering 하위 수집기로 공개 게시글이 없는 분류의 키도 남기고, 평균은 거르지 않은 별도 파이프라인에서 구합니다.
exercise/CategoryPublicationSummarySolution.java
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=Summary[publishedTitles=[], averageViews=30.0], notice=Summary[publishedTitles=[welcome], averageViews=30.0]}

결과 Map을 toUnmodifiableMap으로 만들었으므로 두 키의 출력 순서는 실행마다 달라질 수 있습니다. filtering이 하위 수집기 자리에 있으니 공개 게시글이 없는 free도 키가 생기고, 제목이 하나도 누산되지 않은 목록을 List.copyOf가 빈 수정 불가 리스트로 마무리합니다. 평균은 공개 여부로 거르지 않았으므로 notice는 공개된 welcome(40)과 비공개 event(20)의 평균인 30.0이고, free는 30.0입니다.

평균 파이프라인 앞에도 filter(Post::published)를 넣으면 요구가 달라집니다. notice의 평균이 공개 게시글만으로 계산한 40.0이 되고, free 키는 사라집니다. 그러면 averages.get("free")가 null을 돌려주고, 이것을 Summary의 double 필드로 언박싱하는 순간 NullPointerException이 납니다. 두 파이프라인이 각각 어떤 원소를 대상으로 하는지 따로 확인해야 하는 이유입니다.

  1. Group[count=2, titles=[welcome, welcome]]입니다. List는 같은 제목 두 개를 모두 담고, List.copyOf는 구조를 잠글 뿐 중복을 지우지 않습니다. 반면 toSet()으로 모으면 [welcome] 하나만 남습니다. 그러니 count의 2는 게시글 수이지 고유 제목 수가 아닙니다.

  2. 두 곳 모두 개수 3, 합계 200, 최댓값 100이 됩니다. report의 views와 views.get("notice")는 같은 IntSummaryStatistics 객체이므로, accept(100) 한 번이 양쪽에 똑같이 보입니다. 반면 report.put(...)과 태그 집합의 add(...)는 둘 다 UnsupportedOperationException으로 거절됩니다. 수정할 수 없는 컨테이너 안에도 수정할 수 있는 객체가 들어 있을 수 있다는 것이 이 문제의 요점입니다.

정리

  • 하위 수집기는 그룹마다 따로 만들어지고, 그것이 만든 결과가 Map의 값이 됩니다. counting, summingInt, mapping(..., toSet()) 가운데 무엇을 고르느냐에 따라 값의 타입과 세는 단위가 달라집니다.
  • mapping은 그룹에 들어오는 원소마다, collectingAndThen은 그룹의 완성된 결과에 한 번 적용됩니다. 그룹 전체를 보고 만들어야 하는 값은 collectingAndThen에 둡니다.
  • groupingBy 앞의 filter는 조건을 통과한 원소가 없는 분류의 키를 없애고, 하위 filtering은 그 키를 빈 결과로 남깁니다. 원본에 없던 분류는 어느 쪽도 만들지 않습니다.
  • 하위 filtering 뒤의 maxBy는 빈 Optional을 낼 수 있으므로, 키가 있다는 이유만으로 orElseThrow()가 안전하다고 보면 안 됩니다.
  • List.copyOf, Set.copyOf, toUnmodifiableMap은 컨테이너의 구조만 잠급니다. 그 안에 든 가변 객체는 여전히 바뀔 수 있습니다.

다음 글

이 장에서 findFirst, max, maxBy가 돌려주는 Optional을 여러 번 만났습니다. 33장에서는 Optional을 메서드의 반환 타입으로 설계하는 방법과, 값을 꺼내고 바꾸는 API를 목적에 맞게 고르는 방법을 다룹니다.