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

안동민 개발노트

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

중간 연산과 flatMap

무상태·상태 보유 중간 연산을 구분하고 `map`의 중첩 결과를 flatMap으로 평탄화하며 `Optional` 구분을 처리합니다.

중간 연산은 Stream을 받아 새 Stream을 반환해 파이프라인을 이어 줍니다. 중요한 질문은 이름 암기가 아니라 원소 개수·타입·순서를 어떻게 바꾸는지입니다. filter는 개수를 줄이고 map은 각 원소의 타입을 바꾸며 flatMap은 각 원소가 만든 하위 Stream을 하나의 흐름으로 합칩니다.

map의 중첩 목록 평탄화 오해

두 과정의 태그 Listmap(Course::tags)를 적용하면 원소는 String이 아니라 List입니다. 실제 출력은 wrong-element-count=2, flattened-count=4로, 과정 수와 태그 수가 다릅니다.

lab/NestedMapShapeBug.java
import java.util.List;

public final class NestedMapShapeBug {
    private record Course(String name, List<String> tags) {}

    public static void main(String[] args) {
        List<Course> courses =
                List.of(
                        new Course("stream", List.of("java", "data")),
                        new Course("lock", List.of("java", "thread")));
        List<List<String>> nested = courses.stream().map(Course::tags).toList();
        List<String> flattened =
                courses.stream().flatMap(course -> course.tags().stream()).toList();
        System.out.println(
                "wrong-element-count=" + nested.size() + ", flattened-count=" + flattened.size());
    }
}

중첩 구조가 요구 사항이라면 map 결과가 맞습니다. 모든 태그를 같은 수준에서 distinct 처리하거나 그룹화할 때만 flatMap을 씁니다. 평탄화가 도메인 구분을 지워서는 안 되므로 원래 과정과 태그의 관계가 필요하면 두 값을 묶은 record로 변환합니다.

filter·map·mapMulti 규칙

filter 조건식이 true인 원소만 다음 단계로 보냅니다. map은 입력 하나마다 출력 하나를 만듭니다. flatMap은 0개 이상의 Stream을 반환하고 하위 Stream을 닫으며 합칩니다. mapMulti는 콜백으로 0개 이상의 값을 직접 전달하므로 출력이 조금씩 늘어나는 경우 임시 Stream 생성을 줄일 수 있습니다.

src/IntermediateShapeCatalog.java
import java.util.List;

public final class IntermediateShapeCatalog {
    public static void main(String[] args) {
        List<String> source = List.of("java:stream,collector", "web:html,css", "java:lock");
        List<String> javaTopics =
                source.stream()
                        .filter(row -> row.startsWith("java:"))
                        .map(row -> row.substring(row.indexOf(':') + 1))
                        .flatMap(value -> List.of(value.split(",")).stream())
                        .toList();
        List<Character> initials =
                source.stream()
                        .<Character>mapMulti(
                                (row, out) -> {
                                    for (String part : row.split("[:,]"))
                                        if (!part.isBlank()) out.accept(part.charAt(0));
                                })
                        .toList();
        System.out.println(javaTopics);
        System.out.println(initials);
    }
}

flatMap 결과는 스트림, 수집기, 락입니다. mapMulti의 명시적 타입 인수는 소비자가 받을 원소 타입의 추론을 분명하게 합니다. 복잡한 파싱과 오류 복구가 필요하면 파이프라인 안에서 문자열을 억지로 쪼개지 말고 도메인 파서 메서드를 만듭니다.

무상태·상태 보유 중간 연산

filter, map, peek은 현재 원소만으로 처리할 수 있는 무상태 연산입니다. distinct는 이미 본 값을, sorted는 전체 입력을, 정렬된 limit·skip은 처리 순서상의 위치를 알아야 하므로 상태를 가집니다. 상태 보유 연산은 메모리와 병렬 조정 비용을 만들 수 있습니다.

distinctequalshashCode 규칙을 사용합니다. 정렬 뒤 distinct를 한다고 비교 결과가 0인 값을 동일 원소로 보는 것은 아닙니다. TreeSet의 동률과 Stream.distinct의 동등성은 기준이 다릅니다.

src/StatefulIntermediateOperations.java
import java.util.Comparator;
import java.util.List;

public final class StatefulIntermediateOperations {
    private record Topic(String name, int minutes) {}

    public static void main(String[] args) {
        List<Topic> source =
                List.of(
                        new Topic("stream", 40),
                        new Topic("lock", 20),
                        new Topic("stream", 40),
                        new Topic("gc", 30));
        List<Topic> result =
                source.stream()
                        .distinct()
                        .sorted(
                                Comparator.comparingInt(Topic::minutes)
                                        .reversed()
                                        .thenComparing(Topic::name))
                        .skip(1)
                        .limit(2)
                        .toList();
        System.out.println(result);
    }
}

완전히 같은 스트림 record 하나가 distinct에서 제거되고 정렬 후 첫 항목을 건너뛴 두 값이 남습니다. skip·limit의 위치를 sorted 앞으로 옮기면 결과가 달라지므로 최적화를 이유로 연산 순서를 임의로 바꾸지 않습니다.

