본문 바로가기

프로그래밍(TA, AA)/JVM 언어

[자바] 자바8 스트림이란?

거의 모든 자바 애플리케이션은 컬렉션을 만들고 처리하는 과정을 포함한다. 컬렉션으로 데이터를 그룹화하고 처리할 수있다. 컬렉션은 대부분의 프로그래밍 작업에 필수적인 요소다. 많은 요소를 포함하는 커다란 컬렉션은 어떻게 처리를 해야할까? 성능을 높이려면 멀티코어 아키텍처를 활용해서 병렬로 컬렉션의 요소를 처리해야 한다.


람다 표현식은 메서드로 전달할 수 있는 익명함수를 단순화한 것이다. 람다 표현식에는 이름은 없지만, 파라미터 리스트/바디/반환형식/발생할수 있는 예외 리스트는 가질 수 있다.


파라미터: 메서드의 파라미터

화살표: ->는 람다의 파라미터 리스트와 바디를 구분

람다의 바디: 람다의 반환값에 해당하는 표현식 



스트림이란 무엇인가?


스트림을 이용하면 선언형(즉, 데이터를 처리하는 임시 구현 코드 대신 질의로 표현할 수 있다)으로 컬렉션 데이터를 처리할 수 있다. 일단 스트림이 데이터 컬렉션 반복을 멋지게 처리하는 기능이라고 생각하면 이해가 쉽다. 또한 스트림을 이용하면 멀티스레드 코드를 구현하지 않아도 데이터를 투명하게 병렬로 처리할 수 있다. 


스트림의 새로운 기능은 소프트웨어공학적으로 다음의 다양한 이득을 가져다 준다. 


선언형으로 코드를 구현할 수 있다. 즉, 루프와 if 조건문 등의 제어 블록을 상요해서 어떻게 동작을 구현할지 지정할 필요없이 선언형 방식으로 동작의 수행을 지정할 수 있다. 선언형 코드와 동작 파라미터화를 활용하면 변하는 요구사항에 쉽게 대응할 수 있다. 즉, 기존 코드를 복사하여 붙여 넣는 방식을 사용하지 않고 람다 표현식을 이용하여 특정 조건의 데이터를 필터링하는 코드도 쉽게 구현할 수 있다.


fiter, sorted, map, collect 같은 여러 빌딩 블록 연산을 연결해서 복잡한 데이터 처리 파이프라인을 만들수 있다. 여러 연산을 파이프라인으로 연결해도 여전히 가독성/명확성이 유지된다. filter 메서드의 결과는 sorted 메서드로, 다시 sorted의 결과는 map 메서드로, map 메서드의 결과는 collect로 연결이 된다.


filter(또는 sorted, map, collect) 같은 연산은 high-level building block으로 이루어져 있으며, 특정 스레딩 모델에 제한되지 않고 자유롭게 어떤 상황에서든 사용할 수 있다. 결과적으로 우리는 데이터 처리과정을 병렬화하면서 스레드와 락을 걱정할 필요가 없다.


스트림 API는 매우 비싼 연산이다. 일반적인 명령형 프로그래밍의 루프를 이용한다면 더 많은 시간을 들였을 것이다. 자바8의 스트림 API의 특징은 다음처럼 요약할 수 있다.


선언형: 더 간결하고 가독성이 좋아진다.

조립할수있음: 유연성이 좋아진다.

병렬화: 성능이 좋아진다.



스트림 중 가장 간단한 작업에 해당하는 컬렉션 스트림부터 살펴보겠다. 자바8의 컬렉션에서는 스트림을 반환하는 stream이라는 메서드(java.util.stream.Stream)가 추가되었다. 숫자 범위나 I/O 자원에서 스트림 요소를 만드는 등 stream 메서드 이외에 다양한 방법으로 스트림을 얻을 수 있다.


스트림이란 '데이터 처리 연산을 지원하도록 소스에서 추출된 연속된 요소'로 정의할 수 있다. 


[연속된 요소]

