Stream이란?

NNIIE·2023년 3월 22일
0

java

목록 보기
6/7
post-thumbnail
post-custom-banner

자바 스트림(Stream)은 자바 8에서 도입된 데이터 처리 기능으로 데이터 컬렉션에 함수형 프로그래밍 스타일을 적용하여 효율적으로 처리할 수 있다.
스트림은 연속된 데이터 요소를 다루며, 중간 연산과 최종 연산을 통해 복잡한 데이터 처리 작업을 간결하게 표현한다.

스트림을 사용하면, 기존의 반복문과 조건문을 사용하는 코드를 간단하게 대체할 수 있으며 또한, 스트림은 병렬 처리를 지원하여 성능 향상에 도움이 된다.





특징

  • 스트림은 외부반복을 통해 작업하는 컬렉션과 달리 내부반복을 통해 작업을 수행한다.
  • 스트림은 한번 사용하면 닫히며, 재사용이 불가능하다.
  • 스트림은 원본데이터를 변경하지 않는다.
  • 중간연산은 최종연산이 수행되기 전까지 실제로 시행되지 않는다. 이걸 지연연산 이라고 한다.
  • Paraller Stream 으로 손쉽게 병렬처리를 지원한다.




동작원리


stream()을 만나는 순간

스트림은 내부적으로 Spliterator라는 인터페이스를 사용해 데이터를 처리하는데 데이터 구조에 따라 다양한 방식의 Spliterator 인터페이스 구현체를 제공한다.
이를통해 각 데이터구조에 따라 최적화된 처리가 가능하다.

  • Spliterator : 스트림의 데이터 소스를 분할하고 순차적으로 처리할 수 있는 기능을 제공한다.

.stream()을 만나는 순간 StreamSupport.stream 메서드를 사용해 적절한 스트림을 생성한다.
이때 spliterator() 메서드를 호출하여 각 데이터 구조에 맞는 Spliterator를 얻는다.
각 데이터 구조 클래스(ArrayList, LinkedList, HashSet 등)는 spliterator() 메서드를 오버라이드하여 해당 데이터 구조에 맞는 Spliterator 구현체를 반환하게 된다.

Stream<E> stream() {
	return StreamSupport.stream(spliterator(), false);
}

예를들면 다음과 같은 데이터 구조에 맞는 Spliterator 구현체를 제공받는다.

  • ArrayList
    • 인덱스를 사용하여 분할 및 순차 처리를 할 수 있는 Spliterator를 반환
  • LinkedList
    • 연결된 노드를 사용하여 분할 및 순차 처리를 할 수 있는 Spliterator를 반환

즉, .stream() 메서드를 호출할 때, 각 데이터 구조 클래스가 알고 있는 자신의 구조에 대한 정보를 바탕으로 적절한 Spliterator 구현체를 생성하고 반환한다.
이렇게 생성된 Spliterator는 스트림의 구현에 사용되어 데이터 구조에 따라 최적화된 처리를 수행할 수 있다.

데이터 구조에 대한 세부 사항을 몰라도 스트림을 사용하여 데이터를 처리할수 있는데, 그이유는 스트림이 고수준의 추상화를 제공하고 있기 때문이라고 생각한다.


지연 연산

지연 연산(Lazy Evaluation)은 실제로 값이 필요한 시점까지 연산을 미루는 걸 의미한다.
중간연산은 스트림을 반환하지만, 최종연산이 호출되기 전까지 수행되지 않는다.
스트림은 중간연산을 누적하고, 최종연산이 호출될 때 한꺼번에 처리한다.
이로인해 성능 및 메모리 효율을 향상시킬 수 있다.
반복문과 조건문으로도 유사한 성능을 얻을 수 있지만 스트림을 사용하면 매우 편리하게 구현할 수 있다.

List<Integer> numbers = IntStream.rangeClosed(1, 100)
								.boxed()
								.collect(Collectors.toList());

// 50보다 큰 첫 번째 짝수를 찾는 로직
numbers.stream()
	.filter(number -> number > 50)
	.filter(number -> number % 2 == 0)
	.findFirst()
	.orElse(0);


// 반복문으로
int result = 0;
for (Integer number : numbers) {
	if (number > 50 && number % 2 == 0) {
		result = number;
		break;
	}
}

장점

  • 성능향상
    필요한 데이터만 처리하여 불필요한 연산을 줄일 수 있다.
    예를 들어, filter 연산을 먼저 수행한 후 map 연산을 수행하면 필터링된 요소들에 대해서만 map 연산이 수행되어 연산량이 줄어든다.

  • 메모리 효율성
    필요한 시점에만 연산을 처리함으로써, 메모리에 저장되어야 할 중간 결과물이 줄어든다.
    예를들어, filtermap연산을 처리할 경우 지연 연산이 없다면 filter 연산을 수행한 결과를 새로운 컬렉션에 저장한 후 그 결과를 기반으로 map 연산을 수행해야 하는데 이렇게 되면 중간 결과를 저장하기 위한 추가 메모리가 필요하게 된다.


