모던 자바 인 액션 - 스트림 활용

 

Chapter5: 스트림 활용

이 장에서는 다음 항목을 설명한다.
  • 필터링, 슬라이싱, 매칭
  • 검색, 매칭, 리듀싱
  • 특정 범위의 숫자와 같은 숫자 스트림 사용하기
  • 무한 스트림

이 장에서는 스트림 API가 지원하는 다양한 연산을 살펴본다.

1. 필터링

프레디케이트로 필터링

filter 메서드는 프레디케이트를 인수로 받아서 프레디케이트와 일치하는 모든 요소를 포함하는 스트림을 반환한다.

ex) 모든 채식요리를 필터링해서 채식 메뉴를 만든다.
List<Dish> vegetarianMenu = menu.stream()
                                .filter(Dish::isVegetarian)
                                .collect(toList());

고유 요소로 필터링

스트림은 고유 요소로 이루어진 스트림을 반환하는 distinct 메서드도 지원한다.

  • 고유 여부는 hashCode, equals로 결정된다.
ex) 모든 짝수를 선택하고 중복을 필터링한다.
List<Integer> numbers = Arrays.asList(1, 2, 1, 3, 3, 2, 4);
numbers.stream()
       .filter(i -> i%2 == 0)
       .distinct()
       .forEach(System.out::println);
// 2, 4

2. 스트림 슬라이싱

스트림의 요소를 선택하거나 스킵하는 다양한 방법을 설명한다.

프레디케이트를 이용한 슬라이싱

TAKEWHILE 활용 - 자바 9

특별한 요리 목록
List<Dish> specialMenu = Arrays.asList(
                new Dish("seasonal fruit", true, 120, Type.OTHER),
                new Dish("prawns", false, 300, Dish.Type.FISH),
                new Dish("rice", true, 350, Dish.Type.OTHER),
                new Dish("chicken", false, 400, Dish.Type.MEAT),
                new Dish("french", true, 530, Dish.Type.OTHER)
        );
320칼로리 이하의 요리를 선택하는 방법? filter를 쓰면 될까?
List<Dish> filteredMenu = specialMenu.stream()
                .filter(dish -> dish.getCalories() < 320)
                .collect(toList());
  • 메뉴 리스트는 이미 칼로리 순으로 정렬되어 있다.
  • filter 연산은 전체 스트림을 반복하면서 각 요소에 프레디케이트를 적용한다.
  • 이미 정렬 되어 있다는 사실을 이용해 320칼로리보다 크거나 같은 요리가 나왔을 때 반복 작업을 중단할 수 있다.
  • 아주 많은 요소를 포함하는 큰 스트림에서는 상당한 차이를 가져올 수 있다.
takeWhile
List<Dish> filteredMenu = specialMenu.stream()
                .takeWhile(dish -> dish.getCalories() < 320)
                .collect(toList());
// seasonal fruit, prawns
  • 프레디케이트 조건에 만족하지 않으면 중단하고 반환한다.
  • 전체를 반복하는 것이 아니기 때문에 정렬이 되어있거나 특정한 경우에 사용할 수 있을 것이다.

DROPWHILE 활용 - 자바 9

takewhile과 정반대의 작업을 수행한다. 나머지 요소를 선택한다.

dropWhile
List<Dish> filteredMenu = specialMenu.stream()
                .dropWhile(dish -> dish.getCalories() < 320)
                .collect(toList());
// rice, chicken, french fries

스트림 축소

주어진 값 이하의 크기를 갖는 새로운 스트림 반환 limit(n) 메서드

ex) 300 칼로리 이상의 세 요리를 선택
List<Dish> dishes = specialMenu.stream()
                .filter(dish -> dish.getCalories() >= 300)
                .limit(3)
                .collect(toList());
// rice, chicken, french fries

요소 건너뛰기

처음 n개 요소를 제외한 스트림을 반환하는 skip(n) 메서드

ex) 300 칼로리 이상의 처음 두 요리를 건너뛴 나머지 요리
List<Dish> dishes = specialMenu.stream()
                .filter(dish -> dish.getCalories() >= 300)
                .skip(2)
                .collect(toList());
