스트림API

hs·2025년 11월 22일

Stream API

  • Java 8부터 도입
  • 데이터 처리용 선언형 API
    • 다량의 데이터를 간결하고 읽기 쉬운 방식으로 처리
  • for/while 없이 연속적인 데이터 변환/연산 가능
  • 스트림(Stream) = 데이터 원소의 시퀀스
    • 유한 스트림 : 배열, 컬렉션, 파일 등
    • 무한 스트림 : Stream.generate(), Stream.iterate()

💡 스트림은 데이터를 직접 저장하지 않으며, 원본 데이터를 변경하지 않는다 (Immutable)

파이프라이닝 (Pipeline)

중간 연산 / 최종 연산

스트림은 연산을 연결해 하나의 파이프라인을 구성

구분예시역할개수실행 시점
중간 연산filter, map, sorted, distinct, peek데이터 변환/필터링여러 개 가능지연 실행 (lazy)
최종 연산collect, forEach, reduce, count, anyMatch결과 생성스트림당 1개호출 시 즉시 실행
  • 중간 연산만 호출해도 실제로는 아무 일도 일어나지 않음
  • 최종 연산을 호출하는 순간 전체 파이프라인이 한 번에 실행
Stream<Integer> s = list.stream()
    .filter(v -> v > 10)
    .map(v -> v * 2); // 아직 아무 연산도 실행되지 않음

List<Integer> result = s.collect(Collectors.toList()); // 이 시점에 전체 파이프라인 실행

지연 연산(lazy)

  • 최종 연산이 호출되기 전까지 실제 연산을 수행하지 않음
  • 불필요한 연산을 미뤄 최종 결과에 필요 없는 작업은 하지 않음
list.stream()
    .filter(v -> v > 10)
    .map(v -> v * 2)
    .findFirst(); // 최종 연산

findFirst()처음 조건을 만족하는 원소 하나만 필요

  • 전체 리스트를 끝까지 도는 게 아니라 조건을 만족하는 원소를 찾는 즉시 나머지 연산은 중간에 멈춤 (단락 평가)

자바스크립트 고차함수와의 차이

항목JavaScript map, filterJava Stream
처리 방식각 함수마다 개별 루프 수행파이프라인 최적화 → 1개의 루프로 처리
성능 최적화거의 없음단일 루프 + 내부 반복 최적화
병렬 처리직접 관리 필요parallelStream() 자동 처리

예시: filter → filter → map이 있다면?

  • JS → 3번 루프
  • Java Stream → 1번 루프

따라서 Stream은 대규모 데이터 처리에서 더 효율적

병렬 처리 (Parallel Processing)

스트림은 parallel() 또는 parallelStream() 을 사용해서 병렬처리를 할 수 있다.

List<Integer> result = list.parallelStream()
    .filter(v -> v > 10)
    .map(v -> v * 2)
    .collect(Collectors.toList());

Fork/Join Framework 기반

Java 병렬 스트림은 내부적으로 Fork/Join Framework를 사용

  • Fork (분할) : 큰 작업을 여러 개의 작은 작업으로 쪼갠다
  • Join (정복/병합) : 쪼갠 작업들의 결과를 다시 합친다

분할 정복(Divide & Conquer) 패턴을 일반화한 프레임워크

대표적으로 병합 정렬, 퀵 정렬 같은 알고리즘에 사용됨

ForkJoinPool (공용 스레드 풀)

병렬 스트림은 ForkJoinPool이라는 스레드 풀을 사용

  • 기본적으로 Runtime.getRuntime().availableProcessors() → CPU 코어 수만큼 워커 스레드 생성
  • 이 풀은 전역으로 하나만 존재하는 공용 풀을 사용
    • parallelStream() 호출 시, 이 공용 풀 위에서 작업이 돌아감

그래서 같은 JVM 안에서 병렬 스트림을 과도하게 쓰면

다른 병렬 작업들과 스레드 풀을 두고 경쟁할 수 있다.

데이터 분할

병렬 스트림은 원본 데이터를 여러 청크 로 나눠서 처리

  1. parallelStream() 호출
  2. 스트림의 Spliterator가 데이터 소스를 일정한 크기로 쪼갬
    • List, Array 같은 건 분할하기 쉽고 병렬 처리 효율이 좋음
    • LinkedList처럼 인덱스 랜덤 접근이 안 되는 구조는 비효율적일 수 있음
  3. 각 청크가 별도의 작업 으로 ForkJoinPool에 제출됨

각 Task는 filter, map, sorted 같은 중간 연산들을 처리


Work Stealing

  • 각 워커 스레드는 자기 전용 작업 큐를 갖고 있음
  • 일반적인 스레드 풀:
    • 큐에 작업 넣고 스레드는 큐에서 작업 꺼내서 처리
    • 작업이 없으면 그냥 논다(대기)
  • ForkJoinPool:
    • 워커 스레드의 작업이 끝나고 할 일이 없으면 다른 스레드의 큐에서 남은 작업을 훔쳐 와서 처리
    • Work Stealing
  • 특정 스레드에만 일이 몰리는 걸 줄임
  • CPU 코어가 쉬지 않도록 최대한 돌릴 수 있음
  • 처리 시간이 줄어들 가능성이 높아짐

동작흐름 정리

  1. parallelStream() 호출
  2. 데이터 소스가 여러 조각으로 분할 (Fork)
  3. 각 조각이 ForkJoinPool의 워커 스레드에 할당
  4. 워커 스레드들은 중간 연산(filter, map 등)을 실행
  5. 작업이 먼저 끝난 스레드는 남은 작업을 Work Stealing으로 가져와 처리
  6. 모든 조각의 결과를 병합(Join) 해서 최종 결과 생성

주의점

  • 데이터 양이 적을 때 → 스레드 분배/병합 오버헤드가 더 큼
  • 순서가 중요한 연산 (forEachOrdered, 정렬, 순번 보장 등)
  • 공유 mutable 상태를 업데이트하는 연산
    • 예) parallelStream() 안에서 외부 리스트에 add 하는 코드 등
profile
sh

0개의 댓글