본문으로 건너뛰기

안동민 개발노트

본문 시작

다운스트림 Collector

groupingBy의 그룹 List를 후처리하지 않고 downstream Collector를 조합해 원하는 값으로 직접 수집합니다.

groupingBy의 기본 하위 수집기는 toList()이므로 Map의 값은 원본 원소의 List입니다.

그룹별 count, 게시글 제목 Set, 통계, 불변 요약값이 필요하다면 먼저 List를 만든 뒤 다시 순회할 이유가 없습니다.

하위 수집기는 각 분류 함수 그룹 안에서 곧바로 원하는 결과 타입을 만듭니다.

groupingBy는 key bucket마다 downstream collector를 실행한다

group을 만든 뒤 별도 loop로 변환하지 않아도 bucket 안의 집계를 한 collector 계약으로 표현할 수 있습니다.

  1. classifier

    T→K

  2. toList

    K→List<T>

  3. counting

    K→Long

  4. mapping

    T→R 후 collect

  5. reducing

    bucket 축약


collectingAndThen의 불변 결과

collectingAndThen(toList(), List::copyOf)의 마무리 함수는 수정할 수 없는 List를 반환합니다.

아래 코드는 수집 뒤 add를 호출해 실제 UnsupportedOperationException이 발생합니다.

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");
    }
}

마무리 함수는 단순한 장식이 아니라 결과의 소유권 규칙을 바꿉니다.

수정 가능한 결과가 필요하면 toCollection(ArrayList::new), 불변 스냅샷이 필요하면 List.copyOf를 사용하고 API 문서에 명시합니다.


하위 수집기가 그룹 안에서 작동하는 위치

분류 함수가 키를 정하면 groupingBy는 해당 키의 하위 수집기 누산기에 원소를 전달합니다.

기본 toList 대신 counting을 주면 값은 Long, mappingtoSet을 조합하면 Set, summarizingInt를 주면 통계 객체가 됩니다.

mapping과 collecting And Then은 bucket 안 값과 완성 결과를 서로 다른 시점에 바꾼다

둘을 같은 map으로 생각하면 element 변환과 collection finishing 경계를 혼동합니다.

  1. bucket T

    groupingBy input

  2. mapping

    T→R per element

  3. downstream collect

    축적

  4. downstream finish

    A→D

  5. collecting And Then

    D→RR once

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);
    }
}

같은 원본에서도 하위 수집기에 따라 값 타입이 달라집니다.


mapping·collectingAndThen 위치

mapping(mapper, downstream)은 그룹에 들어오는 각 원소를 바꾼 뒤 하위 수집기로 보냅니다.

collectingAndThen(downstream, finisher)는 그룹의 완성 결과 하나를 후처리합니다.

전자는 PostMetric을 제목 String으로, 후자는 List를 불변 ListSummary 레코드로 바꾸는 데 적합합니다.

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);
    }
}

매퍼는 원소마다 실행되고 마무리 함수는 그룹마다 한 번 실행됩니다.

마무리 함수가 가변 누산기를 외부와 공유하지 않도록 방어적 복사본을 만듭니다.

Upstream filter와 downstream filtering은 빈 key 보존이 다르다

같은 predicate라도 grouping 전후 위치에 따라 key 자체가 사라지거나 빈 bucket이 남습니다.

  1. stream.filter → grouping

    제외 원소의 key는 생성 안 될 수 있음

  2. grouping → filtering

    관찰된 key에 빈 downstream 가능

  3. 선택 기준

    빈 key가 의미 있는가


filtering과 빈 그룹 키

파이프라인 앞쪽의 filtergroupingBy보다 먼저 적용하면 조건을 통과한 원소가 하나도 없는 키 자체가 사라집니다.

하위 수집기 filtering은 분류 함수로 그룹을 만든 뒤 그룹 안에서 원소를 걸러, 결과가 빈 컬렉션인 키도 남길 수 있습니다.

보고서에 등장한 모든 게시판 분류를 표시할지 먼저 결정합니다.

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());
    }
}

앞쪽에서 filter한 결과에는 free 키만 있고, 하위 수집기로 거른 결과에는 notice 키와 빈 List도 남습니다.

어느 결과가 맞는지는 보고서 스키마가 키의 완전성을 요구하는지에 달려 있습니다.


flatMapping과 중첩 컬렉션

PostMetric이 태그 List를 가진다면 groupingBy의 하위 수집기로 flatMapping(post -> post.tags().stream(), toSet())을 사용해 분류별 고유 태그 Set을 바로 만들 수 있습니다.

mapping을 쓰면 List를 원소로 가진 Set이 되어 결과 형태가 달라집니다.

Downstream 변환 위치는 원소·bucket·전체 결과 중 어디를 바꾸는지로 고른다

모든 변환을 collect 뒤 loop로 처리하면 타입 관계와 parallel 결합 기회를 잃습니다.

  1. 원소

    mapping

  2. 선별

    filtering

  3. bucket 결과

    collectingAndThen

  4. 전체 Map

    terminal 뒤 명시 변환

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);
    }
}

두 그룹 결과를 키로 결합했습니다.

원본을 한 번만 읽어야 하는 대량 파이프라인이라면 사용자 정의 누산기나 teeing을 검토하되, 두 표준 수집기가 더 명확하다면 단순성을 우선합니다.


reducing으로 대표값 선택

분류별 최고 조회 수 PostMetricmaxBy, 합산 조회 수는 reducing으로 구할 수 있습니다.

항등값 없는 reducingOptional 값을 만듭니다.

실제 그룹에는 원소가 있더라도 반환 타입이 Optional인 이유를 숨기려고 무조건 get을 호출하지 않습니다.

collectingAndThen(maxBy(...), optional -> optional.orElseThrow())로 그룹 값에서 Optional을 제거할 수 있습니다.

분류 함수가 실제 원소로만 키를 만든다는 불변식을 마무리 함수에서 명시하는 셈입니다.


연습 문제

Post의 분류, 제목, 조회 수, 공개 여부를 분류별 Summary로 만드세요.

공개된 제목만 불변 List에 담고 전체 게시글의 평균 조회 수는 별도 값으로 계산합니다.

공개된 게시글이 없는 분류도 빈 List로 남아야 합니다.

정답과 두 하위 수집기 결과 결합

filtering 하위 수집기로 키를 유지하고 averagingInt로 전체 평균을 구한 뒤 동일한 키의 결과를 Summary로 합칩니다.

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 키의 공개된 제목은 빈 List이고 평균 조회 수는 30입니다.

notice 키에는 welcome과 평균 조회 수 30이 들어갑니다.

하위 수집기를 고를 때는 원소 변환, 그룹별 filter, 중첩 구조 평탄화, 스칼라 값 축약, 결과 마무리 가운데 어느 단계의 변화인지 구분합니다.

단계를 정확히 고르면 중간 List와 후처리 반복문 없이 그룹 값의 규칙을 바로 만들 수 있습니다.