모던 자바 인 액션 4장
모던 자바 인 액션 4장 : 스트림 소개
이제 함수형 데이터 처리부분인 스트림에 진입하였다.
4장부터 7장까지 스트림에 대한 내용으로 가득하다. 바로 출발해보자.
4.1 스트림이란 무엇인가?
스트림은 자바 8 API에 새로 추가된 기능이며 스트림을 이용하면 선언형으로 컬렉션 데이터를 처리할 수 있다.
선언형?
대표적으로 SQL이다. 데이터를 처리하는 코드 대신 질의로 표현할 수 있음을 이야기한다.
또한 스트림을 이용하면 멀티스레드 코드를 구현하지 않아도 데이터를 투명하게 병렬로 처리할 수있다.
우선 스트림을 코드로 확인해보자.
아래 예제는 저칼로리의 요리명을 반환하고, 칼로리를 기준으로 요리를 정렬하는 자바 7 코드이다.
// 저칼로리 요리 리스트 저장
List<Dish> lowCaloricDishes = new ArrayList<>();
for(Dish dist: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());
}
아래는 스트림을 이용한 코드이다.
import static java.util.Comparator.comparing;
import static java.util.stream.Collectors.toList;
List<String> lowCaloricDishesName =
menu.stream()
.filter(d -> d.getCalories() < 400)
.sorted(comparing(Dish::getCalories))
.map(Dish::getName)
.collect(toList());
위 코드에서 stream()을 parallelStream()으로 바꾸면 이 코드를 멀티코어 아키텍처에서 병렬로 실행할 수 있다.(이는 7장에서 자세히 살펴본다.)
filter (또는 sorted, map, collect) 같은 연산은 고수준 빌딩 블록으로 이루어져 특정 스레딩 모델에 제한되지 않고 자유롭게 어떤 상황에서든 사용할 수 있다.(또한 멀티코어 아키텍처를 최대한 투명하게 활용할 수 있게 구현되어있음)
아래 그림은 위에서 작성한 코드를 그림으로 나타낸다. 파이프라인을 형성하여 데이터를 처리할 수 있다.

