스트림(Stream)

김수민·2023년 3월 14일
0

백엔드 부트캠프

목록 보기
23/52

스트림의 핵심 개념과 특징

  • 스트림(Stream): 배열 및 컬렉션의 저장 요소를 하나씩 참조해서 람다식으로 처리할 수 있도록 하는 반복자
  • 각 데이터를 흐름에 따라 우리가 원하는 결과로 가공하고 처리하는 일련의 과정과 관련이 있음

스트림의 도입 배경

for문이나 Iterator를 사용하는 경우, 많은 경우 코드가 길고 복잡해짐.

Iterator를 사용한 반복 처리

import java.util.Arrays;
import java.util.Iterator;
import java.util.List;

public class PrintNumberOperator {
    public static void main(String[] args) {
        // 각 숫자를 배열화
        List<Integer> list = Arrays.asList(1, 2, 3, 4, 5);

        // Iterator 생성
        Iterator<Integer> it = list.iterator();

        // 리스트를 순회하며 값 출력
        while (it.hasNext()) {
            int num = it.next();
            System.out.print(num);
        }
    }
}

//출력값
12345

스트림을 사용한 반복 처리

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);
    }
}

//출력값
선언형 프로그래밍을 사용한 합계 : 14
  • 각 메서드에서 람다식을 사용하여 데이터를 처리하고 있음

기존 방식으로 데이터를 처리하는 경우 데이터 소스를 각기 다른 방식으로 다뤄야한다는 불편함이 있음.
예. 어던 데이터 집합을 순차적으로 정렬해야한다고 할 때, 같은 정렬 기능을 수행하는 데에 있어서 배열의 경우에는 Arrays.sort()를, List의 경우에는 Collections.sort()를 각각 다르게 사용해야함.
-> 개발자의 입장에서 불편

스트림: 데이터 소스가 무엇이냐에 관계없이 같은 방식으로 데이터를 가공/처리할 수 있음.
하나의 통합된 방식으로 데이터를 다룰 수 있게 됨.

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와 문자열 배열에 각각 스트림을 생성하고 forEach() 메서드를 사용해서 각 요소들을 순회하며 출력해줌.
forEach()메서드: 데이터 소스의 각 요소들을 순회하면서 람다식 안에 정의된 어떤 명령을 실행하는 데 사용하는 최종연산자

스트림의 특징

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

먼저 배열, 컬렉션, 임의의 수 등 다양한 데이터 소스를 일원화하여 스트림으로 작업하기 위해서는 스트림을 생성해야함. 스트림이 생성되고 나면, 최종 처리를 위한 중간 연산을 수행할 수 있음. 여기에는 필터링, 매핑, 정렬 등의 작업이 포함되며, 중간 연산의 결과는 또 다른 스트림이기 때문에 계속 연결하여 연산을 수행할 수 있음. 마지막으로, 중간 연산이 완료된 스트림을 최종적으로 처리하는 최종 연산(총합, 평균, 카운팅 등)을 끝으로 스트림은 닫히고 모든 데이터 처리가 완료됨. 최종 연산의 경우는 스트림의 요소를 소모하면서 연산을 수행하기 때문에 최종적으로 단 한번의 연산만 가능함. 최종 연산 후에 다시 데이터를 처리하고 싶다면 다시 스트림을 생성해주어야 함.

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

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

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

  1. 스트림은 일회용임(onetime-only)

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

  1. 스트림은 내부반복자임.


외부 반복자의 경우 요소가 필요할 때마다 순차적으로 컬렉션에서 필요한 요소들을 불러오는 반면, 내부반복자는 데이터 처리 코드만 컬렉션 내부로 주입해줘서 그 안에서 모든 데이터 처리가 이루어지도록 함.

스트림의 생성

배열 스트림 생성

배열을 데이터 소스로 하는 스트림 생성은 Arrays클래스의 stream() 메서드 또는 Stream 클래스의 of() 메서드를 사용할 수 있음

  • Arrays.stream()
public class StreamCreator {
   
       public static void main(String[] args) {
           // 문자열 배열 선언 및 할당
           String[] arr = new String[]{"김코딩", "이자바", "박해커"};
   
           // 문자열 스트림 생성
           Stream<String> stream = Arrays.stream(arr);
   
           // 출력
           stream.forEach(System.out::println);
   
       }
   }
   
   // 출력값
   김코딩
   이자바
   박해커
  • Stream.of()
