
중간 연산은 계획이고, 최종 연산은 실행 버튼입니다.
3편에서 스트림의 기본을, 4편에서 정렬·중복 제거·자르기를 봤습니다. 그런데 지금까지 쓴 것들에는 공통점이 하나 있습니다. 결과가 다시 스트림이었다는 것. 그래서 계속 점을 찍어 이어 붙일 수 있었죠.
오늘은 그 줄의 맨 끝을 다룹니다. 스트림을 값으로 바꿔 꺼내는 자리, 최종 연산입니다.
최종 연산이 없으면 아무 일도 안 일어납니다
먼저 놀라운 사실 하나. 아래 코드를 실행하면 화면에 아무것도 찍히지 않습니다.
List<String> names = List.of("김유신", "이순신", "강감찬");
names.stream()
.filter(n -> {
System.out.println("검사 중: " + n); // 이 줄조차 안 찍힙니다
return n.startsWith("이");
});
filter 안에 출력문을 넣었는데도 조용합니다. 이유는 중간 연산이 게으르기 때문입니다. 스트림은 중간 연산을 만나면 "그런 일을 하겠다"고 적어만 두고 실행하지 않습니다.
맨 뒤에 최종 연산이 붙어야 비로소 원소가 흐르기 시작합니다.
long count = names.stream()
.filter(n -> n.startsWith("이"))
.count(); // 여기서 실제로 흐릅니다
쉽게 — 중간 연산은 장 보기 목록을 적는 일이고, 최종 연산은 실제로 마트에 가는 일입니다. 목록만 열심히 적어 놓으면 냉장고는 그대로입니다.
이 게으름은 낭비를 줄이려고 만든 성질입니다. 뒤에 limit(1)이 붙어 있다면 원소 하나만 통과시켜 보고 멈추면 되니까요. 전부 걸러 놓고 하나만 꺼내는 것보다 훨씬 적게 일합니다.
무엇을 얻고 싶은지로 고릅니다
최종 연산은 종류가 많아 보이지만, 원하는 결과 모양으로 나누면 네 갈래입니다.

