[Java] Stream 활용 (2)

🏃‍♀️·2023년 9월 4일

Java [이론]

목록 보기
13/14

숫자형 스트림

메뉴의 칼로리 합계를 구하려면 어떻게 해야할까?

int calories = menu.stream()
                   .map(Dish::getCalories)
                   .reduce(0, Integer::sum);

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

int calories = menu.stream()
				   .map(Dish::getCalories)
                   .sum();	// error

위 코드처럼 sum 메소드를 호출하면 좋지 않을까? 그러나 map 메소드가 Stream<T>를 생성하기 때문에 불가능하다. 스트림의 요소 형식은 Integer이지만 인터페이스에는 sum 메소드가 없다.

왜 sum 메소드가 없을까?

예를 들어 menu처럼 Stream<Dish> 형식의 요소만 있다면 sum이라는 연산을 수행할 수 없기 때문이다.

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

1. 기본형 특화 스트림

자바 8에서는 세 가지 기본형 특화 스트림을 제공한다. 스트림 API는 박싱 비용을 피할 수 있도록 'int 요소에 특화된 IntStream', 'double 요소에 특화된 DoubleStream', 'long 요소에 특화된 LongStream'을 제공한다.

각각의 인터페이스는 숫자 스트림의 합계를 계산하는 sum, 최댓값 요소를 검색하는 max와 같이 자주 사용하는 숫자 관련 리듀싱 연산 수행 메소드를 제공한다. 또한 필요할 때 다시 객체 스트림으로 복원하는 기능도 제공한다. 특화 스트림은 오직 박싱 과정에서 일어나는 효율성과 관련 있으며 스트림에 추가 기능을 제공하지 않는다는 사실을 기억하자.

  • 숫자 스트림으로 매핑

스트림을 특화 스트림으로 변환할 때는 mapToInt, mapToDouble, mapToLong 세 가지 메소드를 가장 많이 사용한다.
이들 메소드는 map과 정확히 같은 기능을 수행하지만, Stream<T> 대신 특화된 스트림을 반환한다.

int calories = menu.stream()	
                   .mapToInt(Dish::getCalories)		// IntStream 반환
                   .sum();

mapToInt 메소드는 각 요리에서 모든 칼로리를 추출한 다음에 IntStream을 반환한다. (Stream<Integer>가 아니다.)
따라서 IntStream 인터페이스에서 제공하는 sum 메소드를 이용할 수 있다. 스트림이 비어있으면 기본값 0을 반환한다.
IntStreammin, max, average등 다양한 메소드를 제공한다.


  • 객체 스트림으로 복원하기

숫자 스트림으로 만든 다음 복구할 수 있을까?

boxed 메소드를 이용하면 특화 스트림을 일반 스트림으로 변환할 수 있다.

IntStream intStream = menu.stream().mapToInt(Dish::getCalories);
Stream<Integer> stream = intStream.boxed();

  • 기본값: OptionalInt

IntStream에서 최댓값을 찾을 때 0이라는 기본값 때문에 잘못된 결과가 도출될 수 있다.
스트림에 요소가 없는 상황과 실제 최댓값이 0인 상황을 어떻게 구별할 수 있을까?

OptioanlInteger, String등의 참조 형식으로 파라미터화 할 수 있다. 또한 OptionalInt, OptionalDouble, OptionalLong 세 가지 기본형 특화 스트림 버전도 제공한다.

OptionalInt maxCalories = menu.stream().mapToInt(Dish::getCalories).max();

최댓값이 없는 상황에 사용할 기본값을 명시적으로 정의할 수 있다.

List<Dish> menu1 = Arrays.asList();
OptionalInt maxCalories = menu1.stream().mapToInt(Dish::getCalories).max();
System.out.println(maxCalories);
System.out.println(maxCalories.orElse(1));
출력결과
OptionalInt.empty
1

2. 숫자 범위

특정 범위의 숫자를 이용해야하는 상황인 1에서 100 사이의 숫자를 생성하려고 한다고 가정하자. 자바 8의 IntStreamLongStream에서는 rangerangeClosed라는 두 가지 정적 메소드를 제공한다.
두 메소드 모두 첫 번재 인수로 시작값을, 두 번째 인수로 종료값을 가진다. range 메소드는 시작값과 종료값이 결과에 포함되지 않는 반면 rangeClosed는 시작값과 종료값이 결과에 포함된다.

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

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

위 코드는 1부터 100까지 숫자 범위를 지정하고 짝수의 갯수를 반환한다.
rangeClosed는 1과 100을 포함해서 총 50개를 반환하고, range는 1과 100을 포함하지 않아 총 49개를 반환한다.

