모던자바인액션 - 5

이건희·2023년 7월 14일
1

모던자바인액션

목록 보기
5/9

이번 장에서는 스트림 API가 지원하는 다양한 연산들을 살펴본다. 스트림을 어떻게 활용하는지 중점적으로 보자.

필터링

filter

스트림 인터페이스는 filter 메서드를 지원한다.

  • Predicate(boolean)을 반환하는 함수를 인수로 받음
  • Predicate와 일치하는 모든 요소를 포함하는 스트림 반환(즉, 중간 연산)

    아래 예시는 filter 메서드를 활용한 문장이다.

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

    boolean을 반환하는 isVegetarian에 부합하는 모든 스트림을 반환한다.

distinct

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, 4

스트림 슬라이싱

takeWhile

  • Predicate에 부합하지 않는 요소가 있으면 반복 작업 중단
  • 전체 스트림을 반복하며 각 요소에 Predicate 적용
  • 따라서 정렬이 되어 있는 스트림에 슬라이싱 유용

    칼로리가 320이하인 음식만 선택하는 예시

    List<Dish> slicedMenu1 = specialMenu.stream()
    				 					 .takeWhile(dish -> dish.getCalories() < 320)
    				 					 //Predicate을 각 요소에 적용해 일치 하지 않는 것이 나오면 멈춤
    				 					 .collect(toList());

dropWhile

  • takeWhile과 정반대 작업 수행
  • Predicate가 처음으로 거짓이 되는 지점까지 발견된 요소를 버림
  • 거짓이 되는 지점에서 작업 중단하고 남은 모든 요소 반환
  • dropWhile은 무한 스트림에서도 작동

    위 takeWhile과 정반대의 작업 예시

    List<Dish> slicedMenu2 = specialMenu.stream()
    				 					 .dropWhile(dish -> dish.getCalories() < 320)
    				 					 //Predicate을 각 요소에 적용해 거짓이 될 시, 나머지 요소 반환
    				 					 .collect(toList());

스트림 축소

limit

  • 주어진 값 이하의 크기를 갖는 새로운 스트림 반환
  • 주어진 스트림을 축소하여 새로운 스트림으로 반환
  • 일치하는 처음 n개의 요소를 선택한 다음 즉시 결과 반환
  • 따라서 스트림이 정렬되지 않은 상태라면 limit의 결과도 정렬되지 않은 상태

    300칼로리 이상 3개의 요리를 선택하는 예시

    List<Dish> dishes = specialMenu.stream()
    			   					.filter(dish -> dish.getCalories() > 300)
    			   					.limit(3)
    			   					//스트림의 크기를 3으로 축소, 일치하는 처음 3개의 요소 선택
    			   					.collect(toList());

요소 건너뛰기

skip

  • 처음 n개 요소 제외 스트림 반환
    - n개 이하 요소 포함하는 스트림에 skip(n) 적용 시 빈 요소 반환

    300칼로리 이상 처음 두 요리를 건너뛰고 300칼로리가 넘는 나머지 요리 반환하는 예시

    List<Dish> dishes = menu.stream()
    						 .filter(d -> d.getCalories() > 300)
    						 .skip(2) //처음 두개 건너뛰기
    					 	 .collect(toList());

각 요소에 함수 적용하기

map

  • map은 함수를 인수로 받는다
  • 각 요소에 적용되어 함수를 적용한 결과가 새로운 요소로 매핑된다.
  • 인자의 시그니처는 T -> R (Function)

    요리명을 추출하는 예시

    List<String> dishNames = menu.stream()
    							  .map(Dish::getName)
    							  //함수를 인자로 받아 함수를 적용한 결과가 새로운 요소로 반환
    						  	  .collect(toList());

flatMap

  • 하나의 평면화된 스트림 반환
  • 스트림의 각 값을 다른 스트림으로 만든 다음, 모든 스트림을 하나의 스트림으로 연결하는 기능

이해가 잘 되지 않으니 예시를 보자.

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

만약 map을 사용할 시,

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

위 코드의 문제점이 무엇일까?

  • map으로 전달된 람다는 각 단어의 String[](문자열 배열)을 반환한다.
  • map 메소드가 반환한 스트림의 형식은 Stream<String[]>이다.
  • 우리가 원하는 것은 Stream<String>이다.
  • 즉, "Hello", "World" 각각 Stream[]이 반환된다.

