CodeGym /행동 /JAVA 25 SELF /Spliterator와 병렬 스트림

Spliterator와 병렬 스트림

JAVA 25 SELF
레벨 33, 레슨 3
사용 가능

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를 사용해) 여러 부분으로 나누고, 각 부분을 별도의 스레드에서 처리합니다.

내부에서는 어떻게 동작할까?

  1. Spliterator가 컬렉션을 여러 부분으로 나눕니다 — 보통 사용 가능한 코어 수(혹은 그보다 약간 더) 정도로요.
  2. 각 부분은 자신의 스레드에서 처리됩니다 — 공용 ForkJoinPool이 사용됩니다.
  3. 결과는 다시 모여 최종 컬렉션 또는 값으로 합쳐집니다.

병렬 스트림 동작 다이어그램

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는 종종 분할이 비효율적이므로 — 병렬화가 오히려 느리게 만들 수 있습니다.

1
과제
JAVA 25 SELF, 레벨 33, 레슨 3
잠금
창고에 들어온 첫 번째 소포 처리 📦
창고에 들어온 첫 번째 소포 처리 📦
1
과제
JAVA 25 SELF, 레벨 33, 레슨 3
잠금
전략 회의에서 팀 간 작업 분배 🎯
전략 회의에서 팀 간 작업 분배 🎯
코멘트
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION