티스토리 뷰

Java/Effective Java

[Item45] Stream vs For-Loop

ryumodern 2022. 4. 5. 19:53

Item45에서 스트림은 주의해서 사용하라 말한다

Stream API는 다량의 데이터 처리 작업을 돕고자 Java8부터 추가되었고 짜잘한 for, if문을 스트림으로 대체할 수 있다

Stream API의 핵심은 책을 참고해보면 다음과 같다

1. Stream 데이터 소스로부터 오는 유한 또는 무한의 흐름

2. Stream Pipeline 데이터 소스를 돌리면서 수행하는 연산의 단계 (중간 연산과 종단 연산으로 나뉜다)

 

Stream이란 이름에서부터 알 수 있듯이 데이터가 흐르고 있고 중간 연산을 활용해 변환(mapping)하거나 걸러낸다(filtering)

쌩으로 출력하거나 다른 파일에 쓰는 경우가 아니라면 무언가 가공이 필요한 데이터이므로 중간 연산이 들어갈 것이다

파이프라인이라는 단어에서 유추할 수 있듯 중간 연산을 이어 붙일 수 있어야 하니 중간 연산은 연산 때리고 결과를 반환한다

스트림에서 중간 연산은 Lazy Evaluation, 지연 평가되므로 종단 연산이 있어야만 수행된다

종단 연산이 있을 때만 평가되는 것이 Pipeline의 핵심이고 무한 시퀀스를 이용하더라도 프로그램이 터지지 않는 이유다

 

여러 개의 중간 연산과 하나의 종단 연산을 체이닝 하여 이쁘장한 형태의 코드를 작성할 수 있다

파이프라인으로 쭉쭉 연결 시키는 모양새를 fluent API라 한다

그렇기 때문에 짧으면 무슨 일을 하는지도 잘 파악되는데 길어질수록 for, if 문으로 풀어쓴 형태보다 읽기 어렵다
즉 자바8에 등장했다고 해서 무조건 스트림만 써서 좋은 게 아니다, 풀어쓴 형태보다 성능이 나은 상황도 많지 않다

다만 요즘은 H/W 성능이 좋아져 무시할만한 상황이 많으니 가독성을 위해 주로 사용하고
특수한 경우에 (주로 처리해야 할 데이터가 엄청 많고 각각의 연산이 독립적일 때) 스트림을 사용하면 좋다

체이닝한 중간 연산에는 람다를 자주 사용하는데 람다에서는 타입을 생략하는 경우가 많아 변수명에 특히 주의해야 한다

의미 있는 변수명을 짓지 못하면 람다 전체의 의미가 모호해진다

가능한 한 a, b 요딴거 말고 길어지더라도 의미를 확실히 전달할 수 있도록 지어야 한다


람다 안에서 외부 변수를 참조할 때 불변이거나 effective final이라 해서 사실 상 불변이어야 한다

사실 상 불변은 final로 선언되어 있지 않더라도 변하지 않을 값이 확실한 경우를 의미한다

그럼에도 외부 컬렉션에 요소를 넣거나 빼는 등의 동작은 가능하다

람다 사용시 주의할 점
1. 같은 스코프의 지역 변수 변경 불가 - 사이드 이펙트 만들지 않기 위함
2. return, break, continue 사용 불가

 

스트림의 장단점을 간략하게 살펴봤는데 그래서 스트림을 쓰라는 것인가, 기존의 반복문을 쓰라는 것인가?

참고 자료를 보면 Stream vs For-loops의 가장 큰 차이점은 아래와 같다

1. Performance

2. Readability

3. For loops work just fine / Streaming API is the fancy way of doing it.

 

참고 자료에서는 성능에 앞서 유지 보수를 고려하라고 한다

Stream API는 성능과 가독성의 trade-off라 하였고 millisecond 단위로

성능이 중요한 프로그램이 아니라면 유지보수가 좋은 쪽을 사용하는 것이 낫다고 한다

즉 일반적으로는 향상된 for-loop 쪽이 성능이 좋다는 것이다

 

