5장. 스트림 활용

Posted by yunki kim on February 18, 2022

  스트림은 내부 반복을 사용하고 있기 때문에 스트림 API가 내부에서 데이터를 처리하는 방식을 관리한다. 따라서 내부적으로 다양한 최적화가 이루어질 수 있다. 

  이번 장에서는 자바 8, 9에서 추가된 스트림 연산을 살펴본다. 

필터링

  스트림에서 필요한 요소만 선택하는 방법이다.

프레디케이트로 필터링

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

1
2
3
4
List<Integer> oddNumbers = numbers.stream()
    .filter(number -> number % 2 != 0)
    .collect(toList());
 
cs

고유 요소 필터링

  고유(unique)한 값만 추출하길 원하면 distinct 메서드를 사용하면 된다. distinct는 고유 여부를 스트림에서 만든 객체의 hashCode, equals로 결정한다.

1
2
3
4
5
6
List<Integer> numbers = Arrays.asList(124331);
List<Integer> oddNumbers = numbers.stream()
    .filter(number -> number % 2 != 0)
    .distinct()
    .collect(toList()); // 1, 3
 
cs

슬라이싱(자바 9)

  스트림의 요소를 선택하거나 스킵할 수 있다.

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

  자바 9는 스트림 요소를 효과적으로 선택할 수 있게 takeWhile, dropWhile 메서드를 제공한다.

TAKEWHILE 활용

  takeWhile 메서드는 스트림을 순회하면서 인자로 넘긴 프레디케이트가 false가 되면 작업을 중단하고 이전까지의 값을 반환한다. 예를 들어 1~9까지 정렬된 리스트가 있을 때 5까지의 값을 추출한다 해보자. filter를 상요하면 전체 스트림을 반복하고 각 요소에 프레디케이트를 적용한다. 하지만 takeWhile 메서드는 프레디케이트가 참일 때 까지만 연산을 수행하므로 연산 비용을 아낄 수 있다.

1
2
3
4
5
List<Integer> numbers = Arrays.asList(123456789);
List<Integer> oddNumbers = numbers.stream()
    .takeWhile(number -> number <= 5)
    .colelct(toList()); // 1, 2, 3, 4, 5
 
cs

DROPWHILE활용

  dropWhile은 takeWhile과 반대로 프레디케이트가 처음으로 거짓이 되는 지접까지 발견된 요소를 버리고 나머지 요소를 반환한다.

1
2
3
4
5
List<Integer> numbers = Arrays.asList(123456789);
List<Integer> oddNumbers = numbers.stream()
    .dropWhile(number -> number <= 5)
    .colelct(toList()); // 6, 7, 8, 9
 
cs

스트림 축소

  limit(n) 메서드는 n이하의 크기를 갖는 새로운 스트림을 반환한다. 

1
2
3
4
5
6
List<Integer> numbers = Arrays.asList(123456789);
List<Integer> oddNumbers = numbers.stream()
    .dropWhile(number -> number <= 5)
   .limit(2)
    .colelct(toList()); // 6, 7
 
cs

요소 건너뛰기

  skip(n)은 처음 n개 요소를 제외한 스트림을 반환한다. n 개 이하의 요소를 포함하는 스트림에 skip(n)을 호출하면 빈 스트림을 반환한다. 

1
2
3
4
5
6
List<Integer> numbers = Arrays.asList(123456789);
List<Integer> oddNumbers = numbers.stream()
    .dropWhile(number -> number <= 5)
    .skip(2)
    .colelct(toList()); // 3, 4, 5
 
cs

매핑

  스트림에서는 map과 flatMap 메서드를 활용해 특정 데이터를 선택하는 기능을 제공한다.

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

  map은 함수를 인자로 받는다. 인수로 제공된 함수는 각 요소에 적용되며 적용한 결과가 새로운 요소로 매핑된다. 여기서 map은 기존의 값을 고치는게 아니기 때문에 '새로운 버전을 만든다'라는 개념이므로 변환(transforming)에 가까운 mapping이란 단어를 사용한다.

1
2
3
4
5
List<Integer> numbers = Arrays.asList(123456789);
List<Integer> oddNumbers = numbers.stream()
    .map(number -> number * 2)
    .colelct(toList()); // 1, 4, 6, 8, 10, 12, 14, 16, 18
 
cs

