본문 바로가기

Java/JAVA

Stream foreach 성능 테스트 (stream을 사용하지 말아야할 때는 언제일까로 시작된 간단한 테스트)

Stream foreach 반복문 테스트

자바 Stream을 이용한지 꽤 됐다.

필자가 개인적으로 Stream을 사용하는 이유는 가독성이 좋아지기 때문이다. (가장 큰 이유)

예전에 Stream이 막 등장했을 시기에는 Stream이 어색하고 전통적인 for loop에 익숙한 개발자와 같이 개발할 수 있기 때문에 Stream 도입을 유의하자고 많이 했다.

근데 요즘에는 고민 없이 사용할 만큼 Stream을 많이 사용한다.

필자 또한 Stream을 잘 이용하는데 문제는 적재적소에 사용하지 못하는 점이다.

여기서 적재적소라 함은 Stream이 더 유용할 때 사용하는 것이다.

보통 map(), flatMap()등을 이용해야할 때나 이용하면 더 작업이 간결하고 성능상에도 이득이 있을 때 사용하는 것을 권장하는 것으로 알고 있다.

그런데 개인적으로는 그냥 자동적으로 stream을 쓰고, filter(), foreach()를 주로 쓰고 그나마 anyMatch()정도(?)를 추가로 사용해왔다.

그러던 중 Stream을 사용하는 것이 성능에 더 안 좋다는 얘기가 자주 들리기 시작하면서 테스트를 해봐야겠다하고 생각했다.

그래서 아래와 같이 성능 테스트를 진행했다. (다른 벤치마크들이 많이 있지만 내가 쓰는 환경에서 내가 쓰는 스타일로 했을 때 성능이 어떤지 확인하고 싶었기 때문이다.)


테스트 및 결과

환경 : intel core i5-6200U 2.3GHz, 8GB RAM, 64bit window, 이클립스에서 실행

package com.example.demo.controller;

import java.util.Collections;
import java.util.Random;
import java.util.concurrent.CopyOnWriteArrayList;

import javax.annotation.PostConstruct;

import org.springframework.util.StopWatch;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;

@RestController
public class TestController {
	private CopyOnWriteArrayList<Integer> list;
	private static int min = Integer.MAX_VALUE;
	@PostConstruct
	private void init() {
		list = new CopyOnWriteArrayList<>();
		//랜덤 10만건
		Random random = new Random();
		for(int i=0;i<100000;i++) {
			list.add(random.nextInt());
		}
	}
	
	@GetMapping("/")
	public String performanceTest() {
		StopWatch stopWatch = new StopWatch();
		stopWatch.start();
		list.stream().forEach(item->{
			if(item < min) {
				min = item;
			}
		});
		stopWatch.stop();
		System.out.println("stream loop : "+stopWatch.getTotalTimeMillis() + "ms");
		///////////////////////////////////////////////////////////////////////		
		stopWatch = new StopWatch();
		min = Integer.MAX_VALUE;
		stopWatch.start();
		list.forEach(item->{
			if(item < min) {
				min = item;
			}
		});
		stopWatch.stop();
		System.out.println("for each : " + stopWatch.getTotalTimeMillis() + "ms");		
		///////////////////////////////////////////////////////////////////////		
		stopWatch = new StopWatch();
		min = Integer.MAX_VALUE;
		stopWatch.start();
		for(int item : list) {
			if(item < min) {
				min = item;
			}
		}
		stopWatch.stop();
		System.out.println("advanced for : " + stopWatch.getTotalTimeMillis() + "ms");
		///////////////////////////////////////////////////////////////////////		
		stopWatch = new StopWatch();
		min = Integer.MAX_VALUE;
		stopWatch.start();
		for(int i=0;i<list.size();i++) {
			if(list.get(i) < min) {
				min = list.get(i);
			}
		}
		stopWatch.stop();
		System.out.println("for : " + stopWatch.getTotalTimeMillis() + "ms");
		///////////////////////////////////////////////////////////////////////		
		stopWatch = new StopWatch();
		min = Integer.MAX_VALUE;
		stopWatch.start();
		Collections.min(list);
		stopWatch.stop();
		System.out.println("collection : " + stopWatch.getTotalTimeMillis() + "ms");
		System.out.println("==========================================================");
		return "complete";
	}
}

자주 사용하는 스프링부트에서 "/"루트 경로로 HTTP 요청이 왔을 때 해당 실험을 하게 해놨다.

여러번 요청을 시도해서 결과를 평균으로 받아봤다.

결과의 일부다. 명칭이 조금 어색할 수도있다. 그냥 필자 마음대로 표기한 것이니 오해없기를 바란다.

10만건에서 최소값을 찾기 위해 반복문을 도는 것을 테스트한 결과다.

테스트 목적에 맞게 확인해보면 stream이 for문보다 느린 것 같이 나온다! for문의 경우 0ms도 가끔 나오는데 stream은 1ms, 2ms 왔다갔다 한다.

더 자세하게 보기 위해 stopWatch를 쓰지 않고 System.nanotime()으로 체크해봤다.

package com.example.demo.controller;

import java.util.Collections;
import java.util.Random;
import java.util.concurrent.CopyOnWriteArrayList;

