Java/Java

스트림 (Stream)

마손리 2023. 3. 10. 11:27

스트림

 

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

스트림을 사용하면 List, Set, Map, 배열 등 다양한 데이터 소스로부터 스트림을 만들 수 있고, 이를 표준화된 방법으로 다룰 수 있으며 스트림은 제공하는 다양한 메서드들을 이용하여 데이터 소스를 다룰수 있다.

 

어떠한 집합의 데이터들에 접근하는데 반복문으로 처리가 가능하다. 하지만 for문이나 Itorator를 사용할경우 코드가 길고 가독성이 떨어질수 있으나 스트림을 통해 구현한다면 코드를 좀더 직관적으로 작성이 가능하다.

 

선언형과 명령형 프로그래밍 비교

// List에 있는 숫자들 중에서 4보다 큰 짝수의 합계 구하기
// ------------------------반복문 사용(명령형 프로그래밍)------------------------
List<Integer> numbersFor = List.of(1, 3, 6, 7, 8, 11);
int sumFor = 0;

for(int number : numbersFor){
    if(number > 4 && (number % 2 == 0)){
        sumFor += number;
    }
}
//sumFor = 14

// ------------------------스트림 사용(선언형 프로그래밍)------------------------
List<Integer> numbersStream = List.of(1, 3, 6, 7, 8, 11);

int sumStream =
        numbersStream.stream()
                .filter(number -> number > 4 && (number % 2 == 0))
                .mapToInt(number -> number)
                .sum();
//sumStream = 14

위의 예시만 보더라도 선언형 프로그래밍을 사용할시 코드를 좀더 직관적이고 이해하기 쉽도록 설계가 가능하다.

또한 스트림은 람다식을 사용하여 코드 한줄로도 많은 작업을 수행할수 있다. 

스트림의 가장 큰 장점은 데이터의 타입변환도 쉬울뿐더러 타입이 다르더라도 대부분의 메서드가 공유되기 때문에 사용하기도 편리하다. 

 

 

스트림의 특징

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

스트림 파이프라인은 1) 스트림의 생성, 2) 중간 연산, 3) 최종 연산이라는 총 세 가지 단계로 구성되어져 있으며 중간 연산을 생략하고 곧바로 최종연산으로 넘어가는 두 단계 구성으로도 가능하다.

 

간단하게 보자면 처음 스트림이 생성된뒤 모든 중간연산(그림에서는 filter()와 mapToInt())를 마치고 반환된 값으로 최종 연산(average())를 수행하며 끝이난다. 참고로 최종연산은 단 한번만 가능하며 최종 연산 후에 다시 데이터를 처리 해야 한다면 다시 스트림을 생성해서 위의 작업을 진행해 주어야 한다.

 

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

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

 

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

스트림이 생성되고 여러 중간 연산을 거쳐 마지막 최종 연산이 수행되고 난 후에는 스트림은 닫히고 다시 사용할 수 없다. 만약 추가적인 작업이 필요하다면, 다시 스트림을 생성해야 한다.

 

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

먼저 외부 반복자(External Iterator)란 반복문이 실행되는 동안 코드의 실행에 필요한 요소들을 배열이나 컬렉션등 외부에서 반복적으로 가져오는 패턴이다.

 

그와 반대로 내부 반복자(Internal Iterator)데이터 처리 코드만 컬렉션 내부로 주입해줘서 그 안에서 모든 데이터 처리가 이뤄지도록 한다.