스트림 API의 특징 요약
- 선언형 : 더 간결하고 가독성이 좋아진다.
- 조립할 수 있음 : 유연성이 좋아진다.
- 병렬화 : 성능이 좋아진다.
책에서는 아래 코드의 요리리스트, 즉 메뉴를 주요 예제로 사용한다.
static List<Dish> list = Arrays.asList(
new Dish("pork", false, 800, Dish.Type.MEAT),
new Dish("beef", false, 700, Dish.Type.MEAT),
new Dish("chicken", false, 400, Dish.Type.MEAT),
new Dish("french fries", false, 530, Dish.Type.OTHER),
new Dish("rice", true, 350, Dish.Type.OTHER),
new Dish("season fruit", true, 120, Dish.Type.OTHER),
new Dish("pizza", true, 550, Dish.Type.OTHER),
new Dish("prawns", false, 300, Dish.Type.FISH),
new Dish("salmon", false, 450, Dish.Type.FISH)
);
package ch4;
public class Dish {
private final String name;
private final boolean vegetarian;
private final int calories;
private final Type type;
public Dish(String name, boolean vegetarian, int calories, Type type) {
this.name = name;
this.vegetarian = vegetarian;
this.calories = calories;
this.type = type;
}
public String getName() {
return name;
}
public boolean isVegetarian() {
return vegetarian;
}
public int getCalories() {
return calories;
}
public Type getType() {
return type;
}
@Override
public String toString() {
return name;
}
public enum Type { MEAT, FISH, OTHER}
}
4.2 스트림 시작하기
컬렉션 스트림부터 살펴본다. 자바 8 컬렉션에는 스트림을 반환하는 stream 메서드가 추가됐다.
스트림이란 정확히 뭘까?
스트림이란 ‘데이터 처리 연산을 지원하도록 소스에서 추출된 연속된 요소`로 정의할 수 있다.
또한 스트림에는 다음과 같은 두 가지 중요한 특징이 있다.
- 파이프라이닝 :대부분의 스트림 연산은 스트림 연산끼리 연결해서 파이프라인을 구성할 수 있도록 스트림 자신을 반환한다. 덕분에 laziness, short-circuit같은 최적화도 얻을 수 있다.
- 내부 반복 : 반복자를 이용해서 명시적으로 반복하는 컬렉션과 달리 스트림은 내부 반복을 지원한다.
다른 예제코드를 보며 스트림을 이해해보자.
List<String> threeHighCaloricDishNames =
menu.stream()
.filter(dish -> dish.getCalories() > 300)
.map(Dish::getName)
.limit(3)
.collect(toList());
System.out.println(threeHighCaloricDishNames);
코드를 보면 SQL 처럼 직관적으로 이해할 수 있다.
칼로리가 300 초과인 것을 필터링하여 요리명을 추출하고 선착순 3개만 리스트로 저장한다.
각각 filter, map, limit의 파이프라인이 적용된 것을 볼 수있다.
- filter : Predicate를 인수로 받아 스트림에서 특정 요소를 제외시킨다.
- map : Function을 인수로 받아 한 요소를 다른 요소로 변환하거나 정보를 추출한다.
- limit : 정해진 개수 이상 요소가 스트림에 저장되지 못하게 스트림 크기를 축소 truncate한다.
- collect : 스트림을 다른 형식으로 변환한다.
4.3 스트림과 컬렉션
스트림과 컬렉션의 차이는 뭘까?
데이터를 언제 계산하느냐가 가장 큰 차이다.
컬렉션은 현재 자료구조가 포함하는 모든값을 메모리에 저장하는 자료구조다. 즉, 컬렉션의 모든 요소는 컬렉션에 추가하기 전에 계산되어야 한다.
반면 스트림은 요청할 때만 요소를 계산하는 고정된 자료구조다.(스트림에 요소 추가, 제거 불가)
핵심은 사용자가 요청하는 값만 스트림에서 추출한다는 것이다.
스트림은 사용자가 데이터를 요청할 때만 값을 계산해서 게으르게 만들어지는 컬렉션과 같은 반면 컬렉션은 적극적으로 팔기도 전에 창고를 가득 채우는 생산자 중심이다.
예시로 브라우저 인터넷 검색이 있다.
구글이나 온라인상점에서 검색어를 입력했다고 가정하자. 그림을 포함한 모든 결과를 내려받을 때까지 기다리지 않아도 10개 또는 20개의 결과 요소를 포함하는 스트림을 얻을 수 있다.
아래는 책에있는 기가막힌 비유 그림이다.
4.3.1 딱 한번만 탐색 가능
스트림은 요소를 탐색하며 소비한다. 따라서 다시 탐색하려면 초기 데이터 소스에서 새로운 스트림을 만들어야 한다.
List<String> title = Arrays.asList("Java8", "In", "Action");
Stream<String> s = title.stream();
s.forEach(System.out::println);
s.forEach(System.out::println); // Exception발생 (스트림이 이미 소비되거나 닫힘)
4.3.2 외부 반복과 내부 반복
컬렉션 인터페이스를 사용하면 사용자가 직접 요소를 반복해야한다. 그러나 스틀미 라이브러리는 내부 반복을 사용한다.
함수에 어떤 작업을 수행할지만 지정하면 모든 것이 알아서 처리된다.
// 컬렉션 : for-each 루프를 이용하는 외부 반복
List<String> names = new ArrayList<>();
for(Dish dish:menu){
names.add(dish.getName());
}
// 스트림 : 내부 반복
List<String> names = menu.stream()
.map(Dish::getName)
.collect(toList());
그렇다면 내부 반복은 어떤 이득이 있을까?
- 병렬성
- 순서 최적화
- 데이터 표현과 하드웨어를 활용한 병렬성 구현을 자동으로 선택
4.4 스트림 연산
List<String> names = menu.stream()
.filter(dish -> dish.getCalories() > 300)
.map(Dish::getName)
.limit(3)
.collect(toList());
스트림에서 연산을 두 그룹으로 구분할 수 있다.
- 중간 연산 : 서로 연결되어 파이프라인 형성(filter,map,limit)
- 최종 연산 : 파이프라인을 실행한 다음에 닫음(collect)
4.4.1 중간 연산
- filter나 sorted 같은 중간 연산은 다른 스트림을 반환한다. 따라서 계속 중간연산을 연결하여 질의를 만들 수 있다.
- lazy : 중간 연산을 합친 다음에 중간 연산을 최종 연산으로 한번에 처리한다. (마치 JPA의 Lazy loading과 비슷한 개념)
List<String> names = 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());
System.out.println(names);
스트림의 lazy 특성으로 인해 얻는 최적화 효과
- limit 연산, 쇼트서킷 기법 덕분에 300 칼로리가 넘는 요리는 처음 3개만 선택되었다.
- filter와 map은 서로 다른 연산이지만 한 과정으로 병합(루프 퓨전)
4.4.2 최종 연산
파이프라인에서 결과를 도출한다. 보통 최종 연산에 의해 List, Integer, void 등 스트림 이외의 결과가 반환된다.
4.4.3 스트림 이용하기
스트림 이용과정은 다음과 같이 3가지로 요약할 수 있다.
- 질의를 수행할 (컬렉션 같은) 데이터 소스
- 스트림 파이프라인을 구성할 중간 연산 연결
- 스트림 파이프라인을 실행하고 결과를 만들 최종 연산
아래 표는 지금까지 사용한 중간 연산과 최종 연산의 요약본이다.
