안동민 개발노트

본문 시작

최종 연산과 기본형 Stream

finding·matching·reduction·수집 최종 연산을 구분하고 빈 Optional 결과와 박싱 비용을 안전하게 처리합니다.

최종 연산은 파이프라인을 소비해 값, Optional, 배열, 컬렉션 또는 부수 효과를 만듭니다.

같은 중간 연쇄도 최종 연산에 따라 필요한 원소 수와 반환 타입이 달라집니다.

결과 원소 수와 빈 원본 정책을 먼저 정해야 get() 호출이나 잘못된 항등값 사용을 피할 수 있습니다.


빈 findFirst 결과의 get 호출

조건을 만족하는 값이 없으면 findFirst()는 빈 Optional을 반환합니다.

아래 고정 입력은 빈 Optional을 만듭니다. 여기에 get()을 호출하면 API 계약상 NoSuchElementException을 던집니다.

lab/EmptyFindFirstFailure.java
import java.util.List;

public final class EmptyFindFirstFailure {
    public static void main(String[] args) {
        String value =
                List.of("stream", "collector").stream()
                        .filter(title -> title.startsWith("z"))
                        .findFirst()
                        .get();
        System.out.println(value);
    }
}

값이 반드시 있어야 하는 불변식이라면 의미 있는 메시지를 담은 orElseThrow를 사용하고, 부재가 정상이라면 Optional 반환이나 대체 경로를 씁니다.

null로 되돌리면 호출자가 다시 누락을 추적해야 합니다.


최종 연산의 결과 형태

count는 long 하나, min·max·find는 Optional, anyMatch·allMatch·noneMatch는 boolean, reduce는 축약 값, toList·collect는 컨테이너, toArray는 배열을 반환합니다.

forEach는 반환값 없이 부수 효과를 실행합니다.

src/TerminalOperationCatalog.java
import java.util.Comparator;
import java.util.List;

public final class TerminalOperationCatalog {
    public static void main(String[] args) {
        List<Integer> values = List.of(3, 7, 10, 12);
        System.out.println("count=" + values.stream().filter(v -> v >= 7).count());
        System.out.println("max=" + values.stream().max(Comparator.naturalOrder()).orElseThrow());
        System.out.println("any-even=" + values.stream().anyMatch(v -> v % 2 == 0));
        System.out.println("all-positive=" + values.stream().allMatch(v -> v > 0));
        System.out.println("sum=" + values.stream().reduce(0, Integer::sum));
        System.out.println("array-length=" + values.stream().toArray(Integer[]::new).length);
    }
}

각 최종 연산은 새 Stream에서 호출합니다.

빈 원본에서 allMatch와 noneMatch는 검사할 반례가 없어 true를 반환합니다.

“데이터가 하나 이상 존재했다”는 조건까지 필요하다면 count나 별도 입력 검사를 결합합니다.


단락 평가 최종 연산

anyMatch는 true, allMatch는 false, noneMatch는 true인 원소 하나로 결과를 확정할 수 있어 모든 원소를 검사하지 않아도 됩니다.

findFirst와 findAny도 하나를 얻으면 종료할 수 있습니다.

따라서 부수 효과 호출 횟수가 원본 크기와 같다고 가정해서는 안 됩니다.

src/ShortCircuitTerminalCounts.java
import java.util.List;
import java.util.concurrent.atomic.AtomicInteger;

public final class ShortCircuitTerminalCounts {
    public static void main(String[] args) {
        AtomicInteger checks = new AtomicInteger();
        boolean found =
                List.of(1, 3, 4, 6, 8).stream()
                        .anyMatch(
                                value -> {
                                    checks.incrementAndGet();
                                    return value % 2 == 0;
                                });
        System.out.println("found=" + found + ", checks=" + checks.get());
    }
}

원문의 순차 순회를 따라가면 4에서 처음 true가 되어 checks=3으로 계산됩니다. 이는 특정 부수 효과 호출 횟수를 보장하는 API 계약이나 새 실행 기록은 아닙니다.

제품 코드는 checks 자체를 업무 결과로 삼지 않습니다.

병렬 anyMatch에서는 호출 수와 순서가 더 달라질 수 있습니다.


reduce의 항등값과 결합 법칙

reduce는 여러 원소를 하나로 결합합니다.

항등값은 빈 원본의 결과이면서 결합 함수의 중립값이어야 합니다.

