Language/Java

[모던 자바 인 액션] Ch.5 - 스트림 활용

비소_ 2022. 6. 16. 21:49

클릭하시면 네이버 Book으로 연결됩니다!

해당 서적을 참고하여 개인 공부용으로 정리한 글입니다.

기본적인 Java 지식이 있으시다면 이해하기 수월한 정도입니다 :)

또한, 해당 책은 2018년 Java 11 기준으로 작성되어 있습니다.

따라서 이후 버전에서 변경된 내용은 수정하여 작성했습니다. (아는 부분만 말입니다..)


1. 필터링(Filtering)

필터링은 스트림의 요소를 조건을 통해 선택하는 방법이다.

스트림의 filter 메서드는 Predicate를 인수로 받아 이와 일치하는 요소를 담고 있는 스트림으로 반환한다.

Stream<T> filter(Predicate<? super T> predicate); //Stream 인터페이스에 정의된 filter 메서드

List<String> result = menu.parallelStream()
        .filter(dish -> dish.getCalorie() < 400) //Predicate의 test를 다음과 같이 정의한 객체가 인수로 넘어간다.
        .forEach(System.out::println);

필터링을 했을 때, 해당 조건을 만족하는 요소 중 중복된 요소가 있을 수 있다.

이를 제거하기 위해 스트림은 distinct 메서드도 지원한다.

동일 여부는 hashCodeequals로 결정된다.


2. 스트림 슬라이싱(Stream Slicing)

스트림 슬라이싱은 스트림 요소 전체에 대해 연산을 수행하지 않고 일부만을 이용하는 기술이다.

 

자바 9부터 스트림의 요소를 효과적으로 선택할 수 있도록 takeWhile과 dropWhile을 제공한다.

두 메서드는 filter와 마찬가지로 Predicate를 인수로 받는다.

 

이름에서 유추할 수 있듯이, takeWhile은 내부 반복을 하다가 조건을 만족하지 않는 요소가 나온다면 지금까지의 요소 중 조건을 만족한 요소만 담은 스트림을 반환한다.

반대로, dropWhile은 지금까지의 요소를 버리고, 그 이후 요소들을 담은 스트림을 반환한다.

따라서, 정렬이 되어 있는 데이터에서 효율적으로 사용할 수 있다.

List<Dish> menu = Arrays.asList(
    new Dish("삼겹살", 800, false, Dish.Type.MEAT),
    new Dish("연어", 450, false, Dish.Type.FISH),
    new Dish("밥", 300, true, Dish.Type.OTHER),
    new Dish("과일", 150, true, Dish.Type.OTHER),
    new Dish("피자", 550, true, Dish.Type.OTHER)
}

menu.stream()
    .takeWhile(dish -> dish.getCalorie() > 300)
    .forEach(dish -> System.out.println(dish.getName())); // 삽겹살, 연어

이외에도 limit 메서드를 통해 최대 n개 요소만 선택할 수 있고, skip 메서드를 통해 n개 요소를 건너뛸 수 있다.


3. 매핑(Mapping)

특정 객체에서 특정 데이터를 선택하기 위해 mapflatMap 메서드를 제공한다.

두 메서드는 Function을 인수로 받아 함수를 적용한 결과가 새로운 요소로 매핑된다.

 

다음은 위에 있는 forEach의 인수를 map으로 한번 더 파이프라이닝 시켜 단순화한 것이다.

List<String> result = menu.stream()
                .takeWhile(dish -> dish.getCalorie() > 300) // Stream<Dish> 반환
                .map(Dish::getName) //getName()의 리턴타입은 String 이므로, Stream<String> 반환
                .forEach(System.out::println);

스트림 평탄화

만약 문자열 리스트에서 고유 문자로 이루어진 리스트를 반환하려면 어떻게 해야 할까?

아래는 잘못된 방법이다.

public static void main(String[] args) {
    String[] wordArr = {"Hello", "World"};

    List<String[]> wrong = Arrays.stream(wordArr)
            .map(word -> word.split(""))
            .distinct()
            .collect(Collectors.toList());
}

split을 통해 각 문자열을 분해해서 문자를 얻을 수는 있지만 리턴 타입이 String[] 이기 때문에

[{"H", "e", "l", "l", "o"}, {"w", "o", "r", "l", "d"}]가 돼버린다.

따라서, distinct()를 호출해도 스트림 안에는 중복되는 String[]이 없기 때문에 아무런 효과 없이 그대로 반환된다.

 

우리가 distinct()를 적용하기 위해서는 ["H", "e", "l", "l", "o", "w", "o", "r", "l", "d"], 즉, Stream<String>을 얻어야 한다.

이를 위해서는 flatMap을 통해 평탄화를 진행해야 한다.

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

public static void main(String[] args) {
    String[] wordArr = {"Hello", "World"};

    List<String[]> correct = Arrays.stream(wordArr)
            .map(word -> word.split(""))
            .flatMap(Arrays::stream)
            .distinct()
            .collect(Collectors.toList());
}

