카테고리 없음

[Java] Stream이 뭔지 모른다면

g0rnn 2025. 7. 29. 00:58

목 표

  • 스트림의 동작을 이해할 수 있다.
  • 스트림이 무엇인지 설명할 수 있다.
  • 스트림을 올바르게 생성할 수 있다.

 

Stream

순차적 및 병렬 처리를 지원하는 요소의 시퀀스
A sequence of elements supporting sequential and parallel aggregate operations

자바에서 Stream은 다양한 데이터 소스를 표준화된 방법으로 다루기 위해 사용된다. Java에서는 List, Set, Map을 통해서 데이터 소스를 다룬다. 자바가 발전하면서 java.util.Collection을 통해 다양한 자료구조들을 하나로 묶으려 하였는데 Map은 리스트와 집합과 데이터를 사용하는 방식이 달랐기에 다양한 자료구조를 완벽히 표준화할 수 없었다. 하지만 JDK 1.8 이후 Stream으로 각각을 표준화된 방법으로 다룰 수 있게 되었다. (물론 Map은 직접적으로 사용은 안된다)

 

Stream은 컬렉션과 같이 각각의 요소로 객체 참조를 가진다. 더불어 스트림엔 IntStream, LongStream, DoubleStream과 같이 원시 타입에 특화된 것이 존재하고 이들을 모두 스트림(streams)라고 부른다.

 

Operations

스트림 연산은 pipeline으로 구성되며, stream pipeline은 Collection, Array, I/O Channel과 같은 source, 0개 이상의 중간연산, 최종 연산으로 구성된다. source에 대한 연산은 최종 연산이 시작될 때만 수행되고 source의 요소들은 오직 그것이 사용되는 시점에만 소비된다. 이런 설명은 너무 어려우니 조금 더 풀어서 설명해보려 한다.

 

스트림을 이용한 작업은 3단계로 이루어진다.

  1. 스트림 생성
  2. 중간 연산
  3. 최종 연산
List<String> names = List.of("gyunho", "cat", "orange", "gyunho");
List<String> result = names.stream() // 스트림 생성
        .filter(name -> name.startsWith("g")) // 여기부터 중간연산(요소를 조건에 맞게 거름)
        .distinct() // 중복 제거(우리가 생성한 클래스라면 equals와 hashCode를 재정의해야함)
        .sorted()   // 정렬
        .limit(1)   // 제한
        .toList();  // 최종연산(리스트로 변환)

중간연산은 0 ~ n번 가능하고 반환 타입이 Stream<T>인 함수를 의미한다. 위에 소개된 함수 외에도 map(), peek(), skip() 등이 있다.

최종 연산은 0 ~ 1번 가능하며 반환 타입이 Stream<T>이 아닌 함수를 의미한다. 스트림의 요소를 소모하기에 단 한번만 사용가능하다. 예를 들어 forEach()는 값을 반환하지 않기 때문에 최종 연산으로 분류된다.

 

더보기

- map() : 스트림의 각 요소를 주어진 함수로 변환

- peek() : 결과 스트림에서 요소가 소비됨에 따라 각 요소에 대한 작업을 수행

- skip() : 처음 n 개의 요소를 건너뜀

 

Stream의 생성

스트림은 주로 Collection 인터페이스의 stream() 메서드를 통해 생성하여 사용한다. 그러나 다른 방법을 알아두면 스트림을 더욱 효율적으로 사용할 수 있기에 이외에 다른 방법을 살펴보자.

 

먼저 스트림은 객체 배열로부터 생성할 수 있다.

// 가변 인자를 사용하여 생성
// 이는 auto boxing으로 Stream<Integer>로 생성된다.
Stream.of(T... values) // 가변 인자인 경우 -> 내부적으로 Arrays.stream()을 호출
Stream.of(T t)         // 하나의 객체

// Arrays 클래스를 통해 생성
// 배열 원소의 일부만을 스트림으로 생성 가능
Arrays.stream(T[] array)
Arrays.stream(T[] array, int startInclusive, int endExclusive)

