본문 바로가기

코딩공부

Java Stream의 개념

스트림(Stream)

**스트림(Stream)**은 배열, 컬렉션의 저장 요소를 하나씩 참조해서 람다식으로 처리할 수 있도록 해주는 반복자입니다.

스트림을 사용하면 List, Set, Map, 배열 등 다양한 데이터 소스로부터 스트림을 만들 수 있고, 이를 표준화된 방법으로 다룰 수 있습니다.

스트림은 데이터 소스를 다루는 풍부한 메서드를 제공합니다.

이를 활용하면, 다량의 데이터에 복잡한 연산을 수행하면서도, 가독성과 재사용성이 높은 코드를 작성할 수 있습니다.

 

 

 

아래 예시를 보겠습니다.

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

public class PrintNumberOperatorByStream {
    public static void main(String[] args) {
        List<Integer> list = Arrays.asList(1, 2, 3, 4, 5);
        Stream<Integer> stream = list.stream();
        stream.forEach(System.out::print);
    }
}

//출력값
//12345

스트림을 사용하면 선언형 프로그래밍(Declarative Programming) 방식으로 데이터를 처리할 수 있어 더욱 인간 친화적이고 직관적인 코드 작성이 가능합니다.

 

아래에서 명령형 프로그래밍 방식과 선언형 프로그래밍 방식의 차이를 보겠습니다.

 

-명령형 프로그래밍 방식-

import java.util.List;

public class ImperativeProgramming {
    public static void main(String[] args){
        // List에 있는 숫자 중에서 4보다 큰 짝수의 합계 구하기
        List<Integer> numbers = List.of(1, 3, 6, 7, 8, 11);
        int sum = 0;

        for(int number : numbers){
            if(number > 4 && (number % 2 == 0)){
                sum += number;
            }
        }

        System.out.println("명령형 프로그래밍을 사용한 합계 : " + sum);
    }
}

//출력값
//명령형 프로그래밍을 사용한 합계 : 14

 

선언형 프로그래밍 방식

import java.util.List;

public class DeclarativePrograming {
    public static void main(String[] args){
        // List에 있는 숫자들 중에서 4보다 큰 짝수의 합계 구하기
        List<Integer> numbers = List.of(1, 3, 6, 7, 8, 11);

        int sum =
                numbers.stream()
                        .filter(number -> number > 4 && (number % 2 == 0))
                        .mapToInt(number -> number)
                        .sum();

        System.out.println("선언형 프로그래밍을 사용한 합계 : " + sum);
    }
}

 

 

스트림을 사용하면, 데이터 소스가 무엇이냐에 관계없이 같은 방식으로 데이터를 가공/처리할 수 있습니다. 다른 말로, 배열이냐 컬렉션이냐에 관계없이 하나의 통합된 방식으로 데이터를 다룰 수 있게 되었다는 뜻입니다.

 

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

public class StreamOperator {
    public static void main(String[] args) {

        // ArrayList
        List<String> fruitList = new ArrayList<>();
        fruitList.add("바나나 ");
        fruitList.add("사과 ");
        fruitList.add("오렌지 ");

        // 배열
        String[] fruitArray = {"바나나 ", "사과 ", "오렌지 "};

        // 각각 스트림 생성
        Stream<String> ListStream = fruitList.stream();
        Stream<String> ArrayStream = Arrays.stream(fruitArray);

        // 출력
        ListStream.forEach(System.out::print);
        ArrayStream.forEach(System.out::print);
    }
}

//출력값
//바나나 사과 오렌지 바나나 사과 오렌지

 

위와 같이, 입력값이 List인 경우와 배열인 경우에 스트림을 사용하면 출력부분의 식이 동일한 것을 볼 수 있습니다.

 

 

 

스트림의 특징

자바의 다른 문법 요소들과 마찬가지로, 스트림 또한 여러 복잡하고 깊은 내용들을 가지고 있지만 이 단계에서 우리는 먼저 딱 4 가지의 핵심적인 특징들만 기억하는 것으로 충분합니다. 핵심적으로 기억해야 하는 스트림의 4 가지의 특징들은 다음과 같습니다.

  1. 스트림 처리 과정은 생성, 중간 연산, 최종 연산 세 단계의 파이프라인으로 구성될 수 있다.
  2. 스트림은 원본 데이터 소스를 변경하지 않는다(read-only).
  3. 스트림은 일회용이다(onetime-only).
  4. 스트림은 내부 반복자이다.

 

 

1. 스트림 처리 과정은 생성, 중간 연산, 최종 연산 세 단계의 파이프라인으로 구성될 수 있다.

스트림을 바르게 이해하기 위해서는 아래 그림으로 요약될 수 있는 **스트림 파이프 라인(stream pipeline)**에 대한 이해가 필수적입니다. 사실 아래 도식을 이해하면 스트림의 핵심을 모두 이해했다고 봐도 큰 과장이 아닙니다.