그럼 항상 for-loop을 사용해야 하는 것인가?

Stream API는 대량 데이터 처리 작업을 위해 등장했다는 것을 상기해보자

이를 위해 병렬 처리도 지원하는데 스트림을 생성할 때 parallelStream을 이용하면 된다

내부적으로 Fork-Join Framework을 사용해 병렬 처리를 해주는 것인데 주의할 점이 많다

 

병렬 처리에는 여러 오버헤드가 따른다

데이터를 여러 스레드에 쪼개 주는 작업에도 부하가 생기기 마련이고

스레드마다 수행 시간이 완전히 일치하지 않는데 이때 일을 다한 스레드는 놀고만 있는 게 아니라 

일을 끝내지 못한 스레드로부터 일을 받아와 처리하기도 한다

이 때 어느 정도 양을 가져올지, 데이터를 가져오는 작업 그 자체, 데이터 처리 결과 병합에 또 부하가 생긴다

데이터가 항상 메모리에 있을 수 없으니 Disk I/O나 DB I/O도 빈번할 수 있고

작업을 쪼갬으로부터 오는 locality 저하도 있다

 

- locality

Disk I/O에서 블록 또는 페이지 단위로 데이터를 읽어오는데 순차적으로 읽어올 수록 I/O 횟수가 줄어드니 빠르다

운영체제에 따라 순차적 읽기를 위해 읽을 것으로 예상되는 데이터를 미리 읽어두고 캐싱해두는 최적화 등도 있다

여러 스레드를 사용할 시 병렬적으로 실행되니 한 스레드가 1-4 읽다가 끊기고 다른 놈이 5-8 읽으니 

Random I/O가 빈번하게 발생하고 지역성이 떨어진다고 한다

 

이런 오버헤드를 종합하여 간단하게 parallel-stream을 사용할 분기점을 브라이언 고츠가 만들어 두었다

NQ Model이라 하며 데이터의 개수(N) X 처리 작업 횟수(Q)로 표현된다, 이 값이 10,000 이상일 때 사용하란다

NQ Model을 항상 적용할 필요는 없고 대략적으로 판단하기 위함이다

 

https://stackoverflow.com/questions/20375176/should-i-always-use-a-parallel-stream-when-possible#:~:text=of%20parallel%20speedup.-,NQ%20Model%3A,-N%20x%20Q

 

책에서는 경험적으로 측정해보는 것이 중요하다고 한다

아무래도 parallel-stream은 선언만 하고 내부적으로 Fork-Join이 사용되니 이를 사용하는 개발자에게는 블랙박스다

포크 조인에 통달한 사람이 아니라면 코드 짜고 '음 이렇게 돌아가겠구먼' 하는 것이 아무 의미 없다

직접 성능을 비교해봐야 알 수 있다, NQ Model 값이 100,000이 나왔어도 for-loop이 빠를 수 있다

 

종합해보면 유려한 코드를 작성할 수 있다는 점에서 stream은 매력적이다

다만 처리할 데이터 수가 많지 않다면 일반적으로 for-loop이 훨씬 빠르다

웹 개발에서 NQ Model을 따르자면 10,000번의 연산을 하지 않는 한 대부분 for-loop을 써야 하지만 

그 정도 성능 저하는 감수할 수 있고, 가독성이 중요하기 때문에 stream을 사용하는 것이다

스트림의 특성 중 외부 변수를 변경할 수 없다는 것도 불변성을 지켜주기에 오히려 장점이 될 수도 있다

즉 성능과 가독성의 trade-off이고 ms 단위가 중요한 프로그램이 아니라면 유지보수를 위해 stream 사용하자

 

스트림의 성능과 관련해 좋은 글이 있어 첨부한다

 

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

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

jypthemiracle.medium.com

댓글
링크
글 보관함
«   2024/12   »
1 2 3 4 5 6 7
8 9 10 11 12 13 14
15 16 17 18 19 20 21
22 23 24 25 26 27 28
29 30 31
Total
Today
Yesterday