본문 바로가기

중간 연산과 flatMap

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

중간 연산은 Stream을 반환해 파이프라인을 이어 줍니다. 항상 별도 객체를 만든다는 뜻은 아닙니다.

중요한 질문은 이름 암기가 아니라 원소 개수·타입·순서를 어떻게 바꾸는지입니다.

filter는 원소를 선별하고 map은 각 원소를 변환하며 flatMap은 각 원소가 만든 하위 Stream의 내용물을 하나의 흐름으로 합칩니다.


map의 중첩 목록 평탄화 오해

두 게시판 분류의 태그 List에 map(Category::tags)를 적용하면 원소는 String이 아니라 List입니다.

두 결과의 size()는 서로 다른 단위의 원소를 셉니다.

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

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

    public static void main(String[] args) {
        List<Category> categories =
                List.of(
                        new Category("notice", List.of("admin", "event")),
                        new Category("free", List.of("question", "answer")));
        List<List<String>> nested = categories.stream().map(Category::tags).toList();
        List<String> flattened =
                categories.stream().flatMap(category -> category.tags().stream()).toList();
        System.out.println(
                "wrong-element-count=" + nested.size() + ", flattened-count=" + flattened.size());
    }
}
같은 태그 원본에서 바깥 목록의 원소가 달라진다

NestedMapShapeBug의 두 수집 결과를 타입·원소 수·내용으로 비교합니다.

결과 목록의 원소 단위

같은 태그 원본에서 바깥 목록의 원소가 달라진다

map은 태그 목록 두 개를 남깁니다. flatMap은 그 안의 태그 네 개를 같은 수준으로 이어 붙입니다.

원소 하나의 단위에 따라 map과 flatMap의 결과가 갈린다같은 Category 두 개에 각 두 태그가 있다. map은 태그List자체 두 개를 바깥 목록 원소로 유지한다. flatMap은각List의내용을이어String네개를같은층으로만든다. 원본Category를변경하는그림이아니고 두별도파이프라인의값관계이며모바일도5노드4간선이다. 원래auxpanels의정확태그문자열은보존한다.같은 Category 2개태그는 두 개씩map목록을 원소로중첩한 결과 목록List 원소 2개flatMap하위 내용을 이어평탄한 결과 목록String 원소 4개
원소 하나의 단위에 따라 map과 flatMap의 결과가 갈린다같은 Category 두 개에 각 두 태그가 있다. map은 태그List자체 두 개를 바깥 목록 원소로 유지한다. flatMap은각List의내용을이어String네개를같은층으로만든다. 원본Category를변경하는그림이아니고 두별도파이프라인의값관계이며모바일도5노드4간선이다. 원래auxpanels의정확태그문자열은보존한다.같은 Category 2개태그는 두 개씩map목록을 원소로중첩한 결과 목록List 원소 2개flatMap하위 내용을 이어평탄한 결과 목록String 원소 4개

map(Category::tags)

목록의 원소와 개수
List<String> 두 개
원문 입력의 결과
[[admin, event], [question, answer]]

flatMap(tags.stream())

목록의 원소와 개수
String 네 개
원문 입력의 결과
[admin, event, question, answer]

고정 입력과 연산 규칙으로 풀이한 결과이며 새 실행 관측이 아닙니다.

중첩 구조가 요구 사항이라면 map 결과가 맞습니다.

모든 태그를 같은 수준의 원소로 처리하려면 flatMap을 씁니다. 예를 들어 전체 태그의 중복 제거와 그룹화를 적용할 수 있습니다.

평탄화가 도메인 구분을 지워서는 안 되므로 원래 게시판 분류와 태그의 관계가 필요하면 두 값을 묶은 record로 변환합니다.

