본문 바로가기
도서/모던 자바 인 액션

모던 자바 인 액션 - 4장 스트림 활용

by Ahngyuho 2023. 7. 31.

해당 포스팅은 모던 자바 인 액션이라는 책을 읽고 정리한 것입니다. 

 

이번 장에서 배울 내용

  • 스트림이란 무엇일까?
  • 컬렉션과 스트림
  • 내부 반복과 외부 반복
  • 중간 연산과 최종 연산

 

컬렉션

컬렉션은 자바 애플리케이션에서 데이터를 처리하는 객체입니다. Java Api의 컬렉션은 데이터를 그룹화하고 작업을 효율적으로 관리하기 위해 다양한 데이터 구조와 알고리즘을 제공해주고 있습니다.

 

거의 모든 비즈니스 로직에는 그룹화 또는 연산 작용을 필요로 합니다.

데이터베이스에서는 선언형으로 위와 같은 작용을 표현할 수 있습니다.

SELECT name FROM dishes WHERE calorie < 400 이라는 문장은 칼로리가 낮은 요리명을 선택하라는 SQL 질의 입니다.

SQL 질의를 보면 한눈에 어떤 속성을 이용해서 어떻게 필터링할 것인지 알 수 있습니다.

자바에서처럼 for문을 이용해 해당 속성의 조건을 만족하는 객체를 가져온다. 그런 연산을 할 필요없이 선언형으로 작성해도 연산이 가능하다는 것입니다.

-> SQL은 개발자가 구현해야 할 것이 없다!

-> 자바 8에는 컬렉션으로도 이와 비슷한 기능을 만들 수 없을까? -> 스트림!

-> 또 많은 요소를 포함하는 컬렉션을 여러 개의 CPU 코어에 쉽게 전달할 수 없을까? -> 스트림!

 

구현할 코드를 줄이고, 성능을 높이기 위해 직접 멀코티어 아키텍처를 활용해서 병렬로 컬렉션의 요소를 처리할 필요 없이 개발자가 간편하게 컬렉션을 이용하게 할 수 있는 방법이 없을까?

-> 스트림!

 

4.1 스트림이란 무엇인가?

스트림은 자바 8 에서 추가된 기능입니다. 스트림을 이용하면 선언형으로 컬렉션 데이터를 처리할 수 있습니다. For문 같은 구체적인 구현없이 컬렉션 데이터를 처리할 수 있는 방법이라고 생각하시면 될 거 같습니다.

또한 스트림을 이용하면 멀티스레드 코드를 구현하지 않아도 데이터를 투명하게 병렬로 처리할 수 있습니다.

 

스트림이 어떤 식으로 개발자에게 편의성을 제공해주는지 확인해보기 위해 예제를 작성해 보겠습니다.

lowCaloriesDishes를 가비지 변수라고 합니다.

이 변수는 컨테이너 역할만 하는 중간 변수입니다. 자바 8에서는 위와 같은 세부 구현을 라이브러리 내에서 모두 처리합니다.

 

이제 스트림을 이용한 코드를 살펴봅시다.

코드만 보시면

 

1. 선언형으로 코드를 구현 가능

- 즉 루프와 if 조건문 등의 제어 블록을 사용해서 어떻게 동작을 구현할지 지정할 필요 없이

저칼로리의 요리만 선택하라같은 동작의 수행을 지정 가능

3장에서 본 동작 파라미터, 람다식을 기억

이렇게 되면 기존의 코드(구체적인 구현) 복사 붙여넣는 방식을 사용하지 않고 람다 표현식을 이용해서 저칼로리가 아닌 고칼로리의 요리만 필터링하는 코드도 쉽게 구현이 가능!

 

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

Filiter 메서드의 결과는 sorted 메서드로, 다시 sorted의 결과는 map 메서드로, map 메서드의 결과는 collet로 연결

 

Filter와 같은 연산을 고수준 빌딩 블록이라고 부릅니다.

스트림은 이런 연산들로 이루어져 있고 특정 스레딩 모델에 제한되지 않아 자유롭게 어떤 상황이든 사용할 수 있다는 장점이 있습니다. 내부적으로는 단일 스레드 모델에 사용할 수 있지만 멀티코어 아키텍처를 최대한 투명하게 활용할 수 있게 구현되어 있습니다.

결과적으로 데이터 처리를 병렬화 하면서 스레드와 락을 걱정할 필요가 없게 되었습니다.

 

