본문 바로가기

함수형 자바

[JAVA] 스트림 (Stream) - Chapter 06

이번 장은 스트림에 대해 다뤘다.

스트림을 종종 써왔지만, 내부 동작에 대해서는 자세히 몰랐기 때문에 이번장은 꽤 어려웠다.

 

스트림

1) 스트림은 선언적이고 지연 평가된 접근법을 제공한다.

2) 내부 반복을 가진 데이터 파이프라인으로, 반복 같은 어떻게 수행되는지는 데이터 소스가 담당하도록 하고, 무엇을 하고싶은지를 집중 할 수있게 한다. (스스로 반복을 수행하는 파이프라인을 사전에 구성)

3) 스트림의 연산들은 java.util.stream.Stream<T>의 메서드로 제공되는 고차 함수로 이루어져 있어, 중간 연산은 새로운 스트림 함수를 return하기 때문에 여러 스트림 함수를 조합하여 사용 할 수 있다.

4) 결과를 반환하는 최종 연산때까지 지연 처리되어 (중간 연산 때 즉각적으로 실행 X, 종료 연산 호출 전까지 아무런 작업도 시작하지 않음), 마지막 연산이 파이프라인에 연결된 이후 파이프라인을 통해 처리된다. (느긋한 계산법)

5) 순회 방식을 자동으로 최적화 한다.

6) 병럴 처리 기능을 가진다. parallel 메서드 호출로 가능하다.

7) 데이터 소스와 요소들이 분리 (연산들은 소스 데이터에 영향을 주지 X 변경 X), 스트림 자체도 어떤 요소들도 저장하지 않음.

 

스트림 파이프라인

크게 map (데이터 변환) / filter (데이터 선택) /reduce (결과 도출) 세가지 유형으로 축약 될 수 있다.

파이프 라인을 통과하는 요소가 적을 수록 성능이 향상되어, 중간 연산 순서도 성능에 영향을 끼친다.

또한 최종 연산에 중간 연산이 영향을 끼치지 않아 생략 가능하다 판단되면, 중간 연산을 생략하곤 한다. (peek이 동작하지 않을 수 있다.)

스트림 생성

각각 스트림 파이프라인은 기존 데이터 소스로부터 새로운 스트림 인스턴스를 생성하는 것으로 시작한다.

가장 흔한 데이터 소스는 Collection (List, Set...) 타입이다.

혹은 Stream.of(T) 같은 static 메서드를 통해 스트림을 생성 할 수 있다.

 

중간 연산

 

요소 선택

특정 조건에 따라 요소를 선택하는데, Predicate를 이용한 필터링이나 요소의 개수를 기반으로 선택한다.

 

1) filter(Predicate p)

Predicate의 결과가 true의 경우 해당 요소는 후속 처리를 위해 선택된다.

 

2) dropWhile(Predicate p)

predicate가 처음으로 false가 될 때까지 통과하는 모든 요소를 폐기.

순서가 정해진 스트림을 위해 설계됨.

 

3) takeWhile(Predicate p)

predicate가 false가 될 때까지 통과하는 모든 요소를 선택.

 

4) limit(long l)

통과하는 요소의 최대 개수 (l)를 제한.

 

5) skip(long l)

앞에서 부터 l개의 요소를 건너뛴다.

 

6) distinct()

중복되지 않은 요소만 반환.

요소를 비교하기 위해 모든 요소를 버퍼에 저장.

 

7) sorted()

java.util.Comparable에 부합하는 경우 정렬.

 

8) sorted(Comparator c)

사용자 정의 Comparator를 사용하여 정렬

 

요소 매핑

원하는 형식으로 변환하거나 요소의 일부 속성만 가져올 수 있다.

 

1) map(Function<T, R> mapper)

mapper 함수가 요소에 적용되고, 새로운 요소가 스트림으로 반환.

 

2) flatMap(Function<T, Stream<R>> mapper)

mapper 함수가 요소에 적용되고, mapper함수는 Stream<R>를 반환한다.

해당 mapper함수를 map의 인수로 사용하는 경우에는 Stream<Stream<R>>을 반환하기 때문에, 펼쳐서 새로운 스트림으로 만들 때 사용된다.

 

3) mapMulti(BiConsumer<T, Consumer<R>> mapper)

mapper가 스트림을 반환하지 않고, Consumer<R>가 요소를 스트림을 통해 더 아래로 전달한다.

map과 flatMap의 두 연산을 하나로 압축 할 수 있다.

파이프 라인에 매핑되는 요소가 적거나 없는 상황이나, 새 스트림 인스턴스를 생성하는 것보다 매핑된 결과를 제공하는 것이 효율적인 경우에 사용을 권장한다.

 

Peek(Consumer<T> action)

스트림의 요소에 개입하지 않고 스트림을 살짝 들여다본다.

주로 디버깅을 지원하기 위해 설계되었다.

 

스트림 종료

최종 연산으로 스트림 파이프라인의 마지막 단계이다.

요소를 실제로 처리하여 결과나 사이드 이펙트를 생성한다.

중간 연산과 달리 즉시 계산된다.

 

크게 축소/ 집계/ 찾기 및 매칭/ 소비 로 구분 할 수 있다.

 

요소 축소

fold 연산이라고 하며, 하나의 결괏값으로 만든다.

