맞는데 왜 틀릴까..?

Java/Effective Java

[Effective Java] Item 45. 스트림은 주의해서 사용하라

안도일 2023. 7. 8. 22:54

 

 

스트림

 

  • 스트림 API는 다량의 데이터 처리 작업을 돕고자 자바 8에 추가되었다.
  • 컬렉션과 다르게 요소(element)를 저장하는 자료구조가 아닌, 데이터의 흐름을 처리하고 변환하는 파이프라인이다.
  • 스트림은 데이터를 다루는 연산들을 조합하여 복잡한 처리 과정을 간결하고 가독성 있게 표현할 수 있다.
  • 또한 내부적으로 데이터를 병렬로 처리하여 성능을 향상할 수 있는 장점도 가지고 있다. 
  • 스트림 안의 데이터 원소들은 객체 참조나 기본 타입 값(int, long, double)이다.

 

 

1. 데이터 소스

스트림은 컬렉션, 배열, I/O 채널 등 다양한 데이터 소스에서 생성할 수 있다.

 

2. 파이프라인

스트림은 연속된 작업들의 파이프라인으로 구성된다. 파이프라인은 데이터 소스에서 시작하여 중간 연산(intermediate operation)과 종단 연산(terminal operation)으로 이어지는 일련의 작업들을 의미한다.

 

3. 지연 평가

스트림은 지연 평가(lazy evaluation)을 사용한다. 중간 연산은 요청이 있을 때까지 실제로 수행되지 않으며, 종단 연산이 호출될 때에만 실행된다. 이를 통해 필요한 데이터만 처리하고, 불필요한 계산을 피할 수 있다.

 

4. 파이프라인 연산

스트림은 다양한 중간 연산과 최종 연산을 제공한다. 중간 연산은 데이터를 변환하고 필터링하며, 최종 연산은 최종 결과를 생성하거나 수집한다. 이러한 연산들을 조합하여 다양한 작업을 수행할 수 있다.

 

5. 병렬 처리

스트림은 내부적으로 병렬 처리를 지원한다. 병렬 스트림을 사용하면 멀티코어 프로세스를 활용하여 작업을 동시에 처리할 수 있어 성능을 향상할 수 있다.

 

 

중간 연산에는 filter, map, flatMap,  distinct, sorted

종단 연산에는 forEach, collect, reduce, count, max, min

 

 

스트림 사용 예시

 

 

  1. numbers 리스트의 스트림을 생성
  2. 중간 연산 filter를 통해 짝수를 필터링하고, mapToInt를 통해 각 요소를 두 배로 변환
  3. 종단 연산 sum을 호출하여 최종 결과를 계산

 

스트림 활용

 

스트림은 사실상 어떠한 계산이라도 해낼 수 있지만 할 수 있다는 뜻이지, 해야 한다는 뜻은 아니다.

스트림을 제대로 사용하면 프로그램이 짧고 깔끔해지지만, 잘못 사용하면 읽기 어렵고 유지보수도 힘들어진다.

 

다음과 같은 프로그램을 통해 예시를 알아보자.

 

  • 사전 파일에서 단어를 읽어 사용자가 지정한 문턱값보다 원소 수가 많은 아나그램 그룹을 출력하는 프로그램
  • 사용자가 명시한 사전 파일에서 각 단어를 읽어 맵에 저장. 키는 그 단어를 구성하는 철자들을 알파벳 순으로 정렬한 값

 

사전 하나를 훑어 원소 수가 많은 아나그램 그룹들을 출력

 

 

  • 맵에 각 단어를 삽입할 때 computeIfAbsent 메서드를 사용.
  • 이 메서드는 맵 안에 키가 있는지 찾은 다음, 있으면 단순히 그 키에 매핑된 값을 반환하고, 키가 없으면 건네진 함수 객체를 키에 적용하여 값을 계산한 다음 그 키와 값을 매핑해 놓고 계산된 값을 반환한다.
  • 이처럼 computeIfAbsent를 사용하면 각 키에 다수의 값을 매핑하는 맵을 쉽게 구현할 수 있다.

 

 

스트림을 과하게 사용

 

 

  • 사전 파일을 여는 부분만 제외하면 프로그램 전체가 단 하나의 표현식으로 처리된다.
  • 코드는 짧지만 읽기는 어렵다. 특히 스트림에 익숙하지 않은 나 같은 프로그래머라면 더욱 읽기 어렵다.
  • 스트림을 과용하면 프로그램이 읽거나 유지보수하기 어려워진다.

 

 