스트림 평면화

  flapMap 메서드는 스트림의 각 값을 다른 스트림으로 만들고 모든 스트림을 하나의 스트림으로 연결하는 기능을 수행한다. 이런 연산이 필요한 이유는 map을 통해 생성된 타입이 원치 않는 타입일 경우가 존재하기 때문이다.

  예를 들어 ["hello", "world"] 리스트가 있고 이를 고유 문자로 이뤄진 리스트로 반환해야 한다면 다음과 같은 코드는 원하는 결과를 반환하지 못한다.

1
2
3
4
5
6
7
words.stream()
    .map(word -> word.split(""))
    .distinct()
    .collect(toList());
// expected: ["H", "e", "l", "O", "W", "r", "d"];
// actual: ["Hello", "World"]
 
cs

  이런 현상이 발생하는 이유는 map의 반환 타입이 Stream<String>이 아닌 Stream<String[]>이기 때문이다. 따라서 Stream<String[]>의 각 값을 다른 스트림으로 만들고 모든 스트림을 하나의 스트림을 합쳐서 Stream<String>을 만드는 과정이 필요하다. 이때, flatMap 메서드를 사용하면 된다.

1
2
3
4
5
6
words.stream()
    .map(word -> word.split("")) // return type: Stream<String[]>
    .flatMap(Arrays::stream) // return type: Stream<String>
    .distinct()
    .collect(toList());
// result: ["H", "e", "l", "O", "W", "r", "d"];
cs

검색과 매칭

  특정 속성이 데이터 집합에 있는지를 검사하기 위해 사용되는 스트림API가 존재한다.

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

  anyMatch 메서드는 프레디케이트가 주어진 스트림에서 적어도 한 요소와 일치하는지 확인한다. 

1
2
3
4
List<Integer> numbers = Arrays.asList(12);
numbers.stream().anyMatch(1); // true
numbers.stream().anyMatch(3); // false
 
cs

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

  allMatch 메서드는 프레디케이트가 주어진 스트림에 모두 만족하는지 검사한다.

1
2
3
4
List<Integer> numbers = Arrays.asList(12);
numbers.stream().allMatch(number -> number < 3); // true
numbers.stream().allMatch(number -> number > 2); // false
 
cs

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

  noneMatch 메서드는 프레디케이트가 주어진 스트림에 모두 만족하지 않는지 검사한다.

1
2
3
List<Integer> numbers = Arrays.asList(12);
numbers.stream().noneMatch(number -> number != 3); // true
numbers.stream().noneMatch(number -> number != 2); // false
cs

 

  위에서 다른 anyMatch, allMatch, noneMatch 메서드는 스트림 쇼트서킷을 사용한다.

요소 검색

  findAny 메서드는 현재 스트림에서 임의의 요소를 반환하며 다른 스트림 연산과 연결해 사용할 수 있다.

1
2
3
4
5
6
List<Integer> numbers = Arrays.asList(12);
Optional<Integer> answer = numbers.stream()
    .filter((number) -> number > 1)
    .findAny();
System.out.println(num.get()); // 2
 
cs

  스트림 파이프라인은 내부적으로 단일 과정으로 실행할 수 있게 최적화 되있다. 즉, 쇼트서킷을 이용해 결과를 찾는 즉시 실행을 종료한다.

Optional

  위 코드에서 사용된 Optional은 값의 존재 여부를 표현하는 클래스다. findAny 메서드는 값이 없으면 null을 반환하는데 이는 에러의 요인이 된다. 따라서 Optional을 사용해 값이 존재하는지 확인하고 없다면 처리 방식을 강제할 수 있다.

  다음은 Optional이 제공하는 인스턴스 메서드 목록이다.

메서드 설명
isPresent() Optional이 값을 포함하면 참을 반환하고 아니면 false를 반환한다
ifPresend(Customer<T> block) 값이 있으면 주어진 블록을 실행한다. (T) -> void
T get() 값이 존재하면 값을 반환하고 없으면 NoSuchElementException을 일으킨다
T orElse(T other) 값이 있으면 값을 반환하고, 없으면 기본값을 반환한다.

첫 번째 요소 찾기

  리스트 또는 정렬된 연속 데이터로부터 생성된 스트림처럼 일부 스트림에는 논리적인 아이템 순서가 정해져 있을 수 있다. 여기서 첫 번째 요소를 찾기 위해서는 findFirst() 메서드를 사용하면 된다.

1
2
3
4
5
6
List<Integer> someNumbers = Arrays.asList(12345);
Optional<Integer> firstSquareDivisibleByThree = someNumbers.stream()
    .map(n -> n * n)
    .filter(n -> n % 3 == 0)
    .findFirst(); // 9
 
