728x90

6. 스트림을 이용한 데이터 처리

  • 자바 8에서 도입된 스트림 API는 데이터 처리에 대해 선언적이고 지연 평가된 접근법을 제공한다.

6.1 반복을 통한 데이터 처리

외부 반복

  • FOR-LOOP
    • 명확한 단점은 반복 기반 루프에 필요한 보일러 플레이트 코드가 많다.
    • 루프의 바디에는 continue와 break의 형태로 반복 과정에 대한 의사 결정 및 여러 문장까지 포함할 수 있다.
    • 연속적인 순회 과정이 필요하며, 병렬 데이터 처리가 필요한 경우 전체 루프 재작성해야 하며, 그 과정에서 ConcurrentModificationException과 같은 복잡한 문제를 해결해야한다.
public class ExternalIteration {
    public static void main(String[] args) {
        List<Integer> numbers = Arrays.asList(1, 2, 3, 4, 5);

        int sum = 0;
        for (int number : numbers) {
            sum += number;
        }

        System.out.println("합: " + sum); // Output: Sum: 15
    }
}

내부 반복

  • 개발자가 순회 과정을 직접 제어하는 것을 포기하고, 데이터 소스 자체가 어떻게 수행되는지를 담당하도록 한다.
  • 순회를 직접 제어하기 위해 Iterator 대신, 데이터 처리 로직은 스스로 반복을 수행하는 파이프라인을 사전에 구성한다.
public class InternalIteration {
    public static void main(String[] args) {
        List<Integer> numbers = Arrays.asList(1, 2, 3, 4, 5);

        int sum = numbers.stream()
                .mapToInt(Integer::intValue)
                .sum();

        System.out.println("합: " + sum); // Output: Sum: 15
    }
}

6.2 함수형 데이터 파이프라인으로써의 스트림

  • 내부 반복자의 장점이 있으며, 함수형 관점에서 유리하다.
  • 스트림은 느긋한 순차 데이터 파이프라인으로 간단하게 설명할 수 있다.

선언적 접근법

  • 단일 호출 체인을 통해 간결하고 명료한 다단계 데이터 파이프라인을 구축한다.

조합성

  • 데이터 처리 로직을 위한 고차 함수로 이루어져 있으며, 필요에 따라 조합하여 사용할 수 있다.

지연 처리

  • 모든 요소를 일일이 순회하는 대신, 마지막 연산이 파이프라인에 연결된 이후에 각 요소가 하나씩 순차적으로 파이프 라인을 통해 처리됩니다.
public class LazyExample {
    public static void main(String[] args) {
        List<String> words = Arrays.asList("apple", "banana", "cherry", "date");

        words.stream()
                .filter(word -> {
                    System.out.println("필터: " + word);
                    return word.length() > 5;
                })
                .map(word -> {
                    System.out.println("맵: " + word);
                    return word.toUpperCase();
                })
                .forEach(word -> System.out.println("반복: " + word));
    }
}

성능 최적화

  • 데이터 소스와 사용된 연산의 종류에 따라 순회 방식을 자동으로 최적화합니다.

병렬 데이터 처리

  • 내장된 병렬처리 기능이 있어, 호출 체인에서 단일 호출을 간단히 변경해서 활용할 수 있다.

흐름

  1. 스트림 생성
    • 기존의 데이터 소스에서 스트림을 생성한다.
    • 연속적인 요소를 제공할 수 있다면 스트림으로 변환할 수 있다.
  2. 작업 수행
    • 중간 연산이라 불리는 것들은 java.util.stream.Stream<T>의 메서드로 제공되는 고차 함수들로, 파이프 라인을 통과하는 요소들을 대상으로 하여 필터링, 매핑, 정렬 등 여러 작업을 수행한다.
    • 각 연산은 새로운 스트림을 반환하며, 원하는 만큼의 다양한 중간 연산과 연결될 수 있다.
  3. 결과 얻기
    • 데이터 처리 파이프라인을 완료하기 위해 스트림 대신 결과를 반환하는 마지막 종료 연산이 필요하다.

스트림의 특성

