자바 뚝딱거리기

[JAVA] 스트림 소개

bimppap 2021. 3. 15. 20:27

많은 요소를 포함하는 커다란 컬렉션은 어떻게 처리해야 할까?

- 0. 스트림이란?

스트림 stream
데이터 처리 연산을 지원하도록 소스에서 추출된 연속된 값 요소

 

스트림은 쉽게 말하자면 데이터 컬렉션 반복을 멋지게 처리하는 기능이다. 멋지다는 게 무슨 의미인가?

아래 코드를 보자. 저칼로리의 요리명을 반환하고, 칼로리를 기준으로 요리를 정렬하는 기능을 구현한 코드다.

// 스트림 미사용
List<Dish> lowCaloricDishes = new ArrayList<>();
for(Dish dish : menu) {
	if(dish.getCalories() < 400) {
    	lowCalroicDishes.add(dish);
    }
}

Collections.sort(lowCaloricDishes, new Comparator<Dish> () {
	public int compare(Dish dish1, Dish dish2) {
    	return Integer.compare(dish1.getCalories(), dish2.getCalories());
    }
});

List<String> lowCaloricDishesName = new ArrayList<>();
for(Dish dish : lowCaoricDishes) {
	lowCaloricDishesName.add(dish.getName());
}

스트림을 사용하면 동일한 기능을 아래와 같이 처리할 수 있다.

// 스트림 사용
List<String> lowCaloricDishesName =
	menu.stream()
    	.filter(dish -> dish.getCalories() < 400)
        .sorted(comparing(Dish::getCalories))
        .map(Dish::getName)
        .collect(toList());

 

두 코드의 차이점은 뭘까? 스트림의 특징과 함께 설명해보겠다.

 

- 1. 스트림의 특징

 

1. 선언형 - 간결해진 코드! 가독성 좋은 코드!

스트림 미사용 코드는 루프와 for-each, if 조건문 등의 제어 블록을 사용해 어떻게 동작을 구현할지 지정해준다. 즉, 원하는 결과를 얻기 위해 일련의 과정을 하나하나 지시해줘야 한다는 것이다. 이 과정을 외부 반복 이라 한다.

그러나 스트림은 동작의 수행만 지정하면 된다. 명령을 내리면 알아서 뚝딱뚝딱 만들어온다. 이는 내부 반복 이라 한다.

내부 반복과 외부 반복의 차이

내부 반복을 이용하면 작업을 투명하게 병렬로 처리하거나 더 최적화된 다양한 순서로 처리할 수 있다.

(여기서 데이터 표현과 하드웨어를 활용한 병렬성 구현 또한 스트림이 자동으로 선택한다. 프로그래머가 직접 처리해야 할 일이 줄어드는 것이다!)

 

2. 조립 가능 - 유연성 좋은 코드!

스트림 API가 제공하는 filtr, sorted, map, collect 등의 연산은 고수준 빌딩 블록으로 이루어져 있다.

우리 는 이 연산들, 즉 빌딩 블록들을 연결해 데이터 처리 파이프라인을 만들 수 있다.

파이프라인을 어업에 비유. 각 블록마다 데이터 처리를 수행한다

아래 코드를 보자.

스트림의 각 연산으로 데이터를 처리할 때마다 우측에 어떤 타입을 반환하는 지 보여준다. 보다시피 중간 연산자는 Stream<T> 반환을 주로 한다.

대부분의 스트림 연산은 스트림 연산끼리 연결해 위 같은 파이프라인을 구성할 수 있도록 스트림 자신을 반환한다.

덕분에 게으름, 쇼트 서킷같은 최적화도 얻을 수 있다. (게으름은 하단의 네 번째 특징을, 쇼트 서킷은 5장.스트림 활용 을 참고하자.)

 

3. 병렬화 - 성능 좋은 코드!

데이터의 양이 많을 경우 parallel, parallelStream 등을 이용해 데이터를 병렬화하여 빠르게 처리할 수 있다.

