[Java] Stream 활용 (1)

🏃‍♀️·2023년 8월 31일

Java [이론]

목록 보기
12/14

학습 목표
스트림 API가 지원하는 다양한 연산을 살펴본다.


필터링

스트림의 요소를 선택하는 방법, 즉 프레디케이트 필터링 방법고유 요소만 필터링하는 방법을 배운다.

1. 프레디케이트로 필터링

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

List<Dish> vegetarianMenu = menu.stream()
                                .filter(Dish::isVegetarian)
                                .collect(toList());

위 코드는 채식 요리를 필터링해서 채식 메뉴를 만드는 코드이다.

2. 고유 요소 필터링

스트림은 고유 요소로 이루어진 스트림을 반환하는 distinct 메소드도 지원한다. 고유 여부는 스트림에서 만든 객체의 hashCode, equals로 결정된다.

List<Integer> numbers = Arrays.asList(1,2,1,3,3,2,4);
numbers.stream()
	   .filter(i -> i % 2 == 0)
       .distinct()
       .forEach(System.out::println);

위 코드는 리스트의 모든 짝수를 선택하고 중복을 필터링한다.



스트림 슬라이싱 [Java 9]

스트림의 요소를 선택하거나 스킵하는 다양한 방법을 설명하겠다. 프레디케이트를 이용하는 방법, 스트림의 처음 몇 개의 요소를 무시하는 방법, 특정 크기로 스트림을 줄이는 방법 등 다양한 방법을 이용해 효율적으로 이런 작업을 수행할 수 있다.

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

자바 9은 스트림의 요소를 효과적으로 선택할 수 있도록 takeWhile, dropWhile 두 가지 새로운 메소드를 지원한다.

아래와 같은 특별한 요리 목록을 가지고 있다고 가정해본다.

