컬렉션을 그룹화한다든가 최댓값을 찾는 등의 연산에서 스트림
을 이용하면 코드를 더 간결하게 만들 수 있다.
스트림이란 무엇인가?
스트림을 이용하면 멀티스레드 코드를 구현하지 않아도 데이터를 투명하게 병렬로 처리할 수 있다. 이번에는 메뉴를 구성하는 요리 컬렉션에서 저칼로리 요리명을 반환하고, 칼로리를 기준으로 요리를 정렬하는 코드를 살펴보자.
List<Dish> lowCaloricDishes = new ArrayList<>();
for (Dish dish : menu) { //누적자로 요소 필터링
if (dish.getCalories() < 400) {
lowCaloricDishes.add(dish);
}
}
Collections.sort(lowCaloricDishes, new Comparator<Dish>() {
public int compare(Dish dish1, Dish dish2) { //익명 클래스로 요리 정렬
return Integer.compare(dish1.getCalories(), dish2.getCalories());
}
});
List<String> lowCaloricDishesName = new ArrayList<>();
for (Dish dish : lowCaloricDishes) {
lowCaloricDishesName.add(dish.getName()); //정렬된 리스트를 처리하면서 요리 이름 선택
}
위의 코드에서 lowCaloricDishes
는 컨테이너 역할만 하는 중간 변수, 즉 가비지 변수 이다. 자바 8을 이용해 코드를 개선해보자.
List<String> lowCaloricDishesName =
menu.stream().filter(d -> d.getCalories() < 400)
.filter(d -> d.getCalories() < 400) //400칼로리 이하의 요리 선택
.sorted(comparing(Dish::getCalories)) //칼로리로 요리 정렬
.map(Dish::getName) //요리명 추출
.collect(toList()); //모든 요리명을 리스트에 저장
두 코드를 비교해보면 stream API의 세 가지 특징을 알 수 있다.
- 선언형 : 더 간결하고 가독성이 좋아진다.
- 조립할 수 있음 : 유연성이 좋아진다.
- 병렬화 : 성능이 좋아진다.
이제 숫자 스트림을 만들고 조작하는 방법을 알아보자.
스트림 시작하기
스트림에는 다음과 같은 두 가지 중요 특징이 있다.
- 파이프라이닝 : 대부분의 스트림 연산은 스트림 연산끼리 연결해서 커다란 파이프라인을 구성할 수 있도록 스트림 자신을 반환한다.
- 내부 반복 : 반복자를 이용해서 명시적으로 반복하는 컬렉션과 달리 스트림은 내부 반복을 지원한다.
List<String> threeHighCaloricDishNames =
menu.stream() //메뉴에서 스트림 얻기
.filter(d -> d.getCalories() > 300) //고칼로리 요리 필터링
.map(Dish::getName) //요리명 추출
.limit(3) //세 개만 선택
.collect(toList())); //결과를 다른 리스트로 저장
이 코드에서 filter, map, limit, collect는 각각 다음과 같은 연산을 한다.
filter
: 람다를 인수로 받아 스트림에서 특정 요소를 제외시킨다.map
: 람다를 이용해서 한 요소를 다른 요소로 변환하거나 정보를 추출한다.limit
: 정해진 개수 이상의 요소가 스트림에 저장되지 못하게 스트림 크기를 축소한다.collect
: 스트림을 다른 형식으로 변환한다. (ex. 스트림 -> 리스트)
컬렉션과 스트림
컬렉션과 스트림의 차이를 알아보자. DVD
에 어떤 영화가 저장되어 있다면 DVD에 전체 자료구조가 저장되어 있으므로 DVD도 컬렉션이다. 하지만, 인터넷 스트리밍
으로 같은 비디오를 시청한다면? 스트리밍, 즉 스트림이다. 스트리밍으로 재생할 때는 사용자가 시청하는 부분의 프레임만 미리 내려받는다.
즉, 데이터를 언제 계산하느냐가 컬렉션과 스트림의 가장 큰 차이다.
- 컬렉션 : 현재 자료구조가 포함하는 모든 값을 메모리에 저장하는 자료구조, 모든 요소는 추가 전 계산되어야 함
- 스트림 : 요청할 때만 요소를 계산하는 고정된 자료구조, 요청하는 값만 스트림에서 추출하는 것, 게으르게 만들어지는 컬렉션
딱 한 번만 탐색 가능
컬렉션과 달리 스트림의 탐색된 요소들은 소비되는 것이므로, 한 번만 탐색할 수 있다 한 번 탐색한 요소를 다시 탐색하려면 초기 데이터 소스에서 새로운 스트림을 만들어야 한다.
외부반복과 내부반복
컬렉션은 (for-each등을 사용해서) 사용자가 직접 요소를 반복해야 하는데, 이를 외부 반복 이라고 한다.
반면, 스트림은 반복을 알아서 처리하고 결과를 어딘가에 저장해주는 내부 반복 을 사용한다.
//for-each를 사용하는 컬렉션의 외부반복
List<String> names = new ArrayList<>();
for (Dish dish : menu) { //메뉴 리스트를 명시적으로 순차 반복
names.add(dish.getName()); //이름을 추출해서 리스트에 추가
}
//스트림의 내부반복
List<String> names = menu.stream()
.map(Dish::getName) //map 메서드를 getName으로 파라미터화해서 요리명 추출
.collect(toList()); //파이프라인을 실행, 반복자 필요 X
내부 반복을 이용하면 작업을 투명하게 병렬로 처리하거나 더 최적화된 다양한 순서로 처리할 수 있다. 또한, for-each를 이용하는 외부 반복에서는 병렬성을 스스로 관리 하는 반면, 내부 반복은 데이터 표현과 하드웨어를 활용한 병렬성 구현을 자동으로 선택 한다. 하지만, 이러한 내부반복의 이점을 누리려면 반복을 숨겨주는 연산 리스트(filter
, map
등)가 미리 정의되어 있어야 할 것이다. 이제 자바가 제공하는 스트림 연산에 대해 알아보자.
스트림 연산
List<String> names = menu.stream() //메뉴에서 스트림 얻기
.filter(d -> d.getCalories() > 300) //중간 연산
.map(Dish::getName) //중간 연산
.limit(3) //중간 연산
.collect(toList())); //스트림을 리스트로 변환
앞에서 봤던 예제에서 사용한 연산들을 크게 두 그룹으로 구분할 수 있다.
filter
,map
,limit
는 서로 연결되어 파이프라인을 형성한다.collect
로 파이프라인을 실행한 다음 닫는다.
이 때, 연결할 수 있는 그룹 1
의 연산을 중간 연산 , 스트림을 닫는 그룹 2
의 연산을 최종 연산 이라고 한다.
중간 연산
중간 연산은 다른 스트림을 반환하기 때문에 중간 연산을 연결해서 질의를 만들 수 있다. 중간 연산의 중요한 특징은 단말 연산을 파이프라인에 실행하기 전까지는 아무 연산도 수행하지 않는다는 것, 즉 게으르다 는 것이다. 중간 연산들은 합친 다음, 최종 연산으로 한 번에 처리한다.
최종 연산
최종 연산은 스트림 파이프라인에서 결과를 도출한다. 보통 이 때 List
, Integer
, void
등이 반환된다.
스트림 이용하기
스트림 이용 과정은 다음 세 단계로 정리할 수 있다.
- 질의를 수행할
컬렉션과 같은데이터 소스 - 스트림 파이프라인을 구성할 중간 연산 연결
- 스트림 파이프라인을 실행하고 결과를 만들 최종 연산