Java8 - Stream


이 포스트에서는 스트림에 대해 알아본 후 컬렉션과 스트림의 차이, 외부 반복과 내부 반복, 마지막으로 스트림의 중간 연산과 최종 연산에 대해 알아본다.

소스는 github 에 있습니다.


목차


1. 스트림

스트림은 데이터 처리 연산을 지원하도록 소스에서 추출된 연속된 요소 이다.
스트림을 이용하면 선언형으로 컬렉션 데이터를 처리할 수 있다. (= 데이터를 처리하는 구현 코드 대신 SQL 문처럼 질의로 표현 가능)

스트림 인터페이스는 java.util.stream.Stream 참고!

선언형으로 데이터 처리
SELECT name FROM students WHERE age > 10; 처럼 나이를 이용하여 어떻게 필터링할 것인지 구현할 필요없이 질의문 자체로 기능 구현

연속된 요소
여기서 말하는 연속은 순차적으로 값에 접근한다는 것을 의미함
컬렉션처럼 스트림은 특정 요소 형식으로 이루어진 연속된 값 집합의 인터페이스 제공
컬렉션은 자료구조이므로 시간과 공간의 복잡성과 관련된 요소 저장 및 접근 연산이 주를 이룸 (= 컬렉션의 주제는 데이터)
스트림은 filter, map 처럼 표현 계산식이 주를 이룸 (= 스트림의 주제는 계산)

또한 스트림을 이용하면 요소가 많은 커다란 컬렉션의 경우 멀티코어 아키텍처를 활용하여 병렬로 컬렉션의 요소를 처리해야 하는데 이러한 병렬 처리 코드를 쉽게 구현할 수 있다. (= 멀티 스레드 코드를 구현하지 않아도 데이터를 투명하게 병렬로 처리)

스트림의 병렬 처리는 Java8 - Stream 으로 병렬 데이터 처리 (1): 병렬 스트림, 포크/조인 프레임워크Java8 - Stream 으로 병렬 데이터 처리 (2): Spliterator 인터페이스 를 참고하세요.

아래는 이 포스트에서 계속 사용하게 될 Dish 불변형(immutable) 클래스이다.

immutable 클래스는 Spring Boot - Spring bean, Spring bean Container, 의존성9. Spring bean, Java bean, DTO, VO 를 참고하세요.

import java.util.Arrays;
import java.util.List;

public final class Dish {
  private final String name;
  private final boolean vegetarian;
  private final int calories;
  private final Type type;

  public Dish(String name, boolean vegetarian, int calories, Type type) {
    this.name = name;
    this.vegetarian = vegetarian;
    this.calories = calories;
    this.type = type;
  }
  public String getName() {
    return name;
  }

  public boolean isVegetarian() {
    return vegetarian;
  }

  public int getCalories() {
    return calories;
  }

  public Type getType() {
    return type;
  }

  public enum Type {
    MEAT, FISH, OTHER
  }

  @Override
  public String toString() {
    return "Dish{" +
            "name='" + name + '\'' +
            ", vegetarian=" + vegetarian +
            ", calories=" + calories +
            ", type=" + type +
            '}';
  }

  public static final List<Dish> menu =
          Arrays.asList( new Dish("pork", false, 800, Dish.Type.MEAT),
                  new Dish("beef", false, 700, Dish.Type.MEAT),
                  new Dish("chicken", false, 400, Dish.Type.MEAT),
                  new Dish("french fries", true, 530, Dish.Type.OTHER),
                  new Dish("rice", true, 350, Dish.Type.OTHER),
                  new Dish("season fruit", true, 120, Dish.Type.OTHER),
                  new Dish("pizza", true, 550, Dish.Type.OTHER),
                  new Dish("prawns", false, 400, Dish.Type.FISH),
                  new Dish("salmon", false, 450, Dish.Type.FISH));
}

아래는 Java7 로 저칼로리 요리의 요리명을 다시 칼로리 기준으로 정렬하는 코드를 구현한 예시이다.

public static List<String> getLowCaloricDishesNamesInJava7(List<Dish> dishes) {
  List<Dish> lowCaloricDishes = new ArrayList<>();  // 가비지 변수, 컨테이너 역할만 하는 중간 변수

  for (Dish d: dishes) {
    if (d.getCalories() < 400) {  // 저칼로리 필터링
      lowCaloricDishes.add(d);
    }
  }

  // 익명 클래스로 요리 정렬
  Collections.sort(lowCaloricDishes, new Comparator<Dish>() {
    @Override
    public int compare(Dish o1, Dish o2) {
      return Integer.compare(o1.getCalories(), o2.getCalories()); // 오름차순으로 정렬
    }
  });

  List<String> lowCaloricDishesName = new ArrayList<>();

  // 정렬된 요리에서 이름 추출
  for (Dish d: lowCaloricDishes) {
    lowCaloricDishesName.add(d.getName());
  }

  return lowCaloricDishesName;
}
// season fruit
// rice
getLowCaloricDishesNamesInJava7(Dish.menu).forEach(System.out::println);