여기서 T는 클래스나 메서드를 선언할 대 타입을 고정하지 않고 사용하고 싶은 경우 사용되는 제네릭 타입이다. 대부분의 경우 참조 타입만 받을 수 있지만, 기본형 배열을 사용하면 제네릭 타입의 인자에 사용될 수 있다. 만약 기본 타입이 오게 되면 auto-boxing이 발생한다.

 

그래서 Stream.of는 여러가지 원소를 직접 입력하거나 기본 타입의 배열을 스트림으로 생성하려는 경우 사용된다. 만약 of의 인자로 List<String>과 같은 컬렉션을 넣게된다면 Stream<List<String>>과 같이 의도하지 않은 결과가 나타날 수 있다. 내 생각엔 of는 정적 팩토리 메서드이기 때문에 스트림을 초기화하는 경우 사용하는 것 같다. 그래서 원시 타입(배열x)에 대해 더 올바른 출력을 보인다고 생각한다.

int[] ints = {1, 2, 3, 4, 5};
IntStream intStream = Arrays.stream(ints);
Stream<int[]> intsStream = Stream.of(ints);
Stream<Integer> integerStream = Stream.of(1, 2, 3, 4); // auto boxing

char[] chars = {'a', 'b', 'c','d'};
Stream<char[]> chars1 = Stream.of(chars); // 이는 스트림의 요소가 char[]이기 때문에 원하는 결괏값이 나오지 않음
//Arrays.stream(chars); -> 컴파일 에러

Arrays.stream()의 경우 사진와 같이 오버로딩을 통해 다양한 인자 값을 받으며 그에 맞는 적절한 반환값을 제공한다. 이 중 char[]를 인자로 받는 것은 없는데 이는 char를 스트림으로 사용할 일이 거의 없기 때문에 제대로 지원되지 않는 것 같다. 

 

스트림은 무한으로 존재할 수 있고, 무한 스트림을 생성하는 메서드들이 몇 가지 있는데 먼저 Random의 ints()이다. 이는 int형의 숫자를 무제한으로 생성한다. 그래서 limit을 통해 요소의 개수를 제한해줘야 한다. 혹은 ints()에 streamSize를 넘겨주면 유한 스트림으로도 생성 가능하다.

 

람다식을 source로 하여 스트림을 생성할 땐 iterate()와 generate()를 사용한다. 이는 무한 스트림을 생성하고 람다식에 의해 생성된 값들로 스트림을 구성한다.

iterate(T seed, UnaryOperator<T> f)는 초기값과 하나의 인자에 대해 값을 반환하는 UnaryOperator를 받는다. 예를 들어 iterate(2, n -> n+2)이면 2, 4, 6, 8..과 같이 2의 배수가 생성된다. generate(Supplier<T> s)는 인자 없이 값을 반환하기만 하는 함수를 받아 스트림을 생성한다. 예를 들어 generate(Math::random)와 같이 함수 참조를 사용하여 랜덤 값을 생성하거나 generate(() -> 1)처럼 1만을 생성할 수도 있다.

 

특징

1. Read-Only

- 스트림은 Read-only로 원본 데이터(source)를 변경하지 않는다.

 

2. 일회용

- 스트림은 일회용이므로 필요하면 다시 생성해야한다. 만약 이미 닫힌 스트림을 사용하려고 할 때는 IllegalStateException이 발생한다.

 

- 위 코드를 실행하면 IllegalStateException이 발생한다. limit을 사용하는 시점에 stream에 노란색 밑줄이 뜨면서 뭔가 이상한 코드라는 점을 알려주는데 해당 내용은 스트림이 소비되었거나 링크되었다고 한다.

 

- 소비는 최종연산 시 발생하는 것이므로 여기서는 링크 때문에 오류가 발생한 것이다. 스트림의 중간 연산은 어떤 연산이 수행될지 표시(link)만 한다.

 

