Java Stream API Basic 1-1
들어가며
이전 버전과 비교하여 자바8에서는 상당히 많은 내용들이 추가되었다. 그 중 가장 도드라지게 많이 사용되는 것이 Stream API
일 것이다.
이번 장에서는 Stream API
를 활용하기에 앞서 간단하게 개념을 살펴보고, 기존에 많이 사용하던 Collection
과 비교하며 어떤 차이점을 갖고있는지 살펴보자.
Stream (숲)
선언형 데이터 처리
Stream
을 사용하면 데이터를 선언형
으로 처리할 수 있다. 여기서 선언형
이란 의미가 와닿지 않는다면 데이터를 ‘How(어떻게)’ 처리하는지를 기술하는게 아니라 What(무엇)
을 하는지를 기술하는 것이라고 이해하면 쉽다.
이를테면
numberElements.stream().filter(eachNumber -> eachNumber > 10) ... |
위와 같은 코드는 “숫자 요소들 중에서 10 이상인 요소들만 걸러내라
“와 같이 What(무엇)
을 하는지를 표현한다.
반면
for (eachNumber : numberElements) { | |
if (eachNumber > 10) { | |
result.add(eachNumber); | |
} | |
} |
위와 같은 코드는 “숫자 요소들을 순회하며 10 이상인 요소인지 검사하고, 만족한다면 결과 리스트에 해당 요소를 추가하고, 만족하지 않는다면 아무것도 하지 않는다.
“와 같이 Filtering(what)
을 위해서 어떻게(How)
를 해야하는지를 자세히 기술한다.
선언형
으로 처리되는 코드는 What
을 표현하기 때문에 보다 간결하고 직관적이다. 다시말해 높은 가독성
을 가진 코드를 작성할 수 있다.
손쉬운 파이프라이닝
Stream
의 각 중간연산(filter
, map
등)은 서로 연결 및 조립하여 복잡한 데이터 처리를 위한 파이프라인을 만들 수 있다.
Stream이란 (나무-1)
자바8에서 부터 Collection
에는 Steam
을 반환하는 .stream()
오퍼레이션이 추가되었다.
Stream이란 ‘데이터 처리 연산’을 지원하도록 ‘소스’에서 추출된 ‘연속된 요소(Sequence of elements)’ 로 정의할 수 있다.
아래에서 연속된 요소
, 소스
, 데이터 처리 연산
이 구체적으로 무엇을 의미하는지 하나씩 살펴보자.
연속된 요소
Collection
과 마찬가지로 Stream
은 특정 타입으로 이루어진 연속된 값 집합의 인터페이스
를 제공한다.
다만 Collection
의 경우 목적이 데이터를 저장하는 자료구조
이기 때문에 요소의 저장 및 삭제(add/put, remove 등)
과 접근(get 등)
이 주를 이룬다.
반면 Stream
의 경우 목적이 데이터 처리를 위한 연산
이기 때문에 filter
, sorted
, map
처럼 계산식(연산)
이 주를 이룬다.
소스
Stream
은 Collection, Array, I/O 자원 등의 데이터 제공 소스로부터 데이터를 ‘소비(Consume)’한다. 만일 정렬된 컬렉션으로부터(소스) 스트림을 생성하면 정렬이 그대로 유지된다.
데이터 처리 연산
Stream
은 함수형 프로그래밍 언어에서 일반적으로 지원하는 연산과 데이터베이스와 비슷한 연산을 지원한다. 이를테면 filter
, map
, reduce
, find
, match
, sort
등으로 데이터 처리가 가능하다. 스트림 연산은 순차적 또는 병렬적으로 실행할 수 있다.
Stream의 두 가지 중요한 특징(나무-2)
파이프라이닝(Pipelining)
대부분의 스트림 연산은 스트림 연산끼리 연결해서 커다란 파이프라인을 구성
할 수 있도록 스트림 자신을 반환
한다. 그 덕분에 게으른 연산
과 쇼트서킷
같은 최적화
도 얻을 수 있다.
내부 반복
반복자(Iterator 등
)을 이용하여 명시적으로 반복하는 Collection
과는 달리 Stream은 내부 반복을 지원
한다.
예시를 통한 이해
List<String> threeHighCaloricDishNames = | |
menu.stream() // '데이터 소스'인 menu로부터 stream을 얻음 | |
.filter(dish -> dish.getCalories() > 300) // '데이터 처리 연산' filter를 적용 | |
.map(Dish::getName) // '데이터 처리 연산' map을 적용 | |
.limit(3) // '데이터 처리 연산' limit을 적용 | |
.collect(toList()); // 파이프라인 처리를 위한 최종 연산 |
위 Stream
연산을 살펴보면
menu.stream()
을 통해서데이터 소스
인menu
로 부터Stream
을 얻는다.filter
,map
,limit
에 이르기 까지데이터 처리 연산
을 조립하여파이프라인
을 구성한다.- 최종 연산인
collect
을 통해파이프라인 처리
를 진행하여 결과를 반환한다. 여기서 중요한 점은, ‘collect’가 호출되기 전 까지는 기존의 filter, map, limit의 연산이 수행되지 않는다는 점이다. 즉, ‘게으른 연산’으로 처리된다.
Stream과 Collection(나무-3)
Collection
은 데이터 저장
이 목적인데 반해, Stream
은 데이터 연산
이 목적이다. 그래서 Stream
은 요청이 있을 때 연산을 수행하는 게으른 특징 을 갖는다.
-
Collection
은 비디오 실행(연산/계산)을 위해 사전에 데이터가 모두 저장되어 있어야 하는 ‘DVD’ 에 비유 할 수 있다. -
Stream
은 비디오 실행(연산/계산)을 위해 필요할 때 실행에 필요한 데이터가 제공되는 ‘인터넷 스트리밍’ 에 비유할 수 있다.
위와 같은 특징을 보다 잘 이해하기 위해 아래의 항목들을 머릿속에 개념화 시켜보자.
언제(when) 처리하느냐
아래의 예시는 사실
비즈니스 로직
을 통해 서로 다르게 구현이 가능하지만, 어디까지나Collection
과Stream
의 차이를 설명하기 위한 극단적인 예시임을 이해해주기 바란다.
숫자로 이루어진 연속된 데이터
에 대해서 총 두 개의 연산
을 수행한다고 가정하자.
- 첫 번째 연산 : 소수 데이터 추출
- 두 번째 연산 : 홀수 번째 데이터 추출
Collection
의 경우는 연산을 진행하기 위해서는모든 값을 저장
해야 한다. 따라서 첫 번째 연산인소수만 추출
을 위해서 우선모든 소수를 저장
을 진행해야 한다. 그리고 해당 연산이 종료된 후모든 소수가 저장된 Collection
을 대상으로홀수 번째 요소들을 필터링
해야 한다.
public void main() { | |
List<Integer> list = second(first(allNumbersList)); // first 먼저 계산된 이후 second가 계산된다. | |
} | |
// Colleciton 첫 번째 연산 | |
public List<Integer> first(List<Integer> allNumbers) { | |
List<Integer> result = new ArrayList<>(); | |
for(Integer eachNumber : allNumbers) { | |
if (isPrime(eachNumber)) { | |
result.add(eachNumber); | |
} | |
} | |
return result; | |
} | |
// Collection 두 번째 연산 | |
public List<Integer> second(List<Integer> origin) { | |
List<Integer> result = new ArrayList<>(); | |
for(int i=0; i<result.size(); i++) { | |
if ((i+1) % 2 == 1) { | |
result.add(result.get(i)); | |
} | |
} | |
return result; | |
} |
위 코드를 살펴보면 첫 번째 연산(first)
을 수행함으로써 모든 소수를 저장
한다. 즉, 두 번째 연산(second)
를 수행하기 위해 사전에 모든 데이터가 저장되어 있어야 한다.
만일 세 번째, 네 번째 등 다른 연산이 추가된다면 이후 연산을 위해 각 연산 시에 모든 데이터들을 저장해야 한다.
이것이 바로 Collection이 ‘연산 진행을 위해 모든 데이터들을 사전에(when) 처리(저장)’해야 함을 의미한다.
Stream
의 경우 이론적으로 요청할 때만 요소를 계산 한다. 다시말해 연산을 위해 미리 데이터들을 저장할 필요가 없다. 이는게으른 연산
을 살펴보면 쉽게 이해가 가능하다.
public void main() { | |
Stream<Integer> allNumbersStream = allNumbers.stream(); | |
Stream<Integer> firstStream = allNumbersStream.filter(eachNumber -> isPrime(eachNumber)); // first 연산 (아직 연산 수행 x) | |
Stream<Integer> secondStream = firstSTream.filter(eachNumber -> isOdd(eachNumber)); // second 연산 (아직 연산 수행 x) | |
List<Integer> result = secondStream.collect(toList()); // 최종 연산 (모든 연산을 최적화 하여 수행) | |
} | |
// simple | |
public void simple() { | |
List<Integer> result = allNumbers.stream() | |
.filter(eachNumber -> isPrime(eachNumber)) // first 연산(소수 추출) | |
.filter(eachNumber -> isOdd(eachNumber)) // second 연산(홀수 추출) | |
.collect(toList()); // 최종 연산 | |
} |
위 코드는 main
과 simple
로 구성되어 있는데, 동일한 코드를 간소화 시킨 것이다.
우선 첫 번째 연산(filter)
와 두 번째 연산(filter)
을 수행하여도 각 단계에서 연산이 수행되지 않는다. firstStream, secondStream에는 연산의 결과가 저장되어있지 않다.
즉 마지막 최종 연산인 collect를 수행했을 때 비로소 첫 번째 연산과 두 번째 연산이 수행된다. 여기서 주의해야 할 점이 있다. 각 연산이 수행된다는 의미가 첫 번째 연산이 모두 수행된 다음 두 번째 연산이 수행된다는 의미가 아니다!
만일 이처럼 연산된다면 Collection
과 다를 바 없어진다. Stream
은 요청할 때 요소를 계산
한다고 하였다. 이는 내부적으로 각 연산이 파이프라이닝 되어 각 요소들을 요청하여 소비(연산)한다.
처음엔 조금 아리송 할지 몰라도 몇 가지 키워드를 머릿속에 넣어놓고 상기해보면 어느순간 이해 될 것이다.
Stream
은 데이터의 흐름이다.Stream
은데이터의 흐름
을파이프라이닝
하여 처리한다.Stream
은 각 연산들이생성자
와소비자
관계를 형성한다.Stream
은 소비자가값을 요청할 때만
계산한다.
컬렉션과 스트림의 철학적 접근
스트림
은 시간적으로 흩어진 값의 집합으로 간주 할 수 있다. 반면 컬렉션
은 공간적으로 흩어진 값의 집합 으로 볼 수 있다.
참고 및 출처
- 모던 자바 인 액션