본문으로 건너뛰기
안동민 개발노트 아이콘

안동민 개발노트

본문 시작
32장 : 스트림과 Collector

다운스트림 Collector

groupingBy의 그룹 `List`를 후처리하지 않고 mapping·filtering·flatMapping·collectingAndThen·reducing으로 원하는 값을 수집합니다.

groupingBy의 기본 하위 수집기는 toList()이므로 Map의 값은 원본 원소의 List입니다. 그룹별 count, 주제 이름 Set, 통계, 불변 요약값이 필요하다면 먼저 List를 만든 뒤 다시 순회할 이유가 없습니다. 하위 수집기는 각 분류 함수 그룹 안에서 곧바로 원하는 결과 타입을 만듭니다.

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("stream", "collector").stream()
                        .collect(Collectors.collectingAndThen(Collectors.toList(), List::copyOf));
        result.add("optional");
    }
}

마무리 함수는 단순한 장식이 아니라 결과의 소유권 규칙을 바꿉니다. 수정 가능한 결과가 필요하면 toCollection(ArrayList::new), 불변 스냅샷이 필요하면 List.copyOf를 사용하고 API 문서에 명시합니다.

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

분류 함수가 키를 정하면 groupingBy는 해당 키의 하위 수집기 누산기에 원소를 전달합니다. 기본 toList 대신 counting을 주면 값은 Long, mappingtoSet을 조합하면 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 Session(String track, String topic, int minutes) {}

    public static void main(String[] args) {
        List<Session> sessions =
                List.of(
                        new Session("java", "stream", 40),
                        new Session("java", "stream", 20),
                        new Session("web", "css", 30));
        Map<String, Long> counts =
                sessions.stream()
                        .collect(Collectors.groupingBy(Session::track, Collectors.counting()));
        Map<String, Set<String>> topics =
                sessions.stream()
                        .collect(
                                Collectors.groupingBy(
                                        Session::track,
                                        Collectors.mapping(Session::topic, Collectors.toSet())));
        Map<String, Integer> totals =
                sessions.stream()
                        .collect(
                                Collectors.groupingBy(
                                        Session::track, Collectors.summingInt(Session::minutes)));
        System.out.println(counts);
        System.out.println(topics);
        System.out.println(totals);
    }
}

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

mapping·collectingAndThen 위치

mapping(mapper, downstream)은 그룹에 들어오는 각 원소를 바꾼 뒤 하위 수집기로 보냅니다. collectingAndThen(downstream, finisher)는 그룹의 완성 결과 하나를 후처리합니다. 전자는 Session을 주제 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 Entry(String track, String topic) {}

    private record Group(int count, List<String> topics) {}

    public static void main(String[] args) {
        List<Entry> entries =
                List.of(
                        new Entry("java", "stream"),
                        new Entry("java", "lock"),
                        new Entry("web", "css"));
        Map<String, Group> result =
                entries.stream()
                        .collect(
                                Collectors.groupingBy(
                                        Entry::track,
                                        Collectors.collectingAndThen(
                                                Collectors.mapping(
                                                        Entry::topic, Collectors.toList()),
                                                topics ->
                                                        new Group(
                                                                topics.size(),
                                                                List.copyOf(topics)))));
        System.out.println(result);
    }
}

매퍼는 원소마다 실행되고 마무리 함수는 그룹마다 한 번 실행됩니다. 마무리 함수가 가변 누산기를 외부와 공유하지 않도록 방어적 복사본을 만듭니다.

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 Session(String track, int minutes) {}

    public static void main(String[] args) {
        List<Session> source = List.of(new Session("java", 20), new Session("web", 70));
        Map<String, List<Session>> upstream =
                source.stream()
                        .filter(s -> s.minutes() >= 60)
                        .collect(Collectors.groupingBy(Session::track));
        Map<String, List<Session>> downstream =
                source.stream()
                        .collect(
                                Collectors.groupingBy(
                                        Session::track,
                                        Collectors.filtering(
                                                s -> s.minutes() >= 60, Collectors.toList())));
        System.out.println(
                "upstream-keys="
                        + upstream.keySet()
                        + ", downstream-java-size="
                        + downstream.get("java").size());
    }
}

