관리 메뉴

제뉴어리의 모든것

[Section1][Java] 심화 - Stream(스트림) 본문

코드스테이츠/정리 블로깅

[Section1][Java] 심화 - Stream(스트림)

제뉴어리맨 2022. 7. 19. 02:32

스트림이란

영어단어로써의 스트림이란 흐름, 개울 이란 의미이다.

의미에서 알 수 있듯이 뭔가 흐르는것을 가리킨다.

우리가 사용하는 JAVA언어에서의 스트림은 데이터의 흐름이다.

데이터가 나열되어 있는것을 배열이라고 하듯이, 데이터들이 흐르는 것을 스트림이라고 하는것이다.

그렇다는것은 일단 데이터들이 여러개가 있어야 할것이다.

물의 원소만 있어서는 개울이라고 할 수 없듯이, 데이터가 여러개 쌓여 있어야 데이터의 흐름이라고 할 수 있겠다.

그러므로 스트림은 Collection, 배열과 같이 데이터들의 집합에서 사용 할 수 있는 기술이다.

 

스트림의 기본 사용법

  • 코드
package stream;


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

class Student{

    private String name;
    private int age;

    public Student(String name, int age) { 
        this.name = name;
        this.age = age;
    }

    public String getName() {
        return name;
    }

    public int getAge() {
        return age;
    }
}

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

        List<Student> studentList = Arrays.asList(new Student("제뉴어리맨", 32),   //Student를 요소로 가지는 리스트 생성
                new Student("줄라이맨", 34), new Student("어거스트맨",28),
                new Student("마치맨",27));

        Stream<Student> stream = studentList.stream(); //studentList의 Stream을 생성

        stream    //스트림 처리 시작 부분
                .filter(s -> s.getAge() >= 30) //중간 연산자, Stream의 각 요소(s)의 getAge를 호출해서 30 이상의 값들만 걸러서 챙김
                .forEach(s -> {   //최종 연사자, 중간연산자로 연산된 내용 각각을 처리
            String name = s.getName();
            int age = s.getAge();
            System.out.printf("%s 학생의 나이는 %d 입니다", name, age);
            System.out.println();
        });   //~ 스트림 처리 종료 부분
    }
}
  • 결과

 

스트림의 특징

  • 선언형으로 데이터 소스를 처리한다.
    선언형으로 데이터 소스를 처리한다라는것은, 선언형 프로그래밍 형식으로 데이터 소스를 처리 한다는것이다.
    그렇다면 선언형 프로그래밍 형식이란?
    일단 선언이란 뜻은 무언가를 널리 알리는것이다. 즉, "데이터소스(요소) 를 이렇게 해라~" 라고 알리는것이다.
    즉, 어떻게 어떻게 처리 하라고 직접적인 명령을 하는것이 아니다.
    그렇다 지금까지 우리가 해오던 방식은 명령형 프로그래밍이다.
    리스트를 순차해서 리스트의 i번째 요소를 변수에 담아서 그 요소를 처리 하고 다시 인덱스를 증가시키고 이런 일련의 과정을 일일히 명령어를 쳐서 처리 했던것이다!
    그러나 선언형 프로그래밍은 그냥 데이터는 자동으로 흘러가고 우리는 그 요소들을 이렇게 이렇게 처리해! 라고 선언만 해주는것이다!
    즉, 데이터를 어떻게 처리해라! 라고만 선언을 해주는것이다.


    참조 : https://boxfoxs.tistory.com/430 (명령형 프로그래밍과 선언형 프로그래밍의 차이)


  • 람다식으로 요소 처리 코드를 제공한다
    위 소스에서도 보았듯이 메소드에 우리가 일반적으로 사용하였던 변수를  넣거나 그런것이 아니라, 람다식을 인자로 넣는다.
    즉, 메소드의 매개변수로 변수가 아닌 "람다식" 또는 "메소드 참조" 내용 자체를 넣어서 해당 값이 메소드 내부에서 처리가 되는것이다.

    EX : 위 소스의 일부분이다
.filter(s -> s.getAge() >= 30) //filter 메소드의 매개변수로 람다식을 넣었다
  • 내부 반복자를 사용하므로 병렬 처리가 쉽다.

    +추가 정보
    - 외부 반복자
    개발자가 직접 index, iterator등을 사용하여 데이터의 집합의 요소를 직접 접근하는 코딩 패턴.
    EX : for, Iterator를 이용하는 반복문 등등

    - 내부 반복자란
    컬렉션과 배열 같은 데이터 집합 내부에서 요소들을 반복시키고, 개발자는 각 요소들에게 작업시킬 내용만을 코딩하는 패턴.