3. 숫자 스트림 활용: 피타고라스 수

피타고라스 수 스트림을 만들어보자.

  • 피타고라스 수

직각 삼각형에서 a * a + b * b = c * c라는 공식이다.

  • 세 수 표현하기

우선 세 수를 정의해야 한다. 세 수를 표현할 때는 클래스를 정의하는 것보다는 세 요소를 갖는 int 배열을 사용하는 것이 좋을 것 같다.예를 들어 (3, 4, 5)를 new Int[]{3, 4, 5}로 표현할 수 있다. 이제 인덱스로 배열의 각 요소에 접근할 수 있다.

  • 좋은 필터링 조합

누군가 게 구중에 a, b 두 수만 제공했다고 가정하자. 두 수가 피타고라스 수의 일부가 될 수 있는 조합인지 어떻게 확인할 수 있을까?

Math.sqrt(a*a + b*b) % 1 == 0;

과 같이 구현할 수 있다.

이 때 x가 부동 소숫점 수라면 x % 1.0이라는 자바 코드로 소숫점 이하 부분을 얻을 수 있다. 예를 들어 5.0이라는 수에 이 코드를 적용하면 소숫점 이하는 0이 된다.

이를 filter에 다음처럼 활용할 수 있다.

filter(b -> Math.sqrt(a*a + b*b) % 1 == 0)

위 코드는 a라는 값이 주어지고 b는 스트림으로 제공된다고 가정할 때 filter로 a와 함께 피타고라스 수를 구성하는 모든 b를 필터링할 수 있다.

  • 집합 생성

필터를 이용해서 좋은 조합을 갖는 a, b를 선택할 수 있게 되었다. 이제 마지막 세 번째 수를 찾아야 한다. 다음처럼 map을 이용해서 각 요소를 피타고라스 수로 변환할 수 있다.

stream.filter(b -> Math.sqrt(a*a + b*b) % 1 == 0)
	  .map(b -> new int[]{a, b, (int)Math.sqrt(a*a + b*b)});

  • b값 생성

이제 b값을 생성해야 한다. Stream.rangeClosed로 주어진 범위의 수를 만들 수 있을을 배웠다. 1부터 100까지의 b값을 생성할 수 있다.

List<int[]> list = 
	   IntStream.rangeClosed(1, 100)
                .filter(b -> Math.sqrt(a*a + b*b) % 1 == 0)
                .boxed()
                .map(b -> new int[]{a, b, (int) Math.sqrt(a*a + b*b)})
                .toList();

filter연산 다음에 rangeClosed가 반환한 IntStreamboxed를 이용해서 Stream<Integer>로 복원했다.

map은 스트림의 각 요소를 int 배열로 변환하기 때문이다. IntStreammap 메소드는 스트림의 각 요소로 int가 반환될 것을 기대하지만 이것은 우리가 원하는 연산이 아니다.

개체값 스트림을 반환하는 IntStreammapToObj 메소드를 이용해서 이 코드를 재구현할 수 있다.

  • a값 생성

마지막으로 a값을 생성하는 코드를 추가한다. 그러면 피타고라스 수를 생성하는 스트림이 완성된다.

Stream<int[]> pythagoreanTriples =
        IntStream.rangeClosed(1, 100).boxed()
                 .flatMap(a -> IntStream.rangeClosed(a, 100)
                                        .filter(b -> Math.sqrt(a*a + b*b) % 1 == 0)
                                        .mapToObj(b -> new int[]{a, b, (int) Math.sqrt(a*a + b*b)})
                );

여기서 flatMap은 어떤 연산을 수행하는 것일까?

우선 a에 사용할 1부터 100까지의 숫자를 만들었다. 그리고 주어진 a를 이용해서 세 수의 스트림을 만든다. 스트림 a의 값을 매핑하면 스트림의 스트림이 만들어질 것이다. 따라서 flatMap 메소드는 생성된 각자의 스트림을 하나의 평준화된 스트림으로 만들어준다. 결과적으로 세 수로 이루어진 스트림을 얻을 수 있다.

또한 b의 범위가 a에서 100으로 바뀐 것도 유의하자. b를 1부터 시작하면 중복된 세 수가 생성될 수 있다.

  • 코드 실행

pythagoreanTriples.limit(5).forEach(t -> System.out.println(t[0] + ", " + t[1] + ", " + t[2]));
3, 4, 5
5, 12, 13
6, 8, 10
7, 24, 25
8, 15, 17

  • 개선할 점

