본문 바로가기
Dev/Java

[모던자바인액션] 스트림API가 지원하는 다양한 연산

by ssyoni 2022. 2. 19.
반응형

자바8과 자바9에 추가된 스트림API의 다양한 연산을 살펴보자

 

1. 필터링

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

프레디케이트로 필터링 방법과 고유 요소만 필터링하는 방법이 있다.

1) 프레디케이트 필터링

프리디케이트(불리언을 반환하는 함수)를 인수로 받아서 프레디케이트와 일치하는 모든 요소를 포함하는 스트림을 반환한다.

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

 

2) 고유 요소 필터링

스트림은 고유 요소만 필터링하는 distinct 메서드를 지원한다.

리스트의 모든 짝수를 선택하고 중복을 필터링하는 예제이다.

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

 

 

2. 스트림 슬라이싱

스트림의 요소를 선택하거나 스킵하는 다양한 방법을 알아보자.

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

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

 

TAKEWHILE 활용

다음과 같이 칼로리 순으로 정렬되어있는 리스트가 있다.

List<Dish> specialMenu = Arrays.asList(
			new Dish("season 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),
)
List<Dish> filteredMenu = specialMenu.stream()
					.filter(dish -> dish.getCalories() < 320)
					.colelct(toList());

 

filter 연산을 이용하면 스트림을 반복하면서 각 요소에 프레디테이크를 적용하게 된다.

이때 320 칼로리 거나 그 이상일 때 반복 연산을 멈추면 더 효율적이지 않을까?

takeWhile 연산을 이용하면 간단하게 처리할 수 있다.

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

 

DROPWHILE

dropWhile 메서드는 takeWhile과 정 반대의 작업을 수행한다. dropWhile은 프레디케이트가 거짓이 되는 지점까지 발견된 요소를 지운다.

다음은 320칼로리보다 큰 요소들을 선택하는 코드이다.

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

 

2) 스트림 축소

limit(n) 메서드 지원 → 스트림이 정렬되어있으면 최대 요소 n개를 반환한다.

300칼로리 이상의 세 요리를 선택해서 리스트 만들기

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

 

3)요소 건너뛰기

skip(n) 메서드 지원 → n개 이하의 요소를 제회한 스트림을 반환한다.

300칼로리 이상의 처음 두 요리를 건너뛴 다음 나머지 요리 반환하는 리스트 만들기

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

 

 

3. 매핑

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

스트림 API는 map과 flatmap 메서드를 제공하여 특정 데이터를 선택하는 기능을 제공한다.

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

인수로 제공되는 함수는 각각의 요소에 적용되고 그 결과가 새로운 요소로 매핑된다.

단어 리스트가 주어졌을 때 각 단어가 포함하는 글자 수의 리스트를 구현해보자. 먼저 리스트 각 요소에 함수를 적용해야 한다. 적용해야 할 함수는 단어를 인수로 받아서 길이를 반환하는 함수이다.

List<String> words = Arrays.asList("modern","java","in","action");
List<Integer> wordLength = words.stream()
			.map(String::length)
			.collect(toList());

다음은 각 요리 이름의 길이를 알아내는 코드이다.

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

 

2) 스트림 평면화

리스트에서 고유문자로 이루어진 리스트를 반환해보자.

["Hello","world"]

다음과 같은 리스트가 있다면 결과로 [”H”,”e”,”l”,”o”,”w”,”r”,”d”]를 포함하는 리스트가 반환되어야 한다.

앞서 배운것을 응용해서 풀어보면 각 단어를 문자열로 매핑한 뒤 distinct 연산을 활용해서 중복 문자를 필터링할 수 있을 것이다.

words.stream()
		 .map(world->world.split(""))
		 .distinct()
		 .collect(toList());

하지만 위의 코드에서 map으로 전달하는 람다는 각 단어의 문자열 배열을 반환한다는 문제가 있다. 즉 Stream<String>의 형식이 반환되는 것이 아니라 Stream<String[]> 형식을 반환하는 것이다.

출처 : 모던자바인액션

 

map과 Array.stream 활용
String[] arrayOfWords = {"Goodbye","world"};
Stream<String> streamOfWords = Arrays.stream(arrayOfWords);

words.stream()
		 .map(word->word.split("")) // 각 단어를 개별 문자열 배열로 변환
		 .map(Arrays::stream) // 각 배열을 별도의 스트림으로 생성
		 .distinct()
	   	 .collect(toList());

→ 문자열 스트림이 아니라 스트림 리스트가 만들어지기 때문에 문제가 해결되지 않음 (List<Stream<String>>)

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

 

flatMap 메서드 사용
List<String> uniqueCharacters = words.stream()
                    .map(word->word.split("")) // 각 단어를 개별 문자를 포함하는 배열로 변환
                    .flatMap(Arrays::stream) // 생성된 스트림을 하나의 스트림으로 평면화
                    .distinct()
                    .collect(toList());

map(Arrays::stream)과 달리 faltMap 은 하나의 평면화된 스트림을 반환한다.

쉽게 말해서 flatMap 은 Stream<String[]> → Stream <String>와 같이 한 차원 낮춰주는 기능을 제공한다.

그림 출처 :  https://dev-kani.tistory.com/33

 

 

문제 1) 숫자 리스트가 주어졌을 때 각 숫자의 제곱근으로 이루어진 리스트를 반환하시오. 예를 들어 [1,2,3,4,5]가 주어지면 [1,4,9,16,25]를 반환해야 한다.

