본문 바로가기
Book/모던 자바 인 액션

[정리] 모던 자바 인 액션 - 스트림 활용(2)

by 따라쟁이개발자 2023. 1. 8.

만약 스트림을 활용해 아래와 같은 질의를 수행하려면 어떻게 해야할까?

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

'메뉴의 모든 칼로리의 합계는?'

 

리듀싱

리듀싱 연산은 모든 스트림 요소를 처리해서 하나의 값으로 도출하는 연산을 말한다.

위와 같은 질의는 리듀싱 연산을 사용하여 해결할 수 있다.

 

리듀싱 내 두 번째 인자로 계산된 결과값은 다음 스트림 요소의 입력 인자가 된다.

리듀싱 연산은 스트림이 하나의 값으로 줄어들 때까지 연산을 반복해서 조합한다.

// 기본적인 반복문 
int sum = 0;    // sum 변수의 초기값
for (int x : numbers) {
    sum += x;   // sum 값을 도출하기 위해 반복되는 연산 (+)
}

// 리듀스를 사용
// 첫 번째 인자 : sum 초기값 0
// 두 번째 인자 : 두 요소를 조합해 새로운 값을 만드는 BinaryOperator<T>
int reduceSum = numbers.stream()
        .reduce(0, (a, b) -> a + b);

// 초기값이 없을 경우, Optional을 리턴
Optional<Integer> reduceSumOptional = numbers.stream()
        .reduce((a, b) -> a + b);

// 최댓값, 최솟값에도 활용 가능
Optional<Integer> max = numbers.stream().reduce(Integer::max);
Optional<Integer> min = numbers.stream().reduce(Integer::min);

추가로, map과 reduce를 함께 사용하는 기법을 map-reduce 패턴이라고 하는데

이 기법의 경우 쉽게 병렬화하는 특징 덕분에 구글이 웹 검색에 적용하게 되면서 유명해졌다.

// map 과 reduce 메서드를 이용해 스트림의 요리 개수 구하기
int dishCount = specialMenu.stream().map(dish -> 1).reduce(0, (a, b) -> a + b);

reduce는 내부 반복을 추상화했다는 점에서 기존 반복문과 차이를 가진다.

기존 반복문의 경우 sum 변수를 요소들이 공유해야 했기 때문에 쉽게 병렬화를 할 수 없었다. 

반면, reduce는 반복을 내부에서 처리하기 때문에 직접 병렬화를 구현하지 않아도 되며, parallelStream() 로 쉽게 해결 가능하다.

 

스트림 연산 :  상태 없음과 상태 있음

스트림 중 map, filter와 같은 경우는 각 요소별 결과를 출력 스트림으로 보낸다. 

즉, 내부적으로 가변 값을 가질 필요가 없어 이러한 메서드들은 '내부 상태를 갖지 않는 연산(stateless operation)'으로 불린다.

 

반면, reduce의 경우 최종 결과를 도출하기 위해 내부적으로 값을 누적시킬 가변 상태가 필요하다.

sorted, distinct 도 마찬가지로 결과를 내기 위해 과거 이력을 알고 있어야 하기 때문에 내부 상태가 필요하다.

즉, 모든 요소가 버퍼에 추가되어 있어야 한다.

이런 메서드들은 '내부 상태를 갖는 연산(stateful operation)'이라고 부르며 내부 상태의 크기에 제한을 가지고 있는(한정, bound) 메서드들이라고 볼 수 있다. 즉, 스트림 크기가 크거나 무한이라면 문제가 생길 수 있는 연산들이라고 이해할 수 있다. 

 

숫자형 스트림

함수형 인터페이스를 다룰 때 추상메서드들이 제네릭 형태로 되어 있어 참조형이 아닌 기본형으로 결과값을 출력하려 할 경우

오토박싱/언박싱으로 소모되는 비용이 발생하여 자바 8에서 기본형 특화 함수형 인터페이스를 제공한다고 언급한 적이 있다.

 

스트림도 Stream<T> 형태를 기반으로 하고 있어 위와 유사한 문제가 발생하며,

이를 해결하기 위해 자바 8은 일반 스트림 외에 IntStream, DoubleStream, LongStream 과 같은 기본형 특화 스트림을 제공한다. 

또한, 필요 시 기본형을 다시 객체 스트림으로 복원하는 기능도 제공하고 있다. 

// 스트림 -> 숫자 스트림으로 매핑
int calories = menu.stream()
        .mapToInt(Dish::getCalories) // Stream<Integer> 가 아닌 IntStream 반환
        .sum();