내부 반복자를 사용해서 얻는 이점은 컬렉션 내부에서 어떻게 요소를 반복시킬 것인가는 컬렉션에게 맡겨두고, 개발자는 요소 처리 코드에만 집중할 수 있다는 점이다. 내부 반복자는 요소들의 반복 순서를 변경하거나 멀티 코어 CPU를 최대한 활용하기 위해 요소들을 분배시켜 병렬 작업을 할 수 있게 도와주기 때문에 하나씩 처리하는 순차적 외부 반복자보다 효율적으로 요소를 반복시킬 수 있다.

 

  • 중간 연산과 최종 연산을 할 수 있다.
    스트림을 이용하면 중간연산, 최종 연산을 처리하여 다양한 처리를 축약된 코드로 다양한 효과를 기대할 수 있다.
    중간연산에서는 매핑, 필터링, 정렬 등을 수행하고 최종 연산에서는 반복, 카운팅, 평균, 총합 등의 집계를 수행한다.

 

파이프라인 구성

일단 파이프라인이란 무엇인가?

프로그래밍 관점을 떠나서 파이프라인이란 부분파이프들이 모여서 긴 파이프라인을 이루는 형태를 말한다.

이 관점으로 스트림에 대입했을때 의미는

여러개의 스트림이 연결되어 있는 구조를 말한다.

스트림은 중간 연산을 마칠때마다 Stream을 만들어 리턴하고, 

그 리턴된 Stream에 또 중간연산들을 추가하여 최종연산으로 결과물을 리턴하는 것이다.

아래의 소스를 보자.

List<Member> list = new ArrayList<>();
list.add(new Member(50,Member.MALE, "제뉴어리맨"));
list.add(new Member(100,Member.FEMALE, "악토버맨"));
list.add(new Member(150,Member.MALE, "준맨"));

//하단부터 스트림 내용
Stream<Member> maleFemaleStream = list.stream();
Stream<Member> maleStream = maleFemaleStream.filter(m -> m.getGender() == Member.MALE);
	//IntStream ageStream = maleStream.mapToInt(Member::getAge); //아래와 동일 코드
IntStream ageStream = maleStream.mapToInt(m -> m.getAge());  //위와 동일 코드
OptionalDouble opd = ageStream.average();
double ageAve = opd.getAsDouble();

위 코드에서 "//하다부터 스트림 내용" 이란 줄 밑에부터 보도록 하자.

1. ArrayList 객체를 본떠 스트림으로 만들었다.

2. maleFemaleStream 스트림을 filter(중간 연산) 메소드로 인해 각 요소들중 gender 변수의 값이 Member.MALE(상수)인 요소들만 걸러내어 또 다른 스트림을 만들었다.

3 (주석 있는 코드와 없는 코드는 동일한 내용이다). mapToInt(중간 연산) 메소드를 호출하여 각 요소들을 각 요소들의 age 변수값으로 맵핑 하여 또 다른 정수형스트림을 만들었다. 

4. 정수형 스트림에서 각 요소들의 평균을 구하는 average() (최종 연산) 를 호출하여 결과값을 만들었다.

5. 만들어진 결과를 더블형으로 ageAve에 할당하였다.

 

위에 내용을 보듯이 

스트림을 생성 (오리지날 스트림) 하고 두개의 중간연산 (filter, mapToInt) 을 처리하고 최종연산 (average)을 진행하여 최종 결과값을 만들어 내었다.

이 중, filter, mapToInt가 부분 파이프이고 오리지날 스트림, 중간연산, 최종연산을 통틀어 파이프라인이라고 할 수 있겠다.

아래는 위에 코드 내용을 그림으로 표현한것이다.

 참고하자

스트림 생성

  • Collection 인터페이스에서의 생성

Collection 인터페이스에는 stream() 이란 메소드가 이미 정의 되어 있으므로 어떤 구현체를 써도 모두 Stream을 생성 할 수 있다.

// List로부터 스트림을 생성
List<String> list = Arrays.asList("a", "b", "c");
Stream<String> listStream = list.stream();
listStream.forEach(System.out::prinln); //스트림의 모든 요소를 출력.

 

  • 배열에서의 생성
    배열의 원소들을 소스로하는 Stream을 생성하기 위해서는 Stream의 of 메서드 또는 Arrays의 stream 메서드를 사용합니다.