스트림을 적절히 활용

 

 

  • 앞의 두 프로그램과 기능은 같지만 스트림을 적당히 사용해 원래 코드보다 짧고 명확하게 표현했다.
  • try-with-resources 블록에서 사전 파일을 열고, 파일의 모든 라인으로 구성된 스트림을 얻는다.
  • 스트림 변수의 이름을 words로 지어 스트림 안의 각 원소가 단어임을 명확히 한다.
  • 이 스트림의 파이프라인에는 중간 연산은 없으며, 종단 연산에서는 모든 단어를 수집해 맵으로 모은다.
  • 이 맵의 values()가 반환한 값으로부터 새로운 Stream<Lits<String>> 스트림을 연다.
  • 이 리스트들 중 원소가 minGroupSize보다 적은 것은 필터링돼 무시된다.
  • 마지막으로 종단 연산인 forEach는 살아남은 리스트를 출력한다.

 

 

스트림 주의 사항

 

  • 자바는 기본 타입인 char용 스트림을 지원하지 않기 때문에 char 값들을 처리할 때는 스트림을 사용하지 말자.
  • 모든 반복문을 스트림으로 바꾸고 싶을 수 있지만, 스트림으로 바꾸는 게 가능할지라도 코드 가독성과 유지보수 측면에서 손해를 볼 수 있다. 따라서 스트림과 반복문을 적절히 조합하는 게 최선이다.
  • 기존 코드는 스트림을 사용하도록 리팩터링 하되, 새 코드가 더 나아 보일 때만 반영하자.

 

 

 

스트림으로 처리하기 어려운 상황

 

스트림 파이프라인은 되풀이되는 계산을 함수 객체(람다, 메서드 참조)로 표현한다.

반면 반복 코드에서는 코드 블록을 사용해 표현한다.

 

 

함수 객체로는 할 수 없지만 코드 블록으로는 할 수 있는 일들

 

  • 코드 블록에서는 범위 안의 지역변수를 읽고 수정할 수 있다. 하지만 람다에서는 final이거나 사실상 final인 변수만 읽을 수 있고, 지역변수를 수정하는 것은 불가능하다.
  • 코드 블록에서는 return 문을 사용해 메서드에서 빠져나가거나 break, continue 문으로 블록 바깥의 반복문을 종료하거나 반복을 건너뛸 수 있고, 메서드 선언에 명시된 검사 예외를 던질 수 있지만 람다로는 이 중 어떤 것도 할 수 없다.

따라서 계산 로직에서 이상의 일들을 수행해야 한다면 스트림과 맞지 않는다.

 

 

 

한 데이터가 파이프라인의 여러 단계를 통과할 때 이 데이터의 각 단계에서의 값들에 동시에 접근하기 어려운 경우

 

  • 스트림 파이프라인은 일단 한 값을 다른 값에 매핑하고 나면 원래의 값을 잃는 구조이므로 발생한다.
  • 원래 값과 새로운 값의 쌍을 저장하는 객체를 사용하는 방법도 있지만 코드 양도 많고 지저분하다.
  • 따라서 가능한 경우라면, 앞 단계의 값이 필요할 때 매핑을 거꾸로 수행하자.

 

 

 

스트림으로 처리하기 좋은 상황

 

  • 원소들의 시퀀스를 일관되게 변환, 필터링하기
  • 원소들의 시퀀스를 하나의 연산을 사용해 결합하기 (더하기, 연결하기, 최솟값 구하기 등)
  • 원소들의 시퀀스를 컬렉션에 모으기 (공통된 속성을 기준으로 묶어가며)
  • 원소들의 시퀀스에서 특정 조건을 만족하는 원소를 찾기

 

 

 

스트림과 반복 중 어느 쪽을 써야 할지 바로 알기 어려운 상황

 

카드 덱을 초기화하는 작업으로 예를 들어보자.

카드의 숫자(rank)와 무늬(suit) 두 집합의 원소들로 만들 수 있는 가능한 모든 조합을 계산할 때, 이 두 집합의 데카르트 곱이라고 한다.

 

 

for-each 반복문을 중첩한 코드

 

 

 

 

스트림으로 구현한 코드

 

 

  • 중간 연산의 flatMap은 스트림의 원소 각각을 하나의 스트림으로 매핑한 다음 그 스트림들을 다시 하나의 스트림으로 합친다.

 

어떠한 코드가 더 좋아 보이는지 확신이 서지 않는다면 첫 번째 방식을 쓰는 게 더 안전할 것이고, 스트림 방식이 나아 보이고 동료들도 스트림 코드를 이해할 수 있고 선호한다면 스트림 방식을 사용하자.

 

 

 

결론

 

  • 스트림을 사용해야 멋지게 처리할 수 있는 일이 있고, 반복문이 더 알맞은 일도 있다.
  • 많은 작업이 이 둘을 조합했을 때 가장 멋지게 해결된다.
  • 스트림과 반복 중 어느 쪽이 더 나은지 확신하기 어렵다면 둘 다 해보고 더 나은 쪽을 택하자.