Stream
자바 SE 8 버전에 추가된 기능으로 Collection에 저장되어있는 데이터를 탐색하고, 선별하여 최종적인 결과를 만들어내는 과정을 좀 더 간편하게 표현할 수 있는 방법입니다.
Classes to support functional-style operations on streams of elements, such as map-reduce transformations on collections.
오라클 자바 docs에서는 컬렉션의 map에서 특정 데이터를 골라내는 것과 같은 연산을 함수스타일로 지원해주는 클래스라고 말하고있네요.
스트림은 데이터를 담고있는게 아니라, 데이터가 흐를 수 있는 통로에 해당하는 개념이며 스트림내의 모든 연산은 보통 한번만 수행되며 스트림연산을 거치더라도 원본데이터의 변화는 생기지 않습니다.
스트림을 이해하기에 앞서서 람다식과 메소드참조에 대한 내용을 알고 있으면 이해에 도움이 됩니다.
Stream 이전의 표현방법
최근에 유튜브에서 인상깊게 본 영상에서 이러한 말이 나왔습니다.
과거의 고통을 모르면 핵심을 파악할 수 없다.
자바 공부를 시작한지 오래되진 않았지만 매우 공감가는 말이기에 Stream 이전에는 어떤식으로 표현을 했길래 Stream이 등장하게 된건지 알아보았습니다.
package me.ddings.java8to11;
import java.util.ArrayList;
import java.util.List;
public class StreamDemo {
public static void main(String[] args) {
List<String> Series = new ArrayList<>();
Series.add("Sekiro: Shadows Die Twice");
Series.add("DarkSouls");
Series.add("DarkSouls2");
Series.add("DarkSouls3");
}
}
Series 리스트에서 D로 시작하는 문자열만 출력하도록 코드를 작성한다고 생각해봅시다.
for문을 순회하면서 if문으로 조건을 걸어주고, 해당 조건을 충족하면 출력하는 식으로 작성하면 되겠죠.
for (int i = 0; i < Series.size(); i++) {
String title = Series.get(i);
if(title.startsWith("D")){
System.out.println(title);
}
}
for (String title : Series) {
if(title.startsWith("D")){
System.out.println(title);
}
}
기존의 방식대로 작성하게 되면 위 두개의 코드처럼 반복문을 통해 순회하면서 조건문으로 원하는 데이터를 골라내는 방식을 사용할겁니다.
자 과거의 방식은 이정도로 알아보고 Stream 추가 이후의 모습을 살펴봅시다.
Stream 이후의 표현방법
Series.stream()
.filter(title->title.startsWith("D"))
.forEach(System.out::println);
말 그대로 함수 스타일로 연산을 수행하는 모습을 확인할 수 있습니다.
여기서 함수 스타일이란, 동일한 데이터를 넣었을 때 항상 같은 결과가 출력되는 성질을 이용하여 간결하게 표현한 것을 의미합니다.
개발자입장에서는 내부코드의 수정을 막음으로써 데이터의 안정성을 높일 수 있을 것이라 생각되며,
사용자 입장에서는 믿을 수있는 결과를 출력해내는 메소드를 이용하여 더 간편하게 코드의 작성이 가능하며 코드가 길어지면 그 체감은 더 커질 것이라 생각됩니다.
Stream의 사용법
대충 어떠한 이유때문에 스트림이 등장했는지는 살펴봤으니 사용법을 알아봅시다.
자바 docs에서 스트림을 사용할 수 있는 방법을 알려주는데, 그 방법은 다음과 같습니다.
- Collection의 stream() 이나 parallelStream() 메소드를 사용하기
- Arrays.stream( Object[] ) 를 통하여 배열에서 사용하기
- stream 클래스의 정적 팩토리 메소드를 이용하여 사용하기. ( ex : Stream.of( Object[] ), IntStream.range(int, int) ... )
- BufferedReader 클래스의 line() 메소드 이용하기
- Files 클래스의 메소드를 이용하여 파일경로를 스트림으로 얻기
- Random클래스의 ints() 메소드를 이용한 랜덤 숫자 스트림 얻기
이 외에도 더 많은 방법이 존재한다고 합니다.
parallelStream() 를 사용하면 스트림의 연산을 병렬로 처리할 수 있습니다
Stream의 동작흐름
스트림은 중간 연산과 터미널 연산이 스트림 파이프라인으로 결합되어있는 형태이며, 0개 이상의 중간연산과 하나의 터미널연산으로 구성됩니다.
중간연산
새로운 stream을 반환하며, 항상 lazy하게 동작합니다.
lazy하게 동작한다는 말은, 터미널 연산이 등장하기 전까지 중간연산이 수행되지 않는다는 것을 의미합니다.
package me.ddings.java8to11;
import java.io.BufferedReader;
import java.util.ArrayList;
import java.util.List;
import java.util.Random;
import java.util.stream.Stream;
public class StreamDemo {
private String title;
private int price;
public StreamDemo(String title, int price) {
this.title = title;
this.price = price;
}
public int getPrice() {
return price;
}
public static void main(String[] args) {
List<StreamDemo> Series = new ArrayList<>();
Series.add(new StreamDemo("Sekiro: Shadows Die Twice", 59_800));
Series.add(new StreamDemo("DarkSouls : REMASTERED", 43_800));
Series.add(new StreamDemo("DarkSouls2", 43_000));
Series.add(new StreamDemo("DarkSouls3", 49_800));
Stream<StreamDemo> streamDemoStream = Series.stream().filter((title) -> {
System.out.println("중간연산이에용!");
return title.price < 50_000;
});
}
}
터미널연산이 없기 때문에 아무것도 출력되지 않는 것을 확인할 수 있습니다.
중간연산의 경우 statless 연산과 stateful 연산으로 또 나누어집니다.
filter나 map과 같은 stateless 연산의 경우 새로운 요소를 처리할때 이전 요소의 상태를 유지할 필요가 없습니다.
반면에 distinct나 sorted와 같은 stateful 연산의 경우 새로운 요소를 처리할때 이전 요소의 상태를 유지하는 연산을 의미합니다.
터미널 연산
터미널 연산은 stream이 아닌 다른 반환값을 가지는 연산을 의미합니다.
모든 중간연산은 터미널 연산이 존재하지 않으면 실행되지 않으며, 터미널연산이 한번 수행되고나면 해당 스트림 파이파라인은 소비된 것으로 간주. 다시 사용되지 않습니다.
동일한 파이프라인을 다시 사용하기 위해서는 스트림을 통과하기 이전의 데이터를 사용하여 새로운 스트림을 만들어야합니다.
streamDemoStream.forEach(series -> System.out.println(series.getTitle()));
streamDemoStream.forEach(series -> System.out.println(series.getTitle()));
실제로 동일한 스트림을 가지고 두번의 터미널 연산을 수행하려고하면 IllegalStateException이 발생한다.
스트림의 연산들
filter( Predicate ) : 중간연산
Series.stream()
.filter(item->item.getTitle().contains("DarkSouls"))
.forEach(System.out::println);
스트림내의 각 요소에 대하여 초건에 해당하는 요소만 걸러내는 용도로 사용된다.
map( Function ) : 중간연산
Stream<StreamDemo> stream = Series.stream();
Stream<String> stringStream = Series.stream()
.map(StreamDemo::getTitle);
Function 의 결과에 따라 새로운 스트림을 생성한다.
위 코드에서는 Stream<StreamDemo> => Stream<String> 로 변경되었다.
flatMap( Function ) : 중간연산
List<StreamDemo> Series = new ArrayList<>();
Series.add(new StreamDemo("Sekiro: Shadows Die Twice", 59_800));
Series.add(new StreamDemo("DarkSouls : REMASTERED", 43_800));
Series.add(new StreamDemo("DarkSouls2", 43_000));
Series.add(new StreamDemo("DarkSouls3", 49_800));
List<StreamDemo> Series2 = new ArrayList<>();
Series2.add(new StreamDemo("Don't Starve Together", 8_000));
List<List<StreamDemo>> combine = new ArrayList<>();
combine.add(Series);
combine.add(Series2);
combine.stream()
.flatMap(c->c.stream())
.forEach(System.out::println);
Collection안에 쌓여있는 Collection 껍질을 까내는 연산이다.
즉, combine에 스트림연산을 수행하지 않았을 경우, List<StreamDemo> 의 형태로 Series와 Series2가 들어있었다면
연산을 수행하고 나면 Series의 모든 요소와 Series2의 모든 요소를 꺼내버린다.
forEach( Consumer ) : 터미널 연산
Series.stream()
.filter(item -> item.getTitle().startsWith("Dark"))
.forEach(System.out::println);
스트림 내의 요소들을 순회하며 Consumer 연산을 수행하는 연산이다.
말 그대로 forEach문의 역할을 수행한다.
generate( Supplier )
Stream.generate(()->10)
.limit(10L)
.forEach(System.out::println);
스트림을 생성해내는 연산
lmit이 없으면 무한반복한다.
iterate( T seed, UnaryOperator<T> f )
List<Long> collect = Stream.iterate(10L, i -> i + 5L)
.limit(10L)
.skip(5L)
.collect(Collectors.toList());
스트림을 생성해내는 연산.
seed는 시작위치를 나타내며, limit까지 반복하여 UnaryOperator연산을 수행한다.
이 외의 연산들은 java.util.stream에서 확인해가며 사용하자!
출처
- docs.oracle.com/javase/8/docs/api/java/util/stream/package-summary.html#package.description