Backend Development/Java

Java - 13. stream

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

이 코드를 실행하면 전체 리스트를 순회하며 모든 요소에 filter와 map을 적용할까? — 아니다. 첫 번째로 조건을 만족하는 요소를 찾는 즉시 중단된다. 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) 부작용 (디버깅) 요소를 소비하되 파이프라인 유지

sorted와 distinct는 stateful 연산이다 — 이전에 본 모든 요소의 상태를 유지해야 한다. 지연 평가의 이점이 줄어든다. 무한 스트림에 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)" 상태로 남는다. 디버깅 용도로만 쓴다.

collect와 Collector — 결과 조립

// 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()의 비용이 순차 스트림보다 높은 이유를 설명하라.

참고

'Backend Development > 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