이 글은 모던 자바 인 액션라는 책을 읽고 정리한 글입니다.
5장에서는 스트림으로 복잡한 데이터 처리 질의를 표현하는 방법을 자세히 살펴봅니다.
이 장의 내용
- 필터링, 슬라이싱, 매칭
- 검색, 매칭, 리듀싱
- 특정 범위의 숫자와 같은 숫자 스트림 사용하기
- 다중 소스로부터 스트림 만ㄷ르기
- 무한 스트림
이 장에서는 스트림 API가 지원하는 다양한 연산을 살펴본다.
필터링
스트림의 요소를 선택하는 방법, 즉 프레디케이트 필터링 방법과 고유 요소만 필터랑하는 방법을 배운다.
프레디케이트로 필터링
스트림 인터페이스는 filter 메서드를 지원한다. filter 메서드는 프레디케이트(불리언을 반환하는 함수)를 인수로 받아서 프레디케이트와 일치하는 모든 요소를 포함하는 스트림을 반환한다.
List<Dish> vegetarianMenu = menu.stream()
.filter(Dish::isVegetarian) // 채식 요리인지 확인하는 메서드 참조
.collect(toList());
고유 요소 필터링
스트림은 고유 요소로 이루어진 스트림을 반환하는 distinct라는 메서드도 지원한다(고유 여부는 스트림에서 만든 객체의 hashCode, equals로 결정된다).
List<Integer> numbers = Arrays.asList(1, 2, 1, 3, 3, 2, 4);
number.stream()
.filter(i -> i % 2 == 0)
.distinct()
.forEach(System.out::println);
스트림 슬라이싱
스트림의 오소를 선택하거나 스킵하는 다양한 방법을 설명합니다.
프레디케이트를 이용한 슬라이싱
자바 9는 스트림의 요소를 효과적으로 선택할 수 있도록 takeWhile, dropWhile 두 가지 새로운 메서드를 지원한다.
TAKEWHILE 활용
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 fires", true, 530, Dish.Type.OTHER));
filter 연산을 이용해 전체 스트림을 반복하는 대신, talkWhile 연산을 이용해서 반복작업을 중단할 수 있다.
takeWhile을 이용하면 무한 스트림을 포함한 모든 스트림에 프레이케이트를 적용해 스트림을 슬라이스할 수 있다.
List<Dish> sliceMenu1 = specialMenu.stream()
.talkWhile(dish -> dish.getCalories() < 320)
.collect(toList()); // seasonal fruit, prawns 목록
DROPWHILE 활용
List<Dish> sliceMenu2 = specialMenu.stream()
.dropWhile(dish -> dish.getCalories() < 320)
.collect(toList()); // rice, chicken, french fries 목록
dropWhile은 프레디케이트가 처음으로 거짓이 되는 지점까지 발견된 요소를 버린다. 프레디케이트가 거짓이 되면 그 지점에서 작업을 중단하고 남은 모든 요소를 반환한다. dropWhile은 무한한 남은 요소를 가진 무한 스트림에서도 동작한다.
스트림 축소
스트림은 주어진 사이즈 이하의 크기를 갖는 새로운 스트림을 반환하는 limit(n) 메서드를 지원한다. 스트림이 정렬되어 있으면 최대 요소 n 개를 반환할 수 있다.
List<Dish> dishes = specialMenu.stream()
.filter(dish -> dish.getCalories() > 300)
.limit(3)
.collect(toList()); //rice, chicken, french fries 목록
소스가 정렬되어 있지않다면 limit의 결과로 정렬되어있지 않은 상태로 반환된다.
요소 건너뛰기
스트림은 처음 n개 요소를 제외한 스트림을 반환하는 skip(n) 메서드를 지원한다.
List dishes = menu.stream()
.filter(d -> d.getCalories() > 300)
.skip(2)
.collect(toList());
매핑
특정 객체에서 특정 데이터를 선택하는 작업은 데이터 처리과정에서 자주 수행되는 연산이다. 스트림 API의 map과 flatMap 메서드는 특정 데이터를 선택하는 기능을 제공한다.
스트림의 각 요소에 함수 적용하기
스트림은 함수를 인수로 받는 map 메서드를 지원한다. 인수로 제공된 함수는 각 요소에 적용되며 함수를 적용한 결과가 새로운 요소로 매핑된다.
// 스트림의 요리명을 추출하는 코드
List<String> dishNames = menu.stream()
.map(Dish::getName)
.collect(toList());
// 단어 리스트의 각 단어가 포함하는 글자수의 리스트를 반환
List<String> words = Arrays.asList("Modern", "Java", "In", "Action");
List<Integer> wordLengths = words.stream()
.map(String::length)
.collect(toList());
// 각 요리명의 길이를 추출하는 코드
List<String> dishNames = menu.stream()
.map(Dish::getName)
.map(String::length)
.collect(toList());
스트림 평면화
매서드 map을 이용해서 리스트의 각 단어의 길이를 반환하는 방법을 확인했다. 이를 응용해서 리스트에서 고유 문자로 이루어진 리스트를 반환해보자.
words.stream()
.map(word -> word.split(""))
.distinct()
.collect(toList());
위 코드에서 map으로 전달한 람다는 각 단어의 String[](문자열 배열)을 반환한다는 점이 문제다. 따라서 map 메서드가 반환한 스트림의 형식은 Stream<String[]> 이다.
map과 Arrays.stream 활용
배열 스트림 대신 문자열 스트림이 필요하다.
words.stream()
.map(word -> word.split("")) // 각 단어를 개별 문자열로 반환
.map(Arrays::stream) // 각 배열을 별도의 스트림으로 생성
.distinct()
.collect(toList));
결국 스트림 리스트(List<Stream<String>>)가 만들어지면서 문제가 해결되지 않았다.
flatMap 사용
List uniqueCharacters = words.stream()
.map(w -> w.split("")) // 각 단어를 개별 문자를 포함하는 배열로 변환
.flatMap(Arrays::stream) // 생성된 스트림을 하나의 스트림으로 평면화
.distinct()
.collect(Collectors.toList());
flatMap은 각 배열을 스트림이 아니라 스트림의 콘텐츠로 매핑한다. flatMap은 스트림의 각 값을 다른 스트림으로 만든 다음에 모든 스트림을 하나의 스트림으로 연결하는 기능을 수행한다.
검색과 매칭
특정 속성이 데이터 집합에 있는지 여부를 검색하는 데이터 처리도 자주 사용이 된다.
프레디케이트가 적어도 한 요소와 일치하는지 확인
프레디케이트가 주어진 스트림에서 적어도 한 요소와 일치하는지 확인할 때 anyMatch 메서드를 이용한다.
if(menu.stream().anyMatch(Dish::isVegetarian)) {
System.out.println("The menu is (somewhat) vegetarian friendly!!");
}
anyMatch는 불리언을 반환하므로 최종연산이다.
프레디케이트가 모든 요소와 일치하는지 검사
allMatch 메서드는 anyMatch와 달리 스트림의 모든 요소가 주어진 Predicate와 일치하는지 검사한다.
boolean isHealthy = menu.stream().allmatch(d -> d.getCalories() < 1000);
NONEMATCH
noneMatch는 allMatch와 반대 연산을 수행한다. noneMatch는 주어진 프레디케이트와 일치하는 요소가 없는지 확인한다.
boolean isHealthy = menu.stream().noneMatch(d -> d.getCalories() > 1000);
anyMatch, allMatch, noneMatch 세 메서드는 스트림 쇼트서킷 기법, 즉 자바의 &&, ||와 같은 연산을 활용한다.
쇼트서킷 평가
표현식에서 하나라도 거짓이라는 결과가 나오면 나머지 표현식의 결과와 상관없이 전체 결과도 거짓이 된다. 이러한 상황을 쇼트서킷이라고 부른다. allMatch, noneMatch, findFirst, findAny 등의 연산은 모든 스트림의 요소를 처리하지 않고도 결과를 반환할 수 있다. 원하는 요소를 찾았으면 즉시 결과를 반환할 수 있다. 특히 무한한 요소를 가진 스트림을 유한한 크기로 줄일 수 있는 유용한 연산이다.
요소 검색
findAny 메서드는 현재 스트림에서 임의의 요소를 반환한다. findAny 메서드를 다른 스트림 연산과 연결해서 사용할 수 있다.
Optional<Dish> dish = menu.stream()
.filter(Dish::isVegetarian)
.findAny();
OPtional이란?
Optional<T> 클래스는 값의 존재나 부재 여부를 표현하는 컨테이너 클래스다. null은 쉽게 에러를 일으킬 수 있으므로 자바 8 라이브러리 설계자는 Optional<T>라는 기능을 만들었다. 일단 Optional은 값이 존재하는지 확인하고 값이 없을 때 어떻게 처리할 것인지 강제하는 기능을 제공한다는 사실만 알아 두자.
- isPresent()는 Optional이 값을 포함하면 true를 반환하고 값을 포함하지 않으면 false를 반환한다.
- ifPresent(Consumer block)은 값이 있으면 주어진 블록을 실행한다. Consumer 함수형 인터페이스에는 T 형식의 인수를 받으며 void를 반환하는 람다를 전달할 수 있다.
- T get()은 값이 존재하면 값을 반환하고, 값이 없으면 NoSuchElementException을 일으킨다.
- T orElse(T other)는 값이 있으면 값을 반환하고, 값이 없으면 기본값을 반환한다.
menu.stream()
.filter(Dish::isVegetarian)
.findAny()
.ifPresent(d -> System.out.println(d.getName()); // 값이 있으면 출력되고, 값이 없으면 아무 일도 일어나지 않는다.
첫 번째 요소 찾기
리스트 또는 정렬된 연속 데이터로부터 생성된 스트림처럼 일부 스트림에는 논리적인 아이템 순서가 정해져 있을 수 있다. 이런 스트림에서 첫 번째 요소를 찾으려면 어떻게 해야할까?
List<Integer> someNumbers = Arrays.asList(1, 2, 3, 4, 5);
Optional<Integer> firstSquareDivisibleByThree =
someNumbers.stream()
.map(n -> n * n)
.filter(n -> n % 3 == 0)
.findFirst(); // 9
findFrist 와 findAny는 언제 사용하나?
병렬 실행에서는 첫 번째 요소를 찾기 어렵다. 따라서 요소의 반환 순서가 상관없다면 병렬 스트림에서는 제약이 적은 findAny를 사용한다.
리듀싱
최종 연산은 불리언(allMatch 등), void(forEach), 또는 Optional 객체(findAny 등)를 반환했다.
리듀싱 연산이란 모든 스트림 요소를 처리해서 값으로 도출하는 연산이다. 함수형 프로그래밍 언어 용어로는 이 과정이 종이를 작은 조각이 될 때까지 반복해서 접는 것과 비슷하다는 의미로 폴드(fold)라고 불린다.
요소의 합
int sum = 0;
for (int x : numbers) {
sum += x;
}
위의 코드를 reduce를 이용해서 다음처럼 스트림의 모든 요소를 더할 수 있다.
int sum = numbers.stream().reduce(0, (a, b) -> a + b);
reduce는 두 개의 인수를 갖는다.
- 초깃값 0
- 두 요소를 조합해서 새로운 값을 만드는 BinaryOperator<T>
reduce로 다른 람다, 즉 (a,b) -> a*b를 넘겨주면 모든 요소에 곱셈을 적용할 수 있다.
int product = numbers.stream().reduce(1, (a, b) -> a * b);
초깃값 없음
초기값을 받지 않도록 오버로드된 reduce도 있다. 그러나 이 reduce는 Optional 객체를 반환한다.
Optional<Integer> sum = numbers.stream().reduce((a, b) -> a + b);
스트림에 아무 요소도 없는 상황을 생각하면 초기값이 없어 reduce 는 합계를 반환할 수 없다. 따라서 합계가 없음을 가리킬 수 있도록 Optional 객체로 감싼 결과를 반환한다.
최댓값과 최솟값
최댓값과 최솟값을 찾을 때도 reduce를 활용할 수 있다.
Optional<Integer> max = numbers.stream().reduce(Integer::max);
Optional<Integer> min = numbers.stream().reduce(Integer::min);
스트림 연산 : 상태 없음과 상태 있음
map, filter 등은 입력 스트림에서 각 요소를 받아 0 또는 결과를 출력 스트림으로 보낸다. 따라서 이들은 보통 상태가 없는, 즉 내부 상태를 갖지 않는 연산(stateless operation)이다.
하지만 reduce, sum, max 같은 연산은 결과를 누적할 내부 상태가 필요하다. 스트림에서 처리하는 요소의 수와 관게없이 내부 상태의 크기는 한정(bounded) 되어 있다.
반면 sorted나 distinct 같은 연산은 filter나 map처럼 스트림을 입력으로 받아 다른 스트림을 출력하는 것처럼 보일 수 있다. 하지만 sorted나 distinct는 filter나 map과는 다르다. 스트림의 요소를 정렬하거나 중복을 제거하려면 과거의 이력을 알고 있어야 한다. 연산을 수행하는 데 필요한 저장소 크기는 정해져 있지 않다. 따라서 데이터 스트림의 크기가 크거나 무한이라면 문제가 생길 수 있다. 이러한 연산을 내부 상태를 갖는 연산(stateful operation) 이라한다.
숫자형 스트림
이전에 reduce 메서드로 스트림 요소의 합을 구하는 예제를 살펴봤다.
int calories = menu.stream()
.map(Dish::getCalories)
.reduce(0, Integer::sum);
사실 위 코드에는 박싱 비용이 숨어있다. 내부적으로 합계를 계산하기 전에 Integer를 기본형으로 언박싱해야 한다.
다행히도 스트림 API 숫자 스트림을 효율적으로 처리할 수 있도록 기본형 특화 스트림(primitive stream speciailzation)을 제공한다.
기본형 특화 스트림
스트림 API는 박싱 비용을 피할 수 있도록 'int 요소에 특화된 IntStream', 'double 요소에 특화된 DoubleStream', 'long 요소에 특화된'LoingStream을 제공한다. 각각의 인터페이스는 합계를 계산하는 sum, 최댓값 요소를 검색하는 max와 같이 자주 사용하는 숫자 관련 리듀싱 연산 수행 메서드를 제공한다. 또한 필요할 때 다시 객체 스트림으로 복원할 수 있는 기능도 제공한다.
숫자 스트림으로 매핑
스트림을 특화 스트림으로 변환할 때는 mapToInt, mapToDouble, mapToLong 세 가지 메서드를 가장 많이 사용한다.
int calories = menu.stream() // Stream<Dish> 반환
.mapToInt(Dish::getCalories) // IntStream 반환
.sum();
mapToInt 메서드는 각 요리에서 모든 칼로리(Integer 형식)을 추출한 다음에 IntStream을(Stream<Integer>가 아님) 반환한다. 따라서 IntStream 인터페이스에서 제공하는 sum 메서드를 이용해서 칼로리 합계를 계산할 수 있다. 스트림이 비어있으면 sum은 기본값 0을 반환한다.
객체 스트림으로 복원하기
boxed 메서드를 이용해서 특화 스트림을 일반 스트림으로 변환할 수 있다.
IntStream intStream = menu.stream().mapToInt(Dish::getCalories); // 스트림을 숫자 스트림으로 변환
Stream(Integer> stream = intStream.boxed(); // 숫자 스트림을 스트림으로 변환
기본값 : OptionalInt
Optional을 Integer, String 등의 참조 형식으로 파라미터화 할 수 있다. 또한 OptionalInt, OptionalDouble, OptionalLong 세 가지 기본형 특화 스트림 버전도 제공한다.
OptionalInt maxCalories = menu.stream()
.mapToInt(Dish::getCalories)
.max();
이제 OptionalInt를 이용해서 최댓값이 없는 상황에 사용할 기본값을 명시적으로 정의할 수 있다.
int max = maxCalories.orElse(1); // 값이 없을 때 기본 최댓값을 명시적으로 설정
숫자 범위
두 메서드 모두 첫 번째 인수로 시작값을, 두 번째 인수로 종료값을 갖는다. range 메서드는 시작값과 종료값이 결과에 포함되지 않는 반면 rangedClosed는 시작값과 종료값이 결과에 포함된다는 점이 다르다.
IntStream evenNumbers = IntStream.rangeClosed(1, 100) // [1, 100]의 범위를 나타낸다.
.filter(x -> x % 2 == 0); // 1부터 100까지의 짝수 스트림
rangedClosed를 이용해서 1부터 100까지의 숫자를 만들 수도 있다.
스트림 만들기
값으로 스트림 만들기
임의의 수를 인수로 받는 정적 메서드 Stream.of 를 이용하여 스트림을 만들 수 있다.
Stream<String> stream = Stream.of("Java 8", "Lambdas", "In", "Action");
stream.map(String::toUpperCase).forEach(System.out::println);
null이 될 수 있는 객체로 스트림 만들기
자바 9에서 null이 될 수 있는 개체를 스트림으로 만들 수 있는 새로운 메소드가 추가되었다.
null 이 될 수 있는 객체를 포함하는 스트림값을 flatMap과 함께 사용하는 상황에서는 이 패턴을 더 유용하게 사용할 수 있다.
String homeValue = System.getProperty("home");
Stream<String> values = Stream.of("config", "home", "user")
.flatMap(key -> Stream.ofNullable(System.getProperty(key)));
배열로 스트림 만들기
배열을 인수로 받는 정적 메서드 Arrays.stream을 이용해서 스트림을 만들 수 있다.
int[] numbers = {2, 3, 4, 5, 6}
int sum = Arrays.stream(numbers).sum(); // 합계는 41
파일로 스트림 만들기
파일을 처리하는 등의 I/O 연산에 사용하는 자바 NIO API(비블록 I/O)도 스트림 API를 활용할 수 있도록 업데이트 되었다. java.nio.file.Files의 많은 정적 메서드가 스트림을 반환한다.
함수로 무한 스트림 만들기
스트림 API는 함수에서 스트림을 만들 수 있는 두 개의 정적 메서드 Stream.iterate와 Stream.getnerate를 제공한다. 두 연산을 이용해서 무한 스트림, 즉 고정된 컬렉션에서 고정된 크기로 스트림을 만들었던 것과는 달리 크기가 고정되지 않은 스트림을 만들 수 있다. iterate와 generate에서 만든 스트림은 요청할 때마다 주어진 함수를 이용해서 값을 만든다. 따라서 무제한으로 값을 계산할 수 있어서 보통 무한한 값을 출력하지 않도록 limit(n)함수를 함께 연결해서 사용한다.
iterate 메서드
Stream.iterate(0, n -> n + 2)
.limit(10)
.forEach(System.out::println); // 0, 2, 4, 6, 8, 10, 12, 14, 16, 18, 20
iterate는 요청할 때마다 값을 생성할 수 있으며 끝이 없으므로 무한스트림을 만든다. 이러한 스트림을 언바운드 스트림이라고 표현한다 . 이런 특징이 컬렉션과의 가장 큰 차이점이다.
generate 메서드
iterate와 비슷하게 generate도 요구할 때 값을 계산하는 무한 스트림을 만들 수 있다. 하지만 iterate와 달리 generate는 생산된 각 값을 연속적으로 계산하지 않는다. generate는 Supplier<T>를 인수로 받아서 새로운 값을 생성한다.
Stream.getnerate(Math::random)
.limit(5)
.forEach(System.out::println);
이미지 출처
'📖 > 모던 자바 인 액션' 카테고리의 다른 글
[모던 자바 인 액션] 6장 스트림으로 데이터 수집 (1) | 2024.04.09 |
---|---|
[모던 자바 인 액션] 4장 스트림 소개 (0) | 2024.04.01 |
[모던 자바 인 액션] 3장 람다 표현식 (0) | 2024.03.28 |
[모던 자바 인 액션] 2장 동작 파라미터화 코드 전달하기 (0) | 2024.03.26 |
[모던 자바 인 액션] 1장 자바 8, 9, 10, 11 : 무슨 일이 일어나고 있는가? (1) | 2024.03.25 |