TinyStream으로 스트림 직접 만들기
filter·map·toList를 갖춘 작은 스트림을 직접 만들면서, 연산마다 새 객체를 돌려주는 불변 체이닝과 외부 반복·내부 반복의 차이, 최종 연산까지 실행을 미루는 지연 평가를 익힙니다.
Stream API를 쓰기만 하면 메서드를 점으로 이어 쓰는 문법이 편리하다는 인상에서 멈추기 쉽습니다. 작은 스트림을 직접 만들어 보면 각 연산이 무엇을 저장하는지, 언제 실행되는지, 원소 타입이 어떻게 바뀌는지, 반복을 누가 맡는지가 분명해집니다. 이 글에서 만들 TinyStream은 원본 목록으로 객체를 만드는 of, 중간 연산인 filter와 map, 최종 연산인 toList와 count를 가집니다. 중간 연산(intermediate operation)은 다시 스트림을 돌려주어 다음 연산을 이어 붙일 수 있게 하는 연산이고, 최종 연산(terminal operation)은 목록이나 개수처럼 스트림이 아닌 결과를 돌려주며 파이프라인을 끝내는 연산입니다.
먼저 흔히 저지르는 실수인 '자기 자신을 고쳐서 돌려주는' 설계를 살펴보고, 연산마다 새 객체를 돌려주는 즉시 평가 버전을 만듭니다. 이어서 반복을 호출하는 쪽이 맡는 외부 반복과 라이브러리가 맡는 내부 반복을 비교하고, 마지막으로 Iterator를 감싸서 최종 연산이 올 때까지 아무것도 실행하지 않는 지연 시퀀스를 만듭니다. 실제 JDK의 Stream과 다른 점은 그때그때 짚겠습니다.
자기 자신을 고쳐서 돌려주는 체이닝의 함정
메서드를 점으로 이어 쓰게 하려면 메서드가 객체를 돌려주기만 하면 됩니다. 가장 쉬운 방법은 다음 구현처럼 this를 돌려주는 것입니다. 이 filter()는 내부 목록에 removeIf()를 적용한 뒤 자기 자신을 돌려주는데, 목록을 새로 만들어 바꿔 끼우는 것이 아니라 같은 목록에서 원소를 지운다는 점에 주의하세요. main에서는 기반 객체 base 하나에서 짝수만 남긴 결과와 3 이상만 남긴 결과를 따로 얻으려고 합니다.
import java.util.ArrayList;
import java.util.List;
import java.util.function.Predicate;
public final class MutableSelfReturningStream<T> {
private List<T> values;
MutableSelfReturningStream(List<T> source) {
values = new ArrayList<>(source);
}
MutableSelfReturningStream<T> filter(Predicate<? super T> predicate) {
values.removeIf(predicate.negate());
return this;
}
List<T> toList() {
return values;
}
public static void main(String[] args) {
MutableSelfReturningStream<Integer> base = new MutableSelfReturningStream<>(List.of(1, 2, 3, 4));
List<Integer> even = base.filter(value -> value % 2 == 0).toList();
List<Integer> large = base.filter(value -> value >= 3).toList();
System.out.println(even);
System.out.println(large);
}
}[4]
[4]기대한 출력은 [2, 4]와 [3, 4]였지만 두 줄 모두 [4]입니다. 상태를 순서대로 따라가 보면 이유가 보입니다. 처음 base.values는 [1, 2, 3, 4]이고, 첫 filter가 1과 3을 지워 같은 목록이 [2, 4]가 됩니다. toList()는 이 목록의 사본이 아니라 목록 자체를 돌려주므로 even도 같은 목록을 가리킵니다. 두 번째 filter는 이미 줄어든 [2, 4]에서 2를 지우고, 그 결과 base.values와 even과 large가 모두 원소 4 하나만 남은 같은 목록을 가리키게 됩니다.
두 결과 변수가 같은 목록을 가리킨다
base.filter가 같은 가변 목록을 수정하여 even과 large도 마지막 [4]를 함께 가리킵니다. [1,2,3,4]→[2,4]→[4] 전환과 alias를 고정해 공유 변경을 설명할 수 있습니다.
공유 목록의 참조
두 결과 변수가 같은 목록을 가리킨다
두 번째 filter 뒤에도 base.values·even·large는 같은 목록을 가리킵니다. 그 목록에는 4 하나만 남습니다.
두 결과 변수가 같은 목록을 가리킨다
두 번째 filter가 끝난 시점입니다. 화살표는 같은 목록의 참조를 뜻하며, even과 large를 출력하면 모두 [4]입니다. 원문 코드의 참조 관계를 그린 것이며 실행 캡처가 아닙니다.
이 예의 조건
- 원 API는 동일한 가변 목록을 반환합니다.
관계도의 보충 설명
| 구분 | 내용 |
|---|---|
| 2 | 원 두 번째 필터가 끝난 뒤 base.values·even·large는 원소 4만 있는 같은 목록을 참조한다. 화살표는 객체 참조이며 호출 순서가 아니다. 원 wide의 4개 노드와 3개 관계를 같은 의미로 모바일 240에 재배치한다. |
| 구분 | 내용 |
|---|---|
| 설명 | 원 두 번째 필터가 끝난 뒤 base.values·even·large는 원소 4만 있는 같은 목록을 참조한다. 화살표는 객체 참조이며 호출 순서가 아니다. 원 wide의 4개 노드와 3개 관계를 같은 의미로 모바일 240에 재배치한다. |
| 전체 읽기 정보 | 두 결과 변수가 같은 목록을 가리킨다 두 결과 변수가 같은 목록을 가리킨다 공유 목록의 참조 두 결과 변수가 같은 목록을 가리킨다 두 번째 filter 뒤에도 base.values·even·large는 같은 목록을 가리킵니다. 그 목록에는 4 하나만 남습니다. 두 결과 변수가 같은 목록을 가리킨다 두 번째 filter가 끝난 시점입니다. 화살표는 같은 목록의 참조를 뜻하며, even과 large를 출력하면 모두 [4]입니다. 원문 코드의 참조 관계를 그린 것이며 실행 캡처가 아닙니다. |
| 관찰·상황 | 값·보충 정보 | 변화·이유 |
|---|---|---|
| 기반 목록 base.values가 1,2,3,4를 보관합니다. |
|
|
| 첫 반환 참조 even이 [2,4]인 같은 목록을 받습니다. |
|
|
| 두 번째 필터 3 이상인 값만 남기면서 2를 제거하고, large도 같은 참조를 받습니다. |
|
|
두 결과 변수가 같은 목록을 가리킨다
base.filter가 같은 가변 목록을 수정하여 even과 large도 마지막 [4]를 함께 가리킵니다. [1,2,3,4]→[2,4]→[4] 전환과 alias를 고정해 공유 변경을 설명할 수 있습니다.
공유 목록의 참조
두 결과 변수가 같은 목록을 가리킨다
두 번째 filter 뒤에도 base.values·even·large는 같은 목록을 가리킵니다. 그 목록에는 4 하나만 남습니다.
두 결과 변수가 같은 목록을 가리킨다
두 번째 filter가 끝난 시점입니다. 화살표는 같은 목록의 참조를 뜻하며, even과 large를 출력하면 모두 [4]입니다. 원문 코드의 참조 관계를 그린 것이며 실행 캡처가 아닙니다.
이 예의 조건
- 원 API는 동일한 가변 목록을 반환합니다.
관계도의 보충 설명
| 구분 | 내용 |
|---|---|
| 2 | 원 두 번째 필터가 끝난 뒤 base.values·even·large는 원소 4만 있는 같은 목록을 참조한다. 화살표는 객체 참조이며 호출 순서가 아니다. 원 wide의 4개 노드와 3개 관계를 같은 의미로 모바일 240에 재배치한다. |
| 구분 | 내용 |
|---|---|
| 설명 | 원 두 번째 필터가 끝난 뒤 base.values·even·large는 원소 4만 있는 같은 목록을 참조한다. 화살표는 객체 참조이며 호출 순서가 아니다. 원 wide의 4개 노드와 3개 관계를 같은 의미로 모바일 240에 재배치한다. |
| 전체 읽기 정보 | 두 결과 변수가 같은 목록을 가리킨다 두 결과 변수가 같은 목록을 가리킨다 공유 목록의 참조 두 결과 변수가 같은 목록을 가리킨다 두 번째 filter 뒤에도 base.values·even·large는 같은 목록을 가리킵니다. 그 목록에는 4 하나만 남습니다. 두 결과 변수가 같은 목록을 가리킨다 두 번째 filter가 끝난 시점입니다. 화살표는 같은 목록의 참조를 뜻하며, even과 large를 출력하면 모두 [4]입니다. 원문 코드의 참조 관계를 그린 것이며 실행 캡처가 아닙니다. |
| 관찰·상황 | 값·보충 정보 | 변화·이유 |
|---|---|---|
| 기반 목록 base.values가 1,2,3,4를 보관합니다. |
|
|
| 첫 반환 참조 even이 [2,4]인 같은 목록을 받습니다. |
|
|
| 두 번째 필터 3 이상인 값만 남기면서 2를 제거하고, large도 같은 참조를 받습니다. |
|
|
이 버그는 호출할 때마다 돌려받은 값만 봐서는 찾기 어렵습니다. 첫 번째 filter 직후에 even을 출력했다면 [2, 4]가 나왔을 것입니다. 저장해 둔 참조가 나중에 어떤 객체를 읽게 되는지까지 따라가야 문제가 드러납니다.
이 설계는 map을 추가하려는 순간 더 버틸 수 없게 됩니다. map은 원소 타입을 T에서 R로 바꾸므로 같은 객체를 돌려줄 수가 없습니다. 위 클래스의 filter 메서드 바로 아래에 다음 메서드를 넣고 import java.util.function.Function;도 추가해서 컴파일하면 오류가 납니다.
<R> MutableSelfReturningStream<R> map(Function<? super T, ? extends R> mapper) {
return this;
}MutableSelfReturningStream.java:19: error: incompatible types: MutableSelfReturningStream<T> cannot be converted to MutableSelfReturningStream<R>
return this;
^
where T,R are type-variables:
T extends Object declared in class MutableSelfReturningStream
R extends Object declared in method <R>map(Function<? super T,? extends R>)
1 errorthis의 타입은 MutableSelfReturningStream<T>인데 돌려줘야 하는 것은 MutableSelfReturningStream<R>입니다. 형변환으로 억지로 맞추면 컴파일은 되지만 비검사 경고가 나고, List<T>로 선언된 목록에 R 값을 넣는 셈이 되어 타입 안전성이 깨집니다. 메서드를 이어 쓰게 하는 API를 플루언트(fluent) API라고 하는데, 플루언트 API라고 해서 자기 자신을 고쳐서 돌려줄 필요는 없습니다. 연산마다 새 객체를 돌려주면 기반 객체를 여러 갈래로 안전하게 나눠 쓸 수 있고, 타입이 바뀌는 연산도 자연스럽게 표현됩니다.
연산마다 새 객체를 돌려주는 TinyStream
아래 ImmutableEagerTinyStream은 생성자를 private으로 막고 of() 팩터리 메서드로만 객체를 만들게 합니다. 생성자는 받은 목록을 List.copyOf로 복사해 그 시점의 스냅샷을 보관합니다. filter와 map은 호출되는 즉시 반복문을 실행하고, 그 결과로 새 ImmutableEagerTinyStream을 만들어 돌려줍니다. map이 돌려주는 객체의 타입이 ImmutableEagerTinyStream<R>이라는 점이 원소 타입이 바뀌었음을 나타냅니다.
import java.util.ArrayList;
import java.util.List;
import java.util.function.Consumer;
import java.util.function.Function;
import java.util.function.Predicate;
public final class ImmutableEagerTinyStream<T> {
private final List<T> values;
private ImmutableEagerTinyStream(List<? extends T> values) {
this.values = List.copyOf(values);
}
static <T> ImmutableEagerTinyStream<T> of(List<? extends T> source) {
return new ImmutableEagerTinyStream<>(source);
}
ImmutableEagerTinyStream<T> filter(Predicate<? super T> predicate) {
List<T> selected = new ArrayList<>();
for (T value : values) {
if (predicate.test(value)) {
selected.add(value);
}
}
return new ImmutableEagerTinyStream<>(selected);
}
<R> ImmutableEagerTinyStream<R> map(Function<? super T, ? extends R> mapper) {
List<R> mapped = new ArrayList<>(values.size());
for (T value : values) {
mapped.add(mapper.apply(value));
}
return new ImmutableEagerTinyStream<>(mapped);
}
void forEach(Consumer<? super T> consumer) {
for (T value : values) {
consumer.accept(value);
}
}
long count() {
return values.size();
}
List<T> toList() {
return values;
}
public static void main(String[] args) {
ImmutableEagerTinyStream<Integer> base = of(List.of(1, 2, 3, 4));
List<String> even = base
.filter(value -> value % 2 == 0)
.map(value -> "N" + value)
.toList();
List<Integer> large = base.filter(value -> value >= 3).toList();
System.out.println(even);
System.out.println(large);
System.out.println("baseCount=" + base.count());
}
}[N2, N4]
[3, 4]
baseCount=4이번에는 두 갈래가 서로 영향을 주지 않습니다. base.filter(...)는 base를 건드리지 않고 [2, 4]를 가진 새 객체를 만들며, 이어지는 map도 그 객체를 바꾸지 않고 [N2, N4]를 가진 또 다른 객체를 만듭니다. 그래서 두 번째 갈래는 처음 그대로인 [1, 2, 3, 4]에서 출발해 [3, 4]를 얻고, 마지막 줄의 base.count()도 4입니다.
메서드 체이닝은 특별한 문법이 아니라, 앞 메서드의 반환값을 다음 메서드를 호출할 객체로 쓰는 호출의 연속입니다. 줄바꿈도 실행 단위와는 상관이 없습니다. 이 즉시 평가 구현에서는 filter 호출이 반복문을 끝까지 돈 다음에야 map 호출이 시작되므로, 앞 글의 단계별 파이프라인과 같은 순서로 함수가 불립니다. 뒤에서 만들 지연 시퀀스는 중간 연산을 호출할 때 '무엇을 할지'만 기록해 두고, 실제 순회는 최종 연산에서 시작합니다.
toList()는 생성자에서 만든 수정할 수 없는 목록을 그대로 돌려주므로, 호출한 쪽이 원소를 추가하거나 지우거나 바꿔서 TinyStream의 내부를 고칠 수 없습니다. 만약 내부에 가변 ArrayList를 보관한다면 돌려줄 때마다 방어적 복사를 해야 했을 것입니다. 다만 목록이 불변이어도 원소 객체는 원본과 공유되므로, 가변 원소의 상태 변화까지 막아 주지는 않습니다. 또 불변 TinyStream 객체는 잠금 없이 여러 스레드가 함께 읽을 수 있지만, 넘겨준 함수가 바깥의 가변 상태를 캡처하고 있다면 실행 전체가 스레드에 안전하다고 할 수는 없습니다.
한 번만 쓸 수 있는 JDK Stream과의 차이
위의 main은 base를 두 번 사용했습니다. TinyStream은 연산마다 스냅샷을 가진 새 객체를 만들 뿐 자기 상태를 바꾸지 않으므로 몇 번이고 다시 쓸 수 있습니다. 이름은 비슷해도 JDK의 Stream은 그렇지 않습니다. Stream은 한 번만 연산할 수 있고, 최종 연산을 마친 스트림을 다시 쓰다가 감지되면 IllegalStateException이 납니다. 파일이나 네트워크처럼 한 번 읽으면 끝나는 원본도 스트림으로 다룰 수 있어야 하고, 지연 순회를 하는 스트림은 순회가 어디까지 진행됐는지를 상태로 들고 있기 때문입니다.
그러니 TinyStream이 재사용된다고 해서 JDK Stream을 필드에 보관해 두고 여러 번 쓰면 안 됩니다. 같은 파이프라인을 여러 번 실행해야 한다면 스트림 대신 Supplier<Stream<T>>처럼 호출할 때마다 새 스트림을 만드는 함수를 보관하는 편이 맞습니다. 스트림 재사용 오류는 스트림 파이프라인: 원본, 중간 연산, 최종 연산에서 실제로 재현해 봅니다.
반복을 누가 맡는가: 외부 반복과 내부 반복
for 반복문과 Iterator를 쓰는 코드에서는 호출하는 쪽이 hasNext와 next를 언제 부를지, 언제 break로 빠져나올지를 직접 정합니다. 이런 방식을 외부 반복(external iteration)이라고 합니다. 반면 forEach나 filter와 map 파이프라인에서는 순회하는 반복문이 라이브러리 안에 있고, 호출하는 쪽은 원소마다 할 일만 함수로 넘깁니다. 이것을 내부 반복(internal iteration)이라고 합니다. 내부 반복에서 중간에 멈추려면 '계속할지'를 돌려주는 콜백을 받는 연산이나, findFirst처럼 멈추는 것 자체가 목적인 최종 연산이 따로 있어야 합니다.
다음 프로그램은 '3을 만나기 전까지의 원소 모으기'를 외부 반복과 내부 반복으로 각각 구현하고, 첫 번째 짝수를 찾는 findFirst도 함께 보여 줍니다.
import java.util.ArrayList;
import java.util.Iterator;
import java.util.List;
import java.util.function.Predicate;
public final class InternalExternalIteration {
@FunctionalInterface
interface VisitStep<T> {
boolean visit(T value);
}
static <T> List<T> externalUntil(List<T> source, Predicate<? super T> stop) {
List<T> visited = new ArrayList<>();
Iterator<T> iterator = source.iterator();
while (iterator.hasNext()) {
T value = iterator.next();
if (stop.test(value)) {
break;
}
visited.add(value);
}
return List.copyOf(visited);
}
static <T> void forEachUntil(Iterable<T> source, VisitStep<? super T> step) {
for (T value : source) {
if (!step.visit(value)) {
return;
}
}
}
static <T> List<T> internalUntil(Iterable<T> source, Predicate<? super T> stop) {
List<T> visited = new ArrayList<>();
forEachUntil(source, value -> {
if (stop.test(value)) {
return false;
}
visited.add(value);
return true;
});
return List.copyOf(visited);
}
static <T> T findFirst(List<T> source, Predicate<? super T> predicate) {
for (T value : source) {
if (predicate.test(value)) {
return value;
}
}
return null;
}
public static void main(String[] args) {
List<Integer> source = List.of(1, 2, 3, 4);
System.out.println(externalUntil(source, value -> value == 3));
System.out.println(internalUntil(source, value -> value == 3));
System.out.println(findFirst(source, value -> value % 2 == 0));
}
}[1, 2]
[1, 2]
2첫 줄의 externalUntil은 반복자를 직접 다루면서 3을 만나면 break로 빠져나옵니다. 둘째 줄의 internalUntil은 반복문을 갖고 있지 않습니다. 반복은 forEachUntil이 맡고, 넘겨받은 콜백이 true를 돌려주면 계속, false를 돌려주면 멈추는 신호로 해석합니다. 결과는 같지만 멈출지 정하는 코드가 반복문 안이 아니라 콜백 안에 있습니다. 셋째 줄의 findFirst는 처음으로 조건을 만족한 2를 돌려주는 순간 순회를 끝냅니다. 이 예제의 findFirst는 찾지 못하면 null을 돌려주지만, JDK Stream의 findFirst()는 '값이 없을 수 있음'을 null 대신 Optional로 나타냅니다.
forEachUntil 같은 연산이 따로 필요한 이유는 표준 Iterable.forEach()가 받는 Consumer의 accept가 void를 돌려주기 때문입니다. 콜백이 멈추라는 신호를 보낼 방법이 없으므로, 중간에 멈춰야 한다면 위처럼 boolean을 돌려주는 콜백을 받는 연산을 만들거나 findFirst 같은 전용 최종 연산을 제공해야 합니다.
내부 반복의 장점은 반복문을 라이브러리가 갖고 있다는 데서 나옵니다. 순회 방법을 라이브러리가 정하므로 순차로 돌지, 여러 스레드로 나눠 돌지, 필요할 때까지 미룰지를 호출하는 쪽 코드를 바꾸지 않고 고를 수 있습니다. 대신 콜백은 몇 번 불릴지, 어느 스레드에서 불릴지, 어떤 순서로 불릴지에 기대지 않아야 합니다. 반대로 break와 continue가 여러 곳에 얽히는 복잡한 흐름이라면 외부 반복문이 더 직접적이고 읽기 쉬울 수 있습니다.
최종 연산까지 실행을 미루는 지연 시퀀스
TinyStream은 filter를 부르는 순간 모든 원소를 검사하고, map을 부르는 순간 모든 결과를 계산합니다. 결과 가운데 앞의 두 개만 필요하더라도 이미 계산은 끝나 있습니다. 이제 이 순서를 뒤집어서, 중간 연산은 '어떻게 순회할지'만 쌓아 두고 실제 계산은 최종 연산이 원소를 요청할 때 하도록 만들어 보겠습니다. 이렇게 결과가 필요해질 때까지 계산을 미루는 방식을 지연 평가(lazy evaluation)라고 합니다.
아래 LazySequence가 보관하는 것은 원소 목록이 아니라 '반복자를 만드는 함수' Supplier<Iterator<T>> 하나입니다. 구성 요소별로 하는 일은 다음과 같습니다.
of는 원본을List.copyOf로 바로 복사해 두고, 호출할 때마다 그 스냅샷의 새 반복자를 만드는snapshot::iterator를 보관합니다.filter는 앞 단계의 반복자를 감싸는 새 반복자를 만드는 함수를 보관합니다. 이 반복자의hasNext()는prepare()를 불러 앞 단계에서 조건을 통과하는 원소가 나올 때까지 꺼내 보고, 찾은 원소를next에 맡아 둡니다.map도 앞 단계의 반복자를 감싸며,next()가 불릴 때마다 앞 단계에서 원소 하나를 받아 변환 함수를 적용합니다.- 최종 연산
take(limit)는 새 반복자를 하나 얻어 결과가limit개 모이거나 원소가 떨어질 때까지 꺼냅니다.
main은 함수가 불릴 때마다 trace에 기록하고, 변환 함수의 호출 수를 AtomicInteger로 셉니다. 람다 안에서는 사실상 final인 지역 변수만 쓸 수 있어서 int 변수를 직접 늘릴 수 없으므로, 값을 바꿀 수 있는 객체에 숫자를 담은 것입니다.
import java.util.ArrayList;
import java.util.Iterator;
import java.util.List;
import java.util.NoSuchElementException;
import java.util.concurrent.atomic.AtomicInteger;
import java.util.function.Function;
import java.util.function.Predicate;
import java.util.function.Supplier;
public final class LazySequence<T> {
private final Supplier<Iterator<T>> iteratorFactory;
private LazySequence(Supplier<Iterator<T>> iteratorFactory) {
this.iteratorFactory = iteratorFactory;
}
static <T> LazySequence<T> of(List<? extends T> source) {
List<T> snapshot = List.copyOf(source);
return new LazySequence<>(snapshot::iterator);
}
LazySequence<T> filter(Predicate<? super T> predicate) {
return new LazySequence<>(() -> new Iterator<>() {
private final Iterator<T> source = iteratorFactory.get();
private T next;
private boolean ready;
private void prepare() {
while (!ready && source.hasNext()) {
T candidate = source.next();
if (predicate.test(candidate)) {
next = candidate;
ready = true;
}
}
}
@Override
public boolean hasNext() {
prepare();
return ready;
}
@Override
public T next() {
prepare();
if (!ready) {
throw new NoSuchElementException();
}
T value = next;
ready = false;
next = null;
return value;
}
});
}
<R> LazySequence<R> map(Function<? super T, ? extends R> mapper) {
return new LazySequence<>(() -> new Iterator<>() {
private final Iterator<T> source = iteratorFactory.get();
@Override
public boolean hasNext() {
return source.hasNext();
}
@Override
public R next() {
return mapper.apply(source.next());
}
});
}
List<T> take(int limit) {
if (limit < 0) {
throw new IllegalArgumentException("negative limit");
}
List<T> result = new ArrayList<>(limit);
Iterator<T> iterator = iteratorFactory.get();
while (result.size() < limit && iterator.hasNext()) {
result.add(iterator.next());
}
return List.copyOf(result);
}
public static void main(String[] args) {
List<String> trace = new ArrayList<>();
AtomicInteger mapped = new AtomicInteger();
LazySequence<String> sequence = LazySequence
.of(List.of(1, 2, 3, 4, 5, 6))
.filter(value -> {
trace.add("test:" + value);
return value % 2 == 0;
})
.map(value -> {
mapped.incrementAndGet();
trace.add("map:" + value);
return "N" + value;
});
System.out.println("built: trace=" + trace);
System.out.println(sequence.take(2));
System.out.println("mapped=" + mapped.get() + " trace=" + trace);
trace.clear();
System.out.println(sequence.take(1));
System.out.println("mapped=" + mapped.get() + " trace=" + trace);
}
}built: trace=[]
[N2, N4]
mapped=2 trace=[test:1, test:2, map:2, test:3, test:4, map:4]
[N2]
mapped=3 trace=[test:1, test:2, map:2]첫 줄이 지연 평가의 핵심입니다. of, filter, map을 모두 호출했는데도 조건 함수와 변환 함수는 한 번도 불리지 않았습니다. 세 호출은 반복자를 만드는 함수를 한 겹씩 감쌌을 뿐입니다. 실제 계산은 take(2)가 반복자를 만들고 원소를 요청하면서 시작되는데, 첫 번째 원소를 얻는 과정을 호출 순서대로 따라가면 다음과 같습니다.
| 순서 | 호출 | 일어나는 일 |
|---|---|---|
| 1 | take가 map 반복자의 hasNext() 호출 | map 반복자가 filter 반복자의 hasNext()를 부름 |
| 2 | filter 반복자의 prepare() | 원본에서 1을 꺼내 검사(탈락), 2를 꺼내 검사(통과), 2를 맡아 둠 |
| 3 | take가 map 반복자의 next() 호출 | filter 반복자의 next()가 맡아 둔 2를 넘김 |
| 4 | map 반복자가 변환 함수 호출 | "N2"가 결과 목록에 들어감 |
두 번째 원소도 같은 방식으로 3을 검사해 탈락시키고 4를 통과시킨 뒤 map:4를 부릅니다. 결과가 두 개 모이면 while의 조건 result.size() < limit이 거짓이 되므로 hasNext()조차 다시 부르지 않고 멈춥니다. 그래서 셋째 줄의 기록에는 5와 6이 없고, 변환 함수는 두 번만 불렸습니다. 기록의 순서가 앞 글에서 본 '합친 반복문'과 같다는 것도 확인해 보세요. 반복자를 감싸는 구조 덕분에 원소 하나가 filter와 map을 연달아 통과한 다음에야 다음 원소로 넘어갑니다.
넷째 줄과 다섯째 줄은 같은 sequence로 take(1)을 다시 부른 결과입니다. take는 부를 때마다 iteratorFactory.get()으로 새 반복자를 만들기 때문에, 1부터 다시 검사해서 2를 통과시키고 map:2를 다시 부릅니다. 앞에서 이미 N2를 계산했지만 그 값을 저장해 두지 않았으므로, 변환 함수의 누적 호출 수는 3이 됩니다.
최종 호출마다 새 커서로 평가하는 Sequence
같은 지연 정의의 take(2)는 1..4를 검사하고 take(1)은 새 커서로 1..2를 다시 검사합니다. 매퍼 호출 횟수와 커서 수명 변화가 캐시와 지연을 구분합니다.
최종 호출마다 새 커서로 평가하는 Sequence
최종 호출마다 새 커서
최종 호출마다 새 커서로 평가하는 Sequence
구성 단계에서는 조건식과 매퍼를 실행하지 않습니다. take마다 새 반복자를 만들어 처음부터 필요한 결과만 구합니다.
of 뒤 filter·map 구성
- 검사한 원본 값·반환값
- 스냅샷만 확보; 조건식·매퍼 미실행
- 매퍼 호출 수
- 0
첫 take(2)
- 검사한 원본 값·반환값
- 1·2·3·4 검사 → [N2, N4]
- 매퍼 호출 수
- 이번 2회; 누적 2
다음 take(1)
- 검사한 원본 값·반환값
- 새 커서로 1·2 검사 → [N2]
- 매퍼 호출 수
- 이번 1회; 누적 3
직접 작성한 반복자와 조건식을 따라간 결과입니다. 원문의 mapped=2 출력은 첫 take 뒤이며 마지막 누적 3은 추가 출력 없이 코드에서 도출한 상태입니다.
지연 평가와 결과 캐시는 별개입니다. 다음 take(1)은 저장된 N2를 꺼내는 대신 새 커서로 1·2를 다시 검사합니다.
이 예의 조건
- 지연 시퀀스 정의는 메모이제이션이 아니다.
관계도의 보충 설명
| 구분 | 내용 |
|---|---|
| 2 | 두 take는 같은 스냅샷·함수 정의를 쓰지만 iteratorFactory.get으로 커서를 새로 만든다. 첫 take(2)는 1..4를 검사해 매퍼 2회이고 다음 take(1)은 1·2부터 다시 검사해 매퍼 1회다. 원 mapped=2 출력은 첫 take 직후이고 뒤 누적 3은 코드에서 구한 상태다. 화살표는 별도 최종 호출의 커서·평가·반환 관계이며 결과 캐시를 뜻하지 않는다. 모바일도 같은 7개 노드와 6개 관계이다. |
| 구분 | 내용 |
|---|---|
| 설명 | 두 take는 같은 스냅샷·함수 정의를 쓰지만 iteratorFactory.get으로 커서를 새로 만든다. 첫 take(2)는 1..4를 검사해 매퍼 2회이고 다음 take(1)은 1·2부터 다시 검사해 매퍼 1회다. 원 mapped=2 출력은 첫 take 직후이고 뒤 누적 3은 코드에서 구한 상태다. 화살표는 별도 최종 호출의 커서·평가·반환 관계이며 결과 캐시를 뜻하지 않는다. 모바일도 같은 7개 노드와 6개 관계이다. |
| 전체 읽기 정보 | 최종 호출마다 새 커서로 평가하는 Sequence 최종 호출마다 새 커서로 평가하는 Sequence 최종 호출마다 새 커서로 평가하는 Sequence 최종 호출마다 새 커서 최종 호출마다 새 커서로 평가하는 Sequence 구성 단계에서는 조건식과 매퍼를 실행하지 않습니다. take마다 새 반복자를 만들어 처음부터 필요한 결과만 구합니다. of 뒤 filter·map 구성 검사한 원본 값·반환값 스냅샷만 확보; 조건식·매퍼 미실행 매퍼 호출 수 0 첫 take(2) 검사한 원본 값·반환값 1·2·3·4 검사 → [N2, N4] 매퍼 호출 수 이번 2회; 누적 2 다음 take(1) 검사한 원본 값·반환값 새 커서로 1·2 검사 → [N2] 매퍼 호출 수 이번 1회; 누적 3 직접 작성한 반복자와 조건식을 따라간 결과입니다. 원문의 mapped=2 출력은 첫 take 뒤이며 마지막 누적 3은 추가 출력 없이 코드에서 도출한 상태입니다. 지연 평가와 결과 캐시는 별개입니다. 다음 take(1)은 저장된 N2를 꺼내는 대신 새 커서로 1·2를 다시 검사합니다. |
| 관찰·상황 | 값·보충 정보 | 변화·이유 |
|---|---|---|
| 정의만 보관 snapshot과 filter/map 함수를 가진 시퀀스를 만듭니다. |
|
|
| 첫 take(2) 새 cursor 1이 1·2·3·4를 검사합니다. |
|
|
| 다음 take(1) 시작 새 cursor 2가 1부터 다시 검사합니다. |
|
|
| 다음 결과 이번 순회에서 N2 하나를 얻습니다. |
|
|
최종 호출마다 새 커서로 평가하는 Sequence
같은 지연 정의의 take(2)는 1..4를 검사하고 take(1)은 새 커서로 1..2를 다시 검사합니다. 매퍼 호출 횟수와 커서 수명 변화가 캐시와 지연을 구분합니다.
최종 호출마다 새 커서로 평가하는 Sequence
최종 호출마다 새 커서
최종 호출마다 새 커서로 평가하는 Sequence
구성 단계에서는 조건식과 매퍼를 실행하지 않습니다. take마다 새 반복자를 만들어 처음부터 필요한 결과만 구합니다.
of 뒤 filter·map 구성
- 검사한 원본 값·반환값
- 스냅샷만 확보; 조건식·매퍼 미실행
- 매퍼 호출 수
- 0
첫 take(2)
- 검사한 원본 값·반환값
- 1·2·3·4 검사 → [N2, N4]
- 매퍼 호출 수
- 이번 2회; 누적 2
다음 take(1)
- 검사한 원본 값·반환값
- 새 커서로 1·2 검사 → [N2]
- 매퍼 호출 수
- 이번 1회; 누적 3
직접 작성한 반복자와 조건식을 따라간 결과입니다. 원문의 mapped=2 출력은 첫 take 뒤이며 마지막 누적 3은 추가 출력 없이 코드에서 도출한 상태입니다.
지연 평가와 결과 캐시는 별개입니다. 다음 take(1)은 저장된 N2를 꺼내는 대신 새 커서로 1·2를 다시 검사합니다.
이 예의 조건
- 지연 시퀀스 정의는 메모이제이션이 아니다.
관계도의 보충 설명
| 구분 | 내용 |
|---|---|
| 2 | 두 take는 같은 스냅샷·함수 정의를 쓰지만 iteratorFactory.get으로 커서를 새로 만든다. 첫 take(2)는 1..4를 검사해 매퍼 2회이고 다음 take(1)은 1·2부터 다시 검사해 매퍼 1회다. 원 mapped=2 출력은 첫 take 직후이고 뒤 누적 3은 코드에서 구한 상태다. 화살표는 별도 최종 호출의 커서·평가·반환 관계이며 결과 캐시를 뜻하지 않는다. 모바일도 같은 7개 노드와 6개 관계이다. |
| 구분 | 내용 |
|---|---|
| 설명 | 두 take는 같은 스냅샷·함수 정의를 쓰지만 iteratorFactory.get으로 커서를 새로 만든다. 첫 take(2)는 1..4를 검사해 매퍼 2회이고 다음 take(1)은 1·2부터 다시 검사해 매퍼 1회다. 원 mapped=2 출력은 첫 take 직후이고 뒤 누적 3은 코드에서 구한 상태다. 화살표는 별도 최종 호출의 커서·평가·반환 관계이며 결과 캐시를 뜻하지 않는다. 모바일도 같은 7개 노드와 6개 관계이다. |
| 전체 읽기 정보 | 최종 호출마다 새 커서로 평가하는 Sequence 최종 호출마다 새 커서로 평가하는 Sequence 최종 호출마다 새 커서로 평가하는 Sequence 최종 호출마다 새 커서 최종 호출마다 새 커서로 평가하는 Sequence 구성 단계에서는 조건식과 매퍼를 실행하지 않습니다. take마다 새 반복자를 만들어 처음부터 필요한 결과만 구합니다. of 뒤 filter·map 구성 검사한 원본 값·반환값 스냅샷만 확보; 조건식·매퍼 미실행 매퍼 호출 수 0 첫 take(2) 검사한 원본 값·반환값 1·2·3·4 검사 → [N2, N4] 매퍼 호출 수 이번 2회; 누적 2 다음 take(1) 검사한 원본 값·반환값 새 커서로 1·2 검사 → [N2] 매퍼 호출 수 이번 1회; 누적 3 직접 작성한 반복자와 조건식을 따라간 결과입니다. 원문의 mapped=2 출력은 첫 take 뒤이며 마지막 누적 3은 추가 출력 없이 코드에서 도출한 상태입니다. 지연 평가와 결과 캐시는 별개입니다. 다음 take(1)은 저장된 N2를 꺼내는 대신 새 커서로 1·2를 다시 검사합니다. |
| 관찰·상황 | 값·보충 정보 | 변화·이유 |
|---|---|---|
| 정의만 보관 snapshot과 filter/map 함수를 가진 시퀀스를 만듭니다. |
|
|
| 첫 take(2) 새 cursor 1이 1·2·3·4를 검사합니다. |
|
|
| 다음 take(1) 시작 새 cursor 2가 1부터 다시 검사합니다. |
|
|
| 다음 결과 이번 순회에서 N2 하나를 얻습니다. |
|
|
반복자 자체가 아니라 반복자를 만드는 함수를 보관한 이유가 여기 있습니다. Iterator는 어디까지 읽었는지를 기억하는 가변 커서입니다. 시퀀스가 반복자 하나를 들고 있었다면 첫 번째 take(2)가 4까지 읽어 둔 커서를 두 번째 take(1)이 이어받아, 5와 6을 검사하고 N6을 돌려줬을 것입니다. Supplier<Iterator<T>>를 보관하면 최종 연산마다 독립된 커서가 생기므로 같은 정의를 몇 번이고 다시 실행할 수 있습니다. 하지만 네트워크 연결에서 한 번 흘러 지나가는 데이터처럼 원본 자체를 다시 읽을 수 없다면 이 방법은 통하지 않고, JDK Stream처럼 한 번만 쓸 수 있는 설계를 택해야 합니다.
지연 평가와 캐시는 서로 다른 것이라는 점도 기억해 두세요. 지연 평가는 '필요할 때까지 계산하지 않는다'는 뜻일 뿐이고, 한 번 계산한 결과를 기억해 두는 것은 메모이제이션(memoization)이라는 별개의 기능입니다. 결과를 재사용하고 싶다면 그 기능을 따로 설계해야 합니다.
더 깊이: JDK Stream은 원본을 언제 읽는가
LazySequence.of는 호출하는 순간 원본을 복사하므로, 그 뒤에 원본 목록이 바뀌어도 시퀀스에는 반영되지 않습니다. JDK의 ArrayList.stream()은 다르게 동작합니다. 다음 프로그램은 스트림을 만든 뒤 원본에 원소를 추가하고 나서 최종 연산을 실행하고, 이어서 같은 스트림을 한 번 더 쓰려고 합니다.
import java.util.ArrayList;
import java.util.List;
import java.util.stream.Stream;
public final class JdkStreamDifferences {
public static void main(String[] args) {
List<String> titles = new ArrayList<>(List.of("one", "two"));
Stream<String> stream = titles.stream();
titles.add("three");
System.out.println(stream.toList());
try {
stream.count();
} catch (IllegalStateException exception) {
System.out.println("IllegalStateException: " + exception.getMessage());
}
}
}[one, two, three]
IllegalStateException: stream has already been operated upon or closed첫 줄에 스트림을 만든 뒤에 추가한 three가 들어 있습니다. JDK 스트림은 최종 연산이 실행될 때 비로소 원본을 순회하기 시작하므로, ArrayList처럼 잘 만들어진 원본이라면 최종 연산이 시작되기 전까지의 변경이 결과에 반영됩니다. java.util.stream 패키지 문서의 '비간섭(non-interference)' 설명이 바로 이 예와 같은 코드로 이 동작을 보여 줍니다. 반대로 최종 연산이 진행되는 도중에 원본을 고치는 것은 허용되지 않는 간섭이며, ConcurrentModificationException이 나거나 결과를 예측할 수 없게 됩니다.
둘째 줄은 앞에서 말한 한 번만 쓸 수 있다는 규칙이 실제로 적용된 모습입니다. Stream 문서는 재사용을 감지하면 IllegalStateException을 던질 수 있다고 적고 있고, JDK 21에서는 위처럼 감지되어 예외가 납니다. TinyStream과 LazySequence는 이해를 돕기 위한 학습용 구현이며, 원본을 읽는 시점과 재사용 규칙은 JDK Stream과 다르다는 점을 기억해 두세요.
정리
- 중간 연산이 자기 자신을 고쳐서
this를 돌려주면, 같은 기반에서 나눈 갈래들이 하나의 목록을 공유하며 서로를 망가뜨립니다. 타입이 바뀌는map은 아예 같은 객체를 돌려줄 수 없습니다. - 연산마다 스냅샷을 가진 새 객체를 돌려주면 기반을 안전하게 여러 번 쓸 수 있습니다. 이 TinyStream은 연산을 호출하는 즉시 반복문을 실행하는 즉시 평가입니다.
- 외부 반복은 호출하는 쪽이 반복문을 갖고, 내부 반복은 라이브러리가 반복문을 갖습니다. 내부 반복에서 중간에 멈추려면 멈춤 신호를 주고받는 연산이나
findFirst같은 전용 최종 연산이 필요합니다. - 지연 시퀀스는 중간 연산에서 반복자를 감싸는 방법만 쌓아 두고, 최종 연산이 원소를 요청할 때 필요한 만큼만 계산합니다. 최종 연산마다 새 반복자를 만들기 때문에 다시 실행할 수 있지만, 이전 결과를 기억하지는 않습니다.
연습 문제
MutableSelfReturningStream의main에서 두 번째 조건만value >= 5로 바꾸면even과large는 무엇을 출력할까요? 또ImmutableEagerTinyStream에서base.filter(짝수)와base.filter(value >= 3)가 서로 다른 원소 목록을 유지하는 이유는 무엇일까요?- 본문의 지연
sequence를 만든 직후take(0),take(3),take(1)을 차례로 부르면, 각 호출에서 검사하는 원본 값과 변환 함수 호출 수, 누적 호출 수는 어떻게 될까요?take(-1)은 원소를 검사하는 데까지 갈까요? ImmutableEagerTinyStream.of(List.of(1, 2, 3, 4, 5, 6))에 본문과 같은 기록 남기는 조건 함수(짝수)와 변환 함수를 차례로 적용하면, 최종 연산을 부르기 전까지 각 함수는 몇 번 불릴까요? 나중에 결과의 앞 두 개만 쓴다면 그 횟수가 줄어들까요?
힌트
- 두 번째
filter가 지우는 목록이 어느 목록인지 생각해 보세요. take는 반복자를 만들기 전에limit부터 검사하고,while은result.size() < limit을hasNext()보다 먼저 확인합니다.- TinyStream의
filter와map은 호출되는 순간 반복문을 끝까지 돕니다.
해설
- 두 줄 모두
[]입니다. 첫filter가 공유 목록을[2, 4]로 줄이고, 두 번째filter가 같은 목록에서 5 미만인 2와 4를 모두 지우므로base.values,even,large가 모두 빈 목록 하나를 가리킵니다. 불변 TinyStream에서는filter가base를 고치지 않고 새 목록을 가진 새 객체를 돌려줍니다. 그래서 짝수 결과[2, 4]와 3 이상 결과[3, 4]는 서로 다른 목록이고,base는 여전히[1, 2, 3, 4]이므로base.count()도 4입니다. 다만 목록만 따로일 뿐 원소 객체는 공유될 수 있다는 점은 그대로입니다. take(0)은while의 첫 조건0 < 0이 거짓이라hasNext()를 한 번도 부르지 않으므로, 원소를 검사하지 않고[]를 돌려줍니다. 변환 함수 호출은 0번입니다.take(3)은 새 반복자로 1부터 6까지 모두 검사하고 2, 4, 6을 변환해[N2, N4, N6]을 돌려주며, 변환 함수는 3번 불립니다. 이어지는take(1)은 또 새 반복자로 1과 2를 검사해[N2]를 돌려주고, 변환 함수는 1번 불려 누적 4번이 됩니다. 앞에서 계산한N2를 꺼내 쓰지 않는 것은 시퀀스가 결과를 기억하지 않기 때문입니다.take(-1)은 첫 줄의limit검사에서IllegalArgumentException("negative limit")을 던지므로, 반복자를 만들지도 원소를 검사하지도 않습니다. 기록을 남기며 실행해 보면take(3)의 기록은test:1부터test:6까지 사이사이에map:2,map:4,map:6이 끼어 있고,take(1)의 기록은[test:1, test:2, map:2]이며,take(0)과take(-1)의 기록은 비어 있습니다.- 조건 함수는
filter를 호출하는 순간 6번, 변환 함수는map을 호출하는 순간 3번 불립니다. 최종 연산을 부르기 전에 이미 아홉 번의 호출이 끝나 있으므로, 나중에 결과의 앞 두 개만 쓰더라도 횟수는 줄지 않습니다. 같은 일을LazySequence의take(2)로 하면 조건 함수 4번, 변환 함수 2번으로 끝납니다. 필요한 결과가 전체의 일부일 때 지연 평가가 유리한 이유가 이것입니다.
다음 글
상태를 가진 람다와 재사용 문제에서는 이 글에서 여러 번 경고한 '바깥 상태를 바꾸는 함수'를 본격적으로 다룹니다. 이미 본 키를 기억하는 조건 함수처럼 상태가 꼭 필요한 경우에, 그 상태를 파이프라인 실행 한 번과 같은 수명으로 맞추는 방법을 배웁니다.