// 배열로부터 스트림을 생성
Stream<String> stream = Stream.of("a", "b", "c"); //가변인자
Stream<String> stream = Stream.of(new String[] {"a", "b", "c"});
Stream<String> stream = Arrays.stream(new String[] {"a", "b", "c"});
Stream<String> stream = Arrays.stream(new String[] {"a", "b", "c"}, 0, 3); //end 범위 미포함

 

 

  • 스트림 생성이 가능한 정적 메소드들
리턴  타입메서드(매개 변수) 소스
Stream java.util.Collection.Stream(), java.util.Collection.parallelSream( ) 컬렉션
Stream, IntStream, LongStream, DoubleStream Arrays.stream(T[]), Arrays.stream(int[]), Arrays.stream(long[]), Arrays.stream(double[]), Stream.of(T[]), IntStream.of(int[]) LongStream.of(long[]), DoubleStream.of(double[]) 배열
IntStream IntStream.range(int, int), IntStream.rangeClosed(int, int) int 범위
LongStream LongStream.range(long, long), LongStream.rangeClosed(long, long) long 범위

 

 

스트림 사용시 주의사항

  • 스트림은 데이터 소스로부터 데이터를 읽는것만 가능하고, 수정이 불가하다. 즉 원본은 건드리지 않는다 (read-only)
  • 스트림은 일회용이다. 한번 사용하면 닫히므로, 또 스트림을 사용하려면 다시 만들어야 한다 (only-one)

 

중간 연산

  • 필터링(filter(), distinct())
    - distinct()) : Stream의 요소들에 중복된 데이터가 존재하는 경우, 중복을 제거하기 위해 사용합니다.
    - filter() : Stream에서 조건에 맞는 데이터만을 정제하여 더 작은 컬렉션을 만들어냅니다. filter() 메서드에는 매개값으로 조건(Predicate)이 주어지고, 조건이 참이 되는 요소만 필터링합니다

  • 매핑(map())
    - map() : map은 기존의 Stream 요소들을 대체하는 요소로 구성된 새로운 Stream을 형성하는 연산.
    map 함수의 인자로 함수형 인터페이스 function을 받는다.

    - flatMap() : flatMap은 요소를 대체하는 복수 개의 요소들로 구성된 새로운 스트림을 리턴

    - flatMap() 과 map() 의 차이점 :
    map은 현재 스트림의 요소 자체를 매핑시키고, 매핑된 값을 스트림으로 반환하여 준다.
    즉, 스트림의 요소가 Array일 경우 Array 자체(주소값)를 매핑 시킨다.
    flatMap은 Array이나 Object인 원소를 가장 작은 단위의 단일 스트림으로 반환함.
    참조 : https://kchanguk.tistory.com/56
 

.map()과 .flatMap()의 차이

1. .map()  .map()은 단일 스트림의 원소를 매핑시킨 후 매핑시킨 값을 다시 스트림으로 변환하는 중간 연산을 담당합니다. 객체에서 원하는 원소를 추출해는 역할을 한다고 말할 수 있습니다. 아

kchanguk.tistory.com

 

  • 정렬(sorted())
    Stream의 요소들을 정렬하기 위해서는 sorted를 사용해야 하며, 파라미터로 Comparator를 넘길 수도 있습니다. Comparator 인자 없이 호출할 경우에는 오름차순으로 정렬이 되며, 내림차순으로 정렬하기 위해서는 Comparator의 reverseOrder를 이용

    -sorted() : 요소들을 정렬하여 반환 (기본은 오름차순)


  • 연산 결과 확인(peek())
    peek()과 forEach() 모두 요소를 출력한다는 기능에서는 동일하지만, 동작 방식이 다름.
    peek()은 중간 연산 메소드이고, forEach는 최종 연산 메소드 이므로
    peek 은 여러번 호출이 가능한 반면, forEach는 호출하고나면 스트림이 닫히므로 다른 메소드 호출이 불가하다.
    주로 디버깅용으로 사용.


최종 연산

최종 연산은 연산 결과가 스트림이 아니므로, 한 번만 연산이 가능