따라서 flatMap을 사용하여 문제를 해결해보자.

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

요소 매칭

특정 속성이 데이터 집합에 있는지 여부를 검색하는 데이터 처리도 자주 사용된다.

anyMatch

  • Predciate가 주어진 스트림에서 적어도 한 요소와 일치하는지 확인
  • anyMatch는 boolean을 반환하므로 최종 연산이다.

    채식 요리가 있는지 확인하는 예시

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

allMatch

  • anyMatch와 달리 모든 요소가 주어진 Predicate와 일치하는지 검사
  • 이 또한 boolean 반환하므로 최종 연산

noneMatch

  • allMatch와 반대 연산 수행
  • 주어진 Predicate와 일치하는 요소가 없는지 확인

쇼트서킷 기법

위 세개의 메서드는 쇼트서킷 기법이다.

  • 하나라도 조건에 부합하지 않으면 조기 종료
  • 모든 스트림의 요소를 처리하지 않고도 결과 반환 가능

요소 검색

findAny

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

  • 쇼트서킷을 이용해 결과를 찾는 즉시 실행 종료

    채식 요리 선택 예시

    Optional<Dish> dish = menu.stream()
    						   .filter(Dish::isVegetarian)
    						   .findAny(); //최종연산 findAny()

위 예시에서 사용된 Optional은 무엇일까?

  • 값의 존재나 부재 여부를 표현하는 컨테이너 클래스
  • findAny는 아무 요소도 반환하지 않을 수도 있다.(null)

  • null은 쉽게 에러를 일으키므로 Optional은 값이 존재하는지 확인하고 값이 없을때 어떻게 처리할지 강제하는 기능을 제공한다.

    Optional 클래스는 뒤에서 자세하게 다룬다고 한다

findFirst

  • 스트림에 논리적인 아이템 순서가 있을때 사용
  • 스트림에서 첫번째 요소 반환

리듀싱

지금까지 살펴본 최종 연산은 boolean, void, Optional 등을 반환했다. 또한 collect로 모든 스트림 요소를 리스트로 모으는 방법도 살펴 보았다.

하지만 '메뉴의 모든 칼로리의 합계를 구하시오' 같이 스트림 요소를 조합해서 더 복잡한 질의를 표현하는 것은 어떻게 할까?

이러한 질의를 수행하려면 Integer 같은 결과가 나올때까지 스트림의 요소를 반복적으로 처리해야한다. 이런 질의를 리듀싱 연산이라고한다.

초기값이 있는 reduce

리듀싱을 살펴보기 전에 for-each 루프를 이용해 숫자 요소를 더하는 코드를 보자

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

위 코드를 리듀싱 연산으로 간단히 할 수 있다.

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

초기값 0부터 값이 누적되어 더해진다.

reduce는 두개의 파라미터를 가진다.

  • 초기값 0
  • 두 요소를 조합해서 새로운 값을 만드는 BinaryOperator( T, T -> T )

이렇듯 스트림이 하나의 값으로 줄어들 때까지 람다는 각 요소를 반복해서 조합한다.

초기값이 없는 reduce

  • 초기값이 없도록 오버로드된 reduce도 있다
  • 초기값이 없고, 스트림에 아무 요소도 없을 시 최종 값을 반환하지 못할수도 있다
  • 따라서 Optional 객체를 반환한다.
    Optional<Integer> sum = numbers.stream().reduce((a, b) -> (a + b));

reduce는 이와 같이 어떤 람다를 넘겨주는지에 따라 최대값, 최소값등 다양하게 값을 구할 수 있다.

중요한 것은 reduce 연산은 새로운 값을 이용해 스트림의 모든 요소를 소비할 때까지 람다를 반복 수행한다는 것이다.


상태 없음과 상태 있음

map, filter 등은 입력 스트림에서 각 요소를 받아 0 또는 결과를 출력 스트림으로 보낸다. 따라서 이들은 내부 결과를 저장할 필요가 없으므로 내부 상태를 가지지 않는 연산이다.