// rice, chicken, french

3. 매핑

특정 객체에서 특정 데이터를 선택하는 작업은 데이터 처리 과정에서 자주 수행되는 연산이다. 스트림 API의 map과 flatMap 메서드는 특정 데이터를 선택하는 기능을 제공한다.

스트림의 각 요소에 함수 적용하기

함수를 인수로 받는 map 메서드

인수로 제공된 함수는 각 요소에 적용되며 함수를 적용한 결과가 새로운 요소로 매핑된다.

ex) Dish::name 을 map 메서드로 전달해서 요리명을 추출하는 코드
List<String> dishNames = menu.stream()
                             .map(dish::getName)
                             .collect(toList());
ex) 단어 리스트에서 각 단어의 글자 수의 리스트를 반환하는 코드
List<String> words = Arrays.asList("Modern", "Java", "In", "Action");
List<Integer> wordLengths = words.stream()
                                 .map(String::length)
                                 .collect(toList());
ex) 요리명을 추출하고 요리명의 글자 수를 반환
List<String> dishNames = menu.stream()
                             .map(dish::getName)
                             .map(String::length)
                             .collect(toList());

스트림 평면화

flatMap은 각 배열을 스트림이 아니라 스트림의 컨텐츠로 매핑한다.

ex) 리스트에서 고유 문자로 이루어진 리스트를 반환한다.
List<String> words = Arrays.asList("Hello", "World");
List<String> uniqueCharacters =
    words.stream()
         .map(word -> word.split("")) // String[]
         .flatMap(Arrays::stream)  // 생성된 스트림을 하나의 스트림으로 평면화
         .distinct()
         .collect(toList());

flatMap 메서드는 스트림의 각 값을 다른 스트림으로 만든 다음에 모든 스트림을 하나의 스트림으로 연결하는 기능을 수행한다.


4. 검색과 매칭

특정 속성이 데이터 집합에 있는지 여부를 검색하는 데이터 처리도 자주 사용한다.

스트림 API는 allMatch, anyMatch, noneMatch, findFirst, findAny 등 다양한 유틸리티 메서드를 제공한다.

프레디케이트가 적어도 한 요소와 일치하는지 확인 - anyMatch

스트림에서 적어도 한 요소와 일치하는지 확인하는 anyMatch 메서드 - boolean을 반환

ex) menu에 채식요리가 있는지 확인한다.
if(menu.stream.anyMatch(Dish::isVegetarian)) {
    System.out.println("The menu is (somewhat) vegetarian friendly!!");
}

프레디케이트가 모든 요소와 일치하는지 검사 - allMatch

스트림의 모든 요소가 주어진 프레디케이트와 일치하는지 검사하는 allMatch 메서드 - boolean을 반환

ex) 메뉴가 건강식(모든 요리가 1000칼로리 이하) 인지 확인한다.
boolean isHealthy = menu.stream().allMatch(dish -> dish.getCalories() <= 1000);

NONEMATCH

주어진 프레디케이트와 일치하는 요소가 없는지 확인하는 nonMatch 메서드 - boolean을 반환

ex) 메뉴가 건강식(모든 요리가 1000칼로리 이하) 인지 확인한다. noneMatch로 구현
boolean isHealthy = menu.stream().noneMatch(dish -> dish.getCalories() > 1000);
쇼트서킷 평가

전체 스트림을 처리하지 않고 하나라도 거짓으로 나오면 전체가 거짓이 된다.

요소 검색

현재 스트림에서 임의의 요소를 반환하는 findAny 메서드

ex) 채식 요리를 선택한다. - filter와 findAny
Optional<Dish> dish =
    menu.stream()
        .filter(Dish::isVegetarian)
        .findAny();

Optional이란?

Optional<T> 클래스는 값의 존재나 부재 여부를 표현하는 컨테이너 클래스다.

