1. Spliterator 소개
Java에서 컬렉션은 Iterator로만 순회한다고 생각했다면, Java 8 이전까지는 완전히 맞는 말이었습니다. 하지만 Stream API와 병렬성의 유행이 찾아오면서 새로운 영웅 — Spliterator가 등장했습니다.
Spliterator는 컬렉션의 요소를 순회할 뿐 아니라 데이터를 분할하여 병렬 처리할 수 있게 해 주는 인터페이스입니다. 이름은 split과 iterator의 합성어입니다.
큰 케이크를 상상해 보세요. 일반 Iterator는 케이크를 한 조각씩 잘라 순서대로 먹습니다. Spliterator는 케이크를 반으로 잘라 친구에게 절반을 주고 — 둘이 동시에 먹기 시작할 수 있습니다. 친구가 많다면 — 더 잘라 나눌 수 있죠!
Spliterator 인터페이스 — 핵심 메서드
public interface Spliterator<T> {
boolean tryAdvance(java.util.function.Consumer<? super T> action);
Spliterator<T> trySplit();
long estimateSize();
int characteristics();
// ... 다른 메서드도 몇 개 더 있지만, 이들이 가장 중요합니다
}
- tryAdvance — 다음 요소에 대해 무언가를 수행합니다(next() + 액션의 조합과 유사).
- trySplit — 소스를 두 부분으로 나누려고 시도하고, 분리된 부분용 새 Spliterator를 반환합니다.
- estimateSize — 남은 요소의 개수를 추정합니다.
- characteristics — 특성의 비트 마스크(순서 보장, 유일성, 불변성 등)를 반환합니다.
2. Spliterator 사용: 수동 순회와 분할
컬렉션에서 Spliterator 얻기
Collection을 구현하는 모든 컬렉션은 자신의 Spliterator를 반환할 수 있습니다:
import java.util.List;
import java.util.Spliterator;
List<String> names = List.of("Vasya", "Petya", "Masha", "Lena");
Spliterator<String> spliterator = names.spliterator();
요소 수동 순회
Spliterator<String> spliterator = names.spliterator();
while (spliterator.tryAdvance(name -> System.out.println("이름: " + name))) {
// 모든 작업은 tryAdvance 내부에서 처리됩니다
}
컬렉션 분할
가장 흥미로운 것은 trySplit() 메서드입니다:
Spliterator<String> spliterator1 = names.spliterator();
Spliterator<String> spliterator2 = spliterator1.trySplit();
System.out.println("첫 번째 부분:");
spliterator1.forEachRemaining(System.out::println);
System.out.println("두 번째 부분:");
if (spliterator2 != null) {
spliterator2.forEachRemaining(System.out::println);
}
무슨 일이 일어날까요: Spliterator는 컬렉션을 두 부분으로 나누려고 시도합니다(항상 정확히 반으로는 아님 — 구현에 따라 다릅니다). 이제 두 부분을 서로 독립적으로 처리할 수 있습니다 — 원한다면 서로 다른 스레드에서요!
3. 병렬 스트림: 왜, 그리고 어떻게 동작하나
병렬 스트림(parallelStream())은 요소를 순차적으로가 아니라 여러 스레드에서 동시에 처리하는 스트림입니다. 특히 데이터가 많고 멀티코어 프로세서일 때 유용합니다.
import java.util.List;
List<String> names = List.of("Vasya", "Petya", "Masha", "Lena");
// 일반 스트림:
names.stream().forEach(System.out::println);
// 병렬 스트림:
names.parallelStream().forEach(System.out::println);
핵심은 무엇일까요?
일반 스트림은 한 개의 스레드에서 요소를 처리합니다. 병렬 스트림은 소스를 (Spliterator를 사용해) 여러 부분으로 나누고, 각 부분을 별도의 스레드에서 처리합니다.
내부에서는 어떻게 동작할까?
- Spliterator가 컬렉션을 여러 부분으로 나눕니다 — 보통 사용 가능한 코어 수(혹은 그보다 약간 더) 정도로요.
- 각 부분은 자신의 스레드에서 처리됩니다 — 공용 ForkJoinPool이 사용됩니다.
- 결과는 다시 모여 최종 컬렉션 또는 값으로 합쳐집니다.
병렬 스트림 동작 다이어그램
flowchart LR
A[컬렉션] --> B{Spliterator}
B --> C1[파트 1] --> D1[스레드 1]
B --> C2[파트 2] --> D2[스레드 2]
B --> C3[파트 3] --> D3[스레드 3]
D1 & D2 & D3 --> E[결과 수집]
4. 병렬 스트림의 장점과 한계
장점
- 가속: 큰 컬렉션 처리 시, 연산이 무거울수록 병렬 스트림이 실행 시간을 눈에 띄게 단축합니다.
- 간편함: 수동으로 멀티스레드 코드를 쓸 필요가 없습니다 — stream()을 parallelStream()으로 바꾸면 됩니다.
제약과 함정
- 항상 더 빠른 것은 아님: 작은 컬렉션에서는 오버헤드가 이점을 상쇄할 수 있습니다.
- 순서가 보장되지 않음: forEach/map/filter에서 순서가 달라질 수 있습니다. 순서가 필요하다면 forEachOrdered를 사용하세요.
- 스레드 안전성 문제: 부작용이 있는 연산(외부 컬렉션/변수 변경)은 데이터 레이스를 유발합니다.
- 모든 연산이 적합한 것은 아님: 의존적인 계산(예: 순차적 누적)은 기대와 다르게 동작할 수 있습니다.
언제 병렬 스트림을 사용할까?
- 큰 컬렉션(수만 개 이상의 요소).
- 각 요소에 비용이 큰 연산이 있을 때.
- 엄격한 순서가 중요하지 않을 때.
- 부작용이 없을 때(순수 함수).
언제 사용하지 말아야 할까?
- 요소가 적을 때.
- 코드가 외부 변수나 컬렉션을 변경할 때.
- 처리 순서를 반드시 유지해야 할 때.
- 데이터 소스가 분할에 비효율적일 때(예: LinkedList).
5. 실용 예제
예제 1: 실행 시간 비교
import java.util.*;
import java.util.stream.*;
public class ParallelStreamDemo {
public static void main(String[] args) {
List<Integer> numbers = IntStream.range(0, 10_000_000)
.boxed()
.collect(Collectors.toList());
long start = System.currentTimeMillis();
long count = numbers.stream()
.filter(n -> isPrime(n))
.count();
long time = System.currentTimeMillis() - start;
System.out.println("일반 스트림: " + time + " ms, 소수 개수: " + count);
start = System.currentTimeMillis();
count = numbers.parallelStream()
.filter(n -> isPrime(n))
.count();
time = System.currentTimeMillis() - start;
System.out.println("병렬 스트림: " + time + " ms, 소수 개수: " + count);
}
// 가장 단순한 소수 판별(예제용)
public static boolean isPrime(int n) {
if (n < 2) return false;
for (int i = 2, sqrt = (int)Math.sqrt(n); i <= sqrt; i++)
if (n % i == 0) return false;
return true;
}
}
무엇을 기대할 수 있을까요: 데이터가 매우 많을 때 병렬 스트림이 종종 더 빠릅니다(특히 멀티코어 프로세서에서). 데이터가 적으면 차이가 없거나, 오히려 병렬 버전이 더 느릴 수도 있습니다.
예제 2: 순서 문제
import java.util.List;
List<String> names = List.of("Vasya", "Petya", "Masha", "Lena");
System.out.println("일반 스트림:");
names.stream().forEach(System.out::println);
System.out.println("병렬 스트림:");
names.parallelStream().forEach(System.out::println);
System.out.println("forEachOrdered를 사용한 병렬 스트림:");
names.parallelStream().forEachOrdered(System.out::println);
결론: 일반 스트림과 forEachOrdered를 사용할 때는 순서가 유지되지만, 병렬 스트림에서 이를 사용하지 않으면 순서가 보장되지 않습니다.
예제 3: 부작용의 위험
import java.util.*;
import java.util.stream.*;
List<Integer> numbers = IntStream.range(1, 1000).boxed().collect(Collectors.toList());
List<Integer> results = new ArrayList<>();
// 위험! 이렇게 하지 마세요!
numbers.parallelStream().forEach(n -> results.add(n * n));
System.out.println("리스트 크기: " + results.size());
무슨 일이 벌어질까요? 리스트 크기가 기대보다 작을 수 있고, 때로는 ConcurrentModificationException이 발생할 수도 있습니다. 이유는 ArrayList가 스레드 안전하지 않은데, 병렬 스트림은 여러 스레드를 동시에 실행하기 때문입니다.
6. Spliterator: 동작 특성과 characteristic
Spliterator의 characteristics
Spliterator는 자신의 속성을 비트 마스크로 표현합니다:
- ORDERED — 요소가 특정 순서로 진행됨(예: 리스트).
- DISTINCT — 모든 요소가 유일함(예: 집합).
- SORTED — 요소가 정렬되어 있음.
- SIZED — 크기를 알고 있음.
- IMMUTABLE — 컬렉션이 불변임.
- CONCURRENT — 컬렉션이 스레드 안전함.
- SUBSIZED — trySplit() 이후의 모든 spliterator들도 자신의 크기를 알고 있음.
Spliterator<String> spliterator = names.spliterator();
int characteristics = spliterator.characteristics();
System.out.println(Integer.toBinaryString(characteristics));
왜 알아야 할까요? Stream API와 병렬 스트림은 이러한 특성을 이용해 최적화를 수행합니다. 예를 들어, 소스가 불변이고 정렬되어 있다면 더 안전하고 효율적으로 분할하고 결과를 모을 수 있습니다.
7. Spliterator를 직접 언제, 어떻게 사용할까?
일상적으로 직접 Spliterator를 구현할 일은 드뭅니다: 표준 컬렉션이 이미 이를 제공합니다. 하지만 자체 데이터 소스를 만들거나 순회/분할을 정교하게 제어하고 싶다면 Spliterator가 유용합니다.
예제: tryAdvance로 수동 순회
import java.util.List;
import java.util.Spliterator;
List<String> names = List.of("Vasya", "Petya", "Masha", "Lena");
Spliterator<String> spliterator = names.spliterator();
spliterator.tryAdvance(name -> System.out.println("첫 번째 요소: " + name));
spliterator.forEachRemaining(name -> System.out.println("나머지: " + name));
예제: 컬렉션 분할
Spliterator<String> spliterator1 = names.spliterator();
Spliterator<String> spliterator2 = spliterator1.trySplit();
if (spliterator2 != null) {
spliterator2.forEachRemaining(name -> System.out.println("파트 2: " + name));
}
spliterator1.forEachRemaining(name -> System.out.println("파트 1: " + name));
8. Spliterator와 병렬 스트림 사용 시 흔한 실수
실수 №1: 작은 컬렉션에 병렬 스트림을 사용하는 것. 가속 대신 느려질 수 있습니다 — 분할과 작업 스케줄링 오버헤드가 이점을 넘어서기 때문입니다.
실수 №2: 요소의 순서가 유지될 것이라 기대하는 것. 병렬 스트림은 순서를 보장하지 않습니다. 순서가 중요하다면 forEachOrdered를 사용하세요. 다만 일부 병렬 효율은 잃게 됩니다.
실수 №3: 람다 표현식에서 부작용을 발생시키는 것. 병렬 스트림 내부에서 외부 변수/컬렉션을 안전하게 변경할 수 없습니다 — 데이터 레이스와 잡기 어려운 버그로 이어집니다.
실수 №4: 병렬 스트림 내부에서 스레드 안전하지 않은 컬렉션을 사용하는 것. 일반 ArrayList에 여러 스레드에서 추가하는 것은 ConcurrentModificationException과 같은 오류로 가는 지름길입니다.
실수 №5: 즉각적인 가속을 기대하는 것. 병렬 스트림은 마법 지팡이가 아닙니다. 프로파일링하세요: 데이터가 적거나 연산이 가벼우면 일반 스트림이 더 빠릅니다.
실수 №6: 분할이 비효율적인 소스와 함께 병렬 스트림을 사용하는 것. 예를 들어 LinkedList는 종종 분할이 비효율적이므로 — 병렬화가 오히려 느리게 만들 수 있습니다.
GO TO FULL VERSION