Java Stream API
Java의 Stream API는 선언적이고 간결한 방식으로 데이터 처리 로직을 구성할 수 있도록 도와주는 매우 강력한 기능이다.
하지만 Stream은 겉으로 보기엔 단순해 보여도, 내부적으로는 lazy evaluation(지연 평가) 기반의 평가 전략을 사용한다.
이 개념을 제대로 이해하지 못하면, 예상과 달리 불필요한 연산이 수행되어 성능 저하로 이어질 수 있다.
우선 lazy evaluation을 알아보기 전에, Stream 동작 순서를 예측해 보자.
테스트 환경
Item을 그대로 반환하지만, sout을 찍어주는 log(Item item) 메서드로 로그를 찍어볼 것이다.
@AllArgsConstructor
@Getter
static class Item{
private int count;
private String name;
public static Item log(Item item){
System.out.println(item);
return item;
}
@Override
public String toString(){
return "(" + count + ", " + name + ")";
}
}
아래와 같은 List<Item> 을 예제로 사용한다.
Item[] arr = new Item[]{
new Item(100, "ITEM1"),
new Item(200, "ITEM2"),
new Item(300, "ITEM3"),
new Item(400, "ITEM4"),
new Item(500, "ITEM5"),
new Item(600, "ITEM6"),
new Item(700, "ITEM7"),
};
List<Item> items = new ArrayList<>(List.of(arr));
예제 1
아래의 출력 결과는 어떻게 될까?
List<Integer> list = items.stream()
.filter(i -> Item.log(i).getCount() > 300)
.map(i -> Item.log(i).getCount())
.limit(2)
.toList();
System.out.println(list);
정답
(100, ITEM1)
(200, ITEM2)
(300, ITEM3)
(400, ITEM4)
(400, ITEM4)
(500, ITEM5)
(500, ITEM5)
[400, 500]
Item 1, 2, 3은 filter 까지만 출력된다.
Item 4, 5는 filter를 통과하고 map이 동작한다.
map까지 통과한 Item이 2개가 되는 순간 limit(2)가 동작하고, 6, 7은 filter조차 타지 않는다.
따라서 순서는 아래와 같다.
filter(1) -> filter(2) -> filter(3) -> filter(4) -> map(4) -> (limit 1개 도달)
-> filter(5) -> map(5) -> (limit 2개 도달)
-> toList()
일단 이렇게만 알아두자.
filter, map : 중간 연산
limit: stateful 중간연산
toList : 최종 연산
(더 보기)를 눌러서 정답 확인
예제 2
아래의 출력 결과는 어떻게 될까?
List<Integer> list2 = items.stream()
.filter(i -> Item.log(i).getCount() > 300)
.map(i -> Item.log(i).getCount())
.sorted((a,b) -> b-a)
.limit(2)
.toList();
System.out.println(list2);
정답
(100, ITEM1)
(200, ITEM2)
(300, ITEM3)
(400, ITEM4)
(400, ITEM4)
(500, ITEM5)
(500, ITEM5)
(600, ITEM6)
(600, ITEM6)
(700, ITEM7)
(700, ITEM7)
[700, 600]
마찬가지로 Item 1, 2, 3은 filter까지만 출력된다.
Item 4, 5, 6, 7은 filter를 통과하고 map이 동작한다.
limit(2)가 있지만, sorted가 동작하기 위해선 전체 요소가 필요하다. 따라서 Item 6, 7도 map을 통과하여 그 결과가 필요하다.
이제 filter와 map을 통과한 Item 4, 5, 6, 7을 정렬한다.
마지막으로 정렬 결과에서 2개를 추출한다.
따라서 실행 순서는 아래와 같다.
filter(1) -> filter(2) -> filter(3)
-> filter(4) -> map(4) -> filter(5) -> map(5) -> filter(6) -> map(6) -> filter(7) -> map(7)
-> sorted 연산 -> limit 연산
-> toList()
sorted는 stateful 중간연산이다.
(더 보기)를 눌러서 정답 확인
여기서 알 수 있는 건 filter와 map은 stateless 한 중간연산이고, sorted와 limit은 stateful 한 중간연산, toList는 최종연산이다.
stateful 하다는 건 전체 결과가 필요하다는 뜻으로 예측할 수 있다.
Stream API 연산의 종류
중간연산 vs 최종연산
Java Stream API의 연산자는 크게 중간 연산(Intermediate Operation)과 최종 연산(Terminal Operation)으로 나뉜다.
- 중간 연산: 스트림 파이프라인을 구성하며, 최종 연산이 호출되기 전까지 실행되지 않는다.
- 최종 연산: 실제로 스트림을 평가하며 결과를 반환하거나 부수 효과를 발생시킨다. 이 시점에서 모든 중간 연산이 함께 실행된다.
중간 연산 | filter, map, sorted, limit 등 | 평가 준비만 하고 실행은 보류됨 |
최종 연산 | toList(), forEach(), count() 등 | 여기서 실제 연산이 수행됨 |
Stateless 중간 연산 vs Stateful 중간 연산
중간 연산은 내부 구현 방식에 따라 두 가지로 세분화할 수 있다:
- Stateless 중간 연산: 각 요소를 독립적으로 처리 가능하며, 별도의 상태를 저장하지 않는다.
- Stateful 중간 연산: 요소 전체 또는 일부를 버퍼에 저장한 후 처리가 가능하며, 순서나 전체성(state)을 고려해야 한다.
즉, 중간 연산 중에 stateful 하다는 것은 전체 또는 일부를 알고 있어야지 처리가 가능하다는 것이다.
limit은 일부를 알아야 하고(2개가 넘었는지), sorted는 전체를 알아야지 연산을 수행할 수 있다.
filter() | Stateless 중간 연산 | 조건에 따라 요소를 걸러냄 |
map() | Stateless 중간 연산 | 요소를 변환함 |
limit() | Stateful 중간 연산 | 앞에서 N개를 유지하려면 상태를 기억해야 함 |
sorted() | Stateful 중간 연산 | 정렬을 위해 전체 요소가 필요 |
toList() | 최종 연산 | 최종 결과를 수집함 |
중간 연산은 "최종 연산이 호출되기 전까지 실행되지 않는다."라고 했는데, 이게 lazy evaluation이다.
Lazy Evaluation
Lazy Evaluation(지연 평가)이란, 스트림 연산을 즉시 실행하지 않고, 최종 연산이 호출되기 전까지 연기하는 평가 전략을 의미한다.
즉, filter, map, sorted 등의 연산은 단순히 “파이프라인에 등록”만 되며,
실제 계산은 toList(), forEach() 등 최종 연산이 호출되는 시점에 수행된다.
Java Stream은 가능한 연산을 최소화하여 성능을 최적화하기 위해 lazy evaluation을 채택하고 있다.
- limit()이 평가 범위를 줄이면, 뒤따르는 연산은 일부 데이터만 처리한다.
- 불필요한 요소는 아예 처리하지 않고 건너뛸 수 있다.
- 평가 순서에 따라 전체 평가 여부가 달라질 수 있다 (예: sorted vs limit 위치)
정리
- Stream 연산자는 중간 연산(stateless, stateful) 과 최종 연산으로 나뉜다.
- Java Stream은 lazy evaluation 기반으로 동작하며, 최종 연산이 호출되는 시점에 전체 연산이 평가된다.
- 연산 순서에 따라 다르게 동작할 수 있고, 불필요한 연산을 피할 수 있으면 피하도록 연산 순서를 설정해야한다.
'Java' 카테고리의 다른 글
[Java] JAVA_HOME 자바 환경변수 설정 (0) | 2024.10.15 |
---|---|
[Java] 소켓 프로그래밍(Socket Programming) 예제 / ServerSocket, Socket, Thread 프로그래밍 (0) | 2024.08.09 |
[Java] 문자열 붙이기 - StringJoiner와 String.join() 알아보기 (1) | 2024.03.17 |
[Java] 깊은 복사(Deep Copy) vs 얕은 복사(Shallow Copy) (0) | 2024.03.16 |
[Java] equals()와 hashCode() / equals와 hashCode를 둘 다 재정의 해야하는 이유 (0) | 2024.03.14 |