하지만 reduce, sum, max 같은 연산은 결과를 누적할 내부 상태가 필요하다. 스트림에서 처리하는 요소 수와 관계 없이 내부 상태의 크기는 한정적이다.

sorted나 distinct도 요소를 정렬하거나 중복을 제거하려면 과거의 이력을 알고 있어야 한다. 예를 들어 어떤 요소를 출력 스트림으로 추가하려면 모든 요소가 버퍼에 추가되어 있어야 한다. 따라서 이들은 내부 상태를 갖는 연산이다.


숫자형 스트림

앞에서 reduce 메서드로 요소의 합을 구하는 예제를 보았다. 예를 들어 다음처럼 메뉴의 칼로리 합계를 구할 수 있다.

int calories = menu.stream()
				   .map(Dish::getCalories)
			   	   .reduce(0, Integer::sum);
				   //reduce 이용, 초기값 0 부터 합계 구하기(메서드 참조)

사실 위 코드에는 박싱 비용이 숨겨져 있다.
내부적으로 합계를 계산하기 전 Integer를 기본형으로 언박싱 해야한다.

다음처럼 sum 메서드를 직접 호출할 수 있다면 좋겠지만 map의 반환값이 Stream이기 때문에 그럴 수 없다.

int calories = menu.stream()
				   .map(Dish::getCalories)
				   .sum();
				   //map 반환이 stream이라 직접 호출 불가능

따라서 스트림 API는 숫자 스트림을 효율적으로 처리할 수 있도록 기본형 특화 스트림을 제공한다.

기본형 특화 스트림

자바 8에서는 박싱 비용을 피할 수 있도록 3가지 기본형 특화 스트림을 제공한다.

  • IntStream - int 특화
  • DoubleStream - double 특화
  • LongStream - long 특화

각각의 인터페이스는 다음과 같은 메서드도 지원한다.

  • sum - 숫자 스트림의 합계 계산
  • max - 최대값 계산
  • min - 최소값 계산
  • average 등등..

주의할점은 특화 스트림은 오직 박싱 과정에서 일어나는 효율성과 관련이 있고 따로 추가 기능을 제공하진 않는다는 것이다.

숫자 스트림으로 매핑

스트림을 특화 스트림으로 변환할 때 사용하는 함수들은 다음과 같다.

  • mapToInt
  • mapToDouble
  • mapToLong

이들은 map과 정확히 같은 기능을 수행하지만, Stream 대신 특화된 스트림을 반환한다.

int calories = menu.stream() //Stream<Dish> 반환
				   .mapToInt(Dish::getCalories)
			   	   //IntStream 반환
			   	   .sum(); //IntStream이라 sum 사용 가능

객체 스트림으로 복원

숫자 스트림을 만든 다음, 원상태인 특화되지 않은 스트림으로 복원할 수 있다.

예를 들어, IntStream은 기본형의 정수값만 만들 수 있다. 따라서 int를 인수로 받아 int를 반환하는 람다를 인수로 받는다.

하지만 int가 아닌 Dish 같은 다른 값을 반환하고 싶을때 boxed 메소드를 사용한다.

  IntStream intStream = menu.stream().mapToInt(Dish::getCalories); //숫자 스트림 변환
  Stream<Integer> stream = intStream.boxed(); //숫자 스트림을 스트림으로 변환

기본값 : OptionalInt

합계에서는 0이라는 기본값이 있었으므로 별 문제가 없었다. 하지만 최대값을 찾을때는 0이라는 기본값 때문에 잘못된 결과가 도출될 수 있다.

이런 상황에서 OptionalInt를 사용해 최대값이 없는 상황에서 사용할 기본값을 명시적으로 정의할 수 있다.(OptionalDouble, OptionalLong 도 지원)

OptionalInt maxCalories = menu.stream()
							  .mapToInt(Dish::getCalories)
							  .max();
int max = maxCalories.orElse(1); //orElse 메소드 이용해 기본값 지정

숫자 범위

특정 범위의 숫자를 이용해야 하는 상황에서 IntStream, LongStream에서는 range와 rangeClosed라는 두가지 정적 메서드를 제공한다.

두 함수 모두 첫번째 인수로 시작값, 두번째 인수로 종료값을 갖는다.

