스트림의 게으름

최창효·2023년 9월 11일
1
post-thumbnail

왜 이럴까

간단한 스트림 코드를 하나 살펴봅시다.

Dish클래스가 정의되어 있습니다.

@Getter
@Setter
public static class Dish{
    int calories;
    String name;
    public Dish(int calories, String name){
        this.calories = calories;
        this.name = name;
    }
}

Dish객체를 담은 menu라는 List가 있습니다.

List<Dish> menu = List.of(
    new Dish(100,"빵"),
    new Dish(150,"국밥"),
    new Dish(200,"백반"),
    new Dish(250,"생선튀김"),

    new Dish(300,"떡볶이"),
    new Dish(350,"도넛"),
    new Dish(400,"비빔밥"),

    new Dish(450,"뚝배기불고기"),
    new Dish(500,"삼겹살"),
    new Dish(550,"스테이크")
);

menu에 대해 다음의 스트림 코드를 실행합니다.

List<String> collect = menu.stream()
        .filter(dish -> {
            System.out.println("filtering : " + dish.getName());
            return dish.getCalories() >= 300;
        })
        .map(dish -> {
            System.out.println("mapping : " + dish.getName());
            return dish.getName();
        })
        .limit(3)
        .collect(toList());
  • filter는 람다를 인수로 받아 스트림에서 특정 요소를 제외시키는 연산입니다.
  • map은 람다를 이용해 한 요소를 다른 요소로 변환하거나 정보를 추출하는 연산입니다.
  • limit는 정해진 개수 이상의 요소가 스트림에 저장되지 못하게 스트림 크기를 축소하는 연산입니다.
  • collect는 스트림을 다른 형식으로 변환하는 연산입니다.

예상

위 스트림 코드의 결과를 예상해 봅시다.

  1. filter내부에서 println을 실행합니다. 해당 코드는 칼로리가 300이상인지 여부와 상관 없으므로 10개의 menu가 모두 출력될 거 같습니다.
  2. filter의 결과로 4개의 메뉴는 제외됐을 거 같습니다.
  3. map내부에서 println을 실행합니다. 해당 코드는 필터에서 걸러진 6개의 메뉴에 대해 적용되고, 총 6개의 메뉴이름이 출력될 거 같습니다.
  4. map에서 반환한 6개의 메뉴이름 중 3개의 메뉴이름이 선택될 거 같습니다.
  5. 선택된 3개의 메뉴이름을 List로 만들 거 같습니다.

결과

하지만 위 코드를 실행하면 예상과는 다른 결과를 마주하게 됩니다.

  • filter에서 실행한 println은 10개의 메뉴가 아닌 7개의 메뉴에 대해서만 실행됐습니다.
  • map에서 실행한 println은 6개의 메뉴가 아닌 3개의 메뉴에 대해서만 실행됐습니다.
  • filter의 println이 모두 실행된 뒤 map의 println이 실행되는 게 아니라 두 println이 뒤섞여 나타나고 있습니다.

왜 이런 결과가 나왔는지 지금부터 알아보겠습니다.

Stream's Lazy evaluation

스트림의 큰 특징 중 하나는 바로 게으름(Laziness)입니다.

게으르다는 뜻은 간단히 말해 결과가 필요할 때까지 계산을 늦춘다는 의미입니다.
스트림의 연산은 크게 중간 연산최종 연산으로 나눌 수 있습니다. 중간 연산은 호출 즉시 실행되지 않고 단지 파이프라인을 구성하기만 합니다. 그리고 이 파이프라인들은 최종 연산이 호출됐을 때 한번에 동작하게 됩니다.

위 예제에서 filter, map, limit는 중간연산, collect는 최종 연산입니다. 즉, collect를 실행하기 전까지 filter와 map과 limit은 실행되지 않았다는 의미입니다.

게으름의 장점