import java.util.stream.Stream;

public class StreamCreator {

    public static void main(String[] args) {
        // 문자열 배열 선언 및 할당
        String[] arr = new String[]{"김코딩", "이자바", "박해커"};

        // 문자열 스트림 생성
        Stream<String> stream = Stream.of(arr);

        // 출력
        stream.forEach(System.out::println);
    }
}

// 출력값
김코딩
이자바
박해커

Arrays.stream()Stream.of() 메서드 모두 동일한 값을 출력함. 따라서 배열로 스트림을 생성할 시에는 둘 중에 더 편한 메서드를 임의로 선택하여 사용,

IntStream의 경우 일반적인 Stream 클래스에는 없는 숫자와 관련한 여러 유용한 메서드들이 정의되어져있음.

  • IntStream의 유용한 기능들
import java.util.Arrays;
import java.util.stream.IntStream;

public class StreamCreator {

    public static void main(String[] args) {

        // int형 배열로 스트림 생성
        int[] intArr = {1,2,3,4,5,6,7};
        IntStream intStream = Arrays.stream(intArr);

        // 숫자와 관련된 경우 intStream을 사용하는 것을 권장
        System.out.println("sum=" + intStream.sum());
//        System.out.println("average=" + intStream.average());

    }
}

//출력값
sum=28

 숫자 연산과 관련된 대부분의 메서드(합계, 카운팅, 평균, 최대값 등)는 최종 연산자이기 때문에 최종 사용시 스트림이 닫힘. 위의 예제 코드에서 sum() 메서드를 호출한 이후 다시 average()메서드를 호출하면 에러가 발생함

컬렉션 스트림 생성

컬렉션 타입(List, Set 등)의 경우 컬렉션의 최상위 클레스인 Collection에 정의된 stream() 메서드를 사용하여 스트림을 생성할 수 있음. 따라서 Collection으로부터 확장된 하위클래스 ListSet을 구현한 컬렉션 클래스들은 모두 stream() 메서드를 사용하여 스트림을 생성할 수 있음

  • 컬렉션 스트림 생성

    import java.util.Arrays;
    import java.util.List;
    import java.util.stream.Stream;
    
    public class StremCreator {
    
      public static void main(String[] args) {
      				//요소들을 리스트
        List<Integer> list = Arrays.asList(1,2,3,4,5,6,7);
        Stream<Integer> stream = list.stream();
        
        steram.forEach(System.out::print);
      }
    }
    
    //출력값
    1234567

    List타입의 스트림을 생성하는 과정을 보여줌. Arrays 클래스에 static하게 선언된 asList() 메서드를 사용하여 요소들을 리스트 타입의 참조변수에 할당한 뒤에 stream() 메서드를 사용하여 스트림을 생성.

    임의의 수 스트림 생성

    난수를 생성하는 자바의 기본 내장 클래스 Random 클래스 안에는 해당 타입의 난수들을 반환하는 스트림을 생성하는 메서드들이 정의되어 있음.
    에. int() 메서드의 경우 int 형의 범위 안에 있는 난수들을 무한대로 생성하여 IntStream 타입의 스트림으로 반환.

 import java.util.Random;
 inport java.util.stream.IntStream;
 
 public class StreamCreator {
 
 	public static void main(String[] args) {
    	//			난수 생성
        IntStream ints = new Random().ints();
        ints.forEach(System.out::println);
    }
}

int 형의 범위에서 추력값이 무한대로 생성됨.
무한 스트림(infinite stream): 스트림의 크기가 정해지지 않은 것
limit 메서드와 함께 사용하거나 매개변수로 스트림의 사이즈를 전달해서 범위를 제한할 수 있음.

import java.util.Random;
import java.util.stream.IntStream;

public class StreamCreator {

    public static void main(String[] args) {
			
			// 스트림 생성의 범위를 5개로 제한
        IntStream ints = new Random().ints(5);
				IntStream ints = new Random().ints().limit(5); 
        ints.forEach(System.out::println);
    }
}

IntStreamLongStream에 정의된 range() 나 rangeClosed() 메서드를 사용하면 특정 범위의 정수 값을 스트림으로 생성해서 반환하는 것도 가능.

import java.util.stream.IntStream;

