Java8 - Stream 활용 (1): 필터링, 슬라이싱, 매핑, 검색, 매칭


이 포스트에서는 스트림 API 가 지원하는 다양한 연산에 대해 알아본다.
스트림 API 가 지원하는 연산을 이용하여 필터링, 슬라이싱, 매핑, 검색, 매칭 등 다양한 데이터 처리 질의를 표현해본다.

소스는 github 에 있습니다.


목차

본 포스트에 사용되는 Dish 클래스는 Java8 - Stream1. 스트림 에 소스가 있습니다.


1. 필터링, 슬라이싱


1.1. Predicate<T> 로 필터링: filter()

List<Dish> vegetarian = Dish.menu.stream()
        .filter(Dish::isVegetarian) // 채식인지 확인하는 메서드 레퍼런스
        .collect(toList());

//    Dish{name='french fries', vegetarian=true, calories=530, type=OTHER}
//    Dish{name='rice', vegetarian=true, calories=350, type=OTHER}
//    Dish{name='season fruit', vegetarian=true, calories=120, type=OTHER}
//    Dish{name='pizza', vegetarian=true, calories=550, type=OTHER}
vegetarian.forEach(System.out::println);

1.2. 고유 요소 필터링: distinct()

고유 여부는 스트림에서 만든 객체의 hashCode, equals 로 결정된다.

아래는 짝수들 중에 중복을 필터링하는 예시이다.

List<Integer> numbers = Arrays.asList(1,2,3,4,7,5,8,2);
numbers.stream()
      .filter(i -> i % 2 == 0) // 짝수 필터링
      .distinct() // 중복 제거
      .forEach(System.out::print);  // 248

1.3. 스트림 축소: limit()

limit() 은 주어진 사이즈 이하의 크기를 갖는 새로운 스트림을 반환한다.

List<Dish> dishes = Dish.menu.stream()
    .filter(d -> d.getCalories() > 500)
    .limit(3)
    .collect(toList());

//    Dish{name='pork', vegetarian=false, calories=800, type=MEAT}
//    Dish{name='beef', vegetarian=false, calories=700, type=MEAT}
//    Dish{name='french fries', vegetarian=true, calories=530, type=OTHER}
dishes.forEach(System.out::println);

1.4. 요소 건너뛰기: skip()

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

List<Dish> dishes = Dish.menu.stream()
        .filter(d -> d.getCalories() < 710)
        .skip(2)
        .collect(toList());

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

2. 매핑

특정 객체에서 특정 데이터를 선택하는 작업에 관한 연산이다.


2.1. 각 요소에 함수 적용: map()

스트림은 함수를 인수로 받는 map() 메서드를 지원한다.
인수로 제공된 함수는 각 요소에 적용되며, 함수를 적용한 결과가 새로운 요소로 매핑된다.

// 각 요소에 함수 적용 - 요리명 추출
List<String> dishNames = Dish.menu.stream()
        .map(Dish::getName)
        .collect(toList());

// [pork, beef, chicken, french fries, rice, season fruit, pizza, prawns, salmon]
System.out.println(dishNames);
// 각 요소에 함수 적용 - 요리명의 길이 추출
List<Integer> dishNameLength = Dish.menu.stream()
        .map(Dish::getName)
        .map(String::length)
        .collect(toList());

// [4, 4, 7, 12, 4, 12, 5, 6, 6]
System.out.println(dishNameLength);

2.2. 스트림 평면화: map() 과 Arrays.stream(), flatMap()

문자열 리스트에서 고유 문자로 이루어진 리스트를 반환해보자.
예를 들면 [“hello”, “world”] 가 있다면 [“h”, “e”, “l”, “o”, “w”, “r”, “d”] 리스트가 반환되어야 한다.

// map() 이 Stream<String[]> 을 반환하기 때문에 오류
List<String> first = words.stream() // Stream<String> 반환
        .map(word -> word.split(""))  // Stream<String[]> 반환
        .distinct()
        .collect(toList());

배열 스트림이 아닌 문자열 스트림이 필요하다.

Arrays.stream() 은 문자열 배열을 받아 문자열 스트림을 만들어준다.

String[] strArray = {"hello", "world"};
Stream<String> streamOfWords = Arrays.stream(strArray);

그렇다면 아래의 결과를 보자.

// Arrays.stream() 으로 고유 문자 추출
// map(Arrays::stream) 이 Stream<Stream<String>> 반환하기 때문에 오류
List<String> first = words.stream() // Stream<String> 반환
        .map(word -> word.split(""))  // Stream<String[]> 반환
        .map(Arrays::stream)  // Stream<Stream<String>> 반환
        .distinct()
        .collect(toList());

이 문제를 해결하려면 각 단어를 개별 문자열로 이루어진 배열로 만든 후 각 배열을 별도의 스트림으로 만들어야 한다.

List<String> first = words.stream() // Stream<String> 반환
        .map(word -> word.split(""))  // Stream<String[]> 반환
        .flatMap(Arrays::stream)  // Stream<String> 반환, 생성된 스트림을 하나의 스트림으로 평면화
        .distinct()
        .collect(toList());

// [h, e, l, o, w, r, d]
System.out.println(first);

이렇게 flatMap() 은 스트림의 각 값을 다른 스트림으로 만든 후 모든 스트림을 하나의 스트림으로 연결하는 기능을 제공한다.


Quiz

숫자 리스트가 있을 때 각 숫자의 제곱근으로 이루어진 리스트 반환

List<Integer> numbers = Arrays.asList(1, 2, 3, 4);
List<Integer> squared = numbers.stream()
        .map(n -> n*n)
        .collect(toList());