컬렉션과 마찬가지로 스트림은 특정 요소 형식으로 이루어진 연속된 값 집합의 인터페이스를 제공한다. 컬렉션은 자료구조이므로 컬렉션에서는 시간과 공간의 복잡성과 관련된 요소 저장 및 접근 연산이 주를 이룬다. 반면 스트림은 filter, sorted, map처럼 표현 계산식이 주를 이룬다. 즉, 컬렉션의 주제는 데이터이고 스트림의 주제는 계산이다.


[소스]

스트림은 컬렉션, 배열, I/O 자원 등의 데이터 제공 소스로부터 데이터를 소비(consume)한다. 정렬된 컬렉션으로 스트림을 생성하면 정렬이 그대로 유지된다. 즉, 리스트로 스트림을 만들면 스트림의 요소는 리스트의 요소와 같은 순서를 유지한다.


[데이터 처리 연산]

스트림은 함수형 프로그래밍 언어에서 일반적으로 지원하는 연산과 데이터베이스와 비슷한 연산을 지원한다. 예를 들어 filter, map, reduce, find, match, sort 등으로 데이터를 조작할 수 있다. 스트림 연산은 순차적으로 또는 병렬로 실행할 수 있다.


[파이프라이닝]

대부분의 스트림 연산은 스트림 연산끼리 연결해서 커다란 파이프라인을 구성할 수 있도록 스트림 자신을 반환한다. 덕분에 laziness, short-circuiting 같은 최적화도 얻을 수 있다. 연산 파이프라인은 데이터 소스에 적용하는 데이터베이스 질의와 비슷하다ㅓ.


[내부 반복]

반복자를 이용해서 명시적으로 반복하는 컬렉션과 달리 스트림은 내부 반복을 지원한다. 

import static java.util.stream.Collectors.toList;
List<String> threeHighCaloricDishNames = 
	menu.stream()	// 메뉴에서 스트림을 얻는다.
		.filter(d -> d.getCalories() > 300)	// 파이프라인 연산 만들기. 고칼로리 요리 필터링
		.map(Dish::getName)	// 요리명 추출
		.limit(3)		// 선착순 세개만 선택
		.collect(toList());	// 결과를 다른 리스트로 저장
System.out.println(threeHighCaloricDishNames);		// 결과를 출력.

우선 menu에 stream 메서드를 호출해서 요리 리스트로부터 스트림을 얻는다. 여기서 데이터소스는 요리 리스트(메뉴)다. 데이터 소스는 연속된 요소를 스트림에 제공한다. 다음으로 스트림에 filter, map, limit, collect로 이어지는 일련의 데이터 처리 연산을 적용한다. collect를 제외한 모든 연산은 서로 파이프라인을 형성할 수 있도록 스트림을 반환한다. 파이프라인은 소스에 적용하는 질의 같은 존재다. 마지막으로 collect 연산으로 파이프라인을 처리해서 결과를 반환(collect는 스트림이 아니라 List를 반환한다)한다. 마지막에 collect를 호출하기 전까지는 menu에서 아무것도 선택되지 않으며 출력 겨로가도 없다. 즉, collect가 호출되기 전까지 메서드 호출이 저장되는 효과가 있다. 


collect: 스트림을 다른 형식으로 변환한다. collect가 다양한 변환 방법을 인수로 받아 스트림에 누적된 요소를 특정 결과로 변환시키는 기능을 수행하는 정도로 이해하자. 예제의 toList()는 스트림을 리스트로 변환하라고 지시하는 인수다. 


스트림 라이브러리에서 필터링, 추출, 축소 기능을 제공하므로 직접 이기능을 구현할 필요가 없었다. 결과적으로 스트림 API는 파이프라인을 더 최적화할 수 있는 유연성을 제공한다. 



스트림과 컬렉션


자바의 기존 컬렉션과 새로운 스트림 모두 연속되 요소 형식의 값을 저장하는 자료구조의 인터페이스를 제공한다. 여기서 sequenced이라는 표현은 순서와 상관없이 아무 값에나 접속하는 것이 아니라 순차적으로 값에 접근한다는 것을 의미한다.


