11 min read

모던 자바 인 액션 - Chapter 5 <스트림 활용>

이번 장에서는 본격적으로 Streamd을 활용하는 방법에 대해 배워보겠습니다.

필터링

스트림 객체는 filter() 메서드를 지원합니다.filter 메서드는 Predicate를 인자로 받아 해당 Predicate와 일치하는 스트림 내부의 요소를 포함하는 스트림 객체를 반환합니다.또 다른 필터링은 지원하는 메서드로는 distinct() 메서드가 존재합니다.해당 메서드는 고유 요소로 이루어진 스트림을 반환합니다.아래 예제를 보시면 쉽게 이해할 수 있습니다.

	@Test
    void distinctTest() {
        List<Integer> numbers = List.of(1, 2, 1, 3, 3, 2, 4);
        numbers.stream()
                .filter(i -> i % 2 == 0)
                .distinct()
                .forEach(System.out::println);
    }

Integer를 요소로 가지는 리스트에서 짝수만으로 이루어진 스트림을 반환하고,이를 다시 distinct()를 통해 내부 요소들의 중복을 없앨 수 있습니다.해당 코드는 2,4를 출력합니다.

스트림 슬라이싱

이번 주제는 스트림의 요소를 선택하거나 스킵하는 다양한 방법을 설명합니다.

takeWhile

이미 칼로리로 정렬되어 있는 음식 리스트가 존재한다고 가정하겠습니다.여기서 칼로리가 320보다 적은 음식 리스트를 스트림을 통해 얻어낼 수 있습니다.가장 먼저 떠오르는 방법은 filter를 통해 스트림 내부 요소를 걸러낼 수 있습니다.즉,전체 스트림을 반복하면서 내부의 모든 요소에 인자로 들어온 Predicate 람다를 적용하게 됩니다.즉,시간 복잡도는 동일하지만 내부의 n의 개수가 커지면 커질수록 최적화를 시킬 수 있는 시간이 더욱 커집니다.

	@Test
    void takeWhileTest() {
        /*
        * specialMenu is already sorted by calories
        * */

        List<Dish> byFilter = specialMenu.stream()
                .filter(d -> d.getCalories() < 320)
                .collect(Collectors.toList());

        List<Dish> byTakeWhile = specialMenu.stream()
                .takeWhile(d -> d.getCalories() < 320)
                .collect(Collectors.toList());

        Assertions.assertThat(byFilter).isEqualTo(byTakeWhile);
    }

이러한 연산 시간의 최적화를 takeWhile()을 활용해서 할 수 있습니다.해당 메서드는 람다로 들어온 인자를 만족하지 않는 요소를 만날 경우 반복을 중단합니다.즉,320 칼로리보다 큰 요소를 만날 경우 반복을 중단시켜 연산 시간을 최적화할 수 있습니다.하지만 이러한 기능을 사용하기 위해서는 중요한 전제 조건이 있습니다.기존에 존재하는 stream()을 생성하는 리스트가 칼로리 기준으로 정렬이 되어있어야한다는 것입니다.이런식으로 스트림의 일부 요소만을 탐색하는 것을 스트림 슬라이싱이라고 합니다.

dropWhile

그러면 320보다 큰 칼로리를 가지는 요소들은 어떻게 탐색할 수 있을까?이는 바로 dropWhile()을 사용하면 됩니다.아래의 예제를 통해 쉽게 파악할 수 있습니다.

	@Test
    void dropWhileTest() {
        List<Dish> byTakeWhile = specialMenu.stream()
                .takeWhile(d -> d.getCalories() < 320)
                .collect(Collectors.toList());

        List<Dish> byDropWhile = specialMenu.stream()
                .dropWhile(d -> d.getCalories() < 320)
                .collect(Collectors.toList());

        assertThat(byTakeWhile.size() + byDropWhile.size()).isEqualTo(specialMenu.size());
    }

takeWhile을 통해 슬라이싱하여 얻어낸 리스트와 dropWhile을 통해 슬라이싱 하여 얻어낸 리스트의 사이즈를 더하면 기존에 존재하는 specailMenu의 크기와 동일합니다.즉,dropWhile은 takeWhile의 정반대 동작을 수행합니다.인자로 들어온 프리디케이트에 false인 요소를 발견하면 그 지점에서 Loop 작업을 중단하고 이후의 모든 요소를 스트림에 반환합니다.

스트림 축소 - limit()