값이 존재하는지 확인하고 값이 없을 때 어떻게 처리할지 강제하는 기능을 제공한다.

  • isPresent(): 값을 포함하면 true, 포함하지 않으면 false
  • ifPresent(Consumer<T> block): 값이 있으면 주어진 블록을 실행한다.
  • T get(): 값이 존재하면 값을 반환, 값이 없으면 NoSuchElementException을 일으킨다.
  • T orElse(T other): 값이 있으면 값을 반환, 값이 없으면 기본값을 반환한다.
ex) 이전 예제는 요리명이 null인지 검사할 필요가 없었다.
menu.stream()
    .filter(Dish::isVegetarian)
    .findAny()
    .ifPresent(dish -> System.out.println(dish.getName()));

첫 번째 요소 찾기

리스트 또는 정렬된 연속된 데이터로부터 생성된 스트림처럼 일부 스트림에는 논리적인 아이템 순서가 정해져 있을 수 있다.

스트림의 첫 번째 요소를 찾는 findFirst 메서드

ex) 리스트에서 3으로 나누어 떨어지는 첫 번째 제곱값을 반환하는 예제
List<Integer> someNumbers = Arrays.asList(1, 2, 3, 4, 5);
Optional<Integer> firstSquareDivisibleByThree = 
    someNumbers.stream()
               .map(n -> n * n)
               .filter(n -> n % 3 == 0)
               .findFirst(); // 9
findFirst와 findAny는 언제 사용하나?

병렬 실행해서 첫 번째 요소를 찾기 어렵다. 요소의 반환 순서가 상관없다면 병렬 스트림에서는 제약이 적은 findAny를 사용한다.


5. 리듀싱

이 절에서는 리듀스 연산을 이용해서 스트림 요소를 조합해서 더 복잡한 질의를 표현하는 방법을 설명한다.

리듀싱 연산: 모든 스트림 요소를 처리해서 값으로 도출, 함수형 프로그래밍에서는 폴드 라고 부른다.

요소의 합

초기 값이 있으면 초기 값을 기준으로 원소를 하나씩 가져와 누적 계산을 진행한다.

for-each 리스트의 숫자 요소를 더하는 코드
int sum = 0;
for (int x : numbers) {
    sum += x;
}
stream reduce 리스트의 숫자 요소를 더하는 코드
int sum = numbers.stream().reduce(0, (a, b) - > a + b);
// 메서드 참조 ver
int sum = numbers.stream().reduce(0, Integer::sum);
  • 초기값 0
  • 두 요소를 조합해서 새로운 값을 만드는 BinaryOperator<T>. (a, b) -> a + b
stream reduce 모든 요소에 곱셈을 적용
int product = numbers.stream().reduce(1, (a, b) - > a * b);

초깃값 없음

Optional<Integer> sum = numbers.stream().reduce((a, b) -> a + b);
  • 스트림에 아무 요소가 없는 상황에서 초깃값이 없으면 reduce는 합계를 반환할 수 없다.
  • 합계가 없음을 가리킬 수 있도록 Optional 객체로 감싼 결과를 반환한다.

최댓값과 최솟값

최댓값과 최솟값을 찾을 때 reduce를 사용할 수 있다.

Optional<Integer> max = numbers.stream().reduce(Integer::max);
Optional<Integer> min = numbers.stream().reduce(Integer::min);
reduce 메서드의 장점과 병렬화
  • reduce를 사용하면 내부 구현에서 병렬로 reduce를 실행할 수 있다.
중간 연산과 최종 연산 정리
연산 형식 반환 형식 사용된 함수형 인터페이스 형식 함수 디스크립터
filter 중간 Strem<T> Predicate<T> T -> boolean
distinct 중간 Strem<T>    
takeWhile 중간 Strem<T> Predicate<T> T -> boolean
dropWhile 중간 Strem<T> Predicate<T> T -> boolean
skip 중간 Strem<T> long  
limit 중간 Strem<T> long  
map 중간 Strem<R> Function<T, R> T -> R
flatMap 중간 Strem<R> Function<T, Stream<R>> T -> Stream<R>
sorted 중간 Strem<T> Comparator<T> (T, T) -> int
anyMatch 최종 boolean Predicate<T> T -> boolean
noneMatch 최종 boolean Predicate<T> T -> boolean
allMatch 최종 boolean Predicate<T> T -> boolean
findAny 최종 Optional<T>    
findFirst 최종 Optional<T> Consumer<T>  
forEach 최종 void Collector<T, A, R> T -> void
collect 최종 R BinaryOperator<T>  
reduce 최종 Optional<T>   (T, T) -> T
count 최종 long    