데이터를 언제 계산하느냐가 컬렉션과 스트림의 가장 큰 차이라고 할 수 있겠다. 컬렉션은 현재 자료구조가 포함하는 모든 값을 메모리에 저장하는 자료구조다. 즉, 컬렉션의 모든 요소는 컬렉션에 추가하기 전에 계산되어야 한다.(컬렉션에 요소를 추가하거나 컬렉션의 요소를 삭제할 수 있다. 이런 연산을 수행할 때마다 컬렉션의 모든 요소를 메모리에 저장해야 하며 컬렉션에 추가하려는 요소는 미리 계산되어야 한다.)


반면 스트림은 이론적으로 요청할때만 요소를 계산하는 고정되 자료구조다.(스트림에 요소를 추가하거나 스트림에서 요소를 제거할 수 없다) 이러한 스트림의 특성은 프로그래밍에 큰 도움을 준다. 사용자가 요청하는 값만 스트림에서 추출한다는 것이 핵심이다. 물론 사용자 입장에서는 이러한 변화를 알 수 없다. 결과적으로 스트림은 생산자와 소비자 관계를 형성한다. 또한 스트림은 lazy하게 만들어지는 컬렉션과 같다. 즉, 사용자가 데이터를 요청할 때만 값을 계산한다. (요청 중심 제조(demand-driven manufacturing) 또는 just-in-time manuefacturing이라고 부름.)


반면 컬렉션은 적극적으로 생성된다. (생산자 중심supplier-driven: 팔기도 전에 창고를 가득 채움)



[딱 한번만 탐색할 수 있다]

반복자와 마찬가지로 스트림도 한번만 탐색할 수 있다. 즉, 탐색된 스트림의 요소는 consume된다. 반복자와 마찬가지로 한번 탐색할 요소를 다시 탐색하려면 초기 데이터소스에서 새로운 스트림을 만들어야 한다. 그러려면 컬렉션처럼 반복 사용할 수 있는 데이터소스여야 한다. 만일 새로운 데이터 소스가 I/O 채널이라면 소스를 반복사용할 수 없으므로 새로운 스트림을 만들수가 없다.


스트림은 단 한번만 소비할 수 있다.


스트림을 시간적으로 흩어진 값의 집합으로 간주할 수 있다. 반면 컬렉션은 특정 시간에 모든 것이 존재하는 공간(컴퓨터 메모리)에 흩어진 값으로 비유할 수 있다. 우리는 for-each 루프 내에서 반복자를 이용해 공간에 흩어진 요소에 접근할 수 있다.



[외부반복과 내부반복]

컬렉션 인터페이스를 사용하려면 사용자가 직접 요소를 반복해야 한다. 이를 external iteration이라고 한다. 반면 스트림 라이브러리(반복을 알아서 처리하고 결과 스트림값을 어딘가에 저장) internal iteration을 사용한다. 함수에 어떤 작업을 수행할지만 지정하면 모든것이 알아서 처리된다.


내부반복은 외부반복과 어떤 점이 다르며 어떤 이득을 줄까?


컬렉션은 외부적 반복, 즉 명시적 컬렉션의 항목을 하나씩 가져와서 처리한다. 내부반복을 이용한다면 작업을 투명하게 병렬로 처리하거나 더 최적화된 다양한 순서로 처리할 수 있다.


기존 자바에서처럼 컬렉션을 외부 반복으로 처리한다면 이와 같은 최적화를 달성하기 어렵다. 하지만 내부 반복뿐아니라 자바8에서 스트림을 제공하는 더 다양한 이유가 있다. 스트림 라이브러리의 내부 반복은 데이터 표현과 하드웨어를 활용한 병렬성 구현을 자동으로 선택한다. 반면 for-each를 이용하는 외부 반복에서는 병렬성을 스스로 관리해야 한다. 자바8에서는 컬렉션 인터페이스와 비슷하면서도 반복자가 없는 무엇이 필요하였고, 이러한 이유로 스트림이 탄생하게 되었다.


스트림은 내부 반복을 사용하므로 반복 과정을 우리가 신경쓰지 않아도 된다. 하지만 이와같은 장점을 누리려면 반복을 숨겨주는 연산 리스트가 미리 정의되어 있어야 한다. 반복을 숨겨주는 대부분의 연산은 람다표현식을 인수로 받으므로 동작 파라미터화를 활용할 수 있다. 




스트림 연산


java.util.stream.Stream 인터페이스는 많은 연산을 정의한다. 스트림 인터페이스 연산을 크게 두가지로 구분할 수 있다.