Parallel Stream

Parallel Stream은 기존의 스트림에 비해 여러 코어에서 동시에 실행될 수 있는 스트림이다. Parallel Stream은 내부적으로 ForkJoinPool을 사용하여 작업을 여러 스레드로 분할한다.


ForkJoinPool

Parallel Stream은 내부적으로 ForkJoinPool을 사용하여 작업을 여러 스레드로 분할한다.

  • ForkJoinPool의 스레드는 작업 큐에서 작업을 가져와 처리한다. 작업이 끝나면 다른 스레드의 작업 큐에서 작업을 가져와 처리하기 때문에 스레드 간의 작업 부하를 균등하게 분배할 수 있다
  • ForkJoinPool은 스레드의 생성과 복구 비용을 최소화하여 성능을 개선한다.
  • 커스텀 해서 사용할 수 있지만 많은 수의 ForkJoinPool를 생성하면 스레드 경쟁이 일어나게 되서 가능하면 기본 ForkJoinPool을 사용하는게 좋다
// 커스텀 할 별도의 ForkJoinPool을 생성
ForkJoinPool customForkJoinPool = new ForkJoinPool(4); 

// 별도의 ForkJoinPool에서 병렬 스트림을 실행
customForkJoinPool.submit(() -> {
	numbers.parallelStream().reduce(0, Integer::sum);
}).join();

customForkJoinPool.shutdown(); 

paraller() 을 만나는 순간

  • 스트림은 데이터를 여러 파티션으로 나눈다.
  • 각각의 파티션은 별도의 스레드에서 처리된다.
  • 처리가 완료되면 결과가 다시 하나의 스트림으로 병합된다.
IntStream.rangeClosed(1, 100)
	.parallelStream()
	.filter(i -> i % 2 == 0)
	.boxed()
	.collect(Collectors.toList());

trade-off

  • 병렬 스트림은 작업 분할, 스레드 스케줄링, 결과 병합 등의 오버헤드가 발생하기 때문에 데이터셋의 크기가 작거나 단순한 연산의 경우 오히려 성능이 떨어진다.
  • 상태를 공유하는 경우, 동기화가 필요하므로 성능이 저하될 수 있다.
  • 스트림이 순차적인 로직에 의존하는 경우 사용할 수 없다.

사용전략

  • 순차스트림과의 성능차이를 JMH라이브러리를 통해 비교
  • 큰 데이터셋에서 사용
  • 상태를 공유하지 않는 무상태(stateless) 연산을 사용하면 동기화 비용을 줄일 수 있다.
  • 순서를 중요하지 않게 처리




왜쓰는데?

  • 추상화

    • 스트림은 다양한 데이터 소스와 동작을 처리할 수 있는 공통 인터페이스를 제공하고, 이를 통해 데이터 구조의 세부 사항에 대해 몰라도 같은 방법으로 데이터 처리 작업을 효율적으로 수행할 수 있다.
    • 즉, 기존에는 저장된 데이터에 접근하기 위해 반복문등을 사용해 접근해야 했고 이렇게 작성된 코드는 가독성저하 및 재사용이 불가능 하며 정형화된 처리패턴이 없어 데이터마다 다른방법으로 접근해야 했던걸 개선했다.
  • 가독성

    • 스트림은 함수형 프로그래밍 스타일을 지원하며, 이를 통해 간결하고 가독성 좋은 코드를 작성할 수 있다.
  • 성능 최적화

    • 스트림은 지연 연산(Lazy Evaluation)을 통해 필요한 시점에만 데이터 처리를 수행한다.
  • 병렬처리

    • 따로 병렬처리 로직을 작성할 필요 없이 간단한 메서드를 이용해 병렬처리를 지원한다.




trade-off

  • 스트림의 연산은 메서드 호출에 따른 오버헤드가 존재하기 때문에 반복문에 비해 성능 면에서 약간의 손실이 발생할 수 있기 때문에 성능이 매우 중요한 상황에선 반복문을 사용하는것이 더 나을 수 있다.
  • 스트림은 메서드 체인을 사용해 연산을 하기 때문에 중간연산 또는 최종연산에 문제가 생길 경우 전체 메서드 체인을 따라가며 디버깅 해야한다.




스트림으로 본 객체지향?

스트림은 객체지향 프로그래밍의 여러 원칙과 개념을 잘 활용하고 있다.


SRP - 단일 책임 원칙

List<String> names = Arrays.asList("tom","john","eille"...);
List<String> filterNames = names.stream()
	.filter(name -> name.length() >= 5)
    .map(String::toUpperCase)
    .sorted()
    .collect(Collectors.toList());

Stream 인터페이스에 정의된 메서드들은 하나의 책임만을 가지고 있다.

  • Stream<T> filter(Predicate<? super T> predicate);
    • 스트림의 요소중 Predicate 조건에 맞는 요소만 포함하여 새로운 스트림으로 리턴
  • <R> Stream<R> map(Function<? super T, ? extends R> mapper);
    • 스트림의 각 요소를 mapper 함수를 이용해 변환하고 변환된 요소를 포함하는 새로운 스트림으로 리턴
  • Stream<T> sorted(Comparator<? super T> comparator);
    • 스트림의 요소를 기본정렬순서로 정렬한 새로운 스트림을 리턴
    • 인자로 Comparator를 받아서 원하는 순서로 정렬도 가능

