본문으로 건너뛰기

Ch 14.4 스트림의 최종 연산 (Terminal Operations)

스트림 파이프라인의 가장 마지막 에 위치하여, 스트림의 요소를 소비(Consume)하고 최종 결과(데이터 개수, 컬렉션 객체, 단일 값 등)를 만들어냅니다. 최종 연산이 호출되는 순간 스트림 파이프라인의 중간 연산들이 일제히 실행되며 스트림은 닫히게 됩니다.

최종 연산의 특징
  • 최종 연산 호출 시 파이프라인 전체가 실행됨 (지연 평가 종료)
  • 한 번 소비된 스트림은 재사용 불가
  • 결과는 컬렉션, 단일 값, void 등 다양한 형태

1. forEach: 각 요소 소비

스트림의 각 요소에 대해 지정된 동작을 수행합니다. 반환값이 없으며(void), 디버깅 및 출력용으로 자주 사용됩니다.

import java.util.Arrays;
import java.util.List;
import java.util.stream.Collectors;

public class ForEachExample {
public static void main(String[] args) {
List<String> members = Arrays.asList("Alice", "Bob", "Charlie");

// 기본 forEach: 순서 보장
System.out.println("=== forEach (순서 보장) ===");
members.stream().forEach(System.out::println);

// 병렬 스트림의 forEachOrdered: 순서 보장
System.out.println("=== 병렬 스트림 forEachOrdered (순서 보장) ===");
members.parallelStream().forEachOrdered(System.out::println);

// 병렬 스트림의 forEach: 순서 비보장
System.out.println("=== 병렬 스트림 forEach (순서 비보장) ===");
members.parallelStream().forEach(System.out::println); // 순서가 달라질 수 있음
}
}
forEach 순서 주의

parallelStream().forEach()는 멀티스레드로 실행되므로 요소 처리 순서가 보장되지 않습니다. 순서가 중요하다면 forEachOrdered()를 사용하세요.

2. 매칭 검사: allMatch, anyMatch, noneMatch

스트림의 요소들이 조건에 맞는지 논리(boolean) 여부를 판단합니다. 단락 평가(short-circuit)가 적용되어 결과가 확정되면 나머지 요소는 검사하지 않습니다.

import java.util.Arrays;
import java.util.List;

public class MatchExample {
public static void main(String[] args) {
List<Integer> scores = Arrays.asList(85, 90, 78, 92, 65);

// allMatch: 모든 요소가 조건을 만족하면 true
boolean isAllPass = scores.stream().allMatch(score -> score >= 60);
System.out.println("모두 60점 이상: " + isAllPass); // true

boolean isAllHighPass = scores.stream().allMatch(score -> score >= 80);
System.out.println("모두 80점 이상: " + isAllHighPass); // false

// anyMatch: 하나라도 만족하면 true
boolean hasPerfect = scores.stream().anyMatch(score -> score == 100);
System.out.println("100점 있음: " + hasPerfect); // false

boolean hasFail = scores.stream().anyMatch(score -> score < 70);
System.out.println("70점 미만 있음: " + hasFail); // true

// noneMatch: 모두 만족하지 않으면 true
boolean noNegative = scores.stream().noneMatch(score -> score < 0);
System.out.println("음수 없음: " + noNegative); // true

// 빈 스트림의 경우
boolean emptyAll = scores.stream().filter(s -> s > 100).allMatch(s -> s > 0);
System.out.println("빈 스트림 allMatch: " + emptyAll); // true (vacuously true)
}
}

3. count: 요소 개수

import java.util.Arrays;
import java.util.List;

public class CountExample {
public static void main(String[] args) {
List<String> words = Arrays.asList("apple", "banana", "apricot", "blueberry", "avocado");

// 전체 개수
long total = words.stream().count();
System.out.println("전체: " + total); // 5

// 조건에 맞는 요소 개수
long aCount = words.stream()
.filter(w -> w.startsWith("a"))
.count();
System.out.println("a로 시작하는 단어: " + aCount); // 3

// 5글자 이상
long longWords = words.stream()
.filter(w -> w.length() >= 6)
.count();
System.out.println("6글자 이상: " + longWords); // 4
}
}

4. findFirst, findAny: Optional 반환

import java.util.Arrays;
import java.util.List;
import java.util.Optional;

public class FindExample {
public static void main(String[] args) {
List<String> names = Arrays.asList("Alice", "Bob", "Charlie", "David");

// findFirst: 스트림의 첫 번째 요소 반환 (순서 보장)
Optional<String> first = names.stream()
.filter(n -> n.startsWith("C"))
.findFirst();
System.out.println(first.orElse("없음")); // Charlie

// findAny: 어떤 요소든 하나 반환 (병렬 스트림에서 성능 유리)
Optional<String> any = names.parallelStream()
.filter(n -> n.length() > 3)
.findAny();
System.out.println(any.isPresent()); // true (어떤 값이든)

// 결과 없을 때
Optional<String> notFound = names.stream()
.filter(n -> n.startsWith("Z"))
.findFirst();
System.out.println(notFound.isPresent()); // false
System.out.println(notFound.orElse("찾을 수 없음")); // 찾을 수 없음
}
}

5. reduce: 누산 연산

요소들을 하나씩 합쳐서 단일 결과값을 만드는 연산입니다.

import java.util.Arrays;
import java.util.List;
import java.util.Optional;

public class ReduceExample {
public static void main(String[] args) {
List<Integer> numbers = Arrays.asList(1, 2, 3, 4, 5);

// reduce(identity, BinaryOperator): identity는 초기값
int sum = numbers.stream()
.reduce(0, (a, b) -> a + b);
System.out.println("합계: " + sum); // 15

// 메서드 참조 사용
int sum2 = numbers.stream()
.reduce(0, Integer::sum);
System.out.println("합계2: " + sum2); // 15

// 누적 곱
int product = numbers.stream()
.reduce(1, (a, b) -> a * b);
System.out.println("곱: " + product); // 120

// identity 없는 reduce → Optional 반환 (빈 스트림 처리)
Optional<Integer> maxOpt = numbers.stream()
.reduce((a, b) -> a > b ? a : b);
System.out.println("최대값: " + maxOpt.orElse(0)); // 5

// 문자열 연결
List<String> words = Arrays.asList("Hello", " ", "World");
String sentence = words.stream()
.reduce("", String::concat);
System.out.println(sentence); // Hello World
}
}

6. min, max: 최솟값, 최댓값

import java.util.Arrays;
import java.util.Comparator;
import java.util.List;
import java.util.Optional;

public class MinMaxExample {
public static void main(String[] args) {
List<Integer> numbers = Arrays.asList(3, 1, 4, 1, 5, 9, 2, 6);

// max: Comparator 필요, Optional 반환
Optional<Integer> max = numbers.stream()
.max(Comparator.naturalOrder());
System.out.println("최대: " + max.orElse(-1)); // 9

// min
Optional<Integer> min = numbers.stream()
.min(Comparator.naturalOrder());
System.out.println("최소: " + min.orElse(-1)); // 1

// 문자열 길이 기준 최댓값
List<String> words = Arrays.asList("apple", "banana", "kiwi", "strawberry");
Optional<String> longest = words.stream()
.max(Comparator.comparingInt(String::length));
System.out.println("가장 긴 단어: " + longest.orElse("")); // strawberry

// 기본형 스트림에서 sum, average, min, max
int[] arr = {10, 20, 30, 40, 50};
System.out.println("합: " + Arrays.stream(arr).sum()); // 150
System.out.println("평균: " + Arrays.stream(arr).average().orElse(0)); // 30.0
System.out.println("최대: " + Arrays.stream(arr).max().orElse(0)); // 50
System.out.println("최소: " + Arrays.stream(arr).min().orElse(0)); // 10
}
}

7. toArray: 배열로 변환

import java.util.Arrays;
import java.util.List;

public class ToArrayExample {
public static void main(String[] args) {
List<String> list = Arrays.asList("a", "b", "c", "d");

// Object[] 로 변환
Object[] objArr = list.stream().toArray();
System.out.println(Arrays.toString(objArr)); // [a, b, c, d]

// String[] 로 변환 (제네릭 배열 생성자 참조)
String[] strArr = list.stream().toArray(String[]::new);
System.out.println(Arrays.toString(strArr)); // [a, b, c, d]

// int[] (기본형 스트림)
int[] intArr = list.stream()
.mapToInt(String::length)
.toArray();
System.out.println(Arrays.toString(intArr)); // [1, 1, 1, 1]
}
}

8. collect: 결과 수집 (가장 강력한 최종 연산)

Collectors 클래스의 다양한 수집기를 활용합니다.

