Java8 - Stream 활용 (2): 리듀싱, 숫자형 스트림, 스트림 생성


이 포스트에서는 스트림 API 가 지원하는 다양한 연산에 대해 알아본다.
스트림 API 가 지원하는 연산을 이용하여 리듀싱으로 데이터 처리 질의를 표현해본다.
마지막으로 숫자 스트림, 파일과 배열 등 다양한 소스로 스트림을 만들어보고, 무한 스트림 등 스트림의 특수한 경우에 대해 알아본다.

소스는 github 에 있습니다.


목차


1. 리듀싱: reduce()

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

map() 과의 차이
**Stream 의 map() 은 Stream 를 반환** ` Stream map(Function<? super T, ? extends R> mapper);` **Stream 의 reduce() 는 제네릭 객체 T 반환** `T reduce(T identity, BinaryOperator accumulator);`


1.1. 요소의 합

아래는 모든 요소를 더하는 예시이다.

List<Integer> numbers = Arrays.asList(3,4,5,1,2);

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

// 15
System.out.println(sum);

위의 reduce() 는 2 개의 인수를 갖는다.

  • 초기값 0
  • 두 요소를 조합하여 새로운 값을 만드는 BinaryOperator<T>
    위에선 람다 표현식 (a,b) -> a+b 사용

BinaryOperator<T> 의 함수 디스크립터는 (T,T) -> T 이다.
BinaryOperator<T> 함수형 인터페이스의 좀 더 자세한 내용은 Java8 - 람다 표현식 (1): 함수형 인터페이스, 형식 검사2.4. 기본형(primitive type) 특화 을 참고하세요.

메서드 레퍼런스를 이용하여 좀 더 간결히 표현 가능하다.

// Java8 에서는 Integer 클래스에 static sum 메서드 제공
int sum2 = numbers.stream().reduce(0, Integer::sum);

// 15
System.out.println(sum2);

초기값을 받지 않도록 오버로드된 reduce() 도 있는데 스트림에 아무 요소도 없는 경우 초기값이 없으면 아무것도 반환할 것이 없으므로 이 reduce() 는 Optional 객체를 반환한다.

reduce 를 이용하면 내부 반복이 추상화되면서 내부 구현에서 병렬로 reduce 를 실행한다. (반복적인 합계에서는 sum 변수를 공유해야 하기 때문에 병렬화가 어려움)

사실 이 작업을 병렬화하려면 입력을 분할하고, 분할된 입력을 더한 후 더한 값을 합쳐야 한다.
포크/조인 프레임워크 를 이용하는 방법이 있는데 이 부분은 Java8 - Stream 으로 병렬 데이터 처리 (1): 병렬 스트림, 포크/조인 프레임워크 를 참고하세요.


1.2. 최대값과 최소값

reduce() 연산은 새로운 값을 이용해서 스트림의 모든 요소를 다 소비할 때까지 반복 수행하면서 결과값을 반환한다.

Optional<Integer> max = numbers.stream().reduce(Integer::max);
int max2 = numbers.stream().reduce(0, (a,b) -> Integer.max(a,b));

// Optional[5]
System.out.println(max);
// 5
System.out.println(max2);

Optional<Integer> min = numbers.stream().reduce(Integer::min);

// Optional[1]
System.out.println(min);

Integer::min 대신 (a,b) -> a<b ? a : b 람다 표현식을 사용해도 되지만 메서드 레퍼런스로 표현한 것이 더 가독성이 좋다.

최소값을 구할 때 reduce(0, Integer::min) 으로 하게 되면 항상 초기값인 0 을 리턴한다.


Quiz

map() 과 reduce() 로 요리 개수 계산

int dishNumbers = Dish.menu.stream()  // Stream<Dish> 반환
        .map(d -> 1)  // Stream<Integer> 반환
        .reduce(0, (a,b) -> a+b);

스트림 각 요소를 1로 매핑한 후 reduce 로 이들의 합계를 구하는 방식으로 map 과 reduce 를 연결하는 기법은 맵 리듀스 패턴이라고 한다.
쉽게 병렬화하는 특징 덕분에 구글이 웹 검색에 적용하면서 유명해졌다.

위 코드는 아래처럼 구현할 수도 있다.

long dishNumbers2 = Dish.menu.stream().count();

이 외의 퀴즈는 Java8 - Stream 활용 (2): Quiz (1) 를 보세요.


2. 숫자형 스트림

아래는 reduce() 로 스트림 요소의 합을 구하는 예시이다.

// Java8 에서는 Integer 클래스에 static sum 메서드 제공
int sum = Dish.menu.stream()  // Stream<Dish> 반환
        .map(Dish::getCalories) // Stream<Integer> 반환
        .reduce(0, Integer::sum);

위 코드에는 내부적으로 합계를 계산하기 전에 참조형 Integer 를 기본형 int 로 변경하는 언박싱 작업이 숨어있다.
스트림 요소 형식은 Integer 이지만 map() 은 Stream 를 반환하기 때문에 sum 메서드를 사용할 수 없다.

