최종 연산과 기본형 Stream
finding·matching·reduction·수집 최종 연산을 구분하고 빈 결과 `Optional`과 박싱 비용을 안전하게 처리합니다.
최종 연산은 파이프라인을 소비해 값, Optional, 배열, 컬렉션 또는 부수 효과를 만듭니다.
같은 중간 연쇄도 최종 연산에 따라 필요한 원소 수와 반환 타입이 달라집니다.
결과 원소 수와 빈 원본 정책을 먼저 정해야 get() 호출이나 잘못된 항등값 사용을 피할 수 있습니다.
빈 findFirst 결과의 get 호출
조건을 만족하는 값이 없으면 findFirst()는 빈 Optional을 반환합니다.
아래 코드는 존재 여부를 검사하지 않고 get()을 호출해 실제 NoSuchElementException이 발생합니다.
import java.util.List;
public final class EmptyFindFirstFailure {
public static void main(String[] args) {
String value =
List.of("stream", "collector").stream()
.filter(topic -> topic.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는 반환값 없이 부수 효과를 실행합니다.
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도 하나를 얻으면 종료할 수 있습니다.
따라서 부수 효과 호출 횟수가 원본 크기와 같다고 가정해서는 안 됩니다.
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입니다.
제품 코드는 checks 자체를 업무 결과로 삼지 않습니다.
병렬 anyMatch에서는 호출 수와 순서가 더 달라질 수 있습니다.
reduce의 식별 정보와 결합 법칙
reduce는 여러 원소를 하나로 결합합니다.
항등값은 빈 원본의 결과이면서 결합 함수의 중립값이어야 합니다.
덧셈의 0, 곱셈의 1, 문자열 연결의 빈 문자열이 예입니다.
min에 임의의 0을 항등값으로 넣으면 모두 양수인 원본에서도 잘못된 0이 남을 수 있으므로 항등값이 없는 reduce가 낫습니다.
병렬 축약을 허용하려면 누산기와 결합 함수가 결합 법칙을 만족하고 공유 가변 상태를 바꾸지 않아야 합니다.
뺄셈처럼 묶는 순서에 따라 값이 달라지는 연산은 병렬 reduce에 맞지 않습니다.
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);
}
}문자열은 Collectors.joining이 의도를 더 잘 표현하고 효율적인 빌더를 사용할 수 있습니다.
직접 reduce는 결합 법칙을 설명할 수 있을 때 선택합니다.
기본형 스트림과 박싱
Stream<Integer>는 각 숫자를 래퍼 객체로 다루고 일반 reduce를 사용합니다.
IntStream은 기본형 int를 직접 다루며 sum, average, summaryStatistics를 제공합니다.
객체 스트림에서 mapToInt로 전환하고 객체 결과가 필요할 때는 boxed로 돌아옵니다.
import java.util.IntSummaryStatistics;
import java.util.List;
public final class PrimitiveStudyStatistics {
private record Session(String topic, int minutes) {}
public static void main(String[] args) {
List<Session> sessions =
List.of(
new Session("stream", 45),
new Session("collector", 60),
new Session("optional", 30));
IntSummaryStatistics stats =
sessions.stream().mapToInt(Session::minutes).summaryStatistics();
double average = sessions.stream().mapToInt(Session::minutes).average().orElse(0.0);
List<Integer> longSessions =
sessions.stream()
.mapToInt(Session::minutes)
.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(longSessions);
}
}빈 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 최종 연산으로 count, sum, 최대값을 모으고, count가 0일 때 경계값을 결과로 노출하지 않습니다.
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);
}
}결과는 count 3, 평균 약 76.67, 최대값 92입니다.
원본을 세 번 순회하지 않고 최종 연산 하나에서 통계를 확정합니다.
최종 연산을 선택할 때는 반환 원소 수, 빈 입력, 단락 평가, 순서, 기본형 특화 스트림을 확인합니다. 최종 연산은 파이프라인의 생명 주기를 끝내므로 필요한 모든 결과를 어떻게 한 번에 회수할지도 함께 설계해야 합니다.