Stream 파이프라인에서 filter가 호출되지 않는 순간 — 지연 평가의 작동 원리

list.stream()
    .filter(x -> { System.out.println("filter: " + x); return x > 5; })
    .map(x -> { System.out.println("map: " + x); return x * 2; })
    .findFirst();

이 코드를 실행하면 전체 리스트를 순회하며 모든 요소에 filtermap을 적용할까? — 아니다. 첫 번째로 조건을 만족하는 요소를 찾는 즉시 중단된다. findFirst()가 단 하나의 결과만 필요하므로, Stream은 그것만 계산한다. 이것이 지연 평가(lazy evaluation)다. (Stream API)

지연 평가가 내부적으로 어떻게 동작하는가: Stream의 중간 연산(filter, map)은 즉시 실행되지 않고, 연산 파이프라인을 구성만 한다. 최종 연산(findFirst)이 호출되는 순간 — 데이터 소스에서 요소를 하나씩 꺼내며, 파이프라인을 통과시킨다. filter를 통과하면 map으로, map을 통과하면 findFirst로. findFirst가 하나를 받으면 — 그 즉시 파이프라인이 중단된다. 나머지 요소는 꺼내지지도 않는다.

비유: Stream 지연 평가는 새로운 직원에게 일을 지시하는 보스와 같다. 보스가 "1번부터 100번까지 서류에서 5보다 큰 숫자 찾아서 10배 하고, 첫 번째 결과만 가져와"라고 지시한다. 직원이 1번부터 확인하다가 6을 발견 → 60을 만들고 → "보스, 60입니다"라고 즉시 보고한다. 7번 ~ 100번 서류는 아예 열어보지도 않는다. Stream도 같다 — 최종 연산이 만족되면 파이프라인이 즉시 멈춘다.

이 글은 Stream의 파이프라인 구조, 지연 평가, 단축 평가(short-circuit), collector, 그리고 병렬 스트림의 함정을 풀어간다.

Stream이란 — 데이터 파이프라인의 추상화

Stream은 컬렉션의 요소를 선언적으로 변환·필터링·집계하는 파이프라인이다. (JLS §Stream API) 핵심 특성:

  • 데이터 원본을 변경하지 않는다 — Stream은 컬렉션을 직접 수정하지 않고 새 결과를 만든다.
  • 일회용이다 — 한 번 소비하면 재사용할 수 없다.
  • 지연 평가 — 최종 연산(terminal operation)이 호출될 때까지 중간 연산은 실행되지 않는다.
flowchart LR
    SRC["원본<br/>(List, Array, ...)"] -->|source| INT["중간 연산<br/>filter, map, sorted, ..."]
    INT -->|lazy| INT2["중간 연산"]
    INT2 -->|lazy| TERM["최종 연산<br/>collect, forEach, count, ..."]
    TERM --> RESULT["결과"]

중간 연산(intermediate) — 지연 평가의 주인공

연산 용도 특징
filter(Predicate) 조건 필터 입력과 같은 타입, 요소 수 감소 가능
map(Function) 변환 타입 변경 가능
flatMap(Function) 평탄화 중첩 구조를 1차원으로
sorted() / sorted(Comparator) 정렬 stateful — 전체 요소를 봐야 함
distinct() 중복 제거 stateful — 이전 요소 기억 필요
limit(n) 처음 n개 short-circuit — n개 후 중단
skip(n) 처음 n개 건너뜀  
peek(Consumer) 부작용 (디버깅) 요소를 소비하되 파이프라인 유지

sorteddistinctstateful 연산이다 — 이전에 본 모든 요소의 상태를 유지해야 한다. 지연 평가의 이점이 줄어든다. 무한 스트림에 sorted를 쓰면 항아리가 멈추지 않는다(hang).

flatMap — 중첩 구조 평탄화

// Java 25
List<List<Integer>> nested = List.of(
    List.of(1, 2, 3),
    List.of(4, 5),
    List.of(6, 7, 8, 9)
);

// map: Stream<Stream<Integer>> (중첩 유지)
// flatMap: Stream<Integer> (평탄화)
List<Integer> flat = nested.stream()
    .flatMap(List::stream)
    .toList();
System.out.println(flat);   // [1, 2, 3, 4, 5, 6, 7, 8, 9]

flatMap은 각 요소를 새로운 Stream으로 변환한 뒤, 모든 Stream을 하나로 합친다. DB 조인이나 중첩 데이터 평탄화에 필수적이다.

최종 연산(terminal) — 파이프라인을 실행하는 트리거

최종 연산이 호출되기 전까지 중간 연산은 실행되지 않는다. 이것이 지연 평가다.

연산 반환 타입 특징
collect(Collector) 컬렉션/맵 결과를 컬렉션으로 수집
toList() (Java 16+) List<T> 불변 리스트 (간편 버전)
forEach(Consumer) void 각 요소에 부작용 수행
count() long 요소 수
reduce(BinaryOperator) Optional<T> 하나로 축약
findFirst() / findAny() Optional<T> short-circuit
anyMatch / allMatch / noneMatch boolean short-circuit
min / max Optional<T> 최소/최대