예시 예제의 categories의 첫 map 결과는 태그 두 개가 든 목록이고 둘째도 목록입니다. 바깥 결과는 [[admin,event],[question,answer]]이므로 size는 2입니다. flatMap에서는 첫 하위 스트림의 admin·event 뒤에 둘째의 question·answer를 이어 붙여 바깥 원소 네 개를 만듭니다. 태그를 네 개 찾았다는 것과 분류를 네 개 찾았다는 것은 다른 계산입니다.


filter·map·mapMulti 규칙

filter 조건식이 true인 원소만 다음 단계로 보냅니다.

map은 입력 하나마다 출력 하나를 만듭니다.

flatMap의 매퍼는 입력마다 하위 Stream 하나를 반환하며, 그 안의 0개 이상 원소가 결과로 합쳐집니다. 각 하위 스트림은 내용을 옮긴 뒤 닫히고 매퍼가 null을 반환하면 빈 스트림으로 취급합니다.

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("notice:welcome,maintenance", "free:question,answer", "notice:event");
        List<String> noticeTitles =
                source.stream()
                        .filter(row -> row.startsWith("notice:"))
                        .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(noticeTitles);
        System.out.println(initials);
    }
}

위 코드의 noticeTitles는 [welcome, maintenance, event]로 계산됩니다.

첫 filter 뒤에는 notice:welcome,maintenance와 notice:event 두 문자열이 남습니다. map이 콜론 뒤를 골라 welcome,maintenance와 event로 바꾸고 flatMap이 쉼표로 나눈 두 값·한 값을 합칩니다. 별도 initials에는 free도 포함되어 [n,w,m,f,q,a,n,e] 여덟 문자가 남습니다. 이 두 파이프라인의 필터 유무도 함께 확인하세요.

mapMulti의 명시적 타입 인수는 소비자가 받을 원소 타입의 추론을 분명하게 합니다. 전달받은 out은 해당 매퍼 호출 안에서만 사용하며 나중에 쓰려고 보관하지 않습니다.

복잡한 파싱과 오류 복구가 필요하면 파이프라인 안에서 문자열을 억지로 쪼개지 말고 도메인 파서 메서드를 만듭니다.

flatMap의 매퍼가 null인 하위 Stream을 반환하면 빈 흐름으로 취급한다는 규칙은, null 목록에 .stream()을 호출해도 된다는 규칙이 아닙니다. category.tags()가 null이면 그 메서드 호출에서 실패합니다. 반면 tags가 빈 목록이면 정상인 빈 하위 Stream입니다. Stream API의 flatMap·mapMulti 계약은 하위 흐름의 닫힘과 consumer를 쓰는 범위를 구별합니다.


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

filter, map, peek은 현재 원소만으로 처리할 수 있는 무상태 연산입니다.

distinct, sorted, limit, skip은 상태를 보유하는 중간 연산입니다. 만나는 순서가 정해진 경우 limit·skip은 그 순서를 기준으로 원소의 위치를 판단합니다.

상태 보유 연산은 메모리와 병렬 조정 비용을 만들 수 있습니다.

distinct의 동등성 기준은 equals이며, 원소 타입의 hashCode도 동등성 규약에 맞춰 구현합니다.

정렬 뒤 distinct를 한다고 비교 결과가 0인 값을 동일 원소로 보는 것은 아닙니다.

TreeSet의 동률과 Stream.distinct의 동등성은 기준이 다릅니다.

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

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

    public static void main(String[] args) {
        List<PostMetric> source =
                List.of(
                        new PostMetric("welcome", 40),
                        new PostMetric("question", 20),
                        new PostMetric("welcome", 40),
                        new PostMetric("event", 30));
        List<PostMetric> result =
                source.stream()
                        .distinct()
                        .sorted(
                                Comparator.comparingInt(PostMetric::viewCount)
                                        .reversed()
                                        .thenComparing(PostMetric::title))
                        .skip(1)
                        .limit(2)
                        .toList();
        System.out.println(result);
    }
}

중복된 PostMetric("welcome", 40) 하나가 distinct에서 제거되고 정렬 후 첫 항목을 건너뛴 두 값이 남습니다.

