유성

Java Stream API의 핵심 개념과 병렬 처리의 함정 본문

Java & Kotlin

Java Stream API의 핵심 개념과 병렬 처리의 함정

백엔드 유성 2025. 6. 15. 23:39

Java 8에 도입된 Stream API는 반복문 위주의 명령형 코드를 선언형 스타일로 바꾸고,

더 나아가 병렬 처리를 손쉽게 구현할 수 있도록 도와주는 강력한 도구입니다.

 

이 글에서는 기본적인 사용법은 다루지 않고,

실무에서 주의해야 할 핵심 개념들과 병렬 처리 시 발생할 수 있는 문제들에 대해 정리해보려 합니다.

 

1. Java Stream API가 무엇인지?

Stream API는 2014년 4월, Java 8과 함께 등장한 기능으로, 컬렉션 또는 배열 데이터를 데이터 흐름(stream) 형태로 처리할 수 있게 합니다.

 

기존에는 for/while 루프를 사용해 명시적으로 데이터를 처리했다면,

Stream은 데이터 처리 과정을 선언적으로 구성할 수 있습니다.

 

이를 통해 가독성이 좋아지고, 병렬 처리도 간결하게 구현할 수 있게 되었습니다.

 

2. Stream의 핵심 개념

선언형 프로그래밍

Stream API는 무엇을 할지만 선언하면 어떻게 처리할지는 내부적으로 알아서 처리합니다.

 

IntStream.rangeClosed(1, 10)
        .filter(i -> i % 2 == 0)
        .map(i -> i * 3)
        .forEach(System.out::println);

 

위 코드는 1부터 10까지의 수 중 짝수를 골라 3을 곱한 결과를 출력합니다.

반복문 없이도 흐름이 한눈에 보이며, 코드 자체가 "무엇을 하고자 하는지" 잘 드러납니다.

 

일회성 데이터 흐름

Stream은 한 번만 소비할 수 있는 단방향 흐름입니다. 이미 처리된 스트림은 재사용이 불가능하며, 필요 시 새로 생성해야 합니다.

IntStream stream = IntStream.rangeClosed(1, 5);
stream.forEach(System.out::println);
stream.forEach(System.out::println); // Exception 발생

 

두 번째 forEach에서 IllegalStateException이 발생합니다.

"stream has already been operated upon or closed"는 이 개념을 가장 잘 설명하는 에러 메시지라고 생각됩니다.

 

지연 처리

Stream의 중간 연산(filter, map 등)은 즉시 실행되지 않고, 최종 연산(forEach, collect)이 호출될 때 비로소 실행됩니다.

IntStream.rangeClosed(1, 10)
         .filter(i -> {
             System.out.println("Filtering " + i);
             return i % 2 == 0;
         }); // 아무것도 출력되지 않음

 

위 코드는 실행해도 출력되지 않습니다. 지연 처리 덕분에 불필요한 연산을 건너뛸 수 있는 최적화가 가능해집니다.

 

이를 "파이프라인을 구성했다" 라고 표현할 수 있습니다.

 

파이프라인 처리

Stream은 여러 중간 연산을 체인 형태로 연결한 파이프라인 구조로 데이터를 처리합니다.

각 요소는 파이프라인을 따라 한 단계씩 처리되며, 전체 요소를 모두 순회하지 않아도 되는 경우 성능상 이점이 큽니다.

List<String> result = list.stream()
                          .filter(조건)
                          .map(변환)
                          .collect(Collectors.toList());

 

.filter, .map 등의 중간 연산은 각각 내부적으로 java.util.stream.ReferencePipeline의 하위 클래스 인스턴스로 표현되며,

이전 연산을 참조하는 방식으로 LinkedList 형태의 파이프라인이 형성됩니다.

 

최종 평가(terminal evaluation)가 호출되면, 소스로부터 데이터를 하나씩 가져와

파이프라인의 각 중간 연산을 순차적으로 적용하면서 값을 처리하거나 필터링합니다.

 

이때 최종 평가는 데이터를 수집(collect), 출력(forEach) 등의 방식으로 소비하게 됩니다.

 

또한 .limit(), .anyMatch()와 같이 중간에 처리를 중단하는 연산도 가능한데, 이를 short-circuit 평가라고 합니다.

 

병렬 처리 지원

Stream은 .parallel() 또는 .parallelStream()을 통해 손쉽게 병렬 처리가 가능합니다.

내부적으로 Java는 ForkJoinPool + Spliterator 조합을 통해 데이터를 분할하고 병렬로 처리합니다.

list.parallelStream()
    .filter(...)
    .map(...)
    .collect(Collectors.toList());

 

위 코드를 실행하면 다음과 같은 흐름으로 동작합니다.

  1. 데이터를 Spliterator로 분할
  2. 각 분할된 데이터를 ForkJoinPool의 워커 스레드가 처리
  3. 계산된 결과를 Join 단계에서 합쳐서 반환

이 때 Spliterator는 데이터를 적절히 쪼개서 여러 스레드에 배분하며,

Work Stealing(작업 훔치기) 알고리즘을 통해 작업량을 동적으로 재조정합니다.

 

Spliterator.trySplit() 메서드가 이 분할 과정을 담당하는 핵심이며, Spliterator는 Collection 인터페이스에 default method로 포함되어 있기 때문에 컬렉션 구현체에서는 대부분 사용할 수 있습니다.

 

3. 병렬 처리 시 주의할 점

데이터 양이 작을 경우 오히려 느려짐

병렬 처리 전에 데이터를 분할하고 결과를 병합하는 과정이 필요하기 때문에,
데이터가 적을 경우 오히려 성능이 나빠질 수 있습니다.

순서가 중요할 경우 부적합

.forEachOrdered()를 사용하면 순서 보장은 가능하지만,
병렬 처리의 장점을 잃게 되고 내부적으로 스레드가 Join을 기다리며 blocking되어 성능 저하를 유발할 수 있습니다.

I/O 작업에는 부적합

병렬 Stream은 CPU-bound 작업에 적합하며,

네트워크 통신, DB 쿼리 수행 등 I/O 작업에는 지양하는 것이 좋습니다.

 

그 이유는 .parallelStream()이 내부적으로 ForkJoinPool.commonPool()을 사용하기 때문입니다.

JVM에서 별도 설정 없이 병렬 작업을 수행하면, 기본적으로 이 공용 스레드 풀이 활용됩니다.

 

이로 인해 완전히 다른 영역의 로직, 예를 들어 주문 처리 로직에서 parallelStream을 사용한 것이 로그인 기능의 지연으로 이어지는 등,

파악하기 어려운 사이드 이팩트가 발생할 수 있고, 이런 경우 문제를 파악하기가 쉽지 않습니다.

 

따라서 I/O 작업처럼 스레드가 blocking될 가능성이 있는 경우,

ExecutorService나 Schedulers.boundedElastic 같은 전용 실행 환경을 사용하는 것이 안전한 선택입니다.