ISP - 인터페이스 분리 원칙
Stream API 는 여러 작은 인터페이스로 구성되어 있다.

  • public interface Stream<T> extends BaseStream<T, Stream<T>> {}
    • 일반 객체를 처리하는 스트림에 대한 연산을 제공하는 인터페이스
  • public interface IntStream extends BaseStream<Integer, IntStream> {}
    • int type 의 Primitive Type을 처리하는 스트림에 대한 연산을 제공하는 인터페이스
  • public interface DoubleStream extends BaseStream<Double, DoubleStream> {}
    • double type 의 Primitive Type을 처리하는 스트림에 대한 연산을 제공하는 인터페이스
  • public interface LongStream extends BaseStream<Long, LongStream> {}
    • long type 의 Primitive Type을 처리하는 스트림에 대한 연산을 제공하는 인터페이스

DIP-의존 역전 원칙

ArrayList<Integer> arrayList = new ArrayList<>(Arrays.asList(8, 2, 11, 21, 14, 7, 19));
LinkedList<Integer> linkedList = new LinkedList<>(Arrays.asList(8, 2, 11, 21, 14, 7, 19));
  
int arrayListSum = arrayList.stream()
	.filter(i -> i > 10)
  	.mapToInt(i -> i * i)
    .reduce(0, Integer::sum);
  
int linkedListSum = linkedList.stream()
	.filter(i -> i > 10)
  	.mapToInt(i -> i * i)
    .reduce(0, Integer::sum);  

Stream은 고수준의 추상화를 제공하기 때문에 우리는 세부 구현사항을 몰라도 동일한 방법으로 편하게 사용할 수 있다.

  • 스트림은 내부에서 Spliterator 인터페이스를 사용해 데이터를 처리한다.
  • 실제로 코드에서 .stream() 을 만나는 순간 각 data structure에 최적화된 Spliterator 구현체가 생성된다.
  • ArrayList
    • 인덱스를 사용하여 분할 및 순차처리에 최적화된 ArrayListSpliterator<E> implements Spliterator<E> {} 구현체 반환
  • LinkedList
    • 연결된 노드를 사용하여 분할 및 순차처리에 최적화된 LLSpliterator<E> implements Spliterator<E> {} 구현체 반환

이러한 점이 우리가 코드를 작성할 때, 어떤 자료구조에서든 .stream() 메서드 하나로 편리하게 사용할 수 있는 이유이다.





사용법


중간연산

필터링, 맵핑, 정렬등의 작업을 수행

  • filter(Predicate<T>) : 조건에 맞는 요소만 걸러내는 작업을 수행
  • map(Function<T, R>) : 각 요소를 다른 형태의 요소로 변환하는 작업을 수행
  • flatMap(Function<T, Stream<R>>) : 각 요소를 여러 개의 요소로 변환한 후, 하나의 스트림으로 합침
  • distinct() : 중복을 제거한 요소만 포함
  • sorted() : 스트림의 요소를 정렬, 기본적으로 요소의 자연 순서에 따라 정렬되며, Comparator를 인자로 전달하여 사용자 지정 정렬을 할 수도 있다.
  • peek(Consumer<T>) : 스트림의 요소를 소비하지 않고, 특정 작업을 수행할 수 있다.


최종연산

결과를 반환하거나 특정 동작을 수행

  • forEach(Consumer<T>) : 스트림의 모든 요소에 대해 작업을 수행
  • toArray() : 스트림의 요소를 배열로 반환
  • reduce() : 스트림의 요소를 줄여 하나의 결과 값을 만듬
  • collect() : 스트림의 결과를 수집하여 다양한 형태로 반환, 주로 리스트, 셋, 맵 등의 컬렉션을 반환할 때 사용
  • min(Comparator<T>) : 스트림의 요소 중 최소값을 찾는다. Comparator를 인자로 전달하여 사용자 지정 기준으로 최소값을 찾을 수 있다.
  • max(Comparator<T>) : 스트림의 요소 중 최대값을 찾는다. Comparator를 인자로 전달하여 사용자 지정 기준으로 최대값을 찾을 수 있다.
  • count() : 스트림의 요소 개수를 반환
  • anyMatch(Predicate<T>) : 스트림의 요소 중 하나라도 주어진 조건에 만족하면 true를 반환
  • allMatch(Predicate<T>) : 스트림의 모든 요소가 주어진 조건에 만족하면 true를 반환
  • noneMatch(Predicate<T>) : 스트림의 모든 요소가 주어진 조건에 만족하지 않으면 true를 반환
  • findFirst() : 스트림의 첫 번째 요소를 반환
  • findAny() : 스트림의 임의의 요소를 반환
post-custom-banner

0개의 댓글