filter, map, limit는 서로 연결되어 파이프라인을 형성한다.

collect로 파이프라인을 실행한 다음에 닫는다.


연결할 수 있는 스트림 연산을 intermediate operation이라고 하며, 스트림을 닫는 연산을 terminal operation 이라고 한다. 왜 스트림의 연산을 두가지로 구분할까?



[intermediate operation : 중간연산]

filter나 sorted 같은 중간 연산은 다른 스트림을 반환한다. 따라서 여러 중간 연산을 연결해서 질의를 만들수 있다. 중간 연산의 중요한 특징은 단말 연산을 스트림 파이프라인에 실행하기 전까지는 아무 연산도 수행하지 않는다는 것이다. 즉 lazy하다는 것이다. 중간 연산을 합친 다음에 합쳐진 중간 연산을 최종 연산으로 한번에 처리하기 때문이다. (loop fusion..)


[terminal operation : 최종연산]

최종 연산은 스트림 파이프라인에서 결과를 도출한다. 보통 최종 연산에 의해 List, Integer, void 등 스트림 이외의 결과가 반환된다. 예를 들어 파이프라인에서 forEach는 소스의 각 요리에 람다를 적용한 다음에 void를 반환하는 최종 연산이다. System.out.println를 forEach에 넘겨주면 menu에서 만든 스트림의 모든 요리를 출력한다.

menu.stream().forEach(System.out::println);


[스트림 이용하기]

스트림 이용과정은 다음과 같이 세가지로 요약할 수 있다.


 - 질의를 수행할 (컬렉션 같은) 데이터 소스

 - 스트림 파이프라인을 구성할 중간 연산 연결

 - 스트림 파이프라인을 실행하고 결과를 만들 최종 연산


스트림 파이프라인의 개념은 builder pattern과 비슷하다. 빌더 패턴에서는 호출을 연결해서 설정을 만든다. 그리고 준비된 설정에 build 메서드를 호출한다.



 - 스트림은 소스에서 추출된 연속 요소로, 데이터 처리 연산을 지원한다.

 - 스트림은 내부 반복을 지원한다. 내부 반복은 filter, map, sorted 등의 연산으로 반복을 추상화한다.

 - 스트림에는 중간 연산과 최종 연산이 있다.

 - filter와 map처럼 스트림을 반환하면서 다른 연산과 연결될 수 있는 연산을 중간 연산이라고 하다. 중간 연산을 이용해서 파이프라인을 구성할 수 있지만 중간 연산으로는 어떤 결과도 생성할 수 없다.

 - forEach, count, collect처럼 스트림 파이프라인을 처리해서 스트림이 아닌 결과를 반환하는 연산을 최종 연산이라고 한다.

 - 스트림의 요소는 요청할 때만 계산된다.


데이터를 어떻게 처리할지는 스트림 API가 관리하므로 편리하게 데이터 관련 작업을 할 수 있다. 따라서 스트림 API 내부적으로 다양한 최적화가 이루어질 수 있다. 스트림 API는 내부 반복뿐 아니라 코드를 병렬로 실행할지 여부도 결정할 수 있다. 이러한 일은 순차적인 반복을 단일 스레드로 구현했기 때문에 외부 반복으로는 불가능하다.


필터링과 슬라이싱



[스트림 축소]

스트림은 주어진 사이즈 이하의 크기를 갖는 새로운 스트림을 반환하는 limit(n) 메서드를 지원한다. 스트림이 정렬되어 있으면 최대 n개의 요소를 반환할 수 있다. 정렬되지 않은 스트림(예를 들면 소스가 Set)에도 limit을 사용할 수 있다. 소스가 정렬되어 있지 않았다면 limit의 결과도 정렬되지 않은 상태로 반환된다.



[요소 건너뛰기]

스트림은 처음 n개 요소를 제외한 스트림을 반환하는 skip(n) 메서드를 지원한다. n개 이하의 요소를 포함하는 스트림에 skip(n)을 호출하면 빈 스트림을 반환한다. 



매핑


