본문 바로가기

개발

자바 8 stream 의 lazy

자바 8에서 등장한 stream 은 lazy 로 동작합니다.

lazy 는 연산이 필요할 때 연산을 한다는 의미입니다.

lazy 는 stream 이 효율적으로 동작할 수 있게 하는 핵심입니다.

 

간단한 코드를 통해서 stream 을 사용했을 때와, for 문을 사용했을 때 연산 횟수(대략적인 성능)를 비교해 보고

lazy 가 어떻게 stream 을 효율적으로 동작할 수 있게 하였는지 알아보겠습니다.

 

연산 횟수 비교


문제: 주어진 (순서가 있는) 리스트에서 3 보다 크고 짝수인 첫 번째 수를 2배한 값을 구하세요.

 

기존의 for 문을 이용해 코드를 작성하면 연산 횟수가 몇 번인지 확인해 보겠습니다.

"연산 횟수" 는 단순히 비교 연산을 몇 번 했는지, 곱하기 연산은 몇 번 했는지 정도를 나타냅니다.
stream 과 for 문의 대략적인 성능 비교를 위해 "연산 횟수" 를 사용하였습니다.
자세한 성능 비교는 Java Stream API는 왜 for-loop보다 느릴까?  에서 보실 수 있습니다.
import java.util.Arrays;
import java.util.List;

public class Main {
    public static void main(String[] args) {
        List<Integer> numbers = Arrays.asList(1, 2, 3, 5, 4, 6, 7, 8, 9, 10);

        int result = 0;
        for (int e : numbers) {
            if (e > 3 && e % 2 == 0) {
                result = e * 2;
                break;
            }
        }
        System.out.println(result);
    }
}
※ 1, 2, 3, 5, 4, 6, 7, 8, 9, 10 입니다

결과는 몇일까요?

8

위 코드의 연산 횟수를 세어보겠습니다.

1번 연산 | 1 > 3 

2번 연산 | 2 > 3

3번 연산 | 3 > 3

4번 연산 | 5 > 3 

5번 연산 | 5 % 2 == 0

6번 연산 | 4 > 3

7번 연산 | 4 % 2 == 0

8번 연산 | 4 * 2

 

총 8번입니다. 

 

stream 으로 코드를 작성해 보고 연산 횟수를 세어보겠습니다.

연산 횟수를 세기 위해서 메소드를 만들고 그 메소드가 호출될 때 문자열을 출력하게 구현하였습니다.

import java.util.Arrays;
import java.util.List;
import java.util.stream.Stream;

public class Main {
    public static void main(String[] args) {
        List<Integer> numbers = Arrays.asList(1, 2, 3, 5, 4, 6, 7, 8, 9, 10);

        Stream<Integer> integerStream = numbers.stream()
                .filter(Main::isGT3)
                .filter(Main::isEven)
                .map(Main::doubleIt);

        System.out.println("Calculation Start");

        System.out.println(integerStream.findFirst());
    }

    public static boolean isGT3(int number) {
        System.out.println("isGT3 " + number);
        return number > 3;
    }
    public static boolean isEven(int number) {
        System.out.println("isEvent " + number);
        return number % 2 == 0;
    }
    public static int doubleIt(int number) {
        System.out.println("doubleIt " + number);
        return number * 2;
    }
}

코드를 실행하여 결과를 확인해 보겠습니다.

Calculation Start
isGT3 1
isGT3 2
isGT3 3
isGT3 5
isEvent 5
isGT3 4
isEvent 4
doubleIt 4
Optional[8]

출력된 문자열을 통해서 stream 을 사용한 연산 횟수 또한 8번이라는 것을 알 수 있습니다.

출력된 문자열을 차례대로 따라가 보면 stream 은 numbers 의 값들을

1개씩 꺼내어 처리한다는 것을 알 수 있습니다.

그 덕분에 6 ~ 10 까지의 값들에 대한 순회를 하지 않고 결과 값을 얻어낼 수 있었습니다.

 

그런데 isGT3, isEven, doubleIt 을 호출한 시점보다 더 늦게 출력한 "Calculation Start" 문자열이

가장 먼저 출력되어 있는 것을 볼 수 있습니다.

그 이유는 stream 이 lazy 로 동작하기 때문입니다.

 