skip·limit의 위치를 sorted 앞으로 옮기면 결과가 달라질 수 있으므로 최적화를 이유로 연산 순서를 임의로 바꾸지 않습니다.

예제의 distinct 뒤에는 welcome40·question20·event30 세 record가 남습니다. 정렬하면 welcome40·event30·question20이고 skip(1) 뒤 limit(2)는 event30·question20입니다. distinct 뒤 skip(1)·limit(2)를 먼저 적용하면 question20·event30을 골라 그 둘만 정렬합니다. 이 예제 입력에서는 마지막 결과가 같아도 일반적인 순서 교환의 근거가 되지는 않습니다.

독립 확인: 예제의 정렬·skip·limit 파이프라인의 입력을 a10·b40·c30·d20 네 PostMetric으로 바꿉니다. 기존 연산 순서와 distinct→skip(1)→limit(2)→sorted를 각각 계산하세요. 같은 조회 수 20의 (a,20)과 (b,20)은 조회 수 비교가 동률이라는 이유로 distinct에서 합쳐지나요?

풀이 확인

예제의 정렬 결과는 b40·c30·d20·a10이며 skip과 limit 뒤 c30·d20입니다. 먼저 skip과 limit하면 b40·c30 두 값만 골라 정렬 결과도 b40·c30입니다. 같은 조회 수여도 title이 다른 두 record는 equals가 달라 distinct에서 각각 남습니다. 정렬 기준의 동률과 원소 동등성은 별개이며, 예제의 비교자에는 thenComparing(title)까지 있다는 사실도 확인합니다.


takeWhile·dropWhile의 접두부

이 절은 만나는 순서가 정해진 스트림을 기준으로 설명합니다. 값의 정렬 여부와 스트림의 순서 유무는 다릅니다.

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

첫 홀수 7 뒤의 짝수를 남기는지가 다르다

순서가 있는 같은 입력의 7에서 takeWhile은 멈추고 dropWhile은 남은 7·8·10을 포함하며 filter는 뒤의 짝수도 선택합니다. 현재 위치와 경계/선택 상태를 비교하면 접두부 조건을 이해하기 쉽습니다.

첫 홀수 7 뒤의 짝수를 남기는지가 다르다

순서 있는 원본 2, 4, 6, 7, 8, 10에 같은 짝수 조건을 적용합니다.

첫 false와 남는 값

첫 홀수 7 뒤의 짝수를 남기는지가 다르다

순서 있는 원본은 2·4·6·7·8·10입니다. 첫 false인 7을 기준으로 접두부를 다루는 연산과 전체 조건 선별을 비교합니다.

첫 false를 경계로 접두부 선택과 전체 선별이 달라진다순서있는List2,4,6,7,8,10에서각operation은독립된Stream에적용한다. takeWhile은첫false7이전2,4,6만,dropWhile은7이포함된7,8,10을,filter는7뒤에도계속조건을판단해2,4,6,8,10을만든다. 같은원본세경로와각결과로7노드6간선이고모바일도동일하다. 세operation을하나의소비된Stream에연속재사용한다는뜻이아니다.같은 순서 있는 List2·4·6·7·8·10takeWhile짝수 참 접두부take 결과2·4·6dropWhile첫 false부터drop 결과7·8·10filter전체에서 짝수filter 결과2·4·6·8·10
첫 false를 경계로 접두부 선택과 전체 선별이 달라진다순서있는List2,4,6,7,8,10에서각operation은독립된Stream에적용한다. takeWhile은첫false7이전2,4,6만,dropWhile은7이포함된7,8,10을,filter는7뒤에도계속조건을판단해2,4,6,8,10을만든다. 같은원본세경로와각결과로7노드6간선이고모바일도동일하다. 세operation을하나의소비된Stream에연속재사용한다는뜻이아니다.같은 순서 있는 List2·4·6·7·8·10takeWhile짝수 참 접두부take 결과2·4·6dropWhile첫 false부터drop 결과7·8·10filter전체에서 짝수filter 결과2·4·6·8·10