느긋한 계산법

  • 스트림에서 중간 연산을 수행할 때 즉각적으로 싱행되지 않는다. 해당 호출은 파이프라인을 단순히 확장하고 새롭게 지연 평가된 스트림을 반환한다.
  • 모든 연산을 모아놓고, 종료 연산을 호출하기 전까지 아무런 작업도 시작하지 않는다.
  • 스트림은 데이터 소스로써, 누군가 더 많은 요소를 요청하지 않는 한 요소를 과도하게 제공하거나 버퍼링할 필요가 없다.
  • 스트림 요소의 흐름은 깊이 우선방식을 따르며, 이를 통해 필요한 CPU 주기, 메모리 사용량, 스택의 깊이를 줄일 수 있다.

(대부분) 상태 및 간섭 없음

  • 스트림은 변경 불편한 원칙을 따른다.
  • 스트림은 간섭하지 않고 통과하는 파이프라인으로, 해당 요소들이 가능한 한 자유롭게, 간섭 없이 통과해야 한다.

최적화

  • 스트림은 성능 햐상을 위해 여러 전략을 활용한다
    • (무상태의) 연산 융합
    • 불필요한 연산 제거
    • 단축 파이프라인 경로

보일러 플레이트 최소화

  • filter, map, findFirst 와 같이 간결하면서도 직관적인 연산으로, 데이터 처리 로직의 복잡성을 명료하고 표현력 있게 풀어낸다.

재사용 불가능

  • 스트림 파이프라인은 단 한 번만 사용할 수 있다. 스트림 파이프라인은 데이터 소스에 연결되어 있으며, 종료 연선이 호출된 후 정확히 한 번만 전달된다.

원시 스트림

  • 스트림과 그 변형(IntStream, LongStream, DoubleStream)들은 모두 BaseStream이라는 기본 인터페이스를 기반으로 한다.

쉬운 병렬화

  • 스트림은 처음부터 병렬 실행을 효율적으로 지원하도록 설계되었고, 자바 7에서 도입된 Fork/Join 프레임워크를 기반으로 한다.
  • 모든 스트림 파이프라인이 병렬 처리에 적합하지 않다. 원본은 충분한 데이터를 포함해야 하며, 연산이 여러 스레드의 오버헤드를 수용할 만큼의 비용을 감당할 수 있어야 한다.

예외 처리의 한계

  • 스트림은 데이터 처리에 함수형 접근 방식을 도입하여 코드를 더 간결하게 만들어준다.

스트림의 핵심, Spliterator

  • 스트림도 자체 반복 인터페이스인 java.util.Spliterator<T>를 사용한다.
    • Spliterator은 특정 특성을 기반으로 요소의 일부를 다른 Spliterator로 분리할 수 있다.
    • 위처럼 독특한 성질로 인해 스트림 API의 핵심 요소가 되며, 스트림은 부분적인 시퀀스들을 병렬로 처리하면서 자바의 Collection API 타입을 수회할 수 있게 한다.

Spliterator의 특성

  • 동시성
  • 고유성
  • 불변성
  • 널아님
  • 순서
  • 정렬
  • 크기
  • 부분크기

6.3 스트림 파이프라인 구축하기

스트림 생성하기

  • stream 메서드는 List나 Set과 같은 Collection 기반의 자료 구조에서 새로운 스트림 인스턴스를 생성하는 가장 간단한 방법

실습

  • 스트림의 요소를 다루는 것은 중간 연산을 통해 이루어진다. 이 연산들은 크게 변환(MAP), 선택(FILTER), 또는 일반적인 스트림 동작 수정을 수정하는 것.

요소 선택

  • Predicate를 이용한 필터링 혹은 요소의 개수를 기반으로 선택함으로써 이루어진다.
Steam filter(Predicate<? super T> predicate)
  • Predicate의 결과가 true인 경우 해당 요소는 후속 처리를 위해 선택.
Steam dropWhile(Predicate<? super T> predicate)
  • Predicate가 true가 될때까지 통과하는 모든 요소를 폐기.
  • 해당 기능은 ORDERED(순서가 있는) 스트림을 위해 설계되었다.
