| 컬렉션(Collection) | 스트림(Stream) | |
|---|---|---|
| 본질 | 데이터를 저장하는 자료구조 | 데이터를 처리하는 파이프라인 |
| 반복 | 외부 반복(external iteration) — 개발자가 for 로 직접 돎 |
내부 반복(internal iteration) — 라이브러리가 돎 |
| 평가 | 즉시(eager) — 요소가 메모리에 있음 | 지연(lazy) — 최종 연산이 호출될 때만 계산 |
| 재사용 | 몇 번이든 순회 가능 | 일회성. 한 번 소비하면 끝 |
| 크기 | 유한 | 무한 가능 (Stream.iterate, generate) |
| 변경 | 요소 추가/삭제 가능 | 원본을 바꾸지 않음. 새 결과를 만듦 |
내부 반복이 핵심입니다. for (Order o : orders) 는 "어떻게 순회할지"를 개발자가 씁니다. orders.stream().filter(...) 는 "무엇을 원하는지"만 쓰고 순회 방법은 라이브러리에 맡깁니다. 그래서 라이브러리가 병렬화, 단락 평가(short-circuit), 연산 융합 같은 최적화를 할 수 있습니다.
소스(생성) 중간 연산(0개 이상) 최종 연산(정확히 1개)
┌────────────┐ ┌────────┐ ┌───────┐ ┌────────┐ ┌──────────────┐
│ list.stream()│──►│ filter │──►│ map │──►│ sorted │───►│ collect / sum│──► 결과
└────────────┘ └────────┘ └───────┘ └────────┘ └──────────────┘
Stream 을 반환 (체이닝) Stream 이 아닌 값 반환| 단계 | 반환 | 실행 시점 | 예 |
|---|---|---|---|
| 생성 | Stream<T> |
— | stream(), Stream.of, IntStream.range |
| 중간 연산 | Stream<T> |
지연 — 기록만 해둠 | filter, map, sorted, distinct, limit |
| 최종 연산 | 값 / void / Optional |
즉시 — 이때 파이프라인 전체가 돎 | collect, forEach, count, reduce, findFirst |
중간 연산은 파이프라인 설계도를 쌓을 뿐 아무것도 실행하지 않습니다. 최종 연산이 그 설계도를 보고 소스에서 요소를 하나씩 끌어와 모든 단계를 통과시킵니다.
"중간 연산은 최종 연산 전까지 실행되지 않는다"와 "요소는 한 개씩 전체 파이프라인을 통과한다"를 peek 으로 눈으로 확인합니다.
Stream<String> s = Stream.of("a", "bb", "ccc", "dddd")
.peek(x -> System.out.println("filter 전: " + x))
.filter(x -> x.length() >= 2)
.peek(x -> System.out.println(" map 전: " + x))
.map(String::toUpperCase);
System.out.println("--- 아직 아무것도 출력 안 됨 ---");
List<String> result = s.limit(2).toList(); // 최종 연산: 여기서 실행
System.out.println(result);
// 출력:
// --- 아직 아무것도 출력 안 됨 ---
// filter 전: a
// filter 전: bb
// map 전: bb
// filter 전: ccc
// map 전: ccc
// [BB, CCC]관찰 포인트:
peek 이 한 줄도 찍히지 않았습니다.a 는 filter 를 통과 못 해 map 까지 가지 않았습니다.limit(2) 가 채워지자 dddd 는 아예 읽지도 않았습니다(단락 평가). 컬렉션 방식이라면 4개 전부 filter → 전부 map → 2개 자르기였을 것입니다.즉 스트림은 "단계별로 전체를 처리"(수평)가 아니라 "요소별로 전체 단계를 처리"(수직)합니다. 이 덕에 무한 스트림도 limit 만 있으면 끝납니다.
| 방법 | 코드 | 특징 |
|---|---|---|
| 컬렉션 | list.stream(), set.stream() |
가장 흔함 |
| 값 나열 | Stream.of("a", "b") |
테스트에 편리 |
| 배열 | Arrays.stream(arr), Arrays.stream(arr, from, to) |
int[] 는 IntStream |
| 범위 | IntStream.range(0, 10), rangeClosed(1, 10) |
인덱스 루프 대체 |
| 반복 | Stream.iterate(1, x -> x * 2) |
무한. limit 필수 |
| 조건 반복 (9+) | Stream.iterate(1, x -> x < 100, x -> x * 2) |
유한 |
| 생성 | Stream.generate(Math::random) |
무한 |
| 파일 | Files.lines(path) |
반드시 try-with-resources (파일 핸들) |
| 문자열 | "a,b".chars(), Pattern.compile(",").splitAsStream(s) |
|
| Map | map.entrySet().stream(), map.values().stream() |
Map 자체는 스트림 없음 |
| 빈 스트림 | Stream.empty() |
null 대신 |
| 합치기 | Stream.concat(s1, s2) |
|
| Optional (9+) | optional.stream() |
0개 또는 1개 |
| 연산 | 시그니처 요약 | 역할 | 상태 |
|---|---|---|---|
filter(Predicate) |
T → 조건 통과만 | 걸러내기 | 무상태 |
map(Function) |
T → R | 1:1 변환 | 무상태 |
flatMap(Function<T, Stream<R>>) |
T → Stream<R> 을 펼침 | 1:N 변환, 중첩 리스트 평탄화 | 무상태 |
mapMulti (16+) |
(T, Consumer<R>) |
flatMap 보다 가벼운 1:N | 무상태 |
mapToInt/Long/Double |
T → 기본형 스트림 | 박싱 제거 | 무상태 |
boxed() |
IntStream → Stream<Integer> | 반대 방향 | 무상태 |
distinct() |
중복 제거 (equals) |
유상태 (본 것 기억) | |
sorted(), sorted(Comparator) |
정렬 | 유상태 (전부 모아야 함) | |
peek(Consumer) |
그대로 통과시키며 부수효과 | 디버깅 | 무상태 |
limit(n) |
앞 n 개 | 단락 | 유상태 |
skip(n) |
앞 n 개 버림 | 유상태 | |
takeWhile(Predicate) (9+) |
조건이 참인 동안만 | 정렬된 데이터에서 단락 | |
dropWhile(Predicate) (9+) |
조건이 참인 동안 버리고 나머지 |
무상태 vs 유상태: filter, map 은 요소 하나만 보고 결정하므로 병렬화가 쉽고 메모리를 안 씁니다. sorted, distinct 는 이전 요소를 기억해야 하고 특히 sorted 는 모든 요소가 도착할 때까지 다음 단계로 못 넘깁니다(파이프라인 장벽). 무한 스트림에 sorted 를 붙이면 영원히 끝나지 않습니다.
takeWhile vs filter: filter(x -> x < 5) 는 끝까지 다 봅니다. takeWhile(x -> x < 5) 는 처음 실패하는 순간 멈춥니다. 정렬된 데이터에서는 takeWhile 이 압도적으로 효율적입니다.
flatMap: List<Order> 에서 각 주문의 List<Item> 을 전부 꺼내 하나의 Stream<Item> 으로 만듭니다. map 을 쓰면 Stream<List<Item>> 이 됩니다.
| 연산 | 반환 | 설명 |
|---|---|---|
collect(Collector) |
컬렉터가 정함 | 가장 강력. 2.7 참고 |
toList() (16+) |
List<T> (불변) |
collect(toList()) 의 축약 |
forEach(Consumer) |
void | 순서 보장 없음 (병렬 시) |
forEachOrdered(Consumer) |
void | 인카운터 순서 보장 |
reduce(identity, BinaryOperator) |
T | 접기. reduce(0, Integer::sum) |
reduce(BinaryOperator) |
Optional<T> |
항등원 없음 → 빈 스트림이면 empty |
count() |
long | |
min(Comparator), max(Comparator) |
Optional<T> |
|
sum(), average(), summaryStatistics() |
기본형 스트림 전용 | |
anyMatch, allMatch, noneMatch |
boolean | 단락 평가 |
findFirst() |
Optional<T> |
인카운터 순서상 첫 번째 |
findAny() |
Optional<T> |
병렬에서 빠름 (아무거나) |
toArray(), toArray(String[]::new) |
배열 | |
iterator() |
Iterator | 외부 반복으로 전환 |
Optional 을 반환하는 최종 연산(min, max, findFirst, findAny, reduce(op))은 빈 스트림일 수 있어서 그렇습니다. null 대신 "없을 수 있음"을 타입으로 강제하는 것입니다(05 레슨).
collect 는 "요소들을 어떻게 누적할지"를 Collector 로 받습니다. Collectors 유틸이 대부분 제공합니다.
| 컬렉터 | 결과 | 설명 |
|---|---|---|
toList(), toSet() |
List, Set |
가변. 구현체 보장 없음 |
toUnmodifiableList() (10+) |
불변 List | |
toCollection(TreeSet::new) |
지정 컬렉션 | |
toMap(keyFn, valueFn) |
Map |
키 중복 시 IllegalStateException |
toMap(keyFn, valueFn, mergeFn) |
Map |
중복 키를 mergeFn 으로 합침 |
toMap(keyFn, valueFn, mergeFn, LinkedHashMap::new) |
지정 Map | 순서 유지 등 |
groupingBy(classifier) |
Map<K, List<T>> |
분류 |
groupingBy(classifier, downstream) |
Map<K, D> |
그룹마다 다시 collect |
groupingBy(classifier, mapFactory, downstream) |
TreeMap::new 로 정렬 |
|
partitioningBy(predicate) |
Map<Boolean, List<T>> |
참/거짓 두 그룹. 항상 두 키 존재 |
joining(", ", "[", "]") |
String |
문자열 연결 |
counting() |
Long |
개수 |
summingInt/Long/Double(fn) |
합계 | |
averagingInt/Long/Double(fn) |
Double |
평균 |
summarizingInt(fn) |
IntSummaryStatistics |
count/sum/min/avg/max 한 번에 |
minBy(cmp), maxBy(cmp) |
Optional<T> |
|
mapping(fn, downstream) |
그룹 안에서 변환 후 수집 | |
filtering(pred, downstream) (9+) |
그룹 안에서 필터 | |
flatMapping(fn, downstream) (9+) |
그룹 안에서 평탄화 | |
reducing(identity, op) |
그룹 안에서 reduce | |
collectingAndThen(downstream, finisher) |
수집 결과에 마무리 함수 (List::size, 불변화) |
|
teeing(c1, c2, merger) (12+) |
두 컬렉터를 동시에 돌려 합침 |
toMap 키 충돌: 고객 이름 → 주문 맵을 만들 때 같은 고객이 두 번 나오면 예외입니다. 운영에서 데이터가 늘어나면 어느 날 갑자기 터지는 전형적인 사고입니다. 반드시 mergeFn 을 주거나((a, b) -> b 마지막 승, Long::sum 합계) 그룹핑을 쓰세요.
groupingBy 다단계: downstream 자리에 또 groupingBy 를 넣으면 2단 맵이 됩니다. groupingBy(Order::month, groupingBy(Order::category, summingLong(Order::amount))) → Map<Month, Map<Category, Long>>.
teeing: 평균과 개수를 동시에, 또는 최소·최대를 한 번의 순회로 얻을 때 씁니다. teeing(minBy(cmp), maxBy(cmp), (min, max) -> ...).
Stream<Integer> 는 요소마다 Integer 객체입니다. 백만 개면 백만 개 객체 + 언박싱. IntStream, LongStream, DoubleStream 은 기본형을 그대로 다룹니다.
Stream<Integer> |
IntStream |
|
|---|---|---|
| 요소 | 힙 객체 | 스택/레지스터의 int |
| 합계 | reduce(0, Integer::sum) (언박싱 반복) |
sum() |
| 통계 | 직접 구현 | average(), max(), summaryStatistics() |
| 전환 | mapToInt(Integer::intValue) → IntStream |
boxed() → Stream<Integer> |
| 반환 | Optional<Integer> |
OptionalInt (getAsInt()) |
숫자 계산이 있으면 mapToInt/Long/Double 로 내려가서 처리하는 것이 관례입니다. summaryStatistics() 는 한 번의 순회로 count/sum/min/max/average 를 전부 줍니다.
parallelStream() 또는 .parallel() 을 붙이면 ForkJoinPool 공용 풀(commonPool) 에서 데이터를 쪼개 병렬 처리합니다.
소스 (1,000,000 개)
│ Spliterator.trySplit() 로 반씩 분할 (재귀)
├─────────────┬─────────────┬─────────────┐
250,000 250,000 250,000 250,000 ← 워커 스레드들이 각각 처리
│ │ │ │
└──── 부분 결과를 combiner 로 합침 (reduce / Collector.combiner) ────┘| 이득인 경우 | 손해인 경우 |
|---|---|
| 요소 수가 많음 (수만~수백만) | 요소 수가 적음 (수백 이하) |
| 요소당 계산이 무거움 (CPU 바운드) | 요소당 작업이 가벼움 (x + 1) |
소스가 잘 쪼개짐: ArrayList, 배열, IntStream.range |
소스가 안 쪼개짐: LinkedList, Stream.iterate, Files.lines |
무상태 연산 위주 (filter, map) |
유상태·순서 의존 (sorted, limit, findFirst) |
| 합치기가 싼 결과 (숫자 합계) | 합치기가 비싼 결과 (큰 Map 병합) |
| 블로킹 I/O 없음 | I/O 대기 — 공용 풀을 점유해 다른 병렬 스트림까지 멈춤 |
공유 상태 금지: 병렬 스트림 안에서 ArrayList.add 나 count++ 를 하면 02 레슨의 경쟁 조건이 그대로 발생합니다. 반드시 collect 로 결과를 모으세요. collect 는 각 스레드가 자기 컨테이너에 누적 후 combiner 로 합치므로 안전합니다.
순서: forEach 는 병렬에서 순서가 뒤섞입니다. 순서가 필요하면 forEachOrdered (병렬 이득 일부 포기). findFirst 는 순서를 지키느라 느리고, findAny 는 빠릅니다. toList() 는 병렬이어도 원래 순서를 유지합니다(인카운터 순서 보장 소스일 때).
커스텀 풀: I/O 가 섞이거나 공용 풀 오염을 피하려면 자기 풀에서 실행합니다.
ForkJoinPool pool = new ForkJoinPool(4);
long sum = pool.submit(() -> data.parallelStream().mapToLong(this::heavy).sum()).get();
pool.shutdown();이는 문서화된 보장이 아닌 구현 동작(스트림이 호출 스레드의 풀을 사용)에 의존하지만 널리 쓰이는 관용구입니다. 진짜 I/O 병렬은 02 레슨의 가상 스레드가 정답입니다.
IllegalStateException: stream has already been operated upon or closed.ConcurrentModificationException 또는 미정의 동작.peek 은 디버깅용입니다. 최적화로 건너뛰어질 수 있습니다(예: count() 가 크기를 바로 알 수 있으면 파이프라인을 안 돌림).