import javax.annotation.PostConstruct;

import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;

@RestController
public class TestController {
	private CopyOnWriteArrayList<Integer> list;
	private static int min = Integer.MAX_VALUE;
	@PostConstruct
	private void init() {
		list = new CopyOnWriteArrayList<>();
		Random random = new Random();
		for(int i=0;i<100000;i++) {
			list.add(random.nextInt());
		}
	}
	
	@GetMapping("/")
	public String performanceTest() {
		long start=0,end=0;
		start = System.nanoTime();
		list.stream().forEach(item->{
			if(item < min) {
				min = item;
			}
		});
		end = System.nanoTime();
		System.out.println("stream loop : "+ String.format("%,d", (end-start)) + "ns");
		///////////////////////////////////////////////////////////////////////		
		min = Integer.MAX_VALUE;
		start = System.nanoTime();
		list.forEach(item->{
			if(item < min) {
				min = item;
			}
		});
		end = System.nanoTime();
		System.out.println("for each : " + String.format("%,d", (end-start)) + "ns");		
		///////////////////////////////////////////////////////////////////////		
		min = Integer.MAX_VALUE;
		start = System.nanoTime();
		for(int item : list) {
			if(item < min) {
				min = item;
			}
		}
		end = System.nanoTime();
		System.out.println("advanced for : "+String.format("%,d", (end-start)) + "ns");
		///////////////////////////////////////////////////////////////////////		
		min = Integer.MAX_VALUE;
		start = System.nanoTime();
		for(int i=0;i<list.size();i++) {
			if(list.get(i) < min) {
				min = list.get(i);
			}
		}
		end = System.nanoTime();
		System.out.println("for : " + String.format("%,d", (end-start)) + "ns");
		///////////////////////////////////////////////////////////////////////		
		min = Integer.MAX_VALUE;
		start = System.nanoTime();
		Collections.min(list);
		end = System.nanoTime();
		System.out.println("collection : " + String.format("%,d", (end-start)) + "ns");
		System.out.println("==========================================================");
		return "complete";
	}
}

줄이 정렬이 안되어 있지만 for문이 보통 80만 나노초 밀리로 환산하면 0.8ms가 걸렸다.

stream은 1.6ms가 걸렸다. 두 배차이가 난다. 애플리케이션 성격에 따라 치명적일 수도 있고 별일 아닐 수 있다.

다음 결과는 10만건이 아니라 20만건으로 해봤다. 리스트에 20만개의 데이터가 들어있는 것이다.

뭔가 석연치 않다. for문이 느릴때도 생기기 때문이다.

(테스트를 제대로 못했을 수도 있다지만...) 결과 차이가 들쭉 날쭉해서 뭐가 더 성능적으로 유리하다고 말하기 어렵다고 된다.....만!

캡쳐 이후로 계속 테스트를 해서 찍어보니까 결국은 for문이 더 빨랐다. (약1.5~2배)

이번에는 반대로 데이터를 1000개로 줄여보았다.

위의 실험과는 다른 결과가 나왔다. 오히려 적은 개수에서는 stream을 만드는 것이 더 성능이 좋았다.

거기에 더 좋은 것이 stream을 만들지 않고 그냥 foreach를 돌리는게 더 빨랐다. 게다가 Collections.min()이 갑자기 좋아진다.

정리

정리를 어떻게 해야하나 싶었다.

자료형에 데이터가 많으면 for문쓰고 데이터가 적으면 stream을 쓰세요? foreach문을 쓰세요? 이러면 될까?

아닌 것 같다.

필자가 정리한 부분은 중간에도 말했듯, 애플리케이션 성격이 중요한 것 같다.

만약에 1~2ms, 속도, 성능이 크리티컬할 때는 for문, stream을 잘 테스트해서 적절하게 사용하는 것이 좋을 것이고

그렇지 않다면 가독성을 위해 stream을 써도 좋을 것 같다.

결과적으로 함수형 프로그래밍 패러다임과 람다가 주는 편의성, 가독성을 생각했을 때 stream을 버리기 아쉬운 마음이 든다.

* 이렇게 테스트만 하고 끝나는게 아니라 왜 이런 결과가 나오는지 원리를 살펴보는 것도 필요하다.

어떤 블로그에서는 Stream(Internal Iteration)을 사용하면 JVM과 라이브러리가 해야할 일이 많아지기 때문에 느려질 수 있다고 한다.

또한 어떤 블로그에서는 JIT Compiler가 수십년간 for-loop에 최적화되어왔기 때문이고 stream이 나온지 그렇게 오래되지 않았기 때문이라고 한다.

뭐 추가적으로 Stream을 만들고 뭔가 추가적인 작업으로 인한 오버헤드가 있을 것으로도 보인다.

자세한 건 더 알아봐야하겠지만... 나름의 결론을 얻고 글을 마친다.

 

참고 사이트

https://homoefficio.github.io/2016/06/26/for-loop-%EB%A5%BC-Stream-forEach-%EB%A1%9C-%EB%B0%94%EA%BE%B8%EC%A7%80-%EB%A7%90%EC%95%84%EC%95%BC-%ED%95%A0-3%EA%B0%80%EC%A7%80-%EC%9D%B4%EC%9C%A0/