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

안동민 개발노트

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

Stream 파이프라인

원본, 중간 연산, 최종 연산의 역할을 나누고 일괄 처리와 원소 파이프라인의 실행 흐름을 비교합니다.

컬렉션 반복문은 어떻게 순회할지를 코드에 적고, Stream은 어떤 값을 남기고 어떤 모양으로 바꿀지를 연산 연쇄로 표현합니다. Stream API는 데이터를 저장하는 컬렉션이 아니라 원본을 한 번 소비하는 계산 파이프라인입니다. 이 차이를 이해해야 재사용, 부수 효과, 최종 연산 누락을 피할 수 있습니다.

단일 Stream의 중복 소비

Stream은 최종 연산이 끝나면 닫힌 소비 경로입니다. 아래 코드는 같은 인스턴스에서 count()toList()를 호출해 실제 IllegalStateException이 발생합니다.

lab/StreamReuseFailure.java
import java.util.List;
import java.util.stream.Stream;

public final class StreamReuseFailure {
    public static void main(String[] args) {
        Stream<String> topics = List.of("stream", "collector", "optional").stream();
        System.out.println("count=" + topics.count());
        System.out.println(topics.toList());
    }
}

두 결과가 필요하면 원본에서 Stream을 각각 새로 만들거나 최종 연산 하나에서 통계를 함께 계산합니다. Supplier<Stream<T>>로 팩터리를 전달할 수도 있지만 Stream 자체를 필드에 장기 보관하지 않습니다.

스트림 파이프라인의 세 구간

원본은 컬렉션, 배열, 범위, 파일 줄처럼 원소를 공급합니다. 중간 연산은 filter, map, sorted처럼 새 Stream을 반환하고 즉시 전체 결과를 만들지 않습니다. 최종 연산은 toList, count, reduce, forEach처럼 소비를 시작하고 Stream이 아닌 결과를 만듭니다.

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

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

    public static void main(String[] args) {
        List<Session> sessions =
                List.of(
                        new Session(" Stream ", 45, true),
                        new Session("Optional", 20, false),
                        new Session("Collector", 60, true));
        List<String> report =
                sessions.stream()
                        .filter(Session::completed)
                        .filter(s -> s.minutes() >= 30)
                        .map(s -> s.topic().strip().toLowerCase())
                        .sorted()
                        .toList();
        System.out.println(report);
    }
}

원본은 sessions, 중간 단계는 두 filtermap·sorted, 최종 연산은 toList입니다. 결과는 불변 List이며 원본 record와 원본 순서는 바뀌지 않습니다. Stream 연쇄의 각 조건식과 매퍼가 외부 상태를 바꾸지 않으면 입력에서 결과로 가는 관계를 독립적으로 설명하기 쉽습니다.

외부 반복과 내부 반복 비교

for 반복문에서는 인덱스, 반복자, continue, 결과의 add 호출을 사용자가 관리합니다. Stream은 라이브러리가 원소 전달 순서를 관리하고 사용자는 변환 함수를 제공합니다. 내부 반복 덕분에 파이프라인 융합과 병렬 실행 가능성이 생기지만, 순서와 부수 효과를 숨긴 채 아무 코드나 안전하게 만드는 것은 아닙니다.

src/LoopAndStreamEquivalent.java
import java.util.ArrayList;
import java.util.List;

public final class LoopAndStreamEquivalent {
    public static void main(String[] args) {
        List<Integer> source = List.of(1, 2, 3, 4, 5, 6);
        List<Integer> loop = new ArrayList<>();
        for (int value : source) {
            if (value % 2 == 0) loop.add(value * value);
        }
        List<Integer> stream =
                source.stream()
                        .filter(value -> value % 2 == 0)
                        .map(value -> value * value)
                        .toList();
        System.out.println("same=" + loop.equals(stream) + ", result=" + stream);
    }
}

두 결과 모두 4, 16, 36입니다. 단순 반복문이 더 읽기 쉬운 경우도 있습니다. break를 사용한 복잡한 제어, 검사 예외, 여러 가변 누산기, 위치 기반 변경이 핵심이면 억지로 Stream에 넣지 않습니다.

일괄 단계와 원소 파이프라인의 메모리 모양

전통적인 일괄 처리는 전체 filter 결과를 임시 List에 만들고, 전체 map 결과를 또 만들고, 마지막 정렬을 수행할 수 있습니다. Stream 파이프라인은 무상태 연산에서 한 원소를 filter에서 map으로 바로 넘겨 중간 컬렉션을 줄일 수 있습니다. sorted처럼 전체 원소가 필요한 상태 보유 연산은 버퍼가 필요합니다.

다음 추적 예제는 filtermap 호출이 항목별로 이어지는 모습을 출력합니다. 출력 관찰은 학습용이며 제품 로직은 peek나 로거 부수 효과에 의존하지 않습니다.

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

public final class ElementPipelineTrace {
    public static void main(String[] args) {
        List<Integer> result =
                List.of(1, 2, 3, 4).stream()
                        .filter(
                                value -> {
                                    System.out.println("filter " + value);
                                    return value % 2 == 0;
                                })
                        .map(
                                value -> {
                                    System.out.println("map " + value);
                                    return value * 10;
                                })
                        .toList();
        System.out.println("result=" + result);
    }
}