Steam takeWhile(Predicate<? super T> predicate)
  • Predicate가 false가 될때까지 통과하는 모든 요소를 폐기.
Steam limit(long maxSize)
  • 연산을 통과하는 요소의 최대 개수를 maxSize로 제한
Steam skip(long n)
  • limit의 반대 개념으로, 앞에서부터 n개의 요소를 건너뛰고 나머지 요소들을 다음 스트림 연산으로 전달
Steam distinct()
  • Object#equals (Object)를 통해 요소들을 비교하며 중복되지 않은 요소만 반환.
  • 해당 연산은 요소를 비교하기 위해 모든 요소를 버퍼에 저장
Steam sorted()
  • java.util.Comparable에 부합하는 경우 자연스럽게 정렬된다.
Steam sorted(Comparator<? super T> comparator)
  • 사용자 정의 comparator를 사용하여 정렬하는 더 유연한 방식 제공

요소 매핑

Stream map(Function<? super T, ? extends R> mapper)
  • mapper함수가 요소에 적용되고 새로운 요소가 스트림으로 반환
Stream flatMap(Function<? super T, ? extends Stream<? extendsR>> mapper)
  • mapper함수가 요소에 적용되지만, 새로운 요소를 반환하는 대신 Stream을 반환해야 한다.
  • flatMap 연산은 컬렉션 또는 Optional과 같은 컨테이너 형태의 요소를 '펼쳐서' 새로운 다중 요소를 포함하는 새로운 스트림으로 만든다.
Stream mapMulti(BiConsumer<? super T, ? super Consumer> mapper)
  • mapMulti 연산은 매퍼가 스트림 인스턴스를 반환할 필요가 없고, Consumer가 요소를 슽트림을 통해 더 아래로 전달합니다.
    • map과 flatMap의 두 연산을 하나로 압축할 수 있다.

스트림에서 Peek사용

  • 스트림의 요소에 개입하지 않고 스트림을 살짝 들여다 본다.
  • peek 연산은 주로 디버깅을 지원하기 위해 설계되었다.

스트림 종료하기

  • 최종연산은 요소를 실제로 처리하여 결과나 사이드 이펙트를 생성하기 위한 슽릠 파이프라인의 마지막 단계

요소 축소

  • 축소 연산은 fold 연산이라고도 하며, 누적 연산자를 반복적으로 적용하여 스트림의 요소들을 하나의 결괏값으로 만든다.
  • 축소는 아래 세 가지로 구분
    • 요소
      • 데이터 처리의 핵심은 데이터 요소를 처리하는 것
    • 초기값
      • 모든 데이터의 축적은 특정 지점에시 시작. 이 시작점을 '초기값'이락 ㅗ한다.
    • 누적 함수
      • 축소 로직은 현재 요소와 이전의 결과 또는 초기값만으로 작동한다.
T reduce(T identity, BinaryOperator accumulator)
  • 이 메서드에서 identity는 accumulator 연산을 시작할 때의 초기값, 즉 '시드'값
Optional reduce(BinaryOperator accumulator)
  • 이 연산은 초기값을 필요로 하지 않는다. 스트림의 첫 번째 요소가 초기값으로 사용된다.
  • 스트림이 어떠한 요소도 포함하고 있지 않다면 반호나값은 비어있는 Optional가 된다.
U reduce(U identity, BiFunction<U, ? super T, U> accumulator, BinaryOperator combiner)
  • map과 reduce를 결합한 변형.
  • 스트림에는 T타입의 요소가 포함되어 있지만 최종적으로 원하는 축소 결과가 U타입인 경우 이 메서드를 사용
  • 혹은 map과 reduce를 명시적으로 사용할 수 있다.