위에서 언급한 것처럼, 스트림 파이프라인은 1) 스트림의 생성, 2) 중간 연산, 3) 최종 연산이라는 총 세 가지 단계로 구성되어 있습니다. 사실 중간 연산을 생략하고 곧바로 최종연산으로 넘어가는 두 단계 구성도 가능하지만, 지금은 가장 빈번하게 사용되는 완전체 파이프라인을 가정해 보겠습니다.

간략하게 흐름을 설명하면, 먼저 배열, 컬렉션, 임의의 수 등 다양한 데이터 소스를 일원화하여 스트림으로 작업하기 위해서는 스트림을 생성해야 합니다. 스트림이 생성되고 나면, 최종 처리를 위한 중간 연산을 수행할 수 있습니다.

여기에는 필터링, 매핑, 정렬 등의 작업이 포함되며, 중간 연산의 결과는 또 다른 스트림이기 때문에 계속 연결해서 연산을 수행할 수 있습니다. 이렇게 연결된 모양새가 마치 파이프라인과 같다고 해서 이러한 구조를 스트림 파이프라인이라고 합니다.

마지막으로, 이렇게 중간 연산이 완료된 스트림을 최종적으로 처리하는 최종 연산(총합, 평균, 카운팅 등)을 끝으로 스트림은 닫히고 모든 데이터 처리가 완료됩니다. 최종 연산의 경우는 스트림의 요소를 소모하면서 연산을 수행하기 때문에 최종적으로 단 한 번의 연산만 가능합니다. 따라서 최종 연산 후에 다시 데이터를 처리하고 싶다면, 다시 스트림을 생성해주어야 합니다.

위의 도식은 남성과 여성으로 구성된 어떤 회원 컬렉션을 스트림을 사용하여 의도한 데이터로 가공하는 과정을 보여주는 스트림 파이프라인입니다. 가장 먼저 스트림을 생성하고, 생성한 스트림에서 중간 연산 단계로 성별이 남자인 회원만 필터링한 후에, 그중에서 나이 요소만을 매핑한 후, 최종 연산을 통해 남자 회원들의 나이 평균을 구했습니다.

 

2. 스트림은 원본 데이터 소스를 변경하지 않는다(read-only).

스트림은 그 원본이 되는 데이터 소스의 데이터들을 변경하지 않습니다. 오직 데이터를 읽어올 수 있고, 데이터에 대한 변경과 처리는 생성된 스트림 안에서만 수행됩니다. 이는 원본 데이터가 스트림에 의해 임의로 변경되거나 데이터가 손상되는 일을 방지하기 위함입니다.

 

3. 스트림은 일회용이다(onetime-only).

위에서 스트림 파이프라인에 관해 설명할 때 잠시 언급했듯이, 스트림은 일회용입니다. 다르게 표현하면, 스트림이 생성되고 여러 중간 연산을 거쳐 마지막 연산이 수행되고 난 후에는 스트림은 닫히고 다시 사용할 수 없습니다. 만약 추가적인 작업이 필요하다면, 다시 스트림을 생성해야 합니다. 마치 컬렉션에서 배웠던 Iterator와 비슷하다고 할 수 있습니다.

 

4. 스트림은 내부 반복자이다.

**내부 반복자(Internal Iterator)**를 이해하기 위해서 먼저 이에 반대되는 개념인 **외부 반복자(External Iterator)**를 알면 도움이 됩니다. 외부 반복자란 개발자가 코드로 직접 컬렉션의 요소를 반복해서 가져오는 코드 패턴을 의미합니다. 인덱스를 사용하는 for문, Iterator를 사용하는 while문 이 대표적입니다.

반면 스트림은 반대로 컬렉션 내부에 데이터 요소 처리 방법(람다식)을 주입해서 요소를 반복처리 하는 방식입니다. 아래 그림을 통해 좀 더 살펴보겠습니다.

위에서 확인할 수 있는 것처럼, 외부 반복자의 경우 요소가 필요할 때마다 순차적으로 컬렉션에서 필요한 요소들을 불러오지만, 내부반복자는 데이터 처리 코드만 컬렉션 내부로 주입해 줘서 그 안에서 모든 데이터 처리가 이뤄지도록 합니다. 병렬 작업, 멀티 코어 최적화 등 어려운 컴퓨터공학 용어를 사용하지 않더라도 한 눈에도 더욱 효율적인 데이터 처리가 가능하다는 사실을 어렵지 않게 이해할 수 있습니다.

'코딩공부' 카테고리의 다른 글

Database SQL 기본문법 정리  (0) 2024.05.02
Database Management System  (0) 2024.04.26
Java Lambda의 개념  (1) 2024.04.22
Java Annotation  (0) 2024.04.22
Java Collection class 종류 정리  (0) 2024.04.19