본문 바로가기
Book/모던 자바 인 액션

[모던 자바 인 액션] Chapter 07. 병렬 데이터 처리와 성능 (1)

by ghan2 2024. 10. 29.
💡 이 장의 내용
- 병렬 스트림으로 데이터를 병렬 처리하기
- 병렬 스트림의 성능 분석
- 포크/조인 프레임워크
- Spliterator로 스트림 데이터 쪼개기

 

7장부터 어려워진다고는 들었는데 확실히,, 읽어도 잘 이해가 되지 않는 부분이 많았다. 

좀 더 찾아보면서 정리해야겠다.

 

이 장에서는 스트림으로 데이터 컬력센 관련 동작을 얼마나 쉽게 병렬로 실행할 수 있는지 설명한다. 스트림을 이용하면 순차 스트림을 병렬 스트림으로 자연스럽게 바꿀 수 있다. 

 

7.1 병렬 스트림

병렬 스트림이란 각각의 스레드에서 처리할 수 있도록 스트림 요소를 여러 청크로 분할한 스트림이다. 따라서 병렬 스트림을 이용하면 모든 멀티코어 프로세서가 각각의 청크를 처리하도록 할당할 수 있다. 

 

예시를 들어 설명하고 있다.

숫자 n을 인수로 받아서 1부터 n까지의 모든 숫자의 합계를 반환하는 메서드를 구현한다고 해보자.
public long sequentialSum(long n) {
	return Stream.iterate(1L, i -> i + 1)
    	.limit(n)
        .reduce(0L, Long::sum);
}

 

7.1.1) 순차 스트림을 병렬 스트림으로 변환하기

숫자 스트림에 parallel 메서드를 호출하면 기존의 함수형 리듀싱 연산이 병렬로 처리된다. 

public long parallelSum(long n) {
	return Stream.iterate(1L, i -> i + 1)
    	.limit(n)
        .parallel()
        .reduce(0L, Long::sum);
}

이 코드를 실행하면 스트림은 여러 청크로 분할되어 수행한다. 리듀싱 연산으로 생성된 부분 결과를 다시 리듀싱 연산으로 합쳐서 전체 스트림의 리듀싱 결과를 도출한다. 

이해를 돕는 그림..

💡 순차/병렬 여부는 마지막 호출에 달려있다 !
순차로 실행할 때는 sequential(), 
병렬로 실행할 때는 paralle()
이 메서드는 호출될 때 내부적으로 불리언 플래그가 활성화된다. 따라서 두 메서드를 모두 호출했더라도 마지막 호출이 paralle()이라면 파이프라인은 전체적으로 병렬로 실행된다. 

 

7.1.2) 스트림 성능 측정

병렬화를 이용하면 순차나 반복 형식에 비해 성능이 더 좋아질 것이라 추측했다. 하지만 소프트웨어 공학에서 추측은 위험한 방법이다. 특히 성능을 최적화할 때는 다음 세 가지 황금 규칙을 기억해야 한다고 한다.

첫째도 측정, 둘째도 측정, 셋째도 측정!

 

측정을 위해 JMH(Java Microbenchmark Harness)라는 라이브러리를 사용한다. 어노테이션 기반으로 간단하고 안정적으로 자바 프로그램이나 JVM을 대상으로 하는 다른 언어용 벤치마크를 구현할 수 있다. 

 