+리던션이란 {
대량의 데이터를 가공하여 축소하는 것.

EX :
데이터의 합계, 평균값, 카운팅, 최대값, 최소값 등이 대표적인 리덕션의 결과물


  • 연산 결과 확인(forEach())
    forEach는 최종 연산 메서드이기 때문에 파이프라인 마지막에서 요소를 하나씩 연산


  • 매칭(match())
    Stream의 요소들이 특정한 조건을 충족하는지 검사하고 싶은 경우에는 match() 메서드를 이용

    - allMatch() : 모든 요소들이 매개값으로 주어진 Predicate의 조건을 만족하는지 조사
    - anyMatch() : 최소한 한 개의 요소가 매개값으로 주어진 Predicate의 조건을 만족하는지 조사
    - noneMatch() : 모든 요소들이 매개값으로 주어진 Predicate의 조건을 만족하지 않는지 조사


  • 기본 집계(sum(), count(), average(), max(), min())
    집계는 최종 연산 기능으로 요소들을 카운팅, 합계, 평균값, 최대값, 최소값 등으로 연산하여 하나의 값으로 산출하는 것을 의미


  • reduce() : 이름에서 알 수 있듯이, 요소들을 줄여가며(reduce) 연산을 처리하는 최종연산 메소드이다.

    - 기본 사용 법
        // 초기값 없는 reduce 
        List<Integer> intList = List.of(1,2,3,4,5);

        int sum = intList.stream().reduce((a,b) -> a + b).get();

        System.out.println("reduce 결과 : "+sum);


        //초기값 있는 reduce
        List<Integer> intList2 = new ArrayList<>();

        int sum2 = intList2.stream().reduce(0,(a,b) -> a + b);

        System.out.println("reduce 결과 : "+sum2);

   - 결과

우선 reduce 메소드의 개념은 

요소를 차례대로 불러와 계속 더해주는것이다.
즉, 초기값이 있을때는 
1. 초기값 + 첫번째 요소 값.

2. 1번 연산의 결과 + 두번째 요소 값.

3. 2번 연산의 결과 + 세번째 요소 값.

위와 같은 차례대로 연산을 한다.

그러나 초기값이 없을때는!

1. 첫번째 요소 값 + 두번째 요소 값

2. 1번 연산의 결과 + 세번째 요소 값 

이런식으로 처리를 하게 된다.

그러므로, 초기값이 없는데 스트림의 요소도 없다면!

에러가 발생한다.

그러나 초기값이 있다면 초기값만 출력하게 된다.

그리고 두 메소드의 리턴타입이 다르다.

int sum = intList.stream().reduce((a,b) -> a + b).get() 

이렇게 초기값이 없는 reduce는 get() 메소드로 int값으로 변환을 해줘야 한다.

reduce의 개념을 코드로 표현 하자면 아래와 같다

int a = identity; //초기값을 a에 저장한다.

for(int b : stream)
	a= a + b;

 

 

지연 연산에 대한 생각

스트림의 특징으로 지연 연산을 한다고 배웠고 들었다.

지연 연산이란것은 최종연산을 하기전까진 중간연산을 미뤄두었다가 최종연산이 호출되면 그제서야 중간연산들을 처리하여 최종연산을 실행하는 것이다.

그래서 나는 최종 연산을 안해보기로 하였다.

내가 지연연산에 대해 배운대로라면 최종연산만 호출하지 않으면 중간연산을 이루어지지 않으니까 선언한 내용이 적용이 안되있어야 하는것인데..

아래의 내용은 테스트한 소스이다.

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

        List<String> strList = List.of("fdfd","adsd","vcvsfd");
        Stream<String> sorted = strList.stream().sorted(); //중간연산
        String[] strings = sorted.toArray(String[]::new); //최종연산 안하고 배열화

        for (String s : strings) {
            System.out.println(s); //출력
        }
    }
}

위 내용처럼 forEach같은 최종연산을 하지 않았다.

결과는 다음과 같다

즉 sorted 메소드가 실행되어서 사전순으로 정렬이 된 상태이다.

최종연산을 호출하지 않았는데도 말이다......

스트림 내용에 대해 더 상세히 알아봐야 겠지만, 일단 결과적으로 봤을때는

중간연산은 일단 스트림의 요소를 어떻게 처리해라 라고 선언을 하는것이니까, 스트림 내부적으로 선언된 처리를 기억하고 있다가, 배열이나 스트림과 같이 기존 데이터 구조로 돌아갈때 기억하고 있던 중간연산을 처리하고 변환하여 주는것이 아닐까 생각이 든다.

테스트시 예상한 결과가 아니라도 일단 객관적으로 나온 결과에 대해 왜 이런 결과가 나왔을지 스스로 파악하고 원리를 예상해 보는것도 중요한 능력인것 같다. 왜냐면 모든 기술의 원리를 100% 다 알고 쓰는것은 불가능하며 개발을 하는것에 있어서 시간적 효율도 굉장히 떨어지기 때문이다.