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을 사용하여 연산을 병렬화한다. 하지만:
- 항상 빠른 것이 아니다 — 분할/병합 오버헤드가 데이터 크기보다 클 수 있다.
- 순서가 보장되지 않을 수 있다 —
forEach는 순서 무관.forEachOrdered는 순서 유지. - 공유 상태 변경은 금지 —
peek에서 외부 변수를 변경하면 경쟁 조건 발생. - 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 집약적 연산에서만 의미가 있다.
생각해 볼 문제
IntStream.range(0, 10).boxed().toList()와IntStream.range(0, 10).collect(ArrayList::new, ArrayList::add, ArrayList::addAll)의 차이는?stream.peek(System.out::println).count()의 출력 결과를 예측해 보자. peek이 실행되는가?Collectors.groupingBy에 downstream collector로mapping을 전달하면 무엇을 할 수 있는가?- 무한 스트림
Stream.iterate(1, n -> n + 1)에.limit(10)없이.findFirst()를 호출하면 어떻게 되는가? parallelStream에서sorted()의 비용이 순차 스트림보다 높은 이유를 설명하라.
참고
- Stream API (Java 25) - 접근 2026-07-10
- Collectors API (Java 25) - 접근 2026-07-10
- Stream 패키지 요약 - 접근 2026-07-10
- Stream.reduce API - 접근 2026-07-10
'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 |
