모던 자바 인 액션 - Chapter 4 <스트림 소개>
스트림이란 무엇인가?
스트림은 자바8에서 새롭게 추가된 기능입니다. 스트림을 이용하면 선언형으로 컬렉션 데이터를 처리할 수 있습니다.또한 이를 활용하면 멀티스레드 코드를 구현하지 않아도 병렬 처리가 가능합니다.아래 예제를 통해 스트림을 사용하는 예를 알아봅시다.예제는 다양한 요리 중 400칼로리보다 적은 요리들을 칼로리를 기준으로 정렬하고 해당 요리의 요리명을 새로운 리스트에 담는 예제입니다.먼저 기존이 자바 코드를 이용해 구현하겠습니다.
@Test
void java7() {
List<Dish> lowCaloricDishes = new ArrayList<>();
for (Dish d : menu) {
if (d.getCalories() < 400) {
lowCaloricDishes.add(d);
}
}
lowCaloricDishes.sort((Dish d1, Dish d2) -> Integer.compare(d1.getCalories(), d2.getCalories()));
List<String> lowCaloricDishesName = new ArrayList<>();
for (Dish dish : lowCaloricDishes) {
lowCaloricDishesName.add(dish.getName());
}
System.out.println("lowCaloricDishesName = " + lowCaloricDishesName);
}
해당 코드의 경우 lowCaloricDishes
라는 가비지 변수를 사용합니다.즉,연산의 중간 과정에서만 필요한 변수입니다.컨테이너의 역할을 수행하기만 합니다.아래의 스트림을 사용하는 예제에서는 이를 라이브러리 내에서 처리해줍니다.
이제 해당 코드를 스트림을 활용하는 코드로 바꿔봅시다.
@Test
void streamTest() {
List<String> lowCaloricDishesName = menu.stream()
.filter(d -> d.getCalories() < 400)
.sorted((d1, d2) -> Integer.compare(d1.getCalories(), d2.getCalories()))
.map(Dish::getName)
.collect(Collectors.toList());
System.out.println("lowCaloricDishesName = " + lowCaloricDishesName);
}
위와 같은 코드를 사용할 경우 책의 표현을 빌리자면 선언적으로 코드를 구현할 수 있는 장점이 존재합니다.이러한 설명은 제 생각에는 로직이 명확하게 어떠한 동작을 하는지 파악하기가 기존의 코드보다 더 쉬워진다고 생각합니다.왜냐하면 동작을 기준으로 함수를 호출하고 로직을 작성할 수 있기 때문입니다.
스트림의 내부 로직을 조금 더 알아보면 filter() 메서드의 결과가 sorted() 메서드로 sorted()의 결과는 map()으로 연결되며 이어집니다.이렇게 메서드들을 연결해서 복잡한 데이터 처리 파이프라인을 만들 수 있었다.
스트림이 무엇인지 간단한 예제를 통해 알아보았습니다.
스트림 시작하기
우선 스트림의 정의부터 살펴보겠습니다.java8에서는 컬렉션의 메서드로 스트림 객체를 반환하는 stream()이 제공됩니다.이러한 스트림의 정의는 데이터 처리 연산을 지원하도록 소스에서 추출된 연속된 요소로 정의 가능합니다.이 정의를 하나씩 이해해봅시다.
- 연속된 요소 : 컬렉션과 동일하게 스트림은 특정 요소 형식(인스턴스의 타입)으로 이루어진 연속된 값 집합의 인터페이스를 제공합니다.컬렉션을 자료구조이므로 시간 및 공간 복잡도와 관련된 요소들의 저장 및 데이터에 대한 접근 연산이 주 목적이다.반면 스트림은 앞서 본 filter, map, sorted 와 같은 표현 계산식을 주 목적으로 사용합니다.쉽게 말해 컬렉션은 자료구조임으로 자료구조 내의 데이터가 주 관심사이고 스트림은 해당 데이터를 가지고 계산하는것에 초점이 더 맞춰져있습니다.
- 소스 : 스트림은 컬렉션,배열,IO 자원 등에서 제공해주는 데이터 소스를 제공 받고 이를 소비하는 연산입니다.특히 정렬된 리스트의 데이터를 소비하여 연산하면 정렬이 그대로 유지된 채 연산합니다.즉,리스트로 스트림을 만들면 스트림의 요소는 임의의 조작을 가하지 않는 한 리스트의 요소와 같은 순서를 유지합니다.
- 데이터 처리 연산 : 스트림은 함수형 프로그래밍에서 일반적으로 지원하는 연산을 지원합니다.예를 들어 filter,map,sorted 등과 같은 기능들입니다.
또한 스트림의 중요한 특징이 두가지 있습니다.
- 파이프라이닝 : 대부분의 스트림 연산들은 결과값을 또 다른 스트림 연산에게 전달할 수 있도록 스트림 자신을 반환합니다.
- 내부 반복 : 반복자를 이용해서 명시적으로 반복하는 컬렉션과 달리 스트림은 내부 반복을 지원합니다.
지금까지의 이론적인 내용은 예제를 통해 알아보겠습니다.
우선 menu라는 stream()이라는 메서드를 호출하여 스트림 객체를 얻어옵니다.여기서 데이터 소스는 Dish 객체가 요소로 들어가 List입니다.다음으로 filter,map,limit,collect로 이어지는 일련의 데이터 처리 연산을 적용합니다.collect 연산을 제외한 모든 연산은 서로 파이프라인을 형성할 수 있도록 stream 객체를 반환합니다.마지막으로 호출되는 collect() 함수는 파이프라인을 처리하여 결과를 반환한다.현재 로직 기준으로는 List를 반환합니다.즉,스트림이라는 객체를 다른 형식으로 변환해주는 기능을 수행해줍니다.
위 예제를 실행 시켜보면 아래와 같이 결과가 나옵니다.
menu라는 리스트에서 정렬된 순서와 동일하게 스트림 연산의 결과 정렬된 것을 확인할 수 있습니다.
스트림과 컬렉션
자바의 기존 컬렉션과 새로운 스트림 모두 연속된 요소 형식의 값을 저장하는 인터페이스를 제공합니다.여기서 연속된다는 것은 순차적으로 해당 데이터들에 접근한다는 것입니다.그러면 지금부터는 컬렉션과 스트림의 차이점에 대해서 알아보겠습니다.
교재와 마찬가지로 영화를 예시로 설명하겠습니다.컬렉션은 영화를 DVD에 저장하는 것이라고 생각하시면 됩니다.즉,전체 데이터를 자료구조 안에 미리 저장되어있다고 생각하시면 됩니다.반면 스트림은 저희가 유튜브로 영상을 보는것처럼 사용자가 시청하는 부분의 일정량의 프레임만을 미리 다운로드합니다.(유튜브 볼 때의 회색영역을 생각하시면 됩니다)이러한 구조의 경우 스트림의 다른 대부분의 영역을 처리하지 않아도 미리 내려받은 프레임을 재생할 수 있습니다.
즉,데이터를 언제 계산하는지가 기존의 컬렉션과 스트림간의 가장 큰 차이점입니다.컬렉션은 현재 자료구조가 포함하는 모든 값을 메모리에 저장하는 자료구조입니다.즉,컬렉션의 모든 요소들은 미리 계산되어야하고 이후 컬렉션에 추가 된다는 것입니다.반면 스트림은 이론적으로 요청할 때만 요소를 계산하는 고정된 자료구조입니다.쉽게 말하면 Lazy하게 데이터의 연산을 수행할 수 있다는것을 의미합니다.
지금부터 스트림과 컬렉션의 차이점을 알아보겠습니다.
List<String> title = ArrayList.asList("This","is","stream")
Stream<String> stream = title.stream();
stream.forEach(System.out::println)
stream.forEach(System.out::println)
위 예제를 실행시킬 경우 스트림은 내부에 존재하는 요소를 출력하는 로직에 소비하고 끝나버립니다.즉,스트림은 내부의 데이터를 단 한번만 소비할 수 있다는 특징을 가집니다.소비라는 범주에는 탐색부터 연산까지 해당 데이터에 접근하는 경우를 모두 포함합니다.
그 다음으로 스트림만의 특징은 내부 반복을 활용한다는 것입니다.
기존의 컬렉션은 외부반복을 활용하여 사용자가 직접 요소를 반복시켰습니다.반면에 스트림 라이브러리는 내부 반복을 활용합니다.컬렉션은 명시적으로 컬렉션의 요소를 하나씩 가져와서 처리합니다.반면 내부 반복은 작업을 더욱 최적화된 순서로 진행시킬 수 있습니다.쉽게 말하면 filter을 통해 얻어낸 스트림 객체를 통해 또 다른 연산을 파이프라이닝할 수 있습니다.즉,사용자가 원하는 최적의 순서로 연산을 시킬 수 있습니다.
스트림 연산
중간 연산
중간 연산이란 쉽게 말해 filter나 sorted와 같이 다른 스트림을 반환합니다.따라서 여러 중간 연산들을 연결해서 파이프라인을 생성할 수 있습니다.이러한 중간 연산의 가장 중요한 특징은 게으르다는것입니다.쉽게 말해 중간 연산을 합친 다음에 합쳐진 중간 연산을 최종 연산으로 한번에 처리하기 때문입니다.
최종 연산
최종 연산은 스트림 파이프라인에서 결과를 도출합니다.최종 연산에 의해 List,void 등의 스트림 객체 이외의 결과가 반환됩니다.예를 들어 foreach() 연산의 경우 인자로 들어오는 람다롤 스트림 요소에 적용 시킨 다음 void를 리턴시키는 최종 연산입니다.저희가 사용했던 최종 연산은 collect()가 있습니다.