지연 연산과 Stream 생성
최종 연산이 없으면 계산되지 않는 결과를 확인하고 단락 평가 최적화와 유한·무한 원본을 안전하게 구성합니다.
Stream 중간 연산은 파이프라인 설명을 조립할 뿐 최종 연산이 호출되기 전에는 원소를 요구하지 않습니다.
이 지연 평가는 불필요한 계산을 건너뛰고 무한 원본 일부를 다룰 수 있게 하지만, 파이프라인을 변수에 담아 두기만 하고 결과가 생겼다고 착각하게도 합니다.
중간 연산의 지연 실행 오판
아래 코드는 map 안에서 호출 횟수를 늘리지만 최종 연산이 없습니다.
실제 출력은 wrong-mapped-count=0입니다.
import java.util.List;
import java.util.concurrent.atomic.AtomicInteger;
public final class MissingTerminalOperationBug {
public static void main(String[] args) {
AtomicInteger mapped = new AtomicInteger();
List.of("a", "b", "c").stream()
.map(
value -> {
mapped.incrementAndGet();
return value.toUpperCase();
});
System.out.println("wrong-mapped-count=" + mapped.get());
}
}매퍼의 부수 효과가 실행 시점을 숨긴 것이 첫 문제입니다.
계산 결과가 필요하면 toList 같은 최종 연산을 호출하고 그 반환값을 사용합니다.
부수 효과만이 목적이면 Stream보다 반복문이 분명할 수 있습니다.
최종 연산의 원소 요청
최종 연산은 파이프라인의 마지막 단계에서 원본 쪽으로 필요한 값을 요청합니다.
filter에서 탈락한 원소는 다음 단계로 가지 않고, limit나 findFirst가 충분한 결과를 얻으면 원본 전체를 읽지 않을 수 있습니다.
import java.util.List;
import java.util.concurrent.atomic.AtomicInteger;
public final class LazyShortCircuitTrace {
public static void main(String[] args) {
AtomicInteger inspected = new AtomicInteger();
int first =
List.of(3, 4, 5, 6, 8).stream()
.peek(value -> inspected.incrementAndGet())
.filter(value -> value % 2 == 0)
.map(value -> value * 10)
.findFirst()
.orElseThrow();
System.out.println("first=" + first + ", inspected=" + inspected.get());
}
}3과 4만 확인하고 first가 40이 되므로 inspected는 2입니다.
peek는 진단용 관찰이며 정확한 업무 횟수를 기록하는 장치로 쓰지 않습니다.
최적화와 단락 평가에 따라 호출 수가 원본 크기보다 작을 수 있습니다.
무상태 연산의 원소 단위 결합
filter와 map은 이전 원소 전체를 기억하지 않는 무상태 중간 연산입니다.
한 원소가 filter를 통과하면 바로 map과 하위 수집기로 이동할 수 있습니다.
sorted, distinct는 여러 원소 상태가 필요해 버퍼 또는 set을 사용합니다.
limit는 정렬된 원본에서 앞쪽 일부만 요구하고 findAny는 병렬 실행에서 어떤 원소든 선택할 수 있습니다.
파이프라인을 최적화하려고 연산 순서를 바꿀 때는 의미가 같은지 먼저 확인합니다.
비용이 작은 filter를 비싼 map보다 앞에 두면 map 호출을 줄일 수 있지만, filter가 map 결과에 의존한다면 순서를 바꿀 수 없습니다.
import java.util.List;
import java.util.concurrent.atomic.AtomicInteger;
public final class FilterBeforeExpensiveMap {
public static void main(String[] args) {
AtomicInteger conversions = new AtomicInteger();
List<String> labels =
List.of(10, 15, 20, 25, 30).stream()
.filter(value -> value >= 20)
.map(
value -> {
conversions.incrementAndGet();
return "m" + value;
})
.limit(2)
.toList();
System.out.println("labels=" + labels + ", conversions=" + conversions.get());
}
}20과 25에서만 매퍼가 실행되므로 conversions는 2입니다.
최종 연산 toList가 없으면 conversions는 0입니다.
변환 횟수 자체가 기능 요구 사항이라면 매퍼의 부수 효과로 세지 말고 결과 데이터에 횟수를 포함합니다.
컬렉션·배열·값 스트림 생성
collection.stream(), Arrays.stream(array), Stream.of(values)가 대표적인 원본 생성 방법입니다.
기본형 배열은 Arrays.stream(array)로 IntStream 등을 만들고, Stream.of(intArray)는 int 배열 하나를 원소로 갖는 Stream이 되므로 타입을 확인합니다.
Stream.empty()는 조건 분기를 합치고, 빌더는 동적으로 몇 원소를 추가한 뒤 빌드합니다.
빌드 후 빌더 재사용은 규칙 밖입니다.
null 허용 단일 값에는 Stream.ofNullable이 0개 또는 1개 원소 원본을 만듭니다.
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();
long array = Arrays.stream(new int[] {1, 2, 3}).sum();
Stream.Builder<String> builder = Stream.builder();
builder.add("left").add("right");
List<String> built = builder.build().toList();
List<String> nullable = Stream.ofNullable(null).map(Object::toString).toList();
System.out.println(
"collection="
+ collection
+ ", array-sum="
+ array
+ ", built="
+ built
+ ", nullable="
+ nullable);
}
}원본 생성법마다 원소 타입과 close 책임을 확인합니다.
Files.lines처럼 I/O 자원에 연결된 Stream은 try-with-resources로 닫습니다.
컬렉션 스트림은 close해도 컬렉션의 생명 주기를 끝내지 않습니다.
무한 스트림의 종료 조건
Stream.iterate(seed, next)는 이전 값에서 다음 값을 만들고 generate(supplier)는 독립적인 값을 공급합니다.
둘 다 limit 같은 단락 연산이 없으면 최종 연산 toList나 count가 끝나지 않습니다.
조건식을 받는 iterate 오버로드는 종료 조건을 원본에 포함할 수 있습니다.
import java.util.List;
import java.util.stream.IntStream;
import java.util.stream.Stream;
public final class BoundedSequenceGenerator {
public static void main(String[] args) {
List<Integer> powers = 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(powers);
System.out.println("sum=" + rangeSum + ", tickets=" + tickets);
}
private static final class SequenceSupplier implements java.util.function.Supplier<String> {
private int next = 1;
public String get() {
return "T" + next++;
}
}
}iterate 조건식은 현재 원소를 포함할지 검사하므로 64 다음의 128은 나오지 않습니다.
가변 공급자는 순차 실행을 전제로 했으며 병렬 실행에 안전하지 않습니다.
고유 번호에는 AtomicInteger나 전용 번호 할당기가 필요합니다.
concat과 원본 크기 특성
Stream.concat(left, right)는 두 원본을 이어 하나로 소비합니다.
지나치게 깊게 결합하면 순회와 close 처리기가 복잡해질 수 있으므로 flatMap이나 빌더를 검토합니다.
크기를 정확히 아는 원본은 count를 최적화할 수 있어 peek 같은 중간 관찰이 생략될 수 있습니다.
Stream 명세는 부수 효과 실행을 결과에 필요 없는 경우 생략할 수 있습니다.
따라서 로깅·지표를 파이프라인 정확성에 넣지 않습니다.
결과 변환에 필요한 함수만 둡니다.
지연 평가가 유리하지 않은 경우
파이프라인 결과를 여러 번 사용해야 한다면 구체화한 불변 List가 더 적합합니다.
원격 커서와 연결된 지연 원본을 메서드 밖으로 반환하면 연결의 생명 주기가 불분명해질 수 있습니다.
자원 소유자가 최종 연산까지 수행한 뒤 결과를 반환합니다.
원본이 작고 변환이 단순하면 지연 최적화 이득보다 표현의 명확성이 중요합니다.
“Stream이 빠르다”는 일반화 대신 할당, 박싱, 상태 보유 연산을 작업 부하에서 측정합니다.
연습 문제
1부터 계속 증가하는 iterate 원본에서 3의 배수만 남기고 제곱한 뒤 앞의 다섯 값을 List로 반환하세요.
실제로 filter가 검사한 원소 수를 결과 정확성에 포함하지 않습니다.
정답과 종료 보장
무한 iterate 뒤에 filter·map·limit 순서로 연산을 연결해 필요한 다섯 값을 얻은 시점에 최종 연산을 끝냅니다.
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입니다.
limit를 빼면 toList가 무한 원본 전체를 기다리므로 종료되지 않습니다.
지연 연산을 안전하게 쓰려면 최종 연산이 실행을 시작한다는 사실, 단락 평가가 호출 수를 줄인다는 점, 상태 보유 연산의 버퍼, 무한 원본의 명시적 구분을 함께 확인해야 합니다.