1은 filter에서 탈락해 map으로 가지 않고, 2는 filter 직후 map으로 전달됩니다. 파이프라인은 원본 전체를 먼저 filter한 임시 List처럼 실행되지 않습니다. 다만 명세가 특정 로깅 순서를 업무 규칙으로 보장한다고 가정하지 않습니다.

Stream의 순서 보존·변경

List.stream()은 처리 순서를 가지며 filtermap은 이를 보존합니다. sorted는 비교자 순서로 재배열하고 unordered()는 순서 제약을 완화할 수 있습니다. Set 원본은 구현에 따라 명확한 처리 순서가 없을 수 있습니다.

forEach는 특히 병렬 스트림에서 순서가 달라질 수 있고 forEachOrdered는 처리 순서를 지키는 비용을 냅니다. toList()는 정렬된 스트림의 처리 순서를 유지합니다. 결과 순서가 기능 요구인지 단지 보기 좋은 출력인지 구분합니다.

파이프라인 함수의 부수 효과 감소

매퍼가 외부 ArrayList에 값을 추가하면서 동시에 값을 반환하면 결과를 만드는 경로가 두 곳으로 갈라집니다. 단락 평가, 병렬 실행, 예외가 발생하면 외부 List와 최종 연산 결과가 불일치할 수 있습니다. 값을 수집하려면 수집기나 toList()를 사용해 최종 연산에서 모읍니다.

함수가 현재 원소와 불변 구성만 사용하면 재실행·테스트가 쉽습니다. 필요한 상태가 있다면 collect의 공급자·누산기·결합 함수 규칙으로 명시합니다. Stream을 단순 반복문 축약 문법으로만 보면 이런 실행 모델을 놓칩니다.

Stream을 피할 조건

컬렉션을 제자리에서 수정해야 하거나 반복 중 여러 제어 흐름을 세밀하게 표현해야 하면 반복문이 명확할 수 있습니다. 성능에 민감한 작은 반복문에서 박싱과 람다 호출 비용이 측정됐다면 기본형 반복문을 검토합니다. 단, 추측이 아니라 프로파일러와 벤치마크를 근거로 합니다.

반대로 filter·map·그룹화처럼 데이터 관계가 파이프라인으로 곧바로 읽히고 원본을 보존하려면 Stream이 적합합니다. 연쇄를 너무 길게 만들지 말고 조건식과 매퍼를 도메인 메서드로 추출해 의도를 드러냅니다.

app/StudyReportPipeline.java
import java.util.List;

public final class StudyReportPipeline {
    private record Entry(String track, String topic, int minutes) {}

    private record Row(String label, int minutes) {}

    public static void main(String[] args) {
        List<Entry> entries =
                List.of(
                        new Entry("java", "stream", 50),
                        new Entry("web", "css", 20),
                        new Entry("java", "collector", 40));
        List<Row> rows =
                entries.stream()
                        .filter(e -> e.track().equals("java"))
                        .map(e -> new Row(e.topic().toUpperCase(), e.minutes()))
                        .sorted((a, b) -> Integer.compare(b.minutes(), a.minutes()))
                        .toList();
        rows.forEach(System.out::println);
    }
}

filter 조건, 변환 타입, 정렬 기준이 한 방향으로 읽힙니다. 최종 연산 결과를 받은 뒤 출력하므로 파이프라인 내부는 순수 변환으로 유지됩니다.

연습 문제

Chapter 레코드의 제목과 완료 여부를 원본으로 삼아 완료된 항목만 남기고, 제목 양끝 공백을 제거한 뒤 길이 오름차순으로 정렬하세요. 길이가 같으면 사전순으로 반환하고 원본 List는 수정하지 않습니다.

정답과 파이프라인 경계

filter가 완료 여부, map이 정규화, sorted가 두 정렬 키, toList가 최종 결과 컬렉션을 담당합니다.

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

public final class CompletedChapterTitlesSolution {
    private record Chapter(String title, boolean completed) {}

    public static void main(String[] args) {
        List<Chapter> source =
                List.of(
                        new Chapter(" Stream ", true),
                        new Chapter("GC", false),
                        new Chapter(" Lock", true));
        List<String> titles =
                source.stream()
                        .filter(Chapter::completed)
                        .map(chapter -> chapter.title().strip())
                        .sorted(
                                Comparator.comparingInt(String::length)
                                        .thenComparing(Comparator.naturalOrder()))
                        .toList();
        System.out.println(titles);
        System.out.println("source-size=" + source.size());
    }
}

결과는 Lock, Stream 순서이고 원본 크기는 3입니다. 비교자 키를 분리해 뺄셈 오버플로나 모호한 동률을 피했습니다.

Stream 파이프라인을 읽는 순서는 원본 식별 정보, 중간 연산의 상태·순서 영향, 최종 연산 결과, 부수 효과입니다. 한 번만 소비된다는 생명 주기와 원소별 전달을 이해하면 반복문과 Stream 가운데 더 명확한 표현을 고를 수 있습니다.