자바 스트림을 좀 더 다양한 선언형으로 표현할 수 있게 하는 라이브러리가 존재합니다.

구아바 아파치 람다제이 등이 존재하는데, 이 라이브러리들은 컬렉션을 제어하는 좀 더 다양한 선언형 표현들을 제공합니다.

 

자바 8의 스트림 API의 특징

  • 선언형: 더 간결하고 가독성이 좋아짐
  • 조립: 높은 유연성
  • 병렬화: 성능 개선

 

이제 스트림 API를 사용하는 방법을 자세하게 알아봅시다.

 

4.2 스트림 시작하기

자바 컬렉션에 stream이라는 메서드가 추가됐습니다.

스트림이란 정확히 무엇일까요?

스트림 정의: 테이터 처리 연산을 지원하도록 소스에서 추출된 연속된 요소

 

정의를 하나씩 뜯어봅시다.

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

컬렉션은 데이터 저장 및 접근이 주요 주제이고, 스트림의 주제는 계산입니다.

 

소스: 스트림은 배열,컬렉션,I/O 자원 등의 데이터 제공 소스로부터 데이터를 제공받아 소비합니다.

정렬된 컬렉션으로 스트림을 생성하면 정렬이 그대로 유지됩니다. , 리스트로 스트림을 만들면 스트림의 요소는 리스트의 요소와 같은 순서를 유지합니다.

 

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

 

이외에도 두가지 주요 특징이 존재합니다.

 

파이프라이닝: 대부분의 스트림 연산은 스트림 연산끼리 연결해서 커다란 파이프라인을 구성할 수 있도록 스트림 자신을 반환합니다. 덕분에 Laziness,short circuiting 같은 최적화도 얻을 수 있게 됩니다. 연산 파이프라인은 데이터베이스 질의(SQL)와 비슷합니다.

 

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

 

위 설명한 내용들을 예제를 통해 확인해 봅시다.

 

소스는 menu로 리스트입니다. 데이터 소스는 연속된 요소를 스트림에 제공합니다.

스트림에 filter,map,limit,collect로 이어지는 일련의 데이터 처리 연산 적용

Collect를 제외한 모든 연산은 서로 파이프라인을 형성할 수 있도록 스트림 반환

파이프라인은 소스에 적용하는 질의 같은 존재.

그리고 collect가 호출되기 전까지 menu에는 무엇도 선택되지 않으며 출력 결과도 없음

Collect 호출 전까지는 메서드 호출이 저장되는 효과

 

4.3 스트림과 컬렉션

 

스트림과 컬렉션의 차이에 대해서 알아보겠습니다.

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

 

이제 차이점을 한번 알아보죠.

컬렉션과 스트림의 가장 큰 차이점은 데이터 계산 시점입니다.

컬렉션은 현재 자료구조가 포함하는 모든 값을 메모리에 저장하는 자료구조입니다.

그래서 데이터를 저장하거나 삭제하는 연산을 진행하는 경우 해당 데이터가 메모리에 존재해야 합니다.

 

반면 스트림의 경우

요청할 때만 요소를 계산하는 고정된 자료구조입니다. 사용자가 요청하는 값만 스트림에서 추출한다는 것이 핵심입니다.

-> 이렇게 되면서 스트림은 생산자(producer)와 소비자(comsumer) 관계를 형성하게 됩니다.

사용자가 요청하는 경우에만 값을 계산하는 laziness한 특징을 가집니다.

이를 요청 중심 제조 또는 즉석 제조라고도 부르는 것 같습니다.

 

반면 컬렉션은 적극적으로 생성됩니다.(생산자 중심: 팔기도 전에 창고를 가득 채우는 것)

 

스트림과 컬렉션

컬렉션은 모든 값을 계산한 후에(생산자 중심) 결과를 반환 -> DVD 데이터를 모두 메모리에 올린 후 시청하는 것과 비슷

스트림은 필요한 시점에만 계산을 진행합니다. 비디오 스트리밍처럼 시청자가 원하는 부분만을 계산해서 보내주는 것과 비슷

 

4.3.1 딱 한 번만 탐색할 수 있다.

반복자와 마찬가지로 스트림도 딱 한 번만 탐색할 수 있습니다. 탐색이된 스트림은 소비됩니다.

그래서 요소를 다시 탐색하려면 데이터 소스에서 새로운 스트림을 만들어야 합니다.

스트림은 단 한 번만 소비 가능!

 

컬렉션과 스트림의 또 다른 차이점은 데이터 반복 처리 방식입니다.