rangeClosed는 시작값, 종료값이 결과에 포함되고
range는 시작값, 종료값이 결과에 표함되지 않는다.

rangeClosed

IntStream evenNumbers = IntStream.rangeClosed(1, 100) //[1,100]
								 .filter(n -> n % 2 == 0);
System.out.println(evenNumbers.count()); // 50개의 짝수

range

IntStream evenNumbers = IntStream.range(1, 100) //(1,100)
								 .filter(n -> n % 2 == 0);
System.out.println(evenNumbers.count()); // 49개의 짝수

스트림 만들기

값으로 스트림 만들기

임의의 수를 인수로 받는 정적 메서드 Stream.of를 이용해 스트림을 만들 수 있다.

Stream<String> stream = Stream.of("Morder", "java", "In", "action");
//Stream.of를 이용해 스트림 생성
stream.map(String::toUpperCase)
	  .forEach(System.out::println);

또한 다음과 같이 스트림을 비울 수 있다.

Stream<String> emptyStream = Stream.empty();

때로는 null이 될 수 있는 객체를 스트림(객체가 null이라면 빈 스트림)으로 만들어야 할 수도 있다.

이럴때는 Stream.ofNullable을 이용한다.

String homeValue = System.getProperty("home");
//getProperty는 키에 대응하는 속성이 없으면 null 반환
Stream<String> homeValueStream
	= homeValue == null ? Stream.empty() : Stream.of(value);

//위 코드를 아래와 같이 구현 가능하다.
Stream<String> homeValueStream
		= Stream.ofNullable(System.getProperty("home"));
//값이 없다면 빈스트림으로 만듦

배열로 스트림 만들기

배열을 인수로 받는 정적 메서드 Arrays.stream을 이용해 스트림을 만들 수 있다.

예시로 다음처럼 기본형 int로 이루어진 배열을 IntStream으로 변환할 수 있다.

int[] numbers = {2, 3, 5, 7, 11, 13};
int sum = Arrays.stream(numbers).sum();
//IntStream 반환

무한 스트림 만들기

  • 스트림 API에서는 함수에서 스트림을 만들 수 있는 두 정적 메서드 Stream.iterate 와 Stream.generate를 제공한다.
  • 두 연산을 이용해 무한 스트림(크기가 고정되지 않은 스트림)을 만들 수 있다.
  • iterate와 generate에서 만든 스트림은 요청할 때마다 주어진 함수를 이용해서 값을 만든다.

iterate 메서드

먼저 iterate를 사용하는 방법을 살펴보자

Stream.iterate(0, n -> n + 2)
	  .limit(10)
	  .forEach(System.out::println);
  • 초기값 0
  • 람다(예제에서는 UnaryOperate 사용)
  • 기존 결과에 의존해서 순차적으로 연산 수행
  • 요청할 때마다 값을 생산

또한 Java 9 iterate 메서드는 Predicate를 지원한다. Predicate을 이용하여 언제까지 작업을 수행할 것인지 지정할 수 있다.

IntStream.iterate(0, n -> n < 100, n -> n + 4).forEach(System.out::println);
//Predicate 이용해서 언제까지 작업할 것인지 지정

generate 메서드

  • iterate와 비슷하게 요구할 때 값을 계산하는 무한 스트림 생성 가능
  • iterate와 달리 생산된 각 값을 연속적으로 계산하지 않음
  • Supplier를 인수로 받아 새로운 값 생산
  Stream.generate(Math::random)
  	    .limit(5)
  		.forEach(System.out::println);

generate는 상태가 없는 메서드, 즉 나중에 계산에 사용할 어떤 값도 저장해두지 않는다.

물론 상태를 저장하게 할 수 있지만 병렬성 문제와 더불어 권장되지 않는다.

주의점

  • 무한한 크기를 가진 스트림을 처리하므로 limit을 이용해 명시적으로 스트림의 크기를 제한해야 한다.

  • 제한하지 않을 시, 최종 연산을 수행하였을 때 아무 결과도 계산되지 않는다.

  • 무한 스트림의 요소는 계산이 반복되므로 정렬하거나 reduce 할 수 없다.

profile
광운대학교 정보융합학부 학생입니다.

0개의 댓글