입력에만 의존하여 순수 함수에 해당된다.

누적 연산자를 반복적으로 적용하는데, 누적 연산자는 이전의 결과를 현재의 요소와 결합하여 새로운 결과를 반환한다.

주어지는 초기값으로 부터 연산하거나, 첫 번째 요소를 초기값으로 대체한다.

 

1) T reduce(T identity, BinaryOperator<T> accumulator)

identity는 accumulator 연산을 시작 할때의 초기 값에 해당된다.

 

2) Optional<T> reduce(BinaryOperator<T> accumulator)

초기값을 필요로 하지 않는다.

스트림의 첫 번째 요소가 초기값으로 대체된다. (스트림이 어떠한 요소도 포함하고 있지 않으면 비어있는 Optional<T> 를 반환한다.)

 

3) U reduce(U identity, BiFunctional<U, T, U> accumulator, BinaryOperator<U> combiner)

map과 reduce를 결합한 변형이다.

스트림에 T타입의 요소가 있지만, 최종적으로 원하는 반환 값이 다른 타입인 U인 경우 사용한다. (모든 문자의 길이 합계를 구하는 경우)

 

3) 특화된 연산

min, max, count(), sum(), average(), summaryStatics()등이 포함된다.

 

집계 연산

결과 연산을 새로운 자료 구조로 집계한다.

Collection과 유사한 타입의 새로운 값으로 변환한다.

 

종료 연산 메서드는 collect가 있는데, 이는 요소를 집계할 Collector를 사용한다.

단일 결과로 합치기 위해 가변 결과 컨테이너를 중간 자료 구조로 활용한다.

 

요소들인 java.util.stream.Collector<T, A, R>의 도움을 받아 집계된다. (T : 스트림 요소 타입, A : 가변 결과 컨테이너 타입, R : 최종 결과 타입)

 

Supplier<A>를 통해 A타입의 가변 결과 컨테이너의 새 인스턴스를 반환하고,

BiConsumer<A, T> 누적기를 통해 가변 결과 컨테이너 (A)와 현재 요소 (T)를 인수로 받아 컨테이너에 추가하고,

BinaryOperator<A> 결합기를 통해 병렬 처리시 여러 누적기들이 동시에 수행되고, 여러 부분 결과 컨테이너들을 하나로 합치고,

Function<A, R> 반환기를 통해 가변 결과 컨테이너 (A)를 최종 결과 타입 (R)로 변환시킨다. (가변 컨테이너와 최종 결과 타입이 같은 경우 필요가 없을 수 있다.)

 

Collection타입, Map타입, 그룹화된 Map타입, 분할된 Map타입, 산술 및 비교 연산, 문자열 연산 등이 이에 해당된다.

 

불변 누적을 사용하는 reduce연산과는 달리 가변 컨테이너를 활용한 가변성에 기반하여, 문자열 결합같은 작업에 더 유리하다.

 

찾기 및 매칭

특정 요소를 찾아내고 종료 연산을 제공한다.

filter 연산을 통해 원하는 요소를 찾은 후 findFirst(), findAny()를 통해 연산을 종료하거나,

anyMatch(Predicate<T> predicate), allMatch(...), noneMatch(...)등을 통해 찾아낸 후 연산을 종료 할 수 있다.

 

요소 소비

Consumer를 인수로 받아들이지만, 값을 반환하지 않는 종료 연산이다. 사이드 이펙트 전용 연산이다.

forEach 혹은 forEachOrdered이 이에 해당된다.

 

스트림을 언제 사용 할까?

작업의 복잡도가 얼마나 높은가?

단순히 몇 줄 안되는 반복문은 스트림 사용에 큰 이점이 없다.

다만, 복잡한 로직을 가진 긴 루프는 스트림을 사용하면 가독성과 유지보수성이 크게 향상 될 수 있다.

 

얼마나 함수적인가?

로직이 함수형 접근에 적합하지 않는다면 스트림이 적합하지 않을 수 있다. 필요하지 않은 코드를 스트림에 맞기 강제하는 것은 좋지 않다.

코드가 함수적이고 순수하고 불변하면 스트림과의 호환성도 높아진다.

 

처리되는 요소의 수는?

스트림 파이프라인 구성에 필요한 오버헤드는 처리하는 요소의 수에 반비례한다.

소규모 데이터 소스일 경우 많은 양의 요소를 처리할 때 보다 필요한 인스턴스, 메서드 호출, 스택 프레임, 메모리 사용량 간의 관계가 중요하다.

 

이러한 고민 거리 중 하나만 만족한다 해서 스트림 사용 여부를 결정하는 것은 바람직 하지 않는다 한다.

성능이 코드 설계와 도구 선택에 있어 가장 중요한 기준이 되어서는 안된다고 한다.

 

스트림의 핵심 목표는 최고의 성능이 아니라 데이터 처리에 있어 보다 선언적이고 표현력 있는 방법을 제공하는 것이라 한다.

객체의 시퀀스에 함수형 코드를 적용하는 가장 간단하고 간결한 방법이 스트림이다.

 

로직의 명료함과 가독성, 재사용성과 유지보수성, 연산의 독립성 등 여러 스트림의 장점등을 고려해서 사용에 겁 먹지 않도록 해보자....