4.3.2 외부 반복과 내부 반복

컬렉션 인터페이스를 사용하려면 사용자가 직접 요소를 반복해야 했습니다.

For , for-each를 사용하는 반복을 외부 반복이라고 합니다.

반면에 스트림 라이브러리는 반복을 알아서 처리하게끔 하는 내부 반복을 사용합니다.

어떤 작업을 수행할지만 지정해주면 모든 것이 알아서 처리가 됩니다.

이와 같이 for-each를 이용하는 것을 외부 반복이라고 합니다.

내부반복이고 외부반복과 어떤 점이 다른지 한번 살펴보겠습니다.

 

 

외부 반복이란 for-each처럼 컬렉션에 있는 데이터를 가져와서 하나씩 처리하는 것을 의미합니다.

컬렉션에 있는 데이터를 외부로 가져와서 처리한다는 데이터 처리 방법 때문에 외부 반복이라고 표현하는 것 같습니다.

하지만 이런 방식이 코드의 유연성을 헤쳐 중복을 유발하는 것 같습니다. 컬렉션은 자바 애플리케이션 전반에 사용되고, 해당 컬렉션에 접근할 때마다 for-each를 사용해서 데이터에 접근하게 되면 비슷한 코드들이 많이 중복되는 느낌을 받을 것 같습니다

게다가 이런 for-each 문은 확실히 가독성이 떨어진다는 단점도 존재합니다.

 

반면에 내부 반복은 많은 작업들을 추상화 할 수 있어서 외부 반복에 비해 굉장히 유연합니다.

동작만 지정해서 넣어주면 실제 작업은 투명화 되어 있기 때문에 코드도 단순해지고, 가독성도 높아집니다.

이뿐만 아니라 자바 8의 스트림 라이브러리의 내부 반복은 외부 반복과 다르게 병렬성을 알아서 처리해줍니다.

 

물론 내부 반복 같은 경우 filtermap과 같이 동작을 지정해주면 반복을 알아서 처리해주는 연산 리스트가 반드시 존재해야 합니다!

동작 파라미터화는 앞에 3장에서 다뤄봤으므로 여기서 배운 내용을 잘 적용해주면 됩니다.

 

4.4 스트림 연산

스트림의 연산은 중간 연산(intermediate operation)과 최종 연산(terminal operation) 이 두 그룹으로 나뉠 수 있습니다.

 

  • 중간연산: 서로 연결할 수 있어 파이프라인을 형성할 수 있는 연산
  • 최종연산: 연결된 파이프라인을 닫는 연산

 

4.4.1 중간연산

중간 연산의 특징은 스트림을 반환한다는 것입니다. 이 중간 연산을 연결하여 알아보기 쉬운 질의(SQL 질의와 같은)를 만들 수 있는 것입니다.

그리고 중간 연산은 최종 연산이 실행되기 전까지는 실행되지 않습니다.

이런 특성을 게으르다(lazy)’ 라고 표현합니다. 중간 연산을 합쳐 최종 연산시 한번에 처리합니다.

 

스트림이 어떤 방식으로 동작되는지 예측할 수 있는 코드를 작성했습니다.

 

 

결과는 서로 한꺼번에 병합되어 같이 실행되었다는 느낌이 듭니다!

 

스트림의 게으른 특성 덕분에 얻은 몇가지 최적화 효과를 확인할 수 있습니다.

 

  • 300칼로리가 넘는 요리는 여러 개지만 오직 3개만 선택! 이는 limit 연산과 쇼트서킷의 결과
  • Filter,map은 서로 다른 연산이지만 한 과정으로 병합!(루프 퓨전)

4.4.2 최종 연산

최종 연산은 스트림 파이프라인에서 결과를 도출해냅니다.

최종 연산은 스트림이 아닌 List,Integer,void등을 결과로 반환합니다.

 

스트림 사용법

  • 질의(스트림을 적용할)를 수행할 데이터 소스
  • 스트림 파이프라인을 구성할 중간 연산 연결
  • 스트림 파이프라인을 실행하고 결과를 도출할 최종 연산

 

4장에서는 스트림을 컬렉션과 비교해서 스트림이 등장하게된 배경과 장점을 알아보았고,

스트림을 사용하는 방법과 연산의 종류,연산의 결과, 연산의 적용 방식(lazy)를 간단하게 알아보았습니다.

 

다음에는 좀 더 복잡한 질의를 처리하기 위한 스트림 사용 방식에 대해서 알아보겠습니다.