// 아래는 참고
// [season fruit, rice]
System.out.println(getLowCaloricDishesNamesInJava7(Dish.menu));

위 코드를 Java8 의 스트림을 이용하면 아래와 같다.

public static List<String> getLowCaloricDishesNamesInJava8(List<Dish> dishes) {
  return dishes.stream()
          .filter(d -> d.getCalories() < 400) // 저칼로리 필터링
          .sorted(comparing(Dish::getCalories)) // 오름차순으로 정렬
          .map(Dish::getName) // 정렬된 요리에서 이름 추출
          .collect(toList()); // 요리명을 리스트로 변환
}

map 은 람다를 이용해서 한 요소를 다른 요소로 변환하거나 정보를 추출한다. (위에선 메서드 레퍼런스인 Dish::getName 를 전달해서 요리명을 추출)

collect 는 파이프라인을 실행한 후 닫는다. (스트림을 다른 형식으로 변환, 위에선 스트림을 리스트로 변환)

collect 연산 방법은 Java8 - Stream 으로 데이터 수집 (1): Collectors 클래스, Reducing 과 Summary, Grouping 을 참고하세요.

Dish::getName 메서드 레퍼런스를 람다 표현식으로 표현하면 d -> d.getName()

위 코드에서 stream() 을 parallel() 으로 변경하면 멀티코어 아키텍처에서 병렬로 실행된다.

parallel() 으로 호출 시 어떤 일이 발생하고 어느 정도의 스레드가 사용되며, 성능 향상은 어느 정도인지에 대한 자세한 부분은 Java8 - Stream 으로 병렬 데이터 처리 (1): 병렬 스트림, 포크/조인 프레임워크1. 병렬 스트림 를 참고하세요.

filter, sorted, map, collect 등과 같은 연산은 구수준 빌딩 블록으로 이루어져 있어서 특정 스레딩 모델에 국한되지 않고 어떤 상황에서도 사용할 수 있으며, 내부적으로는 단일 스레드 모델에 사용할 수 있지만 멀티코어 아키텍처를 활용할 수 있도록 구현되어 있기 때문에 데이터 처리 과정을 병렬화하면서 스레드와 락을 걱정할 필요가 없다.

위처럼 스트림 API 사용 시 아래와 같은 이점을 얻을 수 있다.

  • 선언형: 간결하고 가독성이 좋음
  • 조립형: 유연성이 좋음
  • 병렬화: 성능이 좋음

스트림의 아래 두 가지 특징이 있다.

  • 파이프라이닝
    • 스트림 연산끼리 연결 가능하도록 스트림 자신을 반환하기 때문에 Laziness, Short-circuiting 과 같은 최적화를 얻을 수 있음
  • 내부 반복

Laziness, Short-circuiting (쇼트 서킷) 과 같은 최적화 와 관련된 내용은 Java8 - Stream 활용 (1): 필터링, 슬라이싱, 매핑, 검색, 매칭3.3. Predicate<T> 가 모든 요소와 일치하지 않는지 확인: noneMatch() 를 참고하세요.

바로 위의 코드를 다시 보자.

public static List<String> getLowCaloricDishesNamesInJava8(List<Dish> dishes) {
  return dishes.stream()
          .filter(d -> d.getCalories() < 400) // 저칼로리 필터링
          .sorted(comparing(Dish::getCalories)) // 오름차순으로 정렬
          .map(Dish::getName) // 정렬된 요리에서 이름 추출
          .collect(toList()); // 요리명을 리스트로 변환
}

collect 를 호출하기 전까지는 아무것도 선택되지 않고 출력 결과도 없다. 즉, collect 가 호출되지 전까지 메서드 호출이 저장되는 효과가 있다.

컬렉션 제어 시 자주 사용되는 라이브러리


2. 스트림 API 와 컬렉션 API 의 차이

컬렉션은 현재 자료구조가 포함하는 모든 값을 메모리에 저장하는 자료구조이다. (컬렉션에 요소를 추가하거나 삭제할 수 있는데 이때마다 컬렉션의 모든 요소를 메모리에 저장)
스트림은 요청할 때만 요소를 계산하는 고정된 자료구조이다. (스트림에는 요소를 추가하거나 제거할 수 없음)
즉, 데이터를 언제 계산하느냐가 컬렉션과 스트림의 가장 큰 차이이다.


2.1. 한 번만 탐색 가능

반복자처럼 스트림도 한 번만 탐색이 가능하다. 탐색된 스트림 요소는 소비되므로 한번 탐색한 요소를 다시 탐색하려면 초기 데이터 소스에서 새로운 스트림을 생성해야 한다.

List<String> words = Arrays.asList("abc", "de", "fgh");
Stream<String> s = words.stream();

s.forEach(System.out::println);

// java.lang.IllegalStateException: stream has already been operated upon or closed 발생
s.forEach(System.out::println);

2.2. 외부 반복과 내부 반복

컬렉션 인터페이스를 사용하려면 개발자가 for-each 등으로 직접 요소를 반복해야 하는데 이를 외부 반복이라고 한다.
스트림 라이브러리는 내부 반복을 사용한다.

