스트림 API 기본 개념

스트림 API란?

  • 스트림은 자바 8부터 추가된 기능으로 데이터의 흐름을 추상화해서 다루는 도구이다.

  • 컬렉션 또는 배열 등의 요소들을 연산 파이프라인을 통해 연속적인 형태로 처리할 수 있게 해준다.

  • 연산 파이프라인이란 여러 연산(중간 연산, 최종 연산)을 체이닝해서 데이터를 변환, 필터링, 계산하는 구조를 말한다.

자바가 제공하는 스트림 예제

import java.util.List;
import java.util.stream.Stream;

public class StreamStartMain {
    public static void main(String[] args) {
        List<String> names = List.of("Apple", "Banana", "Berry", "Tomato");

        //"B"로 시작하는 이름만 필터 후 대문자로 바꿔서 리스트 수집
        Stream<String> stream = names.stream(); //스트림 생성
        List<String> result = stream.filter(name -> name.startsWith("B"))   //중간 연산
                                    .map(s -> s.toUpperCase())  //중간 연산
                                    .toList();  //최종 연산

        //외부 반복
        for (String s : result) {
            System.out.println(s);
        }

        //forEach 내부 반복
        names.stream()
             .filter(name -> name.startsWith("B"))
             .map(s -> s.toUpperCase())
             .forEach(x -> System.out.println(x));

        //메서드 참조
        names.stream()
             .filter(name -> name.startsWith("B"))
             .map(String::toUpperCase)  //임의 객체의 인스턴스 메서드 참조(매개변수 참조)
             .forEach(System.out::println); //특정 객체의 인스턴스 메서드 참조
    }
}
  • 중간 연산은 데이터를 걸러내거나 형태를 변환하며, 최종 연산을 통해 최종 결과를 모으거나 실행할 수 있다.

  • 스트림의 내부 반복을 어떻게 반복할지(for, while 루프 등) 직접 신경 쓰기보다는, 결과가 어떻게 변환되어야 하는지에만 집중할 수 있다. 이러한 특징을 선언형 프로그래밍 스타일이라 한다.

  • 메서드 참조를 사용하여 람다식을 더 간결하게 표현하고 가독성을 높일 수 있다.

스트림에서 제공하는 다양한 중간 연산과 최종 연산을 통해 복잡한 데이터 처리 로직도 간단하고 선언적으로 구현할 수 있다.


스트림 API 특징

  1. 데이터 소스를 변경하지 않음

    • 스트림에서 제공하는 연산들은 원본 컬렉션을 변경하지 않고 결과만 새로 생성한다.

  2. 일회성

    • 한 번 사용된(소비) 스트림은 다시 사용할 수 없다. 필요하다면 새로 스트림을 생성해야 한다.

  3. 파이프라인 구성

    • 중간 연산들이 이어지다가 최종 연산을 만나면 연산이 수행되고 종료된다.

  4. 지연 연산

    • 중간 연산은 필요할 때까지 실제로 동작하지 않고 최종 연산이 실행될 때 한 번에 처리된다.

  5. 병렬 처리 용이

    • 스트림으로부터 병렬 스트림을 쉽게 만들 수 있어 멀티코어 환경에서 병렬 연산을 비교적 단순한 코드로 작성할 수 있다.

1. 데이터 소스를 변경하지 않음 (Immutable)

import java.util.List;

public class ImmutableMain {
    public static void main(String[] args) {
        List<Integer> origin = List.of(1, 2, 3, 4, 5);
        System.out.println("origin = " + origin);

        List<Integer> filtered = origin.stream()
                                       .filter(n -> n % 2 == 0)
                                       .toList();
        System.out.println("filtered = " + filtered);
        System.out.println("origin = " + origin);
    }
}
origin = [1, 2, 3, 4, 5]
filtered = [2, 4]
origin = [1, 2, 3, 4, 5]

2. 일회성

import java.util.List;
import java.util.stream.Stream;

public class DuplicateExecutionMain {
    public static void main(String[] args) {
        Stream<Integer> stream = Stream.of(1, 2, 3, 4);

        stream.forEach(System.out::println); //1.최초 실행
        stream.forEach(System.out::println); //2. 스트림 중복 실행, 오류 발생

        //대안 : 대상 리스트를 스트림으로 새로 생성해서 사용
        List<Integer> list = List.of(1, 2, 3, 4);
        Stream.of(list).forEach(System.out::println);
        Stream.of(list).forEach(System.out::println);
    }
}
1
2
3
4
Exception in thread "main" java.lang.IllegalStateException: stream has already been operated upon or closed
	at java.base/java.util.stream.AbstractPipeline.sourceStageSpliterator(AbstractPipeline.java:311)
	at java.base/java.util.stream.ReferencePipeline$Head.forEach(ReferencePipeline.java:807)
	at stream.basic.DuplicateExecutionMain.main(DuplicateExecutionMain.java:11)
  • 스트림을 중복 실행하면 stream has already been operated upon or closed이라는 메시지와 함께 예외가 발생한다.

  • 하나의 리스트로 여러번 스트림을 통해 실행해야 한다면 스트림이 필요할 때마다 스트림을 새로 생성해서 사용해야 한다.