스트림은 주어진 값 이하의 크기를 갖는 새로운 스트림을 반환하는 limit(int) 메서드를 지원합니다.

	@Test
    void limitTest() {
        List<Dish> dishes = specialMenu.stream()
                .filter(d -> d.getCalories() > 300)
                .limit(3)
                .collect(Collectors.toList());

        assertThat(dishes.size()).isEqualTo(3);
    }

아주 간단하게 위와 같은 예제로 해당 메서드를 사용해 볼 수 있습니다.프레디케이트와 일치하는 처음 세 요소를 선택한 다음에 즉시 결과를 반환합니다.limit의 경우에도 stream을 생성하는 리스트가 정렬되어 있으면 정렬이 유지되어 결과를 반환하고 정렬되지 않은 리스트인 경우에는 정렬되지 않는 상태로 반환됩니다.

요소 건너뛰기

스트림은 처음 n개 요소를 제외한 스트림을 반환하는 skip(int n) 메서드를 지원합니다.만약 n개 이하의 요소를 가지는 스트림에 skip(n)을 호출하게 되면 요소가 없는 빈 스트림이 반환됩니다.

	@Test
    void skipTest() {
        List<Dish> dishes = menu.stream()
                .filter(d -> d.getCalories() > 300)
                .collect(Collectors.toList());

        List<Dish> bySkip = menu.stream()
                .filter(d -> d.getCalories() > 300)
                .skip(2)
                .collect(Collectors.toList());

        assertThat(bySkip.size() + 2).isEqualTo(dishes.size());
    }

skip()을 호출하면 인자로 들어온 정수만큼의 요소를 건너 뛴 다음 스트림을 반환합니다.

매핑

스트림은 특정 객체에서 특정 데이터를 선택하는 작업인 매핑을 수행하는 메서드인 map()과 flatMap() 메서드를 지원합니다.

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

스트림은 함수를 인수로 받는 map 메서드를 지원합니다.파라미터로 제공된 함수는 스트림 내부의 각 요소에 적용되며 함수를 적용한 결과가 새로운 요소로 매핑됩니다.즉,기존의 스트림에 존재하던 요소들이 새로운 함수가 적용된 요소들로 변환되어 매핑된다고 생각하셔도 됩니다.아래 코드는 Dish 객체 내의 name 필드를 뽑아내어,해당 String의 길이를 가지는 리스트를 스트림을 통해 생성하는 예제입니다.

	@Test
    void mapTest() {
        List<Integer> nameLength = menu.stream()
                .map(Dish::getName)
                .map(String::length)
                .collect(Collectors.toList());

        nameLength.forEach(System.out::println);
    }

스트림 평면화

위 예제를 통해 map을 사용해서 리스트의 각 길이를 반환하는 방법을 알아봤습니다.이를 응용하여 스트링 리스트에서 중복되지 않는 문자로 이루어진 리스트를 만들어 봅시다.즉,원하는 리스트의 결과값이 List<String> 형태입니다.

	@Test
    void nonFlatMapTest() {
        List<String> words = List.of("Hello", "World");
        List<String[]> collect = words.stream()
                .map(word -> word.split(""))
                .distinct()
                .collect(Collectors.toList());

        collect.forEach(strings -> {
            for (String string : strings) {
                System.out.println(string);
            }
        });
    }

위 코드를 실행해보시면 기대한 반환형이 아닌 List<String[]>가 반환됩니다.즉,split() 함수의 반환형과 동일하게 스트림의 타입이 결정됩니다.distinct()의 경우 구별할 요소가 String[]입니다.즉,스트림 내부의 요소가 서로 다르기에 저희가 원하는 내부 문자들의 중복을 제거하지 못합니다.결론적으로 “Hello”와 “World”가 각각 서로 다른 스트림의 요소로 인식이 되어 원하는 동작이 나오지 않고 있습니다.

위와 같은 문제는 flatMap()을 통해 해결 가능합니다.

	@Test
    void flatMapTest() {
        List<String> words = List.of("Hello", "World");
        List<String> collect = words.stream()
                .map(word -> word.split(""))
                .flatMap(Arrays::stream)
                .distinct()
                .collect(Collectors.toList());

        collect.forEach(System.out::println);
        assertThat(collect.stream().distinct().count()).isEqualTo(7);
    }