결과가 필요할 때까지 계산을 늦춤으로써 우리는 실행을 최적화 시킬 수 있다는 이점을 얻게 됩니다.
JVM은 계산을 실행하기 전에 스트림 파이프라인이 어떤 중간연산과 최종연산으로 구성되어있는지를 파악한 뒤 어떤 방식으로 최적화를 진행할지를 미리 계획하고, 그 계획에 따라 스트림의 개별 요소에 대한 연산을 수행합니다.

이러한 특성으로 인해 다음과 같은 방식의 최적화를 생각해볼 수 있습니다.

map() 다음 파이프라인이 limit(3)연산이네? 그렇다면 map을 다 돌아보지 않고 조건을 만족하는 3개를 얻었다면 곧바로 작업을 종료해도 되겠군.

순차적으로 생각할 때는 map이 limit를 모르는 상태에서 작업이 진행돼야 하지만, 최종 연산까지 작업을 늦췄을 때는 우리가 filter, map, limit을 사용한다는 걸 알고 작업을 진행하기 때문에 거기에 알맞은 보다 효과적인 연산이 가능하다는 사실을 기억하면 될 거 같습니다.

스트림API는 우리가 하려는 작업을 고수준으로 추상화했습니다. 따라서 구체적인 연산작업이 어떻게 최적화되는지, 또는 그러한 연산작업을 우리가 조정하거나 등이 필요하지 않습니다. 단지 스트림이 연산을 최적화 한다는 걸 이용하고 활용해 주시면 됩니다.

스트림의 최적화 전략

루프퓨전(loop fusion)

루프 퓨전이란 파이프라인에서 연속적으로 체이닝된 복수의 스트림 연산을 하나의 연산 과정으로 병합시키는 걸 의미합니다.
위 예제에서 스트림이 내부적으로 filter와 map을 하나의 연산으로 병합하는 최적화를 진행했고, 그 결과 filter의 println과 map의 println이 섞여서 나오게 됐습니다.

쇼트서킷(short circuit)

쇼트 서킷이란 불필요한 연산을 생략해 성능을 개선하는 연산 방식을 말합니다.

논리연산자 &&||를 생각하면 쉽게 이해할 수 있습니다. A && B는 A가 false일 경우 B를 확인하지 않습니다. 또한 A || B는 A가 true일 경우 B를 확인하지 않습니다. 이처럼 특정 조건을 달성했을 때 더 이상 연산을 진행하지 않는 걸 쇼트서킷이라 합니다.
위 예제에서 10개의 데이터 중 7개를 찾아봤을 때, 그 7개의 데이터 중 3개의 데이터가 조건을 만족했기 때문에 더 이상 탐색을 진행하지 않고 연산을 종료했습니다.

우리 코드에서 사용한 limit외에 allMatch, anyMatch 등의 연산이 쇼트 서킷을 사용합니다.

  • limit(N)은 조건을 만족하는 N개의 데이터를 찾았다면 더 이상 데이터를 탐색하지 않습니다.
  • allMatch는 하나라도 조건을 만족하지 않는 데이터를 찾았다면 더 이상 데이터를 탐색하지 않고 해당 데이터를 반환합니다.
  • anyMatch는 조건을 만족하는 최초의 데이터를 찾았다면 더 이상 데이터를 탐색하지 않고 해당 데이터를 반환합니다.

결론

처음 코드가 예상했던대로 동작하지 않았던 이유는 스트림의 게으른 특성으로 인해 결과가 필요할 때까지 계산을 늦췄으며, 연산을 실제로 진행하는 과정에서 내부적인 최적화가 일어났기 때문입니다. 최적화 과정에서 복수의 스트림 연산을 하나로 병합시키는 루프퓨전으로 인해 filter의 println과 map의 println이 뒤섞여 실행되었으며, 불필요한 연산을 생략하는 쇼트서킷으로 인해 예상했던 결과보다 적은 출력이 발생했었습니다.

References

profile
기록하고 정리하는 걸 좋아하는 개발자.

0개의 댓글