이런 경우 스트림 API 는 숫자 스트림을 효율적으로 처리할 수 있도록 기본형(primitive) 특화 스트림을 제공한다.

박싱, 언박싱에 대해서는 Java8 - 람다 표현식 (1): 함수형 인터페이스, 형식 검사2.4. 기본형(primitive type) 특화 를 참고해주세요.


2.1. 기본형(primitive) 특화 스트림: IntStream, DoubleStream, LongStream

IntStream, DoubleStream, LongStream 각각의 인터페이스는 sum(), max(), min(), average() 등 자주 사용하는 숫자 관련 리듀싱 연산 메서드를 제공한다.
필요할 때 다시 객체 스트림으로 복원하는 boxed() 메서드도 제공한다.

기본형 특화 스트림은 오직 박싱 과정에서 일어나는 효율성과 관련있으며 스트림에 추가 기능을 제공하지 않는다.


2.2. 숫자 스트림으로 매핑: mapToInt(), mapToDouble(), mapToLong()

숫자 스트림을 기본형 특화 스트림으로 변환 시 mapToInt(), mapToDouble(), mapToLong() 를 많이 사용한다.
map() 과 정확히 같은 기능을 수행하지만 Stream 대신 기본형으로 특화된 스트림인 IntStream, LongStream, DoubleStream 을 반환한다.

int sum2 = Dish.menu.stream() // Stream<Dish> 반환
        .mapToInt(Dish::getCalories)  // IntStream 반환
        .sum(); // int 반환
System.out.println(sum2);

위에서 mapToInt() 는 모든 칼로리(Integer, 참조형) 을 추출한 후 IntStream 을 반환하기 때문에 IntStream 에서 제공하는 sum() 메서드 사용이 가능하다.


2.3. 객체 스트림으로 복원: boxed()

숫자 스트림을 만든 후 다시 원상태인 특화되지 않은 스트림으로 복원 시 boxed() 메서드를 사용한다.

IntStream 의 map int 를 인수로 받아서 int 를 반환하는 람다인 IntUnaryOperator (T -> T) 를 인수로 받는데 만일 int 가 아닌 원 객체인 Dish 같은 값을 반환하고 싶을 때 사용한다.

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

2.4. 기본값: OptionalInt, OptionalDouble, OptionalLong

IntStream 에서 최대값/최소값을 찾을 때 기본값이 0인 상황이라면 스트림에 요소가 없는 상황과 최대값이 0인 상황을 구분할 수 없다.
따라서 Optional 을 통해 값이 존재하는지 여부를 확인할 수 있다.
Optional 또한 OptionalInt, OptionalDouble, OptionalLong 3 가지 기본형 특화 스트림 버전을 제공한다.

OptionalInt maxCalorie = Dish.menu.stream() // Stream<Dish> 반환
        .mapToInt(Dish::getCalories)  // IntStream 반환
        .max(); // OptionalInt 반환

// 최대값이 없으면 1 리턴
int max = maxCalorie.orElse(1);

2.5. 숫자 범위: range(), rangeClosed()

IntStream 과 LongStream 은 range(), rangeClosed() 2 개의 static 메서드를 제공한다.

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

// 1~100 까지의 짝수 스트림
IntStream evenNumbers = IntStream.rangeClosed(1, 100).filter(n -> n%2 == 0);
long evenCount = evenNumbers.count();

// 50
System.out.println(evenCount);

3. 스트림 생성


3.1. 값으로 스트림 생성: Stream.of()

static 메서드인 Stream.of() 는 임의의 수를 인수로 받아 스트림을 생성한다.

// 값으로 스트림 생성
Stream<String> streams = Stream.of("hello", "world");

// 문자열 스트림의 모든 문자열을 대문자 변환 후 하나씩 출력
//HELLO
//WORLD
streams.map(String::toUpperCase).forEach(System.out::println);

// 스트림 비우기
Stream<String> emptyStream = Stream.empty();

3.2. 배열로 스트림 생성: Arrays.stream()

static 메서드인 Arrays.stream() 은 배열을 인수로 받아 스트림을 생성한다.

int[] numbers = {1,2,3};
int sum = Arrays.stream(numbers)  // IntStream 반환
        .sum();
// 6
System.out.println(sum);

3.3. 파일로 스트림 생성: Files.lines()

java.nio.file.Files 의 많은 static 메서드가 스트림을 반환한다.
Files.lines() 는 행 스트림을 문자열로 반환한다.

아래는 파일에서 고유한 단어를 출력하고 수를 찾는 예시이다.

long uniqueWordsCount = 0;
List<String> uniqueWords;

String path = System.getProperty("user.dir") + "/src/main/java/com/assu/study/mejava8/chap05/data.txt";

