* 이 게시물은 쿠팡 파트너스 활동의 일환으로, 이에 따른 일정액의 수수료를 제공받습니다.
(자바 버전 여행 ④)
앞 편에서 람다로 ‘동작을 값처럼’ 넘기는 감을 잡았다. 스트림은 그 람다를 받아, 컬렉션을 다루는 방식을 통째로 바꾼다. for 루프로 돌리고 조건 걸고 새 리스트에 담던 일을, 한 줄의 흐름으로 표현하게 된다.
반복문 vs 스트림
이름 목록에서 길이가 4 이상인 것만 골라 대문자로 바꿔 새 리스트를 만든다고 하자. 예전 방식은 이렇다.
// 반복문 — 상자(임시 리스트)와 if가 필요
List<String> result = new ArrayList<>();
for (String name : names) {
if (name.length() >= 4) {
result.add(name.toUpperCase());
}
}
스트림으로 쓰면 ‘무엇을 하는지’가 순서대로 읽힌다. 거른다(filter) → 바꾼다(map) → 담는다(collect).
// 스트림 — 흐름으로 읽힌다
List<String> result = names.stream()
.filter(name -> name.length() >= 4)
.map(String::toUpperCase)
.collect(Collectors.toList());
연산은 두 종류다
스트림 연산은 성격이 둘로 나뉜다. 이걸 알면 스트림이 왜 효율적인지 이해된다.
| 구분 | 예 | 특징 |
|---|---|---|
| 중간 연산 | filter, map, sorted, distinct | 스트림을 반환. 여러 개 이어붙임 |
| 최종 연산 | collect, forEach, count, reduce | 결과를 냄. 이게 있어야 실행됨 |
중요한 건 중간 연산은 최종 연산이 붙기 전엔 실제로 돌지 않는다는 점이다(지연 연산). filter만 써 놓고 collect가 없으면 아무 일도 안 일어난다. 덕분에 자바는 요소마다 filter→map을 한 번에 훑어서 낭비 없이 처리한다.
자주 쓰는 최종 연산
// 합계
int total = nums.stream().mapToInt(Integer::intValue).sum();
// 그룹으로 묶기
Map<Boolean, List<Integer>> byEven =
nums.stream().collect(Collectors.partitioningBy(n -> n % 2 == 0));
// 콤마로 잇기
String joined = names.stream().collect(Collectors.joining(", "));
병렬 스트림 — 공짜처럼 보이지만
stream()을 parallelStream()으로 바꾸면 여러 코어로 나눠 돈다. 큰 데이터엔 좋지만 함정이 있다. 작은 데이터에선 스레드 나누는 비용이 더 크고, 공유 상태를 건드리면 결과가 깨진다. 나도 리스트에 add하다 값이 사라지는 걸 겪고서야 배웠다.
// 위험 — 공유 리스트에 병렬로 add
List<Integer> bad = new ArrayList<>();
nums.parallelStream().forEach(bad::add); // 요소가 누락/깨질 수 있음
// 안전 — collect로 모은다
List<Integer> ok = nums.parallelStream().collect(Collectors.toList());
직접 해보기 — 반복문을 스트림 파이프라인으로
짝수의 제곱 리스트를 for와 스트림 두 방식으로 만들어 비교하자.
- 1~20에서 짝수만 골라 제곱한 리스트를 for 루프로 먼저 만든다.
- 같은 걸
filter → map → collect스트림으로 바꾼다. collect를 지웠을 때 아무 일도 안 일어나는(지연 연산) 걸 확인한다.
var nums = IntStream.rangeClosed(1, 20).boxed().toList();
var squares = nums.stream()
.filter(n -> n % 2 == 0)
.map(n -> n * n)
.collect(Collectors.toList());
System.out.println(squares);
실행해 보면 이렇게 나온다.
[4, 16, 36, 64, 100, 144, 196, 256, 324, 400]
여기서 조금씩 바꿔 보자.
.map(...)뒤에.peek(System.out::println)를 넣어 요소가 하나씩 흐르는 순서를 관찰한다.collect(...)를mapToInt(Integer::intValue).sum()으로 바꿔 합계를 낸다.
한 걸음 더 — stream()을 parallelStream()으로 바꾸고, 작은 데이터에선 오히려 느려질 수 있음을 System.nanoTime()으로 재 보자.
✅ 확인 — filter·map·collect의 역할을 각각 말하고, 최종 연산이 없으면 실행되지 않는다는 걸 확인했으면 통과다.
중간 연산 vs 최종 연산 — 게으르다
스트림 연산은 둘로 나뉜다. 중간 연산(filter·map·sorted)은 새 스트림을 반환하며 ‘게으르게’ 쌓이기만 한다. 최종 연산(collect·forEach·count·reduce)이 호출돼야 비로소 전체가 흐른다. 그래서 filter를 세 개 걸어도 데이터를 세 번 순회하는 게 아니라, 최종 연산 때 원소마다 한 번씩만 파이프를 통과한다(지연 평가).
List<String> r = names.stream()
.filter(n -> n.length() >= 4) // 중간(게으름)
.map(String::toUpperCase) // 중간(게으름)
.collect(Collectors.toList()); // 최종 — 여기서 실제로 흐른다
자주 쓰는 수집 — Collectors
결과를 모으는 건 대개 collect다. toList·toSet은 기본이고, groupingBy(분류), joining(문자열 결합), counting 등이 강력하다. 특히 groupingBy 하나로 ‘SQL의 GROUP BY’ 같은 집계가 한 줄에 된다.
Map<Integer, List<String>> byLen =
names.stream().collect(Collectors.groupingBy(String::length));
String csv = names.stream().collect(Collectors.joining(", "));
병렬 스트림 — 공짜가 아니다
parallelStream()으로 멀티코어를 쓸 수 있지만 만능이 아니다. 데이터가 크고, 원소 처리가 무겁고, 순서·공유 상태가 없어야 이득이다. 작은 컬렉션이나 I/O가 섞인 작업엔 오히려 스레드 조율 비용으로 느려진다. 공유 변수를 수정하면 경쟁 조건으로 결과가 깨진다. ‘병렬로 바꾸면 빨라지겠지’가 아니라, 재보고 확실할 때만 쓴다.
함정 — 일회용이고, 부수효과는 자제
스트림은 한 번 소비하면 재사용 불가다(다시 만들어야 한다). 그리고 forEach 안에서 외부 리스트에 add하는 식의 부수효과는 병렬에서 깨진다 — 결과는 collect로 ‘반환’받는 게 스트림다운 방식이다. ‘for 루프 습관’을 그대로 옮기지 말자. 스트림은 항상 for보다 빠른 것도 아니니, 주된 이득이 가독성임을 기억하면 어디에 쓸지 판단이 선다.
람다와 스트림까지 왔으면 자바 8의 절반은 넘었다. 다음 편은 자바 개발자를 가장 오래 괴롭힌 존재, null을 다루는 Optional이다.
자주 묻는 질문 (FAQ)
스트림이 for 루프보다 항상 빠른가요?
아니요. 가독성·표현력이 장점이고 성능은 비슷하거나 상황에 따라 다릅니다. 작은 데이터에선 for가 더 빠를 수도 있습니다.
스트림은 재사용할 수 있나요?
안 됩니다. 최종 연산을 한 번 쓰면 소비되어, 다시 쓰려면 새로 stream()을 만들어야 합니다.

답글 남기기