이 과정에서 스레드와 락을 걱정할 필요도 없다! 자세한 얘기는 모던 자바 인 액션 7장. 병렬 스트림에서 다루니 참고 바란다.

 

4. 요청 중심 제조 - 필요한 때만 사용하기!

컬렉션과 스트림의 가장 큰 차이점은 데이터를 언제 계산하느냐에 달려있다. 예시로 DVD와 인터넷 스트리밍을 생각해보자.

DVD를 보기 위해선 모든 프레임을 내려받고 재생을 하게 된다. 반면 인터넷 스트리밍은 사용자가 시청하는 부분의 프레임을 미리 내려받는다. 그 후의 프레임은 아직 받아놓지 않았음에도 불구하고 우리는 계속 영상을 시청할 수 있다.

유튜브 영상에서 우리가 보고 있는 구간의 뒷부분의 '일부'만 미리 내려받는 게 스트리밍의 핵심이다.

컬렉션과 스트림 둘 다 연속된 요소 형식의 값을 저장하는 자료구조의 인터페이스를 제공한다. 하지만 컬렉션은 현재 자료구조가 포함하는 모든 값을 메모리에 저장하는 자료구조다. 스트림은 요청할 때만 요소를 계산하는 고정된 자료구조다. 게으르게 만들어지는 컬렉션과 같다. 때문에 스트림은 무한 스트림을 생성할 수 있다.

 

- 3. 스트림을 사용해보자

 

스트림의 특징을 모두 둘러봤으니 정의를 다시 한번 곱씹어보자. 데이터 처리 연산을 지원하도록 소스에서 추출된 연속된 값 요소... 이게 무슨 소리일까?

 

  • 연속된 요소
    스트림은 컬렉션과 마찬가지로 특정 요소 형식으로 이루어진 연속된 값 집합의 인터페이스를 제공한다.
  • 소스
    스트림은 컬렉션, 배열, I/O 자원 등의 데이터 제공 소스로부터 데이터를 소비한다.
    (소비한다는 건 딱 한 번만 탐색할 수 있다는 뜻이다)
    정렬된 컬렉션으로 스트림을 생성하면 정렬이 그대로 유지된다.
  • 데이터 처리 연산
    스트림은 함수형 프로그래밍 언어에서 일반적으로 지원하는 연산과 데이터베이스와 비슷한 연산을 지원한다.
    스트림 연산은 순차적 또는  병렬로 실행할 수 있으며 파이프라인을 형성하는 중간 연산스트림을 닫는 최종 연산이 있다.

빠른 이해를 위해 사용 예시를 보도록 하자.

1) 코드에서 menu는 Dish 라는 객체를 담은 리스트이다. 즉, 소비될 데이터 제공 소스이다.

2) 스트림 API는 stream()을 통해 menu를 Dish 객체들로 이루어진 연속된 값 집합(스트림)을 만들어낸다.

3) 이후 중간 연산 filter, map, limit 를 거친다.

    - 중간 연산은 단말 연산을 스트림 파이프라인에 실행하기 전까지 아무 연산도 수행하지 않는다.

       중간 연산을 합친 다음에 합쳐진 중간 연산을 최종 연산으로 한 번에 처리한다.

    - 위 코드의 플로우를 보자면 limit(3)에 3개를 만족할 때까지 filter -> map -> filter -> map -> (반복) 의 순서로 데이터를 처리한다.

       filter 와 map이 다른 연산이지만 한 과정으로 병합된 것이다. 이를 루프 퓨전이라고 한다.

4) 최종 연산 collect 를 통해 스트림 파이프라인에서 결과를 도출한다. 보통 스트림 이외의 결과가 반환된다.

 

 

스트림의 기본적인 이해는 여기까지다. 더 다양한 스트림 연산은 5장에서 확인해보자.

 

 

참고

[ 모던 자바 인 액션 ] Ch4. 스트림 소개