언어/JAVA

[JAVA] Java Stream의 병렬처리: 성능 향상의 비밀

shaprimanDev 2025. 2. 27. 06:45
반응형

 

Java 8에서 도입된 Stream API는 데이터 처리 방식에 혁신을 가져왔습니다. 특히 병렬 스트림(Parallel Stream) 기능은 멀티코어 프로세서의 성능을 최대한 활용할 수 있게 해주는 강력한 도구입니다. 현대 애플리케이션에서 대용량 데이터 처리가 일상화된 지금, 병렬 처리의 중요성은 더욱 커지고 있습니다. 과연 병렬 스트림은 어떤 상황에서 효과적일까요? 일반 스트림과 비교해 얼마나 성능 향상을 가져올 수 있을까요?

1. 병렬 스트림의 기본 개념

병렬 스트림은 데이터를 여러 청크(chunk)로 분할하여 각각 다른 스레드에서 처리한 후 결과를 합치는 방식으로 작동합니다. Java의 Fork/Join 프레임워크를 기반으로 하여 복잡한 멀티스레드 프로그래밍 없이도 간단하게 병렬 처리를 구현할 수 있습니다.

일반 스트림과 병렬 스트림의 가장 큰 차이점은 코드 한 줄의 차이로 나타납니다:

// 일반 스트림
List<Integer> numbers = Arrays.asList(1, 2, 3, 4, 5, 6, 7, 8, 9, 10);
int sum = numbers.stream()
                .filter(n -> n % 2 == 0)
                .mapToInt(Integer::intValue)
                .sum();

// 병렬 스트림
int parallelSum = numbers.parallelStream()
                        .filter(n -> n % 2 == 0)
                        .mapToInt(Integer::intValue)
                        .sum();

위 예제에서 볼 수 있듯이, 일반 stream()parallelStream()으로 변경하거나 .parallel() 메서드를 호출하는 것만으로 병렬 처리가 가능해집니다.

2. 병렬 스트림의 내부 작동 원리

병렬 스트림이 어떻게 작동하는지 이해하기 위해서는 Fork/Join 프레임워크에 대한 기본 지식이 필요합니다. 이 프레임워크는 작업을 재귀적으로 작은 단위로 분할하고(fork), 각 단위 작업의 결과를 합치는(join) 방식으로 동작합니다.

병렬 스트림의 처리 과정은 다음과 같습니다:

  1. 데이터 소스를 여러 청크로 분할
  2. 각 청크를 별도의 스레드에서 처리
  3. 처리된 결과를 합치기

예를 들어, 큰 배열의 모든 요소를 제곱하는 작업을 병렬로 처리한다고 가정해 보겠습니다:

int[] numbers = IntStream.rangeClosed(1, 10_000_000).toArray();

// 순차 처리
long startTime1 = System.currentTimeMillis();
Arrays.stream(numbers)
      .map(n -> n * n)
      .sum();
long endTime1 = System.currentTimeMillis();

// 병렬 처리
long startTime2 = System.currentTimeMillis();
Arrays.stream(numbers)
      .parallel()
      .map(n -> n * n)
      .sum();
long endTime2 = System.currentTimeMillis();

System.out.println("순차 처리 시간: " + (endTime1 - startTime1) + "ms");
System.out.println("병렬 처리 시간: " + (endTime2 - startTime2) + "ms");

이 예제를 실행하면 대부분의 현대 컴퓨터에서 병렬 처리가 순차 처리보다 훨씬 빠르게 완료됩니다. 8코어 CPU에서는 이론적으로 최대 8배의 성능 향상이 가능합니다(실제로는 오버헤드로 인해 그보다 낮은 향상을 보입니다).

3. 병렬 스트림의 적절한 사용 시나리오

