Java Stream은 왜 Terminal 전까지 아무것도 하지 않는가
Lazy evaluation의 본질부터 Sink 체인, Spliterator 분할, Collector의 가변 reduction까지 — Stream API 설계 철학을 관통하는 하나의 원리를 추적한다.
- 01 람다는 어떻게 바이트코드가 되는가
- 02 Java Stream은 왜 Terminal 전까지 아무것도 하지 않는가
- 03 parallelStream()은 왜 항상 빠르지 않은가
- 04 Optional은 왜 메서드 반환 타입으로만 써야 하는가
- 05 CompletableFuture는 왜 Future를 버렸는가
- 06 Java 인터페이스는 왜 이렇게 진화했는가
- 07 Java 날짜/시간 API는 왜 이렇게 설계됐을까
- 08 Java는 왜 Record, Sealed, Pattern을 함께 설계했을까
- 09 Virtual Thread는 왜 수백만 개가 가능한가
- 10 자바 함수형 프로그래밍의 다섯 가지 기둥
Java Stream API를 쓰다 보면 이상한 경험을 한다. map()을 호출해도 아무 일이 일어나지 않는다. peek()을 걸어도 출력이 없다. terminal 연산 하나를 붙이는 순간 모든 것이 한꺼번에 실행된다. 이것은 버그가 아니다. Stream의 모든 설계 결정은 하나의 질문에서 나온다 — “평가를 최대한 늦출 수 있다면, 무엇을 얻는가?”
파이프라인은 실행이 아니라 구조다
Stream.of(1, 2, 3).filter(x -> x > 1).map(x -> x * 2) 를 호출하고 나면 무엇이 남는가? 실행 결과가 아니라 파이프라인 노드의 연결 리스트가 남는다. Head → StatelessOp(filter) → StatelessOp(map). 각 중간 연산은 AbstractPipeline의 서브클래스 인스턴스를 하나 만들고, previousStage 포인터로 이전 노드를 가리킨다. 모든 노드가 sourceStage(첫 번째 Head)를 공유하기 때문에 terminal 호출 시 역방향으로 전체 파이프라인을 탐색할 수 있다.
이 구조 덕분에 중간 연산은 O(연산 수) 의 객체 생성 비용만 든다. 원소는 아직 한 번도 건드리지 않았다.
Terminal이 당기면, Source가 민다
forEach()나 collect()가 호출되는 순간, 두 가지 일이 순서대로 일어난다.
첫째, 역방향 Sink 체인이 구성된다. Terminal Sink를 시작으로, 각 파이프라인 노드의 opWrapSink()가 호출되며 체인이 쌓인다. map 노드가 ForEachSink를 감싸고, filter 노드가 그 MapSink를 감싼다. 체인 끝에 FilterSink가 맨 앞에 앉는다.
둘째, Source가 순방향으로 원소를 push한다. spliterator.forEachRemaining(filterSink)가 호출되면, 원소 하나가 FilterSink.accept() → MapSink.accept() → ForEachSink.accept() 순으로 전파된다. 모든 중간 연산이 한 번의 패스로 실행되며, 어떤 중간 배열도 생성되지 않는다.
// 출력 순서가 "수직"임을 확인:
Stream.of(1, 2, 3)
.filter(x -> { System.out.println("filter: " + x); return x > 1; })
.map(x -> { System.out.println("map: " + x); return x * 2; })
.forEach(x -> System.out.println("result: " + x));
// filter: 1
// filter: 2
// map: 2
// result: 4
// filter: 3
// map: 3
// result: 6
원소 1, 2, 3이 각각 파이프라인을 완전히 통과한다. “filter를 전부 끝내고 map을 시작”하는 것이 아니다.
Short-circuit — 조기 종료 신호의 전파
Lazy evaluation이 진정 강력해지는 지점은 Sink.cancellationRequested()다. limit(3)의 LimitSink는 세 번째 원소를 내보내고 나면 remaining == 0이 된다. 이후 cancellationRequested()가 true를 반환하면, Source의 copyInto() 루프가 break된다.
Stream.iterate(1, x -> x + 1) // 무한 소스
.filter(x -> x % 2 == 0)
.limit(5)
.forEach(System.out::println); // 10까지만 생성됨
findFirst(), anyMatch(), allMatch() 모두 같은 메커니즘으로 동작한다. 조건이 충족되면 done = true가 설정되고, 체인 전체의 cancellationRequested()가 true를 반환해 Source가 멈춘다. anyMatch(x > 5)가 1000만 개 리스트에서 첫 번째 6을 만나는 순간 종료되는 이유다.
sorted()는 end()가 호출될 때까지 모든 원소를 버퍼에 쌓는다. 무한 Stream에 sorted()를 걸면 limit()이 있어도 메모리가 먼저 찬다. 순서는 항상 filter() → limit() → sorted()여야 한다.
Spliterator — 병렬화의 열쇠
parallelStream()이 .stream()과 다른 이유는 Spliterator.trySplit()이다. ArrayList의 Spliterator는 중점을 기준으로 O(1) 분할이 가능하다. ForkJoinPool이 재귀적으로 trySplit()을 호출해 청크를 만들고, 각 청크를 별도 스레드에 할당한다.
반면 LinkedList의 Spliterator는 trySplit()이 항상 null을 반환한다. 연결 리스트는 중간을 알 수 없으므로 분할 불가능하다. LinkedList.parallelStream()은 병렬화 오버헤드만 더하고 이득은 없다.
Spliterator의 characteristics() 비트도 중요하다. SORTED 플래그가 있으면 distinct()가 HashSet 대신 인접 비교만 사용해 메모리를 O(N)에서 O(1)로 줄인다. SIZED | SUBSIZED가 있으면 병렬 청크 크기를 정확히 계산해 부하를 균등하게 분산할 수 있다.
Collector — 가변 reduction이 불변보다 나은 이유
reduce("", (a, b) -> a + b)로 문자열 1만 개를 연결하면 O(N²)이 된다. 매 단계마다 새로운 String을 만들기 때문이다. collect(StringBuilder::new, StringBuilder::append, ...)는 누적자를 제자리에서 수정하므로 O(N)이다. 100배 이상의 차이가 여기서 나온다.
Collector의 5개 컴포넌트 중 combiner는 병렬 처리 전용이다. 각 청크가 독립적인 누적자(supplier로 생성)를 사용하고, 청크가 완료되면 combiner가 이를 하나로 병합한다. 올바른 combiner 없이는 병렬 collect()가 데이터를 잃거나 손상시킨다.
Stateless 먼저, Stateful 나중에
map()과 filter()는 원소 하나를 받아 처리하고 즉시 다음으로 넘긴다. 메모리 O(1), 병렬화 즉시 가능. sorted()와 distinct()는 모든 원소를 버퍼에 담은 뒤에야 다음 단계로 보낼 수 있다. 메모리 O(N), 병렬 병합 복잡.
1억 개 정수에서 상위 100개를 정렬해 가져오는 경우를 비교하면:
sorted() → filter(): 1억 개를 정렬(O(N log N)) 후 필터 → ~2초, ~800MBfilter() → sorted(): 1천 개로 축소 후 정렬(O(k log k)) → ~50ms, ~8KB
40배 속도, 100배 메모리 차이가 연산 순서 하나로 생긴다.
정리
- Stream의 중간 연산은 노드를 추가할 뿐, 실행하지 않는다. 평가는 Terminal이 당기는 순간 시작된다.
- 각 원소는 모든 중간 연산을 수직으로 한 번에 통과한다. 중간 배열은 만들어지지 않는다.
Sink.cancellationRequested()가 Short-circuit의 신호선이다.limit,findFirst,anyMatch모두 이 메커니즘으로 Source를 멈춘다.- 병렬화는
Spliterator.trySplit()의 가능 여부에 달렸다.LinkedList는 분할 불가,ArrayList는O(1)분할. - 성능 최적화의 1원칙: Stateless(filter/map) 먼저, Stateful(sorted/distinct) 나중에.
Lazy evaluation은 단순한 성능 트릭이 아니다. 무한 Stream을 유한하게 처리하고, 불필요한 연산을 원천에서 차단하는 설계 철학이다. Terminal 한 줄이 빠진 파이프라인이 조용히 아무것도 하지 않는 이유가 바로 거기 있다.