[Java] Stream 은 항상 성능이 좋을까?

728x90

안녕하세요🤚

오늘은 Stream API 를 공부하며 궁금했던 내용에 대해서 이야기 해보려고 합니다.

 

서론

요즘 모던 자바 인 액션 책에 푹 빠져있어, 매일 매일 책을 읽으며 새로운 지식들을 쌓아가고 있습니다.

아직 응애 개발자인, 3개월차 개발자가 보기엔 어려운 내용이 많지만, 새로운 내용이 많아서 처음부터 시작하는 거 같은 신기한 기분을 느끼고 흥미로워서 재밌습니다.

 

스트림은 자바 8부터 새롭게 생긴 문법으로 기존의 자바 문법들 보다 더 좋을 것이라고 생각했습니다.

그래서 아직 사용이 자연스럽지 않음에도 연습할 필요성을 느껴서 책을 사서 공부를 하는 중입니다.

 

모던 자바 인 액션은 자바8 이후 새로운 문법에 대해서 이야기하는 책으로

대표적으로 Lambda, Stream, Optional 등 새로운 문법들을 많이 다룹니다.

 

새로운 내용을 다루는 만큼, 기존 전통적인 문법들과 다른점이 많아 이해하기 어려운 내용이 많지만 

2,3번 정도 읽으면 완전히 내것이 될거라는 생각으로 책을 읽으며 내용을 정리하고 있습니다.

 

오늘은 여러가지 새로운 문법중에서 Stream 이란 주제로 글을 써봤습니다.

 

여러분은 Stream 을 왜 사용한다고 생각하시나요?

제가 생각하는 바로는 바로 가독성성능향상을 이유로 많이 쓰일 것이라고 생각합니다.

 

Builder 패턴과 비슷한 구조로 파이프라인을 만들어 코드를 구성해 가독성도 향상이 되고

성능 적으로도 좋다는 말이 있으니 일석이조로 좋다는 생각이 들었고

그러면 코드를 작성할 때 상황을 고려하지 않고 매번 스트림을 사용하면 되지 않나? 라는 고민을 하였습니다.

 

하지만 다른 시니어 개발자들을 코드들을 보면 때로는 Stream 을 사용하고, 다른 경우는 for loop 를 사용하는걸 체크 했습니다.

 

결국 상황에 따라 유동적으로 코드를 짤 수 있는 능력을 길러야 한다는 생각이 들었고,

어떤 상황에서 어떻게 활용을 해야할까라는 고민이 생겼습니다.

 

아직 주니어 개발자인 저는😅, 그 유동적으로 어떤 상황에서 어떻게 사용을 해야하는지 지금 당장은 생각이 나지는 않았습니다.

 

저는 실제로 업무를 하면서도, Stream, Lambda 고급문법을 사용하려고 노력은 하지만, 

잘 생각이 안날 때는 그냥 for loop 기존에 쓰던 방식대로 코드를 짜고는 합니다.

 

물론 람다, 스트림을 사용하지 않고 코드를 짜서 API 를 개발하는데 크게 문제는 없습니다.

 

문제는 결국 성능 또는 가독성이죠

 

그렇다면 이런 고민을 해봤습니다.

어떤 상황에서, 스트림을 어떻게 활용을 해야지, 일석이조로 성능 과 가독성을 챙길 수 있을까?

 

이제 그 방법과 해결책에 대한 이야기를 해보겠습니다.

 

본론

기본적으로 알고 있어야 할 내용은, Stream 은 Lazy 한 연산으로, 매번 중간 연산마다 조건을 실행하지 않습니다.

대신, 중간 연산마다 연산의 파이프라인(=스트림 메소드) 를 리턴합니다.

최종 연산 메소드가 마지막을 끊어 줘야지, 중간 연산을 모두 합친 후에 최종 연산이 실행이 됩니다.

 

이제 간단한 성능 테스트를 해보겠습니다.

(참고로 제가 테스트를 실행한 운영환경은 (macOS m1 8코어 입니다)

테스트 방법 관련해서 궁금한 내용은 성능테스트 링크에서 확인할 수 있습니다.

 

테스트의 분류를 간단하게 설명해보자면

1) Primitive type (for ~loop)

2) wrapped type (stream)

같은 결과를 구현하는 기능을 총 3가지로 나눠서 분류 해본 것 입니다. 

 

그리고 부가적으로

1) 순차 스트림 (stream)

2) 병렬 스트림 (paralleStream)

총 2종류의 테스트를 해보려고 합니다.

 

테스트는 JMH 라이브러리를 사용해서 진행하였습니다.

 

테스트 내용은 숫자 n 을 파라미터로 받아, 1~n 까지 모든 숫자의 합계를 반환하는 메소드를 구현해보는 것입니다.

 

아래 코드는 실행 기본 코드 입니다.

@State(Scope.Benchmark)
@OutputTimeUnit(TimeUnit.MILLISECONDS)
@BenchmarkMode(Mode.AverageTime)
@Fork(value = 1, jvmArgs = {"-Xms4G","-Xmx4G"})
public class StreamMeasure {