// 숫자 스트림 -> 객체 스트림으로 복원
IntStream intStream = menu.stream().mapToInt(Dish::getCalories);
Stream<Integer> stream = intStream.boxed();

// 숫자 스트림의 기본값 처리 (Optional 대체)
OptionalInt maxCalroies = menu.stream()
        .mapToInt(Dish::getCalories)
        .max();

프로그램에서는 sum, max, min 외에 숫자의 범위를 제한해야 하는 경우도 많은데

IntStream, LongStream에서는 range, rangeClosed 라는 범위지정 메소드도 제공한다. 

// range는 범위 경계값 불포함, rangeClosed는 경계값 포함
// 아래는 1-100까지의 짝수 스트림으로 50개의 짝수를 가짐
IntStream evenNumbers = IntStream.rangeClosed(1, 100)
        .filter(n -> n % 2 == 0);

 

스트림 만들기

컬렉션에서 스트림을 얻는 방법 외에 스트림을 직접 만드는 방법도 존재한다.

// 값으로 스트림 만들기
Stream<String> stream = Stream.of("Modern", "Java", "In", "Action"); 

// 빈 스트림 만들기
Stream<String> emptyStream = Stream.empty();

// null 포함 객체 스트림 만들기 (null일 경우, 빈 스트림을 생성)
Stream<String> nullableStream = Stream.ofNullable(System.getProperty("test"));

// 배열로 스트림 만들기
int[] numbers = {2, 3, 4, 7, 11, 13};
int sum = Arrays.stream(numbers).sum();

// 파일로 스트림 만들기 
// 아래는 파일에서 고유 단어 수를 찾는 예제
long uniqueWords = 0;
try (Stream<String> lines =
             Files.lines(Paths.get("data.txt"), Charset.defaultCharset())) {
    uniqueWords = lines.flatMap(line -> Arrays.stream(line.split(" ")))
            .distinct()
            .count();
} catch (IOException e) {
    
}

앞서 스트림은 컬렉션과 다르게 무한한 요수 갯수도 처리가 가능하다고 배웠다.

스트림 API의 Stream.iterate와 Stream.generate를 사용하면 무한 스트림도 만들 수 있다. (무한 스트림 = 언바운드 스트림)

iterate와 generate에서 만든 스트림은 요청할 때마다 주어진 함수를 이용해 값을 만든다. 즉, 무한으로 값을 연산할 수 있다. 

하지만, 보통은 무한한 값을 출력하지 않도록 limit 함수를 함께 연결해 사용한다.

// iterate의 첫 번째 인자는 초기값, 두 번째 인자는 UnaryOperator를 받아 무한한 값 생성
// iterate은 생성된 값을 받아 연속적으로 처리한다 (2 -> 4 -> 6)
Stream.iterate(0, n -> n + 2)
        .limit(10)
        .forEach(System.out::println);

// generate은 Supplier를 인수로 받아 무한으로 새로운 값을 생성해낸다.
Stream.generate(Math::random)
        .limit(10)
        .forEach(System.out::println);

// IntStream은 기본형에 맞춰 IntSupplier를 인수로 받는다.
IntStream ones = IntStream.generate(() -> 1);   // generate의 인자로 IntSupplier 사용됨.

 

마치며

  • 스트림 API를 이용하면 복잡한 데이터 처리 질의를 표현할 수 있다.
  • filter, distinct, takeWhile(자바9), dropWhile(자바9), skip, limit 을 사용하면 스트림을 필터링하거나 자를 수 있다.
  • map, flatMap 을 사용하면 스트림 요소를 추출하거나 변환할 수 있다.
  • findFirst, findAny, allMatch, noneMatch, anyMatch 로 스트림 요소를 검색하거나 조건과 일치하는 요소를 찾을 수 있다.
  • 위 메소드들은 쇼트서킷 기법 사용해 결과를 찾는 즉시 반환하며, 전체 요소를 처리하지 않아도 된다.
  • reduce 메소드로 스트림의 모든 요소를 반복 조합하며 값을 도출할 수 있다. 
  • 스트림은 상태를 저장해두어야 하는 stateful operation과 상태를 저장해두지 않아도 되는 stateless oepration가 있다.
  • IntStream, DoubleStream, LongStream 은 기본형 특화 스트림이다.
  • iterate, generate 로 무한 스트림을 만들 수 있다.

댓글