덧셈의 0, 곱셈의 1, 문자열 연결의 빈 문자열이 예입니다.

min에 임의의 0을 항등값으로 넣으면 모두 양수인 원본에서도 잘못된 0이 남을 수 있으므로 항등값이 없는 reduce가 낫습니다.

reduce 계약은 누산 함수의 결합 법칙과 간섭 없는 상태 비의존 동작을 요구합니다. 세 인수 형태에서는 결합 함수도 항등값과 누산 함수에 맞는 결합 규칙을 만족해야 합니다.

뺄셈처럼 묶는 순서에 따라 값이 달라지는 연산은 병렬 reduce에 맞지 않습니다.

src/SafeReductionExamples.java
import java.util.List;
import java.util.OptionalInt;

public final class SafeReductionExamples {
    public static void main(String[] args) {
        int sum = List.of(5, 7, 9).stream().reduce(0, Integer::sum);
        OptionalInt minimum = List.of(5, 7, 9).stream().mapToInt(Integer::intValue).min();
        String joined =
                List.of("stream", "terminal").stream()
                        .reduce("", (left, right) -> left.isEmpty() ? right : left + "," + right);
        System.out.println("sum=" + sum + ", min=" + minimum.orElseThrow() + ", joined=" + joined);
    }
}
중간에 빈 문자열이 있으면 묶는 순서가 결과를 바꾼다

SafeReductionExamples의 문자열 누산 함수 f를 a, 빈 문자열, b에 적용합니다.

중간에 빈 문자열이 있으면 묶는 순서가 결과를 바꾼다
묶는 방식중간 계산결과
왼쪽부터f("a", "") = "a," 뒤 f("a,", "b")"a,,b"
오른쪽부터f("", "b") = "b" 뒤 f("a", "b")"a,b"
왼쪽부터
중간 계산: f("a", "") = "a," 뒤 f("a,", "b")
결과: "a,,b"
오른쪽부터
중간 계산: f("", "b") = "b" 뒤 f("a", "b")
결과: "a,b"

f는 원문 문자열 누산 함수입니다. main은 비어 있지 않은 stream·terminal만 사용하며, 위 반례는 실행 관측이 아닌 함수식 계산입니다.

이 문자열 누산 함수는 중간에 빈 문자열이 올 수 있는 일반 입력에서 결합 법칙을 만족하지 않습니다. 문자열 연결은 Collectors.joining(",")이 의도를 직접 표현합니다.


기본형 스트림과 박싱

Stream<Integer>는 각 숫자를 래퍼 객체로 다루고 일반 reduce를 사용합니다.

IntStream은 기본형 int를 직접 다루며 sum, average, summaryStatistics를 제공합니다.

객체 스트림에서 mapToInt로 전환하고 객체 결과가 필요할 때는 boxed로 돌아옵니다.

app/PrimitiveBoardStatistics.java
import java.util.IntSummaryStatistics;
import java.util.List;

public final class PrimitiveBoardStatistics {
    private record PostMetric(String title, int viewCount) {}

    public static void main(String[] args) {
        List<PostMetric> posts =
                List.of(
                        new PostMetric("welcome", 45),
                        new PostMetric("maintenance", 60),
                        new PostMetric("question", 30));
        IntSummaryStatistics stats =
                posts.stream().mapToInt(PostMetric::viewCount).summaryStatistics();
        double average = posts.stream().mapToInt(PostMetric::viewCount).average().orElse(0.0);
        List<Integer> popularViewCounts =
                posts.stream()
                        .mapToInt(PostMetric::viewCount)
                        .filter(value -> value >= 40)
                        .boxed()
                        .toList();
        System.out.println(
                "count="
                        + stats.getCount()
                        + ", sum="
                        + stats.getSum()
                        + ", min="
                        + stats.getMin()
                        + ", max="
                        + stats.getMax()
                        + ", avg="
                        + average);
        System.out.println(popularViewCounts);
    }
}

빈 IntStream의 평균과 min은 Optional 계열이고 summaryStatistics의 최솟값·최댓값은 경계값을 가질 수 있으므로 count를 함께 확인합니다.

기본형 스트림이 항상 더 빠르다고 단정하지 않고 할당과 작업 부하를 측정합니다.


반복문과 Stream 비교