컬렉터를 활용한 요소 집계
  • Stream의 종료 연산자인 collect는 요소들을 집계할 때 Collector를 사용한다.
  • 스트림 요소들을 단일 결과로 합치기 위해 누적 연산자를 반복적으로 적용하는 대신, 이 연산들은 가변 결과 컨테이너를 중간 자료 구조로 활용한다.
  1. Supplier<a> supplier()
    • 수집 과정 중 사용되는 가변 결과 컨테이너의 새 인스턴스 반환
  2. Biconsumer<A, T> accumulator()
    • 결과 컨테이너와 현재 요소를 인수로 받아, 타입 T의 스트림 요소들을 타입 A의 컨테이너에 추가.
  3. BinaryOperator<A> combiner()
    • 병렬로 스트림을 처리할 때 여러 누적기들이 동시에 작업을 수행할 수 있다.
  4. Function<A, R> finisher()
    • 중간 결과 컨테이너를 타입 R의 반환 객체로 변환하는 역할
  5. 최종 결과
    • List, Map 또는 단일값을 포함하여 수집된 인스턴스를 의미.
java.util.Collection 타입으로 수집
  • toCollection(Supplier collectionFactory)
  • toList()
  • toSet()
  • toUnmodifiableList()
  • toUnmodifiableSet()
java.util.Map 타입으로 수집
  • toMap()
  • toConcurrentMap()
  • toUnmodifiableMap()
그룹화된 java.util.Map으로 수집
  • groupingBy()
  • groupingByConcurrent()
분할된 java.util.Map으로 수집
  • partitionBy()
산술 및 비교 연산
  • averagingInt(ToIntFunction<? super T> mapper)
  • summingInt(ToIntFunction<? super T> mapper)
  • summarizingInt(ToIntFunction<? super T> mapper)
  • counting()
  • minBy(Comparator<? super T> comparator)
  • maxBy(Comparator<? super T> comparator)
문자열 연산
  • joining()
고급 응용 사례
  • reducing()
  • collectingAndThen(Collector<T, A, R> downstream, Function<R, RR> finisher)
  • mapping(Function<? super T, ? extends U> mapper, Collector<? super U, A, R> downstream)
  • filtering(Predicate<? super T> predicate, Collector<? super T, A, R> downstream)
  • teeing(Collector<? super T, A1, R1> downstream1, Collector<? super T, A2, R2> downstream2, BiFunction<R1, R2, R> merger)

요소를 집적 집계

List 반환

  • 컬렉터 기반의 워크플로 없이 요소를 집계하므로 더 적은 메모리가 필요.
  • collect(Collectors.toList())를 사용할 때와 동일하게 반환되는 목록의 구현 탕비이나 직렬화에 대한 보장이 없음

배열 반환

  • Object[] toArray()
  • A[] toArray(IntFunction<A[]> generator)

요소 찾기 및 매칭

  • Optional findFirst()
    • 첫번째로 만나는 스트림의 요소 반환
  • Optioanl findAny()
    • 임의의 요소 반환, 비어있다면 Optional를 반환
  • boolean allMatch(Predicate<? super T> predicate)
    • 모든 요소가 일치하면 true
  • boolean anyMatch(Predicate<? super T> predicate)
    • 일치하는 요소가 하나라도 존재하면 true
  • boolean noneMatch(Predicate<? super T> predicate)
    • 일치하는 요소가 스트림에 없으면 true

요소 소비

  • 사이드 이펙트 전용 연산
  • forEach(Consumer<? super T> action)
    • 요소마다 주어진 동작을 수행, 병렬 스트림에서 최적의 성능을 위해 실행 순서는 명시적으로 비결정적
  • void forEachOrdered(Consumer<? super T> action)
    • 스트림이 Ordered 상태라면 만나게 되는 순서에 따라 요소에 대한 action 실행

연산 비용

6.4 스트림 사용 여부 선택

  • 작업의 복잡도
  • 작업의 내용을 쉽게 파악할 수 있다면 간단한 for-each 반복문을 사용하는 것이 더 나을 수 있다.

스트림 파이프라인이 얼마나 함수적인가?

  • 함수형 접근 방식에 적합하지 않다면 스트림이 제공하는 모든 혜택과 안정성을 보장받지 못할 것.

처리 되는 요소의 수는?

  • 스트림 파이프 라인 구성에 필요한 오버헤드는 처리하는 요소의 수에 비례하여 감소
반응형
코드플리