특정 객체에서 특정 데이터를 선택하는 작업은 데이터 처리 과정에서 자주 수행되는 연산(SQL 테이블의 특정 열 선택)이다. 스트림 API의 map과 flatMap 메서드는 특정 데이터를 선택하는 기능을 제공한다.



[map : 스트림의 각 요소에 함수 적용하기]

스트림은 함수를 인수로 받는 map 메서드를 지원한다. 인수로 제공된 함수는 각 요소에 적용되며 함수를 적용한 결과가 새로운 요소로 매핑된다. 이 과정은 기존의 값을 '고친다:modify'라는 개념보다는 '새로운 버전을 만든다'라는 개념에 가까우므로 '변환:transforming'에 가까운 '매핑:mapping'이라는 단어를 사용한다. 



[flatMap : 스트림 평면화]


map과 Arrays.stream 활용

String[] arrayOfWords = {"Goodbye", "World"};
Stream<String> streamOfwords = Arrays.stream(arrayOfWords);


파이프라인에 Arrays.stream() 메서드를 적용해보자.

words.stream()
	.map(word -> word.split(""))		// 각 단어를 개별 문자열 배열로 반환
	.map(Arrays::stream)		// 각 배열을 별도의 스트림으로 생성
	.distinct()
	.collect(toList());


flatMap 사용


flatMap을 사용하면 다음과 같이 문제를 해결할 수 있다.

List<String> uniqueCharacters = 
	word.stream()
		.map(w -> w.split(""))	// 각 단어를 개별 문자를 포함하는 배열로 변환
		.flatMap(Arrays::stream)	// 생성된 스트림을 하나의 스트림으로 평면화
		.distinct()
		.collect(Collectors.toList());

flatMap은 각 배열을 스트림이 아닌 스트림의 콘텐츠로 매핑한다. 즉, map(Arrays::stream)과 달리 flatMap은 하나의 평면화된 스트림을 반환한다. flatMap 메서드는 스트림의 각 값을 다른 스트림으로 만든 다음에 모든 스트림을 하나의 스트림으로 연결하는 기능을 수행한다.



검색과 매칭


특정 속성이 데이터 집합에 있는지 여부를 검색하는 데이터 처리도 자주 사용된다. 스트림 API는 allMatch, anyMatch, noneMatch, findFirst, findAny 등 다양한 유틸리티 메서드를 제공한다.



[anyMatch : 프레디케이트가 적어도 한 요소와 일치하는지 확인]

프레디케이트가 주어진 스트림에서 적어도 한 요소와 일치하는지 확인할때 anyMatch 메서드를 이용한다. anyMatch는 Boolean을 반환하므로 최종 연산이다. 예를 들어 다음 코드는 menu에 채식요리가 있는지 확인하는 예제이다.

if (menu.stream().anyMatch(Dish::isVegetarian)) {
	System.out.println("The menu is (somewhat) vegetarian friendly!!");
}


[allMatch : 프레디케이트가 모든 요소와 일치하는지 검사]

allMatch 메서드는 anyMatch와 달리 스트림의 모든 요소가 주어진 프레디케이트와 일치하는지 검사한다.

boolean isHealthy = menu.stream().allMatch(d -> d.getCalories() < 1000);


[noneMatch]

noneMatch는 allMatch와 반대 연산을 수행한다. 즉, noneMatch는 주어진 프레디케이트와 일치하는 요소가 없는지 확인한다.

boolean isHealthy = menu.stream().noneMatch(d -> d.getCalories() >= 1000);


anyMatch, allMatch, noneMatch 세가지 메서드는 스트림 short-circuit 기법, 즉 자바의 &&, ||와 같은 연산을 활용한다.


short-circuit 평가란?

때로는 전체 스트림을 처리하지 않았더라도 결과를 반환할 수 있다. 예를 들어 여러 and 연산으로 연결된 커다란 불린 표현식을 평가한다고 하면, 표현식에서 하나라도 거짓이라는 결과가 나오면 나머지 표현식의 결과와 상관없이 전체 결과도 거짓이 된다. 이러한 상황을 쇼트서킷이라고 부른다.