List<Integer> result = nums.stream()
                .map(n -> n*n)
                .collect(toList());

문제 2) 두 개의 숫자 리스트가 있을 때 모든 숫자 쌍의 리스트를 반환하시오. 예를 들어 두 개의 리스트[1,2,3]와 [3,4]가 주어지면 [(1,3), (1,4), (2,3), (2,4), (3,3), (3,4)] 를 반환해야 한다.

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

문제 3) 이전 예제에서 합이 3으로 나누어 떨어지는 쌍만 반환하기

ex) (2,4), (3,3)

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

 

 

4. 검색과 매칭

특정 속성이 데이터 집합에 있는지 여부를 검색하는 데이터 처리를 위해 스트림 API가 제공하는 메서드들

allMatch, anyMatch, noneMatch, findFirst, findAny

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

anyMatch

menu에 채식요리가 있는지 확인하는 코드

if (menu.stream().anyMatch(Dish::isVegetarian)){
    System.out.println("This menu is vegetarian friendly !!!");
}

 

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

allMatch

모든 요소가 주어진 프레디케이트와 일치하는지를 검사한다.

다음은 메뉴가 건강식(모든 요리가 1000칼로리 이하면 건강식)인지 확인하는 예제이다.

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

 

noneMatch

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

위의 예제를 nonematch 를 사용해서 다시 구현해보자.

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

 

anyMatch, allMatch, noneMatch 세 개의 메서드는

  • 쇼트서킷 기법을 활용한다.
  • boolean 값을 반환하기 때문에 최종 연산이다.

** 쇼트서킷이란? : 표현식에서 하나라도 거짓이라는 결과가 나오면 나머지 표현식의 결과와 상관없이 결과값을 즉시 반환한다. 모든 스트림의 요소를 처리하지 않고도 결과를 반환할 수 있다. limit 도 쇼트서킷 연산이다.

 

3) 요소 검색

  • findAny : 스트림에서 가장 먼저 탐색되는 요소를 반환
  • findFirst : 조건에 일치하는 요소들 중 스트림에서 순서가 가장 앞에 위치한 요소를 반환

두 메서드는 모두 filter 조건에 일지하는 하나의 요소를 Optional 객체로 반환한다.

 

 

5. 리듀싱(reduce)

리듀스 연산을 이용하면

‘모든 메뉴의 합계를 구하시오’,

‘메뉴에서 칼로리가 가장 높은 요리는?’

같은 스트림 요소를 조합해서 더 복잡한 질의를 표현할 수 있다.

이러한 질의를 수행하려면 Integer 결과가 나올 때까지 스트림의 모든 요소를 반복적으로 처리해야한다.

이를 리듀싱 연산 이라고 한다.

 

1) 요소의 합

다음은 for-each 문을 사용해서 리스트의 숫자 요소를 더하는 코드이다.

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

위의 코드를 reduce를 이용하여 반복된 패턴을 추상화할 수 있다.

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

아래의 그림은 reduce의 연산과정을 보여준다.

출처 : 모던자바인액션

람다의 첫 번째 파라미터 a 는 초기 값으로 주어진 0이 사용된다. 그 다음 스트림에서 4를 소비해서 두 번째 파라미터인 b로 사용된다. 이를 반복하며 누적값을 구한다.

자바8에서는 Integer클래스에서 제공하는 두 숫자를 더하는 정적 sum 메서드를 제공한다. 메서드 참조를 이용해서 코드를 더 간결하게 만들 수 있다.

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

 

초기값 없이 reduce 사용하기

초깃값을 받지 않도록 하는 reduce는 Optional 객체를 반환한다.

Optional을 반환하는 이유는? → 스트림에 아무 요소가 없는 상황이라면, 초깃값이 없으므로 reduce는 합계를 반환할 수 없다. 합계가 없음을 가리킬 수 있도록 Optional 객체로 감싼 결과를 반환한다.

 

2) 최댓값과 최솟값

최댓값과 최솟값을 구하기 위해 reduce 는 두 개의 인수를 받는다

  • 초깃값
  • 스트림의 두 요소를 합쳐서 하나의 값으로 만드는에 사용할 람다표현식
// 최댓값
Optional<Integer> max = numbers.stream().reduce(Integer::max);
// 최솟값
Optional<Integer> min = numbers.stream().reduce(Integer::min);

 

문제) map과 reduce 메서드를 이용해서 스트림의 요리 개수를 계산하시요.

int cnt = menu.stream().map(dish -> 1).reduce(0,(a,b)->a+b);

 

reduce 메서드의 장점과 병렬화
  • 기존의 단계적 반복으로 합계를 구하게 되면 sum 변수를 공유해야 하기 때문에 쉽게 병렬화하기가 어렵다.
  • reduce를 이용하면 내부 반복이 추상화되기 때문에 내부구현에서 병렬로 reduce 처리가 가능하다.

 

스트림 연산 고려 조건
- map, filter 등은 입력스트림에서 요소를 받아 출력 스트림을 보낸다. → 내부 상태를 갖지 않는 연산(stateless operation)
- reduce, sum, max 등은 누적할 내부 상태가 필요하다. 또한 스트림에서 처리하는 요소 수와 상관 없이 내부 상태의 크기는 한정(bound)되어있다.
- sorted, distinct 등은 스트림의 요소를 정렬하거나 중복을 제거하기 위해 과거의 이력을 알고 있어야 한다. 때문에 모든 요소가 버퍼에 추가되어 있어야 한다. → 내부 상태를 갖는 연산(stateful opertaion)

 

반응형

댓글