try (Stream<String> lines = Files.lines(Paths.get(path), Charset.defaultCharset())) {
//      uniqueWordsCount = lines.flatMap(line -> Arrays.stream(line.split(" ")))
//              .distinct()
//              .count();

  uniqueWords = lines.flatMap(line -> Arrays.stream(line.split(" "))) // 각 행의 단어를 여러 스트림으로 만드는 것이 아니라 flatMap() 으로 스트림을 하나로 평면화
          .distinct()
          .collect(Collectors.toList());
} catch (IOException e) {
  throw new RuntimeException(e);
}

// [안녕하세요., 안녕~, 중복을, 제거할꺼에요., 중복]
System.out.println(uniqueWords);
//System.out.println(uniqueWordsCount);

try-with-resources 구문은 직접 찾아보세요.


3.4. 함수로 무한 스트림 생성

스트림 API 는 함수에서 스트림을 무한으로 만들 수 있는 2 개의 static 메서드인 Stream.iterate() 와 Stream generate() 를 제공한다. (언바운드 스트림)
두 메서드 모두 요청할 때마다 주어진 함수를 이용해서 값을 만들며, 보통 무한한 값을 출력하지 않도록 limit() 과 함께 사용한다.

3.4.1. Stream.iterate()

연속된 일련의 값을 만들 때 보통 Stream.iterate() 를 사용한다. (날짜 생성 등…)

// 0 부터 짝수 10개 출력
List<Integer> evenNumbers = Stream.iterate(0, n -> n+2)
        .limit(10)
        .collect(Collectors.toList());

// [0, 2, 4, 6, 8, 10, 12, 14, 16, 18]
System.out.println(evenNumbers);

위의 Stream.iterate() 는 초기값 0 과 람다 UnaryOperator<T> (T -> T) 를 인수로 받아 새로운 값을 생산한다.

관련된 퀴즈는 Java8 - Stream 활용 (2): Quiz (2) 를 보세요.


3.4.2. Stream.generate()

Stream.generate() 도 Stream.iterate() 처럼 무한 스트림을 만들지만 iterate() 와 달리 generate() 는 생산된 각 값을 연속적으로 계산하지 않는다.
Stream.generate() 는 Supplier<T> (() -> T) 를 인수로 받아 새로운 값을 생산한다.

// 0과 1 사이의 임의의 double 숫자 5개 생성
Stream.generate(Math::random)
        .limit(5)
        .forEach(System.out::println);
// 0.2783737421773751
//0.14317629653439412
//0.9158527117939272
//0.009494136863103964
//0.5834030590726629

Math.random 은 임의의 새로운 값을 생성하는 static 메서드이다.

위에서 사용한 Supplier (메서드 레퍼런스 Math::random) 은 나중에 계산에 사용할 어떤 값도 저장하지 않는 상태가 없는 메서드이다.
만일 generate() 를 사용하여 피보나치 수열을 구하려면 이전 상태를 저장하고 갱신해야 하는데, 병렬 코드에서는 공급자에 상태가 있으면 안전하지 않다.

공급자가 상태를 갖게 되면 생기는 부작용은 Java8 - Stream 으로 병렬 데이터 처리 (1): 병렬 스트림, 포크/조인 프레임워크1.3. 병렬 스트림의 올바른 사용 를 참고하세요.

IntStream 의 generate() 메서드는 Supplier<T> 대신 IntSupplier 를 인수로 받는다.

// 1 을 출력하는 무한 스트림 생성
IntStream ones = IntStream.generate(() -> 1).limit(10);

// 1 이 10번 출력됨
ones.forEach(System.out::println);

위처럼 람다로 생성하는 것이 아니라 IntSupplier 에 정의된 getAsInt() 를 구현하는 객체를 익명 클래스로 만들어 명시적으로 전달할 수도 있다.

// 2 를 출력하는 무한 스트림 생성
IntStream twos = IntStream.generate(new IntSupplier() {
  @Override
  public int getAsInt() {
    return 2;
  }
}).limit(10);

// 2 가 10번 출력됨
twos.forEach(System.out::println);

람다와 익명 클래스는 비슷한 연산을 수행하지만 익명 클래스에서는 getAsInt() 메서드의 연산을 커스터마이징할 수 있는 상태 필드를 정의할 수 있다는 점이 다르다. (= 부작용이 생길 수 있는 예시)
람다는 상태를 변경하지 않으므로 부작용이 없다.

Java8 - Stream 활용 (2): Quiz (2)피보나치 수열 (Stream.generate()) 을 보면 getAsInt() 호출 시 객체 상태가 바뀌며 새로운 값을 생산한다.

iterate() 를 사용했을 때는 각 과정에서 새로운 값을 생성하면서도 기존 상태를 바꾸지 않는 순수한 불변 (immutable) 상태를 유지했다.

스트림을 병렬로 처리하면서 올바른 결과를 얻으려면 불변 상태 기법을 유지해야 한다.


참고 사이트 & 함께 보면 좋은 사이트

본 포스트는 라울-게이브리얼 우르마, 마리오 푸스코, 앨런 마이크로프트 저자의 Java 8 in Action을 기반으로 스터디하며 정리한 내용들입니다.






© 2020.08. by assu10

Powered by assu10