allMatch, noneMatch, findFirst, findAny 등의 연산은 모든 스트림의 요소를 처리하지 않고도 결과를 반환할 수 있다. 원하는 요소를 찾았으면 즉시 결과를 반환할 수 있다. 마찬가지로 스트림의 모든 요소를 처리할 필요없이 주어진 크기의 스트림을 생성하는 limit도 쇼트서킷 연산이다. 특히 무한한 요소를 가진 스트림을 유한한 크기로 줄일수 있는 유용한 연산이다.




[요소 검색]

findAny 메서드는 현재 스트림에서 임의의 요소를 반환한다. findAny 메서드를 다른 스트림연산과 연결해서 사용할 수 있다. 예를 들어 다음 코드처럼 filter와 findAny를 이용해서 채식요리를 선택할 수 있다.

Optional<Dish> dish = 
	menu.stream()
		.filter(Dish::isVegetarian)
		.findAny();


스트림 파이프라인은 내부적으로 단일 과정을 실행할 수 있도록 최적화된다. 즉, 쇼트서킷을 이용해서 결과를 찾는 즉시 실행을 종료한다.



Optional이란?

Optional<T> 클래스(java.util.Optional)는 값의 존재나 부재 여부를 표현하는 컨테이너 클래스다. findAny는 아무 요소도 반환하지 않을수 있다. null은 쉽게 에러를 일으킬수 있으므로 자바8에서 Optional<T>라는 기능이 만들어졌다. Optional을 이용해서 null 확인 관련 버그를 피할 수 있게될 것이다. 일단 Optional은 값이 존재하는지 확인하고 값이 없을때 어떻게 처리할 것인지 강제하는 기능을 제공한다는 정도만 이해해도 좋다.


- isPresent()는 Optional이 값을 포함하면 true를 반환하고, 값을 포함하지 않으면 false를 반환한다.

- ifPresent(Consumer<T> block)는 값이 있으면 주어진 블록을 실행한다. Consumer 함수형 인터페이스에는 T 형식의 인수를 받으며 void를 반환하는 람다를 전달할 수 있다.

- T get()은 값이 존재하면 값을 반환하고, 값이 없으면 NoSuchElementException을 일으킨다.

- T orElse(T other)는 값이 있으면 값을 반환하고, 값이 없으면 기본값을 반환한다.


menu.stream()
	.filter(Dish::isVegetarian)
	.findAny()		// Optional<Dish> 반환
	.ifPresent(d -> System.out.println(d.getName()));		// 값이 있으면 출력되고, 값이 없으면 아무 일도 일어나지 않음.



[첫번째 요소 찾기]

리스트 또는 정렬된 연속 데이터로부터 생성된 스트림처럼 일부 스트림에는 논리적인 아이템 순서가 정해져 있을 수 있다. 이런 스트림에서 첫번째 요소를 찾으려면 어떻게 해야할까?

List<Integer> someNumbers = Arrays.asList(1,2,3,4,5);
Optional<Integer> firstSquareDivisibleByThree = 
	someNumbers.stream()
		.map(x -> x * x)
		.filter(x -> x % 3 == 0)
		.findFirst();



리듀싱


리듀싱 연산은 모든 스트림 요소를 처리해서 값으로 도출한다. 함수형 프로그래밍 언어 용어로는 이 과정이 마치 종이를 작은 조각이 될때까지 반복해서 접는것과 비슷하다는 의미로 fold라고 부르기도 한다.


reduce를 이용하면 애플리케이션의 반복된 전체 합계 패턴을 추상화할 수 있다. reduce를 이용해서 다음처럼 스트림의 모든 요소를 더할 수 있다.

int sum = numbers.stream().reduce(0, (a,b) -> a + b);


reduce는 두개의 인수를 갖는다.


 - 초깃값 0

 - 두 요소를 조합해서 새로운 값을 만드는 BinaryOperator<T> (ex. (a,b) -> a + b)



reduce로 다른 람다, 즉 (a, b) -> a * b를 넘겨주면 모든 요소에 곱셈을 적용할 수 있다.

int product = numbers.stream().reduce(1, (a,b) -> a*b);


reduce 연산과정에서 스트림이 하나의 값으로 줄어들때까지 람다는 각 요소를 반복해서 조합한다. 메서드 레퍼런스를 이용해서 이 코드를 좀더 간결히 만들수 있다. 자바 8에서는 Integer 클래스에 두 숫자를 더하는 정적 sum 메서드를 제공한다. 따라서 직접 람다 코드를 구현할 필요가 없다.