모든 상황에서 병렬 스트림이 좋은 성능을 보장하지는 않습니다. 병렬 처리는 다음과 같은 경우에 효과적입니다:

  1. 데이터 크기가 충분히 큰 경우: 작은 데이터셋에서는 병렬화 오버헤드가 성능 이득보다 클 수 있습니다.
  2. 작업이 계산 집약적인 경우: 간단한 작업보다 복잡한 계산이 필요한 작업에서 더 효과적입니다.
  3. 데이터 소스가 쉽게 분할 가능한 경우: ArrayList와 같은 자료구조는 효율적으로 분할되지만, LinkedList는 그렇지 않습니다.

다음은 대용량 데이터에서 소수를 찾는 예제입니다:

long n = 10_000_000;

// 순차 처리
long startTime1 = System.currentTimeMillis();
long count1 = LongStream.rangeClosed(2, n)
                      .filter(ParallelStreamDemo::isPrime)
                      .count();
long endTime1 = System.currentTimeMillis();

// 병렬 처리
long startTime2 = System.currentTimeMillis();
long count2 = LongStream.rangeClosed(2, n)
                      .parallel()
                      .filter(ParallelStreamDemo::isPrime)
                      .count();
long endTime2 = System.currentTimeMillis();

System.out.println("소수 개수: " + count1);
System.out.println("순차 처리 시간: " + (endTime1 - startTime1) + "ms");
System.out.println("병렬 처리 시간: " + (endTime2 - startTime2) + "ms");

// 소수 판별 메서드
public static boolean isPrime(long n) {
    if (n <= 1) return false;
    if (n <= 3) return true;
    if (n % 2 == 0 || n % 3 == 0) return false;

    for (long i = 5; i * i <= n; i += 6) {
        if (n % i == 0 || n % (i + 2) == 0) return false;
    }
    return true;
}

이 예제에서는 소수 판별이라는 계산 집약적인 작업과 큰 데이터셋을 사용하기 때문에 병렬 처리의 이점이 뚜렷하게 나타납니다.

4. 병렬 스트림 사용 시 주의사항

병렬 스트림을 사용할 때는 다음과 같은 주의사항을 고려해야 합니다:

  1. 상태 공유 최소화: 여러 스레드가 동시에 접근하는 상태 변수는 경쟁 조건(race condition)을 유발할 수 있습니다.
// 잘못된 예제
List<Integer> numbers = Arrays.asList(1, 2, 3, 4, 5, 6, 7, 8, 9, 10);
int[] sum = {0}; // 공유 상태
numbers.parallelStream().forEach(n -> sum[0] += n); // 경쟁 조건 발생!
System.out.println("합계: " + sum[0]); // 결과가 일관되지 않을 수 있음

// 올바른 예제
int correctSum = numbers.parallelStream().reduce(0, Integer::sum);
System.out.println("올바른 합계: " + correctSum);
  1. 순서 의존적 연산 주의: findFirst(), limit() 같은 순서에 의존하는 연산은 병렬 처리에서 성능이 저하될 수 있습니다.
// 순서 의존적 연산의 예
List<Integer> numbers = IntStream.rangeClosed(1, 1_000_000).boxed().collect(Collectors.toList());

// 병렬 스트림에서 순서 의존적 연산은 비효율적일 수 있음
Optional<Integer> firstEven = numbers.parallelStream()
                                 .filter(n -> n % 2 == 0)
                                 .findFirst(); // 병렬 처리에서 비효율적
  1. 결정적 연산자 사용: 병렬 처리에서는 연산의 순서가 보장되지 않으므로, 비결정적 연산(예: forEach)보다 결정적 연산(reduce, collect 등)을 사용하는 것이 안전합니다.
// 비결정적 연산의 예
List<String> names = Arrays.asList("Tom", "Jerry", "Mickey", "Donald");
names.parallelStream().forEach(System.out::println); // 출력 순서가 매번 다를 수 있음

// 결정적 연산의 예
List<String> sortedNames = names.parallelStream()
                             .sorted()
                             .collect(Collectors.toList()); // 결과는 항상 동일

5. 성능 측정 및 최적화

