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 |