cs

  잠시 findFirst()와 findAny()를 비교해 보자. findAny()는 현재 스트림에서의 임의의 요소를 반환하지만 쇼트 서킷을 사용하기 때문에 임의의 요소 중 첫 번째을 반환한다. 즉, 겉보기에는 findFirst() 메서드와 중복되는 것 같다. findAny()와 findFirst()가 모두 필요한 이유는 병렬 시행 때문이다. 병령 시행 시 첫 번째 요소를 찾기 어려우므로 요소의 반환 순서가 상관 없다면 병렬 스트림에서는 제약이 적은 findAny를 사용한다.

리듀싱

  모든 스트림 요소를 처리해 값으로 도출하는 연산을 리듀싱 연산이라 한다. 함수형 프로그래밍 언어 용어로는 이 과정이 마치 종이(스트림)를 작은 조각이 될 때까지 반복해 접는 것과 비슷하다는 의미로 폴드(fold)라 한다.

요소의 합

  reduce 메서드를 활용해 다음과 같이 요소의 합을 구할 수 있다.

1
2
3
4
5
6
7
int sum = numbers.stream().reduce(0, (a, b) -> a + b);
// 메서드 참조를 사용하면
// 자바 8에서는 Integer 클래스에 두 숫자를 더하는 정적 sum 메서드를
// 제공한다.
int sum = numbers.stream(0, Integer::sum);
 
 
cs

    reduce 메서드는 초기값과 두 요소를 조합해 새로운 값을 만드는 BinaryOperator<T>를 인자로 받는다.

1
T reduce(T identity, BinaryOperator<T> accumulator);
cs

  위 예제에서 a는 현재 까지의 결과이며 b는 numbers의 요소다. 따라서 number의 요소가 1, 2, 3이라면 a는 순차적으로 0, 1, 3이고 b는 1, 2, 3이다. 최종 연산 결과는 6이다.

초기값 없음

  초기값이 존재하지 않는 오버로드 된 reduce도 존재한다. 

1
Optional<Integer> sum = number.stream().reduce((a, b) -> a + b);
cs

  초기값이 없는 reduce가 Optional을 반환하는 이유는 스트림에 초기값이 없을 수 도 있기 때문에 합계가 없음을 가리킬 수 있게 하기 위함이다.

최댓값과 최솟값

1
2
3
4
// 최솟값
Optional<Integer> max = numbers.stream().reduce(Integer::min);
// 최댓값
Optional<Integer> min = numbers.stream().reduce(Integer::max);
cs

reduce 메스드의 장점과 병렬화

  reduce를 이용하면 내부 반복이 추상화되면서 내부 구현에서 병렬로 reduce를 실행할 수 있게 된다. 반복적인 합계에서는 합을 저장하는 변수를 공유해야 하기 때문에 쉽게 병렬화 할 수 없다.

스트림 연산: 상태 없음과 상태 있음

  스트림을 이용하면 원하는 모든 연산을 쉽게 구현할 수 있고 stream 메서드를 parallelStream으로 바꾸는 것만으로도 쉽게 병렬성을 얻을 수 있다. 하지만 이런 스트림 연산은 각각 다양한 연산을 수행하고 그에 따라 각각의 연산은 내부적인 상태를 고려해야 한다.

  map, filter 등은 입력 스트림에서 각 요소를 받아 0 또는 결과를 출력 스트림으로 보낸다. 따라서 이들은 보통 상태가 없는, 내부 상태를 갖지 않는 연산(stateless operation)이다.

  reduce, sum, max 같은 연산은 결과를 누적할 내부 상태가 필요하다. 스트림에서 처리하는 요소 수와 관계없이 내부 상태의 크기는 한정(bounded)되어 있다.

  sorted나 distinct 같은 연산은 겉보기에는 stateless operation인거 같지만 스트림 요소를 정렬하거나 중복을 제거하려면 과거 이력을 알고 있어야 한다. 예를 들어 어떤요소를 출력 스트림으로 추가하려면 모든 요소가 버퍼에 추가되어 있어야 한다. 예를 들어 어떤 요소를 출력 스트림으로 추가하려면 모든 요소가 버퍼에 추가되 있어야 한다. 연산을 수행하는 데 필요한 저장소 크기를 정해져있지않다. 따라서 데이터 스트림의 크기가 무한이면 문제가 생길 수 있다. 이런 연산을 내부 상태를 갖는 연산(stateful operation)이라 한다.

 

