4장: 스트림

데이터셋에 여러 복잡한 연산이 필요하다면 스트림을 사용해보자.

스트림

  • 데이터 처리 연산을 지원하도록 소스에서 추출된 연속된 요소(Sequence of elements)

  • 연속된 요소: 스트림은 컬렉션처럼 특정 요소 형식으로 이루어진 연속된 값 집합의 인터페이스를 제공한다. 컬렉션에서는 시간과 공간의 복잡성과 관련된 요소의 저장 및 접근 연산을 제공하는 반면 스트림은 표현 계산식을 제공한다. 즉, 컬렉션의 주제는 데이터고 스트림의 주제는 계산이다.

  • 소스: 스트림은 컬렉션, 배열 I/O 자원 등의 데이터 제공 소스로부터 데이터를 소비한다.

  • 데이터 처리 연산: 스트림은 함수형 프로그래밍 언어에서 일반적으로 지원하는 연산과 데이터베이스와 비슷한 연산(filter, map, reduce, find, match, sort 등)을 지원한다. 이 연산들은 순차적, 병렬로 실행할 수 있다.

  • 아래 예제를 보면 menu는 데이터 소스이며 연속된 요소를 스트림에 제공한다. 이 스트림에 filter, map, limit, collect로 이어지는 데이터 처리 연산을 적용한다. 이 때 collect를 제외한 모든 연산은 파이프라인을 형성할 수 있도록 스트림을 반환한다. 마지막으로 collect 연산으로 파이프라인을 처리해 결과를 반환한다.

List<String> threeHighCaloricDishNames = 
	menu.stream()
		.filter(dish -> dish.getCalories() > 300) // 300kcal 이상인 요리만 필터링
		.map(Dish::getName) // 요리명 추출
		.limit(3) // 3개만 선택
		.collect(toList()); // 결과를 리스트로 저장 (최종 연망ㄴㄹ)

특징

선언형

  • 선언형(데이터를 처리하는 임시 구현 코드 대신 질의로 표현)으로 컬렉션 데이터를 처리할 수 있다.

  • 즉, 컬렉션을 직접 반복하지 않고 원하는 것을 질의만 하면 얻을 수 있도록 도와준다.

  • 다음 SQL문과 같은 결과를 자바의 List<Dish>로 얻어야한다면, for문과 if문 등의 반복자, 누적자를 이용할 수 밖에 없었지만, 스트림을 사용하면 간단하게 질의만 하면 된다.

SELECT name FROM dishes WHERE calories < 400;

조립 가능

  • filter, sorted, map, collect 같은 고수준 빌딩 블록 연산을 연결해 복잡한 데이터 파이프라인을 만들 수 있다.

  • 덕분에 유연성이 좋아진다.

병렬 처리

  • 내부적으로 단일 스레드 모델에 사용할 수 있지만 멀티코어 아키텍처를 최대한 투명하게 활용할 수 있게 구현되어 특정 스레딩 모델에 제한되지 않고 자유롭게 어떤 상황에서든 이용할 수 있다.

  • 따로 멀티스레드 코드를 구현하지 않아도 데이터 처리 과정을 병렬화하면서 스레드와 락을 알아서 처리해주어 성능 향상 효과를 볼 수도 있다.

파이프라이닝

  • 대부분의 스트림 연산은 스트림 연산끼리 연결해 거대한 파이프라인을 구성할 수 있도록 스트림 자신을 반환한다. 그 덕분에 laziness, short-circuiting 같은 최적화가 가능하다. (5장 참고)

내부 반복

  • 반복자를 직접 명시해 반복하는 컬렉션과 달리 내부 반복을 지원한다.

스트림과 컬렉션

  • 자바의 컬렉션과 스트림 모두 순차적으로 값에 접근하는 연속된 요소 형식의 값을 저장하는 자료구조의 인터페이스를 제공한다.

  • DVD와 유튜브를 볼때의 차이처럼 데이터를 언제 계산하는지 차이가 있다.

    • 컬렉션은 현재 자료구조가 포함하는 모든 값을 메모리에 저장한다. 즉, 컬렉션의 모든 요소는 컬렉션에 추가하기 전에 계산되어야한다. 예를 들어 소수를 담는 컬렉션을 생성할 때에 모든 요소를 포함하려 할 것이므로 컬렉션 생성 작업이 영원히 끝나지 않을 것이다.

    • 스트림은 이론적으로 요청할 때만 요소를 계산하는 고정된 자료구조로, 스트림 자체에 요소를 추가하거나 제거할 수 없다. 오직 사용자가 요청하는 값을 스트림에서 추출할 뿐이다.

탐색은 1회뿐

  • 스트림은 반복자와 마찬가지로 단 한번만 탐색할 수 있다. 한 번 탐색한 요소를 다시 탐색하려면 초기 데이터 소스에서 새로운 스트림을 만들어야 한다. 이 때 초기 데이터가 I/O채널처럼 한번 사용되고 소멸되면 반복이 불가능하며, 컬렉션처럼 반복 사용할 수 있는 데이터 소스여야한다.

외부 반복과 내부 반복

  • for-each 문을 직접 사용자가 명시해줘야 하는 외부 반복 방법 대신 스트림에서는 내부적으로 알아서 반복하여 작업을 수행해준다.

  • 스트림은 내부 반복을 통해 병렬성을 최적화하여 관리해준다.

스트림 연산

중간 연산

  • 입력 인자를 받아 다른 스트림을 반환한다.

  • 파이프라인처럼 여러 연산을 연결할 수 있다.

  • lazy한 특성으로 인해 최종 연산을 스트림 파이프라인에 실행하기 전에는 아무 연산도 수행하지 않는다.

  • 쇼트 서킷 기법으로 최적화가 가능하다.

  • 아래 주석과 같이 서로 다른 여러 연산이 한 과정으로 병합(루프 퓨전)되어 수행된다.

List<String> names =
    menu.stream()
        .filter(dish -> {
            System.out.println("filtering: " + dish.getName());
            return dish.getCalories() > 300;
        })
        .map(dish -> {
            System.out.println("mapping: " + dish.getName());
            return dish.getName();
        })
        .limit(3)
        .collect(toList());

// filtering: pork
// mapping: pork
// filtering: beef
// mapping: beef
// filtering: chicken
// mapping: chicken

최종 연산

  • 스트림 파이프라인에서 결과를 도출해 반환한다.

  • 보통 최종 연산에 의해 List, Integer, void 등 스트림 이외의 결과가 반환된다.

스트림 순서

  • 질의를 수행할 컬렉션 같은 데이터 소스를 준비한다.

  • 스트림 파이프라인을 구성할 중간 연산들을 연결한다.

  • 스트림 파이프라인을 실행하고 결과를 만들 최종 연산을 연결해 수행한다.

스트림은 빌더 패턴에서 여러 파라미터를 설정한 후(중간 연산) build() 메서드를 호출(최종 연산)해 객체를 생성하는 방식과 유사하다.

Last updated