[Java] 20. 스트림(Stream) 이란?


일반적으로 List 데이터 구조를 Collection이나 Iterator와 같은 인터페이스를 이용해서 컬렉션을 다룬다. Map이나 Array 데이터 구조도 그에 맞는 인터페이스를 이용헤 컬렉션을 다룬다. Stream은 데이터구조 어떠한 간에 같은 방식으로 다룰 수 있게 된다.


1. 스트림(Stream)이란?

스트림은 데이터 처리 과정에서 코드를 더 간결하고 명확하게 만들어주는 장점을 갖고 있습니다. 또한, 병렬 처리를 통해 멀티코어 CPU를 활용하여 성능을 향상시킬 수 있는 기능도 제공합니다. Java 8부터 스트림이 도입되었으며, 이후 버전에서 기능이 확장되고 개선되었다.

String[] strArr = { "eee", "fff", "aaa" };
List<String> strList = Arrays.asList(strArr);
Stream<String> strStream1 = Arrays.stream(strArr);
Stream<String> strStream2 = strList.stream();

아래 데이터는 함수형 프로그래밍이 때문에 각 객체는 sort 된 상태로 출력은 되지만
불변성으로 strStream1, strStream2는 원데이터는 변하지 않는다.

strStream1.sorted().forEach(System.out::println);
strStream2.sorted().forEach(System.out::println);


스트림 장점

1. 간결하고 가독성이 높은 코드 작성

스트림을 이용하면 코드를 간결하게 작성할 수 있습니다. 내부 반복을 사용하여 코드의 가독성을 높이고, 복잡한 반복문 없이 데이터 처리를 할 수 있습니다.

2. 높은 추상화 수준과 유연성

스트림은 고수준의 추상화를 제공하여 데이터를 다루는 방식을 명확하게 표현할 수 있습니다. 데이터의 변형, 필터링, 집계 등을 간단한 메서드 체인으로 표현할 수 있습니다.

3. 병렬 처리 지원

스트림은 병렬 처리를 지원합니다. 이를 통해 멀티코어 CPU를 활용하여 데이터를 병렬로 처리할 수 있어 대용량 데이터의 처리에서 성능을 향상시킬 수 있습니다.

4. 지연 연산(Lazy Evaluation)

중간 연산들은 최종 연산이 호출될 때까지 실제로 데이터를 처리하지 않습니다. 이는 연산을 최적화하고 필요한 시점에만 처리를 수행하여 성능을 향상시킵니다.

5. 내부 반복을 통한 선언적 코드 작성

스트림은 내부 반복을 통해 개발자가 데이터 처리 과정을 명시적으로 구현하지 않아도 됩니다. 이는 선언적 프로그래밍 스타일을 촉진하며, 높은 추상화 수준에서 데이터 처리를 할 수 있도록 도와줍니다.

스트림은 Java의 함수형 프로그래밍 스타일과 잘 어울려 데이터 처리를 더욱 효과적으로 할 수 있게 해줍니다. 이러한 장점들로 인해 스트림은 Java 개발에서 데이터 처리를 간편하고 효율적으로 수행할 수 있는 강력한 도구로 인정받고 있습니다.



2. 지연된 연산(Lazy Evaluation)

스트림 연산에서 최종 연산이 수행되기 전까지는 중간 연산이 수행되지 않는다. 최종 연산이 수행되어야 스트림의 요소들이 중간 연산을 거쳐 최종 연산에서 처리된다.

중간 연산자는 실제로 데이터를 처리하지 않고 새로운 스트림을 생성한다. 스트림을 변환하거나 필터링하여 새로운 스트림을 반환하며 이들은 연속적으로 체이닝될 수 있고, 이 때 Lazy Evaluation의 특성을 갖는다. 중간 연산은 최종 연산이 호출될 때까지 실제로 처리되지 않으며, 중간 연산들의 체인은 연산을 연기한다.

최종 연산자는 스트림의 요소를 소비하고 결과를 반환합니다. 이들은 스트림을 닫는 역할을 하며, 최종 연산이 호출되어야 중간 연산자들이 실제로 동작합니다. 최종 연산이 호출되기 전까지는 중간 연산들이 실제로 실행되지 않는다.

그래서 아래 예제코드 콘솔 결과를 보면 최종연산이 없는 stream은 실행되지 않는 것을 확인할 수 있다.

최종연산자와 중가연산의 지연된 연산 예제 코드

public class ExStreamLazyEvaluation {