앞쪽에서 filter한 결과에는 web 키만 있고, 하위 수집기로 거른 결과에는 java 키와 빈 List도 남습니다. 어느 결과가 맞는지는 보고서 스키마가 키의 완전성을 요구하는지에 달려 있습니다.

flatMapping과 중첩 컬렉션

Session이 태그 List를 가진다면 groupingBy의 하위 수집기로 flatMapping(session -> session.tags().stream(), toSet())을 사용해 그룹별 고유 태그 Set을 바로 만들 수 있습니다. mapping을 쓰면 List를 원소로 가진 Set이 되어 결과 형태가 달라집니다.

app/TrackDownstreamReport.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 TrackDownstreamReport {
    private record Session(String track, int minutes, List<String> tags) {}

    private record Summary(Set<String> tags, IntSummaryStatistics minutes) {}

    public static void main(String[] args) {
        List<Session> source =
                List.of(
                        new Session("java", 40, List.of("stream", "api")),
                        new Session("java", 60, List.of("collector", "api")),
                        new Session("web", 30, List.of("css")));
        Map<String, Set<String>> tags =
                source.stream()
                        .collect(
                                Collectors.groupingBy(
                                        Session::track,
                                        Collectors.flatMapping(
                                                session -> session.tags().stream(),
                                                Collectors.toSet())));
        Map<String, IntSummaryStatistics> minutes =
                source.stream()
                        .collect(
                                Collectors.groupingBy(
                                        Session::track,
                                        Collectors.summarizingInt(Session::minutes)));
        Map<String, Summary> report =
                tags.entrySet().stream()
                        .collect(
                                Collectors.toUnmodifiableMap(
                                        Map.Entry::getKey,
                                        entry ->
                                                new Summary(
                                                        Set.copyOf(entry.getValue()),
                                                        minutes.get(entry.getKey()))));
        System.out.println(report);
    }
}

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

reducing으로 대표값 선택

그룹별 최고 SessionmaxBy, 합산 점수는 reducing으로 구할 수 있습니다. 항등값 없는 reducingOptional 값을 만듭니다. 실제 그룹에는 원소가 있더라도 반환 타입이 Optional인 이유를 숨기려고 무조건 get을 호출하지 않습니다.

collectingAndThen(maxBy(...), optional -> optional.orElseThrow())로 그룹 값에서 Optional을 제거할 수 있습니다. 분류 함수가 실제 원소로만 키를 만든다는 불변식을 마무리 함수에서 명시하는 셈입니다.

연습 문제

Session의 트랙, 주제, 학습 시간, 완료 여부를 트랙별 Summary로 만드세요. 완료된 주제만 불변 List에 담고 전체 세션의 평균 학습 시간은 별도 값으로 계산합니다. 완료된 주제가 없는 트랙도 빈 List로 남아야 합니다.

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

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

exercise/TrackCompletionSummarySolution.java
import java.util.List;
import java.util.Map;
import java.util.stream.Collectors;

public final class TrackCompletionSummarySolution {
    private record Session(String track, String topic, int minutes, boolean completed) {}

    private record Summary(List<String> completed, double averageMinutes) {}

    public static void main(String[] args) {
        List<Session> source =
                List.of(
                        new Session("java", "stream", 40, true),
                        new Session("java", "lock", 20, false),
                        new Session("web", "css", 30, false));
        Map<String, List<String>> done =
                source.stream()
                        .collect(
                                Collectors.groupingBy(
                                        Session::track,
                                        Collectors.filtering(
                                                Session::completed,
                                                Collectors.mapping(
                                                        Session::topic,
                                                        Collectors.collectingAndThen(
                                                                Collectors.toList(),
                                                                List::copyOf)))));
        Map<String, Double> averages =
                source.stream()
                        .collect(
                                Collectors.groupingBy(
                                        Session::track, Collectors.averagingInt(Session::minutes)));
        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);
    }
}

web 키의 완료된 주제는 빈 List이고 평균은 30입니다. java 키에는 스트림과 평균 30이 들어갑니다.

하위 수집기를 고를 때는 원소 변환, 그룹별 filter, 중첩 구조 평탄화, 스칼라 값 축약, 결과 마무리 가운데 어느 단계의 변화인지 구분합니다. 단계를 정확히 고르면 중간 List와 후처리 반복문 없이 그룹 값의 규칙을 바로 만들 수 있습니다.