8.1 기본 컬렉션 수집

import java.util.*;
import java.util.stream.*;

public class CollectBasic {
public static void main(String[] args) {
List<String> fruits = Arrays.asList("Apple", "Banana", "Cherry", "Apple", "Date");

// toList() (Java 16+: Stream.toList() - 불변 리스트)
List<String> list = fruits.stream()
.filter(f -> f.length() > 4)
.collect(Collectors.toList()); // 가변 리스트
System.out.println(list); // [Apple, Banana, Cherry, Apple]

// Java 16+ 불변 리스트
// List<String> immutableList = fruits.stream().toList();

// toSet(): 중복 제거
Set<String> set = fruits.stream()
.collect(Collectors.toSet());
System.out.println(set.size()); // 4 (Apple 중복 제거)

// toMap(): Map으로 수집
Map<String, Integer> fruitLengthMap = fruits.stream()
.distinct()
.collect(Collectors.toMap(
f -> f, // 키: 과일 이름
String::length // 값: 이름 길이
));
System.out.println(fruitLengthMap); // {Apple=5, Banana=6, Cherry=6, Date=4}
}
}

8.2 joining: 문자열 연결

import java.util.Arrays;
import java.util.List;
import java.util.stream.Collectors;

public class CollectJoining {
public static void main(String[] args) {
List<String> names = Arrays.asList("Alice", "Bob", "Charlie");

// 단순 연결
String s1 = names.stream().collect(Collectors.joining());
System.out.println(s1); // AliceBobCharlie

// 구분자
String s2 = names.stream().collect(Collectors.joining(", "));
System.out.println(s2); // Alice, Bob, Charlie

// 구분자 + 접두사 + 접미사
String s3 = names.stream().collect(Collectors.joining(", ", "[", "]"));
System.out.println(s3); // [Alice, Bob, Charlie]

// CSV 형식
String csv = names.stream().collect(Collectors.joining(",", "\"", "\""));
System.out.println(csv); // "Alice,Bob,Charlie"
}
}

8.3 groupingBy: 그룹화

import java.util.*;
import java.util.stream.*;

public class CollectGroupingBy {
enum Category { FOOD, ELECTRONIC, CLOTHING }

record Product(String name, Category category, double price) {}

public static void main(String[] args) {
List<Product> products = Arrays.asList(
new Product("Rice", Category.FOOD, 5000),
new Product("Laptop", Category.ELECTRONIC, 1200000),
new Product("T-Shirt", Category.CLOTHING, 30000),
new Product("Bread", Category.FOOD, 3000),
new Product("Phone", Category.ELECTRONIC, 800000),
new Product("Jeans", Category.CLOTHING, 70000)
);

// 카테고리별 그룹화
Map<Category, List<Product>> byCategory = products.stream()
.collect(Collectors.groupingBy(Product::category));

byCategory.forEach((cat, prods) -> {
System.out.println(cat + ": " + prods.stream()
.map(Product::name).collect(Collectors.joining(", ")));
});

// 카테고리별 개수
Map<Category, Long> countByCategory = products.stream()
.collect(Collectors.groupingBy(Product::category, Collectors.counting()));
System.out.println("카테고리별 개수: " + countByCategory);

// 카테고리별 평균 가격
Map<Category, Double> avgByCategory = products.stream()
.collect(Collectors.groupingBy(
Product::category,
Collectors.averagingDouble(Product::price)
));
System.out.println("카테고리별 평균가: " + avgByCategory);
}
}

8.4 partitioningBy: 분할

import java.util.*;
import java.util.stream.*;

public class CollectPartitioning {
public static void main(String[] args) {
List<Integer> numbers = Arrays.asList(1, 2, 3, 4, 5, 6, 7, 8, 9, 10);

// 짝수/홀수 분할
Map<Boolean, List<Integer>> evenOdd = numbers.stream()
.collect(Collectors.partitioningBy(n -> n % 2 == 0));

System.out.println("짝수: " + evenOdd.get(true)); // [2, 4, 6, 8, 10]
System.out.println("홀수: " + evenOdd.get(false)); // [1, 3, 5, 7, 9]

// 합격/불합격 분류
List<Integer> scores = Arrays.asList(45, 78, 90, 55, 82, 63, 71);
Map<Boolean, List<Integer>> passFailMap = scores.stream()
.collect(Collectors.partitioningBy(s -> s >= 60));

System.out.println("합격: " + passFailMap.get(true));
System.out.println("불합격: " + passFailMap.get(false));
}
}

