[모던 자바] 스트림(Stream) 활용하기
이 전 글에는 스트림(Stream)이란 무엇인가?를 알아보았다.
그럼 스트림 API가 지원하는 다양한 연산을 알아보자.
필터링
Predicate로 필터링 (filter)
스트림 인터페이스는 filter 메서드를 지원한다.
filter 메서드는 Predicate를 인수로 받아서 일치하는 모든 요소를 포함하는 스트림으로 반환한다.
List<Dish> vegetarianMenu = menu.stream()
.filter(Dish::isVegetarian)
.collect(toList());
고유 요소 필터링 (distinct)
스트림은 고유 요소(Primary)로 이루어진 스트림을 반환하는 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는 스트림의 요소를 효과적으로 선택할 수 있도록 takeWhile, dropWhile 두 가지로 새로운 메서드를 지원한다.
takeWhile
많은 데이서 소스에서는 filter를 사용할땐 정렬이 되어있어도 전체 스트림을 반복하면서 각 요소에 Predicate를 적용하지만 takeWhile을 이용하면 설정한 조건이 되었을 때 반복 작업을 중단 할 수 있다.
작은 데이터 소스에선 차이가 별로 없지만 많은 데이터 소스일때는 상당한 차이가 날 수 있다.
List<Dish> sliceMenu = specialMenu.stream()
.takeWhile(dish -> dish.getCalories() < 320)
.collect(toList());
dropWhile
takeWhile의 나머지 요소를 반환한다.
List<Dish> sliceMenu = specialMenu.stream()
.dropWhile(dish -> dish.getCalories() < 320)
.collect(toList());
스트림 축소 (limit)
스트림은 주어진 값 이하의 크기를 갖는 새로운 스트림을 반환하는 limit(n) 메서드를 지원한다.
스트림이 정렬되어 있으면 최대 요소 n개를 반환할 수 있다. 정렬되지 않은 스트림에도 limit를 사용할 수 있지만 결과도 정렬되지 않은 상태로 반환된다.
List<Dish> dishes = specialMenu.stream()
.filter(dish -> dish.getCalories() > 300)
.limit(3)
.collect(toList());
요소 건너뛰기 (skip)
스트림은 처음 n개 요소를 제외한 스트림을 반환하는 skip(n) 메서드를 지원한다.
n개 이하의 요소를 포함하는 스트림에 skip(n)을 호출하면 빈 스트림이 반환된다.
List<Dish> dishes = specialMenu.stream()
.filter(dish -> dish.getCalories() > 300)
.skip(2)
.collect(toList());
매핑
특정 객체에서 특정 데이터를 선택하는 작업은 데이터 처리 과정에서 자주 수행되는 연산이다.
스트림 API의 map과 flatMap 메서드는 특정 데이터를 선택하는 기능을 제공한다.
스트림의 각 요소 함수 적용하기 (map)
스트림 함수는 인수로 받는 map 메서드를 지원한다.
인수로 제공된 함수는 각 요소에 적용되며 함수를 적용한 결과가 새로운 요소로 매핑(변환)된다.
List<String> foodNames = foods.stream()
.map(Food::getName)
.collect(toList());
위 코드 처럼 특정 객체에서 특정한 값을 가져올 수도 있고, 필요에 맞게 변환할때 사용한다.
map 메서드는 서로 연결해서 더 자세한 값을 가져올 수도 있다. 아래 예제는 map 메서드를 연결해 음식 이름 값의 길이를 반환하는 코드다.
List<String> foodNames = foods.stream()
.map(Food::getName)
.map(String::length)
.collect(toList());
스트림 평면화 (flatMap)
flatMap 메서드는 스트림의 각 값을 다른 스트림으로 만든 다음에 모든 스트림을 하나의 스트림으로 연결하는 기능을 수행한다.
만약 ["Hello", "World"]라는 문자열 배열을 split으로 쪼개 ["H", "e", "l", "l", "o", "W", "o", "r", "l", "d"]의 스트림을 반환한다고 해보자.
String[] words = {"Hello", "World"};
Stream<String[]> wordSteams = words.stream()
.map(word -> word.split(""))
.collect(toList());
위 코드를 실행해 보면 Stream<String>이 아닌 Stream<String[]>이 반환된다.
이럴 때 flatMap을 사용하여 하나의 평면화 된 스트림을 반환할 수 있다. flatMap은 각 배열을 스트림이 아니라 스트림의 콘텐츠로 매핑한다.
String[] words = {"Hello", "World"};
Stream<String> wordSteams = words.stream()
.map(word -> word.split(""))
.flatMap(Arrays::stream) // Arrays.stream(words.split(""))
.collect(toList());
검색과 매칭
특정 속성이 데이터 집합에 있는지 여부를 검색하는 데이터 처리도 자주 사용된다.
스트림 API는 allMatch, anyMatch, noneMatch, findFirst, findAny 등 다양한 유틸리티 메서드를 제공한다.
Predicate가 적어도 한 요소와 일치하는지 확인 (anyMatch)
Predicate가 주어진 스트림에 적어도 한 요소와 일치하는지 확인할 때 anyMatch 메서드를 이용한다.
if (foods.stream().anyMatch(Food::isSpicy)) {
// 음식 중 매운 음식이 하나라도 있을때
}
anyMatch는 boolean으로 반환하므로 최종연산이다.
Predicate가 모든 요소와 일치하는지 확인 (allMatch)
anyMatch 메서드와는 다르게 스트림의 모든 요소가 주어진 Predicate와 일치하는지 검사한다.
if (foods.stream().allMatch(Food::isSpicy)) {
// 모든 음식이 매운 음식일때
}
noneMatch
noneMatch는 allMatch와 반대 연산을 수행한다.
즉, noneMatch는 주어진 Predicate와 일치하는 요소가 없는지 확인한다.
if (foods.stream().noneMatch(Food::isSpicy)) {
// 모든 음식이 매운 음식이 아닐때
}
임의의 요소 검색 (findAny)
findAny 메서드는 현재 스트림에서 임의의 요소를 반환한다.
findAny 메서드를 다른 스트림 연산과 연결해서 사용할 수 있다.
// 매운 음식 중 임의의 음식 검색
Optional<Food> food = foods.stream()
.filter(Food::isSpicy)
.findAny();
스트림 파이프라인은 내부적으로 찾는 결과를 만났을때 즉시 실행이 종료되어 최적화 된다.
첫 번째 요소 검색 (findFirst)
리스트 또는 정렬된 연속 데이터처럼 순서가 존재하는 스트림으로부터 첫 번째 요소를 찾을때 findFirst 메서드를 사용한다.
// 매운 음식 중 첫번째 음식 검색
Optional<Food> food = foods.stream()
.filter(Food::isSpicy)
.findFirst();
findFirst는 findAny에 비해 병렬 실행에서 첫 번째 요소를 찾기가 힘들다.
따라서 요소의 반환 순서가 상관없다면 병렬 스트림에서는 제약이 적은 findAny를 사용한다.
리듀싱
리듀스 연산은 반복적인 연산을 실행해 결과를 도출하는 연산이다.
예를 들어 1부터 10까지의 숫자를 더하는 연산을 실행하는 것이 리듀싱 연산이다.
함수형 프로그래밍 언어 용어로는 이 과정이 마치 종이를 작은 조각이 될 때까지 반복해서 접는 것과 비슷하다는 의미로 폴드라고 부른다.
요소의 합 (reduce)
만약 1부터 10까지의 숫자를 더 할때 for-each를 사용하면 아래처럼 사용할 수 있다.
Integer[] numbers = {1, 2, 3, 4, 5, 6, 7, 8, 9, 10};
int sum = 0;
for (int nubmer : numbers) {
sum += nubmer;
}
리스트에서 하나의 숫자가 남을 때까지 reduce 과정을 반복한다.
이런 상황에서 아래와 같이 reduce를 이용하면 애플리케이션의 반복된 패턴을 추상화할 수 있다.
int sum = numbers.stream().reduce(0, (a, b) -> a + b);
이 코드를 메서드 참조를 이용해서 더 간결하게 만들 수 있다.
int sum = numbers.stream().reduce(0, Integer::sum);
초기값이 없는 reduce
초깃값을 받지 않도록 오버로드된 reduce도 있다.
만약 요소가 하나도 없는 경우를 대비에 Optional로 변환한다.
Optional<Integer> sum = numbers.stream().reduce((a, b) -> (a + b));
최댓값과 최솟값
최댓값과 최솟값을 찾을 때도 reduce를 활용할 수 있다.
Optional<Integer> max = numbers.stream().reduce(Integer::max);
Optional<Integer> min = numbers.stream().reduce(Integer::min);
요소의 수 구하기
요소의 갯수를 구할때도 reduce를 활용할 수 있다.
int count = foods.stream()
.map(f -> 1)
.reduce(0, (a, b) -> a + b);
기존의 단계적 반복으로 합계를 구하는 것과 reduce를 이용해서 합계를 구하는 것은 어떤 차이가 있을까?
reduce를 이용하면 내부 반복이 추상화되면서 내부 구현에서 병렬로 reduce를 실행할 수 있게 된다.
반복적인 합계에서는 sum 변수를 공유해야 하므로 쉽게 병렬화하기 어렵다.
상태가 있는, 없는 스트림 연산
스트림 연산은 병렬로 쉽게 실행할 수 있다는 특징이 있는데, 각각의 연산은 내부적인 상태를 고려해봐야 한다.
map, filter 등은 입력 스트림에서 각 요소를 받아 결과를 출력 스트림으로 보낸다. 따라서 이런 연산들은 내부에 상태를 갖지 않는 연산이다.
반면 sorted나 distinct 같은 연산은 요소를 정렬하고, 중복을 제거하려면 과거의 이력을 알고 있어야 한다.
이렇게 상태가 있는 스트림 연산에 같은 경우는 모든 요소가 버퍼에 추가되어 있어야 하기 때문에, 데이터 스트림의 크기가 크거나 무한이라면 문제가 생길 수 있다.
이러한 연산을 내부 상태를 갖는 연산(stateful operation)이라 한다.
상태가 있는 스트림 연산들은 아래와 같다
- distinct
- skip
- limit
- sorted
- reduce
기본형 특화 스트림
자바 스트림 API는 숫자 스트림을 효율적으로 처리할 수 있도록 Java 8에서는 세 가지 기본형 특화 스트림(primitive stream specialzation)을 제공한다.
스트림 API는 박싱 비용을 피할 수 있도록
int 요소에 특화면 IntStream,
double 요소에 특화된 DoubleStream,
long 요소에 특화된 LongStream을 제공한다.
각각의 인터페이스는 숫자 스트림의 합계를 계산하는 sum, 최댓값 요소를 검색하는 max 같이 자주 사용하는 숫자 관련 리듀싱 연산 수행 메서드를 제공할 뿐 만 아니라 다시 객체 스트림으로 복원하는 기능도 제공한다.
특화 스트림은 오직 박싱(Casting) 과정에서 일어나는 효율성과 관련이 있으며 스트림에 추가 기능을 제공하지는 않는다.
숫자 스트림으로 매핑
스트림을 특화 스트림으로 변환할 때는 mapToInt, mapToDouble, mapToLong 세 가지 메서드를 가장 많이 사용한다.
int totalPayPrice = pays.stream()
.mapToInt(Pay::getPayPrice)
.sum();
위에서 사용한 mapToInt 메서드는 특정 값(PayPrice)을 추출한 다음 IntStream을 반환한다.
따라서 IntStream 인터페이스에서 제공하는 sum 메서드를 이용해서 칼로리 합계를 계산할 수 있다.
스트림이 비어있으면 sum은 기본값 0을 반환한다.
IntStream은 max, min, average 등 다양한 유틸리티 메서드도 지원한다.
객체 스트림으로 복원하기
숫자 스트림을 만든 다음에, 원상태인 특화되지 않은 스트림으로 복원이 가능하다.
boxed 메서드를 이용해서 특화 스트림을 일반 스트림으로 변환할 수 있다.
IntStream intStream = pays.stream().mapToInt(Pay::getPayPrice);
Stream<Integer> stream = intStream.boxed(); // 숫자 스트림을 스트림으로 변환
특화된 Optional
Optional 클래스는 값이 존재하는지 여부를 구별할 수 있다.
만약 스트림 연산값이 null인 경우가 필요할때는 Optional 클래스를 사용하기도 한다.
Optional을 Integer, String 등의 참조 형식으로 파라미터화 할 수 있다. 또한 OptionalInt, OptionalDouble, OptionalLong 세가지 기본형 특화 스트림 버전도 제공한다.
숫자 범위
특정 범위의 숫자를 이용해야 하는 상황에서 숫자형 스트림이 제공하는 range, rangeClosed라는 두 가지 정적 메서드를 사용할 수 있다.
range 메서드와 rangeClosed 메서드는 최소, 최댓값을 인자로 받는데
range를 사용하면 최소, 최대 인자값을 포함하지 않은 범위의 숫자들를 제공하고,
rangeClosed를 사용하면 최소, 최대 인자값을 포함한 범위의 숫자들을 제공한다.
IntStream eventNumbers = IntStream.rangeClosed(1, 100) // [1, 2 ... 99, 100]
.filter(n -> n % 2 == 0);
System.out.println(eventNumbers.count());
위 코드를 보면 filter 메서드를 사용했는데, filter를 호출할때는 실제로 아무 계산도 이루어지지 않고, 최종 연산인 count를 호출할 때 filter 연산이 이루어진다.