stream operation 과 lazy


stream operation 은 중간 연산(intermediate operation) 과 끝 연산(terminal operation) 으로 나뉩니다.

중간 연산에는 filter, map 등이 있습니다

중간 연산은 값을 바로 계산하지 않고 새로운 Stream 을 반환하고 끝 연산이 호출될 때 연산을 합니다.

끝 연산에는 forEach, sum 등이 있고 연산을 시작하여 결과값을 만들어내거나 출력 같은 side-effect 를 만들어냅니다.

 

위 코드에서 isGT3, isEven, doubleIt 만 호출한 결과로는 stream 이 리턴이 되고 (중간 연산)

끝 연산인 findFirst 가 호출될 때 실제로 연산이 되면서 결과 값을 나옵니다. (끝 연산)

 

만약 lazy 로 동작하지 않는다면 어떻게 동작할까요?

메소드 결과 엘리먼트 연산 횟수 총 연산 횟수
isGT3 5, 4, 6, 7, 8, 9, 10 10 10
isEven 4, 6, 8, 10 7 17
doubeIt 8, 12, 16, 20 4 21

lazy 로 동작하지 않는다면 isGT3 메소드를 하나만 수행하더라도 결과가 나와야 하기 때문에

numbers 의 모든 엘리먼트들에 대해 연산을 해야 합니다.

그래서 위 표와 같은 불필요한 값들까지 연산을 하게 됩니다.

 

Java 문서 에는 Stream 이 lazy 로 인한 효율에 대해 다음과 같이 말합니다.

Processing streams lazily allows for significant efficiencies; in a pipeline such as the filter-map-sum example above, filtering, mapping, and summing can be fused into a single pass on the data, with minimal intermediate state. Laziness also allows avoiding examining all the data when it is not necessary; for operations such as "find the first string longer than 1000 characters", it is only necessary to examine just enough strings to find one that has the desired characteristics without examining all of the strings available from the source. (This behavior becomes even more important when the input stream is infinite and not merely large.)

Stream 을 lazy 하게 처리하는 것은 엄청나게 효율성을 높일 수 있습니다. filter-map-sum 으로 이어진 Pipelline 으로 데이터를 filtering, mapping, summing 하는 과정을 하나의 통로로 만들어서 최소의 중간단계 상태로 처리할 수 있습니다. 또한, laziness 는 필요하지 않을 때 모든 데이터를 확인하지 않을 수 있게 합니다. "1000 글자보다 긴 첫 번째 문자열을 찾아라" 와 같은 연산에서, 한 개의 문자열만 찾을 정도만 데이터를 검색하면 됩니다. ( 이 동작은 input stream 이 무한일 때 더 중요합니다)

 

무한 stream


stream 이 lazy 로 동작하여서 무한 stream 을 생성할 수 있습니다.

왜냐하면 필요한 값만 한 개씩 꺼내서 연산될 수 있기 때문입니다.

그러면 무한 stream 을 사용한 예제를 보면서 무한 stream 의 유용성을 느껴보겠습니다.

 

문제: 양의 정수 k 와 n이 주어졌을 때, k 보다 크거나 같은 수 중에 짝수이면서 루트 값이 20보다 큰 수들의 합을 구하세요. (결과는 Integer)

public static int compute(int k, int n) {
    return Stream.iterate(k, e -> e + 1)
            .filter(e -> e % 2 == 0)
            .filter(e -> Math.sqrt(e) > 20)
            .mapToInt(e -> e * 2)
            .limit(n)
            .sum();
}

위 코드와 같이 무한 stream 을 사용하면 임수 변수 같은 것을 사용하지 않고

함수형 스타일로 코드를 구현할 수 있습니다.

 

 

결론


1. stream 은 lazy 로 동작하여서 모든 값을 한번에 계산하는게 아닌 하나씩 값을 꺼내서 계산합니다.

2. lazy 로 동작하는 덕분에 for 문의 break 를 사용한 것처럼 불필요한 연산을 하지 않을 수 있습니다.

3. lazy 로 동작하는 덕분에 무한 stream 을 이용하여 함수형 스타일로 코드를 구현할 수도 있습니다.

 

 

출처

Get a Taste of Lambdas and Get Addicted to Streams by Venkat Subramaniam