map으로 인해 분리된 배열을 Arrays::stream을 통해 각각 Stream<String>으로 만들고,

이렇게 만들어진 2개의 Stream<String>을 flatMap이 하나의 Stream<String>으로 평탄화 시키는 것이다.

아래 그림이 이를 보여준다.

https://stackoverflow.com/questions/26684562/whats-the-difference-between-map-and-flatmap-methods-in-java-8


4. 검색과 매칭

특정 속성이 데이터 집합에 있는지 여부를 검색하기 위해 allMatch, anyMatch, noneMatch, findFirst, findAny 등 다양한 유틸리티 메서드를 제공한다.

 

위 5가지 메서드는 쇼트 서킷 기법을 이용하는데, &&와 || 같은 논리 연산자처럼 하나라도 만족하지 않으면 뒤의 결과와 상관없이 결과를 반환할 수 있다.

 

allMatch, anyMatch, noneMatch는 모두 Predicate를 인수로 받는다.

인수로 받은 조건을 적용해서 모든 요소가 매치하는지, 하나라도 매치하는지, 모두 매치하지 않는지 확인 후 boolean을 반환한다.

 

findFirst와 findAny는 단일(직렬) 처리 시 둘 다 맨 처음 요소를 반환한다.

하지만, 병렬 처리 시 findFirst는 순서상 첫 번째 요소를 반환하지만, findAny는 여러 스레드 중 가장 먼저 찾은 요소가 반환되므로, 뒤에 있는 요소가 반환될 수 있다.

또한, 조건을 만족하는 요소가 없을 경우 null을 반환하면, NPE가 발생할 수 있으므로, 자바에서는 Optional 클래스를 통해 값의 존재나 부재 여부를 표현하는 컨테이너 클래스를 제공한다.

 

Optional 클래스는 다음과 같은 기능들을 제공한다.

  • isPresent() : 값이 있으면 true, 없으면 false
  • ifPresent(Consumer<T> block) : 값이 있으면 주어진 블록 실행
  • T get() : 값이 있으면 값을 반환, 없으면 NoSuchElementException 발생
  • T orElse(T other) : 값이 있으면 값을 반환하고, 없으면 기본값 반환

bold 처리된 메서드는 NPE 핸들링이 가능하므로 자주 사용된다.


5. 리듀싱(Reducing)

리듀싱 연산은 스트림 요소를 조합해서 값이 나올 때까지 모든 요소를 반복적으로 처리하는 작업을 말한다.

함수형 프로그래밍에서는 종이가 작아질 때까지 접는 것과 비슷하다는 의미로 폴드(Fold)라고 부른다.

 

리스트의 요소를 다 더하고 싶을 때 우리는 for-each를 이용해서 구할 수 있다.

이때 초깃값 (sum = 0)과 연산자(+)가 필요하다.

스트림에서는 이 두 가지만을 이용하여 표현할 수 있다.

int sum = numbers.stream().reduce(0, (a, b) -> a + b);

첫 번째 인수는 초깃값, 두 번째 인수는 BinaryOperator<T>이다.

아래는 이 코드의 동작 순서를 보여준다.

메서드 참조를 이용하면 더 간결하게 표현할 수 있다.

int sum = numbers.stream().reduce(0, Integer::sum);

초깃값을 받지 않는 오버로드(OverLoad)된 reduce도 있다.

이는 요소가 하나도 없는 상황처럼 결과를 반환할 수 없을 때 null을 처리하기 위해 Optional 객체를 반환한다.

 

맵과 리듀스를 연결하는 기법을맵리듀스(MapReduce) 패턴이라고 한다. (Hadoop의 맵리듀스도 동일한 의미이다.)

둘은 쉽게 병렬화하는 특징을 가졌다.

 

for-each를 사용한 합계에서는 sum 변수를 공유해야 하므로 병렬화하기 어렵다.

스트림은 포크/조인 프레임워크(Fork/Join Framework)를 이용해 입력을 분할하고 각각 더한 뒤, 나온 결과값들을 합친다.

대신 병렬로 실행하려면 람다의 상태가 바뀌지 않아야 하며, 순서와 상관없이 결과가 바뀌지 않는 구조여야 한다.

 

상태 없음과 상태 있음

스트림의 연산들은 다양한 연산을 수행하는 만큼 내부적인 상태를 고려해야 한다.

map과 filter 등은 각 요소를 받아 0 또는 결과를 출력 스트림으로 보낸다.

이를 내부 상태를 갖지 않는 연산(Stateless Operation)이라고 한다.

 

하지만, reduce, sum, max 같은 연산은 결과를 누적할 내부 상태가 필요하다.

내부 상태의 크기는 요소 수와 관계없이 한정되어 있다. (Stateful Bounded Operation)

위에서는 int를 내부 상태로 사용했다. 여기서는 바로 이전 기록만 알고 있으면 된다.

 

반면 sorted와 distinct 같은 연산은 과거의 결과를 모두 알고 있어야 한다.

