[자바 인 액션] 04 - 스트림 소개

5 minute read

Chapter 04. 스트림 소개

1. 스트림이란 무엇인가?

스트림을 이용하면 선언형1으로컬렉션 데이터를 처리할 수 있다.

  • 기존 코드(자바 7)

    • lowCaloricDishes라는 가비지 변수(컨테이너 역할만 하는 중간 변수)를 사용
    List<Dish> lowCaloricDishes = new ArrayList<>();
    for(Dish d: menu) {
        `if(d.getCalories() < 400) {
            lowCaloricDishes.add(d);
        }
    }
      
    Collections.sort(lowCaloricDishes, new Comparator<Dish>() {
        public int compare(Dish d1, Dish d2) {
            return Integer.compare(d1.getCalories(), d2.getCalories());
        }
    });
      
    List<String> lowCaloricDishesName = new ArrayList<>();
    for(Dish d: lowCaloricDishes) {
        lowCaloricDishesName.add(d.getName());
    }
    
  • 자바 8

    • 세부 구현은 라이브러리 내에서 모두 처리하고 어떻게 처리할지 질의만 정의한다.
    List<String> lowCaloricDishesName = menu.stream()
        .filter(d -> d.getCalories() < 400)
        .sorted(comparing(Dishes::getCalories))
        .map(Dish::getName)
        .collect(toList());
    
    • stream()parallelStream()으로 바꾸면 이 코드를 멀티코어 아키텍처에서 병렬로 실행할 수 있다.
    List<String> lowCaloricDishesName = menu.parallelStream()
        .filter(d -> d.getCalories() < 400)
        .sorted(comparing(Dishes::getCalories))
        .map(Dish::getName)
        .collect(toList());
    

다음 질문의 답은 7장에서 설명

  • parallelStream을 호출 했을 때 정확히 어떤 일이 일어날까?

  • 얼마나 많은 스레드가 사용되는 걸까?
  • 얼마나 성능이 좋을까?

스트림 이점

  • 선언형으로 코드를 구현할 수 있다.
    • 어떻게 동작해야하는지 지정할 필요 없이 동작의 수행만 지정
  • filter, sorted, map, collect 같은 여러 빌딩 블록 연산을 연결해서 복잡한 데이터 처리 파이프라인을 만들 수 있다.
    • 가독성과 명확성이 유지된다.

filter(sorted, map, collect) 같은 연산은 고수준 빌딩 블록 으로 이루어져 있으므로 특정 스레딩 모델에 제한되지 않고 자유롭게 어떤 상황에서든 사용할 수 있다.

(내부적으로 단일 스레드 모델에 사용할 수 있지만 멀티코어 아키텍처를 최대한 투명하게활용할 수 있게 구분되어 있다.) 결과적으로 사용자가 데이터 처리 과정을 병렬화하면서 스레드와 락을 걱정할 필요가 없다.

스트림 특징

  • 선언형 : 더 간결하고 가독성이 좋아진다.
  • 조립할 수 있음 : 유연성이 좋아진다.
  • 병렬화 : 성능이 좋아진다.

4.2 스트림 시작하기

스트림이란 정확히 뭘까?

스트림이란 ‘데이터 처리 연산을 지원하도록 소스에서 추출된 연속된 요소‘로 정의할 수 있다.

  • 연속된 요소

    컬렉션과 마찬가지로 스트림은 특정 요소 형식으로 이루어진 연속된 값 집합의 인터페이스를 제공한다.

    • 컬렉션의 주제 - 데이터

      컬렉션은 자료구조로 시간과 공간의 복잡성과 관련된 요소 저장 및 접근 연산

    • 스트림의 주제 - 계산

      표현 계산식이 주를 이룬다.

  • 소스 스트림

    컬렉션, 배열, I/O 자원 등의 데이터 제공 소스로부터 데이터를 소비(consume)한다.

    리스트로 스트림을 만들면 스트림의 요소는 리스트의 요소와 같은 순서를 유지한다.

  • 데이터 처리 연산

    스트림 연산은 순차적으로 또는 병렬로 실행할 수 있다.

스트림 특징

  • 파이프라이닝

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

    ⇒ 그 덕분에, 게으름(laziness), 쇼트서킷(short-circuiting) 최적화를 얻을 수 있다.

    연산 파이프라인은 데이터 소스에 적용하는 데이터베이스 질의와 비슷하다.

  • 내부 반복

    반복자를 이용해서 명시적으로 반복하는 컬렉션과 달리 스트림은 내부 반복을 지원한다.

연산 작업 반환타입
filter 람다를 인수로 받아 특정 요소 제외 스트림
map 람다를 이용해 한 요소를 다른 요소로 변환/ 정보 추출 스트림
limit 스트림 크기를 축소 truncate 스트림
collect 스트림을 다른 형식으로 변환 다른 형식

4.3 스트림과 컬렉션

  • 둘 다 연속된 요소 형식의 값을 저장하는 자료구조의 인터페이스를 제공한다.
  • “연속된” - “순차적으로 값에 접근한다.”는 의미

데이터를 언제 계산하느냐가 컬렉션과 스트림의 가장 큰 차이라고 할 수 있다.

컬렉션

  • 현재 자료구조가 포함하는 모든 값메모리에 저장하는 자료구조

컬렉션의 모든 요소는 컬렉션에 추가하기 전에 계산되어야 한다.

(컬렉션에 요소를 추가/삭제할 수 있다. 이런 연산을 수행할 때마다 컬렉션의 모든 요소를 메모리에 저장해야 하며 컬렉션에 추가하려는 요소는 미리 계산되어야 한다.)

  • 적극적으로 생성된다.

    즉, 모든 값을 계산할까지 기다린다.

스트림

  • 이론적으로 요청할 때만 요소를 계산하는 고정된 자료구조

(스트림에 요소를 추가/제거할 수 없다.)

  • “사용자가 요청하는 값만 스트림에서 추출한다.”는 것이 핵심

  • 스트림은 게으르게 만들어지는 컬렉션과 같다.

    즉, 사용자가 데이터를 요청할 때만 값을 계산한다.

4.3.1 딱 한 번만 탐색할 수 있다.

탐색된 스트림의 요소는 소비된다.

재 탐색하려면 초기 데이터 소스에서 새로운 스트림을 만들어야 한다.(그러려면 컬렉션처럼 반복 사용할 수 있는 데이터 소스여야 한다. 데이터 소스가 I/O 채널이라면 소스를 반복사용할 수 없으므로 새로운 스트림을 만들 수 없다.)

4.3.2 외부 반복과 내부 반복

컬렉션 인터페이스

  • 외부 반복 - 사용자가 직접 요소를 반복해야 한다. (ex. for-each)

스트림 라이브러리

  • 내부 반복을 사용 - 반복을 알아서 처리하고 결과스트림값을 어딘가에 저장해준다.
  • 함수에 어떤 작업을 수행 할 지만 지정하면 모든 것이 알아서 처리된다.
// 외부 반복
List<String> names = new ArrayList<>();
for(Dish d:menu) { // 메뉴 리스트를 명시적으로 순차 반복
    names.add(d.getName()); //이름을 추출해서 리스트에 추가
}

// 내부 반복
List<String> names = menu.stream()
  .map(Dish::getName) //map 메서드를 getName 메서드로 파라미터화해서 요리명을 추출
  .collect(toList()); //파이프라인을 실행한다. 반복자 필요없음

외부 반복보다 내부 반복이 더 좋은 이유

  • 작업을 투명하게 병렬로 처리
  • 더 최적화된 다양한 순서로 처리

스트림 라이브러리의 내부 반복데이터 표현하드웨어를 활용한 병렬성 구현을 자동으로 선택한다.

반면 for-each를 이용하는 외부 반복에서는 병렬성을 스스로 관리해야 한다.

병렬성을 스스로 관리한다” ? → 병렬성을 포기하든지 아니면 synchronized로 시작하는 힘들고 긴 전쟁을 시작함을 의미한다.

스트림은 내부 반복을 사용하므로 반복 과정을 우리가 신경 쓰지 않아도 된다. 그러려면 반복을 숨겨주는 연산 리스트가 미리 정의되어 있어야 한다. 이 연산은 람다 표현식 을 인수로 받기 때문에 동작 파라미터화를 활용할 수 있다.


4.4 스트림 연산

스트림 인터페이스의 연산을 크게 두 가지(중간 연산, 최종 연산)로 구분할 수 있다.

  • 중간 연산 - 서로 연결되어 파이프라인을 형성
  • 최종 연산 - 파이프라인을 실행한 다음에 닫는다.
List<String> names = menu.stream()
    .filter(d -> d.getCalories() > 300) // 중간 연산
    .map(Dish::getName) // 중간 연산
    .limit(3) // 중간 연산
    .collect(toList()); // 최종 연산

중간 연산

중간 연산의 중요한 특징은 단말 연산을 스트림 파이프라인에 실행하기 전까지는 아무 연산도 수행하지 않는다. 즉 게으르다(lazy)는 것이다.

스트림 파이프라인의 처리 과정을 확인해보기 위해 람다가 현재 처리 중인 데이터를 출력해서 확인해보자 (학습용으로만 )

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

// 출력 결과
// filtering pork
// mapping pork
// filtering beef
// mapping beef
// filtering chicken
// mapping chicken

출력 결과를 보자. 스트림의 게으른 특성 덕분에 몇 가지 최적화 효과를 얻을 수 있다.

  1. limit 연산 그리고 쇼트서킷 : 300 칼로리가 넘는 요리는 여러 개지만 오직 처음 3개만 선택되었다.
  2. 루프 퓨전 : filtermap은 서로 다른 연산이지만 한 과정으로 병합되었다.

중간 연산 종류

연산 형식 연산의 인수 함수 디스크립터
filter 중간 연산 Predicate T -> boolean
map 중간 연산 Function<T,R> T -> R
limit 중간 연산    
sorted 중간 연산 Conparator (T, T) -> int
distinct 중간 연산    

최종 연산

최종 연산은 스트림 파이프라인에서 결과를 도출한다. 보통 최종 연산에 의해 List, Integer, void 등 스트림 이외의 결과가 반환된다.

최종 연산 종류

연산 형식 목적 반환 형식
forEach 최종 연산 스트림의 각 요소를 소비하면서 람다를 적용한다. void를 반환한다. void
count 최종 연산 스트림의 요소 개수를 반복한다. long을 반환한다. long(generic)
collect 최종 연산 스트림을 리듀스해서 리스트, 맵, 정수 형식의 컬렉션을 만든다.  

정리

  • 스트림은 소스에서 추출된 연속 요소로, 데이터 처리 연산을 지원한다.
  • 스트림은 내부 반복을 지원한다. 내부 반복은 filter, map, sorted 등의 연산으로 반복을 추상화한다.
  • 스트림에는 중간 연산최종 연산이 있다.
  • filtermap처럼 스트림을 반환 하면서 다른 연산과 연결될 수 있는 연산을 중간 연산 이라고 한다. 중간 연산을 이용해서 파이프라인을 구성할 수 있지만 중간 연산으로는 어떤 결과도 생성할 수 없다.
  • forEachcount처럼 스트림 파이프라인을 처리해서 스트림이 아닌 결과를 반환하는 연산을 최종 연산이라고 한다.
  • 스트림의 요소는 요청할 때만 계산된다.
  1. 선언형 - 데이터를 처리하는 임시 구현 코드 대신 질의로 표현