Java Stream API는 왜 for-loop보다 느릴까?

The Korean Commentary on ‘The Performance Model of Streams in Java 8" by Angelika Langer

Java 8에서는 Stream API가 추가되었는데, 이는 Iterator를 쓰는 이전 Java 5 및 Collection에 forEach가 추가된 Java 6 이후 메이저 업데이트이다.

추석 연휴 어느 날, 카카오의 2021 공채 문제 중 순위 검색 문제를 풀고 있었다. 스트림으로 문제를 풀었는데 정확성 테스트는 통과하지만 효율성 테스트는 통과하지 못했다. 대체 무엇이 문제인 것일까?

문득 생각이 났다. 코드스쿼드 과정 중, 나는 "스트림 성애자" 라고 불릴 정도로 모든 Spring Boot 프로젝트에 Stream API를 적극적으로 도입했다. Stream을 사용하면 코드의 가독성이 좋고, 유지보수에 탁월하다는 생각이었다. 그런데 다른 동료는 Stream이 속도가 느리다며, 대규모 트래픽을 감당할 때는 오히려 traditional for-loop 스타일이 좋다고 본인의 입장을 견고히 했다. 순위검색 문제에서 효율성 테스트를 통과하지 못하자, 1년 반 전의 기억이 생각났다. 그렇게 다시 Java Collection 인터페이스에 구현된 for-loop으로 리팩토링했고, 효율성 테스트에서 통과할 수 있었다. 그렇게 의문을 품게 되었다. 왜 Java의 스트림 API는 for-loop보다 느리다고 하는 것일까?

다행히도 참고할 만한 좋은 자료가 있었다. Effective Java의 공저자인 Angelika Langer가 JAX London 2015에서 발표했던 'The Performance Model of Streams in Java 8" 이라는 발표가 있다. 해당 발표 내용을 중점으로, 본인이 궁금해하는 지점을 추가해서 글을 풀어나가도록 하겠다. 발표자료 파일은 다음에 있다.

스트림이 무엇인가?

스트림은 함수형 프로그래밍 언어에서 이야기하는 sequence와 동일한 용어이다. Sequence는 task의 순서를 나열한 것이다. 예를 들어, 여러분이 군부대에서 식기 당번을 맡았다고 하자. 그러면 식기 당번이 해야 할 일을 sequence로 나열하면 다음과 같다고 가정해보자.

# 대대 식기 당번이 해야 할 일
1. 밥차에서 밥을 받아온다. deliverMeal();
2. 사람들이 다 먹은 식기를 설거지한다. washDishes();
3. 사람들이 먹다 남긴 음식물 쓰레기를 짬통에 버린다. throwTrash();

밥을 가져오지도 않았는데 (1번), 음식물 쓰레기를 짬통에 버린다거나(3번), 식기를 설거지하는 것은 (2번) 상식적으로 말이 되지 않는 소리다. 밥차에서 밥을 받아오고, 사람들이 밥을 다 먹고 나면 그 식기를 설거지하고, 사람들이 먹다 남긴 쓰레기를 버려야 한다. 이렇게 정해진 일을 순서대로 처리하는 것은 중요한 일이다. (그렇게 하지 않았다면, 선임병이나 선임부사관에게 혼이 날 것이다.) 이러한 순서, 즉 Sequence대로 일을 처리하라고 함수를 파라미터로 넘기는 행위를 우리는 Sequential Programming이라고 부른다.

객체지향 프로그래밍 관점에서는 Internal Iterator Pattern, 즉 내부 반복자 패턴이라고 명명할 수 있겠다. GOF 디자인 패턴 책에서 등장하는 개념인데,컬렉션 내부에서 요소들을 반복시키고 개발자는 요소당 처리해야할 코드만 제공하는 코드 패턴 이라고 한다. 즉, 처리해야 할 함수를 제공하면 Collection을 순회하는 것을 외부에서 하는 것이 아니라 (External Iterator), Stream을 만들어서 스트림 내부에서 순회하는 것이다. 외부 Client 입장에서 Iterator를 관리할 것인가, 아니면 Iterator가 스스로 iterating하는 로직을 관리할 것인가의 차이로 보인다.