기본형 특화 스트림

  자바 8에서는 세 가지 기본형 특화 스트림(primitive stream specialization)을 사용해 스트림 API 사용으로 인한 박싱 비용을 피할 수 있다. 기본형 특화 스트림의 종류는 다음과 같다.

    1. int 요소에 특화된 IntStream

    2. double 요소에 특화된 doubleStream

    3. Long 요소에 특화된 LongStream

  각 인터페이스는 숫자 관련 리듀싱 연산 수행 메서드를 제공한다. 예를 들어 IntStream의 일부를 보면 다음과 같은 메서드가 존재한다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
/**
 * Returns the sum of elements in this stream.  This is a special case
 * of a <a href="package-summary.html#Reduction">reduction</a>
 * and is equivalent to:
 * <pre>{@code
 *     return reduce(0, Integer::sum);
 * }</pre>
 *
 * <p>This is a <a href="package-summary.html#StreamOps">terminal
 * operation</a>.
 *
 * @return the sum of elements in this stream
 */
int sum();
 
/**
 * Returns an {@code OptionalInt} describing the minimum element of this
 * stream, or an empty optional if this stream is empty.  This is a special
 * case of a <a href="package-summary.html#Reduction">reduction</a>
 * and is equivalent to:
 * <pre>{@code
 *     return reduce(Integer::min);
 * }</pre>
 *
 * <p>This is a <a href="package-summary.html#StreamOps">terminal operation</a>.
 *
 * @return an {@code OptionalInt} containing the minimum element of this
 * stream, or an empty {@code OptionalInt} if the stream is empty
 */
OptionalInt min();
 
/**
 * Returns an {@code OptionalInt} describing the maximum element of this
 * stream, or an empty optional if this stream is empty.  This is a special
 * case of a <a href="package-summary.html#Reduction">reduction</a>
 * and is equivalent to:
 * <pre>{@code
 *     return reduce(Integer::max);
 * }</pre>
 *
 * <p>This is a <a href="package-summary.html#StreamOps">terminal
 * operation</a>.
 *
 * @return an {@code OptionalInt} containing the maximum element of this
 * stream, or an empty {@code OptionalInt} if the stream is empty
 */
OptionalInt max();
 
cs

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

숫자 스트림으로 매핑

  스트림을 특화 스트림을 만들 때는 mapToInt, mapToDouble, mapToLong을 가장 많이 상요하며 이들은 Stream<T> 대신 특화된 스트림을 반환한다. 따라서 다음과 같이 바로 합을 구할 수 있다.

1
2
3
4
int calories = menu.stream() // Stream<Dish>
    .mapToInt(Dish::getCalories) // IntStream
    .sum();
 
cs

  만약 스트림이 비어 있으면 sum은 0을 반환한다.

객체 스트림으로 복원

  특화된 스트림을 일반 스트림으로 복원하고 싶다면 boxed()를 사용하면 된다. IntStream의 boxed의 경우 다음과 같다.

1
2
3
4
5
6
7
8
9
10
11
12
/**
 * Returns a {@code Stream} consisting of the elements of this stream,
 * each boxed to an {@code Integer}.
 *
 * <p>This is an <a href="package-summary.html#StreamOps">intermediate
 * operation</a>.
 *
 * @return a {@code Stream} consistent of the elements of this stream,
 * each boxed to an {@code Integer}
 */
Stream<Integer> boxed();
 
cs

기본값: OptionalInt

  특화된 스트림을 사용해 최대값을 찾을 때도 sum 메서드와 같이 스트림이 없을 때 0을 반환한다면, 0으로 인해 잘못된 결과다 도출될 수 있다. 따라서 이런 연산의 경우 Optional의 기본형 특화 스트림 버전인 OptionalInt, OptionalLong, OptionalDouble을 제공한다. 이를 통해 다음과 같이 값이 없을 경우의 반환값을 지정할 수 있다.

  IntStream의 경우 reduce, min, max, findFirst, findAny의 반환값이 OptionalInt이다.

1
2
3
4
5
int maxCalories = menu.stream()
    .mapToInt(Dish::getCalories)
    .max()
    .orElse(0)l // 값이 없으면 0을 반환.
 
cs