public class StreamCreator {

    public static void main(String[] args) {

        //특정 범위의 정수
        IntStream intStream = IntStream.rangeClosed(1, 10);
        intStream.forEach(System.out::println);
    }
}

//출력값
12345678910

rangeClosed()range()의 차이는 두 번째로 전달되는 매개 변수가 범위안에 포함되는지 여부에 따라 구분됨.

스트림의 중간 연산

필터링(filter(), distinct())

필터링: 필요에 따라 조건에 맞는 데이터들만을 정제하는 역할을 하는 중간 연산자

  • distinct(): Stream의 요소들에 중복된 데이터가 존재하는 경우, 중복을 제거하기 위해 사용
  • filter(): Stream에서 조건에 맞는 데이터만을 정제하여 더 작은 컬렉션을 만들어냄. filter() 메서드에서는 매개값을 조건(Predicate)이 주어지고 조건이 참이 되는 요소만 필터링함. 조건은 람다식을 사용하여 정의함.
import java.util.Arrays;
import java.util.List;

public class FilteringExample {
    public static void main(String[] args) throws Exception {

        List<String> names = Arrays.asList("김코딩", "이자바", "박해커", "김코딩", "박해커");

        names.stream()
                .distinct() //중복 제거
                .forEach(element -> System.out.println(element));
        System.out.println();

        names.stream()
                .filter(element -> element.startsWith("김")) // 김씨 성을 가진 요소만 필터링 
                .forEach(element -> System.out.println(element));
        System.out.println();

        names.stream()
                .distinct() //중복제거
                .filter(element -> element.startsWith("김")) // 김씨 성을 가진 요소만 필터링 
                .forEach(element -> System.out.println(element));
    }
}

// 출력값
김코딩
이자바
박해커

김코딩
김코딩

김코딩

가장 첫 번째 스트림에서는 중복 제거 중간 연산만을 수행하여 리스트 타입의 참조변수 names 안에 중복되고 있는 "김코딩" 요소를 제거함. 두 번째 스트림에서는 "김"씨 성을 가진 요소들만 필터링하여 "김코딩"을 두 번 출력함.
첫 번째와 두 번째 스트림은 각각 독립적인 스트림. 대표적인 최종 연산자인 forEach()로 첫 번째 스트림이 닫히고 난 후에 두 번째 스트림이 생서오디어 새로운 연산을 수행함.
마지막 세 번째 스트림은 중복제거와 필터링을 모두 수행하였고, 그 결과 "김코딩"이라는 문자열을 단 한번 출력함.

매핑(map())

매핑: 스트림 내 요소들에서 원하는 필드만 추출하거나 특정 형태로 변환할 때 사용하는 중간연산자. 값을 변환하기 위한 조건을 람다식으로 정의.

import java.util.Arrays;
import java.util.List;

public class IntermediateOperationExample {
    public static void main(String[] args) {
        List<String> names = Arrays.asList("kimcoding", "javalee", "hackerna", "luckyguy");
        names.stream()
                .map(element -> element.toUpperCase()) // 요소들을 하나씩 대문자로 변환
                .forEach(element->System.out.println(element));
    }
}

// 출력값
KIMCODING
JAVALEE
HACKERNA
LUCKYGUY

리스트 타입의 참조변수 names 안에 정의되어 있는 각 요소들을 순회하면서 소문자 이름을 대문자로 변환한 값들이 담긴 스트림으로 반환하는 연산 과정을 보여줌.

// flatMap()
Arrays.stream(namesArray).flatMap(Arrays::stream).forEach(System.out::println);

// 출력값
박해커
이자바
김코딩
나박사

flatMap()은 중첩 구조를 제거하고 단일 컬렉션(Stream<String>)으로 만들어주는 역할. 요소들을 "평평하게" 한다는 의미에서 플래트닝(flattening)이라고 함.

정렬(sorted())

  • 정렬을 할 때 사용하는 중간 연산자
  • 괄호(())안에 Comparator라는 인터페이스에 정의된 static 메서드와 디폴트 메서드를 사용하여 간편하게 정렬 작업을 수행할 수 있음. 괄호 안에 아무 값도 넣지 않은 상태로 호출하면 기본 정렬(오름차순)로 정렬됨

기본 정렬

import java.util.Arrays;
import java.util.List;