현재 문제 해결 코드에서는 제곱근을 두 번 계산한다. 따라서 a*a, b*b, a*a+b*b 형식을 만족하는 세 수를 만든 다음 우리가 원하는 조건에 맞는 결과만 필터링하는 것이 더 최적화된 방법이다.

Stream<double[]> pythagoreanTriples =
        IntStream.rangeClosed(1, 100).boxed()
                 .flatMap(a -> IntStream.rangeClosed(a, 100)
                                   	    .mapToObj(b -> new double[]{a, b, (int) Math.sqrt(a*a + b*b)})
                                        .filter(t -> t[1] % 1 == 0)
                );


스트림 만들기

stream 메소드로 컬렉션에서 스트림을 얻을 수 있었다. 범위의 숫자에서 스트림을 만드는 방법도 설명했다. 이 밖에도 다양한 방식으로 스트림을 만들 수 있다. 이 절에서는 일련의 값, 배열, 파일, 함수를 이용한 무한 스트림 만들기 등 다양한 방식으로 스트림을 만드는 방법을 설명한다.

1. 값으로 스트림 만들기

임의의 수를 인수로 받는 정적 메소드 Stream.of를 이용해서 스트림을 만들 수 있다. 예를 들어 다음 코드는 Stream.of로 문자열 스트림을 만드는 예이다. 스트림의 모든 문자열을 대문자로 변환한 후 문자열을 하나씩 출력한다.

Stream<String> stream = Stream.of("Modern ", "Java ", "In ", "Action");
stream.map(String::toUpperCase).forEach(System.out::println);
MODERN 
JAVA 
IN 
ACTION

2. null이 될 수 있는 객체로 스트림 만들기

자바 9에서는 null이 될 수 있는 개체를 스트림으로 만들 수 있는 새로운 메소드가 추가되었다. 때로는 null이 될 수 있는 객체를 스트림으로 만들어야 할 수도 있다. (객체가 null이라면 빈 스트림 반환)

예를 들어 System.getProperty는 제공된 키에 대응하는 속성이 없으면 null을 반환한다. 이런 메소드를 스트림에 활용하려면 다음처럼 null을 명시적으로 확인해야 했다.

String homeValue = System.getProperty("home");
Stream<String> homeValueStream = homeValue == null ? Stream.empty() : Stream.of(homeValue);

Stream.ofNullable을 이용하여 다음처럼 코드를 구현할 수 있다.

Stream<String> homeValueStream = Stream.ofNullable(System.getProperty("home"));

null이 될 수 있는 객체를 포함하는 스트림값을 flatMap과 함께 사용하는 상황에서는 이 패턴을 더 유용하게 사용할 수 있다.

Stream<String> values = 
	Stream.of("config", "home", "user")
          .flatMap(key -> Stream.ofNullable(System.getProperty(key)));

3. 배열로 스트림 만들기

배열을 인수로 받는 정적 메소드 Arrays.stream을 이용해서 스트림을 만들 수 있다. 예를 들어 다음처럼 기본형 int로 이루어진 배열을 IntStream으로 변환할 수 있다.

int[] num = {2,3,4,6,5,7,9};
int sum = Arrays.stream(num).sum();

4. 파일로 스트림 만들기

파일을 처리하는 등의 I/O 연산에 사용하는 자바의 NIO API(비블록 I/O)도 스트림 API를 활용할 수 있도록 업데이트되었다. java.nio.file.Files의 많은 정적 메소드가 스트림을 반환한다. 예를 들어 Files.lines는 주어진 파일의 행 스트림을 문자열로 반환한다. 다음 코드처럼 파일에서 고유한 단어 수를 찾는 프로그램을 만들 수 있다.

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();	// 7
}catch(IOException e){
    
}
data.txt
hello
world

Files.lines로 파일의 각 행 요소를 반환하는 스트림을 얻을 수 있다. 스트림의 소스가 I/O 자원이므로 이 메소드를 try/catch 블록으로 감쌌고 메모리 누수를 막으려면 자원을 닫아야한다. 기존에는 finally블록에서 자원을 닫았다. 그러나 Stream 인터페이스는 AutoCloseable 인터페이스를 구현하므로 try 블록 내에서 자원은 자동으로 관리된다.

linesplit 메소드를 호출해서 각 행의 단어를 분리할 수 있다. 각 행의 단어를 여러 스트림으로 만드는 것이 아니라 flatMap으로 스트림을 하나로 평면화했다.

마지막으로 distinctcount를 연결해서 스트림의 고유 단어 수를 계산했다.

5. 함수로 무한 스트림 만들기