takeWhile

선택 기준
첫 false 이전 접두부
남는 값
2, 4, 6

dropWhile

선택 기준
그 접두부를 버린 나머지
남는 값
7, 8, 10

filter

선택 기준
위치와 무관하게 조건을 만족하는 값
남는 값
2, 4, 6, 8, 10

이 값들은 원문의 순서 있는 List로 계산했습니다. 순서 없는 스트림에서 takeWhile·dropWhile은 같은 접두부 계약을 제공하지 않습니다.

이 예의 조건

  • 순서가 있는 같은 List를 각 operation마다 독립 Stream으로 처리합니다.
관계도의 보충 설명
원 도표의 전체 관계·라벨·조건
구분내용
2순서 있는 List 2,4,6,7,8,10의 각 operation은 독립된 Stream에 적용한다. takeWhile은 첫 false인 7 이전의 2,4,6만, dropWhile은 7이 포함된 7,8,10을, filter는 7 뒤에도 계속 조건을 판단해 2,4,6,8,10을 만든다. 같은 원본의 세 경로와 각 결과를 7개 노드와 6개 간선으로 나타내며 모바일도 동일하다. 세 operation을 이미 소비된 하나의 Stream에 연속해서 재사용한다는 뜻이 아니다.
원 도표의 설명과 주석
구분내용
설명순서 있는 List 2,4,6,7,8,10의 각 operation은 독립된 Stream에 적용한다. takeWhile은 첫 false인 7 이전의 2,4,6만, dropWhile은 7이 포함된 7,8,10을, filter는 7 뒤에도 계속 조건을 판단해 2,4,6,8,10을 만든다. 같은 원본의 세 경로와 각 결과를 7개 노드와 6개 간선으로 나타내며 모바일도 동일하다. 세 operation을 이미 소비된 하나의 Stream에 연속해서 재사용한다는 뜻이 아니다.
전체 읽기 정보첫 홀수 7 뒤의 짝수를 남기는지가 다르다 첫 홀수 7 뒤의 짝수를 남기는지가 다르다 순서 있는 원본 2, 4, 6, 7, 8, 10에 같은 짝수 조건을 적용합니다. 첫 false와 남는 값 첫 홀수 7 뒤의 짝수를 남기는지가 다르다 순서 있는 원본은 2·4·6·7·8·10입니다. 첫 false인 7을 기준으로 접두부를 다루는 연산과 전체 조건 선별을 비교합니다. takeWhile 선택 기준 첫 false 이전 접두부 남는 값 2, 4, 6 dropWhile 선택 기준 그 접두부를 버린 나머지 남는 값 7, 8, 10 filter 선택 기준 위치와 무관하게 조건을 만족하는 값 남는 값 2, 4, 6, 8, 10 이 값들은 원문의 순서 있는 List로 계산했습니다. 순서 없는 스트림에서 takeWhile·dropWhile은 같은 접두부 계약을 제공하지 않습니다.
예의 값과 변화
관찰·상황값·보충 정보변화·이유
참 접두부

2,4,6은 짝수 조건의 결과가 true입니다.

  • takeWhile: 2,4,6 보관
  • dropWhile: 2,4,6 제외
  • 첫 false 전의 연속 구간이 접두부입니다.
첫 false

7에서 접두부 조건이 끝납니다.

  • takeWhile: 여기서 멈춤
  • dropWhile: 7부터 보관
  • 경계값 7은 drop 결과에 포함됩니다.
뒤 원소

8,10에 전체 filter는 계속 조건을 적용합니다.

  • take/drop: [2,4,6] /[7,8,10]
  • filter: [2,4,6,8,10]
  • 전체 선별은 접두부 뒤의 짝수도 선택합니다.

첫 홀수 7 뒤의 짝수를 남기는지가 다르다

