안동민 개발노트

본문 시작

지연 연산과 Stream 생성

최종 연산이 없으면 계산되지 않는 결과를 확인하고 단락 평가 최적화와 유한·무한 원본을 안전하게 구성합니다.

Stream 중간 연산은 파이프라인 설명을 조립할 뿐 최종 연산이 호출되기 전에는 원소를 요구하지 않습니다.

이 지연 평가는 불필요한 계산을 건너뛰고 무한 원본 일부를 다룰 수 있게 하지만, 파이프라인을 변수에 담아 두기만 하고 결과가 생겼다고 착각하게도 합니다.


중간 연산의 지연 실행 오판

아래 코드는 map 안에서 호출 횟수를 늘리지만 최종 연산이 없습니다.

최종 연산이 없으므로 매퍼 본문이 호출되지 않아 wrong-mapped-count=0인 코드입니다.

lab/MissingTerminalOperationBug.java
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가 충분한 결과를 얻으면 원본 전체를 읽지 않을 수 있습니다.

src/LazyShortCircuitTrace.java
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());
    }
}

findFirst의 반환값은 40입니다. 3과 4에서 peek를 호출하는 순차 추적에서는 계수를 2로 계산하지만, 이 호출 횟수를 모든 구현의 결과 계약으로 삼지는 않습니다.

Stream은 결과를 바꾸지 않는 연산과 부수 효과를 생략할 수 있으므로 peek를 정확한 업무 횟수 기록에 사용하지 않습니다.


무상태 연산의 원소 단위 결합

filter와 map은 이전 원소 전체를 기억하지 않는 무상태 중간 연산입니다.

한 원소가 filter를 통과하면 바로 map과 하위 수집기로 이동할 수 있습니다.

sorted, distinct는 여러 원소 상태가 필요해 버퍼 또는 set을 사용합니다.

limit(n)은 만남 순서가 있는 스트림에서 앞의 n개를 선택하며, sorted()가 필요한 것은 아닙니다. findAny는 순차·병렬 여부와 관계없이 임의의 원소를 선택할 수 있습니다.

파이프라인을 최적화하려고 연산 순서를 바꿀 때는 의미가 같은지 먼저 확인합니다.

비용이 작은 filter를 비싼 map보다 앞에 두면 map 호출을 줄일 수 있지만, filter가 map 결과에 의존한다면 순서를 바꿀 수 없습니다.

src/FilterBeforeExpensiveMap.java
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에 매퍼를 한 번씩 적용하는 순차 추적에서는 계수를 2로 계산합니다. 매퍼 안의 부수 효과 횟수는 반환 목록과 별도의 계약으로 보지 않습니다.

변환 횟수 자체가 기능 요구 사항이라면 매퍼의 부수 효과로 세지 말고 결과 데이터에 횟수를 포함합니다.


컬렉션·배열·값 스트림 생성

collection.stream(), Arrays.stream(array), Stream.of(values)가 대표적인 원본 생성 방법입니다.

기본형 배열에서는 배열 요소를 원소로 만들 것인지 배열 자체를 하나의 원소로 만들 것인지 구분합니다.

Stream.empty()는 조건 분기를 합치고, 빌더는 동적으로 몇 원소를 추가한 뒤 빌드합니다.

빌드 후 빌더 재사용은 규칙 밖입니다.

null 허용 단일 값에는 Stream.ofNullable이 0개 또는 1개 원소 원본을 만듭니다.

src/FiniteStreamSources.java
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);
    }
}

int[] values = {1, 2, 3}을 넘기는 두 API는 다음처럼 다른 원본을 만듭니다.

같은 int 배열을 전달해도 다른 원소 타입

같은 int 배열을 전달해도 다른 원소 타입

같은 int 배열을 전달해도 다른 원소 타입
생성 방법원소 타입과 수최종 연산 예
Arrays.stream(values)int 원소 3개 · IntStreamsum() → 6
Stream.of(values)int[] 원소 1개 · Stream<int[]>count() → 1
Arrays.stream(values)
원소 타입과 수: int 원소 3개 · IntStream
최종 연산 예: sum() → 6
Stream.of(values)
원소 타입과 수: int[] 원소 1개 · Stream<int[]>
최종 연산 예: count() → 1

원본 생성법마다 원소 타입과 close 책임을 확인합니다.

Files.lines처럼 I/O 자원에 연결된 Stream은 try-with-resources로 닫습니다.

컬렉션 스트림은 close해도 컬렉션의 생명 주기를 끝내지 않습니다.


무한 스트림의 종료 조건

Stream.iterate(seed, next)는 이전 값에서 다음 값을 만들고, generate(supplier)는 이전 원소를 인수로 넘기지 않고 공급자를 호출합니다. 공급자 자체가 상태를 가질 수 있으므로 값이 서로 독립적이라는 보장은 아닙니다.

둘 다 limit 같은 단락 연산이 없으면 최종 연산 toList나 count가 끝나지 않습니다.

조건식을 받는 iterate 오버로드는 종료 조건을 원본에 포함할 수 있습니다. 단락 연산이 있다는 사실만으로 항상 종료하는 것은 아닙니다. 예를 들어 무한 원본을 filter가 계속 거절하면 뒤의 limit도 필요한 개수를 얻지 못합니다.

app/BoundedSequenceGenerator.java
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 순서로 연산을 연결해 필요한 다섯 값을 얻은 시점에 최종 연산을 끝냅니다.

exercise/FiveMultipleSquaresSolution.java
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);
    }
}

이 입력에서는 3의 배수가 계속 나오므로 필요한 다섯 값을 채울 수 있으며, 결과는 9, 36, 81, 144, 225입니다.

limit를 빼면 toList가 무한 원본 전체를 기다리므로 종료되지 않습니다.

지연 연산을 안전하게 쓰려면 최종 연산이 실행을 시작한다는 사실, 단락 평가가 호출 수를 줄인다는 점, 상태 보유 연산의 버퍼, 무한 원본의 명시적 구분을 함께 확인해야 합니다.