	private static final long N = "유동적으로 바뀔 예정";
    

}

 

1) Primitive type

private static final long N = 10_000_000L; // 두 번째는 10_000_000_000L;

	// for ~ loop
	@Benchmark
	public long iterativeSum() {
		long result = 0;
		for (long i = 1L; i <= N ; i++) {
     	   result += i;
		}
		return result;
	}

	// 일반 stream
	@Benchmark
	public long sequentialSum() {
		return Stream.iterate(1L, i-> i+1)
			.limit(N)
			.reduce(0L, Long::sum);
	}
    
    	// 특화 stream
    	@Benchmark
	public long sequentialLongSum() {
		return LongStream.iterate(1L, i -> i+1)
			.limit(N)
			.reduce(0L, Long::sum);
	}

 

1) N = 10,000,000 

위 테스트에서는 ( for~loop > 특화 stream > 기본 stream )순으로 성능이 좋은걸 체크 했습니다. 

생각보다 for~loop 랑 일반 stream 이랑 성능 차이가 엄청 많이 나는걸 체크 할 수 있습니다.

 

 

2) N = 10,000,000,000 

위 세가지를 비교해보면 primitive type 에서의 성능 비교는 값이 크든 작든 

1) for~loop

2) 특화(순차) stream

3) 기본(순차) stream

순으로 성능이 좋은걸 확인할 수 있습니다.

 

 

2) Wrapped type

wrapped type 은 여러가지가 있지만, 그 중에서도 대표적인 자주 사용하는 Long, ArrayList 타입을 이용하여 테스트 하였습니다.

 

1) Long

	private static final Long N = 10_000_00L;
	
    // for~loop
	@Benchmark
	public Long iterativeSum() {
		Long result = 0L;
		for (long i = 1L; i <= N ; i++) {
			result += i;
		}
		return result;
	}

	// 순차 stream
	@Benchmark
	public Long sequentialSum() {
		return Stream.iterate(1L, i -> i + 1L)
			.limit(N)
			.reduce(0L, Long::sum);
	}

	// 순차 특화 stream
	@Benchmark
	public Long sequentialLongSum() {
		return LongStream.iterate(1L, i -> i+1L)
			.limit(N)
			.reduce(0L, Long::sum);
	}

 

1) N = 10,000,000

위 테스트에는 특화된 스트림이 압도적으로 높은 성능을 제공 합니다.

확실히 자료구조를 알고서 명시적으로 하니 오토박싱등 부가적인게 필요없어서 성능이 빠른것으로 예상이 됩니다.

 

그래도 기본 스트림이 아까 primitive type 에 비해서는 for~loop 와 성능 면에서 덜 차이가 나는걸 볼 수 있습니다.

 

Wrapped Type 특화 Stream > for~loop > 기본(순차) Stream 순으로 성능이 좋은걸 체크 할 수 있었습니다.

 

2) ArrayList

	private static final int SIZE = 10_000_00; 

	private ArrayList<Long> arrayList;

	@Setup
	public void setup() {
		// 벤치마크 실행 전 초기화 작업
		arrayList = new ArrayList<>(SIZE);
		for (long i = 1L; i <= SIZE; i++) {
			arrayList.add(i);
		}
	}

	@Benchmark
	public Long iterativeSum() {
		Long result = 0L;
		for (Long value : arrayList) {
			result += value;
		}
		return result;
	}

	@Benchmark
	public Long sequentialSum() {
		return arrayList.stream()
			.reduce(0L, Long::sum);
	}

	@Benchmark
	public Long sequentialLongSum() {
		return arrayList.stream()
			.mapToLong(Long::longValue)
			.reduce(0L, Long::sum);
	}

1) N = 10,000,000

드디어 for~loop 와 기본 스트림을 성능이 비슷해졌다. 

생각대로 특화 스트림이 제일 좋은 성능을 가진것을 확인했다. 

 

특화스트림은 자료구조를 잡아주고 하니 성능이 훨씬 좋다.

왜 성능이 좋을까 고민을 해봤는데, 박싱과 언박싱 오버헤드가 사라지는게 제일 큰 이유 라고 생각합니다.

(오버헤드 : 어떤 처리를 하기 위해 들어가는 간접적인 처리 시간 · 메모리 등)

 

 

🙋‍♂️ 그럼 여기서 질문 왜 primitive type 이랑 wrapped type 이랑 위 코드에서 성능면에서 차이가 난다고 생각하시나요?

 

결론은 wrapped type 은 heap 영역에 값이 저장이 되고 , primitive type 은 stack 영역에 값이 저장이 되기 때문입니다.

 

JVM 에서 heap 메모리에 저장되는 간접 참조는 힙에 객체의 내용이 있고, 그곳을 가리키는 메모리 영역(Stack) 이 있다.