int sum = numbers.stream().reduce(0, Integer::sum);


[초깃값 없음]

초깃값을 받지 않도록 오버로드된 reduce도 있다. 그러나 이 reduce는 Optional 객체를 반환한다.

Optional<Integer> sum = numbers.stream().reduce((a, b) -> (a + b));

스트림에 아무 요소가 없는 상황이라면, 초깃값이 없으므로 reduce는 합계를 반환할 수 없다. 따라서 합계가 없음을 가리킬 수 있도록 Optional 객체로 감싼 결과를 반환한다.



[reduce 메서드의 장점과 병렬화]

기존의 단계적 반복으로 합계를 구하는 것과 reduce를 이용해서 합계를 구하는 것은 어떤 차이가 있을까? reduce를 이용하면 내부 반복이 추상화되면서 내부 구현에서 병렬로 reduce를 실행할 수 있게 된다. 반복적인 합계에서는 sum 변수를 공유해야 하므로 쉽게 병렬화하기 어렵다. 사실 이 작업을 병렬화하려면 입력을 분할하고, 분할된 입력을 더한 다음에, 더한 값을 합쳐야 한다. 어쨌든 mutable accumulator pattern은 병렬화와 거리가 먼 기법이다. 해서 reduce라는 새로운 패턴이 필요한 것이다.


collect 메서드를 이용하여 더 복잡한 형식의 리듀스도 존재한다. 예를 들어 요리를 종류별로 그룹화할때 스트림을 정수로 리듀스하는 것이 아니라 Map으로 리듀스할 수도 있다.



숫자형 스트림


자바8에서는 세가지 primitive stream specialization을 제공한다. 스트림 API는 박싱 비용을 피할 수 있도록 'int 요소에 특화된 IntStream', 'double 요소에 특화된 DoubleStream', 'long 요소에 특화된 LongStream'을 제공한다. 각각의 인터페이스는 숫자 스트림의 sum, max와 같이 자주 사용하는 숫자 관련 리듀싱 연산 수행 메서드를 제공한다. 또한 필요할때 다시 객체 스트림으로 복원하는 기능도 제공한다. 특화 스트림은 오직 박싱 과정에서 일어나는 효율성과 관련있으며 스트림에 추가 기능을 제공하지는 않는다.


[숫자 스트림으로 매핑]

스트림을 특화 스트림을 변환할때는 mapToInt, mapToDouble, mapToLong 세가지 메서드를 가장 많이 사용한다. 이들 메서드는 map과 정확히 같은 기능을 수행하지만, Stream<T> 대신 특화된 스트림을 반환한다. 스트림이 비어있으면 sum은 기본값 0을 반환한다. IntStream은 sum, max, min, average 등 다양한 유틸리티 메서드도 지원한다.


[객체 스트림으로 복원하기]

숫자 스트림을 만든 다음에, 원상태인 특화되지 않은 스트림으로 복원할 수 있을까? 그러려면 스트림 인터페이스에 정의된 일반적인 연산을 사용하면 된다. boxed 메서드를 이용해서 특화 스트림을 일반 스트림으로 변환할 수 있다.

IntStream intStream = menu.stream().mapToInt(Dish::getCalories);	// 스트림을 숫자스트림으로 변환
Stream<Integer> stream = intStream.boxed();	// 숫자 스트림을 스트림으로 변환


스트림에 요소가 없는 상황과 실제 최댓값이 0인 상황을 어떻게 구별할 수 있을까? Optional을 Integer, String 등의 레퍼런스 형식으로 파라미터화할 수 있다. 또한 OptionalInt, OptionalDouble, OptionalLong 세가지 기본형 특화 스트림 버전도 제공한다.


예를 들어 다음처럼 OptionalInt를 이용하여 IntStream의 최댓값 요소를 찾을 수 있다.

OptionalInt maxCalories = menu.stream()
			.mapToInt(Dish::getCalories)
			.max();

int max = maxCalories.orElse(1);	// 값이 없을때 기본 최댓값을 명시적으로 설정


[숫자 범위]