두 개의 숫자 리스트가 있을 때 모든 숫자 쌍의 리스트 반환
예) [1,2,3], [3,4] 가 있으면 [(1,3), (1,4), (2,3), (2,4), (3,3), (3,4)] 반환

List<Integer> number1 = Arrays.asList(1, 2, 3);
List<Integer> number2 = Arrays.asList(3, 4);
List<int[]> pairs = number1.stream()  // Stream<Integer> 반환
        .flatMap(i -> number2.stream()
                .map(j -> new int[]{i,j}))  // Stream<int[]> 반환
        .collect(toList());

pairs.forEach(pair -> System.out.println("(" + pair[0] + ", " + pair[1] + ")"));

두 개의 map 을 이용해서 두 리스트를 반복하여 숫자 쌍을 만들 수 있는데 그러면 Stream<Stream<int[]» 가 반환되므로 결과를 Stream<int[]> 를 평면화한 스트림이 필요하다.

위 퀴즈에서 합이 3으로 나누어 떨어지는 쌍만 반환
예) (2,4), (3,3)

List<int[]> pairs2 = number1.stream() // Stream<Integer> 반환
        .flatMap(i -> number2.stream()
                  .filter(j -> (i+j)%3 == 0)
                  .map(j -> new int[]{i,j}))  // Stream<int[]> 반환
        .collect(toList());
pairs2.forEach(pair -> System.out.println("(" + pair[0] + ", " + pair[1] + ")"));

3. 검색, 매칭

특정 속성이 데이터 집합에 있는지 여부를 검색하는 작업에 관한 연산이다.


3.1. Predicate<T> 가 적어도 한 요소와 일치하는지 확인: anyMatch()

if (isVegetarian()) {
  System.out.println("Vegetarian");
}

private static boolean isVegetarian() {
  return Dish.menu.stream().anyMatch(Dish::isVegetarian); // anyMatch() 는 boolean 을 반환하므로 최종 연산
}

3.2. Predicate<T> 가 모든 요소와 일치하는지 확인: allMatch()

if (isHealthy()) {
  System.out.println("healthy");
}

private static boolean isHealthy() {
  return Dish.menu.stream().allMatch(d -> d.getCalories() < 1000);
}

3.3. Predicate<T> 가 모든 요소와 일치하지 않는지 확인: noneMatch()

if (isHealthy2()) {
  System.out.println("healthy2");
}

private static boolean isHealthy2() {
  return Dish.menu.stream().noneMatch(d -> d.getCalories() >= 1000);
}

anyMatch(), noneMatch() 모두 스트림 쇼트서킷 기법을 활용한다. (= java 의 &&, || 와 같은 연산)

쇼트서킷이란 전체 스트림을 처리하지 않더라도 중간에 조건식이 맞으면 결과를 바로 반환하는 것을 말한다.
allMatch(), noneMatch(), findFirst(), findAny() 등의 연산은 모든 스트림의 요소를 처리하지 않고 원하는 요소를 찾으면 즉시 결과를 반환한다.

스트림의 모든 요소를 처리하지 않고 주어진 크기의 스트림을 생성하는 limit() 도 쇼트서킷 연산이다. limit() 은 무한한 요소를 가진 스트림을 유한한 크기로 줄일 수 있는 유용한 연산이다.


3.4. 요소 검색: findAny()

현재 스트림에서 임의의 요소를 반환한다.

private static Optional<Dish> findVegetarian() {
  // 쇼트서킷을 이용해서 결과를 찾는 즉시 실행 종료
  return Dish.menu.stream().filter(Dish::isVegetarian).findAny();
}

Optional<Dish> dish = findVegetarian();
dish.ifPresent(d -> System.out.println(d.getName())); // 값이 있으면 출력하고 없으면 아무일도 일어나지 않음

3.5. Optional<T>

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

위 코드에서 findAny() 는 아무 요소도 반환하지 않을 수 있고, 그러면 null Exception 이 발생하므로 Optional<T> 형식으로 선언하였다.

Optional<T> 에 대한 좀 더 상세한 내용은 Java8 - Optional 클래스 를 참고하세요.

  • isPresent()
    • Optional 이 값을 포함하면 true 반환
  • ifPresent(Consumer<T> block)
    • 값이 있으면 주어진 블록 실행
    • Consumer<T> 의 함수 디스크립터는 T -> void 임 (= T 를 인자로 받아서 void 를 반환하는 람다 전달 가능)
  • T get()
    • 값이 존재하면 값 반환, 값이 없으면 NoSuchElementException 발생
  • T orElse(T other)
    • 값이 존재하면 값 반환, 값이 없으면 기본값 반환

Consumer 함수형 인터페이스는 Java8 - 람다 표현식 (1): 함수형 인터페이스, 형식 검사2.2. Consumer<T>: void accept(T) 를 참고하세요.


3.6. 첫 번째 요소 찾기: findFirst()

아래는 숫자 리스트에서 3으로 나누어 떨어지는 첫 번째 제곱값을 반환하는 예시이다.

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

  // Optional[81]
  System.out.println(firstSquareDivisibleByThree);

findAny() 와 findFirst() 두 가지 메서드가 모두 필요한 이유는 바로 병렬 실행에서는 첫 번째 요소를 찾기 어렵기 때문이다.
따라서 요소의 반환 순서가 상관없다면 병렬 스트림에서는 제약이 적은 findAny() 를 사용하는 것이 좋다.


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

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






© 2020.08. by assu10

Powered by assu10