지연 평가 + 단축 평가의 시너지

// Java 25 — 단축 평가 체감
List<Integer> nums = List.of(1, 2, 3, 4, 5, 6, 7, 8, 9, 10);

Optional<Integer> first = nums.stream()
    .filter(x -> {
        System.out.println("filter " + x);
        return x > 5;
    })
    .map(x -> {
        System.out.println("map " + x);
        return x * 10;
    })
    .findFirst();
filter 1
filter 2
filter 3
filter 4
filter 5
filter 6
map 6

확인할 것: filter가 1

5까지 실행되고(다 false), 6에서 true가 되자마자 map(6)이 실행되고 findFirst가 결과를 반환한다. 7

10은 처리되지 않는다 — findFirst가 하나만 필요하므로.

peek로 파이프라인 디버깅

// Java 25 — peek으로 중간 값 확인
nums.stream()
    .filter(n -> n > 3)
    .peek(n -> System.out.println("필터 통과: " + n))
    .map(n -> n * 2)
    .peek(n -> System.out.println("변환 후: " + n))
    .limit(2)
    .toList();

peek은 중간 연산이므로, 뒤에 최종 연산이 없으면 실행되지 않는다 — 파이프라인이 "먹지 않은(unconsumed)" 상태로 남는다. 디버깅 용도로만 쓴다.

collectCollector — 결과 조립

// Java 25 — toList (Java 16+, 간편)
List<String> names = stream.map(Person::name).toList();

// Java 25 — Collectors.toList() (가변 리스트)
List<String> mutable = stream.collect(Collectors.toList());

// Java 25 — 그룹핑
Map<String, List<Person>> byCity = people.stream()
    .collect(Collectors.groupingBy(Person::city));

// Java 25 — 분할 (boolean 키)
Map<Boolean, List<Integer>> evenOdd = nums.stream()
    .collect(Collectors.partitioningBy(n -> n % 2 == 0));

// Java 25 — 문자열 결합
String joined = names.stream().collect(Collectors.joining(", ", "[", "]"));
// "[Java, Kotlin, Scala]"

// Java 25 — 다운스트림 collector (그룹핑 + 집계)
Map<String, Long> countByCity = people.stream()
    .collect(Collectors.groupingBy(Person::city, Collectors.counting()));

reduce — 축약 연산

// Java 25
List<Integer> nums = List.of(1, 2, 3, 4, 5);

// reduce(identity, accumulator)
int sum = nums.stream().reduce(0, Integer::sum);   // 15

// reduce(BinaryOperator) → Optional
Optional<Integer> product = nums.stream().reduce((a, b) -> a * b);
// Optional[120]

// reduce(identity, accumulator, combiner) — 병렬용
int parallelSum = nums.parallelStream().reduce(
    0,                    // identity
    Integer::sum,         // accumulator (각 스레드)
    Integer::sum          // combiner (스레드 간 결과 병합)
);

reduce의 identity 값은 결합법칙이 성립하는 누적 연산이어야 한다. reduce("", (a, b) -> a + b)는 올바르지만, reduce(0, (a, b) -> a - b)는 병렬에서 순서가 바뀌어 결과가 달라진다(결합법칙 위반).

병렬 스트림(parallelStream) — 주의해서 쓸 것

// Java 25 — 병렬 스트림
long count = list.parallelStream()
    .filter(x -> x > 5)
    .count();

parallelStream()은 ForkJoinPool을 사용하여 연산을 병렬화한다. 하지만:

  1. 항상 빠른 것이 아니다 — 분할/병합 오버헤드가 데이터 크기보다 클 수 있다.
  2. 순서가 보장되지 않을 수 있다forEach는 순서 무관. forEachOrdered는 순서 유지.
  3. 공유 상태 변경은 금지peek에서 외부 변수를 변경하면 경쟁 조건 발생.
  4. stateful 중간 연산은 비용 증가sorted, distinct는 전체를 버퍼링해야 함.

병렬 스트림은 CPU 집약적 연산 + 대량 데이터(수만 건 이상)에서만 의미가 있다. 소량 데이터에서는 순차 스트림이 더 빠르다. 공통 ForkJoinPool을 사용하므로, 다른 병렬 작업에 영향을 줄 수 있다는 점도 주의. (Stream API - Parallel)

기본형 스트림 — IntStream, LongStream, DoubleStream

제네릭 Stream<Integer>는 박싱 비용이 든다. 수치 연산이 많으면 IntStream 등의 기본형 스트림을 쓴다:

// Java 25 — IntStream
int sum = IntStream.range(1, 101).sum();           // 1~100 합 = 5050
double avg = IntStream.of(1, 2, 3, 4, 5).average().orElse(0);   // 3.0

// 요약 통계 한 번에
IntSummaryStatistics stats = IntStream.of(3, 1, 4, 1, 5, 9, 2, 6)
    .summaryStatistics();