9. 기본형 스트림 통계: summaryStatistics()

import java.util.Arrays;
import java.util.IntSummaryStatistics;
import java.util.List;
import java.util.stream.Collectors;

public class SummaryStatisticsExample {
public static void main(String[] args) {
List<Integer> scores = Arrays.asList(85, 92, 78, 65, 90, 88, 73);

// IntSummaryStatistics: count, sum, min, max, average 한 번에
IntSummaryStatistics stats = scores.stream()
.mapToInt(Integer::intValue)
.summaryStatistics();

System.out.println("개수: " + stats.getCount()); // 7
System.out.println("합계: " + stats.getSum()); // 571
System.out.println("최소: " + stats.getMin()); // 65
System.out.println("최대: " + stats.getMax()); // 92
System.out.printf("평균: %.2f%n", stats.getAverage()); // 81.57

// Collectors.summarizingInt
IntSummaryStatistics stats2 = scores.stream()
.collect(Collectors.summarizingInt(Integer::intValue));
System.out.println("동일 결과: " + stats2.getSum()); // 571
}
}

10. 실전 예제: 주문 데이터 집계

import java.util.*;
import java.util.stream.*;

public class OrderAggregation {
enum Category { FOOD, ELECTRONICS, FASHION }

record OrderItem(String productName, Category category, int quantity, double unitPrice) {
double totalPrice() { return quantity * unitPrice; }
}

public static void main(String[] args) {
List<OrderItem> orders = Arrays.asList(
new OrderItem("Laptop", Category.ELECTRONICS, 1, 1_200_000),
new OrderItem("Rice", Category.FOOD, 3, 5_000),
new OrderItem("T-Shirt", Category.FASHION, 2, 35_000),
new OrderItem("Phone", Category.ELECTRONICS, 2, 800_000),
new OrderItem("Coffee", Category.FOOD, 5, 6_000),
new OrderItem("Jeans", Category.FASHION, 1, 80_000),
new OrderItem("Headphones", Category.ELECTRONICS, 1, 150_000)
);

// 1. 전체 주문 총액
double totalRevenue = orders.stream()
.mapToDouble(OrderItem::totalPrice)
.sum();
System.out.printf("전체 총액: %,.0f원%n", totalRevenue);

// 2. 카테고리별 총 매출액
System.out.println("\n[카테고리별 총 매출]");
orders.stream()
.collect(Collectors.groupingBy(
OrderItem::category,
Collectors.summingDouble(OrderItem::totalPrice)
))
.entrySet().stream()
.sorted(Map.Entry.<Category, Double>comparingByValue().reversed())
.forEach(e -> System.out.printf(" %s: %,.0f원%n", e.getKey(), e.getValue()));

// 3. 최고가 단일 상품
orders.stream()
.max(Comparator.comparingDouble(OrderItem::unitPrice))
.ifPresent(item ->
System.out.printf("%n최고가 상품: %s (%.0f원)%n",
item.productName(), item.unitPrice()));

// 4. 카테고리별 상품 수
System.out.println("\n[카테고리별 상품 수]");
orders.stream()
.collect(Collectors.groupingBy(OrderItem::category, Collectors.counting()))
.forEach((cat, count) -> System.out.println(" " + cat + ": " + count + "종"));

// 5. 10만원 이상 vs 미만 주문 분류
Map<Boolean, List<OrderItem>> pricePartition = orders.stream()
.collect(Collectors.partitioningBy(o -> o.totalPrice() >= 100_000));
System.out.println("\n고가(10만원 이상) 주문:");
pricePartition.get(true).forEach(o ->
System.out.printf(" %s: %,.0f원%n", o.productName(), o.totalPrice()));

// 6. 상품명 목록을 CSV로 출력
String productList = orders.stream()
.map(OrderItem::productName)
.collect(Collectors.joining(", "));
System.out.println("\n전체 상품: " + productList);
}
}
고수 팁: 최종 연산 선택 기준
목적추천 최종 연산
단순 출력/부수효과forEach
조건 검사anyMatch, allMatch, noneMatch
개수 세기count
단일 요소 찾기findFirst, findAny
누산/집계reduce, sum, average
극값min, max
컬렉션 변환collect(Collectors.toList/toSet/toMap)
통계 전체summaryStatistics