티스토리 뷰

Java/기본기

[JAVA 8] 3. 스트림의 기본

Jason of the Argos 2022. 3. 13. 20:50

스트림이란 무엇인가?

<모던자바인액션>은 스트림을 '데이터 처리 연산을 지원하도록 소스에서 추출된 연속된 요소(sequence of elements)'로 정의한다.

1) 연속된 요소: 컬렉션처럼 특정 요소 형식으로 이루어진 연속된 값 집합의 인터페이스를 제공하고, 이 연속된 요소에 대한 계산식을 제공함

2) 소스: 컬렉션, 배열, I/O 자원 등 데이터 제공 소스로부터 데이터를 소비한다.

3) 데이터 처리 연산: 함수형 프로그래밍 언어에서 일반적으로 지원하는 연산과 데이터베이스와 비슷한 연산을 지원한다.

 

보다 더 쉬운 설명으로는 '스트림은 데이터 컬렉션 반복을 멋지게 처리하는 기능이다'라고 정의한다.

 

스트림의 장점

1) 선언형: 간결하고 가독성이 좋아짐

2) 조립할 수 있음: 유연성이 좋아짐

3) 병렬화: 성능이 좋아짐

 

책을 읽으면서 내가 이해한 스트림은 '자바 안에서 이루어지는 데이터 처리를 선언형으로 유연하게 할 수 있게 끔 해주는 기능' 이다.

(선언형 프로그래밍이란 무엇인가?)

 

유연하게 라는 것은 두 가지로 설명할 수 있는데 바로 조립성병렬화이다.

스트림의 조립성이란?

스트림은 filter, sorted, map, collect 등 여러 고수준 빌딩블록(high-level building block)을 연결해서 하나의 파이프라인으로 만들 수 있다. 즉 여러 스트림을 조립해서 하나의 일련의 스트림을 만들 수 있다는 것이다.

List<String> threeHighCaloricDishNames =
	menu.stream()
        .filter(dish  -> dish.getCalories() > 300)
        .map(Dish::getName)
        .limit(3)
        .collect(toList());
System.out.println(threeHighCaloricNames);

이 예제 코드를 보면 menu 라는 리스트에 담긴 요리들 중 칼로리가 300보다 높은 요리 3개를 선택해서 이름만 가져온다. 위의 코드를 도식화 하면 밑의 그림과 같다.

출처: 모던 자바 인 액션

앞서 언급한 고수준 빌딩블록이라는 용어를 그림을 보면 이해할 수 있다. filter, map, limit, collect가 각 블록이 된다는 것이다. 선언형 프로그래밍의 핵심은 동작방식 구체적 명세를 추상화하는 것에 있다. 즉, 우리가 filter를 호출할 때 이 스트림은 추상화 되어있기 때문에 filter가 어떻게 내부적으로 작동하는지 하나도 신경 안쓰고 그저 가져다가 쓸 수 있다는 것이다. 추상화라는 생각하면 왜 filter를 고수준 빌딩블록 이라고 하는지 이해가 된다. 

 

두 가지 중요한 특징:

1) 파이프라이닝: 대부분의 스트림 연산은 스트림 연산끼리 연결해서 커다란 파이프라인을 구성할 수 있도록 스트림 자신을 반환한다.

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

 

내부반복 vs 외부반복

컬렉션 인터페이스를 사용하려면 사용자가 직접 요소를 반복해야 한다. for-each나 Iterator를 이용해서 반복할 수 있으며 이를 외부 반복(external iteration)이라고 한다. 반면 스트림 라이브러리는 반복을 알아서 처리하고 결과 스트림 값을 저장해주는 내부 반복(internal iteration)을 사용한다.

내부 반복의 장점은 병렬성을 쉽게 얻을 수 있다는 점이며, 내부적으로 더 최적화된 방법으로 처리될 수 있기 때문이다.

 

중간연산, 최종연산

스트림은 연결할 수 있는 스트림 연산인 중간 연산(intermediate operation)과 스트림을 닫는 연산인 최종 연산(terminal operation)으로 구성된다.

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

 

List<String> names = menu.stream() // 스트림 open
		.filter(dish -> dish.getCalories > 300) // 중간 연산 시작
		.map(Dish::getname)
		.limit(3) // 중간 연산 끝, short-circuit
		.collect(toList()); // 종단 연산

스트림의 게으른 특성 덕분에 얻을 수 있는 최적화 효과가 있다.

첫 번째는 쇼트 서킷이다. 모든 연산을 다 해보기 전에 조건을 만족하면 추가적인 불필요한 연산은 하지 않는다. 위의 예시에서는 limit 연산이 쇼트 서킷 연산에 해당된다. 3개의 결과를 얻은 후 앞선 filter와 map연산은 더 이상 수행할 필요가 없어 빠르게 최종 연산을 수행한다.

두 번째는 루프 퓨전이다. 위의 예시 코드에서 filter와 map 연산에 값을 print 하는 과정을 추가한다면 filter와 map이 다른 연산이지만 한 과정으로 병합되어 처리됨을 확인할 수 있다. 루프 퓨전은 이렇게 둘 이상의 연산이 합쳐 하나의 연산으로 처리됨을 말한다.

최종 연산은 스트림 파이프라인에서 결과를 도출한다. 스트림 외의 결과를 반환하는 연산을 말한다.

 

'Java > 기본기' 카테고리의 다른 글

[JAVA8] 6. Optional  (0) 2022.04.17
[JAVA 8] 1. 동작 파라미터화  (0) 2022.02.19
[JAVA] Java는 정말 WORA할까?  (0) 2021.08.11
[Java 기본] I-1. JVM이란 무엇인가  (0) 2021.07.13
[JAVA] Wrapper 클래스는 왜 있는 걸까?  (0) 2021.03.05