그림 1. 결과를 무엇으로 받고 싶은지가 곧 선택 기준입니다.
담기 — 다시 컬렉션으로
가장 많이 씁니다. 거르고 바꾼 결과를 리스트로 받아 두는 것이죠.
List<String> result = names.stream()
.filter(n -> n.length() == 3)
.collect(Collectors.toList());
자바 16부터는 더 짧게 쓸 수 있습니다.
List<String> result = names.stream()
.filter(n -> n.length() == 3)
.toList();
다만 차이가 하나 있습니다. toList()로 만든 리스트는 수정할 수 없습니다. 나중에 add를 부르면 오류가 납니다. 고칠 일이 있다면 collect(Collectors.toList())를 쓰거나, new ArrayList<>(...)로 감싸 주면 됩니다.
Collectors에는 toSet()도 있고, Collectors.joining(", ")처럼 문자열로 이어 붙이는 것도 있습니다.
String line = names.stream().collect(Collectors.joining(", "));
// 김유신, 이순신, 강감찬
세기 — 개수만 필요할 때
count()는 개수를 돌려줍니다. 반환형이 long이라는 점만 기억하면 됩니다.
long n = names.stream().filter(s -> s.startsWith("이")).count();
합치기 — 하나의 값으로 접기
숫자를 다룰 때 자주 나옵니다. 합계나 평균이 대표적이죠.
List<Integer> nums = List.of(10, 20, 30, 40);
int sum = nums.stream().mapToInt(n -> n).sum();
double avg = nums.stream().mapToInt(n -> n).average().orElse(0);
mapToInt가 눈에 걸릴 겁니다. Stream<Integer>에는 sum()이 없고, 숫자 전용 스트림인 IntStream으로 바꿔야 쓸 수 있기 때문입니다. average()는 값이 없을 수도 있어 Optional로 돌아오는데, orElse(0)으로 기본값을 정해 줬습니다.
더 일반적인 방법이 reduce입니다. 원소를 둘씩 합쳐 하나로 접어 나가는 연산입니다.
int total = nums.stream().reduce(0, (a, b) -> a + b);
첫 인자 0은 시작값입니다. 그다음 (0,10) → 10, (10,20) → 30, (30,30) → 60… 이렇게 굴러가며 하나로 줄어듭니다. 곱셈으로 바꾸고 싶으면 시작값을 1로 두고 a * b를 쓰면 됩니다.
sum()은 덧셈만 하지만 reduce는 접는 방법을 내가 정한다는 점이 다릅니다.
찾기 — 있는지, 어디 있는지
조건에 맞는 것이 있는지만 알고 싶을 때가 있습니다. 세 형제를 씁니다.
boolean any = names.stream().anyMatch(n -> n.startsWith("이")); // 하나라도?
boolean all = names.stream().allMatch(n -> n.length() == 3); // 전부?
boolean none = names.stream().noneMatch(n -> n.isEmpty()); // 하나도 없나?
셋 다 boolean을 돌려줍니다. 그리고 이들은 답이 정해지는 순간 바로 멈춥니다. anyMatch는 조건에 맞는 첫 원소를 만나면 나머지는 보지도 않습니다.
값 자체가 필요하면 findFirst입니다.
Optional<String> first = names.stream()
.filter(n -> n.startsWith("이"))
.findFirst();
System.out.println(first.orElse("없음"));
여기서 Optional이 나옵니다. 조건에 맞는 값이 아예 없을 수도 있기 때문입니다. 없는데 억지로 꺼내면 오류가 나므로, 자바는 "있을 수도 없을 수도 있는 상자"에 담아 돌려줍니다. 꺼낼 때는 orElse로 기본값을 정하거나 ifPresent로 있을 때만 쓰면 됩니다.
단골 함정 — 스트림은 일회용입니다
가장 많이 걸리는 지점입니다. 아래 코드는 실행하면 예외가 납니다.
Stream<String> s = names.stream();
long a = s.count();
long b = s.count(); // IllegalStateException
한 번 최종 연산이 지나간 스트림은 닫힙니다. 다시 쓸 수 없습니다. JAVA3 6편의 파일 스트림과 같은 성격이라고 생각하면 됩니다. 한 번 읽고 지나간 흐름은 되감기지 않습니다.
해결은 간단합니다. 필요할 때마다 원본에서 새로 만들면 됩니다.
long a = names.stream().count();
long b = names.stream().count(); // 매번 새 스트림
원본 컬렉션은 그대로 남아 있으니 몇 번이든 다시 만들 수 있습니다.
두 번째 함정은 최종 연산을 빼먹는 것입니다. filter와 map만 써 놓고 결과가 안 나온다며 헤매는 경우인데, 앞에서 본 게으름 때문입니다. 줄 맨 끝을 확인하세요.
직접 실행해 보기
게으름부터 일회용까지 한 번에 확인해 봅니다. 출력 순서를 눈여겨보세요.
import java.util.*;
import java.util.stream.*;
public class Main {
public static void main(String[] args) {
List<String> names = List.of("김유신", "이순신", "강감찬", "이성계");
// 1) 최종 연산이 없으면 아무 일도 안 일어납니다
System.out.println("--- 최종 연산 없음 ---");
names.stream().filter(n -> {
System.out.println("검사: " + n);
return n.startsWith("이");
});
System.out.println("(위에 아무것도 안 찍혔습니다)");
// 2) count를 붙이면 그제야 흐릅니다
System.out.println();
System.out.println("--- count 추가 ---");
long c = names.stream().filter(n -> {
System.out.println("검사: " + n);
return n.startsWith("이");
}).count();
System.out.println("결과: " + c + "명");
// 3) 담기 / 합치기 / 찾기
System.out.println();
List<String> three = names.stream().filter(n -> n.length() == 3).toList();
System.out.println("세 글자: " + three);
System.out.println("이어 붙이기: " + String.join(" / ", names));
List<Integer> nums = List.of(10, 20, 30, 40);
System.out.println("합계(sum): " + nums.stream().mapToInt(n -> n).sum());
System.out.println("합계(reduce): " + nums.stream().reduce(0, (a, b) -> a + b));
System.out.println("곱(reduce): " + nums.stream().reduce(1, (a, b) -> a * b));
Optional<String> first = names.stream().filter(n -> n.startsWith("최")).findFirst();
System.out.println("최씨 찾기: " + first.orElse("없음"));
System.out.println("이씨 있나? " + names.stream().anyMatch(n -> n.startsWith("이")));
// 4) 스트림 재사용은 오류
System.out.println();
Stream<String> s = names.stream();
System.out.println("첫 사용: " + s.count());
try {
System.out.println("두 번째: " + s.count());
} catch (IllegalStateException e) {
System.out.println("두 번째 사용 → 예외 발생: 스트림은 일회용입니다");
}
}
}📱 코드 실행(실습)은 태블릿·PC에서 이용할 수 있어요. 모바일에서는 위 코드를 눈으로 따라가며 확인하세요.
reduce의 시작값을 바꿔 보거나, findFirst의 조건을 "이"로 바꿔 무엇이 나오는지 확인해 보세요.
한 장 정리
| 목적 | 메서드 | 결과 |
|---|---|---|
| 담기 | collect(...) · toList() | 컬렉션 |
| 이어 붙이기 | Collectors.joining() | 문자열 |
| 세기 | count() | long |
| 합계·평균 | mapToInt().sum() · average() | 숫자 |
| 접기 | reduce(초깃값, (a,b) -> ...) | 하나의 값 |
| 존재 확인 | anyMatch · allMatch · noneMatch | boolean |
| 값 찾기 | findFirst() | Optional |
이것만 기억하면 됩니다. 중간 연산은 적어 두기만 하고, 최종 연산이 와야 실제로 흐릅니다. 그리고 그 흐름은 한 번뿐이라, 다시 쓰려면 원본에서 새 스트림을 만들어야 합니다. 어떤 최종 연산을 쓸지는 결과를 무엇으로 받고 싶은가로 고르면 됩니다.
미니 퀴즈
개념을 제대로 잡았는지 직접 풀어 봅니다. 정답은 눌러야 나옵니다.
미니 퀴즈
포켓코딩(JAVA4) 5편 · 예제 출처: 강사 개인 GitHub 저장소. 다음 편: Optional — null을 다루는 새로운 방법.