6. 실전 연습

예제 코드 - 거래자와 트랜잭션

import java.util.Objects;

public class Trader {
  private final String name;
  private final String city;

  public Trader(String n, String c) {
    name = n;
    city = c;
  }

  public String getName() {
    return name;
  }

  public String getCity() {
    return city;
  }
  
  public String toString() {
    return String.format("Trader:%s in %s", name, city);
  }
}
import java.util.Objects;

public class Transaction {
  private final Trader trader;
  private final int year;
  private final int value;

  public Transaction(Trader trader, int year, int value) {
    this.trader = trader;
    this.year = year;
    this.value = value;
  }

  public Trader getTrader() {
    return trader;
  }

  public int getYear() {
    return year;
  }

  public int getValue() {
    return value;
  }

  public String toString() {
    return String.format("%s, year: %d, value: %d", trader, year, value);
  }
}

Trader raoul = new Trader("Raoul", "Cambridge");
Trader mario = new Trader("Mario", "Milan");
Trader alan = new Trader("Alan", "Cambridge");
Trader brian = new Trader("Brain", "Cambridge");

List<Transaction> transactions = Arrays.asList(
    new Transaction(brian, 2011, 300),
    new Transaction(raoul, 2012, 1000),
    new Transaction(raoul, 2011, 400),
    new Transaction(mario, 2012, 710),
    new Transaction(mario, 2012, 700),
    new Transaction(alan, 2012, 950)
);

1. 2011년에 일어난 모든 트랜잭션을 찾아 값을 오름차순으로 정리하시오.

답안
    List<Transaction> sortedTransactions2011 =
            transactions.stream()
                    .filter(t -> t.getYear() == 2011)
                    .sorted(Comparator.comparing(Transaction::getValue))
                    .collect(Collectors.toList()); 

2. 거래자가 근무하는 모든 도시를 중복 없이 나열하시오.

답안
    List<String> cities = transactions.stream()
            .map(t -> t.getTrader().getCity())
            .distinct()
            .collect(Collectors.toList());

3. 케임브리지에서 근무하는 모든 거래자를 찾아서 이름순으로 정렬하시오.

답안
    List<Trader> sortedTraderInCambridge = transactions.stream()
            .map(Transaction::getTrader)
            .filter(t -> t.getCity().equals("Cambridge"))
            .distinct()
            .sorted(Comparator.comparing(Trader::getName))
            .collect(Collectors.toList());

4. 모든 거래자의 이름을 알파벳순으로 정렬해서 반환하시오.

답안
    String names = transactions.stream()
            .map(t -> t.getTrader().getName())
            .distinct()
            .sorted()
            .reduce("", (a, b) -> a + b);

5. 밀라노에 거래자가 있는가?

답안
    boolean milanBased = transactions.stream()
            .anyMatch(t -> t.getTrader().getCity().equals("Milan"));

6. 케임브리지에 거주하는 거래자의 모든 트랜잭션값을 출력하시오.

답안
    transactions.stream()
            .filter(t -> t.getTrader().getCity().equals("Cambridge"))
            .map(Transaction::getValue)
            .forEach(System.out::println);

7. 전체 트랜잭션 중 최댓값은 얼마인가?

답안
    Optional<Integer> max = transactions.stream()
            .map(Transaction::getValue)
            .reduce(Integer::max);

8. 전체 트랜잭션 중 최솟값은 얼마인가?

답안
    Optional<Transaction> min = transactions.stream()
            .min(Comparator.comparing(Transaction::getValue));

7. 숫자형 스트림

스트림 API 숫자 스트림을 효율적으로 처리할 수 있도록 기본형 특화 스트림(primitive stream specialization)을 제공한다.

기본형 특화 스트림

