자바 스트림(stream)

이성민·2025년 8월 5일

JAVA

목록 보기
1/4
post-thumbnail

서론

시작하기에 앞서, 이 글은 자바의 정석 3판을 완독한 후 작성한 내용입니다.
그중에서도 특히 기억에 남고, 실무에도 큰 도움이 될 수 있는 스트림(Stream)에 대해 정리하였습니다.
(물론 책에 나온 모든 내용이 개발에 많은 도움이 됩니다!)
또한, 훗날 제가 내용을 잊었을 때 참고 자료로 삼거나, 자바 스트림을 처음 공부하는 분들에게 도움이 되기를 바랍니다.


스트림(stream)이란?

- 스트림 등장 이전의 문제점

  1. 자바에서는 많은 수의 데이터를 다룰 때, 컬렉션이나 배열에 데이터를 담고 원하는 결과를 얻기 위해 for문이나 Iterator를 이용해서 코드를 작성했습니다.
    하지만, 이러한 방식의 코드는 너무 길고 가독성도 떨어지며 코드의 재사용도 떨어집니다.
  2. 또 다른 문제는 데이터 소스마다 다른 방식으로 다뤄야한다는 점입니다.
    Collection이나 Iterator와 같은 인터페이스의 각 컬렉션 클래스에는 같은 기능의 메서드들이 중복해서 정의되어 있습니다.
    예를 들면, List를 정렬할 때는 Collections.sort()를 사용하고, 배열을 정렬할 때는 Arrays.sort()를 사용한다는 점입니다.

- 스트림의 등장

이러한 문제를 극복하기 위해 나온 것이 바로 컬렉션을 함수형 인터페이스를 통해 직관적으로 처리할 수 있도록 Java 8부터 제공한 스트림(Stream)입니다.

  1. 스트림은 데이터 소스를 추상화하여 데이터 소스가 무엇이던 간에 같은 방식으로 다룰 수 있게 되어, 코드의 재사용성이 높습니다.
  2. 스트림을 이용하면, 배열이나 컬렉션뿐만 아니라 파일에 저장된 데이터도 모두 같은 방식으로 다룰 수 있습니다.

- 스트림 사용 예

스트림에 대한 더 자세히 설명하기 전에 이해하기 쉽게 스트림(stream) 사용 전후에 대해 예시를 먼저 보여드리겠습니다.

(예시: 짝수만 골라 제곱한 후 정렬해서 출력하기)

  • 스트림(stream) 사용 x
List<Integer> numbers = Arrays.asList(1, 2, 3, 4, 5, 6);
List<Integer> result = new ArrayList<>();

for (Integer number : numbers) {
    if (number % 2 == 0) {
        result.add(number * number);
    }
}

Collections.sort(result);

for (Integer r : result) {
    System.out.println(r);
}
  • 스트림(stream) 사용 o
List<Integer> numbers = Arrays.asList(1, 2, 3, 4, 5, 6);

numbers.stream()
       .filter(n -> n % 2 == 0)    // 짝수만 필터링
       .map(n -> n * n)           // 제곱
       .sorted()                  // 정렬
       .forEach(System.out::println);  // 출력

스트림 특징

- 람다식으로 요소 처리 코드를 제공한다.

위의 코드에서 볼 수 있듯이, 스트림은 람다식 또는 메소드 참조를 이용합니다. 따라서, 코드가 간결해지는 장점이 있습니다.


- 데이터 소스를 변경하지 않는다.

스트림은 데이터 소스로 부터 데이터를 읽기만할 뿐, 데이터 소스를 변경하지 않는다는 차이가 있다. -> 재사용을 원하면 결과를 새로운 변수에 할당 해줘야 함.

numbers.stream()
       .filter(n -> n % 2 == 0)
       .map(n -> n * n)
       .sorted()
       .forEach(System.out::println);  // 출력만 할 뿐 결과를 저장하지 않음.

위 코드는 출력만 할 뿐 결과를 저장하지 않습니다. 하지만, 아래처럼 할당을 해주면 재사용이 가능합니다.

List<Integer> result = numbers.stream()
                              .filter(n -> n % 2 == 0)
                              .map(n -> n * n)
                              .sorted()
                              .collect(Collectors.toList()); // 결과를 리스트로 반환

- 일회용이다.

스트림은 Iterator처럼 일회용이다. Iterator로 컬렉션의 요소를 모두 읽고 나면 다시 사용할 수 없는 것처럼, 스트림도 한번 사용하면 다시 사용할 수 없다. -> 필요하면 스트림을 다시 생성해야한다.

List<Integer> numbers = Arrays.asList(1, 2, 3, 4, 5, 6);

numbers.stream()
       .filter(n -> n % 2 == 0)    
       .map(n -> n * n)           
       .sorted()                 
       .forEach(System.out::println);  // 최종연산 후 스트림 닫힘.(최종연산은 후에 설명.)

// 에러 스트림이 이미 닫혔음.
int numOfStr = strStream1.count(); // java.lang.IllegalStateException

- 작업을 내부 반복으로 처리한다.

