[Java8] 스트림 (Stream)
기존의 Java에서 컬렉션 데이터를 처리할때 특정 조건에 따라 필터링을 하려면 복잡한 과정을 거쳐야 했습니다.
반면에 SQL 문법의 경우 사용자가 원하는 조건의 데이터 목록을 검색할때 명시적이고 간단한 방법을 이용했는데 Java8 에서 새로 추가된 기능인 스트림은 Java의 컬렉션 데이터에 대해 SQL 질의문 처럼 데이터를 처리할수 있는 기능을 가지고 있습니다.
스트림(Stream)이란??
기존 루프문 처리의 문제점
기존 Java에서 컬렉션 데이터를 처리할때는 for, foreach 루프문을 사용하면서 컬렉션 내의 요소들을 하나씩 다루었습니다. 간단한 처리나 컬렉션의 크기가 작으면 큰 문제가 아니지만 복잡한 처리가 필요하거나 컬렉션의 크기가 커지면 루프문의 사용은 성능저하를 일으키게 되었습니다.
스트림의 등장
스트림은 Java8에서 추가된 기능으로 컬렉션 데이터를 선언형으로 쉽게 처리할수 있습니다. 복잡한 루프문을 사용하지 않아도 되며 루프문을 중첩해서 사용해야 되는 최악의 경우도 더이상 없어졌습니다.
또한 스트림은 병렬처리(Multi thread)를 별도의 멀티스레드 구현없이도 쉽게 구현할수 있습니다.
사과 필터를 예제로 스트림을 사용하지 않은 방법과 사용한 방법의 코드 차이점을 확인 해보겠습니다.
- 빨간색 사과 필터
- 무게순서대로 정렬
- 사과들의 고유번호 출력
[스트림 사용 X]
스트림을 사용하지 않을때는 각 필터링 단계마다 코드를 작성해야 합니다.
// 빨간색 사과 필터링
List<Apple> redApples = forEach(appleList, (Apple apple) -> apple.getColor().equals("RED"));
// 무게 순서대로 정렬
redApples.sort(Comparator.comparing(Apple::getWeight));
// 사과 고유번호 출력
List<Integer> redHeavyAppleUid = new ArrayList<>();
for (Apple apple : redApples)
redHeavyAppleUid.add(apple.getUidNum());
[스트림 사용 O]
스트림을 사용하여 단 한줄로 표현 할수 있습니다.
List<Integer> redHeavyAppleUid = appleList.stream()
.filter(apple -> apple.getColor().equals("RED")) // 빨간색 사과 필터링
.sorted(Comparator.comparing(Apple::getWeight)) // 무게 순서대로 정렬
.map(Apple::getUidNum).collect(Collectors.toList()); // 사과 고유번호 출력
또한 스트림은 paralleStream 메서드를 통해 별도의 멀티스레드 구현 없이도 병렬처리가 가능합니다.
List<Integer> redHeavyAppleUid = appleList.parallelStream() // 병렬 처리
.filter(apple -> apple.getColor().equals("RED")) // 빨간색 사과 필터링
.sorted(Comparator.comparing(Apple::getWeight)) // 무게 순서대로 정렬
.map(Apple::getUidNum).collect(Collectors.toList()); // 사과 고유번호 출력
예제에서 사용한 filter, sorted, map 같은 함수들은 Steam API 에서 제공하는 함수들입니다.
스트림 API의 특징 정리
- 선언형 : 더 간결하고 가독성이 좋아진다.
- 함수의 조립 : 유연성이 좋아진다.
- 병렬화 : 성능이 좋아진다.
스트림(Stream) 시작해보기
이제 본격적으로 스트림을 학습하기에 앞서서 예제에서 사용할 Collection을 미리 정의하겠습니다.
List
메뉴명을 보면 눈치채신분들도 있겠지만 제가 좋아하는 프로인 헬스키친에서 나오는 메뉴들입니다 ㅎㅎ
List<Food> foodList = new ArrayList<>();
foodList.add(new Food("FlatBread",true,400,Food.Type.OTHER));
foodList.add(new Food("OnionSoup",true,300,Food.Type.OTHER));
foodList.add(new Food("LobsterRisotto",false,520,Food.Type.FISH));
foodList.add(new Food("CaesarSalad",true,200,Food.Type.OTHER));
foodList.add(new Food("BeefWellington",false,670,Food.Type.MEAT));
foodList.add(new Food("FiletMignon",false,600,Food.Type.MEAT));
foodList.add(new Food("CrispySalmon",false,620,Food.Type.FISH));
foodList.add(new Food("StripSteak",false,740,Food.Type.MEAT));
foodList.add(new Food("SearedScallops",false,340,Food.Type.FISH));
Food.java
import java.lang.reflect.Type;
public class Food {
public enum Type {
MEAT,
FISH,
OTHER
}
private final String name;
private final boolean isVegetarian;
private final int calories;
private final Type type;
public Food(String name, boolean isVegetarian, int calories, Type type) {
this.name = name;
this.isVegetarian = isVegetarian;
this.calories = calories;
this.type = type;
}
public String getName() {
return name;
}
public boolean isVegetarian() {
return isVegetarian;
}
public int getCalories() {
return calories;
}
public Type getType() {
return type;
}
}
스트림의 정의
위에서도 한번 설명 했었지만 스트림이란 '데이터 처리연산을 지원하도록 소스에서 추출된 연속된 요소' 로 정의 할수 있습니다. 정의의 내용을 하나씩 살펴보겠습니다.
연속된 요소 (Sequence of element)
컬렉션 자료구조와 마찬가지로 스트림은 특정 요소 형식으로 이루어진 연속된 값 집합의 인터페이스를 제공합니다. 컬렉션에서는 시간, 공간의 복잡성과 관련된 요소 저장 및 연산이 이루어진다면 스트림에서는 filter, sorted, map 처럼 표현 계산식으로 이루어져 있습니다.
즉 컬렉션의 주제는 데이터, 스트림의 주제는 계산입니다.
소스 (Source)
스트림은 컬렉션, 배열, I/O 자원등의 소스로부터 데이터를 소비하고 정렬된 컬렉션으로 스트림을 생성하면 정렬이 그대로 유지됩니다. 즉 리스트로 스트림을 만들면 스트림의 요소는 리스트의 요소와 같은 순서를 유지합니다.
데이터 처리 연산
스트림은 함수형 프로그래밍에서 지원하는 연산과 데이터베이스의 SQL 질의형과 비슷한 연산을 처리할수 있습니다. filter, sort, map, match 등으로 데이터를 조작할수 있고 순차적 혹은 병렬로 실행할수 있습니다.
파이프라이닝과 내부 반복
또한 스트림에는 파이프라이닝, 내부 반복 이라는 중요한 특징이 있습니다.
파이프라이닝 (Pipelining)
스트림 연산들은 서로 연결하여 큰 파이프 라인을 구성할수 있도록 스트림 자신을 반환합니다. 데이터 소스에 적용하는 데이터베이스 질의문과 비슷합니다.
내부 반복
반복자를 이용하여 명시적으로 반복하는 컬렉션과 다르게 스트림은 내부 반복 기능을 제공합니다.
지금까지 설명한 내용을 바탕으로 예제 코드를 만들어 보겠습니다.
List<String> highCaloriesFoodName = foodList.stream()
.filter(food -> food.getCalories() > 400)
.map(Food::getName)
.limit(3)
.collect(Collectors.toList());
System.out.println(highCaloriesFoodName);
위 코드에서 steram() 함수를 통해 foodList 라는 소스(Source) 로부터 연속된 요소를 얻어 스트림을 만들고 해당 스트림에 Stream API 함수인 filter, map, limit, collect 로 이어지는 데이터 처리연산을 적용합니다.
collect를 제외한 filter, map, limit 연산은 파이프라인을 형성할수 있도록 스트림을 반환하는데 파이프라인은 데이터 베이스의 SQL 질의문과 같은 존재입니다.
마지막으로 collect 연산으로 파이프라인을 처리하여 결과를 반환합니다. 단, collect는 스트림이 아닌 List를 반환합니다.
스트림과 컬렉션의 차이점
스트림과 컬렉션은 둘다 연속된 요소형식의 값을 저장하는 자료구조 인터페이스를 제공합니다. 그렇다면 컬렉션과 스트림은 어떤 차이점이 존재할까요??
HDD에 저장된 영상과 유튜브 영상으로 비교할수 있습니다. HDD에 저장된 영상파일의 경우 내가 보고싶은 지점을 클릭하면 바로 재생되지만 유튜브같은 온라인 스트리밍 동영상 같은 경우는 클릭한 재생 지점 근처만 로딩이 되고 다른 부분을 클릭하면 그때마다 데이터를 다시 읽어서 재생하는 방식입니다.
데이터 계산 시점
로컬 저장소의 영상과 유튜브 온라인 영상의 차이처럼 스트림과 컬렉션의 차이는 데이터를 계산하는 시점입니다.
- 컬렉션 : 모든 요소는 컬렉션에 추가하기전에 계산되어야 한다.
- 스트림 : 요청할때만 요소를 계산하는 고정된 자료구조
사용자가 요청하는 값만 추출할수 있는 특성때문에 스트림은 컬렉션보다 프로그래밍에 장점이 있습니다.
예를들어 소수 집합을 만들고 사용자가 특정 위치의 소수를 알고싶다고 할때 컬렉션의 경우 소수집합을 만드는 과정에서 무한루프에 빠져버리기 때문에 사용자는 원하는것을 얻지 못할것입니다. 반면 스트림의 경우 사용자가 요청할때만 값을 계산하므로 이러한 문제에 유연하게 대처할수 있습니다.
반복의 일회성
컬렉션과 스트림은 반복처리를 할때도 차이가 있습니다. 컬렉션의 경우 같은 소스에 대하여 여러번 반복 처리를 할수 있지만 스트림은 단 한번만 반복문을 처리할수 있습니다. 스트림에서는 소비(Consumer) 개념을 쓰기 때문에 한번 소비한 요소에 대해서 접근할수 없기 때문입니다.
Stream<Food> s = foodList.stream();
s.forEach(System.out::println); // 정상
s.forEach(System.out::println); // IllegalStateException 발생
만약 위 코드를 실행한다면 stream has already been operated upon or closed 라는 에러와 함께 프로그램이 중단됩니다.
외부반복, 내부반복
컬렉션의 경우 foreach 문법을 사용하여 사용자가 반복문을 직접 명시해야 하는데 이를 외부반복 이라 하고 스트림은 라이브러리를 사용하는 내부반복 개념입니다. 스트림은 별도의 반복자 없이도 반복문을 처리할수 있습니다. 스트림이 사용하는 내부반복의 장점은 작업을 병렬로 처리할수 있고 더 최적화된 다양한 순서로 처리할수 있다는 점입니다.
Food 리스트의 이름들을 추출하는 코드를 컬렉션과 스트림 두가지 방법으로 구현해보겠습니다.
[컬렉션]
List<String> foodNameList = new ArrayList<>();
for(Food food : foodList){
foodNameList.add(food.getName());
}
[스트림]
List<String> foodNameList = foodList.stream()
.map(Food::getName)
.collect(Collectors.toList());
스트림 연산
java.util.stream.Stream 에는 스트림 API에서 제공하는 여러가지 연산이 정이 되있는데 스트림 연산들은 크게 중간연산, 최종연산으로 구분할수 있습니다.
- 중간연산 : 파이프라인으로 연결할수 있는 연산들
- 최종연산 : 파이프라인을 실행한다음 닫는 연산
List<String> highCaloriesFoodName = foodList.stream()
.filter(food -> food.getCalories() > 400) // 중간연산
.map(Food::getName) // 중간연산
.limit(3) // 중간연산
.collect(Collectors.toList()); // 최종연산
위 예제코드에서 보면 filter, map, limit은 중간연산이고 collect는 최종연산 입니다.
중간연산
filter나 map 같은 중간연산은 다른 스트림을 반환하기 때문에 여러개의 중간연산을 연결하여 질의를 만들수 있습니다. 중요한 특징은 최종연산을 실행하기 전까지는 아무 연산도 수행하지 않는다는것입니다.
스트림 파이프라인에서 연산이 어떻게 진행되는지 확인해보기 위해 연산에 출력문을 넣어서 확인 해보겠습니다.
List<String> highCaloriesFoodName = foodList.stream()
.filter(food -> {
System.out.println("filter : " + food.getName());
return food.getCalories() > 400;
})
.map(food -> {
System.out.println("map : " + food.getName());
return food.getName();
})
.limit(3)
.collect(Collectors.toList());
System.out.println(highCaloriesFoodName);
filter : FlatBread
map : FlatBread
filter : OnionSoup
filter : LobsterRisotto
map : LobsterRisotto
filter : CaesarSalad
filter : BeefWellington
map : BeefWellington
[FlatBread, LobsterRisotto, BeefWellington]
OnionSoup, CaesarSalad는 filter에서 필터링 되었기 때문에 map에서는 찍히지 않고 나머지 음식들이 최종연산되어 출력되는것을 확인할수 있습니다.
최종연산
최종연산은 파이프라인 연산의 결과를 출력합니다. 예제에서는 List 형태로만 결과를 받았지만 이 외에도 Integer, void 등 다양한 형태로 출력할수 있습니다.
내용 정리
스트림을 사용하는 단계는 다음과 같이 3단계에 걸쳐서 진행됩니다.
- 질의를 수행할 데이터소스 (ex 컬렉션)
- 스트림 파이프라인을 구성할 중간 연산
- 스트림 연산을 실행하고 결과로 출력할 최종연산
'개발 언어 > Java' 카테고리의 다른 글
Java String 자르기, 분리, 합치기 (substring, split, join) (0) | 2023.02.10 |
---|---|
[Java] String, JsonNode 상호 변환하기 (0) | 2022.04.26 |
[Java] 시간 측정하기 System currentTimeMillis (0) | 2021.12.24 |
[Java] 람다표현식의 조합과 Comparator, Predicate, Function 디폴트 메서드(Default method) (0) | 2021.12.16 |
[Java] 메서드 참조(Method reference) - 정적 메서드, 생성자 참조와 람다표현식 활용 (0) | 2021.12.15 |
댓글