지연 연산과 스트림 만들기
최종 연산을 호출해야 비로소 계산이 시작되는 지연 연산과 필요한 만큼만 원소를 꺼내는 단락 평가를 실행 결과로 확인하고, 컬렉션·배열·값·파일·무한 원본에서 스트림을 만드는 방법을 배웁니다.
앞 글에서 순차 스트림은 원소 하나를 파이프라인 끝까지 보낸 뒤 다음 원소를 꺼낸다는 것을 확인했습니다. 그렇다면 이 흐름은 언제 시작될까요? filter나 map을 호출하는 순간일까요, 아니면 결과를 달라고 하는 순간일까요? 답은 뒤쪽이고, 이 성질을 지연 연산이라고 합니다. 스트림이 쓸데없는 계산을 건너뛰고 끝이 없는 원본까지 다룰 수 있는 것도 지연 연산 덕분입니다.
이 글의 앞부분에서는 지연 연산과 단락 평가가 실제로 어떤 원소를 처리하고 어떤 원소를 건너뛰는지 실행 결과로 확인합니다. 뒷부분에서는 컬렉션, 배열, 값 목록, 파일, 무한 수열에서 스트림을 만드는 방법과 각 방법의 주의점을 정리합니다.
최종 연산을 호출해야 계산이 시작된다
아래 코드는 map이 호출되는 시점을 출력문으로 확인합니다. 파이프라인을 변수에 담아 둔 뒤 메시지를 하나 출력하고, 그다음에 최종 연산 toList()를 호출합니다.
import java.util.List;
import java.util.stream.Stream;
public final class LazyUntilTerminal {
public static void main(String[] args) {
Stream<String> pipeline =
List.of("a", "b", "c").stream()
.map(
value -> {
System.out.println("map " + value);
return value.toUpperCase();
});
System.out.println("파이프라인을 만들었습니다");
List<String> result = pipeline.toList();
System.out.println("result=" + result);
}
}map의 람다는 원소를 변환할 때마다 map 줄을 찍습니다. 그러니 map 줄과 안내 메시지 가운데 무엇이 먼저 나오는지가 변환이 언제 일어났는지를 알려 줍니다.
파이프라인을 만들었습니다
map a
map b
map c
result=[A, B, C]코드에서는 map을 먼저 호출했는데, 출력에서는 '파이프라인을 만들었습니다'가 map a보다 먼저 나옵니다. map을 호출한 시점에는 변환 함수를 파이프라인에 등록했을 뿐이고, 원본에서 원소를 꺼내 그 함수를 호출하는 일은 toList()를 호출한 뒤에 일어났기 때문입니다. 중간 연산은 '이렇게 처리하라'는 설명을 쌓아 두기만 하고, 최종 연산이 그 설명을 실행합니다. 이처럼 결과가 실제로 필요해질 때까지 계산을 미루는 방식을 지연 연산(lazy evaluation)이라고 합니다.
이 성질 때문에 생기는 흔한 실수가 최종 연산을 빼먹는 것입니다. 다음 문장은 컴파일 오류도 경고도 없이 실행되지만, 최종 연산이 없으므로 아무것도 출력하지 않습니다.
List.of("a", "b", "c").stream()
.map(
value -> {
System.out.println("map " + value);
return value.toUpperCase();
}); // 최종 연산이 없다결과가 필요하면 toList() 같은 최종 연산을 호출하고 그 반환값을 써야 합니다. 원소마다 출력이나 저장 같은 동작만 하고 싶다면 최종 연산 forEach를 쓸 수 있지만, 그런 코드는 대개 반복문으로 쓰는 편이 의도가 더 분명합니다. 이 글의 예제가 람다 안에서 출력하는 것은 실행 순서를 눈으로 보기 위해서이고, 실제 프로그램에서 따라 할 방식은 아닙니다.
최종 연산은 필요한 만큼만 원소를 꺼낸다
지연 연산의 진짜 이점은 최종 연산이 무엇을 원하는지 알고 나서 계산을 시작한다는 데 있습니다. findFirst는 조건을 만족하는 첫 번째 원소 하나만 원하므로, 답을 찾은 뒤에는 나머지 원소를 원본에서 꺼낼 필요가 없습니다. 아래 코드의 peek는 원소를 바꾸지 않고 지나가는 원소를 들여다보게 해 주는 중간 연산인데, 여기서는 원본에서 어떤 원소를 꺼냈는지 찍는 데 씁니다.
import java.util.List;
public final class LazyShortCircuitTrace {
public static void main(String[] args) {
int first =
List.of(3, 4, 5, 6, 8).stream()
.peek(value -> System.out.println("꺼냄 " + value))
.filter(value -> value % 2 == 0)
.map(value -> value * 10)
.findFirst()
.orElseThrow();
System.out.println("first=" + first);
}
}peek는 원본에서 원소를 꺼낼 때마다 꺼냄 줄을 찍습니다. 원본에는 원소가 다섯 개 있으므로, 꺼냄 줄이 다섯 개보다 적다면 나머지는 꺼내지도 않은 것입니다.
꺼냄 3
꺼냄 4
first=403은 꺼내자마자 filter에서 탈락합니다. 다음으로 꺼낸 4는 filter와 map을 지나 40이 되고, 이 순간 findFirst가 답을 얻습니다. 그래서 5, 6, 8은 원본에서 꺼내지도 않았습니다. 원소가 다섯 개가 아니라 백만 개였더라도 꺼낸 원소는 두 개였을 것입니다. findFirst는 원소가 하나도 없을 수 있어서 결과를 Optional에 담아 돌려주고, orElseThrow()는 값이 있으면 꺼내고 없으면 NoSuchElementException을 던집니다. Optional은 33장에서 자세히 다룹니다.
이처럼 답이 정해지면 남은 원소를 처리하지 않고 끝내는 연산을 단락(short-circuiting) 연산이라고 합니다. &&가 왼쪽이 거짓이면 오른쪽을 계산하지 않는 단락 평가와 같은 생각입니다.
| 종류 | 연산 | 멈추는 때 |
|---|---|---|
| 최종 연산 | findFirst, findAny | 원소를 하나 찾았을 때 |
| 최종 연산 | anyMatch, allMatch, noneMatch | 답이 정해졌을 때. 예를 들어 anyMatch는 조건을 만족하는 원소를 처음 만났을 때 |
| 중간 연산 | limit(n) | 원소를 n개 내보냈을 때 |
| 중간 연산 | takeWhile | 조건을 처음으로 만족하지 않는 원소를 만났을 때 |
findAny는 이름 그대로 아무 원소나 돌려줄 수 있습니다. 순차 스트림에서는 대개 첫 원소가 나오지만, 명세는 어떤 원소를 고를지 정하지 않으며 병렬 스트림에서는 실행할 때마다 다른 원소가 나올 수 있습니다. 첫 번째 원소가 필요하면 findFirst를 씁니다.
비싼 변환은 filter 뒤에 둔다
단락 연산은 중간에도 올 수 있습니다. 다음 코드는 20 이상인 값만 골라 문자열 라벨로 바꾸고, limit(2)로 앞의 두 개만 남깁니다. 라벨 변환이 비싼 작업이라고 생각하고, 원본에서 꺼낸 원소와 변환한 원소를 각각 찍어 봅니다.
import java.util.List;
public final class FilterBeforeExpensiveMap {
public static void main(String[] args) {
List<String> labels =
List.of(10, 15, 20, 25, 30).stream()
.peek(value -> System.out.println("꺼냄 " + value))
.filter(value -> value >= 20)
.map(
value -> {
System.out.println(" 변환 " + value);
return "m" + value;
})
.limit(2)
.toList();
System.out.println("labels=" + labels);
}
}변환 줄은 두 칸 들여 써서 꺼냄 줄과 구별했습니다. 원본의 다섯 원소 가운데 몇 개가 꺼내지고 몇 개가 변환되는지 세어 보세요.
꺼냄 10
꺼냄 15
꺼냄 20
변환 20
꺼냄 25
변환 25
labels=[m20, m25]출력에서 두 가지를 확인할 수 있습니다. 첫째, 변환은 filter를 통과한 20과 25에만 일어났습니다. 만약 map을 filter 앞에 두고 라벨에서 숫자를 다시 꺼내 거르도록 바꾸면, 10과 15도 변환된 뒤에 버려지므로 변환이 네 번 일어납니다. 둘째, 30은 꺼내지도 않았습니다. limit(2)가 원소 두 개를 내보낸 순간 더 받을 필요가 없다는 신호를 보내고, 원본은 다음 원소를 꺼내지 않고 멈춥니다.
그래서 결과가 같다면 값싸고 많이 걸러 내는 filter를 비싼 map보다 앞에 두는 것이 좋습니다. 다만 순서를 바꾸기 전에 의미가 같은지부터 확인해야 합니다. filter의 조건이 map의 결과를 써야 한다면 순서를 바꿀 수 없고, limit처럼 위치를 세는 연산은 앞뒤로 옮기면 결과가 달라집니다. 연습 문제 2에서 그런 예를 직접 계산해 봅니다.
결과에 필요 없는 계산은 생략될 수 있다
지연 연산은 원소를 늦게 꺼내는 데서 그치지 않고, 결과에 영향이 없다면 계산을 아예 건너뛰기도 합니다. 다음 코드는 두 파이프라인에서 원소 수를 셉니다. 차이는 두 번째에만 filter가 있다는 것뿐입니다.
import java.util.List;
public final class CountSkipsPeek {
public static void main(String[] args) {
long all =
List.of("A", "B", "C").stream()
.peek(value -> System.out.println("peek " + value))
.count();
System.out.println("count=" + all);
long notB =
List.of("A", "B", "C").stream()
.peek(value -> System.out.println("peek " + value))
.filter(value -> !value.equals("B"))
.count();
System.out.println("count=" + notB);
}
}두 파이프라인 모두 count()는 맞는 개수를 돌려줍니다. 눈여겨볼 것은 각 파이프라인에서 peek 줄이 몇 번 찍히느냐입니다.
count=3
peek A
peek B
peek C
count=2첫 번째 파이프라인에서는 peek가 한 번도 호출되지 않았습니다. 원본인 List는 원소가 세 개라는 것을 이미 알고 있고, peek는 원소 수를 바꾸지 않으므로 원소를 하나씩 셀 필요 없이 답이 3으로 정해지기 때문입니다. Java 9부터 count() 문서는 이처럼 원본에서 개수를 바로 계산할 수 있으면 원소를 순회하지 않고 중간 연산도 실행하지 않을 수 있다고 밝히고 있습니다. 두 번째 파이프라인은 filter가 몇 개를 남길지 미리 알 수 없으므로 원소를 모두 꺼내 검사했고, 그래서 peek가 세 번 호출되었습니다.
이 예는 앞 글에서 말한 원칙이 왜 필요한지 보여 줍니다. peek나 map 안에 로그 기록, 통계 집계, 데이터 저장을 넣으면, 그 코드는 파이프라인이 어떻게 구성되었는지에 따라 실행될 수도 있고 건너뛰어질 수도 있습니다. peek는 주로 디버깅을 돕기 위해 있는 연산이고, 프로그램이 꼭 해야 하는 일은 최종 연산의 결과를 받은 뒤에 합니다.
스트림을 만드는 여러 방법
지금까지는 주로 List.of(...).stream()으로 스트림을 만들었습니다. 원본은 이 밖에도 여러 가지가 있습니다.
| 원본 | 만드는 방법 | 원소 |
|---|---|---|
| 컬렉션 | list.stream() | 컬렉션의 원소 |
| 배열 | Arrays.stream(array) | 배열의 원소. int[]이면 IntStream |
| 값 나열 | Stream.of("x", "y") | 인수로 준 값 |
| 원소 없음 | Stream.empty() | 없음 |
| 코드로 하나씩 추가 | Stream.builder() | add로 넣은 값 |
null일 수 있는 값 하나 | Stream.ofNullable(value) (Java 9) | 값이 있으면 1개, null이면 0개 |
| 두 스트림 잇기 | Stream.concat(a, b) | a의 원소 다음에 b의 원소 |
| 정수 범위 | IntStream.range(0, n), IntStream.rangeClosed(1, n) | int 값 |
| 파일의 줄 | Files.lines(path) | 줄마다 String 하나 |
| 무한 수열 | Stream.iterate(...), Stream.generate(...) | 끝없이 만들어지는 값 |
다음 코드는 표의 원본 가운데 여섯 가지를 차례로 만들어 봅니다. 모두 원소 수가 정해진 유한한 원본입니다.
import java.util.Arrays;
import java.util.List;
import java.util.stream.Stream;
public final class FiniteStreamSources {
public static void main(String[] args) {
long collection = List.of("a", "b").stream().count();
int arraySum = Arrays.stream(new int[] {1, 2, 3}).sum();
List<String> values = Stream.of("x", "y", "z").toList();
Stream.Builder<String> builder = Stream.builder();
builder.add("left").add("right");
List<String> built = builder.build().toList();
String nickname = null;
List<String> nullable = Stream.ofNullable(nickname).toList();
List<String> joined = Stream.concat(Stream.of("a", "b"), Stream.of("c")).toList();
System.out.println("collection=" + collection);
System.out.println("array-sum=" + arraySum);
System.out.println("values=" + values);
System.out.println("built=" + built);
System.out.println("nullable=" + nullable);
System.out.println("joined=" + joined);
}
}Stream.ofNullable은 값이 null이면 빈 스트림을, 아니면 원소 하나짜리 스트림을 만듭니다. 여기서는 nickname이 null이므로 nullable 줄에서 빈 쪽의 결과를 볼 수 있습니다.
collection=2
array-sum=6
values=[x, y, z]
built=[left, right]
nullable=[]
joined=[a, b, c]Arrays.stream에 int[]를 넘기면 Stream<Integer>가 아니라 int 전용 스트림인 IntStream이 나오고, 그래서 sum()을 바로 호출할 수 있습니다. 빌더는 넣을 원소를 조건문 등으로 정해 가며 하나씩 추가할 때 씁니다. build()를 호출한 뒤에는 빌더를 다시 쓸 수 없고, 그 뒤에 add를 호출하면 IllegalStateException이 발생합니다.
Stream.ofNullable은 null이면 빈 스트림을, 아니면 원소 하나짜리 스트림을 만듭니다. 그래서 nickname이 null인 이 예제에서는 빈 목록이 나왔습니다. Map.get처럼 null을 돌려줄 수 있는 호출을 flatMap(id -> Stream.ofNullable(cache.get(id)))처럼 감싸면 null인 결과를 자연스럽게 빼고 이어 갈 수 있습니다. Stream.empty()도 비슷한 자리에 씁니다. 어떤 분기에서는 원소를 내보내고 다른 분기에서는 아무것도 내보내지 않아야 할 때, 그 분기가 빈 스트림을 돌려주면 타입을 맞출 수 있습니다. flatMap은 다음 글에서 다룹니다.
Stream.concat은 두 스트림을 이어 붙인 새 스트림을 만듭니다. 이어 붙일 스트림이 많다고 concat을 여러 번 겹쳐 호출하면, 원소 하나를 꺼낼 때마다 겹친 깊이만큼 호출이 쌓여 StackOverflowError까지 날 수 있다고 Stream 문서가 경고합니다. 그럴 때는 Stream.of(a, b, c).flatMap(s -> s)처럼 스트림의 스트림을 한 번에 펼치는 편이 낫습니다.
기본형 배열은 Arrays.stream으로 펼친다
int 배열을 스트림으로 만들 때 Stream.of를 쓰면 예상과 다른 결과가 나옵니다. 같은 배열 values를 두 API에 넘겨 보겠습니다.
import java.util.Arrays;
import java.util.stream.IntStream;
import java.util.stream.Stream;
public final class PrimitiveArraySources {
public static void main(String[] args) {
int[] values = {1, 2, 3};
IntStream spread = Arrays.stream(values); // int 원소 3개
Stream<int[]> wrapped = Stream.of(values); // int[] 원소 1개
System.out.println("Arrays.stream(values).sum()=" + spread.sum());
System.out.println("Stream.of(values).count()=" + wrapped.count());
System.out.println("IntStream.of(values).count()=" + IntStream.of(values).count());
Integer[] boxed = {1, 2, 3};
System.out.println("Stream.of(boxed).count()=" + Stream.of(boxed).count());
}
}네 줄은 합계 하나와 개수 셋입니다. 같은 values를 넘겼는데도 어느 API를 썼느냐에 따라 개수가 달라지는지 보세요.
Arrays.stream(values).sum()=6
Stream.of(values).count()=1
IntStream.of(values).count()=3
Stream.of(boxed).count()=3같은 int 배열을 전달해도 다른 원소 타입
같은 배열, 다른 원소 단위
같은 int 배열을 전달해도 다른 원소 타입
values는 int 배열 {1, 2, 3}입니다. 배열의 숫자 세 개를 펼칠지 배열 자체 한 개를 넣을지가 다릅니다.
Arrays.stream(values)
- 원소 타입과 수
- int 원소 3개 · IntStream
- 최종 연산 예
- sum() → 6
Stream.of(values)
- 원소 타입과 수
- int[] 원소 1개 · Stream<int[]>
- 최종 연산 예
- count() → 1
Arrays.stream(values)는 배열 안의 숫자 세 개를 int 원소로 펼친 IntStream을 만들고, 그 합은 6입니다. 반면 Stream.of(values)의 원소 수는 1입니다. 배열 길이를 잘못 센 것이 아니라, 배열 객체 하나를 원소로 가진 Stream<int[]>가 만들어졌기 때문입니다. 변수 선언에 적힌 타입 Stream<int[]>가 이를 그대로 보여 줍니다.
이유는 제네릭에 있습니다. Stream.of에는 값 하나를 받는 of(T t)와 여러 값을 받는 of(T... values)가 있는데, 타입 매개변수 T에는 참조형만 올 수 있습니다. 그래서 int[]는 'T의 배열'이 될 수 없고, 배열 전체가 값 하나로 취급되어 T가 int[]인 of(T t)가 선택됩니다. 원소 타입이 참조형인 Integer[]는 T[]에 맞으므로 마지막 줄처럼 원소 세 개로 펼쳐집니다. 기본형 배열은 Arrays.stream이나 IntStream.of로 펼치면 됩니다. IntStream 같은 기본형 스트림이 박싱을 어떻게 줄이는지는 32장 4절에서 다룹니다.
파일처럼 자원에 연결된 스트림은 닫는다
Files.lines가 만드는 스트림은 파일을 연 채로 원소를 하나씩 읽습니다. 앞 글에서 말했듯이 최종 연산은 스트림을 닫지 않으므로, 이런 스트림은 try-with-resources로 직접 닫아야 합니다. 다음 코드는 임시 파일을 만들어 내용이 있는 줄을 세고, onClose로 스트림이 닫히는 순간에 메시지를 출력합니다.
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.List;
import java.util.stream.Stream;
public final class LinesWithResources {
public static void main(String[] args) throws IOException {
Path file = Files.createTempFile("posts", ".txt");
Files.write(file, List.of("stream", "", "collector"));
try (Stream<String> lines =
Files.lines(file).onClose(() -> System.out.println("파일 스트림을 닫았습니다"))) {
long nonBlank = lines.filter(line -> !line.isBlank()).count();
System.out.println("내용 있는 줄=" + nonBlank);
}
Files.delete(file);
}
}onClose로 등록한 동작은 스트림이 닫힐 때 실행됩니다. 그러니 닫힘 메시지가 개수 출력의 앞에 나오는지 뒤에 나오는지가 스트림이 언제 닫혔는지를 알려 줍니다.
내용 있는 줄=2
파일 스트림을 닫았습니다출력 순서를 보면 최종 연산 count()가 끝나 결과를 출력한 뒤에도 스트림은 아직 열려 있었고, try 블록을 빠져나가는 순간에 닫혔습니다. Stream은 AutoCloseable을 구현하므로 try-with-resources에 그대로 넣을 수 있습니다. 컬렉션이나 배열에서 만든 스트림은 붙잡고 있는 자원이 없으므로 닫지 않아도 됩니다. 이런 스트림을 닫는다고 원본 컬렉션이 사라지는 것도 아닙니다. Files.lines로 큰 파일을 처리하는 예는 24장 4절에 있습니다.
무한 스트림은 멈출 곳을 정해 줘야 한다
Stream.iterate(seed, next)는 첫 값 seed에서 시작해 직전 값에 next 함수를 적용한 값을 끝없이 만들어 냅니다. Stream.generate(supplier)는 원소가 필요할 때마다 supplier를 호출해 값을 받습니다. 둘 다 끝이 없는 스트림이지만, 지연 연산 덕분에 이런 스트림을 만드는 것 자체는 아무 문제가 없습니다. 원소는 요청받을 때만 만들어지기 때문입니다. 문제는 toList()나 count()처럼 모든 원소를 원하는 최종 연산을 그대로 붙일 때이고, 그래서 어디서 멈출지를 파이프라인에 적어 줘야 합니다.
import java.util.List;
import java.util.function.Supplier;
import java.util.stream.IntStream;
import java.util.stream.Stream;
public final class BoundedSequenceGenerator {
public static void main(String[] args) {
List<Integer> byLimit = Stream.iterate(1, value -> value * 2).limit(7).toList();
List<Integer> byTakeWhile =
Stream.iterate(1, value -> value * 2).takeWhile(value -> value <= 64).toList();
List<Integer> byHasNext = Stream.iterate(1, value -> value <= 64, value -> value * 2).toList();
int rangeSum = IntStream.rangeClosed(1, 10).sum();
List<String> tickets = Stream.generate(new SequenceSupplier()).limit(3).toList();
System.out.println("limit " + byLimit);
System.out.println("takeWhile " + byTakeWhile);
System.out.println("hasNext " + byHasNext);
System.out.println("sum=" + rangeSum + ", tickets=" + tickets);
}
private static final class SequenceSupplier implements Supplier<String> {
private int next = 1;
@Override
public String get() {
return "T" + next++;
}
}
}SequenceSupplier는 get()이 불릴 때마다 번호를 하나씩 올려 T1, T2처럼 돌려줍니다. IntStream.rangeClosed(1, 10)은 끝값 10까지 포함하는 범위이므로 sum은 1부터 10까지의 합입니다.
limit [1, 2, 4, 8, 16, 32, 64]
takeWhile [1, 2, 4, 8, 16, 32, 64]
hasNext [1, 2, 4, 8, 16, 32, 64]
sum=55, tickets=[T1, T2, T3]처음 세 줄은 1, 2, 4, …로 두 배씩 커지는 같은 수열을 세 가지 방법으로 멈춘 것입니다. limit(7)은 개수로 멈추므로 몇 개가 필요한지 미리 알아야 합니다. takeWhile(Java 9)은 조건을 처음으로 만족하지 않는 원소, 여기서는 128을 만나는 순간 멈춥니다. 조건식을 받는 iterate(seed, hasNext, next)(Java 9)는 멈추는 조건을 원본에 넣은 것으로, for (int v = 1; v <= 64; v *= 2)와 같은 순서로 동작합니다. 다음 값 128을 만든 뒤 조건 value <= 64를 검사하고, 거짓이므로 그 값을 내보내지 않고 스트림을 끝냅니다.
넷째 줄의 IntStream.rangeClosed(1, 10)은 1부터 10까지 양 끝을 포함하는 유한한 범위이고, 그 합은 55입니다. 끝을 빼려면 range(1, 10)을 씁니다. tickets는 generate가 공급자를 세 번 호출해 얻은 값입니다. 공급자 SequenceSupplier는 필드 next에 상태를 가지고 있어서 호출할 때마다 다른 번호를 돌려줍니다. 이런 공급자는 순차 스트림에서만 안전합니다. 병렬 스트림에서는 여러 스레드가 next++를 동시에 실행할 수 있고, generate가 만드는 스트림에는 만남 순서도 없어서 limit(3)이 어떤 세 원소를 고를지 정해져 있지 않습니다. 여러 스레드가 번호를 나눠 받아야 한다면 AtomicInteger 같은 원자적 수단이 필요합니다.
단락 연산이 있다고 해서 무한 스트림이 반드시 끝나는 것은 아닙니다. java.util.stream 패키지 문서의 표현으로는 단락 연산이 있는 것은 무한 스트림 처리가 끝나기 위한 필요조건이지 충분조건이 아닙니다. 다음 코드는 findFirst라는 단락 연산이 있는데도 끝나지 않습니다.
// 끝나지 않는 코드: filter가 모든 원소를 버리므로 findFirst가 답을 얻지 못한다
Optional<String> never = Stream.generate(() -> "x").filter(String::isEmpty).findFirst();generate는 계속 "x"를 만들고 filter는 그것을 계속 버리므로, findFirst에는 원소가 하나도 도착하지 않습니다. limit 뒤에 필요한 개수만큼 원소가 도착하지 않는 경우도 마찬가지입니다. 같은 이유로 filter(value -> value <= 64)는 takeWhile을 대신할 수 없습니다. filter는 64보다 큰 값을 버릴 뿐 스트림을 끝내지는 않기 때문입니다.
지연 연산이 오히려 문제가 되는 경우
지연 연산은 결과를 한 번 쓸 때 가장 잘 맞습니다. 같은 결과를 여러 번 써야 한다면 스트림을 매번 다시 만들어 같은 계산을 반복하기보다, toList()로 한 번 모아 둔 목록을 재사용하는 편이 낫습니다. 앞 글에서 본 것처럼 스트림은 한 번만 쓸 수 있기 때문이기도 합니다.
자원에 연결된 스트림을 메서드 밖으로 돌려줄 때는 더 조심해야 합니다. 스트림을 돌려받은 쪽은 아직 계산이 하나도 일어나지 않은 상태를 받는 것이라서, 자원이 언제 닫히는지가 애매해집니다. 다음 메서드는 파일을 닫는 것을 잊지 않으려고 try-with-resources를 썼지만, 그 때문에 오히려 쓸 수 없는 스트림을 돌려줍니다.
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.List;
import java.util.stream.Stream;
public final class ClosedTooEarly {
static Stream<String> readLines(Path file) throws IOException {
try (Stream<String> lines = Files.lines(file)) {
return lines; // try 블록을 빠져나가며 닫힌다
}
}
public static void main(String[] args) throws IOException {
Path file = Files.createTempFile("posts", ".txt");
Files.write(file, List.of("stream", "collector"));
try {
System.out.println(readLines(file).count());
} catch (IllegalStateException e) {
System.out.println("IllegalStateException: " + e.getMessage());
} finally {
Files.delete(file);
}
}
}main은 readLines가 돌려준 스트림에 바로 count()를 호출하고, IllegalStateException이 나면 그 메시지를 찍습니다. 임시 파일은 finally에서 지우므로 예외가 나도 남지 않습니다.
IllegalStateException: stream has already been operated upon or closedreadLines가 돌려준 스트림은 메서드를 빠져나가는 순간 닫혔고, 호출한 쪽에서 count()를 부르자 이미 닫힌 스트림이라는 예외가 났습니다. 지연 연산 때문에 파일을 실제로 읽는 시점은 최종 연산을 호출할 때인데, 그때는 이미 파일이 닫힌 뒤입니다. 해결 방법은 자원을 연 메서드가 최종 연산까지 끝내고 List나 개수 같은 결과를 돌려주는 것입니다. 꼭 스트림을 돌려줘야 한다면 Files.lines처럼 '호출한 쪽이 닫아야 한다'고 문서에 분명히 적습니다.
마지막으로, 원본이 작고 변환이 단순하다면 지연 연산으로 아끼는 계산은 대개 무시할 만합니다. '스트림이 더 빠르다'거나 '반복문이 더 빠르다'고 일반화하지 말고, 할당과 박싱, sorted 같은 상태 보유 연산의 비용이 실제 작업에서 문제가 될 때 측정해서 판단합니다.
더 깊이: 원본을 읽는 시점과 원본을 고치면 안 되는 이유
지연 연산은 원본을 읽는 시점에도 적용됩니다. stream()을 호출할 때 원본을 복사해 두는 것이 아니라, 최종 연산이 시작될 때 원본을 읽기 시작합니다. 다음 코드는 스트림을 만든 뒤, 최종 연산을 호출하기 전에 원본 목록에 원소를 추가합니다.
import java.util.ArrayList;
import java.util.List;
import java.util.stream.Stream;
public final class LateBindingSource {
public static void main(String[] args) {
List<String> words = new ArrayList<>(List.of("one", "two"));
Stream<String> stream = words.stream();
words.add("three");
System.out.println(stream.toList());
}
}words.stream()을 호출한 시점에는 원소가 두 개였습니다. 결과 목록에 원소가 몇 개 들어 있는지 보면 스트림이 원본을 언제 읽었는지 알 수 있습니다.
[one, two, three]스트림을 만든 뒤에 추가한 three가 결과에 들어 있습니다. ArrayList처럼 JDK의 일반 컬렉션은 최종 연산이 시작되기 전까지의 수정을 반영하도록 만들어져 있기 때문입니다. 반대로 최종 연산이 실행되는 도중에 원본을 고치는 것은 허용되지 않습니다. 패키지 문서는 이 규칙을 비간섭(non-interference)이라고 부릅니다. 예를 들어 words.stream().forEach(...) 안에서 words.add를 호출하면, ArrayList에서 만든 스트림은 수정을 감지하고 대개 ConcurrentModificationException을 던집니다. 원본을 바꾸는 대신 결과를 새 목록으로 만들어 돌려받는 것이 스트림을 쓰는 올바른 방식입니다.
정리
- 중간 연산은 처리 방법을 등록할 뿐이고, 최종 연산을 호출해야 원본에서 원소를 꺼내 계산이 시작됩니다. 최종 연산을 빼먹은 파이프라인은 아무 일도 하지 않습니다.
findFirst,anyMatch,limit같은 단락 연산은 답이 정해지면 남은 원소를 꺼내지 않습니다. 그래서 값싸게 많이 걸러 내는filter를 비싼map앞에 두면 계산이 줄어듭니다.- 결과에 영향이 없으면 구현이 연산을 생략할 수 있습니다.
List의count()가peek를 건너뛴 것처럼, 람다 안의 부수 효과는 실행된다고 믿을 수 없습니다. - 기본형 배열은
Arrays.stream이나IntStream.of로 펼칩니다.Stream.of(intArray)는 배열 하나를 원소로 가진 스트림을 만듭니다. Files.lines처럼 자원에 연결된 스트림은try-with-resources안에서 최종 연산까지 끝냅니다.- 무한 스트림에는
limit,takeWhile, 조건식을 받는iterate로 멈출 곳을 정해 줍니다. 단락 연산이 있어도 원소가 충분히 도착하지 않으면 끝나지 않습니다.
연습 문제
- 1부터 1씩 커지는 무한 원본을
Stream.iterate로 만들고, 3의 배수만 골라 제곱한 값 가운데 앞의 다섯 개를 목록으로 만드세요. - 1번 풀이에서
limit(5)를filter바로 앞으로 옮기면 결과는 무엇일까요? 또 원본을 1부터 10까지로 바꾸고limit(5)를 원래 자리에 두면 결과가 다섯 개가 될까요? int[] values = {};일 때Arrays.stream(values).count()와Stream.of(values).count()는 각각 무엇을 돌려줄까요?Stream.Builder에서build()를 호출한 뒤 같은 빌더에add("late")를 호출하면 어떻게 될까요?
1번 정답과 풀이
import java.util.List;
import java.util.stream.Stream;
public final class FiveMultipleSquaresSolution {
public static void main(String[] args) {
List<Integer> result =
Stream.iterate(1, value -> value + 1)
.filter(value -> value % 3 == 0)
.map(value -> value * value)
.limit(5)
.toList();
System.out.println(result);
}
}실행 결과:
[9, 36, 81, 144, 225]무한 원본 뒤에 filter, map, limit 순서로 연산을 이었으므로, 필요한 다섯 값을 얻는 순간 최종 연산이 끝납니다. 3의 배수는 끝없이 나오므로 filter를 통과하는 원소도 계속 생기고, 다섯 번째 통과 원소인 15의 제곱 225를 내보내는 순간 limit(5)가 더 받을 필요가 없다는 신호를 보냅니다. 그래서 원본은 15까지만 만들어집니다. limit를 빼면 toList()가 끝없는 원본의 모든 원소를 기다리게 되어 프로그램이 끝나지 않습니다.
2번 풀이
limit(5)를 filter 앞으로 옮기면 원본에서 1부터 5까지 다섯 개만 꺼낸 뒤에 거르므로, 3만 통과해 결과는 [9]입니다. '처음 다섯 개 가운데 3의 배수'와 '3의 배수 가운데 처음 다섯 개'는 다른 질문이고, limit의 위치가 그 차이를 만듭니다.
원본을 1부터 10까지로 바꾸면(예를 들어 Stream.iterate(1, value -> value <= 10, value -> value + 1)) 3의 배수는 3, 6, 9 세 개뿐이라 결과는 [9, 36, 81]입니다. limit(5)는 '많아야 다섯 개'라는 뜻이고, 부족한 원소를 만들어 채우지는 않습니다. 원본이 유한하므로 세 개를 내보낸 뒤 원본이 끝나면서 정상적으로 종료됩니다.
3번 풀이
Arrays.stream(values).count()는 0이고, Stream.of(values).count()는 1입니다. Arrays.stream은 배열 안의 원소를 펼치므로 빈 배열에서는 원소가 없습니다. Stream.of는 배열 객체 하나를 원소로 받으므로, 배열이 비어 있어도 '빈 배열 하나'라는 원소가 있습니다.
4번 풀이
IllegalStateException이 발생합니다. 빌더는 build()를 호출하면 '완성된' 상태로 바뀌고, 그 뒤로는 원소를 받지 않습니다. 이미 만든 스트림에 원소를 더 붙이는 방법은 없으므로, 원소를 더 넣어야 한다면 새 빌더를 만들거나 Stream.concat으로 다른 스트림을 이어 붙입니다.
다음 글
filter·map·flatMap과 상태 보유 중간 연산에서는 중간 연산을 무상태 연산과 상태 보유 연산으로 나눠 살펴봅니다. 원소 하나를 여러 원소로 펼치는 flatMap과, 조건이 처음 깨지기 전까지의 앞부분만 고르거나 버리는 takeWhile, dropWhile도 다룹니다.