(참고 : 람다식의 외부 반복 작업 진행순서 https://mason-lee.tistory.com/62) 

 

스트림 생성

Stream은 크게 기본형 스트림(IntStream, DoubleStream, LongStream)과 일반 스트림(Stream)으로 분류되며 기본적인 메서드들은 모두 같지만 기본형 스트림에는 숫자형 데이터를 처리하는 특수한 메서드들이 좀더 포함되 있다.

 

배열의 스트림 생성

// String 타입의 Stream 생성
String[] string = new String[]{};

Stream<String> str1 = Arrays.stream(string);
Stream<String> str2 = Stream.of(string);


// int 타입의 Stream 생성
int[] ints = new int[]{};

IntStream int1 = Arrays.stream(ints); //기본형 스트림
IntStream int2 = IntStream.of(ints); //기본형 스트림
Stream<Integer> int3 = IntStream.of(ints).boxed(); //일반 스트림

 

컬렉션의 스트림 생성

List<Integer> list = Arrays.asList();
Set<String> set = new HashSet<>();

// ------- Set to Stream -------
Stream<String> setToStream1 = set.stream();
//Stream<String> setToStream2 = Stream.of(set); 컴파일오류

// ------- List To Stream -------
Stream<Integer> listToStream1 = list.stream();
Stream<List<Integer>> listToStream2 = Stream.of(list);

IntStream intStream = listToStream1.mapToInt(n->n);   //일반 스트림을 mapToInt를 사용하여 IntStream으로 변환

// IntStream integer4 = IntStream.of(list); 컴파일오류
// 컬렉션계열은 기본타입 자료구조를 사용못하므로 기본형 스트림을 바로 생성할수 없다.
// 래퍼클래스 구조의 스트림을 생성한뒤 mapToInt 등으로 기본형 스트림으로 바꿔줘야된다.

 

스트림 중간 연산

 

List<Integer> list1 = Arrays.asList(5,6,3,4);
List<Integer> list2 = Arrays.asList(8,7,3,4);

//스트림 생성
Stream<Integer> creatingStream = Stream.concat(list1.stream(),list2.stream());

//중간 연산
Stream<Integer> intermediateOperation =
        creatingStream 
                .distinct() //중복된 값을 없애준다. 
                .filter((num)->num%2<0) // 람다식의 반환이 true값만을 골라준다.
                .sorted((a,b)->b-a); // 내림차순정렬, 오름차순을 원할경우 람다식을 비워준다.

위의 예시는 특정 메서드들을 활용하여 중간연산을 하는 과정이다.

 

매핑(map())

매핑은 스트림 내 모든 요소들에서 원하는 필드만 추출하거나 특정 형태로 변환할 때 사용하는 중간연산자이다.

forEach()와 peek()과 다른점은 그 둘은 void로 리턴값이 없으며 forEach()는 최종 연산 메서드에 속한다.

.map(element -> element.toUpperCase()) // 요소들을 하나씩 대문자로 변환

 

플랫 매핑(flatMap())

다중배열을 사용할경우 내부배열의 데이터들을 동일한 선상으로 외부로 꺼내는 메서드이다.

.flatMap(Arrays::stream)

 

픽(peek())

forEach() 와 마찬가지로, 요소들을 순회하며 특정 작업을 수행하지만 최종 연산 메서드인 forEach와 다르게 여러번 호출이 가능하며 매핑과는 다르게 void메서드로서 리턴값이 없어야한다.

.peek(System.out::println)

 

이외에도 많은 메서드들이 존재한다.

 

 

스트림 최종 연산

모든 중간 연산이 끝난후에나 실행되는 메서드로 단 한번밖에 호출이 되지 않으며 최종 연산 이후 다른 연산이 필요 할 시에는 새로운 스트림을 생성해 주어야 한다.

List<Integer> list1 = Arrays.asList(5,6,3,4);
List<Integer> list2 = Arrays.asList(8,7,3,4);

//스트림 생성
Stream<Integer> creatingStream = Stream.concat(list1.stream(),list2.stream());

//중간 연산
Stream<Integer> intermediateOperation =
        creatingStream
                .distinct() //중복된 값을 없애준다.
                .filter((num)->num%2<0) // 람다식의 반환이 true값만을 골라준다.
                .sorted((a,b)->b-a); // 내림차순정렬, 오름차순을 원할경우 람다식을 비워준다.

//최종 연산
List<Integer> terminalOperation = (List<Integer>) intermediateOperation.collect(Collectors.toList());

// 스트림생성, 중간연산, 최종연산을 모두 엮은 코드
Stream.concat(list1.stream(),list2.stream())
        .distinct()
        .filter((num)->num%2<0)
        .sorted((a,b)->b-a)
        .collect(Collectors.toList());

 

 

최종 연산의 종류

  • 기본 집계 : sum() , count() , average(), max() , min()
  • 매칭 : allMatch(), anyMatch(), noneMatch() 
  • 요소 소모 : reduce()
  • 요소 수집 : collect()

이외에도 많은 메서드들이 존재하며 자료가 너무 방대하기에 필요한 작업마다 구글링 하기를 추천....