스트림 API는 함수에서 스트림을 만들 수 있는 두 정적 메소드 Stream.iterateStream.generate를 제공한다.
두 연산을 이용해서 무한 스트림, 즉 고정된 컬렉션에서 고정된 크기로 스트림을 만들었던 것과는 달리 크기가 고정되지 않은 스트림을 만들 수 있다.

iterategenerate에서 만든 스트림은 요청할 때마다 주어진 함수를 이용해서 값을 만든다. 따라서 무제한으로 값을 계산할 수 있다. 하지만 보통 무한한 값을 출력하지 않도록 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

iterate 메소드는 초깃값과 람다를 인수로 받아서 새로운 값을 끊임없이 생산할 수 있다.
예제에서는 람다 n -> n + 2, 즉 이전 결과에 2를 더한 값을 반환한다. 결과적으로 iterate 메소드는 짝수 스트림을 생성한다.

스트림의 첫 번째 요소는 0이다. 다음에는 2를 더해 2가된다. 그리고 2를 다시 더해 4가 되는 식이다. 기본적으로 iterate는 기존 결과에 의존해서 순차적으로 연산을 수행한다. iterate는 요청할 때마다 값을 생산할 수 있으며 끝이 없으므로 무한 스트림을 만든다. 이러한 스트림을 언바운드 스트림이라고 표현한다.

바로 이런 특징이 스트림과 컬렉션의 가장 큰 차이점이다. 예제에서는 limit 메소드를 이용해서 스트림의 크기를 명시적으로 처음 10개의 짝수로 제한했다. 그리고 최종 연산인 forEach를 호출해서 스트림을 소비하고 개별 요소를 출력한다.

일반적으로 연속된 일련의 값을 만들 때는 iterate를 사용한다. 예를 들어 1월 13일, 2월 1일 등의 날짜를 생성할 수 있다.

피보나치 수열 집합 생성하기

Stream.iterate(new int[]{0, 1}, t -> new int[]{t[1], t[0] + t[1]})
      .limit(10)
      .forEach(t -> System.out.println("(" + t[0] + ", " + t[1] + ")"));
(0, 1)
(1, 1)
(1, 2)
(2, 3)
(3, 5)
(5, 8)
(8, 13)
(13, 21)
(21, 34)
(34, 55)

자바 9의 iterate 메소드는 프레디케이트를 지원한다. 예를 들어 0에서 시작해서 100보다 크면 숫자 생성을 중단하는 코드를 다음처럼 구현할 수 있다.

Stream.iterate(0, n -> n < 100, n -> n + 4)
      .forEach(System.out::println);

iterate 메소드는 두 번째 인수로 프레디케이트를 받아 언제까지 작업을 수행할 것인지 기준으로 사용한다.

스트림 쇼트서킷을 지원하는 takeWhile을 이용해서도 구현할 수 있다.

Stream.iterate(0, n -> n + 4)
      .takeWhile(n -> n < 100)
      .forEach(System.out::println);

  • generate 메소드

iterate와 비슷하게 generate도 요구할 때 값을 계산하는 무한 스트림을 만들 수 있다. 하지만 iterate와 달리 generate는 생산된 각 값을 연속적으로 계산하지 않는다.

generateSupplier<T>를 인수로 받아서 새로운 값을 생성한다.

Stream.generate(Math::random)
      .limit(5)
      .forEach(System.out::println);

위 코드는 0과 1 사이의 랜덤 숫자를 5개 반환한다.

0.21966934659749449
0.6594465869348184
0.48908761192379147
0.21537422875816525
0.9343212438806966

Math.random은 임의의 새로운 값을 생성하는 정적 메소드다. 이번에도 limit을 이용해서 명시적으로 스트림의 크기를 제한했다. limit이 없다면 스트림은 언바운드 상태가 된다.

generate 메소드는 언제 활용할 수 있을까?

우리가 사용한 발행자(메소드 참조 Math.random)는 상태가 없는 메소드, 즉 나중에 계산에 사용할 어떠한 값도 저장해두지 않는다.
하지만 발행자에 꼭 상태가 없어야하는 것은 아니다. 발행자가 상태를 저장한 다음에 스트림의 다음 값을 만들 때 상태를 고칠 수도 있다.

예를 들어 generate를 이용해서 피보나치수열을 구현해보면서 iterate 메소드와 generate를 비교할 수 있다.
여기서 중요한 점은 병렬 코드에서는 발행자에 상태가 있으면 안전하지 않다는 것이다. 따라서 상태를 갖는 발행자는 단지 설명에 필요한 예제일 뿐 실제로는 피해야 한다.