따라서 스트림의 크기가 크거나 무한이라면 문제가 발생할 수 있다.

이러한 연산을 내부 상태를 갖는 연산(Stateful Unbounded Operation)이라고 한다.


6. 숫자형 스트림

reduce에서 합계를 구하기 위해 Integer::sum을 이용했는데 이는 int → Integer로 박싱(Boxing) 비용이 숨어있다.

스트림 API의 sum 메서드 역시 Wrapper 클래스만 가능하기 때문에 불가능하다.

따라서, 이러한 primitive type을 위한 기본형 특화 스트림(Primitive Stream Specialization)을 제공한다.

특화 스트림은 오로지 박싱 과정의 효율성만 제공할 뿐 다른 기능은 없다.

 

기본형 특화 스트림에는 IntStream, DoubleStream, LongStream이 존재한다.

Stream<T>와 다르다는 것에 주목하자.

int calories = menu.stream()
    	.mapToInt(Dish::getCalorie) //IntStream 반환
        .sum();

숫자형 스트림을 다시 원상태로 복원하는 방법은 boxed 메서드를 이용하는 것이다.

 

만약 숫자형 스트림에서 요소가 없다면 null이 아니라 0을 반환하는데, 실제 최솟값이 0인 것과 어떻게 구분할 수 있을까.

이를 위해 Optional 역시 OptionalInt, OptionalDouble, OptionalLong 세 가지 기본형 버전을 제공한다.

사용 방법은 동일하다!

 

IntStream과 LongStream은 범위를 지정할 수 있는 rangerangeClosed 메서드를 제공한다.

range는 인수들을 포함하지 않고, rangeClosed는 인수들을 포함한다.

int sum = IntStream.rangeClosed(1,100).sum(); //1부터 100까지의 합 : 5050

7. 스트림 만들기

1. 값으로 스트림 만들기

Stream<String> stream = Stream.of("Hello", "World!");
Stream<String> emptyStream = Stream.empty();

2. Null이 될 수 있는 객체로 스트림 만들기

String homeValue = System.getProperty("home"); //null일 수도 있고 아닐 수도 있음
// null이면 빈 스트림이된다.
Stream<String> stream = Stream.ofNullable(homeValue);

3. 배열로 스트림 만들기

int[] numbers = {2, 3, 5, 7, 11};
int sum = Arrays.stream(numbers).sum();

4. 파일로 스트림 만들기

long uniqueWords = 0;
try(Stream<String> lines = Files.lines(Paths.get("data.txt"), StandardCharsets.UTF_8)){
    uniqueWords = lines.flatMap(line -> Arrays.stream(line.split(" ")))
    			.distinct()
        		.count(); //파일의 고유 단어 수 계산
} catch(IOException e){
    ...
}

스트림은 AutoCloseable이므로 finally 블록으로 자원을 닫을 필요가 없다.

5. 함수로 무한 스트림 만들기

스트림 API는 iterate와 generate 메서드로 스트림을 만들 수 있다.

기존 고정된 스트림에서 크기가 고정되지 않은 스트림을 만들 수 있다.

보통 무한한 값을 출력하지 않도록 limit 메서드와 함께 사용한다.

1. Iterate

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

iterate는 초깃값(0)을 받아 람다를 적용하면서 값을 생산한다.

아래는 피보나치 수를 출력하는 코드이다.

Stream.iterate(new int[]{0, 1}, value -> new int[]{value[1], value[0] + value[1]})
    .limit(20)
    .forEach(value -> System.out.println(value[0] + " " + value[1] + " " + value[2]));

또한, iterate는 Predicate를 지원하기 때문에 생성을 중단하는 코드를 구현할 수 있다.

IntStream.iterate(0, n -> n < 100, n -> n + 4)
    .forEach(System.out::println);

2. Generate

iterate와 달리 generate는 값을 연속적으로 계산하지 않는다.

generate는 Supplier<T>를 인수로 받아 새로운 값을 생산한다.

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

이 코드는 Supplier가 Math::random이라는 상태가 없는 메서드를 이용한다.

하지만 반드시 상태가 없을 필요는 없다.

Supplier가 상태를 저장한 다음 이후 값을 만들 때 상태를 고칠 수도 있다.

문제는 병렬 코드에서는 Supplier에 상태가 있으면 안전하지 않다는 것이다.

따라서, 없는 것이 부작용도 없고 바람직하다.


결론

  • 스트림 API를 통해 복잡한 데이터 처리를 선언형으로 표현할 수 있다.
  • 스트림 슬라이싱 메서드들은 쇼트서킷(Short Curcuit) 기법을 통해 결과를 즉시 반환한다.
  • reduce 메서드는 모든 요소를 반복 조합하여 값을 도출한다.
  • map과 reduce는 병렬성이 좋아 자주 함께 사용된다.
  • 기본형은 박싱(Boxing) 비용을 줄이기 위해 기본형 특화 스트림을 제공한다.