패션을 따를 것인가, 아니면 기능성을 따질 것인가.... 그것이 문제로다
유행이냐 실용성이냐
지금으로부터 한 20년전쯤 되는 어느 추운 겨울 날, 한창 헤어 스타일과 옷에 신경을 쓰고 있는 한 소년이 핏한 옷을 입고 닥터마틴 구두에 얇은 코트를 걸치고 나가다 엄니한테 등짝 스매싱을 맞았었다!!!
소년은 엄니한테 요즘 유행하는 패션도 모른다고 항변했고, 엄니는 아들에게 추워죽겠는데 무슨 패션이냐고 스매싱을 날렸다...
그 소년이 30대 후반을 지내는 지금, Java programming style에도 비슷한 일들이 벌어지고 있다. 물론 절대로 Imperative style이 맞냐 Lambda style이 맞냐를 이야기 하는 것이 아니고, 뭐가 낫다고 말하는 것도 아니다.(오해하실까봐 ^^;) 그럼 왜 이제는 새롭지도 않은 JDK8의 Lambda Expression을 가지고 이런 글을 쓰는 것일까? 그 이유는 모든 것들을 적재적소에 사용할 줄 알아야 최고의 조화를 낼 수 있다는 이야기를 하고 싶기 때문이다.
몇 일전에 개발자 한 분과 면접을 진행했었는데 경력대비 실력도 좋고 코딩 스타일도 너무 깔끔했었다. 그 분은 모든 처리를 Lambda 기반의 functional style 로 코딩하였고, Collection기반의 자료구조에 대한 처리는 거의 대부분 stream으로 처리했는데 매우 깔끔했고, 화려했다.
물론 면접과제였기 때문에 일부로라도 그렇게 했겠지만, 솔직히 과할 정도로 많이 사용된 stream으로 인해 부담스럽기까지 할 정도였다. 그런데 과연 이젠 JDK8이상에서는 Lamda expression progamming 방식으로 개발을 해야만 하는 것일까?
결론은 '그렇지 않다' 이다.
그럼 왜 그럴까? 아래 테스트코드로 성능을 확인해보자.
조건 : 50만건의 난수를 담은 List에서 max값을 RETURN하는 메소드를 두 가지 타입으로 만들고 10번씩 돌렸을 때
사양 : Intel i7-6500U / RAM 16GB / Window 10
Imperative Programming(기존 개발방식) : 평균 5.6ms
public int iteratorMaxInteger(List integers) {
Long startDttm = System.currentTimeMillis();
int max = Integer.MIN_VALUE;
for (Iterator< Integer > it = integers.iterator(); it.hasNext();) {
max = Integer.max(max, it.next());
}
Long endDttm = System.currentTimeMillis();
Long elapseTime = endDttm-startDttm;
System.out.println("iterator Elapse Time : "+elapseTime+"ms");
return max;
}
//결과
iterator Elapse Time : 16ms
iterator Elapse Time : 18ms
iterator Elapse Time : 10ms
iterator Elapse Time : 1ms
iterator Elapse Time : 2ms
iterator Elapse Time : 2ms
iterator Elapse Time : 2ms
iterator Elapse Time : 2ms
iterator Elapse Time : 1ms
iterator Elapse Time : 2ms
Functional Programming(Lambda Stream방식) : 평균 14.4ms
public int lambdaMaxInteger(List<Integer> integers) {
Long startDttm = System.currentTimeMillis();
int max = integers.stream().reduce(Integer.MIN_VALUE, (a, b) -> Integer.max(a, b));
Long endDttm = System.currentTimeMillis();
System.out.println("Stream Elapse Time : "+(endDttm-startDttm)+"ms");
return max;
}
//결과
Stream Elapse Time : 21ms
Stream Elapse Time : 10ms
Stream Elapse Time : 9ms
Stream Elapse Time : 44ms
Stream Elapse Time : 4ms
Stream Elapse Time : 6ms
Stream Elapse Time : 5ms
Stream Elapse Time : 4ms
Stream Elapse Time : 4ms
Stream Elapse Time : 37ms
테스트 결과 기존 방식대로 iterator기반 FOR-LOOP 처리가 약 3배정도 빠른 걸 알 수 있다 . Stream을 활용하여 개발시 간결하고 깔끔하게 코딩을 할 수는 있겠지만, 적어도 반복문 처리에 있어서는 Imperative programming의 방식이 훨씬 더 좋은 성능을 기대할 수 있다.
도대체 왜죠?
@Angelika Langer 가 2015년 JAX London에서 'Java performance tutorial – How fast are the Java 8 streams?'의 주제로 발표한 내용을 보면 그 이유를 알 수 있다.
Compilers have 40+ years of experience optimizing loops and the virtual machine’s JIT compiler is especially apt to optimize for-loops over arrays with an equal stride like the one in our benchmark. Streams on the other hand are a very recent addition to Java and the JIT compiler does not (yet) perform any particularly sophisticated optimizations to them.
요약하자면, JIT Compiler는 40년 이상 for-loop에 최적화되어 왔고, Streams 방식은 추가된지 얼마 되지 않았으므로, 기존 방식 대비 좋은 성능을 기대하기 어렵다.
그럼 Java에서는 Lambda의 Streams을 사용하지 말라는 것인가요?
노우노우~ 상황에 따라 잘 쓰면 된다. @Angelika Langer 역시도 최종 결론에서 아래와 같이 말했다.
The ultimate conclusion to draw from this benchmark experiment is NOT that streams are always slower than loops. Yes, streams are sometimes slower than loops, but they can also be equally fast; it depends on the circumstances. The point to take home is that sequential streams are no faster than loops. If you use sequential streams then you don’t do it for performance reasons; you do it because you like the functional programming style.
요약하자면, 항상 느린 것은 아니고 경우에 따라 동등할 정도로 빠르다.
그럼 그게 언제지? 바로 아래와 같은 경우처럼 Collection으로 구성할 때이다. (물론 parallel로 처리하면 빠르게 처리할 수 있는 방법은 있겠지만, 지금 주제에서는 벗어나므로 언급하지 않겠음)
조건 : 도로명주소의 SAM파일(124MB)에서 '|'로 split 후 List에 add하도록 10번을 수행한 케이스
사양 : Intel i7-6500U / RAM 16GB / Window 10
Lambda Streams 개발방식 : 평균 5,730ms
dataList = br.lines()
.map(i->i.split("[|]"))
.collect(Collectors.toList());
//결과
Read File Elapse Time : 6280
Read File Elapse Time : 7063
Read File Elapse Time : 7069
Read File Elapse Time : 5079
Read File Elapse Time : 5433
Read File Elapse Time : 5487
Read File Elapse Time : 4845
Read File Elapse Time : 5836
Read File Elapse Time : 5331
Read File Elapse Time : 4881
역시...Streams개발방식은 자원에 대한 소모는 대다나다!!!
지금부터는 기존 개발방식으로~
Imperative Programming(기존 개발방식) : 평균 6113.7ms
List<String[]> dataList = new ArrayList<String[]>();
while ((readLine = br.readLine()) != null) {
columnDatas = readLine.split("[|]");
dataList.add(columnDatas);
}
//결론
Read File Elapse Time : 5296
Read File Elapse Time : 6230
Read File Elapse Time : 7784
Read File Elapse Time : 7065
Read File Elapse Time : 7225
Read File Elapse Time : 5377
Read File Elapse Time : 5833
Read File Elapse Time : 5317
Read File Elapse Time : 5391
Read File Elapse Time : 5619
결론
위의 결과에서도 보았겠지만, readLine에 대한 split을 한 다음에 Collaction 객체로 반환하는 처리는 Streams programming[평균 5,730ms vs평균 6113.7ms]처리가 더 빨랐지만(자원소모도 훨씬많았지만...), for-loop처리는 Streams 방식[평균 14.4ms vs 평균 5.6ms]이 더 느렸다.
그러므로 성능이 중요한 프로젝트에서 loop처리를 많이 해야한다면, Streams의 forEach보다는 기존의 반복문으로 처리하는 편이 훨씬 좋은 선택일 것이고 그렇게 하길 여러 권위자들도 권장하고 있다.
사족
처음에 언급한 것처럼 특정 개발스타일이 맞고 틀리고를 말하고 싶은 것이 아니다. 다만, 개발자는 본인이 처한 상황과 환경에 맞게 적절한 방식으로 개발을 하는 것이 중요하다는 것을 언급하고 싶었을 뿐!
그런데 근래 일을 하다보면 간혹가다가 아래와 같이 말하는 사람들을 보곤 한다.
요즘 누가 Java나 C를 해요. Go나 python같은 스크립트 언어가 얼마나 좋은데... 제가 굳이JVM이 하는 일을 왜 알아야 하죠?
맞다. 충분히 일리있는 말이고 그렇게 생각할 수는 있는 이유는 분명 있다. 하지만, 그 이야기를 듣고 나서 다양한 경험이 부족한 개발자의 자기 푸념으로밖에 들리는 않는 이유는 왜일까?
개발자이기 때문에 현재 자신이 처한 상황이나 환경을 잘 고려해서 적재적소에 좋은 선택을 하는 것이 필요할 것이다.
(근데 솔까 파일처리나 웹소켓처리는 python이나 Golang이 빠르고 편하긴 하다...^^;)
참고사이트
https://blog.takipi.com/benchmark-how-java-8-lambdas-and-streams-can-make-your-code-5-times-slower/
https://jaxenter.com/java-performance-tutorial-how-fast-are-the-java-8-streams-118830.html(Angelika Langer발표내용)
https://dzone.com/articles/stream-performance-3(Angelika Langer의 성능비교)