성능에 민감한 수치 반복문은 기본형 for가 JIT 최적화와 단순한 메모리 접근에서 유리할 수 있습니다.

박싱된 Stream<Integer>에는 래퍼 객체와 언박싱 비용이 들며 기본형 IntStream은 이를 줄입니다.

그러나 가독성, 분기 구조, 원본 타입, 벡터화 가능성, JIT 예열 상태에 따라 실제 차이는 달라집니다.

벽시계 측정 한 번과 System.currentTimeMillis만으로 마이크로벤치마크의 결론을 내리지 않습니다.

JMH처럼 예열, fork, 죽은 코드 제거 방지를 제공하는 도구를 쓰고 실제 입력 크기를 반영합니다.

데이터가 작으면 차이가 운영 지연 시간에 의미 없을 수 있습니다.


forEach의 순서와 부수 효과

순차 예제에서 forEach가 만나는 순서대로 실행되더라도 병렬 실행의 순서 보장으로 확대하지 않습니다.

forEachOrdered는 순서를 유지하지만 병렬 이득을 줄일 수 있습니다.

결과 컬렉션이 필요하면 부수 효과를 일으키는 add 대신 collect를 사용합니다.

forEach 실행 중 예외가 발생하면 앞선 원소의 부수 효과는 이미 실행됐습니다.

원자적인 일괄 처리가 필요하면 먼저 불변 결과를 만들고 트랜잭션 경계에서 적용합니다.


최종 연산 결과의 API 반환

메서드가 Stream을 반환하면 호출자가 자원 수명과 일회 소비 책임을 떠안습니다.

메모리 안의 순수한 원본에는 유용할 수 있지만 파일이나 데이터베이스 커서라면 소유자가 최종 연산까지 수행하는 편이 안전합니다.

결과가 클 때는 결과 구체화 비용과 역압력 요구 사항을 별도로 설계합니다.


연습 문제

int 점수 목록에서 60점 이상만 대상으로 count, 평균, 최대값을 하나의 record에 담으세요.

통과 점수가 없으면 평균과 최대값을 0으로 정합니다.

정답과 빈 상태 처리

한 번의 summaryStatistics 최종 연산으로 필요한 통계를 모은 뒤, 외부에 반환할 Summary를 만듭니다.

exercise/PassingScoreSummarySolution.java
import java.util.IntSummaryStatistics;
import java.util.List;

public final class PassingScoreSummarySolution {
    private record Summary(long count, double average, int maximum) {}

    public static void main(String[] args) {
        IntSummaryStatistics stats =
                List.of(55, 60, 78, 92, 40).stream()
                        .mapToInt(Integer::intValue)
                        .filter(score -> score >= 60)
                        .summaryStatistics();
        Summary summary =
                stats.getCount() == 0
                        ? new Summary(0, 0.0, 0)
                        : new Summary(stats.getCount(), stats.getAverage(), stats.getMax());
        System.out.println(summary);
    }
}
통과 점수가 없을 때 최대값 경계 상수를 외부 결과로 내보내지 않는다

PassingScoreSummarySolution의 통계 누산 결과와 반환 Summary를 구분합니다.

통과 점수가 없을 때 최대값 경계 상수를 외부 결과로 내보내지 않는다
통과 점수IntSummaryStatistics반환 Summary
60, 78, 92count 3 · sum 230 · max 92count 3 · average 약 76.67 · maximum 92
통과 점수 없음count 0 · sum 0 · max Integer.MIN_VALUEcount 0 · average 0.0 · maximum 0
60, 78, 92
IntSummaryStatistics: count 3 · sum 230 · max 92
반환 Summary: count 3 · average 약 76.67 · maximum 92
통과 점수 없음
IntSummaryStatistics: count 0 · sum 0 · max Integer.MIN_VALUE
반환 Summary: count 0 · average 0.0 · maximum 0

첫 행은 main의 고정 입력을 계산한 값이고, 둘째 행은 빈 통과 집합에 대한 코드 분기입니다. 빈 입력을 따로 실행한 기록은 아닙니다.

최종 연산을 선택할 때는 반환 원소 수, 빈 입력, 단락 평가, 순서, 기본형 특화 스트림을 확인합니다.

최종 연산은 파이프라인의 생명 주기를 끝내므로 필요한 모든 결과를 어떻게 한 번에 회수할지도 함께 설계해야 합니다.