공공부하자개발 · 영어 학습 노트
자바
고급모던 자바와 성능0/10 완료
  • 01제네릭과 와일드카드
  • 02멀티스레드와 동기화
  • 03람다식과 함수형 인터페이스
  • 04Stream API와 병렬 처리
  • 05Optional로 NPE 방지
  • 06메서드 활용 패턴 (고급)
  • 07Java 21 모던 문법
  • 08어노테이션·리플렉션·동적 프록시
  • 09CompletableFuture 심화와 가상 스레드 실전
  • 10JVM 메모리·GC·OOM 진단
사이트 소개개인정보처리방침연락처
© 2026 공부하자
홈 › 고급 › 04 / 10

Stream API와 병렬 처리

섹션 7진행 0 / 10
1왜 배우는가2핵심 원리3코드 예제4응용 변형 예제5자주 하는 실수 (Tip)6연습 문제7정리‹ 이전다음 ›

2. 핵심 원리

2.1 스트림 vs 컬렉션

컬렉션(Collection) 스트림(Stream)
본질 데이터를 저장하는 자료구조 데이터를 처리하는 파이프라인
반복 외부 반복(external iteration) — 개발자가 for 로 직접 돎 내부 반복(internal iteration) — 라이브러리가 돎
평가 즉시(eager) — 요소가 메모리에 있음 지연(lazy) — 최종 연산이 호출될 때만 계산
재사용 몇 번이든 순회 가능 일회성. 한 번 소비하면 끝
크기 유한 무한 가능 (Stream.iterate, generate)
변경 요소 추가/삭제 가능 원본을 바꾸지 않음. 새 결과를 만듦

내부 반복이 핵심입니다. for (Order o : orders) 는 "어떻게 순회할지"를 개발자가 씁니다. orders.stream().filter(...) 는 "무엇을 원하는지"만 쓰고 순회 방법은 라이브러리에 맡깁니다. 그래서 라이브러리가 병렬화, 단락 평가(short-circuit), 연산 융합 같은 최적화를 할 수 있습니다.

2.2 파이프라인 구조 — 생성, 중간, 최종

text
   소스(생성)          중간 연산(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

중간 연산은 파이프라인 설계도를 쌓을 뿐 아무것도 실행하지 않습니다. 최종 연산이 그 설계도를 보고 소스에서 요소를 하나씩 끌어와 모든 단계를 통과시킵니다.

2.3 지연 평가(Lazy Evaluation) — 증명 코드

"중간 연산은 최종 연산 전까지 실행되지 않는다"와 "요소는 한 개씩 전체 파이프라인을 통과한다"를 peek 으로 눈으로 확인합니다.

java
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]

관찰 포인트:

  1. 최종 연산 전에는 peek 이 한 줄도 찍히지 않았습니다.
  2. a 는 filter 를 통과 못 해 map 까지 가지 않았습니다.
  3. limit(2) 가 채워지자 dddd 는 아예 읽지도 않았습니다(단락 평가). 컬렉션 방식이라면 4개 전부 filter → 전부 map → 2개 자르기였을 것입니다.

즉 스트림은 "단계별로 전체를 처리"(수평)가 아니라 "요소별로 전체 단계를 처리"(수직)합니다. 이 덕에 무한 스트림도 limit 만 있으면 끝납니다.

2.4 스트림 생성

방법 코드 특징
컬렉션 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개

2.5 중간 연산(Intermediate Operations)

연산 시그니처 요약 역할 상태
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>> 이 됩니다.

2.6 최종 연산(Terminal Operations)

연산 반환 설명
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 레슨).

2.7 Collectors 딥다이브

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) -> ...).

2.8 기본형 스트림 — 박싱 비용

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 를 전부 줍니다.

2.9 parallelStream — 원리와 함정

parallelStream() 또는 .parallel() 을 붙이면 ForkJoinPool 공용 풀(commonPool) 에서 데이터를 쪼개 병렬 처리합니다.

text
소스 (1,000,000 개)
      │ Spliterator.trySplit() 로 반씩 분할 (재귀)
      ├─────────────┬─────────────┬─────────────┐
   250,000       250,000       250,000       250,000    ← 워커 스레드들이 각각 처리
      │             │             │             │
      └──── 부분 결과를 combiner 로 합침 (reduce / Collector.combiner) ────┘
  • 공용 풀 크기 = CPU 코어 수 − 1 (+ 호출 스레드). 전체 JVM 이 하나의 풀을 공유합니다.
  • 분할(split) 과 합치기(combine) 비용이 있으므로, 데이터가 작거나 요소당 작업이 가벼우면 순차보다 느립니다.
이득인 경우 손해인 경우
요소 수가 많음 (수만~수백만) 요소 수가 적음 (수백 이하)
요소당 계산이 무거움 (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 가 섞이거나 공용 풀 오염을 피하려면 자기 풀에서 실행합니다.

java
ForkJoinPool pool = new ForkJoinPool(4);
long sum = pool.submit(() -> data.parallelStream().mapToLong(this::heavy).sum()).get();
pool.shutdown();

이는 문서화된 보장이 아닌 구현 동작(스트림이 호출 스레드의 풀을 사용)에 의존하지만 널리 쓰이는 관용구입니다. 진짜 I/O 병렬은 02 레슨의 가상 스레드가 정답입니다.

2.10 스트림 일회성과 부수효과 규칙

  • 스트림은 한 번만 최종 연산할 수 있습니다. 두 번째 호출은 IllegalStateException: stream has already been operated upon or closed.
  • 람다는 비간섭(non-interfering) 이어야 합니다: 파이프라인 도중 소스 컬렉션을 수정하면 ConcurrentModificationException 또는 미정의 동작.
  • 람다는 무상태(stateless) 여야 합니다: 외부 변수에 의존한 결과가 실행 순서에 따라 달라지면 병렬에서 깨집니다.
  • peek 은 디버깅용입니다. 최적화로 건너뛰어질 수 있습니다(예: count() 가 크기를 바로 알 수 있으면 파이프라인을 안 돌림).
핵심 원리
  • 2.1 스트림 vs 컬렉션
  • 2.2 파이프라인 구조 — 생성, 중간, 최종
  • 2.3 지연 평가(Lazy Evaluation) — 증명 코드
  • 2.4 스트림 생성
  • 2.5 중간 연산(Intermediate Operations)
  • 2.6 최종 연산(Terminal Operations)
  • 2.7 Collectors 딥다이브
  • 2.8 기본형 스트림 — 박싱 비용
  • 2.9 parallelStream — 원리와 함정
  • 2.10 스트림 일회성과 부수효과 규칙
이전 섹션1 왜 배우는가2 / 7다음 섹션3 코드 예제