우리 예제에서 IntStream을 이용하면 박싱 연산 문제를 피할 수 있다.

IntStreamgenerate 메소드는 Supplier<T> 대신에 IntSupplier를 인수로 받는다. 다음은 무한 스트림을 생성하는 코드이다.

IntStream ones = IntStream.generate(() -> 1);

IntSupplier인터페이스에 정의된 getAsInt를 구현하는 객체를 명시적으로 전달할 수도 있다.

IntStream twos = IntStream.generate(new IntSupplier() {
    @Override
    public int getAsInt() {
        return 2;
    }
});

generate 메소드는 주어진 발행자를 이용해서 2를 반환하는 getAsInt 메소드를 반복적으로 호출할 것이다. 여기서 사용한 익명 클래스와 람다는 비슷한 연산을 수행하지만 익명 클래스에서는 getAsInt 메소드의 연산을 커스터마이즈할 수 있는 상태 필드를 정의할 수 있다는 점이 다르다.

바로 부작용이 생길 수 있는 예제이다. 지금까지 살펴본 람다는 부작용이 없었다. 즉, 람다는 상태를 바꾸지 않는다.

피보나치 수열 작업으로 돌아와서 이제 기존의 수열을 저장하고 getAsInt로 다음 요소를 계산하도록 IntSupplier를 만들어야 한다. 또한 다음에 호출될 때는 IntSupplier의 상태를 갱신할 수 있어야 한다.

IntSupplier fib = new IntSupplier() {
    private int prev = 0;
    private int current = 1;

    @Override
    public int getAsInt() {
        int oldPrev = this.prev;
        int next = this.prev + this.current;

        this.prev = this.current;
        this.current = next;

        return oldPrev;
    }
};

IntStream.generate(fib).limit(10).forEach(System.out::println);
0
1
1
2
3
5
8
13
21
34

위 코드에서는 IntSupplier 인스턴스를 만들었다. 만들어진 객체는 피보나치 요소와 두 인스턴스 변수에 어떤 피보나치 요소가 들어있는지 추적하므로 가변 상태 객체이다. getAsInt를 호출하면 객체 상태가 바뀌며 새로운 값을 생산한다.

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

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

우리는 무한한 크기를 가진 스트림을 처리하고 있으므로 limit을 이용해서 명시적으로 스트림의 크기를 제한해야 한다. 그렇지 않으면 최종 연산을 수행했을 때 아무 결과도 계산되지 않는다.

마찬가지로 무한 스트림의 요소는 무한적으로 계산이 반복되므로 정렬하거나 리듀스할 수 없다.


정리

  • 스트림 API를 이용하면 복잡한 데이터 처리 질의를 표현할 수 있다.
  • filter, distinct, takeWhile(Java 9), dropWhile(Java 9), skip, limit 메소드로 스트림을 필터링하거나 자를 수 있다.
  • 소스가 정렬되어 있다는 사실을 알고 있을 때 takeWhile, dropWhile 메소드를 효과적으로 사용할 수 있다.
  • map, flatMap 메소드로 스트림의 요소를 추출하거나 변환할 수 있다.
  • findFirst, findAny 메소드로 스트림의 요소를 검색할 수 있다. allMatch, noneMatch, anyMatch 메소드를 이용해서 주어진 프레디케이트와 일치하는 요소를 스트림에서 검색할 수 있다.
  • 이들 메소드는 쇼트서킷, 즉 결과를 찾는 즉시 반환하며, 전체 스트림을 처리하지 않는다.
  • reduce 메소드로 스트림의 모든 요소를 반복 조합하며 값을 도출할 수 있다. 예를 들어 reduce로 스트림의 최댓값이나 모든 요소의 합계를 계산할 수 있다.
  • filter, map 등은 상태를 저장하지 않는 상태 없는 연산이다. reduce 같은 연산은 값을 계산하는 데 필요한 상태를 저장한다. sorted, distinct등의 메소드는 새로운 스트림을 반환하기에 앞서 스트림의 모든 요소를 버퍼에 저장해야 한다. 이런 메소드를 상태 있는 연산이라고 부른다.
  • IntStream, DoubleStream, LongStream은 기본형 특화 스트림이다. 이들 연산은 각각의 기본형에 맞게 특화되어 있다.
  • 컬렉션뿐 아니라 값, 배열, 파일, iterate, generate 같은 메소드로도 스트림을 만들 수 있다.
  • 무한한 개수의 요소를 가진 스트림을 무한 스트림이라 한다.


이 글은 모던 인 자바 액션 책을 실습하며 참고하여 작성한 글입니다.

0개의 댓글