3. 파이프라인 구성

직접 만든 스트림과 자바가 제공하는 스트림이 어떻게 다른지 알아보자.

import java.util.List;

public class LazyEvalMain1 {
    public static void main(String[] args) {
        List<Integer> data = List.of(1, 2, 3, 4, 5, 6);
        //짝수만을 골라 10을 곱해라
        ex1(data);
        ex2(data);
    }

    //직접 만든 스트림
    private static void ex1(List<Integer> data) {
        System.out.println("=== MyStreamV3 ===");
        List<Integer> result = MyStreamV3.of(data)
                                         .filter(n -> {
                                             boolean isEven = n % 2 == 0;
                                             System.out.println("filter() 실행: " + n + "(" + isEven + ")");
                                             return isEven;
                                         })
                                         .map(n -> {
                                             int mapped = n * 10;
                                             System.out.println("map() 실행: " + n + " -> " + mapped);
                                             return mapped;
                                         })
                                         .toList();
        System.out.println("result = " + result);
    }

    //자바 스트림
    private static void ex2(List<Integer> data) {
        System.out.println("=== Java Stream ===");
        List<Integer> result = data.stream()
                                   .filter(n -> {
                                       boolean isEven = n % 2 == 0;
                                       System.out.println("filter() 실행: " + n + "(" + isEven + ")");
                                       return isEven;
                                   })
                                   .map(n -> {
                                       int mapped = n * 10;
                                       System.out.println("map() 실행: " + n + " -> " + mapped);
                                       return mapped;
                                   })
                                   .toList();
        System.out.println("result = " + result);
    }
}
=== MyStreamV3 ===
filter() 실행: 1(false)
filter() 실행: 2(true)
filter() 실행: 3(false)
filter() 실행: 4(true)
filter() 실행: 5(false)
filter() 실행: 6(true)
map() 실행: 2 -> 20
map() 실행: 4 -> 40
map() 실행: 6 -> 60
result = [20, 40, 60]
=== Java Stream ===
filter() 실행: 1(false)
filter() 실행: 2(true)
map() 실행: 2 -> 20
filter() 실행: 3(false)
filter() 실행: 4(true)
map() 실행: 4 -> 40
filter() 실행: 5(false)
filter() 실행: 6(true)
map() 실행: 6 -> 60
result = [20, 40, 60]

직접 만든 스트림은 일괄 처리 방식이고, 자바의 스트림은 파이프라인 방식이다.

  • 일괄 처리 (Batch Processing)

    • 각 단계마다 결과물을 모아두고, 전체가 끝난 뒤에야 다음 단계로 넘어간다.

    • filter()를 모든 데이터에 대해 적용(일괄 처리)하고,

    • 그 결과를 한꺼번에 모아서 그 다음에 map()일괄 처리한다.

  • 파이프라인 처리 (Pipeline Processing)

    • 각 단계가 끝난 제품을 즉시 다음 단계로 넘기면서 단계들이 연결(체이닝) 되어 있는 형태이다.

    • filter() 단계를 통과하면 해당 요소는 곧바로 map() 단계로 이어지고

    • 최종 결과를 가져야 하는 시점(최종 연산)이 되어서야 모든 단계가 완료된다.

자바 스트림은 중간 단계에서 데이터를 모아서 한번에 처리하지 않고, 한 요소가 중간 연산을 통과하면 곧바로 다음 중간 연산으로 이어지는 파이프라인 형태를 가진다.

4. 지연 연산

자바 스트림은 toList()와 같은 최종 연산을 수행할 때만 작동한다.

import java.util.List;

public class LazyEvalMain2 {
    public static void main(String[] args) {
        List<Integer> data = List.of(1, 2, 3, 4, 5, 6);
        //짝수만을 골라 10을 곱해라
        ex1(data);
        ex2(data);
    }

    //직접 만든 스트림
    private static void ex1(List<Integer> data) {
        System.out.println("=== MyStreamV3 ===");
        MyStreamV3.of(data)
                  .filter(n -> {
                      boolean isEven = n % 2 == 0;
                      System.out.println("filter() 실행: " + n + "(" + isEven + ")");
                      return isEven;
                  })
                  .map(n -> {
                      int mapped = n * 10;
                      System.out.println("map() 실행: " + n + " -> " + mapped);
                      return mapped;
                  });
        //toList()를 호출하지 않음
    }