스트림은 독자 여러분도 잘 알다시피 Object에 method chaining을 하며 사용한다. 우리는 이것을 보고 Fluent Programming 또는 Fluent API라고 부른다. 이에 따라 한 번 식기당번이 해야 할 일을 코드로 옮겨보겠다.

// 식기당번을 리스트로 갖고 있는 오브젝트
List<OnMealDuty> onMealDuties = new ArrayList<>();
// List Collection을 기반으로 Stream을 생성
onMealDuties.stream().filter(duty -> duty.grade == "일병")
.map(duty -> {
duty.deliverMeal();
duty.washDishes();
duty.throwTrash();
};

위에서 보는 filter 함수는 식기당번 중 일병만을 찾고 있다. filter 함수는 스트림에 포함되는 원소를 하나씩 확인하면서 조건에 따라 true인 원소만 스트림에 남겨둔다. 스트림을 반환하는 함수를 중간 연산(intermediate operations)라고 부른다. 이와 달리 map 함수는 Stream이 아니라, 최종 결과물인 map을 반환하는 최종 연산(terminal operation)이다.

terminal execution이 실행되면, 그제서야 intermediate execution이 일어난다.

스트림을 반환한다는 것은 연산의 파이프라인(pipeline)을 반환한다는 의미이다. 스트림은 lazy한데, 매번 중간 연산마다 조건을 실행하지 않는다. 대신, 중간 연산마다 연산의 파이프라인을 리턴한다. 최종 연산 과정에 들어와서야 이전 중간 연산들을 모두 합친 후에야 이를 이용해서 최종 연산에 돌입한다. 자료구조의 구현체는 Collection과 달리 Stream은 실제 데이터를 저장하지 않으니, 데이터에 접근할 수 있는 방법이 없다. 대신, Stream은 자료구조를 어떻게 '다룰 지' 를 논한다. 이미 존재하는 자료구조 내에서 새로운 스트림을 생성할 뿐이지, 기존 데이터를 수정하거나 바꾸지 않는다.

우리의 오늘 주제는 for-loop으로 순회하는 것이 Stream보다 빠르다는데, 정말 그런 것인지 그렇다면 왜 그런 것인지 알아보는 것이다. Langer씨는 강연에서 loop와 순차 스트림(sequential stream), 그리고 병렬 스트림(parallel stream) 별로 퍼포먼스가 어떤지 벤치마크 실험을 했다. 강연자가 사용한 벤치마크 기기는 Intel E8500 (2 x 3.17GHZ, 4GB RAM), JDK 1.8이었다. 결과가 어땠을까?

Match 1: for-loop vs 순차 스트림

벤치마크를 위해 총 500000개의 primitive type int를 저장하는 배열을 하나 만들고, 배열에서 가장 큰 원소를 찾는 함수를 각각 for-loop와 순차 스트림으로 만들어보자.

// for-loop
int[] a = ints;
int e = ints.length;
int m = Integer.MIN_VALUE;
for (int i = 0; i < e; i++) {
if (a[i] > m) {
m = a[i];
}
}
// sequential stream
int m = Arrays.stream(ints).reduce(Integer.MIN_VALUE, Math::max);

결과는 for-loop의 압도적인 승리였다. for-loop은 0.36ms가 소요된 반면, 순차 스트림은 5.35ms가 소요되었다. 약 15배 정도 빠른 것이다. 스트림이 유독 느린 이유는 무엇일까? Langer씨는 JIT Compiler가 for-loop을 워낙 40년 이상 다뤄왔다 보니까 for-loop에 대한 internal optimization이 이미 잘 되어 있다고 말한다. 하지만 스트림은 2015년 이후에 도입되었으므로 아직 컴파일러가 최적화를 제대로 하지 못했다는 것이다.

그렇다면 자바 스트림을 아예 쓰지 마라는 것인가? 그렇지 않다. 다음 예시에서는 primitive type이 아니라 wrapped type으로 확인해보고자 한다. ArrayList를 하나 만들고, 500000개의 Integer 타입을 저장하도록 만들자. 이후, Integer 중 가장 큰 원소를 리턴하도록 하자. 결과는 어땠을까?

차이가 눈에 띄게 줄었음을 확인할 수 있다. for-loop은 6.55ms가 걸린 반면, sequential stream은 8.33ms가 소요되었다. for-loop이 약 1.27배 정도밖에 빠르지 않은 것이다. 그 이유는 무엇일까? ArrayList를 순회하는 비용 자체가 워낙 크기 때문에, for-loop와 stream 간의 성능 차이를 압도해버리는 것이다. ArrayList에서 모든 원소를 순회하는 데 걸리는 시간 복잡도는 O(n)이다. wrapped type은 primitive type과 달리 stack이 아닌 heap 메모리 영역에 저장된다.

JVM 상에서 Heap Memory에 저장되는 간접 참조는 힙에 객체의 내용이 있다면 중간에 그곳을 가리키는 메모리 영역이 있고(핸들 메모리) 스택에 있는 변수가 핸들 메모리를 참조해서 실제 힙의 주소를 얻은 후 힙의 실제 내용을 참조하게 되는 방식이다. 반면, JVM 상에서 Stack Memory에 저장되는 직접 참조의 경우 스택에 있는 변수가 힙 메모리의 실제 주소를 가지고 있어 직접 참조 하게 되는 방식이다. 그러므로 자주 참조되는 변수나 객체가 있다면 직접 참조 방식이 훨씬 뛰어난 성능을 보인다.

즉, int와 같은 primitive type은 stack에 저장되어 JVM 상에서 직접 참조로 값을 바로 불러올 수 있지만, wrapped type은 heap에 저장되어 간접적으로 주소를 가져와 값을 참조해야 한다. 간접 참조하는 비용이 직접 참조하는 비용보다 훨씬 비싸기 때문에, iteration cost 자체가 높다는 뜻이고 결국 for-loop의 컴파일러 최적화 이점이 사라진다는 것이다.

지금까지 우리가 살펴본 예제는 순회비용(cost of iteration)이 계산비용(cost of functionality)보다 높은 것이었다. wrapped type을 heap memory에 간접 참조하여 값을 가져오는 것은, 단순히 두 숫자 간의 크기 비교를 하는 것보다 훨씬 비싼 비용이다. 만약 원소를 단순히 순회하는 비용보다 원소 하나하나에 대한 계산비용이 훨씬 높다면 어떨까? 즉, 스트림 내부에 개발자가 복잡한 로직을 담은 함수를 구현해서 계산 비용이 높다면, 결과값은 달라지게 될까?

아주 비싼 계산비용이 소모되는 함수 예시를 들기 위해 아파치 라이브러리에서 slowSin()을 들고 왔다. 파라미터로 넘겨지는 메서드에 대하여 사인함수 값을 계산하고, 이에 대한 테일러 급수를 계산하는 함수이다. int 타입에 대한 array를 하나 만들고, 10000개의 원소를 순회하면서 각각의 원소에 slowSin() 함수를 계산해보자. 이와 마찬가지로, Integer 타입에 대한 ArrayList를 하나 만들고, 같은 과정을 적용해보자. 결과는 어떻게 되었을까?

// for-loop
int[] a = ints;
int e = a.length;
double m = Double.MIN_VALUE;
for (int i = 0; i < e; i++) {
double d = Sine.slowSin(a[i]);
if (d > m) m = d;
}
// sequential stream
Arrays.stream(ints).mapToDouble(Sine::slowSin).reduce(Double.MIN_VALUE, Math::max);

결과는 놀라웠다. 더 이상 for-loop이 빠르지 않다! slowSin() 함수 계산 비용이 순회 비용을 압도하는 것이다. 이를 통하여 우리는 함수 내부의 시간 복잡도가 충분히 크다면, stream을 사용하는 것이 for-loop에 대비하여 속도 손실이 없을 것임을 확인할 수 있다. Langer씨도 iteration cost와 functionality cost의 합이 충분히 클 때, 순차 스트림의 속도는 for-loop와 가까워진다라고 말한다.

Langer씨 외에도 for-loop와 순차 스트림의 성능을 비교한 분이 계시다. 해당 링크를 참조하면서 교차검증을 해보기를 추천한다.

Match 2: 순차 스트림 vs 병렬 스트림

스트림 이야기를 꺼냈으니, 병렬 스트림도 언급하지 않을 수 없다. 사람들은 일반적으로 병렬 스트림이 순차 스트림보다 빠르다고 생각한다. 하나의 프로세스에 대하여 여러 개의 쓰레드를 적용할 수 있기 때문이다. 일단 병렬 스트림은 그 자체로 순차적일 수밖에 없는(inherently sequential) for-loop보다는 확실히 빠르다. 그렇다면 병렬 스트림은 순차 스트림보다 속도 측면에서 우위일까?

속도에 대한 논의를 이어가기 전에, 순차 스트림과 병렬 스트림이 무엇인지에 대해 알아 보아야겠다. 순차 스트림은 하나의 쓰레드에서 모든 반복(Iteratior)을 수행하는 것을 의미한다. 순차 스트림은 싱글 스레드를 사용하기 때문에 CPU 코어 자원을 마음껏 활용하지 못하는 대신, 공유 자원 이슈를 고민할 필요가 없다.

이와 반대로, 병렬 스트림은 여러 개의 쓰레드에서 반복을 나누어 수행하는 것이다. 멀티 쓰레드이므로 공유자원 동기화 문제가 발생한다. 만약 mutate state가 있는 객체가 있다면, 각 코어 별로 할당된 쓰레드가 동일한 메모리 영역에 접근하여 값을 서로 바꾸려고 할 것이다.

Java의 멀티 쓰레드 구현체인 parallelStream()은 thread-safe를 보장하지 않기 때문에 작업하는 개발자의 입장에서 별도의 처리가 필요하다. 순차 스트림과 병렬 스트림의 차이에 대해 더 깊이 알고 싶다면 다음 링크를 참고하라.

사진 출처: developers.com

Java 8에서는 병렬 스트림을 위하여 parallelStream() API를 제공한다. 스트림에 .parallel()을 붙여주면, 병렬 스트림을 사용하겠다는 선언이 된다. parallel()은 fork-join framework를 바탕으로 하나의 쓰레드를 재귀적으로 특정 depth까지 반복하며 여러 개의 쓰레드로 잘게 나눈 뒤, 자식 쓰레드의 결과값을 취합하여 최종 쓰레드의 결과값을 만들어 리턴하는 방식으로 구현되었다.

Fork-join 방식에 대해 이해하기 위하여, 다음 링크에서 설명한 내용의 일부를 참조하겠다. 숨겨져 있는 좋은 글이라고 생각하고 원문을 확인해보기 바란다.

Step 1: 폴더 안에서 파일을 읽어서 ‘사랑’ 이라는 단어가 몇 개 있는지 확인해 보기 위해, 폴더 3개를 감싸고 있는 부모 폴더를 Job으로 만들어 보낸다.

Step 2. 쓰레드 A는 부모 잡을 받아서 자신의 로컬 큐에 1차 분할한다. 쓰레드 B는 놀고 있기 때문에, A의 로컬 큐에서 잡을 가져와서 일을 한다. 이 때, 쓰레드 B는 본인이 가져온 Job을 보니, 양이 많아서 분할한다.
Step 3. 이런 식으로 세부적으로 분할해서 특정 개수의 리프 쓰레드가 모두 골고루 가져가서 일을 할 때까지 재귀적으로 반복된다.
Step 4. 모든 쓰레드가 일을 종료하는 시간이 비슷해진다.

예를 들어, 천 만개의 랜덤한 숫자(1~100사이)가 있다고 가정하자. 이 원소 중 10보다 작은 수가 몇 개가 있는 지 계산하는 프로그램을 실행한다고 가정하자. 이 때 CPU 코어는 4개이다.

방법 1. 그냥 싱글 쓰레드 하나로 천만번을 순회하면서 숫자를 센다. (6초 걸림)

방법 2. ForkJoinPool 을 사용해서 리프가 100개 일 때까지 분할(fork)해서 각각의 수치를 위로 합쳐서(join) 계산한다. 쓰레드 4개를 골고루 사용하며 대신 태스크 객체는 분할한 만큼 만들어진다. (2.5초 걸림)

방법 3. 그냥 ThreadPoolExecutor 로 쓰레드 4개를 만든 후에 각각 천만개/4 로 나뉘어진 영역에 대해 순회하면서 숫자를 계산해서 합친다. (2초 걸림) ForkJoinPool 보다 더 빠른데, 그 이유는 리프 쓰레드의 개수를 맞추기 위한 쓸 때 없는 객체 생성이 없어졌기 때문이다.

방법 4.앞선 방법 3처럼 쓰레드 4개가 거의 동일한 일을 하게 된다면 ForkJoinPool이 오히려 비효율적이다. 그런데 하나의 쓰레드가 굉장히 오래 걸리고 나머지 3개의 쓰레드는 금방 끝이나는 경우라면, ForkJoinPool이 훨씬 속도가 빠르다. (ThreadPoolExecutor 4초, ForkJoinPool 3초)

Fork-join은 앞서 언급했듯이 fork phase에서 리프 쓰레드의 갯수가 일정 수준만큼 도달할 때까지 재귀적으로 리프 쓰레드를 만든다. 위 장표는 fork하는 과정을 간단하게 표시한 것으로, 실제로는 이렇게 2단계 정도로 나누지 않는다. 리프 쓰레드의 각 태스크는 CPU 코어 개수 x 4까지 할당될 수 있다. 만약 듀얼코어 CPU라면 8개의 태스크가 각각의 쓰레드마다 하나씩 부여될 수 있는 것이다. 이후 리프 쓰레드가 일정 개수만큼 다 만들어지면 execution phase로 넘어가 자신에게 할당된 job을 실행한다. 이후 join phase에서는 리프 쓰레드가 리턴한 결과값을 상위 쓰레드로 넘기고, 상위 쓰레드는 이를 바탕으로 결과값을 더 상위 쓰레드에 넘기고 하면서 최종적으로 마지막 쓰레드가 하나의 결과값을 리턴한다.

리프 쓰레드 외에도 job을 fork하는 쓰레드와 리프 쓰레드의 결과값을 상위 쓰레드로 join하는 별도 쓰레드 2개가 있다.우리는 job을 split하고 원소를 iterate하는 spliterator라고 부르는데, splitter와 iterator를 합친 말이다. 각각의 스트림 소스가 되는 Collection은 소스 별로 고유한 spliterator 타입이 있다. spliterator는 어떻게 스트림 소스를 분리해야 하는지 알고, execution phase에서 어떻게 원소를 iterate해야 하는지 안다. 예를 들어, ArrayList에는 ArrayListSpliterator가 있다.

parallelStream은 Apache의 Common Pool을 사용하는데, fork-job framework의 구현체라고 이해하면 된다. Common Pool은 오브젝트 풀링의 대표적인 예시인데, 많은 수의 오브젝트가 생성되는 것을 방지하기 위하여 Pool에 오브젝트를 대출하고 반납하는 방식이라고 이해하면 된다. 이를 통하여 오브젝트 할당 횟수를 줄여 불필요한 오버헤드 비용을 줄일 수 있다. CPU 코어가 2개 이상일 때, 코어 하나는 최종 연산(terminal operation)을 실행하기 위한 쓰레드가 대기하고 있고 나머지 코어에 fork된 쓰레드를 분배한다. 하나의 쓰레드가 끝나면 동기적으로 다음 쓰레드로 결과값을 넘긴다.

만약에 병렬 스트림에 다음과 같은 코드가 주어졌다고 가정해보자. 중간 연산이 어떻게 작동될 지 생각해보자.

.parallelStream().filter(...) .mapToInt(...) .reduce((i,j) -> Math.max(i,j));

앞서 Stream을 Pipeline Programming이라고 명명한 적 있다. Execution Phase에 들어갔을 때 각각의 쓰레드는 filter, mapToInt, reduce를 lazy하게 실행한다. 즉, 실행 단계에 가서야 순차적으로 스트림으로 지정해두었던 연산 행위를 실행한다. 이후 임의의 쓰레드가 일을 끝마쳤을 때 그 결과값을 상위 쓰레드에 재귀적으로 넘기는 것이다.

병렬 쓰레드는 분명 순차 쓰레드보다 오버헤드 비용이 더욱 발생한다. fork-join task object를 만들고, job을 split하고, thread pool 스케줄링을 관리하고, common pool을 사용하여 오브젝트를 재사용하는 만큼 쓰지 않는 오브젝트를 정리하는 Garbage Collector 비용도 발생할 것이다. 이러한 오버헤드 비용을 감수하더라도 병렬 스트림을 쓰는 것이 우위에 있을 때 parallelStream을 사용해야 할 것이다.

이제 순차 스트림과 병렬 스트림의 성능을 확인해보자. 첫 번째 예시를 똑같이 사용할 것이다. 500000개의 숫자가 들어있고 가장 큰 원소를 찾는 것이다. int 타입이 들어간 Array와 Integer 타입이 들어간 ArrayList 모두에 대하여 확인해볼 것이다. 결과는 어떻게 되었을까?

// sequential stream
int m = Arrays.stream(ints).reduce(Integer.MIN_VALUE, Math::max);
int m = myCollection.stream().reduce(Integer.MIN_VALUE, Math::max);
// parallel stream
int m = Arrays.stream(ints).parallel().reduce(Integer.MIN_VALUE, Math::max);
int m = myCollection.parallelStream().reduce(Integer.MIN_VALUE, Math::max);

결과는 예상대로 병렬 스트림이 순차 스트림보다 빨랐지만, 그 차이가 드라마틱하지 않다. 그 이유는 이전에서 보았듯이 계산 비용이 작아서 쓰레드를 나누는 비용이 훨씬 비싸기 때문이다. 심지어 LinkedList를 사용한 경우에는 병렬 스트림이 오히려 느렸다. 그 이유는 무엇일까? LinkedList는 job을 split하기 어렵기 때문이다. 약 50만번 iterator를 사용해서 next()를 하며 값을 일일이 참조해야 하는데 순회 비용이 비싼 것이다. 이와 달리 ArrayList는 index를 통해 List 값에 접근할 수 있다. 아무튼 이번 예시 역시 computing cost가 크지 않은 연산이었다. 이전 예시처럼 slowSin() 함수를 도입하여 컴퓨팅 연산비용을 높여보자.

// for-loop
Arrays.stream(ints).parallel().mapToDouble(Sine::slowSin ) .reduce(Double.MIN_VALUE, (i, j) -> Math.max(i, j);
// collection
myCollection.parallelStream().mapToDouble(Sine::slowSin ) .reduce(Double.MIN_VALUE, (i, j) -> Math.max(i, j);

어떤가? 병렬 스트림이 순차 스트림이 1.8배 정도 빨랐다. 벤치마크 환경이 CPU 듀얼코어 임을 생각하면 2배 정도 빨라야 하는데, 0.2배 차이가 나는 이유는 병렬 알고리즘의 오버헤드 비용이나 Amdahl’s Law에서 말하듯 병렬 스트림이 속도를 높일 수 있는 한계점이 있거나 하기 때문일 것이다.

정리하자면 다음과 같을 것이다. 병렬 스트림 처리가 의미가 있으려면 데이터가 충분히 커야 한다. Collection에 있는 원소 개수가 10000개는 있어야 한다. 물론 slowSin()처럼 컴퓨팅 연산 비용이 큰 경우에는 크기가 조금 더 작아도 된다.

물론 벤치마크 환경에 따라 인텔의 Turbo Boost 기능처럼 다른 코어가 쉬고 있을 때 CPU frequency를 증가시키는 기술을 쓸 수도 있다. Turbo Boost는 Parallel Stream 환경에서는 적용되지 않기 때문에 Sequential Stream이 더 빠르게 보일 수는 있다. 하지만 Langer씨가 측정에 사용한 하드웨어는 워낙 구식이라 Turbo Boost가 지원되지 않는다고 언급한다.

Stateless Functionality vs Stateful Functionality

만약 상태를 관리해야 하는 stateful이라면 어떨까? 즉, Shared mutable state가 있어서 각 쓰레드마다 참조해야 하는 변경 가능한 값이 있는 경우이다. 다른 Pure Functional Language에서는 Shared mutable state는 병렬 스트림을 지원하지 않는다. 동기화 문제 등으로 큰 비용이 발생하므로 Shared mutable state가 있는 경우에는 병렬 스트림 사용이 적합하지 않다는 것이다. 하지만 Java 8에서는 지원하기로 했다.

예를 들어, distinct()가 stateful functionality이다. 중복된 값을 추출하는 intermediate operation인데, 원소가 처리될 때마다 지금 어디까지 어떤 원소를 처리했는지 상태를 기록하고, 상태를 매 작업마다 참고하고 업데이트해야 중복 제거를 정상적으로 처리할 수 있다. filter마냥 지금 들어온 원소의 조건만 확인하고 이전 원소 상태와는 관련없이 true와 false로 넘길 수 있는 문제가 아니라는 것이다. forEach()는 terminal execution인데, forEach에 따라 이전 연산이 실행될 때 shared state에 접근할 경우에는 개발자의 적절한 동기화 처리가 필요하다. 이는 collect()의 경우에도 마찬가지인데, 스트림의 데이터 처리를 하고 원하는 형태의 자료형으로 바꿔줄 때에도 2개 이상의 쓰레드가 반환되는 자료형(container)에 동시 접근할 경우 정상적인 처리가 되지 않을 수 있다.

distinct()의 경우를 살펴보자. 상태를 저장하지 않고 파이프라인만 제공하는 stateless stream과 달리, stateful stream은 실제로 상태를 저장하고 비교하는 오버헤드 비용이 발생한다. 각 중간 연산마다 다른 쓰레드들이 모두 연산을 마칠 때까지 연산을 다 마친 쓰레드는 다음 연산으로 넘어가지 않는다. 이것을 barrier, 또는 알고리즘적 오버헤드라고 부른다. 따라서 stateful한 경우에는, 병렬 스트림의 속도 향상을 체감하기 어렵다. 그럼에도 지원한 경우에는 혹시나 개발자들이 stateful state인 경우에도 병렬 스트림을 쓰고 싶어할 수 있을 것 같아서 그냥 본인들이 만드는 게 그나마 제일 낫겠다고 판단했다고 하더라고.

게다가 멀티 쓰레드로 리턴되는 배열 순서가 ordering되었다는 보장이 없으므로 별도의 ordering 처리가 필요하다. distinct()는 기본값으로 정렬 연산을 수행하며 순서를 보장한다. 따라서 순서가 중요할 경우, 정렬에 들어가는 비용도 고려해야 한다. 만약 LinkedHashSet처럼 순서가 중요하지 않은 경우, unordered() 메소드를 붙여주며 정렬을 굳이 하지 않게 되고, 결국 정렬에 드는 비용이 소모되지 않는다.

마지막으로, distinct() 메소드의 성능을 벤치마크로 확인해보자.

// sequential
Arrays.stream(integers).distinct().count();
// parallel ordered Arrays.stream(integers).parallel().distinct().count();
// parallel unordered Arrays.stream(integers).parallel().unordered().distinct().count();

예상과 같이 순차 스트림이 가장 빨랐으며, 병렬 스트림 중 순서를 고려하지 않는 연산이 그 다음으로 빨랐다. 병렬 스트림 중 순서를 고려하는 경우가 압도적으로 느렸다. 만약 slowSin()처럼 복잡한 연산 비용이 들어가야 하면 어떨까? 0에서 9999까지 slowSin() 연산을 수행하고, 이 중 중복이 없는 값을 map으로 리턴한다. 5004개의 중복이 없는 값이 나왔다.

Arrays.stream(newIntegers).parallel()
.unordered()
.map(i -> new Double(2200* Sine.slowSin(i * 0.001))
.intValue())
.distinct()
.count();

이전 결과와 유사하게 순차 스트림보다 병렬 스트림이 훨씬 성능이 좋았다. 이 때에는 순서를 고려하는 경우의 병렬 스트림과 순서를 고려하지 않는 경우의 병렬 스트림의 속도가 유사했다. 아무래도 각 쓰레드가 수행하는 컴퓨팅 연산 비용 자체가 크다보니 iteration에 들어가는 시간 차이를 능가하는 것이라고 보인다.

결론

오늘의 결론이다. 스트림 사용이 for-loop보다 의미가 있으려면 Collection이 되는 스트림 소스의 크기가 충분히 크거나, 컴퓨팅 연산이 CPU-intensive할 정도로 비용이 매우 비싸야 한다. 병렬 스트림을 사용하려면, 스트림 소스인 Collection은 split하기 쉬운 자료 구조이어야 하며, 웬만해서는 연산이 stateful하지 않아야 한다.

그리고 성능은 개발자의 작동 환경에 따라 상이할 수 있다. Langer씨는 LinkedList로 순차 스트림과 병렬 스트림을 테스트한 예시를 쿼드코어 CPU인 Intel i5–4590으로 다시 테스트를 해보았는데, 다음과 같이 다른 결과를 보였다. 따라서 Langer씨는 for-loop이나 순차 스트림, 병렬 스트림 중 어느 iteration 방법을 사용하겠다고 했을 때 본인의 작동 환경에서 미리 테스트하는 지혜가 필요하다고 조언한다.

개인적인 의견을 붙여본다면, 나는 개발자 간의 매끄러운 협업을 위해 가독성이 중요하다고 생각하고, 이 점에서 스트림 사용을 적극 환영한다. 특히, 코드의 유지보수가 용이하다는 점에서 스트림 사용이 중요하다고 본다. 물론, 가독성과 유지보수 비용이 속도와 퍼포먼스 측정에는 들어가지 않는다. 하지만 속도와 비슷하게 유지보수 비용은 중요한 문제라고 생각한다. 극단의 성능을 요구하는 특이 상황 (ex. 암호화폐 거래소 매매/매도 체결 API) 이 아니라면 스트림 사용을 적극적으로 고려해볼 만 하다고 생각한다.

마지막으로, 다음 구절을 하나 소개하려고 한다. 출처는 여기다.

So although the difference is not that big, for loops win by pure performance. That being said; performance is not the only important metric to measure your code by. Everything in software engineering is a trade-off. A relevant trade-off in this context is the following: "Performant code usually is not very readable, and readable code usually is not very performant."

As the cost of maintenance is much higher these days than the cost of hardware, the trade-off usually leans towards readable/maintainable code now. So unless millisecond performance is mission critical (and you optimised your entire stack and software for this), it is not a strong argument in most cases.

속도도 중요한 이슈이지만, 가독성과 유지보수의 용이도 중요한 이슈이다. 상황에 따라 개발자가 어떠한 방식을 취할 것인가를 종합적으로 고려해 판단할 필요가 있어보인다.

--

--

Software Engineer at DSRV. ZK Evangelist at Boom Labs. twitter.com/@sigridjin_eth

Love podcasts or audiobooks? Learn on the go with our new app.

Get the Medium app

A button that says 'Download on the App Store', and if clicked it will lead you to the iOS App store
A button that says 'Get it on, Google Play', and if clicked it will lead you to the Google Play store