    public static void main(String[] args) {
        List<Integer> numbers = Arrays.asList(1, 2, 3, 4, 5, 6, 7, 8, 9, 10);

        System.out.println("스트림 생성 후 중간 연산 적용 전");

        // 중간 연산을 수행한 스트림, 실제 계산이 수행되지 않음
        numbers.stream()
                .filter(n -> {
                    System.out.println("[A] Filtering number: " + n);
                    return n % 2 == 0;
                })
                .map(n -> {
                    System.out.println("[A] Mapping number: " + n);
                    return n * n;
                });

        System.out.println("중간 연산만 적용한 후 - 아무 출력 없음");

        // 최종 연산을 호출할 때 중간 연산이 실제로 수행됨
        long count = numbers.stream()
                .filter(n -> {
                    System.out.println("[B] Filtering number: " + n);
                    return n % 2 == 0;
                })
                .map(n -> {
                    System.out.println("[B] Mapping number: " + n);
                    return n * n;
                })
                .count(); // 최종 연산

        System.out.println("최종 연산(count) 호출 후 - 중간 연산이 실행됨");
        System.out.println("최종 결과: " + count);
    }
}
스트림 생성 후 중간 연산 적용 전
중간 연산만 적용한 후 - 아무 출력 없음
[B] Filtering number: 1
[B] Filtering number: 2
[B] Mapping number: 2
[B] Filtering number: 3
[B] Filtering number: 4
[B] Mapping number: 4
[B] Filtering number: 5
[B] Filtering number: 6
[B] Mapping number: 6
[B] Filtering number: 7
[B] Filtering number: 8
[B] Mapping number: 8
[B] Filtering number: 9
[B] Filtering number: 10
[B] Mapping number: 10
최종 연산(count) 호출 후 - 중간 연산이 실행됨
최종 결과: 5


  • 스트림은 위에 예제코드에서 볼 수 있는 듯이 데이터 소스로부터 read만 할뿐 원래 데이터 소스를 변경하지 않고 최종연산된 결과를 반환한다.
  • 스트림은 일회성이다. 중간연산(stream 생성) 후에 최종연산(stream 닫음)을 하면 다시 사용할 수 없다.
  • 스트림은 작업을 내부 반복으로 처리한다. 반복문을 forEach 메서드 매개변수로 전달해서 내부에서 처리한다.



3. 스트림의 최종연산

최종 연산자는 스트림 파이프라인의 끝을 나타내며, 스트림의 요소를 소모하여 실제 결과를 반환합니다. 이 연산자가 호출되기 전까지 중간 연산은 실제로 수행되지 않습니다. 최종 연산자는 스트림을 닫는 역할을 한다.

예를 들어, forEach(), collect(), reduce() 등이 최종 연산자에 해당합니다. 이들을 통해 스트림의 요소를 반복하거나, 리스트, 맵, 집계된 결과를 얻을 수 있디.

최종 연산설명
void forEach(Consumer<? super T> action)
void forEachOrdered(Consumer<? super T> action)
각 요소에 지정된 작업 수행
long count()스트림의 요소의 개수 반환
Optional<T> max(Comparator<? super T> comparator)
Optional<T> min(Comparator<? super T> comparator)
스트림의 최대값/최소값 반환
Optional<T> findAny()
Optional<T> findFirst()
스트림의 요소 아무거나 하나를 반환
스트림의 첫번째 요소 하나를 반환
boolean allMatch(Predicate<T> p)
boolean anyMatch(Predicate<T> p)
boolean noneMatch(Predicate<T> p)
모든 요소를 만족하는지 확인
하나라도 요소를 만족하는지 확인
모든 요소를 만족시키지 않는지 확인
Object[] toArray()
A[] toArray(IntFunction<A[]> generator)
스트림의 모든 요소를 배열로 반환
Optional<T> reduce(BinaryOperator<T> accumulator)
T reduce(<T> identity, BinaryOperator<T> accumulator)
U reduce(<U> identity, BiFunction<U,T,U> accumulator, BinaryOperator<U> combiner)
스트림의 요소를 하나씩 줄여가면서(리듀싱) 계산
R collect(Collector<T,A,R> collector)
R collect(Supplier<R> supplier, BiConsumer<R,T> accumulator, BiConsumer<R.R> combiner)
주로 요소를 그룹화하거나 분할한 결과를 컬렉션으로 반환


4. 스트림의 중간연산

중간 연산자는 스트림을 반환하며, 연속적으로 연산을 수행할 수 있습니다. 이들은 ‘게으른(lazy)’ 특성을 갖고 있어 최종 연산이 호출되기 전까지 실제 요소를 처리하지 않습니다. 주로 데이터를 변환하거나 필터링하고, 스트림의 요소를 수정하지 않고 새로운 스트림을 생성한다.

예를 들어, map(), filter(), distinct() 등이 중간 연산자에 속합니다. 이들을 연달아 사용하여 원하는 데이터 변형이나 필터링을 수행할 수 있습니다.

