| 일 | 월 | 화 | 수 | 목 | 금 | 토 |
|---|---|---|---|---|---|---|
| 1 | 2 | 3 | 4 | 5 | 6 | 7 |
| 8 | 9 | 10 | 11 | 12 | 13 | 14 |
| 15 | 16 | 17 | 18 | 19 | 20 | 21 |
| 22 | 23 | 24 | 25 | 26 | 27 | 28 |
- spring boot
- 동시성제어
- 백엔드개발
- docker
- Java
- redis
- 성능 최적화
- grafana
- DevOps
- webflux
- netty
- helm
- NIO
- Kubernetes
- Kotlin
- 데이터베이스
- selector
- CloudNative
- jvm
- RDBMS
- JPA
- kafka
- mysql
- 성능최적화
- GitOps
- 백엔드
- prometheus
- monitoring
- 트랜잭션
- SpringBoot
- Today
- Total
유성
Java Stream API의 핵심 개념과 병렬 처리의 함정 본문
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());
위 코드를 실행하면 다음과 같은 흐름으로 동작합니다.
- 데이터를 Spliterator로 분할
- 각 분할된 데이터를 ForkJoinPool의 워커 스레드가 처리
- 계산된 결과를 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 같은 전용 실행 환경을 사용하는 것이 안전한 선택입니다.
'Java & Kotlin' 카테고리의 다른 글
| 클래스로더는 .class 파일을 어떻게 실행시키나? (4) | 2025.08.02 |
|---|---|
| NIO(New I/O)와 Webflux 비동기 프로그래밍 (1) | 2025.07.07 |
| Socket 통신 (1) | 2024.12.15 |
| Java ThreadPoolExecutor 예외 처리와 Exception Handler (0) | 2024.10.09 |
| Java Thread로 직접 구현하는 커스텀 쓰레드 풀: 기본 원리부터 동작까지 (0) | 2024.10.06 |