takeWhile·dropWhile의 접두부

filter는 원본 전체에서 조건식을 평가하지만 takeWhile은 처음 false가 나올 때 멈추고 앞쪽 구간만 남깁니다. dropWhile은 처음 false가 나올 때까지 버리고 그 뒤를 모두 보냅니다. 정렬되지 않은 입력에서 “조건을 만족하는 모든 값”을 원하면 filter가 맞습니다.

src/PrefixIntermediateOperations.java
import java.util.List;

public final class PrefixIntermediateOperations {
    public static void main(String[] args) {
        List<Integer> source = List.of(2, 4, 6, 7, 8, 10);
        System.out.println("take=" + source.stream().takeWhile(value -> value % 2 == 0).toList());
        System.out.println("drop=" + source.stream().dropWhile(value -> value % 2 == 0).toList());
        System.out.println("filter=" + source.stream().filter(value -> value % 2 == 0).toList());
    }
}

takeWhile의 결과는 2, 4, 6이고 dropWhile의 결과는 7, 8, 10이며, filter의 결과는 2, 4, 6, 8, 10입니다. 같은 조건식이라도 연산자의 구조적 의미가 다릅니다.

peek의 관찰 용도

peek은 지연 중간 연산이며 최종 연산이 요구한 원소에서만 호출됩니다. count 최적화처럼 결과에 필요 없는 순회가 생략되면 호출되지 않을 수도 있습니다. 외부 엔터티 저장, 필수 지표, 입력 검사를 peek에 넣지 않습니다.

객체를 peek에서 변경하면 파이프라인 앞뒤에서 같은 참조의 상태가 달라지고 병렬 실행에서 경합이 생깁니다. map으로 새 불변 record를 만들어 결과에 변경을 반영합니다.

find 연산이 반환하는 Optional의 의미

findFirstfindAny는 원소가 없을 수 있어 Optional을 반환합니다. 중간 연산이 아니라 최종 연산이지만 filter 파이프라인의 끝에서 자주 만납니다. get()으로 존재를 가정하지 말고 orElse, orElseGet, orElseThrow, map으로 부재 정책을 표현합니다.

app/TopicLookupPipeline.java
import java.util.List;
import java.util.Optional;

public final class TopicLookupPipeline {
    private record Topic(String slug, String title, boolean published) {}

    public static void main(String[] args) {
        List<Topic> topics =
                List.of(new Topic("stream", "Stream", true), new Topic("draft", "Draft", false));
        Optional<String> title =
                topics.stream()
                        .filter(Topic::published)
                        .filter(topic -> topic.slug().equals("stream"))
                        .map(Topic::title)
                        .findFirst();
        System.out.println(title.orElse("not-found"));
    }
}

Optional은 조회 최종 연산의 부재를 나타냅니다. Stream 자체가 0개 이상 원소 흐름이고 Optional은 최대 하나의 결과라는 원소 수 차이를 유지합니다.

중간 연산 순서를 결정하는 질문

의미가 같다면 선택도가 높고 비용이 작은 filter를 앞에 두어 비싼 map 호출을 줄입니다. distinct 기준이 변환 전후에 달라지면 위치도 결과를 바꿉니다. sorted는 전체 버퍼가 필요하므로 limit보다 앞인지 뒤인지 기능 요구를 확인합니다.

병렬 스트림에서는 정렬된 상태 보유 연산이 큰 조정 비용을 낼 수 있습니다. 순서가 필요 없다면 unordered()를 검토하되, 결과 형식에서도 순서를 제거할 수 있을 때만 사용합니다.

연습 문제

Track(name, slugs) 목록을 원본으로 받아 빈 slugs는 건너뛰고, 과정 이름과 slug를 가진 Row 목록을 만드세요. slug 사전순으로 정렬하되 각 Track과의 관계를 잃지 마세요.

정답과 관계 보존

flatMap 안에서 각 slugTrackname과 묶은 Row로 바꿔야 출처 관계가 유지됩니다.

exercise/TrackDocumentRowsSolution.java
import java.util.Comparator;
import java.util.List;

public final class TrackDocumentRowsSolution {
    private record Track(String name, List<String> slugs) {}

    private record Row(String track, String slug) {}

    public static void main(String[] args) {
        List<Track> tracks =
                List.of(
                        new Track("java", List.of("stream", "lock")),
                        new Track("empty", List.of()),
                        new Track("web", List.of("css")));
        List<Row> rows =
                tracks.stream()
                        .flatMap(
                                track ->
                                        track.slugs().stream()
                                                .map(slug -> new Row(track.name(), slug)))
                        .sorted(Comparator.comparing(Row::slug).thenComparing(Row::track))
                        .toList();
        System.out.println(rows);
    }
}

결과에는 java-락, web-css, java-스트림 관계가 slug 순으로 남습니다. 빈 Track은 하위 Stream이 비어 자동으로 사라집니다.

중간 연산은 원소의 형태, 개수, 처리 순서, 버퍼링 방식을 바꿉니다. flatMap을 단순한 중첩 제거로만 보지 말고 관계 보존과 원소 수를 함께 설계해야 파이프라인 결과가 도메인 의미를 유지합니다.