    //자바 스트림
    private static void ex2(List<Integer> data) {
        System.out.println("=== Java Stream ===");
        data.stream()
            .filter(n -> {
                boolean isEven = n % 2 == 0;
                System.out.println("filter() 실행: " + n + "(" + isEven + ")");
                return isEven;
            })
            .map(n -> {
                int mapped = n * 10;
                System.out.println("map() 실행: " + n + " -> " + mapped);
                return mapped;
            });
        //toList()를 호출하지 않음
    }
}
=== MyStreamV3 ===
filter() 실행: 1(false)
filter() 실행: 2(true)
filter() 실행: 3(false)
filter() 실행: 4(true)
filter() 실행: 5(false)
filter() 실행: 6(true)
map() 실행: 2 -> 20
map() 실행: 4 -> 40
map() 실행: 6 -> 60
=== Java Stream ===
  • 직접 만든 스트림은 최종 연산을 호출하지 않았는데도 filter()map()이 바로바로 실행된다.

  • 반면 자바 스트림은 최종 연산이 호출되지 않으면 아무 일도 하지 않는 것을 확인할 수 있다.

  • 중간 연산의 작업들은 파이프라인 설정을 해놓기만 하고, 정작 실제 연산은 최종 연산이 호출되기 전까지 전혀 진행되지 않는다.

  • 즉 스트림은 filter, map과 같은 중간 연산을 호출할 때 전달한 람다를 내부에 저장만 해두고 실행하지는 않는다. 이후에 최종 연산 이 호출되면 그때 각각의 항목을 꺼내서 저장해둔 람다를 실행한다.

즉시 연산과 지연 연산

  • 즉시 연산

    • 직접 만든 스트림은 즉시(Eager) 연산을 사용하고 있다. 중간 연산이 호출될 때마다 바로 연산을 수행한다.

    • 그 결과 최종 연산이 없어도 filter, map등이 즉시 동작해버려 필요 이상의 연산이 수행되곧 한다.

  • 지연 연산

    • 지연(Lazy) 연산은 꼭 필요할 때만 연산을 수행하도록 연산을 최대한 미룬다.

    • 그래서 연산을 반드시 수행해야 하는 최종 연산을 만나야 가지고 있던 중간 연산들을 수행한다.

👆 지연 연산과 최적화

자바의 스트림은 지연 연산, 파이프라인 등 복잡하게 설계되어 있는데 이를 통해 어떤 최적화를 할 수 있는지 알아보자.

직접 만든 스트림 기능 추가

public class MyStreamV3<T> {
    private final List<T> internalList;
    
    //...

    //추가
    public T getFirst() {
        return internalList.getFirst();
    }
}
import java.util.List;

public class LazyEvalMain3 {
    public static void main(String[] args) {
        List<Integer> data = List.of(1, 2, 3, 4, 5, 6);

        //짝수를 찾아 10을 곱해라
        //계산한 짝수 중에서 첫 번째 항목 하나만 찾아라
        ex1(data);
        ex2(data);
    }

    //직접 만든 스트림
    private static void ex1(List<Integer> data) {
        System.out.println("=== MyStreamV3 ===");
        Integer result = MyStreamV3.of(data)
                                   .filter(n -> {
                                       boolean isEven = n % 2 == 0;
                                       System.out.println("filter() 실행: " + n + "(" + isEven + ")");
                                       return isEven;
                                   })
                                   .map(n -> {
                                       int mapped = n * 10;
                                       System.out.println("map() 실행: " + n + " -> " + mapped);
                                       return mapped;
                                   })
                                   .getFirst();
        System.out.println("result = " + result);
    }

    //자바 스트림
    private static void ex2(List<Integer> data) {
        System.out.println("=== Java Stream ===");
        Integer result = data.stream()
                             .filter(n -> {
                                 boolean isEven = n % 2 == 0;
                                 System.out.println("filter() 실행: " + n + "(" + isEven + ")");
                                 return isEven;
                             })
                             .map(n -> {
                                 int mapped = n * 10;
                                 System.out.println("map() 실행: " + n + " -> " + mapped);
                                 return mapped;
                             })
                             .findFirst()
                             .get();
        System.out.println("result = " + result);
    }
}
=== MyStreamV3 ===
filter() 실행: 1(false)
filter() 실행: 2(true)
filter() 실행: 3(false)
filter() 실행: 4(true)
filter() 실행: 5(false)
filter() 실행: 6(true)
map() 실행: 2 -> 20
map() 실행: 4 -> 40
map() 실행: 6 -> 60
result = 20
=== Java Stream ===
filter() 실행: 1(false)
filter() 실행: 2(true)
map() 실행: 2 -> 20
result = 20
  • 직접 만든 스트림모든 요소에 대해 필터를 거치고 통과한 요소에 대해 map을 끝까지 수행한 후 결과 목록 중 첫 번째 원소를 꺼냈다. 결과적으로 총 9번의 연산이 발생했다. (filter 6번, map 3번)

  • 자바의 스트림findFirst()라는 최종 연산을 만나면 조건을 만족하는 요소를 찾은 순간 연산을 멈추고 곧바로 결과를 반환해버린다. 결과적으로 총 3번의 연산이 발생했다. (filter 2번, map 1번)

이를 단축 평가(short-circuit) 라고 하며, 조건을 만족하는 결과를 찾으면 더 이상 연산을 진행하지 않는 방식이다. 이것은 지연 연산파이프라인 방식이 있기 때문에 가능한 최적화 중 하나이다.

정리하면 스트림 API의 핵심은 어떤 연산을 할지 파이프라인으로 정의해놓고, 최종 연산이 실행될 때 한번에 처리한다는 점이다. 이를 통해 필요한 시점에만 데이터를 처리하고, 필요 이상으로 처리하지 않는다는 효율성을 얻을 수 있다.

Last updated