간단한 스트림 코드를 하나 살펴봅시다.
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내부에서 println을 실행합니다. 해당 코드는 칼로리가 300이상인지 여부와 상관 없으므로 10개의 menu가 모두 출력될 거 같습니다.
- filter의 결과로 4개의 메뉴는 제외됐을 거 같습니다.
- map내부에서 println을 실행합니다. 해당 코드는 필터에서 걸러진 6개의 메뉴에 대해 적용되고, 총 6개의 메뉴이름이 출력될 거 같습니다.
- map에서 반환한 6개의 메뉴이름 중 3개의 메뉴이름이 선택될 거 같습니다.
- 선택된 3개의 메뉴이름을 List로 만들 거 같습니다.
하지만 위 코드를 실행하면 예상과는 다른 결과를 마주하게 됩니다.
왜 이런 결과가 나왔는지 지금부터 알아보겠습니다.
스트림의 큰 특징 중 하나는 바로 게으름(Laziness)
입니다.
게으르다는 뜻은 간단히 말해 결과가 필요할 때까지 계산을 늦춘다
는 의미입니다.
스트림의 연산은 크게 중간 연산
과 최종 연산
으로 나눌 수 있습니다. 중간 연산은 호출 즉시 실행되지 않고 단지 파이프라인을 구성하기만 합니다. 그리고 이 파이프라인들은 최종 연산이 호출됐을 때 한번에 동작하게 됩니다.
위 예제에서 filter, map, limit는 중간연산, collect는 최종 연산입니다. 즉, collect를 실행하기 전까지 filter와 map과 limit은 실행되지 않았다는 의미입니다.
결과가 필요할 때까지 계산을 늦춤으로써 우리는 실행을 최적화 시킬 수 있다
는 이점을 얻게 됩니다.
JVM은 계산을 실행하기 전에 스트림 파이프라인이 어떤 중간연산과 최종연산으로 구성되어있는지를 파악한 뒤 어떤 방식으로 최적화를 진행할지를 미리 계획하고, 그 계획에 따라 스트림의 개별 요소에 대한 연산을 수행합니다.
이러한 특성으로 인해 다음과 같은 방식의 최적화를 생각해볼 수 있습니다.
map() 다음 파이프라인이 limit(3)연산이네? 그렇다면 map을 다 돌아보지 않고 조건을 만족하는 3개를 얻었다면 곧바로 작업을 종료해도 되겠군.
순차적으로 생각할 때는 map이 limit를 모르는 상태에서 작업이 진행돼야 하지만, 최종 연산까지 작업을 늦췄을 때는 우리가 filter, map, limit을 사용한다는 걸 알고 작업을 진행하기 때문에 거기에 알맞은 보다 효과적인 연산이 가능하다는 사실을 기억하면 될 거 같습니다.
스트림API는 우리가 하려는 작업을 고수준으로 추상화
했습니다. 따라서 구체적인 연산작업이 어떻게 최적화되는지, 또는 그러한 연산작업을 우리가 조정하거나 등이 필요하지 않습니다. 단지 스트림이 연산을 최적화 한다는 걸 이용하고 활용해 주시면 됩니다.
루프 퓨전이란 파이프라인에서 연속적으로 체이닝된 복수의 스트림 연산을 하나의 연산 과정으로 병합시키는 걸 의미합니다.
위 예제에서 스트림이 내부적으로 filter와 map을 하나의 연산으로 병합하는 최적화를 진행했고, 그 결과 filter의 println과 map의 println이 섞여서 나오게 됐습니다.
쇼트 서킷이란 불필요한 연산을 생략해 성능을 개선하는 연산 방식을 말합니다.
논리연산자 &&
와 ||
를 생각하면 쉽게 이해할 수 있습니다. A && B
는 A가 false일 경우 B를 확인하지 않습니다. 또한 A || B
는 A가 true일 경우 B를 확인하지 않습니다. 이처럼 특정 조건을 달성했을 때 더 이상 연산을 진행하지 않는 걸 쇼트서킷이라 합니다.
위 예제에서 10개의 데이터 중 7개를 찾아봤을 때, 그 7개의 데이터 중 3개의 데이터가 조건을 만족했기 때문에 더 이상 탐색을 진행하지 않고 연산을 종료했습니다.
우리 코드에서 사용한 limit
외에 allMatch
, anyMatch
등의 연산이 쇼트 서킷을 사용합니다.
처음 코드가 예상했던대로 동작하지 않았던 이유는 스트림의 게으른 특성
으로 인해 결과가 필요할 때까지 계산을 늦췄
으며, 연산을 실제로 진행하는 과정에서 내부적인 최적화
가 일어났기 때문입니다. 최적화 과정에서 복수의 스트림 연산을 하나로 병합시키는 루프퓨전
으로 인해 filter의 println과 map의 println이 뒤섞여 실행되었으며, 불필요한 연산을 생략하는 쇼트서킷
으로 인해 예상했던 결과보다 적은 출력이 발생했었습니다.