System.out.printf("count=%d, sum=%d, min=%d, avg=%.1f, max=%d%n",
    stats.getCount(), stats.getSum(), stats.getMin(),
    stats.getAverage(), stats.getMax());
// count=8, sum=31, min=1, avg=3.9, max=9

// 문자열 → IntStream (각 문자의 코드값)
"Hello".chars().forEach(c -> System.out.print((char) c + " "));
// H e l l o

IntStream.range(1, 101)1 ≤ n < 101 (반개구간). rangeClosed(1, 100)1 ≤ n ≤ 100 (폐구간). 둘의 결과는 같지만 의미가 다르다.

Stream vs for-loop — 언제 무엇을?

기준 for-loop Stream
단순 순회 더 빠름 (오버헤드 없음)  
필터 + 변환 + 수집   더 간결함
병렬 처리 수동 스레드 관리 parallelStream() 한 줄
가독성 명령형 (단계 명시) 선언형 (무엇을 할지)
디버깅 break point, 변수 검사 쉬움 peek 필요, 스택 트레이스 복잡

가독성을 위해 Stream을 우선하되, 성능이 중요한 핫 경로에서는 for-loop를 고려한다. 특히 박싱이 발생하는 Stream<Integer>보다 IntStream 또는 for-loop가 유리할 수 있다. 미세 최적화가 필요하면 JMH로 벤치마크한다.

실습 — Stream 파이프라인 실전

// Java 25 — StreamDemo.java
import java.util.*;
import java.util.stream.*;

public class StreamDemo {
    record Person(String name, String city, int age) {}

    public static void main(String[] args) {
        List<Person> people = List.of(
            new Person("Alice", "Seoul", 30),
            new Person("Bob", "Busan", 25),
            new Person("Charlie", "Seoul", 35),
            new Person("Diana", "Seoul", 28),
            new Person("Eve", "Busan", 40)
        );

        // 도시별 평균 나이
        Map<String, Double> avgAgeByCity = people.stream()
            .collect(Collectors.groupingBy(
                Person::city,
                Collectors.averagingInt(Person::age)
            ));
        System.out.println(avgAgeByCity);   // {Seoul=31.0, Busan=32.5}

        // 서울 거주자 중 최연소 2명 이름
        List<String> top2 = people.stream()
            .filter(p -> p.city().equals("Seoul"))
            .sorted(Comparator.comparing(Person::age))
            .limit(2)
            .map(Person::name)
            .toList();
        System.out.println(top2);   // [Diana, Alice]
    }
}
java StreamDemo.java
{Seoul=31.0, Busan=32.5}
[Diana, Alice]

확인할 것: 그룹핑 + 집계, 필터 + 정렬 + 제한 + 변환이 하나의 파이프라인으로 선언적으로 표현된다. 루프와 임시 변수 없이 원하는 결과를 도출한다.

요약 — 이 글의 결론

  • Stream은 지연 평가된다. 최종 연산이 호출되기 전까지 중간 연산은 실행되지 않는다. findFirst, anyMatch 등 short-circuit 연산은 필요한 만큼만 계산한다.
  • toList()(Java 16+)는 불변 리스트를 반환한다. 가변이 필요하면 collect(Collectors.toList()) 또는 collect(Collectors.toCollection(ArrayList::new))를 쓴다.
  • flatMap은 중첩 구조를 평탄화한다. Stream of Stream을 Stream으로 합친다. DB 조인, 중첩 리스트 평탄화에 필수.
  • reduce는 결합법칙이 성립하는 연산에만 쓴다. 병렬 스트림에서 특히 중요 — 연산 순서가 바뀌어도 결과가 같아야 한다.
  • 병렬 스트림은 무조건 빠른 것이 아니다. 분할/병합 오버헤드, stateful 연산, 공통 풀 경합을 고려한다. 대량 데이터 + CPU 집약적 연산에서만 의미가 있다.

생각해 볼 문제

  1. IntStream.range(0, 10).boxed().toList()IntStream.range(0, 10).collect(ArrayList::new, ArrayList::add, ArrayList::addAll)의 차이는?
  2. stream.peek(System.out::println).count()의 출력 결과를 예측해 보자. peek이 실행되는가?
  3. Collectors.groupingBy에 downstream collector로 mapping을 전달하면 무엇을 할 수 있는가?
  4. 무한 스트림 Stream.iterate(1, n -> n + 1).limit(10) 없이 .findFirst()를 호출하면 어떻게 되는가?
  5. parallelStream에서 sorted()의 비용이 순차 스트림보다 높은 이유를 설명하라.

참고

'Develop Artifacts > Java' 카테고리의 다른 글

Java - 15. datetime  (0) 2026.07.12
Java - 14. optional  (0) 2026.07.12
Java - 12. lambda functional  (0) 2026.07.12
Java - 11. generics  (0) 2026.07.12
Java - 10. collections  (0) 2026.07.12