public class IntermediateOperationExample {
	public static void main(String[] args) {
    			 // 동물들의 이름을 모아둔 리스트
    	List<String> animals = Arrays.asList("Tiger", "Lion", "Monkey", "Duck", "Horse", "Cow");
        		// 인자값 없는 sort() 호출
        animals.stream().sorted().forEach(System.out::print);
    }
}

// 출력값
Cow
Duck
Horse
Lion
Monkey
Tiger

역순으로 정렬

import java.util.Arrays;
import java.util.Comparator;
import java.util.list;

public class IntermediateOperationExample {
	public static void main(String[] args) {
    	List<String> animals = Arrays.asList("Tiger", "Lion", "Monkey", "Duck", "Horse", "Cow);
        
        	// 인자값에 Comparator 인터페이스에 규정된 메서드 사용
        animals.stream().sorted(Comparator.reverseOrder()).forEach(System.out::println);
        
    }
}

// 출력값
Tiger
Monkey
Lion
Horse
Duck
Cow

Comparator 인터페이스 안에 정의된 reverseOrder() 라는 이름의 메서드를 인자로 넘겨 호출하면서 역순으로 정렬하는 기능을 실행할 수 있음

기타

  • skip(): 스트림의 일부 요소를 건너뜀
import java.util.stream.IntStream;
    
    public class IntermediateOperationExample {
        public static void main(String[] args) {
    
            // 1~10 범위의 정수로 구성된 스트림 생성
            IntStream intStream = IntStream.rangeClosed(1, 10);
    
            // 앞의 5개의 숫자를 건너뛰고 숫자 6부터 출력
            intStream.skip(5).forEach(System.out::println);
        }
    }
    
    // 출력값
    6
    7
    8
    9
    10
  • limit(): 스트림의 일부를 자름
import java.util.stream.IntStream;
    
    public class IntermediateOperationExample {
        public static void main(String[] args) {
    
            // 1~10 범위의 정수로 구성된 스트림 생성
            IntStream intStream = IntStream.rangeClosed(1, 10);
    
            // 앞에서부터 5개의 숫자만 출력
            intStream.limit(5).forEach(System.out::println);
        }
    }
    
    // 출력값
    1
    2
    3
    4
    5
  • peek(): forEach()와 마찬가지로 요소들을 순회하며 특정 작업을 수행함.

스트림의 최종 연산

기본집계(sum(), count(), average(), max(), min())

숫자와 관련된 기본적인 집계의 경우에는 대부분 최종 연산자임.

  • 기본 집계
import java.util.Arrays;

public class TerminalOperationExample {
    public static void main(String[] args) {
        // int형 배열 생성
        int[] intArray = {1,2,3,4,5};

        // 카운팅
        long count = Arrays.stream(intArray).count();
        System.out.println("intArr의 전체 요소 개수 " + count);

        // 합계
        long sum = Arrays.stream(intArray).sum();
        System.out.println("intArr의 전체 요소 합 " + sum);

        // 평균
        double average = Arrays.stream(intArray).average().getAsDouble();
        System.out.println("전체 요소의 평균값 " + average);

        // 최대값
        int max = Arrays.stream(intArray).max().getAsInt();
        System.out.println("최대값 " + max);

        // 최소값
        int min = Arrays.stream(intArray).min().getAsInt();
        System.out.println("최소값 " + min);

        // 배열의 첫 번째 요소 
        int first = Arrays.stream(intArray).findFirst().getAsInt();
        System.out.println("배열의 첫번째 요소 " + first);
    }
}

// 출력값
intArr의 전체 요소 개수 5
intArr의 전체 요소 합 15
전체 요소의 평균값 3.0
최대값 5
최소값 1
배열의 첫번째 요소 1

매칭(allMatch(), anyMatch(), noneMatch())

match() 메서드를 사용하면 조건식 람다 Predicate를 매개변수로 넘겨 스트림의 각 데이터 요소들이 특정한 조건을 충족하는지 만족시키지 않는지 검사하여 그 결과를 boolean 값으로 반환함.

  • allMatch() : 모든 요소들이 조건을 만족하는지 여부 판단

  • noneMatch(): 모든 요소들이 조건을 만족하지 않는지 여부 판단

  • anyMatch(): 하나라도 조건을 만족하는 요소가 있는지 여부 판단