List<Dish> specialMenu = Arrays.asList(
        new Dish("seasonal fruit", true, 120, Dish.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 fries", true, 530, Dish.Type.OTHER)
);

  • TAKEWHILE 활용

320 칼로리 이하의 요리 리스트를 만드려면 어떻게 할 수 있을까?

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

이때까지 학습한대로 filter를 활용할 수 있다.
그러나 specialMenu는 이미 칼로리 순으로 정렬이 되어있다는 사실에 주목하자.

filter연산을 사용하면 전체 스트림을 반복하면서 각 요소에 프레디케이트를 적용하게 된다. 따라서 리스트에 대한 연산을 수행하다가 320 칼로리보다 크거나 같은 요리가 나왔을 때 반복 작업을 중단할 수 있다. 이것은 작은 리스트에서는 별 거 아니라고 느껴질 지 몰라도 아주 많은 요소를 포함하는 큰 스트림에서는 상당한 차이를 보일 수 있다.

그렇다면 어떻게 중단 지점을 지정할 수 있을까?

바로 takeWhile 연산을 이용해 간단하게 처리할 수 있다.
takeWhile을 이용하면 무한 스트림을 포함한 모든 스트림에 프레디케이트를 적용해 스트림을 슬라이스할 수 있다.

List<Dish> sliceMenu 
	= specialMenu.stream()
                 .takeWhile(dish -> dish.getCalories() < 320)
                 .collect(toList());

  • DROPWHILE 활용

반대로 320 칼로리 이상의 요리 리스트를 만드려면 어떻게 할 수 있을까?

dropWhile을 활용하면 된다.

List<Dish> sliceMenu 
	= specialMenu.stream()
                 .dropWhile(dish -> dish.getCalories() < 320)
                 .collect(toList());

dropWhiletakeWhile과 정반대의 작업을 수행한다. 프레디케이트가 처음으로 거짓을 반환하는 지점까지 발견된 요소를 모두 버린다. 프레디케이트가 거짓이 되면 그 지점에서 작업을 중단하고 모든 요소를 반환한다. dropWhile은 무한한 남은 요소를 가진 무한 스트림에서도 동작한다.


2. 스트림 축소

스트림은 주어진 값 이하의 크기를 갖는 새로운 스트림을 반환하는 limit(n) 메소드를 지원한다. 스트림이 정렬되어 있으면 최대 요소 n개를 반환할 수 있다. 예를 들어 다음처럼 300칼로리 이상의 세 요리를 건택해서 리스트를 만들 수 있다.

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

프레디케이트와 일치하는 처음 세 요소를 선택한 다음 즉시 결과를 반환한다.

정렬되지 않은 스트림(ex. Set)에서도 limit을 사용할 수 있다. 소스가 정렬되어있지 않닸다면 limit의 결과도 정렬되지 않은 상태로 반환된다.

3. 요소 건너뛰기

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

limit(n)skip(n)상호 보완적인 연산을 수행한다.

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

위 코드는 300 칼로리 이상의 처음 두 요리를 건너뛴 다음 300 칼로리가 넘는 나머지 요리를 반환한다.


매핑

특정 객체에서 특정 데이터를 선택하는 작업은 데이터 처리 과정에서 자주 수행되는 연산이다. 예를 들어 SQL의 테이블에서 특정 열만 선택할 수 있다. 스트림 API의 mapflatMap 메소드는 특정 데이터를 선택하는 기능을 제공한다.

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

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

List<String> dishNames = menu.stream()
							 .map(Dish::getName())
                             .collect(toList());

위 코드는 Dish::getNamemap 메소드로 전달해서 스트림의 요리명을 추출하는 코드이다.
이 과정은 기존의 값을 고친다라는 개념보다는 새로운 버전을 만든다는 개념에 가까우므로 변환에 가까운 매핑이라는 단어를 사용한다.

getName은 문자열을 반환하므로 map 메소드의 출력 스트림은 Stream<String> 형식을 갖는다.

위 코드의 요리명의 길이를 알고싶다면 어떻게 할까?

List<Integer> wordLength = menu.stream()
							   .map(Dish::getName())
                               .map(String::length)
                               .collect(toList());                    

map 메소드를 다른 map 메소드에 연결할 수 있다.

2. 스트림 평면화

리스트에서 고유 문자로 이루어진 리스트를 반환해보자.
예를 들어 ["Hello", "World"] 리스트가 있다면 결과로 ["H", "e", "l", "o", "W", "r", "d"]를 포함하는 리스트가 반환되어야한다.

word.stream()
    .map(w -> w.split(""))
    .distinct()
    .collect(toList());

이때까지 학습한 메소드를 활용하여 코드를 작성해보고자 했을 때 이러한 방식으로 작성할 것이다. 리스트에 잇는 각 단어를 문자로 매핑한 다음 distinct로 중복된 문자를 필터링하고자 했을 것이다.

그러나 위 코드에서 map으로 전달한 람다는 각 단어의 String[]을 반환한다는 점이 문제이다. 우리가 원하는 것은 Stream<String>이지만 해당 코드로 반환되는 스트림 형식은 Stream<String[]>이다.

이 문제를 해결하기 위해선 flatMap 메소드를 이용할 수 있다.

  • map과 Arrays.stream 활용

우선 배열 스트림 대신 문자열 스트림이 필요하다.

String[] arrayOfWord = {"Hello", "World"};
Stream<String> streamOfWord = Arrays.stream(arrayOfWord);

위 코드는 문자열을 받아 스트림을 만드는 Arrays.stream() 활용 예시이다.

word.stream()
    .map(w -> w.split(""))
	.map(Arrays::stream)
	.distinct()
    .collect(toList());

문제에 Arrays.stream()을 적용해보았다.
각 단어를 개별 문자열 배열로 변환한 것을 별도의 스트림으로 생성하였지만 결국 List<Stream<String>>이 만들어지면서 문제가 해결되지 않았다.

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


  • flatMap 사용

flatMap을 이용하여 문제를 해결할 수 있다.

word.stream()
     .map(w -> w.split(""))
	 .flatMap(Arrays::stream)
 	 .distinct()
     .collect(toList());

flatMap은 각 배열을 스트림이 아니라 스트림의 콘텐츠로 매핑한다. 즉, map(Arrays::stream)과 달리 flatMap은 하나의 평면화된 스트림을 반환한다.

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

🔎 퀴즈
두 개의 숫자 리스트가 있을 때 모든 숫자 쌍의 리스트를 반환하시오.

List<Integer> num1 = Arrays.asList(1,2,3);
List<Integer> num2 = Arrays.asList(3,4);
List<int[]> pairs = num1.stream()
                        .flatMap(i -> num2.stream()
                                          .map(j -> new int[] {i,j})
                        ).toList();

위 결과에서 쌍의 합이 3의 배수인 경우만 포함하는 리스트를 반환하시오.

List<int[]> pairs = num1.stream()
                        .flatMap(i -> num2.stream()
                                          .filter(j -> (i + j) % 3 == 0)
                                          .map(j -> new int[] {i,j})
                        ).toList();


검색과 매칭

특정 속성이 데이터 집합에 있는지 여부를 검색하는 데이터 처리도 자주 사용된다. 스트림 API는 allMatch, anyMatch, noneMatch, findFirst, findAny 등 다양한 유틸리티 메소드를 제공한다.

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

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

if(menu.stream().anyMatch(Dish::isVegetarian)){
	System.out.println("Vegetarian");
}

위 코드는 menu에 채식 요리가 하나라도 있는지 확인하는 예제이다.

anyMatchboolean을 반환하므로 최종 연산이다.

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

allMatch는 스트림의 모든 요소가 주어진 프레디케이트와 일치하는 지 검사한다. 예를 들어 메뉴가 모두 1000칼로리 이하라면 건강식이라고 볼 수 있다.

boolean isHealthy = menu.stream()
                    	.allMatch(dish -> dish.getCalories() < 1000);

  • NONEMATCH

noneMatchallMatch와 반대 연산을 수행한다. 즉, noneMatch는 주어진 프레디케이트와 일치하는 요소가 없는지 확인한다.

boolean isHealthy = menu.stream()
                    	.noneMatch(dish -> dish.getCalories() >= 1000);

예를 들어 allMatch의 예제를 이렇게 다시 구현할 수 있다.


anyMatch, allMatch, noneMatch 세 메소드는 스트림 쇼트서킷 기법, 즉 자바의 &&, || 와 같은 연산을 활용한다.

쇼트서킷 평가
때로는 전체 스트림을 처리하지 않았더라도 결과를 반환할 수 있다. 예를 들어 여러 and 연산으로 연결된 커다란 불리언 표현식을 평가한다고 가정하자. 표현식에서 하나라도 거짓이라는 결과가 나오면 나머지 표현식의 결과와는 상관없이 전체 결과도 거짓이 된다. 이러한 상황을 쇼트서킷이라고 부른다.

anyMatch, allMatch, noneMatch, findFirst, findAny등의 연산은 모든 스트림의 요소를 처리하지 않고도 결과를 반환할 수 있다. 원하는 요소를 찾았으면 즉시 결과를 반환할 수 있다.

마찬가지로 스트림의 모든 요소를 처리할 필요 없이 주어진 크기의 스트림을 생성하는 limit도 쇼트서킷 연산이다. 특히 무한한 요소를 가진 스트림을 유한한 크기로 줄일 수 있는 유용한 연산이다.


3. 요소 검색

findAny 메소드는 현재 스트림에서 임의의 요소를 반환한다. findAny 메소드를 다른 스트림 연산과 연결해서 사용할 수 있다. 채식 요리를 선택하는 코드를 작성해보겠다.

Optional<Dish> dish = menu.stream()
						  .filter(Dish::isVegetarian)
                          .findAny();

filterfindAny 메소드를 이용하여 채식 요리를 선택할 수 있다.

스트림 파이프라인은 내부적으로 단일 과정으로 실행할 수 있도록 최적화한다. 즉, 쇼트서킷을 이용해서 결과를 찾는 즉시 실행을 종료한다. 그런데 위 코드에서 사용된 Optional은 무엇일까?

  • Optional이란?

Optional<T> 클래스는 값의 존재나 부재 여부를 표현하는 컨테이너 클래스다. 이전 예제에서의 findAny 메소드는 아무 요소도 반환하지 않을 수 있다. null은 쉽게 에러를 일으킬 수 있으므로 자바 8 라이브러리 설계자는 Optional<T>를 만들었다. Optional은 값이 존재하는지 확인하고 값이 없을 때 어떻게 처리할지 강제하는 기능을 제공한다.

  • isPresent()Optional이 값을 포함하면 참을 반환하고, 값을 포함하지 않으면 거짓을 반환한다.
  • isPresent(Consumer<T> block)은 값이 있으면 주어진 블록을 실행한다. Consumer함수형 인터페이스에는 T 형식의 인수를 받으며 void를 반환하는 람다를 전달할 수 있다.
  • T get()은 값이 존재하면 값을 반환하고, 값이 없으면 NoSuchElementException을 일으킨다.
  • T orElse(T other)는 값이 존재하면 값을 반환하고, 값이 없으면 기본값을 반환한다.

예를 들어 위 예제에서 Optional<Dish>에서는 요리명이 null인지 검사할 필요가 없었다.

menu.stream()
	.filter(Dish::isVegetarian)
    .findAny()
    .ifPresent(d -> System.out.println(d.getName()));

ifPresent를 통해 null 여부를 검사한다.


4. 첫 번째 요소 찾기

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

이런 스트림에서 첫 번째 요소를 찾으려면 어떻게 해야할까?

예를 들어 숫자 리스트에서 3으로 나누어떨어지는 첫 번째 제곱값을 반환하는 코드를 작성해보자.

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

findFirst와 findAny는 언제 사용하는 것인가?

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



리듀싱

리듀스 연산을 사용해서 스트림 요소를 조합하여 조금 더 복잡한 질의를 표현하는 방법에 대해 살펴볼 것이다. '메뉴의 모든 칼로리의 합계를 구하시오.' 같은 질의를 수행하려면 Integer 같은 결과가 나올 때까지 스트림의 모든 요소를 반복적으로 처리해야 한다. 이런 질의를 리듀싱 연산(모든 스트림 요소를 처리해서 값으로 도출하는)이라고 한다.

함수형 프로그래밍 언어 용어로는 이 과정을 마치 종이를 작은 조각이 될 때까지 반복해서 접는 것과 비슷하는 의미로 폴드라고 부른다.

1. 요소의 합

reduce 메소드를 살펴보기 전에 for-each 루프를 이용해서 리스트의 숫자 요소를 더하는 코드를 확인하자.

int sum = 0;
for(int i: numbers){
	sum += i;
}

numbers의 각 요소는 결과에 반복적으로 더해진다. 리스트에서 하나의 숫자가 남을 때까지 reduce과정을 반복한다. 코드에는 두 개의 파라미터를 사용했다.

  • sum 변수의 초깃값 0
  • 리스트의 모든 요소를 조합하는 연산 (+)

위 코드를 복사&붙여넣기하지 않고 모든 숫자를 곱하는 연산을 구현할 수 있다면 좋을 것이다.

이런 상황에 reduce를 이용하면 애플리케이션의 반복된 패턴을 추상화할 수 있다.

// 덧셈 연산
int reduceSum = numbers.stream().reduce(0, (a,b) -> a + b);
// 곱셈 연산
int reduceMul = numbers.stream().reduce(0, (a,b) -> a * b);

// reduceSum을 메소드 참조를 사용하여 작성
int reduceSum = numbers.stream().reduce(0, Integer::sum);

reduce의 인수

  • 초깃값
  • 두 요소를 조합해서 새로운 값을 만드느 BinaryOperator<T>

  • 초깃값 없음

초깃값을 받지 않도록 오버로드된 reduce도 있다. 그러나 이 reduceOptional객체를 반환한다.

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

Optional<Integer>를 반환하는 걸까?
스트림에 아무 요소도 없는 상황에 대비하여 결과를 Optional객체로 감싸 반환한다.


2. 최댓값과 최솟값

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

Optional<Integer> max = numbers.stream().reduce((a, b) -> a > b ? a : b);
Optional<Integer> min = numbers.stream().reduce((a, b) -> a < b ? a : b);

위 코드를 메소드 참조를 활용하여 간결하게 표현해보자.

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


실전 연습

지금까지 학습한 내용을 토대로 예제를 풀어보며 스스로 해결하는 과정을 가진다.

  1. 2011년에 일어난 모든 트랜잭션을 찾아 값을 오름차순으로 정리하시오.
  2. 거래자가 근무하는 모든 도시를 중복 없이 나열하시오.
  3. 케임브리지에 근무하는 모든 거래자를 찾아서 이름순으로 정렬하시오.
  4. 모든 거래자의 이름을 알파벳순으로 정렬해서 반환하시오.
  5. 밀라노에 거래자가 있는가?
  6. 케임브리지에 거주하는 거래자의 모든 트랜잭션값을 출력하시오.
  7. 전체 트랜잭션 중 최댓값은 얼마인가?
  8. 전체 트랜잭션 중 최솟값은 얼마인가?

거래자 Trader / 트랜잭션 Transaction 클래스를 생성하고 예제 활용을 위한 객체 생성은 책을 참고하여 그대로 만들었다.

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

List<Transaction> in2011 = 
	 transactions.stream()
                 .filter(t -> t.getYear() == 2011)
                 .sorted(Comparator.comparing(Transaction::getValue))
                 .toList();

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

List<String> city = 
  transactions.stream()
              .map(t -> t.getTrader().getCity())
              .distinct()
              .toList();

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

List<Trader> tradersInCambridge = 
	transactions.stream()
                .map(Transaction::getTrader)
                .filter(trader -> trader.getCity().equals("Cambridge"))
                .distinct()
                .sorted(Comparator.comparing(Trader::getName))
                .toList();

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

List<List<String>> tradersName = 
	transactions.stream()
                .map(t -> Arrays.stream(t.getTrader().getName().split(""))
                        .sorted().toList()
                )
                .distinct()
                .toList();

문제 이해를 잘못했다.. 문제의 의도는 모든 거래자의 이름을 순서대로 String으로 뽑으라는 거였는데 나는 거래자의 이름 안에서 정렬인 줄 알았다..

정답

String traderStr = 
	transactions.stream()
                .map(t -> t.getTrader().getName())
                .distinct()
                .sorted()
                .reduce("", (n1, n2) -> n1 + n2);

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

boolean isTrue = 
	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<Integer> min = 
	transactions.stream()
                .map(Transaction::getValue)
                .reduce(Integer::min);


이 글은 모던 인 자바 액션 책을 실습하며 참고하여 작성한 글입니다.

0개의 댓글