외부 반복

List<String> names = new ArrayList<>();
for (Dish d: Dish.menu) {
  names.add(d.getName());
}

내부 반복

List<String> names2 = Dish.menu.stream()
            .map(Dish::getName) // map 메서드를 getName 메서드로 파라메터화해서 이름 추출
            .collect(toList()); // 파이프라인 실행, 반복자 필요없음

컬렉션은 외부적으로 반복하기 때문에 명시적으로 항목을 하나씩 가져와서 처리하는 반면 내부 반복을 사용하면 병렬로 처리 가능하다.


3. 스트림 연산

아래 코드에서 filter, map, limit 처럼 서로 연결되어 파이프라인을 형성하는 연산을 중간 연산이라고 하고, collect 처럼 파이프라인을 실행한 후 스트림을 닫는 연산을 최종 연산이라고 한다.

List<String> names = dishes.stream()  // 스트림 얻음
        .filter(d -> d.getCalories() > 100) // 중간 연산
        .map(Dish::getName) // 중간 연산 
        .limit(3) // 중간 연산
        .collect(toList()); // 최종 연산

3.1. 중간 연산

filter 나 map 은 다른 스트림을 반환하기 때문에 또 다른 중간 연산을 연결해서 질의를 만들 수 있다.
중간 연산을 합친 다음 합쳐진 중간 연산을 최종 연산으로 한번에 처리하기 때문에 최종 연산이 실행되기 전까진 아무 연산도 수행하지 않는다. (= Lazy)

아래 코드는 중간 과정을 보기 위한 코드로 운영에 적용하기에 좋지 않은 코드이므로 참고만 하자.

List<String> names = dishes.stream()
            .filter(d -> {
              System.out.println("filter: "+ d.getName());
              return d.getCalories() > 100;
            }) // 중간 연산
            .map(d -> {
              System.out.println("map: "+ d.getName());
              return d.getName();
            }) // 중간 연산
            .limit(3) // 중간 연산
            .collect(toList()); // 최종 연산
filter: pork
map: pork
filter: beef
map: beef
filter: chicken
map: chicken
[pork, beef, chicken]

100 칼로리가 넘는 것은 여러 개가 있지만 limit 연산에 의해 처음 3개만 선택되었다. 이를 쇼트 서킷 (Short-circuit) 이라고 한다.(모든 것을 iteration 하지 않고 일찍 끝냄)
또한 filter, map 이 서로 다른 연산이지만 한 과정으로 병합된 것을 알 수 있다. 이렇게 다른 연산이 한 과정으로 병합되는 것을 루프 퓨전(loop fusion) 이라고 한다.

쇼트 서킷에 대한 좀 더 자세한 내용은 Java8 - Stream 활용 (1): 필터링, 슬라이싱, 매핑, 검색, 매칭3.3. Predicate<T> 가 모든 요소와 일치하지 않는지 확인: noneMatch() 를 참고하세요.


3.2. 최종 연산

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


3.3. 스트림 이용 과정

스트림 이용 과정은 아래와 같이 3 가지로 요약 가능하다.

  • 질의를 수행할 (컬렉션 같은) 데이터 소스
  • 스트림 파이프라인을 구성할 중간 연산 연결
  • 스트림 파이프라인을 실행하고 결과를 만들 최종 연산

중간 연산 (이 외에도 다양한 중간 연산이 있다)

연산반환 형식연산의 인수함수 디스크립터
filterStream<T>Predicate<T>T -> boolean
mapStream<T>Function<T,R>T -> R
limitStream<T>  
sortedStream<T>Comparator<T>(T,T) -> int
distinctStream<T>  

최종 연산 (이 외에도 다양한 최종 연산이 있다)

연산목적
forEach스트림의 각 요소를 소비하면서 람다 적용, void 반환
count스트림의 요소 개수 반환, long 반환
collect스트림을 reduce 해서 리스트, 맵, 정수 형식의 컬렉션 생성

정리하며..

  • 스트림은 소스에서 추출된 연속 요소로 데이터 처리 연산을 지원
  • 스트림은 내부 반복을 지원
  • 내부 반복은 filter, map, sorted 등의 연산으로 반복을 추상화함
  • 스트림에는 중간 연산과 최종 연산이 있음
  • 중간 연산은 스트림을 반환하여 다른 연산과 연결될 수 있는 연산으로, 중간 연산을 이용해서 파이프라인 구성은 가능하지만 어떤 결과도 생성할 수 없음
  • 최종 연산은 스트림 파이프라인을 처리해서 스트림이 아닌 결과를 반환하는 연산
  • 중간 연산은 스트림의 요소를 소비하지 않고, 최종 연산은 스트림의 요소를 소비하여 최종 결과 도출
  • 스트림의 요소는 요청할 때만 계산됨

참고 사이트 & 함께 보면 좋은 사이트

본 포스트는 라울-게이브리얼 우르마, 마리오 푸스코, 앨런 마이크로프트 저자의 Java 8 in Action을 기반으로 스터디하며 정리한 내용들입니다.






© 2020.08. by assu10

Powered by assu10