자바 8에서는 세 가지 기본형 특화 스트림을 제공한다.

  • IntStream
  • longStream
  • doubleStream

기본형 특화 스트림은 오직 박싱 과정에서 일어나는 효율성과 관련이 있으며 스트림에 추가 기능을 제공하지는 않는다.

숫자 스트림으로 매핑

스트림을 특화 스트림으로 변환할 때는 mapToInt, mapToLong, mapToDobule 메서드를 가장 많이 사용한다.

sum, max, min, average 등 다양한 유틸리티 메서드를 지원한다.

ex) 각 요리에서 모든 칼로리(Integer)를 추출한 다음에 IntStream을 반환
int calories = menu.stream()
                   .mapToInt(Dish::getCalories)
                   .sum();

객체 스트림으로 복원하기

boxed 메서드를 이용해서 특화 스트림을 일반 스트림으로 변환한다.

ex) 숫자 스트림을 스트림으로 변환
IntStream intStream = menu.stream().mapToInt(Dish::getCalories);
Stream<Integer> stream = intStream.boxed();

기본값 : OptionalInt

기본 값이 0인 문제로 잘못된 결과가 도출될 수 있다. 이를 위해 OptionalInt, OptionalLong, OptionalDouble 세 가지 기본형 특화 스트림 버전도 제공한다.

ex) 최댓값 요소
OptioanlInt maxCalories = menu.stream()
                              .mapToInt(Dish::getCalories)
                              .max();

int max = maxCalories.orElse(1); // 값이 없을 때 기본 최댓값을 명시적으로 설정

숫자 범위

  • 프로그램에서는 특정 범위의 숫자를 이용해야 하는 상황이 있다.
  • IntStream과 LongStream에서는 range와 rageClosed라는 두 가지 정적 메서드를 제공한다.
  • 첫 번째 인수로 시작 값, 두 번째 인수로 종료 값을 받는다.
    • range 메서드는 시작값과 종료값이 결과에 포함되지 않는다.
    • rangeClosed 메서드는 시작값과 종료값이 결과에 포함된다.
ex) 1부터 100까지의 숫자를 만들고 짝수를 반환
IntStream evenNumbers = IntStream.rangeClosed(1, 100)
                                 .filter(n -> n % 2 == 0);
System.out.println(evenNumbers.count()); // 50
  • range 메서드를 사용했다면 시작, 끝값이 포함되지 않으므로 49를 반환할 것이다.

숫자 스트림 활용: 피타고라스 수

a² + b² = c²

ex) 피타고라스 수를 스트림으로 계산
Stream<int[]> pythagoreanTriples = 
    IntStream.rangeClosed(1, 100).boxed()
             .flatMap(a -> 
                 IntStream.rangeClosed(a, 100)
                          .filter(b -> Math.sqrt(a*a + b*b) % 1 == 0)
                          .mapToObj(b -> new int[]{a, b, (int) Math.sqrt(a*a + b*b)})
             );
개선
Stream<double[]> pythagoreanTriples2 = 
    IntStream.rangeClosed(1, 100).boxed()
             .flatMap(a -> 
                 IntStream.rangeClosed(a, 100)
                          .mapToObj(b -> new int[]{a, b, (int) Math.sqrt(a*a + b*b)})
                          .filter(t -> t[2] % 1 == 0)
             );

8. 스트림 만들기

일련의 값, 배열, 파일, 함수를 이용한 무한 스트림 만들기 등 다양한 방식으로 스트림을 만드는 방법을 설명한다.

값으로 스트림 만들기

임의의 수를 인수로 받는 정적 메서드 Stream.of로 스트림을 만들 수 있다.

ex) 스트림의 모든 문자열을 대문자로 변환 후 문자열을 출력한다.
Stream<String> stream = Stream.of("Modern", "Java", "In", "Action");
stream.map(String::toUpperCase).forEach(System.out::println)

null이 될 수 있는 객체로 스트림 만들기 - 자바 9

자바 9에서 null이 될 수 있는 개체를 스트림으로 만들 수 있는 새로운 메서드 Stream.ofNullable이 등장했다.