숫자 범위

  IntStream과 LongStream은 rage와 rangeClosed 정적 메서드를 제공하고 이를 통해 특정 숫자 범위를 활용한 연산을 할 수 있다. 이 두 연산은 첫 번째 인자로 시작값을, 두 번째 인자로 종료값을 받는다는 공통점이 있다. 하지만 range는 시작값과 종료값이 결과에 포함되지 않는 반면((start, end)) rangeClosed는 시작값과 종료값이 결과에 포함된다([start, end]).

1
2
3
4
5
6
7
IntStream.rangeClosed(1100// [1, 100]
    .filter(number -> number % 2 == 0); // 1 <= number <= 100을 만족하는 짝수
 
 
IntStream.range(1100// (1, 100)
    .filter(number -> number % 2 == 0); // 1 < number < 100을 만족하는 짝수
 
cs

스트림 만들기

  지금까지 stream 메서드를 활용해 컬렉션에서 스트림을 얻는 방법과 숫자 범위를 사용해 스트림을 얻는 방법을 살펴 보았다. 이제 배열, 파일 등을 활용해 스트림을 만드는 방법을 살펴보자.

값으로 스트림 만들기

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

1
2
Stream<String> stream = Stream.of("a""b""c");
 
cs

null이 될 수 있는 객체로 스트림 만들기

  자바 9에서 추가된 ofNullable 덕분에 더이상 null이 될 수 있는 객체를 스트림으로 만들 때 명시적으로 확인할 필요가 없어졌다.

1
2
3
4
5
6
// 자바 8까지는 null을 명시적으로 확인해야 했다.
Stream<String> Stream = homeValue == null ? Stream.empty() : Stream.of(value);
 
// 자바 9부터는 ofNullable를 사용해 명시적 확인을 없앨 수 있다.
Stream<String> Stream = Stream.ofNullable(value);
 
cs

  이게 가능한 이유는 ofNullable가 내부적으로 전달받은 인자의 null 여부를 체크해 null이면 Stream.empty()를 호출하기 때문이다.

1
2
3
4
5
6
// ofNullable 로직 
public static<T> Stream<T> ofNullable(T t) {
    return t == null ? Stream.empty()
        : StreamSupport.stream(new Streams.StreamBuilderImpl<>(t), false);
}
 
cs

배열로 스트림 반들기

  배열을 인수로 받는 정적 메서드 Arrays.stream을 사용하면 배열을 스트림으로 만들 수 있다.

1
2
3
4
int[] numbers = {1234};
Arrays.Stream(numbers) // IntStream
 
 
cs

파일로 스트림 만들기

  파일을 처리하는 등의 I/O 연산에 상요하는 자바의 NIO API(비블록 I/O)는 스트림 API를 활용할 수 있게 업데이트 되었다. 예를 들어 java.nio.file.Files의 File.lines는 주어진 파일의 행 스트림을 문자열로 반환한다. 따라서 다음과 같은 연산을 할 수 있다.

1
2
3
4
5
6
7
8
9
10
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) {
    ...
}
 
 
cs

함수로 무한 스트림 만들기

  스트림 API는 크기가 고정되지 않은 스트림을 만드는 무한 스트림(inifinite stream) 기능을 제공한다. 무한 스트림은 Stream.iterate와 Stream.generate를 통해 생성할 수 있다. 이런 무한 스트림을 unbounded stream이라 한다.

iterate 메서드

  iterate는 인자로 초기값과 람드를 받아 새로운 값을 끊임없이 생성한다. 기본적으로 iterate는 기존 결과에 의존해 순차적으로 연산을 수행한다. 통상적으로 일련의 연속된 값을 생성하기 위해 iterate를 사용하며 limit을 통해 갯수를 제한한다.

1
2
3
4
Stream.iterate(0, n -> n + 2)
    .limit(10)
    .forEach(System.out::println);
 
cs

  자바 9부터는 iterate가 predicate를 제공한다. 따라서 중단 조건을 다음과 같이 명시할 수 있다.

1
2
3
Stream.iterate(0, n -> n < 100 , n -> n + 2// n >= 100이면 중단
    .forEach(System.out::println);
 
cs

  또는 스트림 쇼트서킷을 지원하는 takeWhile을 사용해 종료 조건을 명시할 수 있다.

1
2
3
4
Stream.iterate(0, n -> n + 2)
    .takeWhille(n -> n < 100)
    .forEach(System.out::println);
 
cs

generate 메서드

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

1
2
3
4
5
Stream.generate(Math::random)
    .limit(5)
    .forEach(System.out::println);
 
 
cs

 

출처 - 모던 자바 인 액션