안동민 개발노트

본문 시작

다운스트림 Collector

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

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

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

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


collectingAndThen의 불변 결과

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

아래 코드의 List.copyOf 결과에 add를 호출하면 API 계약상 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를 사용합니다. List.copyOf는 null 원소를 거절하며 원소 객체 자체를 깊게 복사하지는 않습니다.


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

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

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

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를 불변 List나 Summary 레코드로 바꾸는 데 적합합니다.

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

이 수집에서 매퍼는 원소를 제목으로 바꾸고, 마무리 함수는 완성된 각 그룹 목록으로 Group을 만듭니다. List.copyOf로 누산 목록과 결과 목록의 구조 변경을 분리합니다.


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());
    }
}
조건을 통과하지 못한 분류를 키에서 지울지 남길지 고른다

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

조건을 통과하지 못한 분류를 키에서 지울지 남길지 고른다
선별 위치notice 키free 키
groupingBy 앞 filter키 자체가 없음조회 수 70인 원소 한 개
groupingBy 안 filtering키 유지 · 빈 List조회 수 70인 원소 한 개
groupingBy 앞 filter
notice 키: 키 자체가 없음
free 키: 조회 수 70인 원소 한 개
groupingBy 안 filtering
notice 키: 키 유지 · 빈 List
free 키: 조회 수 70인 원소 한 개

원문 입력과 API 규칙으로 풀이했습니다. 하위 filtering도 원본에 전혀 없던 분류의 키까지 생성하지는 않습니다.


flatMapping과 중첩 컬렉션

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

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

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);
    }
}
바깥 Map과 태그 집합을 잠가도 통계 객체는 바뀔 수 있다

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

바깥 Map과 태그 집합을 잠가도 통계 객체는 바뀔 수 있다
결과 위치만드는 방법허용되는 변경
바깥 report MaptoUnmodifiableMap키 추가·삭제·값 교체 불가
Summary의 tagsSet.copyOf집합 원소 추가·삭제 불가
Summary의 viewsIntSummaryStatistics 참조accept·combine으로 통계 변경 가능
바깥 report Map
만드는 방법: toUnmodifiableMap
허용되는 변경: 키 추가·삭제·값 교체 불가
Summary의 tags
만드는 방법: Set.copyOf
허용되는 변경: 집합 원소 추가·삭제 불가
Summary의 views
만드는 방법: IntSummaryStatistics 참조
허용되는 변경: accept·combine으로 통계 변경 가능

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

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

원본을 한 번만 읽어야 하는 대량 파이프라인이라면 사용자 정의 누산기나 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로 합칩니다.

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와 후처리 반복문 없이 그룹 값의 규칙을 바로 만들 수 있습니다.