순서가 있는 같은 입력의 7에서 takeWhile은 멈추고 dropWhile은 남은 7·8·10을 포함하며 filter는 뒤의 짝수도 선택합니다. 현재 위치와 경계/선택 상태를 비교하면 접두부 조건을 이해하기 쉽습니다.

첫 홀수 7 뒤의 짝수를 남기는지가 다르다

순서 있는 원본 2, 4, 6, 7, 8, 10에 같은 짝수 조건을 적용합니다.

첫 false와 남는 값

첫 홀수 7 뒤의 짝수를 남기는지가 다르다

순서 있는 원본은 2·4·6·7·8·10입니다. 첫 false인 7을 기준으로 접두부를 다루는 연산과 전체 조건 선별을 비교합니다.

첫 false를 경계로 접두부 선택과 전체 선별이 달라진다순서있는List2,4,6,7,8,10에서각operation은독립된Stream에적용한다. takeWhile은첫false7이전2,4,6만,dropWhile은7이포함된7,8,10을,filter는7뒤에도계속조건을판단해2,4,6,8,10을만든다. 같은원본세경로와각결과로7노드6간선이고모바일도동일하다. 세operation을하나의소비된Stream에연속재사용한다는뜻이아니다.같은 순서 있는 List2·4·6·7·8·10takeWhile짝수 참 접두부take 결과2·4·6dropWhile첫 false부터drop 결과7·8·10filter전체에서 짝수filter 결과2·4·6·8·10
첫 false를 경계로 접두부 선택과 전체 선별이 달라진다순서있는List2,4,6,7,8,10에서각operation은독립된Stream에적용한다. takeWhile은첫false7이전2,4,6만,dropWhile은7이포함된7,8,10을,filter는7뒤에도계속조건을판단해2,4,6,8,10을만든다. 같은원본세경로와각결과로7노드6간선이고모바일도동일하다. 세operation을하나의소비된Stream에연속재사용한다는뜻이아니다.같은 순서 있는 List2·4·6·7·8·10takeWhile짝수 참 접두부take 결과2·4·6dropWhile첫 false부터drop 결과7·8·10filter전체에서 짝수filter 결과2·4·6·8·10

takeWhile

선택 기준
첫 false 이전 접두부
남는 값
2, 4, 6

dropWhile

선택 기준
그 접두부를 버린 나머지
남는 값
7, 8, 10

filter

선택 기준
위치와 무관하게 조건을 만족하는 값
남는 값
2, 4, 6, 8, 10

이 값들은 원문의 순서 있는 List로 계산했습니다. 순서 없는 스트림에서 takeWhile·dropWhile은 같은 접두부 계약을 제공하지 않습니다.

이 예의 조건

  • 순서가 있는 같은 List를 각 operation마다 독립 Stream으로 처리합니다.

관계도의 보충 설명