자바8의 IntStream과 LongStream에서는 range와 rangeClosed라는 두가지 정적 메서드를 제공한다. 두 메서드 모두 첫번째 인수로 시작값을, 두번째 인수로 종료값을 갖는다. range 메서드는 시작값과 종료값이 결과에 포함되지 않는 반면 rangeClosed는 시작값과 종료값이 결과에 포함된다는 점이 다르다.

IntStream evenNumbers = IntStream.rangeClosed(1, 100).filter(n -> n%2 == 0);	// 1부터 100까지의 짝수 스트림
System.out.println(evenNumbers.count());	// 1부터 100까지에는 50개의 짝수가 있다.


[피타고라스 예제]

Stream<int[]> pythagoreanTriples = IntStream.rangeClosed(1, 100).boxed()
				.flatMap(a ->
					IntStream.rangeClosed(a, 100)
						.filter(b -> Math.sqrt(a*a + b*b)%1 == 0)
						.mapToObj(b -> new int[]{a, b, (int)Math.sqrt(a*a + b*b)})
					);



스트림 만들기


스트림은 데이터 처리 질의를 표현하는 강력한 도구이다. stream 메서드로 컬렉션에서 스트림을 얻을 수도 있다. 다음은 다양한 방식으로 스트림을 만드는 방법을 설명한다.



[파일로 스트림 만들기]

파일을 처리하는 I/O 연산에서 사용하는 자바의 NIO API도 스트림 API를 활용할수 있도록 업데이트 되었다. java.nio.file.Files의 많은 정적 메서드가 스트림을 반환한다. Files.lines는 주어진 파일의 행 스트림을 문자열로 반환한다.

long uniqueWords = 0;
try (Stream<String> lines = Files.lines(Paths.get("data.txt"), Charset.defaultCharset())) {
	uniqueWords = lines.flatMap(line -> Arrays.stream(line.split(" "))).distinct.count();
}
catch (IOException e) {
}

Files.lines로 파일의 각행 요소를 반환하는 스트림을 얻을 수 있다. line에 split 메서드를 호출해서 각 행의 단어를 분리할 수 있다. 각 행의 단어를 여러 스트림으로 만드는 것이 아니라 flatMap으로 스트림을 하나로 평면화하였다. 마지막으로 distinct와 count를 연결하여 스트림의 고유 단어수를 계산한 방식이다.



[함수로 무한 스트림 만들기]

스트림 API는 함수에서 스트림을 만들 수 있는 두 개의 정적 메서드 Stream.iterate와 Stream.generate를 제공한다. 두 연산을 이용해서 무한 스트림, 즉 고정된 컬렉션에서 고정된 크기의 스트림을 만들었던 것과는 달리 크기가 고정되지 않은 스트림을 만들 수 있다. iterate와 generate에서 만든 스트림은 요청할 때마다 주어진 함수를 이용해서 값을 만든다. 따라서 무제한으로 값을 계산할 수 있다. 하지만 보통 무한한 값을 출력하지 않도록 limit(n) 함수를 연결해서 사용한다.


1. iterate

Stream.iterate(0, n -> n+2).limit(10).forEach(System.out::println);

iterate 메서드는 초깃값과 람다를 인수로 받아서 새로운 값을 끊임없이 생산할 수 있다. 기본적으로 iterate는 기존 결과에 의존해서 순차적으로 연산을 수행한다. iterate는 요청할때마다 값을 생산할 수 있으며 끝이 없으므로 무한 스트림을 만든다. 이러한 스트림을 언바운드 스트림이라고 표현한다. 바로 이러한 특징이 스트림과 컬렉션의 가장 큰 차이점이다.


2. generate


iterate와 비슷하게 generate도 요구할때 값을 계산하는 무한 스트림을 만들 수 있다. 하지만 iterate와 달리 generate는 생산된 각 값을 연속적으로 계산하지 않는다. generate는 Supplier<T>를 인수로 받아서 새로운 값을 생산한다.

Stream.generate(Math::random).limit(5).forEach(System.out::println);

이번에도 명시적으로 limit 메서드를 이용해서 스트림의 크기를 한정했다. limit이 없다면 스트림은 언바운드 상태가 된다.