즉 스택에서 힙의 주소를 얻은 후 힙의 실제 내용을 참조하게 되는 방식이다. (간접 참조)

 

반면 JVM 에서 stack 에 저장되는 것들의 경우는 중간에 참조할 값이 없이, stack 에 있는 것이 그 자체의 값이다.

직접 참조를 이용한다.

 

즉 primitive type 은 stack 에 저장되어 JVM 에서 직접 참조로 값을 바로 불러올 수 있다.

하지만 wrapped type 은 heap 에 저장이 되어 JVM 에서 중간(스택) 을 거쳐서 값을 가져온다 

 

간접 참조를 하는 것이 직접 참조 하는 것 보다 CPU 할당을 많이 받기 때문에 , 순차접근비용 자체가 높다.

결국 간접 참조를 필요로 하는 코드에서 for~loop 는 이점이 사라진다. 

 

마지막으로 순차 스트림과 병렬 스트림을 알아보겠습니다. 

 

3) 순차 스트림 vs 병렬 스트림

	private static final long N = 10_000_00L; // 두번째 테스트느느 10_000_000_000L

	// 순차 stream
	@Benchmark
	public long iterativeSum() {
		return LongStream.rangeClosed(1L, N)
			.limit(N)
			.reduce(0L, Long::sum);
		}
        
   	 // 병렬 stream
	@Benchmark
	public long parallelRangedSum() {
		return LongStream.rangeClosed(1L,N)
			.parallel()
			.reduce(0L, Long::sum);
	}

 

1) N = 10,000,000 일 때 

병렬 스트림이, 순차 스트림보다 거의 20배 정도 빠른 성능을 체크할 수 있습니다.

 

2) N = 10,000,000,000 일 때 

 

N 값이 더 커진 테스트에서도 병렬 스트림이 순차 스트림보다 더 좋은 성능을 가진것을 확인할 수 있었습니다.

 

그러면 모든 반복문에서 병렬 스트림을 사용하면 되는거 아닌가? 하는 생각을 했습니다.

 

하지만 병렬 스트림을 잘모르고 사용했을 때의 치명적인 단점이 있습니다.

바로 데이터에 접근할 때 생기는 문제인 동기화 & 데이터 레이스  문제 입니다. 

 

순차 스트림은 하나의 코어(프로세스) 에서 하나의 쓰레드에서 모든 반복(Iterator)을 수행하는 것을 의미한다. 순차 스트림은 싱글 스레드를 사용하기 때문에 CPU 코어 자원을 마음껏 활용하지 못하는 대신, 공유 자원 이슈를 고민할 필요가 없다.

같은 개념은 아니지만 비슷한 느낌으로 동시성 을 떠올릴 수 있습니다. 

 

병렬 스트림은 하나의 코어(프로세스) 에서 여러 개의 쓰레드가 자원을 공유하며 반복을 나누어 수행하는 것이다.

멀티 쓰레드이므로 공유자원 동기화 문제가 발생한다. 만약 공육 객체( 또는 전역변수 = mutate state)가 있는 객체가 있다면

각 코어 별로 할당된 쓰레드가 동일한 메모리 영역에 접근하여 값을 서로 바꾸려고 할 것이다.

 

https://girawhale.tistory.com/131

 

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

 

간단하게 말하면 스트림을 재귀적으로 분할하고,각 서브 스트림을 서로 다른 스레드의 연산으로 할당하고 결과를 하나의 값으로 합쳐야 한다. 멀티 코어(병렬) 에서의 데이터 이동은 생각보다 비쌉니다. 

 

방금 전에 말했던 병렬에서 가장 많은 문제인 데이터 레이스 &동기화 문제에 대해서 어떻게 해결해야 하는지에 대한 내용은

아래 포스팅에서 자세하게 다뤄 볼 예정입니다.

=> https://hyeonq.tistory.com/167

 

 

결론


1) Primitive Type 반복문 처리는 그냥 for~loop 를 쓰자

2) Wrapped Type 은 반복문 대신 스트림을 써도 괜찮다

3) 병렬 스트림은 제대로 알고 상황에 알맞게 쓰자. + 공유 상태의 객체를 다룰 때는 사용하지 말자

 

상황에 맞춰서 적절하게 스트림을 사용해야 할 필요가 있습니다.

뭔가 잘 모르겠다 싶으면 직접 성능테스트를 돌려보면 된다. 내 눈으로 결과를 보기 전까지는 내 코드를 믿지말자..!

 

이상 포스팅 마치겠습니다. 

REF 📖
1)https://sigridjin.medium.com/java-stream-api%EB%8A%94-%EC%99%9C-for-loop%EB%B3%B4%EB%8B%A4-%EB%8A%90%EB%A6%B4%EA%B9%8C-50dec4b9974b
2) https://velog.io/@injoon2019/%EC%8A%A4%ED%8A%B8%EB%A6%BC%EC%9D%80-%ED%95%AD%EC%83%81-%EC%A2%8B%EC%9D%84%EA%B9%8C
728x90