외부 반복이란 개발자가 코드로 직접 컬렉션의 요소를 반복해서 가져오는 코드 패턴을 말합니다. 반면, 내부 반복은 컬렉션 내부에서 요소들을 반복시키고, 개발자는 요소당 처리해야 할 코드만 제공하는 코드 패턴을 말합니다.

또한, 스트림은 내부 반복(internal iteration) 방식을 사용하기 때문에 병렬 처리(parallel processing)가 쉬워집니다.

이 덕분에 스트림은 요소의 순서를 변경하거나, 멀티코어 CPU를 활용하여 요소들을 나누어 병렬로 처리하는 것이 수월해집니다.

스트림에서는 ‘무엇을 할지’만 람다식으로 정의하고, ‘어떻게 반복할지’는 스트림이 알아서 처리합니다.
따라서 데이터 처리 흐름이 더 선언적(declarative)이고, 병렬화도 자동으로 최적화됩니다.

- 병렬 스트림

이어서 병렬 스트림 코드에 대해서 설명하겠습니다. 저희가 할일이라고는 그저 스트림에 parallel()이라는 메서드를 호출하기만 하면 됩니다. 모든 스트림은 기본적으로 병렬 스트림이 아니므로 병렬처리를 할 때만 parallel()를 호출하면 됩니다.

  • 순차 처리
List<Integer> numbers = Arrays.asList(1, 2, 3, 4, 5, 6);

numbers.stream() // 스트림 시작
       .filter(n -> n % 2 == 0)    
       .map(n -> n * n)         
       .sorted()              
       .forEach(System.out::println);  
  • 병렬 처리
List<Integer> numbers = Arrays.asList(1, 2, 3, 4, 5, 6);

numbers.parallelStream()   // 병렬 스트림 시작
       .filter(n -> n % 2 == 0)
       .map(n -> n * n)
       .sorted()
       .forEach(System.out::println);

*주의사항 : 병렬처리가 항상 더 빠른 결과를 얻게 해주는 것이 아니라는 것을 명심해야 합니다.


- 스트림의 연산

스트림이 제공하는 연산은 중간 연산과 최종 연산으로 분류할 수 있습니다.

  • 중간 연산 : 연산 결과가 스트림인 연산. 스트림에 연속해서 중간 연산할 수 있음.
  • 최종 연산 : 연산 결과가 스트림이 아닌 연산. 스트림의 요소를 소모하므로 단 한번만 가능.
numbers.stream()
       .filter(n -> n % 2 == 0)    // 중간 연산
       .map(n -> n * n)           // 중간 연산
       .sorted()                  // 중간 연산
       .forEach(System.out::println);  // 최종 연산

스트림(stream)안에는 중간 연산과 최종 연산이 많이 정의 되어있습니다. 하지만, 모든 내용을 자세히 외우는 것보다는 가볍게 어떤 것들이 있다는 정도만 봐두는게 좋다고 생각하여 따로 작성은 하지않겠습니다. 궁금하신 분들은 자바의정석 3판 816.p ~ 817.p에 보면 자세히 설명이 되어있습니다.

- 지연된 연산

스트림 연산에서 한 가지 중요한 점은 최종 연산이 수행되기 전까지는 중간 연산이 수행되지 않는다는 것입니다.

중간 연산을 호출하는 것은 다지 어떤 작업이 수행되어야하는지를 지정해주는 것일 뿐입니다.

최종 연산이 수행되어야 비로소 스트림의 요소들이 중간 연산을 거쳐 최종 연산에서 소모됩니다.


결론

스트림(stream)에는 아래와 같은 장점들이 있습니다.

  • 추상화
    스트림은 다양한 데이터 소스와 동작을 처리할 수 있는 공통 인터페이스를 제공하고, 이를 통해 데이터 구조의 세부 사항에 대해 몰라도 같은 방법으로 데이터 처리 작업을 효율적으로 수행할 수 있다.
    즉, 기존에는 저장된 데이터에 접근하기 위해 반복문 등을 사용해 접근해야 했고 이렇게 작성된 코드는 가독성저하 및 재사용이 불가능하며 정형화된 처리패턴이 없어 데이터마다 다른 방법으로 접근해야 했던걸 개선했다.
  • 가독성
    스트림은 함수형 프로그래밍 스타일을 지원하며, 이를 통해 간결하고 가독성 좋은 코드를 작성할 수 있다.
  • 성능 최적화
    스트림은 지연 연산(Lazy Evaluation)을 통해 필요한 시점에만 데이터 처리를 수행한다.
  • 병렬처리
    따로 병렬처리 로직을 작성할 필요 없이 간단한 메서드를 이용해 병렬처리를 지원한다.

마지막으로, 스트림에 대한 더 많은 내용들이 있지만 모든 걸 설명하면 처음 보는 사람들에게는 오히려 독이 될 수도 있다고 생각합니다.
또한, 저도 나중에 이 글을 봤을 때 너무 많은 글이 있을 때 가독성이 떨어질 수도 있기에 가장 중요한 부분만 추려봤으니 도움이 되기를 바랍니다.
스트림에 대해 더 자세히 알고 싶다면 자바의 정석 3판 챕터 14 부분을 읽으면 도움이 될 겁니다!


참고 자료

링크텍스트
링크텍스트
링크텍스트

profile
BE 개발자

0개의 댓글