원 도표의 전체 관계·라벨·조건
구분내용
2순서 있는 List 2,4,6,7,8,10의 각 operation은 독립된 Stream에 적용한다. takeWhile은 첫 false인 7 이전의 2,4,6만, dropWhile은 7이 포함된 7,8,10을, filter는 7 뒤에도 계속 조건을 판단해 2,4,6,8,10을 만든다. 같은 원본의 세 경로와 각 결과를 7개 노드와 6개 간선으로 나타내며 모바일도 동일하다. 세 operation을 이미 소비된 하나의 Stream에 연속해서 재사용한다는 뜻이 아니다.
원 도표의 설명과 주석
구분내용
설명순서 있는 List 2,4,6,7,8,10의 각 operation은 독립된 Stream에 적용한다. takeWhile은 첫 false인 7 이전의 2,4,6만, dropWhile은 7이 포함된 7,8,10을, filter는 7 뒤에도 계속 조건을 판단해 2,4,6,8,10을 만든다. 같은 원본의 세 경로와 각 결과를 7개 노드와 6개 간선으로 나타내며 모바일도 동일하다. 세 operation을 이미 소비된 하나의 Stream에 연속해서 재사용한다는 뜻이 아니다.
전체 읽기 정보첫 홀수 7 뒤의 짝수를 남기는지가 다르다 첫 홀수 7 뒤의 짝수를 남기는지가 다르다 순서 있는 원본 2, 4, 6, 7, 8, 10에 같은 짝수 조건을 적용합니다. 첫 false와 남는 값 첫 홀수 7 뒤의 짝수를 남기는지가 다르다 순서 있는 원본은 2·4·6·7·8·10입니다. 첫 false인 7을 기준으로 접두부를 다루는 연산과 전체 조건 선별을 비교합니다. takeWhile 선택 기준 첫 false 이전 접두부 남는 값 2, 4, 6 dropWhile 선택 기준 그 접두부를 버린 나머지 남는 값 7, 8, 10 filter 선택 기준 위치와 무관하게 조건을 만족하는 값 남는 값 2, 4, 6, 8, 10 이 값들은 원문의 순서 있는 List로 계산했습니다. 순서 없는 스트림에서 takeWhile·dropWhile은 같은 접두부 계약을 제공하지 않습니다.
예의 값과 변화
관찰·상황값·보충 정보변화·이유
참 접두부

2,4,6은 짝수 조건의 결과가 true입니다.

  • takeWhile: 2,4,6 보관
  • dropWhile: 2,4,6 제외
  • 첫 false 전의 연속 구간이 접두부입니다.
첫 false

7에서 접두부 조건이 끝납니다.

  • takeWhile: 여기서 멈춤
  • dropWhile: 7부터 보관
  • 경계값 7은 drop 결과에 포함됩니다.
뒤 원소

8,10에 전체 filter는 계속 조건을 적용합니다.

  • take/drop: [2,4,6] /[7,8,10]
  • filter: [2,4,6,8,10]
  • 전체 선별은 접두부 뒤의 짝수도 선택합니다.

예시 예제의 List의 2·4·6은 연속된 참 접두부이고 7에서 처음 거짓이 됩니다. takeWhile은 그 앞의 [2,4,6], dropWhile은 7을 포함한 나머지 [7,8,10]을 남깁니다. filter는 7을 제외한 뒤에도 8·10을 선별하므로 [2,4,6,8,10]입니다. takeWhile이 전체 입력의 짝수를 찾는 별칭은 아닙니다.

독립 확인: 순서 있는 [7,2,4]와 [2,4]에 같은 짝수 조건으로 takeWhile·dropWhile·filter를 각각 적용합니다. 결과를 쓰고 빈 결과가 오류가 아닌 이유를 설명하세요.

풀이 확인

[7,2,4]는 take=[], drop=[7,2,4], filter=[2,4]입니다. 첫 값 7부터 거짓이므로 참 접두부가 비었습니다. [2,4]는 take=[2,4], drop=[], filter=[2,4]입니다. 이번에는 모든 값이 참이라 drop할 접두부가 전체입니다. 둘 다 정상인 0개 원소 흐름이며, 순서 없는 스트림의 선택을 같은 접두부로 해석하지 않습니다.


peek의 관찰 용도

peek은 지연 중간 연산이며 최종 연산이 요구한 원소에서만 호출됩니다.

count 최적화처럼 결과에 필요 없는 순회가 생략되면 호출되지 않을 수도 있습니다.

외부 엔터티 저장, 필수 지표, 입력 검사를 peek에 넣지 않습니다.

객체를 peek에서 변경하면 파이프라인 앞뒤에서 같은 참조의 상태가 달라지고 병렬 실행에서 경합이 생깁니다.

map으로 새 불변 record를 만들어 결과에 변경을 반영합니다.


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

findFirst와 findAny는 원소가 없을 수 있어 Optional을 반환합니다.

중간 연산이 아니라 최종 연산이지만 filter 파이프라인의 끝에서 자주 만납니다.

