Java Stream Api 에 대한 이런저런 고찰

.·2021년 12월 16일

JAVA

목록 보기
4/4

Stream 은 java8 부터 등장한 문법으로 기존에 사용하던 for-loop보다 가독성이 좋다는 점에서 많이 쓰인다. 하지만 for-loop 보다 느리며, 알고리즘 효율성 테스트에서 가끔 영향을 줄 때가 있다.
그렇다면 Java의 스트림 API는 왜 for-loop보다 느리며 언제 스트림이 대체되는게 좋은걸까?

스트림이란 무엇인가?

  1. 스트림은 함수형 프로그래밍 언어에서 이야기하는 sequence(=task의 순서)와 동일한 용어이다. Sequence대로 일을 처리하라고 함수를 파라미터로 넘기는 행위를 우리는 Sequential Programming이라고 부른다.

  2. 스트림은 Internal Iterator Pattern(=내부 반복자 패턴)을 사용한다.
    내부 반복자 패턴이란, 컬렉션 내부에서 요소들을 반복시키고 개발자는 요소당 처리해야할 코드만 제공하는 코드 패턴 이다. 즉 스트림 내부에서 순회가 일어나며 Client 입장에서는 iterating 하는 로직을 관리하기만 하면 된다.

  3. Fluent Programming 또는 Fluent API 로 불린다.

 int sum = widgets.stream()
                      .filter(w -> w.getColor() == RED)
                      .mapToInt(w -> w.getWeight())
                      .sum();
 
};

Langer씨는 강연에서 loop와 순차 스트림(sequential stream), 그리고 병렬 스트림(parallel stream) 별로 퍼포먼스가 어떤지 벤치마크 실험을 했다.

for-loop 와 순차스트림

for-loop vs 순차스트림

다음은 배열에서 가장 큰 원소를 찾는 함수이다.

// 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
//reduce(T identity, BinaryOperator<T> accumulator)
int m = Arrays.stream(ints).reduce(Integer.MIN_VALUE, Math::max);

for-loop: 0.36 ms
stream: 5.35 ms

스트림이 약 15배 느린것을 확인할 수 있다.
Langer씨는 JIT Compiler가 for-loop을 워낙 40년 이상 다뤄왔다 보니까 for-loop에 대한 internal optimization이 이미 잘 되어 있다고 말한다. 하지만 스트림은 2015년 이후에 도입되었으므로 아직 컴파일러가 최적화를 제대로 하지 못했다는 것이다.

for-loop vs 순차스트림 에서 저장될 자료구조 wrapped type 으로 변경

ArrayList를 하나 만들고, 500000개의 Integer 타입을 저장하여 그 중 가장 큰 원소를 리턴하도록 했을때

for-loop: 6.55ms
stream: 8.33ms

차이가 확연히 준 것을 확인 할 수 있다. ArrayList를 순회하는 비용 자체가 커서 둘 간의 성능차이를 압도해 버린다고 한다.
wrapped type은 primitive type과 달리 stack(직접참조)이 아닌 heap(간접참조) 메모리 영역에 저장된다.

이때 간접 참조하는 비용이 직접 참조하는 비용보다 훨씬 비싸기 때문에, iteration cost 자체가 높다는 뜻이고 결국 for-loop의 컴파일러 최적화 이점이 사라진다는 것이다.

원소 하나하나에 대한 계산비용 높이기

slowSin(): 파라미터로 넘겨지는 메서드에 대하여 사인함수 값을 계산하고, 이에 대한 테일러 급수를 계산하는 함수

// 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: 11.72ms
stream: 11.85ms

더 이상 for-loop이 빠르지 않음을 확인 할 수 있다.
이를 통하여 함수 내부의 시간 복잡도가 충분히 크다면, stream을 사용하는 것이 for-loop에 대비하여 속도 손실이 없을 것임을 확인할 수 있다.

요약: " iteration cost와 functionality cost의 합이 충분히 클 때, 순차 스트림의 속도는 for-loop와 가까워진다. "

순차스트림 vs 병렬스트림

순차 스트림은 하나의 쓰레드에서 모든 반복을 수행하는 것을 의미한다. 순차 스트림은 싱글 스레드를 사용하기 때문에 CPU코어 자원을 마음껏 활용하지 못하는 대신, 공유 자원 이슈를 고민 할 필요가 없다.
반대로 병렬 스트림은 여러 개의 쓰레드에서 반복을 나누어 수행하는 것이다. 멀티 쓰레드이므로 공유자원 동기화 문제가 발생한다.

Java 의 멀티 쓰레드 구현체인 paralledlStream()은 thread-safe를 보장하지 않기 때문에 작업하는 개발자의 입장에서 별도의 처리가 필요하다.

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

순차스트림 vs 병렬스트림

500000개의 숫자가 들어있고 가장 큰 원소를 찾는다. int 타입과 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);

int-Array: seq : 5.35ms
int-Array: par : 3.35ms

ArrayList: seq : 8.33ms
ArrayList: par : 6.33ms

LinkedList: seq :12.74ms
LinkedList: par :19.57ms

병렬 스트림이 순차 스트림보다 빠르지만, 그 차이가 드라마틱 하지 않다. 이는 계산비용이 작아서 병렬로 처리함으로써 쓰레드를 나누는 비용이 더 크기 때문이다. LinkedList 의 경우는 병렬로 처리 할 경우 job을 split하기 어렵기 때문에 오히려 느리다.

원소 하나하나에 대한 계산비용 높이기

위에서 사용하였던 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);

int-Array: seq : 10.81ms
int-Array: par : 6.03ms

ArrayList: seq : 10.97ms
ArrayList: par : 6.10ms

LinkedList: seq :11.15ms
LinkedList: par :6.25ms

컴퓨팅 연산비용을 높이니 확실히 병렬 스트림이 속도가 1.8배정도 높아졌다.

for-loop 와 stream 을 적절하게 사용하기 위해서는 어떤 자료구조를 사용하며, 타입은 무엇인지, 연산비용, 데이터 개수 등 고려해야 할 것들이 생각보다 많다는걸 알게되었다.
그럼에도 불구하고 stream 이 가지고 있는 '가독성' 역시 하나의 고려 요소가 될 수 있다고 생각하며 실제 어플리케이션 개발시에 1~2초의 성능이 크리티컬할지 아닐지에 따라 결정될 수 있다고 생각한다.

profile
yi

0개의 댓글