3. 지연된 연산

- 스트림은 지연된 연산으로 최종 연산 전까지는 중간연산이 수행되지 않으며 수직적인 연산을 수행한다.

private static int counter;

public static void main(String[] args) {
    List<String> names = List.of("김프로", "정프로", "이프로", "정강이", "정프콘");
    List<Character> jung = names.stream()
            .filter(info -> {
                System.out.print(info + ": ");
                return info.startsWith("정");
            })
            .map(info -> {
                wasCalled();
                return info.charAt(2);
            })
            .toList();
    System.out.println(counter);
}

private static void wasCalled() {
    counter++;
    System.out.println(counter);
}

이 코드의 출력은 어떻게 될까?

//출력
김프로: 정프로: 1
이프로: 정강이: 2
정프콘: 3
3

출력을 보면 각 요소가 수직적으로 연산된다는게 무슨 의미인지 알 수 있다. “김프로”가 filter→map된 후 “정프로”가 filter→map 연산이 수행된다. 이후 모든 요소에 중간 연산이 적용되면 해당 시퀀스를 toList한다. 그렇다면 다음은 어떨까?

 

names.stream()
        .filter(info -> {
            wasCalled();
            return info.startsWith("정");
        })
        .map(info -> info.charAt(2));
System.out.println(counter);

이전과는 다르게 filter에서 wasCalled가 수행되고 출력된다. 이 경우 출력은 0이다. 그 이유는 최종연산이 없기 때문에 중간연산이 이루어지지 않는다! 이게 지연된 연산이다. 마지막으로 다음의 코드를 살펴보자.

names.stream()
        .filter(info -> {
            wasCalled();
            return info.startsWith("정");
        })
        .map(info -> info.charAt(2))
        .toList();
System.out.println(counter);

이번엔 최종연산이 존재하고 이전과 동일하게 filter내부에서 wasCalled가 호출된다. 이 경우 출력은 1에서 5까지 출력된 후 마지막으로 counter가 5로 출력된다.

 

병렬 스트림

스트림은 기본적으로 순차적으로 작업을 처리한다. 그러나 대용량 데이터를 빠르게 처리해야한다면 parallel()을 사용하여 스트림의 작업을 병렬적으로 처리할 수 있다. 다시 순차적인 처리를 원한다면 sequential() 메서드를 사용하여 바꿀 수 있다.

기본형 스트림

스트림에는 기본 타입에 특화된 기본형 스트림(IntStream, DoubleStream, LongStream)을 제공한다. 이는 기본(원시) 타입에서 참조 타입으로 바꿀 때 auto boxing & unboxing이 발생하지 않아 효율적이다. 그리고 Stream의 요소들이 숫자라는 것이 확실하므로 average(), sum()처럼 숫자와 관련된 유용한 메서드 제공한다. 일반 Stream 객체를 원시 Stream으로 바꾸거나 반대로 바꾸는 경우 mapToInt(), mapToDouble()로 원시 객체로, mapToObject로 일반 Stream 객체로 바꿀 수 있다.

// 리스트 요소들의 총합
int sum = list.stream().mapToInt(Integer::intValue).sum();
System.out.println(sum); // 출력: 15

//char의 경우 본질적으로 숫자이기 때문에 'IntStream'으로 변환 가능하다.
IntStream charStream = "Stream".chars(); //[83, 116, 114, 101, 97, 109]

 

한 줄 요약

  • 스트림은 병렬 혹은 순차적 처리를 위한 시퀀스이다.
  • 스트림은 소스, 중간 연산, 최종 연산으로 구성되어 있으며, 중간 연산은 최종 연산이 시행되어야 이루어진다.
  • 스트림의 연산은 수직적으로 일어나며 일회용이고 원본을 변경하지 않는다.

 

참고자료

Stream (Java Platform SE 8) 공식문서

자바의 정석 기초편 ch14-15 강의

Java 스트림 Stream (2) 고급 | EricHan's IT Blog