우선 내부 스트링마다 각각의 스트림을 리턴할 수 있도록 해야합니다.그래서 stream 메서드를 flatMap의 파라미터로 넘겨줍니다.이후 flatMap을 통해 하나의 평면화된 스트림을 반환하도록 합니다.만약 해당 예제에서 flatMap 대신 map을 사용하게 될 경우 각각의 스트림 객체가 리스트의 요소로 들어가게 됩니다.

정리하면 flatMap은 스트림의 각 값들을 서로 다른 스트림으로 만든 다음 모든 개별 스트림들을 하나의 스트림으로 연결하는 기능을 수행합니다.

검색과 매칭

특정 속성이 데이터에 존재하는지 여부를 검색하는 처리를 위해 스트림은 다양한 유틸리티 메서드들을 제공합니다.

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

위와 같은 경우를 처리할 때는 anyMatch() 메서드를 사용합니다.

예제로 설명을 대신합니다.

	@Test
    void anyMatchTest() {
        if (menu.stream().anyMatch(Dish::isVegetarian)) {
            System.out.println("The menu is (somewhat) vegetarian friendly!!");
        }
    }

프리디케이트가 모든 요소와 일치하는 지 확인

위의 경우는 allMatch() 메서드를 사용합니다.

	@Test
    void allMatchTest() {
        if (menu.stream().allMatch(dish -> dish.getCalories() < 1000)) {
            System.out.println("The menu is (somewhat) healthy!!");
        }
    }

요소 검색

findAny() 메서드는 현재 스트림에서 임의의 요소를 반환합니다.해당 메서드는 다른 스트림 연산 메서드와 연결하여 사용할 수 있습니다.아래의 예제와 같이 filter()라는 메서드와 함께 연결하여 사용가능합니다.

	@Test
    void findAnyTest() {
        Optional<Dish> any = menu.stream()
                .filter(Dish::isVegetarian)
                .findAny();
        Assertions.assertThat(any.get()).isInstanceOf(Dish.class);
    }

또한 위 스트림의 파이프라인은 내부적으로 단일 과정을 수행하도록 최적화 됩니다.쇼트 서킷을 이용해서 결과를 찾는 즉시 스트림의 내부 반복을 중단합니다.

리듀싱

리듀스 연산을 활용하여 스트림의 내부 요소들을 조합하여 더욱 복잡한 연산을 한번 수행해보겠습니다.대표적인 예시로는 저희가 사용하는 Dish 리스트의 각 메뉴들의 모든 칼로리의 합을 구하는 것등이 있습니다.이처럼 리듀싱 연산은 모든 스트림 요소를 처리해서 특정한 값을 얻어내는 연산이라고 할 수 있습니다.참고로 함수형 프로그래밍 용어에서 리듀싱은 폴드(fold)라고도 불립니다.

요소의 합 구하기

For 루프를 사용하여 리스트 내의 정수들의 총합을 구하는 예제를 다들 쉽게 구현 가능합니다.이러한 로직의 경우 sum이라는 변수에 누적합을 더해가며 원하는 값을 얻어냅니다.리듀스는 아래의 한줄 코드로 해당 연산을 수행합니다.

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

보이는 바와 같이 reduce()는 두가지 인수를 필요로 합니다.

  1. 초기값 0
  2. 원하는 동작을 수행할 람다 표현식, 해당 예제에서는 누적 합이기에 위와 같은 인자가 필요합니다.누적 곱을 원할 경우는 사칙 연산 기호만 바꿔주면 됩니다.

파라미터로 들어오믄 람다의 동작에 대해 조금 더 설명해보겠습니다.만약 1,2,3,4라는 숫자 리스트를 통해 해당 동작을 수행한다고 가정하겠습니다.먼저 초기값 0과 1이 각각의 a,b로 들어가게 되고 1이라는 결과가 저장됩니다.이후 1과 2, 3과 3과 같은 식으로 누적으로 해당 요소들의 총합을 계산해줍니다.여기서 계산 과정에 사용된 요소는 소비되고 스트림 내부에 하나의 요소만이 남을 때까지 연산을 계속 수행합니다.

만약 리듀스의 인자로 하나의 람다만 들어가게 되는 경우는 초기값이 없이 reduce()를 호출하는 경우입니다.이러한 경우 stream 내부 요소가 아무것도 없는 상황을 가정하여 Optional 객체를 리턴시켜줍니다.위 상황의 예시로는 정수 리스트에서 최대값을 구하는 예제가 있습니다.

	Optional<Integer> max = numbers.stream()
									.reduce(Integer::max);