get()으로 존재를 가정하지 말고 orElse, orElseGet, orElseThrow, map으로 부재 정책을 표현합니다.

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

public final class PostLookupPipeline {
    private record Post(String slug, String title, boolean published) {}

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

Optional은 조회 최종 연산의 부재를 나타냅니다.

Stream 자체가 0개 이상 원소 흐름이고 Optional은 최대 하나의 결과라는 원소 수 차이를 유지합니다.

예제의 PostLookupPipeline의 첫 공개 필터는 draft를 제외합니다. 남은 welcome의 slug가 일치해 title인 Welcome을 얻습니다. slug 조건을 missing으로 바꾸면 findFirst는 빈 Optional이고 orElse의 not-found가 출력됩니다. 이 값은 예제 Post의 실제 제목과 부재를 하나의 문자열로 합친 반환 정책이므로, 제목에 not-found 자체가 올 수 있는 API라면 Optional을 유지하는 선택도 검토합니다.


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

의미가 같다면 선택도가 높고 비용이 작은 filter를 앞에 두어 비싼 map 호출을 줄입니다.

distinct 기준이 변환 전후에 달라지면 위치도 결과를 바꿉니다.

sorted는 정렬을 위해 입력을 모아야 할 수 있으므로 limit보다 앞인지 뒤인지 기능 요구를 확인합니다.

병렬 스트림에서는 만나는 순서를 보존하는 상태 보유 연산이 큰 조정 비용을 낼 수 있습니다.

순서가 필요 없다면 unordered()를 검토하되, 결과 형식에서도 순서를 제거할 수 있을 때만 사용합니다.


연습 문제

Category(name, slugs) 목록을 원본으로 받아 빈 slugs는 건너뛰고, 게시판 분류와 slug를 가진 Row 목록을 만드세요.

slug 사전순으로 정렬하되 각 Category와의 관계를 잃지 마세요.

정답과 관계 보존

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

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

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

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

    public static void main(String[] args) {
        List<Category> categories =
                List.of(
                        new Category("notice", List.of("welcome", "event")),
                        new Category("empty", List.of()),
                        new Category("free", List.of("question")));
        List<Row> rows =
                categories.stream()
                        .flatMap(
                                category ->
                                        category.slugs().stream()
                                                .map(slug -> new Row(category.name(), slug)))
                        .sorted(Comparator.comparing(Row::slug).thenComparing(Row::category))
                        .toList();
        System.out.println(rows);
    }
}

결과에는 notice-event, free-question, notice-welcome 관계가 slug 순으로 남습니다.

빈 Category는 하위 Stream이 비어 자동으로 사라집니다.

독립 확인: 연습 Category 입력을 notice:[same,b], empty:[], free:[same,a]로 바꿉니다. map(Category::slugs)로 수집한 바깥 원소 수와 flatMap 뒤 Row로 묶어 기존 정렬을 적용한 결과를 각각 구하세요. String만 평탄화한 뒤 distinct하면 same의 두 분류 관계를 모두 유지하나요?

풀이 확인

map의 결과에서는 빈 목록도 원소 하나이므로 바깥 원소는 3개입니다. Row는 (free,a), (notice,b), (free,same), (notice,same) 순서로 정렬됩니다. 먼저 slug를, 같으면 category를 비교하므로 같은 same 두 값의 출처도 남습니다. String만 평탄화해 distinct하면 [same,b,a] 세 값으로 줄어 어느 분류의 same인지 알 수 없습니다. 빈 slugs가 Row를 만들지 않는 것과 Category 자체가 원본에서 삭제되는 것도 구별합니다.

중간 연산은 원소의 형태, 개수, 처리 순서, 버퍼링 방식을 바꿉니다.

flatMap을 단순한 중첩 제거로만 보지 말고 관계 보존과 원소 수를 함께 설계해야 파이프라인 결과가 도메인 의미를 유지합니다.