중간 연산설명
Stream<T> filter(Predicate<? super T> predicate)주어진 조건에 맞는 요소만을 걸러냅니다.
<R> Stream<R> map(Function<? super T, ? extends R> mapper)요소들을 다른 형태로 변환합니다.
flatMap(Function<? super T, ? extends Stream<? extends R» mapper)각 요소에 대해 매핑 함수를 적용하고, 이 함수는 각 요소를 다른 스트림으로 변환
Stream<T> distinct()중복을 제거합니다.
Stream<T> sorted()요소들을 정렬합니다.
Stream<T> sorted(Comparator<? super T> comparator)요소들을 정해진 순서대로 정렬합니다.
Stream<T> limit(long maxSize)요소 개수를 제한합니다.
Stream<T> skip(long n)처음 몇 개의 요소를 건너뜁니다.
Stream<T> peek(Consumer<? super T> action)각 요소를 가져와서 추가적인 작업을 수행하고 스트림을 반환합니다.

위의 각 중간 연산은 스트림을 변환하거나 필터링하거나 제한하는 등 다양한 작업을 수행합니다. 이러한 중간 연산자들을 조합하여 다양한 데이터 처리 작업을 할 수 있습니다.



5. 병렬 스트림(Parallel Stream)

내부적인 병렬 처리 스트림은 내부적으로 Fork/Join 프레임워크를 이용하여 요소들을 분할하고 멀티스레드를 이용하여 병렬적으로 처리한디. 이때 각 요소는 별도의 스레드에서 중간연산을 처리 후에 최종연산에서 같이 처리한다.

public class ExParallelStream {

    public static void main(String[] args) {
        List<Integer> numbers = Arrays.asList(1, 2, 3, 4, 5, 6, 7, 8, 9, 10);

        // 리스트의 요소를 병렬로 처리하는 병렬 스트림 생성
        long count = numbers.parallelStream()
                .filter(n -> {
                    System.out.println("Thread Name=[" + Thread.currentThread().getName()  + "], Filtering number: " + n);
                    return n % 2 == 0;
                })
                .count(); // 요소 개수를 세는 최종 연산

        System.out.println("병렬 스트림 처리 결과: " + count);
    }
}
Thread Name=[ForkJoinPool.commonPool-worker-17], Filtering number: 6
Thread Name=[ForkJoinPool.commonPool-worker-5], Filtering number: 9
Thread Name=[ForkJoinPool.commonPool-worker-13], Filtering number: 4
Thread Name=[ForkJoinPool.commonPool-worker-9], Filtering number: 5
Thread Name=[ForkJoinPool.commonPool-worker-23], Filtering number: 2
Thread Name=[ForkJoinPool.commonPool-worker-31], Filtering number: 10
Thread Name=[ForkJoinPool.commonPool-worker-19], Filtering number: 3
Thread Name=[main], Filtering number: 7
Thread Name=[ForkJoinPool.commonPool-worker-27], Filtering number: 8
Thread Name=[ForkJoinPool.commonPool-worker-3], Filtering number: 1
병렬 스트림 처리 결과: 5



6. 스트림 만들기

Array, List Stream 생성

String[] strArr = { "eee", "fff", "aaa" };
List<String> strList = Arrays.asList(strArr);

Stream<String> strStream1 = Arrays.stream(strArr);
strStream1.sorted().forEach(System.out::println);

Stream<String> strStream2 = strList.stream();
strStream2.sorted().forEach(System.out::println);


Stream 연결, Stream에 가변 추가

List<String> objectList = new ArrayList<>();
objectList.add("Apple");
objectList.add("Banana");
objectList.add("Orange");

// 객체 리스트로부터 Stream 생성
Stream<String> objectStream = objectList.stream();

// 3. Stream.concat() : Stream 연결, 
//    Stream.of() : Stream에 가변 추가
Stream<String> combinedStream = Stream.concat(objectStream, Stream.of("Grapes", "Pineapple"));
combinedStream.forEach(System.out::println);


Strema File

//File Read
Path filePath = Paths.get("파일_경로");

try {
    Stream<String> lines = Files.lines(filePath); // 파일에서 각 라인을 스트림으로 읽기
    lines.forEach(System.out::println); // 각 라인 출력
    lines.close(); // 스트림 닫기
} catch (IOException e) {
    e.printStackTrace();
}
//File Write
Path filePath = Paths.get("파일_경로");

try {
    Stream<String> content = Stream.of("첫 번째 라인", "두 번째 라인", "세 번째 라인");
    Files.write(filePath, (Iterable<String>) content::iterator); // 스트림의 내용을 파일에 쓰기
} catch (IOException e) {
    e.printStackTrace();
}




[출처]

  • 자바의정석 (저자: 남궁성)