  • 매칭

import java.util.Arrays;

public class TerminalOperationExample {
    public static void main(String[] args) throws Exception {
        // int형 배열 생성
        int[] intArray = {2,4,6};

        // allMatch()
        boolean result = Arrays.stream(intArray).allMatch(element-> element % 2 == 0);
        System.out.println("요소 모두 2의 배수인가요? " + result);

        // anyMatch()
        result = Arrays.stream(intArray).anyMatch(element-> element % 3 == 0);
        System.out.println("요소 중 하나라도 3의 배수가 있나요? " + result);

        // noneMatch()
        result = Arrays.stream(intArray).noneMatch(element -> element % 3 == 0);
        System.out.println("요소 중 3의 배수가 하나도 없나요? " + result);
    }

}

// 출력값
요소 모두 2의 배수인가요? true
요소 중 하나라도 3의 배수가 있나요? true
요소 중 3의 배수가 하나도 없나요? false

요소소모(reduce())

스트림의 요소를 줄여나가면서 연산을 수행하고 최종적인 결과를 반환함.
reduce() 메서드의 경우에는 먼저 첫 번째와 두 번째 요소를 가지고 연산을 수행하고, 그 결과와 다음 세 번째 요소를 가지고 또 다시 연산을 수행하는 식으로 연산이 끝날 때까지 반복함. 따라서 reduce() 메서드의 매개변수 타입은 BinaryOperator<T>로 정의되어 있음.
T reduce(T identity, BinaryOperator<T> accumulator>
첫 번째 매개변수 identity는 특정연산을 시작할 대 설정되는 초기값을 의미.
두 번째 accumulator는 각 요소들을 연산하여 나온 누적된 결과값을 생성하는데 사용하는 조건식.

  • reduce()
import java.util.Arrays;

public class TerminalOperationExample {
    public static void main(String[] args) throws Exception {
        int[] intArray = {1,2,3,4,5};

        // sum()
        long sum = Arrays.stream(intArray).sum();
        System.out.println("intArray 전체 요소 합: " + sum);

        // 초기값이 없는 reduce()
        int sum1 = Arrays.stream(intArray)
                .map(element -> element * 2)
	                .reduce((a , b) -> a + b)
                .getAsInt();
        System.out.println("초기값이 없는 reduce(): " + sum1);

        // 초기값이 있는 reduce()
        int sum2= Arrays.stream(intArray)
                .map(element -> element * 2)
                .reduce(5, (a ,b) -> a + b);
        System.out.println("초기값이 있는 reduce(): " + sum2);
    }
}

// 출력값
intArray 전체 요소 합: 15
초기값이 없는 reduce(): 30
초기값이 있는 reduce(): 35

요소 수집(collect())

스트림의 요소들을 List, Set, Map 등 다른 타입의 결과로 수집하고 싶은 경우에 사용.
collect() 메서드는 Collector 인터페이스 타입의 인자를 받아서 처리할 수 있는데, 직접 구현하거나 미리 제공된 것들을 사용할 수 있음.

import java.util.Arrays;
import java.util.List;
import java.util.Map;
import java.util.stream.Collectors;

public class TerminalOperationExample {

    public static void main(String[] args) {
        // Student 객체로 구성된 배열 리스트 생성 
        List<Student> totalList = Arrays.asList(
                new Student("김코딩", 100, Student.Gender.Male),
                new Student("박해커", 80, Student.Gender.Male),
                new Student("이자바", 90, Student.Gender.Female),
                new Student("나미녀", 60, Student.Gender.Female)
        );
        
        // 스트림 연산 결과를 Map으로 반환
        Map<String, Integer> maleMap = totalList.stream()
                .filter(s -> s.getGender() == Student.Gender.Male)
                .collect(Collectors.toMap(
                        student -> student.getName(), // Key
                        student -> student.getScore() // Value
                ));

        // 출력
        System.out.println(maleMap);
    }
}

class Student {
    public enum Gender {Male, Female};
    private String name;
    private int score;
    private Gender gender;

    public Student(String name, int score, Gender gender) {
        this.name = name;
        this.score = score;
        this.gender = gender;
    }

    public String getName() {
        return name;
    }

    public int getScore() {
        return score;
    }

    public Gender getGender() {
        return gender;
    }
}

// 출력값
{김코딩=100, 박해커=80}

0개의 댓글