gradle 플러그인을 추가해준다. [여기 참고 https://github.com/melix/jmh-gradle-plugin]

plugins {
    id 'java'
    id "me.champeau.jmh" version "0.7.1"
}

 

벤치마크 클래스는 src/main/java에 만드는 것이 아니라 src/jmh/java에 만들어야 한다. (이걸 몰랐다..)

@State(Scope.Benchmark)
@BenchmarkMode(Mode.AverageTime) // 벤치마크 대상 메서드를 실행하는 데 걸린 평균 시간 측정
@OutputTimeUnit(TimeUnit.MILLISECONDS) // 벤치마크 결과를 밀리초 단위로 출력
@Fork(value = 2, jvmArgs = {"-Xms4G", "-Xmx4G"}) // 4Gb의 힙 공간을 제공한 환경에서 두 번 벤치마크를 수행해 결과의 신뢰성 확보
public class ParallelStreamBenchmark {
    private static final long N = 10_000_000L;

    @Benchmark // 벤치마크 대상 메서드
    public long sequentialSum() {
        return Stream.iterate(1L, i -> i + 1)
                .limit(N)
                .reduce(0L, Long::sum);
    }

    @TearDown(Level.Invocation) // 매 번 벤치마크를 실행한 다음에는 가비지 컬렉터 동작 시도
    public void tearDown() {
        System.gc();
    }
}
// 실행
.gradle jmh

 

다음과 같은 실행 결과가 나왔다. 

순차 스트림 - 실행 결과

순차 스트림을 하는 것 보다 전통적인 for 루프를 사용하는 것이 더 저수준으로 동작할 뿐 아니라 특히 기본값을 박싱하거나 언박싱할 필요가 없으므로 더 빠를 것 같다. 다음은 for문의 실행결과다.

 

for문 - 실행 결과

 

차이가 .. 왜 이렇게 많이나지..? 어쨌든... for문으로 돌리는 편이 훨씬 빠르다 !

 

그렇다면 병렬로 처리한다면 결과는 어떨까?

병렬 스트림 - 실행 결과

책과 결과가 달라서 당황스러운데.. 🫠 교재에서는 순차스트림 (121.843), for문(3.278), 병렬 스트림(604.059)의 결과가 나왔다. 이런 iterate와 같은 반복 작업은 병렬로 수행할 수 있는 독립 단위로 나누기 어렵다.

  • 반복 결과로 박싱된 객체가 만들어지므로 숫자를 더하려면 언박싱을 해야 한다.
  • 반복 작업은 병렬로 수행할 수 있는 독립 단위로 나누기가 어렵다.

이 두 가지 문제점이 있어서 여기서 말하고자 하는 바는 무조건적인 병렬 스트림 선택은 위험할 수 있으며, 내부적으로 어떤 일이 일어나는지 꼭 이해하고 잘 고려해서 사용해야 한다는 것이다. 

 

더 특화된 메서드를 사용하라 !

멀티코어 프로세서를 활용해서 효과적으로 합계 연산을 병렬로 실행하려면 어떻게 해야 할까?

  • LongStream.rangeClosed는 기본형 long을 직접 사용하므로 박싱과 언박싱 오버헤드가 사라진다.
  • LongStream.rangeClosed는 쉽게 청크로 분할할 수 있는 숫자 범위를 생산한다 ! (1~20 이라면 1~5, 6~10 식으로 분할할 수 있다.)

아주 빨라졌다!

 

따라서 올바른 자료구조를 선택해야 병렬 실행도 최적의 성능을 발휘할 수 있다. 그러나 병렬화는 항상 공짜가 아니라는 것을 기억하자. 병렬화를 이용하려면 스트림을 재귀적으로 분할해야 하고, 각 서브스트림을 서로 다른 스레드의 리듀싱 연산으로 할당하고, 이들 결과를 하나로 합쳐야 한다. 

 

7.1.3) 병렬 스트림의 올바른 사용법

병렬 스트림을 잘못 사용하면서 발생하는 많은 문제는 공유된 상태를 바꾸는 알고리즘을 사용하기 때문에 일어난다. 

// n 까지의 자연수를 더하면서 공유된 누적자를 바꾸는 프로그램
public long sideEffectSum(long n) {
	Accumulator accumulator = new Accumulator();
    LongStream.rangeClosed(1, n).forEach(accumulator::add);
    
    return accumulator.total;
}

public class Accumulator {
	public long total = 0;
    public void add(long value) {
    	total += value;
    }
}

이 연산을 병렬로 실행한다면 어떻게 될까? 

total += value는 얼핏 보면 아토믹 연산 같지만 그렇지 않다. 결국 여러 스레드에서 공유하는 객체의 상태를 바꾸는 forEach 블록 내부에서 add 메서드를 호출하면서 문제가 발생한다. 

이 예제처럼 병렬 스트림이 올바로 동작하려면 공유된 가변 상태를 피해야 한다는 사실을 기억하자.

 

7.1.4) 병렬 스트림 효과적으로 사용하기

  • 확신이 서지 않으면 직접 측정하라
  • 박싱을 주의하라. 되도록이면 기본형 특화 스트림을 사용하는 것이 좋다.
  • 순차 스트림보다 병렬 스트림에서 성능이 떨어지는 연산이 있다. (순서에 의존하는 경우 특히)
  • 스트림에서 수행하는 전체 파이프라인 연산 비용을 고려하라.
  • 소량의 데이터에서는 병렬 스트림이 도움 되지 않는다 !
  • 스트림을 구성하는 자료구조가 적절한지 확인하라
  • 스트림의 특성과 파이프라인의 중간 연산이 스트림의 특성을 어떻게 바꾸는지에 따라 분해 과정의 성능이 달라질 수 있다. 
  • 최종 연산의 병합 과정 비용을 살펴보라.