ex) Stream.ofNullable 예제
// 자바 9 이전 코드
String homeValue = System.getProperty("home");
Stream<String> homeValueStream = homeValue == null ? Stream.empty() : Stream.of(value)

// 자바 9 Stream.ofNullable
Stream<String> homeValueStream = Stream.ofNullable(System.getProperty("home"));

// flatMap과 함께 사용
Stream<String> values = Stream.of("config", "home", "user")
                              .flatMap(key -> Stream.ofNullable(System.getProperty(key)));

배열로 스트림 만들기

배열을 인수로 받는 Arrays.stream으로 스트림을 만들 수 있다.

ex) 기본형 int로 이루어진 배열을 IntStream으로 변환할 수 있다.
int[] numbers = {2, 3, 5, 7, 11, 13};
int sum = Arrays.stream(numbers).sum(); // 41

파일로 스트림 만들기

파일을 처리하는 등의 I/O 연산에 사용하는 자바의 NIO API도 스트림 API를 활용할 수 있도록 업데이트 되었다.

ex) 파일에서 고유한 단어 수를 찾는 프로그램
long uniqueWords = 0;
try(Stream<String> lines = Files.lines(Paths.get("data.txt"), CharSet.defaultCharSet())) {
    uniqueWords = lines.flatMap(line -> Arrays.stream(line.split(" ")))
      .distinct()
      .count();
} catch(IOException e) {
  // 예외처리
}

함수로 무한 스트림 만들기

함수에서 스트림을 만들 수 있는 Stream.iterate와 Stream.generate를 제공한다.

iterate 메서드

Stream.iterate(0, n -> n + 2)
      .limit(10)
      .forEach(System.out::println);
  • 초기값과 람다를 인수로 받아서 새로운 값을 끊임없이 생산한다.
  • 예제는 이전 결과에 2를 더한 값을 반환한다. 0, 2, 4, 6 …
  • 요철할 때마다 값을 생산할 수 있으며 끝이 없으므로 무한 스트림을 만든다. (언바운드 스트림)

generate 메서드

generate는 iterate와 달리 생산된 각 값을 연속적으로 계산하지 않는다. Supplier<T> 를 인수로받아 새로운 값을 생산한다.

Stream.generate(Math::random)
      .limit(5)
      .forEach(System.out::println);

9. 마치며

  • 스트림 API를 이용하면 복잡한 데이터 처리 질의를 표현할 수 있다.
  • filter, distinct, takeWhile(자바 9), dropWhile(자바 9), skip, limit 메서드로 스트림을 필터링하거나 자를 수 있다.
  • 소스가 정렬되어 있다는 사실을 알고 있을 때 takeWhile, dropWhile 메서드를 효과적으로 사용할 수 있다.
  • map, flatMap 메서드로 스트림의 요소를 추출하거나 변환할 수 있다.
  • findFirst, findAny 메서드로 스트림의 요소를 검색할 수 있다. allMatch, noneMatch, anyMatch 메서드를 이용해서 주어진 프레디케이트와 일치하는 요소를 스트림에서 검색할 수 있다.
  • 이들 메서드는 쇼트서킷, 즉 결과를 즉시 반환하며, 전체 스트림을 처리하지 않는다.
  • reduce 메서드로 스트림의 모든 요소를 반복 조합하며 값을 도출할 수 있다.
  • filter, map 등은 상태를 저장하지 않는 상태 없는 연산이다. reduce 값은 연산은 값ㄷ을 계산하는 데 필요한 상태를 저장한다. sorted, distinct 등의 메서드는 새로운 스트림을 반환하기에 앞서 스트림의 모든 요소를 버퍼에 저장해야 한다. 이런 메서드를 상태 있는 연산이라고 부른다.
  • IntStream, DoubleStream, LongStream은 기본 특화형 스트림이다.
  • 컬렉션뿐 아니라 값, 배열, 파일, iterate, generate 같은 메서드로도 스트림을 만들 수 있다.
  • 무한한 개수의 요소를 가진 스트림을 무한 스트림이라 한다.