병렬 스트림의 실제 성능 이점을 확인하려면 정확한 성능 측정이 필요합니다. Java의 JMH(Java Microbenchmark Harness)를 사용하면 보다 정확한 성능 테스트가 가능합니다.

다음은 간단한 성능 측정 예제입니다:

List<Integer> numbers = IntStream.rangeClosed(1, 10_000_000).boxed().collect(Collectors.toList());

// 워밍업 (JIT 컴파일러 최적화를 위함)
for (int i = 0; i < 5; i++) {
    numbers.stream().reduce(0, Integer::sum);
    numbers.parallelStream().reduce(0, Integer::sum);
}

// 성능 측정
long start1 = System.nanoTime();
long sum1 = numbers.stream().reduce(0, Integer::sum);
long duration1 = (System.nanoTime() - start1) / 1_000_000;

long start2 = System.nanoTime();
long sum2 = numbers.parallelStream().reduce(0, Integer::sum);
long duration2 = (System.nanoTime() - start2) / 1_000_000;

System.out.println("순차 스트림: " + duration1 + "ms");
System.out.println("병렬 스트림: " + duration2 + "ms");
System.out.println("속도 향상: " + (double)duration1 / duration2 + "배");

이러한 측정을 통해 실제 애플리케이션에서 병렬 처리가 얼마나 효과적인지 확인할 수 있습니다.

6. 실제 활용 사례

병렬 스트림은 다양한 실제 시나리오에서 활용될 수 있습니다:

  1. 대용량 파일 처리: 대용량 로그 파일을 분석하거나 변환할 때 유용합니다.
// 대용량 파일의 각 라인을 병렬로 처리
Path filePath = Paths.get("huge_log_file.txt");
try {
    long errorCount = Files.lines(filePath)
                        .parallel()
                        .filter(line -> line.contains("ERROR"))
                        .count();
    System.out.println("에러 발생 횟수: " + errorCount);
} catch (IOException e) {
    e.printStackTrace();
}
  1. 데이터 변환 작업: 대량의 데이터를 한 형식에서 다른 형식으로 변환할 때 효과적입니다.
// 사용자 객체 리스트를 DTO로 변환
List<User> users = userRepository.findAll(); // 수천 개의 사용자 데이터
List<UserDTO> userDTOs = users.parallelStream()
                          .map(user -> convertToDTO(user))
                          .collect(Collectors.toList());

// 변환 메서드 (계산 비용이 높다고 가정)
private UserDTO convertToDTO(User user) {
    // 복잡한 변환 로직
    return new UserDTO(user.getId(), user.getName(), calculateRating(user));
}
  1. 이미지 처리: 대량의 이미지를 리사이징하거나 필터링하는 작업을 병렬화할 수 있습니다.

이러한 실제 사례들에서 병렬 스트림은 상당한 성능 향상을 가져올 수 있습니다.


Java Stream의 병렬 처리 기능은 멀티코어 환경에서 애플리케이션의 성능을 크게 향상시킬 수 있는 강력한 도구입니다. 적절한 사용 사례를 선택하고 주의사항을 고려한다면, 최소한의 코드 변경으로 최대의 성능 이득을 얻을 수 있습니다. 앞으로 하드웨어의 다중 코어 확장 추세에 따라 병렬 프로그래밍의 중요성은 더욱 커질 것입니다.


[전문용어]

  • [Stream API]: Java 8에서 도입된 컬렉션 데이터를 함수형으로 처리할 수 있는 기능
  • [병렬 스트림]: 데이터를 여러 스레드에서 병렬로 처리하는 스트림
  • [Fork/Join 프레임워크]: 재귀적으로 작업을 분할하고 결과를 합치는 병렬 처리 프레임워크
  • [경쟁 조건]: 여러 스레드가 공유 데이터에 동시에 접근하여 발생하는 버그
  • [JIT 컴파일러]: 자바 바이트코드를 런타임에 기계어로 변환하는 컴파일러
  • [JMH]: Java Microbenchmark Harness, 자바 코드의 성능을 정밀하게 측정하는 도구

 

반응형