스트림

goose_bumps·2024년 11월 29일

Java

목록 보기
21/21

데이터를 정렬할 때 컬렉션과 배열의 경우 서로 다른 메서드를 호출하여 정렬해야 한다. 그리고 데이터를 확인하기 위해서는 iterator 또는 for를 사용하여 코드를 작성하는데, 이 방식은 너무 길고 한 번에 알아보기 어렵다.

간단한 String 배열과 List의 데이터를 출력하는 예시를 보자.

        String[] strArray = {"b","c","k","d","o"};
        List<String> list = Arrays.asList(strArray);

        // 데이터 정렬
        Arrays.sort(strArray);
        Collections.sort(list);

        // strArray 데이터 확인(for문 사용)
        for (String s : strArray) {
            System.out.println(s);
        }

        // list 데이터 확인(iterator 사용)
        Iterator it = list.iterator();
        while(it.hasNext()){
            System.out.println(it.next());
        }

데이터를 정렬하는 방법도 다르고, 출력하는 방법도 복잡하다.

정렬, 출력 모두 같은 방법으로 통일할 수 없을까?
이때 스트림을 사용하면 코드를 더 간결하게 만들 수 있다.

        String[] strArray = {"b","c","k","d","o"};
        List<String> list = Arrays.asList(strArray);
        
        // 스트림 정렬
        Stream<String> arrayStream = Stream.of(strArray);
        Stream<String> listStream = list.stream();
        
        // 스트림 출력
        arrayStream.sorted().forEach(System.out::println);
        listStream.sorted().forEach(System.out::println);

배열과 컬렉션 두 가지 모두 스트림으로 만들면 정렬과 출력 모두 같은 방법으로 할 수 있다.

스트림은 이처럼 코드의 재사용성을 높히고 간결하게 하기 위해 데이터 소스를 추상화하고, 데이터를 다루는데 자주 사용되는 메서드들을 정의해놓은 것이다.

1. 스트림의 특성

스트림의 주요 특성을 정리하자면 다음과 같다.

  • 데이터 소스를 변경하지 않음
  • 일회용
  • 내부 반복으로 작업 처리

스트림은 데이터를 읽어오기만 할 뿐 데이터 소스 자체를 변경하지는 않는다. 그래서 위의 예시처럼 배열을 스트림으로 만들어 정렬 시켜도 데이터 소스인 배열은 변하지 않는다.

스트림은 일회성이기 때문에 최종 연산 후에는 스트림이 닫혀 재사용이 불가능하다. 다시 사용하려면 다시 스트림을 생성해야 한다. 최종 연산은 뒤에서 설명할 스트림 연산에서 다루겠다.

마지막으로 스트림은 내부적으로 반복이 되어 작업이 간결하다. forEach()가 대표적인데, 매개변수로 람다식을 받아 데이터 소스의 모든 요소에 이 람다식을 전부 적용한다.

1) 스트림 연산

스트림의 연산은 중간 연산과 최종 연산으로 분류할 수 있다. 글로 설명하는 것보다 예제로 보는게 이해가 더 빠르기 때문에 예시로 보여주겠다.

listStream.sorted().distinct().limit(3).forEach(System.out::println);

위에서 보여준 list의 스트림에 중간 연산에 해당하는 메서드를 추가한 것이다.
여기서 sorted(),distinct(),limit()가 중간 연산에 해당하고 forEach()가 최종 연산에 해당한다.

둘의 차이점은 중간 연산의 결과는 스트림이지만, 최종 연산의 결과는 스트림이 아니다.
하지만, 최종 연산이 호출되면 스트림이 닫히게 되어 다시 사용하려면 재생성해야 한다.

중간 연산의 경우 연산 결과가 스트림이라고 했지만 연산 전의 스트림과 같은 것이 아니며, 최종 연산이 수행되기 전까지는 중간 연산은 수행되지 않는다.
호출한다고 해도 즉각적으로 수행되는 것이 아니며, 단지 어떤 작업이 수행되어야 하는지 지정하는 것일뿐이다.

중간 연산과 최종 연산에 해당하는 메서드들은 뒤에서 더 자세히 다루겠다.

2) 기본형을 다루는 스트림

스트림은 선언 타입으로 Stream< T >을 받는데 제네릭 타입이기 때문에 원시 타입을 받을 수 없다. 그래서 wrapper 타입을 사용해야 하는데, 만약 원시 타입을 사용할 경우 AutoBoxing&UnBoxing으로 인해 비효율적이다.

이럴 때 IntStream, LongStream, DoubleStream을 사용하면 효율적으로 사용이 가능하다.
이 스트림들은 기본형 타입을 다루기 때문에 Stream< Interger >보다는 IntStream을 사용하는 것이 더 효율적이다.
(LongStream, DoubleStream도 마찬가지이다.)

3) 병렬 스트림

fork&join 프레임워크를 사용하면 작업의 병렬 처리가 가능한데 스트림 또한 병렬 스트림을 사용하면 작업을 병렬 처리할 수 있다.

우선 병렬 스트림을 만들면 되는데 parallel() 메서드를 호출하면 된다.
앞에서 다룬 배열의 스트림을 병렬 스트림으로 만들어 보겠다.

        Stream<String> arrayStream = Stream.of(strArray);
        Stream<String> parallelStream = arrayStream.parallel();

이러면 끝이다. 병렬 스트림이 만들어진 것이다.
만약 parallel()을 호출하여 만들어진 병렬 스트림을 다시 병렬 처리되지 않는 스트림으로 만들고 싶다면 sequential() 메서드를 호출하면 된다.
단, 이 메서드는 병렬 처리된 스트림(parallel()을 호출한)을 원상복귀 시키는 것이기 때문에 원래 병렬 스트림이 아닌 스트림에는 호출할 필요가 없다.

2. 스트림 생성

스트림의 생성 방법을 알아보자.

1) 컬렉션에서 스트림 생성

컬렉션의 최고 조상인 Collection 클래스에 stream()이 정의되어 있는데, Collection을 상속받는 모든 자손과 구현하는 컬렉션 클래스들은 모두 사용이 가능하다.

인스턴스 메서드이기 때문에 Collection 참조변수명.stream()으로 호출하며, List와 Set 그리고 이를 구현하는 컬렉션들 모두 동일하게 사용가능 하다.

        List<Integer> list = new ArrayList<>();
        Set<Integer> set = new HashSet<>();
        for(int i = 0; i < 6; i++){
            list.add(i);
            set.add(i);
        }
        Stream<Integer> listStream = list.stream();
        Stream<Integer> setStream = set.stream();

2) 배열에서 스트림 생성

배열에서 스트림을 생성하려면 Stream 인터페이스나 Arrays 클래스에서 static 메서드를 호출해야 한다.

Return TypeMemthod
Stream< T >Stream.of(T..values)
Stream< T >Stream.of(T[ ])
Stream< T >Arrays.stream(T[ ])
Stream< T >Arrays.stream(T[ ], int startInclusive, int endExclusive)

첫 번째 메서드는 가변 인자를 입력하면 되고 마지막 메서드는 포함할 데이터의 범위를 입력하는 것인데, endExclusive는 포함되지 않는 범위이다.

		String[] strArray = {"a","B","D","f"};
        
        Stream<String> stream1 = Stream.of("a","B","D","f");
        Stream<String> stream2 = Stream.of(strArray);
        Stream<String> stream3 = Arrays.stream(strArray);
        Stream<String> stream4 = Arrays.stream(strArray,0,4);

3) 특정 범위의 정수를 스트림으로 생성

스트림을 기본형 타입을 다루는 스트림으로 만들려면 스트림의 변환을 사용해야 하는데 이에 대해서는 뒤에서 다룰 것이다.
만약, 특정 범위의 정수를 스트림으로 생성하고 싶을 경우 range(int beginInclusive, int endExclusive)rangeClosed(int beginInclusive, int endInclusive)를 사용하면 된다.

두 메서드 모두 IntStream 인터페이스의 static 메서드이기 때문에 IntStream.range() 형식으로 출력하며, 차이점은 마지막 범위를 포함하지 않냐 포함하냐이다.
(rangeClosed()가 마지막 범위를 포함하는 메서드이다.)

간단하게 0~9까지 정수를 포함하는 스트림을 만들어보자.

        IntStream intStream = IntStream.range(0,10);
        intStream.forEach(System.out::println); // 0 ~ 9

4) 난수로 이루어진 스트림 생성

Random 클래스에는 난수로 이루어진 스트림을 생성하는 인스턴스 메서드들이 포함되어 있다.

Return TypeMemthod
IntStreamints()
LongStreamlongs()
DoubleStreamdoubles()

이 메서드들로 만들어진 스트림은 크기가 정해져 있지 않은 무한 스트림이기 때문에 limit()를 사용하여 크기를 지정해야 한다. 크기를 지정하지 않고 무한 스트림을 출력하면 멈추지 않고 계속 출력이 된다....

        Random random = new Random();
        IntStream intStream = random.ints();
        intStream.limit(10);

        intStream.forEach(System.out::println); 
        //java.lang.IllegalStateException: stream has already been operated upon or closed

예시처럼 실행할 경우 IllegalStateException이 발생하는데 limit()가 최종 연산으로 연산되었기 때문에 스트림이 닫혀서 그렇다.
이럴 경우에는 중간 연산으로 바꿔주고 최종 연산으로 forEach()를 호출하면 된다.

        Random random = new Random();
        IntStream intStream = random.ints();
        intStream.limit(5).forEach(System.out::println);

-1654559939
-1827981775
1502982020
227386151
317673059

물론 출력 결과는 난수이기 때문에 실행할 때마다 다르다.

이 방법 말고도 처음부터 유한 스트림으로 만드는 방법도 있다.

Return TypeMemthod
IntStreamints(long streamSize)
LongStreamlongs(long streamSize)
DoubleStreamdoubles(long streamSize)

매개변수로 스트림의 크기를 입력하여 생성하는 것이다.
이렇게 생성된 스트림의 난수는 다음과 같은 범위를 갖는다.

Range
Integer.MIN_VALUE <= ints() <= Integer.MAX_VALUE
Long.MIN_VALUE <= longs() <= Long.MAX_VALUE
0.0 <= doubles() <= 1.0

범위를 지정하여 생성하는 방법도 있다.

Return TypeMemthod
IntStreamints(int beginInclusive, int endExclusive)
IntStreamints(long streamSize, int beginInclusive, int endExclusive)
LongStreamlongs(long beginInclusive, long endExclusive)
LongStreamlongs(long streamSize, long beginInclusive, long endExclusive)
DoubleStreamdoubles(double beginInclusive, double endExclusive)
DoubleStreamdoubles(long streamSize, double beginInclusive, double endExclusive)

시작범위는 포함되고 끝범위는 포함되지 않으며, 스트림 크기를 매개변수로 넣어 크기와 범위 모두 지정이 가능하다.

매개변수로 메서드를 오버로딩한 것이기 때문에 상황에 따라 적절하게 사용하면 된다.

5) 빈 스트림 생성

스트림이 비어있을 경우 null값을 입력할 수도 있지만, 이 방법보다는 빈 스트림을 생성하는 것이 더 좋다.

        Stream<String> nullStream = null;
        Stream<String> emptyStream = Stream.empty();

Stream 인터페이스의 empty()메서드를 호출하여 생성하며 왜 빈 스트림을 생성하는지 더 좋은 이유는 다음과 같다.

  • NullException 방지
  • 코드의 가독성과 유지보수성 향상
  • 함수형 프로그래밍에 적합
  • Optional과 시너지 효과
  • 테스트와 디버깅이 쉬움

Optional 클래스는 뒤에서 자세하게 다룰 내용이다.

6) 스트림 연결

Stream 인터페이스의 concat() 이라는 static 메서드를 사용하면 스트림 간의 연결이 가능하다. 단, 연결하려는 스트림은 서로 같은 타입이어야 한다.

Return TypeMemthod
static < T > Stream< T >concat(Stream<? extends T> a, Stream<? extends T> b)

매개변수로 입력되는 스트림의 타입은 와일드 카드가 사용되었으므로 T와 그 조상 타입만 들어올 수 있다.

        Stream<String> stm1 = Arrays.asList("a","b","c").stream();
        Stream<String> stm2 = Arrays.asList("d","e","f").stream();

        Stream<String> concatStream = Stream.concat(stm1,stm2);
        concatStream.forEach(System.out::print); // abcdef

3. 중간연산

중간연산은 연산의 결과가 스트림이며, 스트림을 가공하는 과정이라 생각하면 된다.
앞에서 언급한 skip(),limit() 등이 중간연산에 해당한다.

1) 자르기

  • skip(int i) : 0번째 요소부터 포함하여 i개의 요소를 건너뛴다
  • limit(int i) : 시작요소부터 i개만큼 크기를 제한한다

limit(3)만 호출할 경우 0번째 요소부터 2번째 요소까지 스트림의 크기를 제한한다.
3번째 요소부터 크기가 5인 스트림으로 가공하려면 다음과 같이 중간연산을 거치면 된다.

        IntStream intStream = IntStream.rangeClosed(0,10); //1~10까지 정수 스트림 생성
        intStream.skip(3).limit(5).forEach(System.out::println);

2) 걸러내기

  • filter(Predicate<T> p) : 주어진 조건 p에 부합하지 않는 요소를 걸러낸다
  • distinct() : 스트림에서 중복된 요소들을 제거한다

distinct()은 매개변수 없이 호출하며 fiter(Predicate<T> p)는 매개변수로 람다식도 사용할 수 있다.

1~10까지 중복된 정수 스트림을 중복을 제거하고 2의 배수만 있는 스트림으로 가공해보자.

        int[] intArr = new int[]{1,2,2,2,3,4,4,5,6,6,6,6,7,7,7,7,7,8,8,8,9,9,10};
        IntStream intStream = IntStream.of(intArr);
        intStream.distinct().filter(i -> i%2==0).forEach(System.out::println);

Predicate를 정의해서 매개변수로 입력하는 것도 가능하다.
예시에서는 IntStream으로 만들었기 때문에 PredicateIntPredicate로 사용해야 한다.

        int[] intArr = new int[]{1,2,2,2,3,4,4,5,6,6,6,6,7,7,7,7,7,8,8,8,9,9,10};
        IntStream intStream = IntStream.of(intArr);
        IntPredicate p = i -> i%2==0;
        intStream.distinct().filter(p).forEach(System.out::println);

3) 정렬

https://velog.io/@nosibi/Comparable-Comparator#3-stream%EC%97%90%EC%84%9C%EC%9D%98-%EC%82%AC%EC%9A%A9

해당 링크의 내용을 참고하면 이해하기 쉽다.

4) 변환

  • map()

스트림 간의 변환에서는 map()을 사용한다.
이 메서드는 Function<T,R>을 매개변수로 입력받아 스트림의 각 요소에 apply()를 적용하여 반환하고 이는 새로운 스트림의 요소가 된다.
그렇다면 매개변수로 들어가는 Function<T,R>T가 기존 스트림의 요소 타입, R이 변환하려는 스트림 요소의 타입이 되어야 한다.

스트림 정렬을 설명할 때 사용했던 Student 클래스를 바탕으로 예시를 보여주겠다.

        List<Student> studentList = Arrays.asList(
                new Student(34,"Kim"),
                new Student(60,"Jeong"),
                new Student(40,"Kang"),
                new Student(98,"Choi"),
                new Student(72,"Park"),
                new Student(59,"Lee"));

        Stream<Student> studentStream = studentList.stream();

학생들의 정보를 List에 넣어서 이를 Student 타입의 스트림으로 만들었다.
이제 이 studentStream을 학생들의 이름만 얻어 학생들의 이름 스트림을 만들어보겠다.

        Function<Student,String> studentNameFunction = s -> s.getName();

        Stream<String> studentNameStream = studentStream.map(studentNameFunction);

매개변수로 들어갈 Function<T,R>studentNameFunction을 만들고 람다식을 통해 name 필드를 반환하도록 정의하였다.

그리고 map()을 호출하여 매개변수로 studentNameFunction을 넘겨주면 스트림 변환이 완료된다.

결과를 확인해보자.

        studentNameStream.sorted(Comparator.naturalOrder()).forEach(System.out::println);

자연순서로 정렬 후 출력을 하면

Choi
Jeong
Kang
Kim
Lee
Park

오름차순으로 이름이 출력이 된다.

스트림 변환에는 몇 가지 주의점이 있다.

  • null 처리
  • 반환 스트림
  • 다차원 데이터 변환

입력 스트림(=기존 스트림)에 null값이 있을 경우, 변환 로직에서 NullPointerException이 발생한다.
따라서, 입력 스트림 요소 중에 null값이 있는지 확인해야 한다.

map()으로 변환한다고 했지만 실제로는 새로운 스트림을 반환하는 것이다. 그러기 때문에 기존에 있던 스트림 즉, 데이터를 수정하지는 않는다.

마지막으로 map()은 각 요소를 하나씩 반환하는 구조이기 때문에 다차원 리스트 등을 평면화하려면 flatMap()을 사용해야 한다. 이에 대한 것은 뒤에서 다시 다루겠다.


  • mapToInt(), mapToLong(), mapToDouble()

map()으로는 기본형 스트림 변환은 불가능하다.
래퍼 클래스 타입의 스트림보다는 기본형 스트림이 Auto Boxing & UnBoxing의 소요가 없는 측면에서 효율적이지만 변환이 안된다면 아쉬울 것이다.

하지만 mapToInt()를 사용하면 기본형 스트림으로 변환이 가능하다.
(long, doublemapToLong(), mapToDouble()을 사용하면 된다.)

이번에는 Student클래스에서 점수를 반환에서 int타입의 기본형 스트림을 만들어보겠다.

        Stream<Student> studentStream = studentList.stream();
        IntStream scoreStream = studentStream.mapToInt(s-> s.getScore());

map()과 동일하게 매개변수로 함수형 인터페이스를 입력하면 되는데 차이점은 IntFunction<U>가 매개변수로 사용되는 것이다.
IntFunction<U>는 반환타입이 int이기 때문에 입력타입만 적어주면 된다.
위의 예시에서는 간결하게 람다식으로 입력하였다.

이제 기본형 스트림으로 변환되었으니 점수를 오름차순으로 정렬해서 출력해보자.

        scoreStream.sorted().forEach(System.out::println);

34
40
59
60
72
98

Stream<T>에서 기본형 스트림으로 변환이 가능하다면 반대의 경우도 가능할까?
물론 가능하다. 원시타입에서 래퍼 클래스 타입 예를 들어, IntStream에서 Stream<Integer>로 변환을 할 때에는 boxed()만 호출해주면 된다.

기본형 스트림에서 다른 Stream<T>로 변환할 때에는 mapToObj()를 호출하고 매개변수로 Function<T,R>을 입력하면 된다. 당연히 입력타입은 원시타입이 되고 반환타입이 변환하고자 하는 스트림의 타입이 되어야 한다.

위 예시의 scoreStreamStream<String>으로 변환해보자.

Stream<String> stringStream = scoreStream.sorted().mapToObj(String::valueOf);

점수의 오름차순으로 정렬 후에 파라미터를 문자열로 만드는 valueOf()를 사용하여 String타입의 스트림으로 변환하였다.


  • flatMap()

앞에서 다차원 리스트를 평면화할 수 있는 flatMap()을 언급했었다. 이게 무슨 말이냐면 List를 요소로 가지는 List를 스트림으로 만들었을 때 스트림을 다차원이 아닌 1차원으로 만드는 것이다.

여기 Student 인스턴스를 가지고 있는 여러 List가 있다.

        List<Student> studentList1 = Arrays.asList(
                new Student(34,"Kim"),
                new Student(60,"Jeong"));

        List<Student> studentList2 = Arrays.asList(
                new Student(40,"Kang"),
                new Student(98,"Choi"));

        List<Student> studentList3 = Arrays.asList(
                new Student(72,"Park"),
                new Student(59,"Lee"));

List에 있는 인스턴스들은 전부 모아서 하나의 스트림으로 만들고자 한다.
그래서 다음과 같이 스트림으로 변환시켜 보았다.

        List<List<Student>> studentLists = Arrays.asList(studentList1,studentList2,studentList3);
        Stream<Stream<Student>> studentListStream = studentLists.stream().map(List::stream);
        studentListStream.forEach(System.out::println);

그런데 실행 결과는 생각과는 다르게 출력되었다.

java.util.stream.ReferencePipelineHead@12edcd21java.util.stream.ReferencePipelineHead@12edcd21 java.util.stream.ReferencePipelineHead@34c45dca
java.util.stream.ReferencePipeline$Head@52cc8049

toString()을 오버라이딩 한 형식으로 학생 정보가 출력되어야 하는데 참조값들이 출력된 것이다.

왜 이렇게 된걸까??

map()으로 변환시킨 스트림은 현재 이런 상태인 것이다.

스트림의 요소가 Student타입의 스트림인 것이다. 그래서 스트림 객체의 참조값이 출력이 된 것이다.

우리는 아래 그림과 같이 변환을 해야한다.

이때 사용해야 하는 것이 바로 flatMap()인 것이다.

        List<List<Student>> studentLists = Arrays.asList(studentList1,studentList2,studentList3);
        Stream<Student> studentStream = studentLists.stream().flatMap(List::stream);
        studentStream.forEach(System.out::println);

Student name = Kim, score = 34
Student name = Jeong, score = 60
Student name = Kang, score = 40
Student name = Choi, score = 98
Student name = Park, score = 72
Student name = Lee, score = 59

원하는데로 학생 정보가 출력이 되었다.

자, 그렇다면 map()flatMap()은 작동 방식이 어떻게 차이가 나는 것일까?

  • map()
    -각 요소를 변환하지만, 변환된 데이터가 스트림일 경우 중첩 구조를 유지
    -결과 : Stream<Stream<T>>
  • flatMap()
    -각 요소를 변환하고, 변환된 하위 스트림을 병합하여 단일 스트림으로 평면화
    -결과 : Stream<T>

결국 중첩 구조가 유지되느냐 아니면 평면화가 되느냐의 차이인 것이다. 이러한 차이는 List<List<T>> 구조뿐만 아니라 List<String[]>처럼 2차원 배열 구조에서도 동일하게 적용된다.

4. 최종연산

스트림 최종연산은 연산 후에 스트림이 닫히게 되어 더 이상 사용할 수 없는 연산이다. 연산 결과가 스트림 요소의 합이나 요소의 최댓값 같은 단일값이거나 스트림의 요소가 담긴 배열, 컬렉션일 수 있다.

1) forEach()

앞에서부터 계속 사용했던 메서드이다. 보통 스트림 요소를 출력하는 용도로 사용되며 스트림 각 요소에 대해 Consumer를 실행한다.

void forEach(Consumer<? super T? action)

2) 조건 검사

스트림 각 요소에 대해 매개변수로 입력된 Predicate를 적용하여 boolean값을 반환한다.

  • allMatch() : 스트림 모든 요소가 주어진 조건을 만족해야 true를 반환한다
  • anyMatch() : 스트림 요소 중 하나라도 조건을 만족하면 true를 반환한다
  • noneMatch() : 스트림의 모든 요소가 조건을 만족하지 않아야 true를 반환한다

처리 과정에서 차이점이 있는데 allMatchnoneMatch는 스트림 요소 중 하나라도 조건을 만족하지 않으면 처리를 종료한다.(false를 반환)
이와 반대로 anyMatch는 요소 중 하나라도 조건을 만족하면 처리를 종료하게 된다.

조건 검사 최종 연산은 데이터가 특정 조건을 만족하는지 여부를 효율적으로 검사할 수 있는 강력하고 간단한 도구라고 할 수 있다.

조건 검사를 통해 컬렉션에서 조건에 부합하는지 확인을 해보는 예제를 보면서 이해해보자.

List<String> stringList = Arrays.asList("Alice","Bob","Charile");
//모든 이름이 3글자 이상인가?
boolean allLong = stringList.stream().allMatch(names -> names.length()>=3);
System.out.println("모든 이름이 3글자 이상인가? : " + allLong);

//B로 시작하는 이름이 있는가?
boolean startWithB = stringList.stream().anyMatch(names -> names.startsWith("B"));
System.out.println("B로 시작하는 이름이 있는가? : " + startWithB);

//8글자가 넘는 이름이 전혀 없는가?
boolean noneLong = stringList.stream().noneMatch(names -> names.length() >= 8);
System.out.println("8글자가 넘는 이름이 전혀 없는가? : " + noneLong);

모든 이름이 3글자 이상인가? : true
B로 시작하는 이름이 있는가? : true
8글자가 넘는 이름이 전혀 없는가? : true

3) 리듀싱

reduce()를 호출하여 스트림 요소를 줄여나가면서 연산을 할 수 있다.

두 가지 방식으로 사용할 수 있는데 초기값을 제공하냐 안하냐로 구분된다.

  • 초기값 제공, 누적기 제공
    T reduce(T identity, BinaryOperator<T> accumulator);

초기값의 타입을 반환값으로 리턴하며, 스트림의 첫 번째 요소부터 누적기를 적용하며 연산을 진행한다.
만약, 스트림이 비어있을 경우 초기값을 반환한다.

List<Integer> numbers = Arrays.asList(1,2,3,4,5);
int sum = numbers.stream().reduce(0, (a,b) -> a + b);
System.out.println("sum = " + sum); //15

초기값인 0이 처음에는 a가 되어 b에는 스트림의 첫 번째 요소인 1이 들어와 연산을 시작한다.
결과값인 1은 다시 a가 되고 스트림의 두 번째 요소인 2b로 들어와 연산을 이어가는 것이다.

원리는 굉장히 간단하다. 간단한 사칙 연산 외에도 다른 방식으로 활용이 가능하다.

  • 초기값 제공 X, 누적기 제공

초기값 없이 누적기만 제공하는 경우 스트림의 첫 번째 요소가 초기값이 된다.
반환 결과에서 차이점을 보이는데, 초기값을 제공한 경우 초기값을 포함한 결합 결과를 반환하지만 초기값을 제공하지 않으면 반환값이 Optional<T>로 처리된다.

또한, 스트림이 비어있을 경우 반환값이 Optional.empty()가 된다.

리스트에 저장된 문자열 중 가장 길이가 긴 문자열의 길이를 알아보는 예제를 만들어보겠다.

List<String> nameList = Arrays.asList("Java","Python","Kotlin","Swift","C","JavaScript");

//int i = nameList.stream().mapToInt(String::length).reduce((a,b)->Integer.max(a,b)); //컴파일 에러

OptionalInt optionalInt = nameList.stream().mapToInt(String::length).reduce((a,b)->Integer.max(a,b));

System.out.println("가장 긴 이름의 글자 수 = " + optionalInt.getAsInt()); //10

Integer 클래스의 클래스 메서드인 max()를 사용하여 최대값을 반환하였다. 람다식 대신에 메서드 참조를 사용해도 무방하다. 위와 같은 경우는 다음과 같이 표현이 가능하다.

OptionalInt optionalInt = nameList.stream().mapToInt(String::length).reduce(Integer::max);

예제에서 결과값을 OptionalInt에 대입한 이유도 위에서 설명했듯이 반환값이 Optional<T>로 처리되기 때문이다. 만약, 타입은 int로 한다면 컴파일 에러가 발생한다.

5. collect()

스트림 최종연산으로 사용 가능한 메서드로, 데이터를 원하는 형태로 수집할 수 있어 매우 중요한 메서드이다.

매개변수 타입이 Collector인데 Collector 인터페이스의 구현체를 매개변수로 입력해야 한다.

대부분 개발자가 사용하는 방법은 Collector 구현체를 생성하는 Collectors 클래스의 팩토리 메서드를 매개변수로 사용한다.
(Collectors는 유틸리티 클래스이다)

collect()의 주요 특징은 아래와 같은데

  • 기본 수집
  • 통계 수집
  • 그룹화 및 분할
  • 문자열 연결

Collectors 유틸리티 클래스의 팩토리 메서드를 사용하면 거의 모든 데이터 처리 작업이 가능하다.

1) 기본 수집 : 컬렉션 및 배열로 변환

스트림에서 컬렉션으로 변환하려면 collect()를 사용해야 하는데, 어떤 컬렉션으로 변환하느냐에 따라 차이가 있다.

기본적으로 toList(),toMap(),toSet()을 사용하면 List,Map,Set 타입으로 변환할 수 있는데, 이 타입들은 인터페이스이기 때문에 구현체는 각각 ArrayList, HashMap, HashSet가 된다.

예시를 통해 스트림에서 컬렉션으로 변환하는 방법을 알아보겠다.

Stream<Member> memberStream = Stream.of(new Member("Kim",36),new Member("Lee",30),new Member("Park",23));
//List<Member> memberList = memberStream.collect(Collectors.toList());
//Map<String,Integer> memberMap = memberStream.collect(Collectors.toMap(member -> member.getName(),member -> member.getAge()));
//Set<Member> memberSet = memberStream.collect(Collectors.toSet());

toMap()의 경우 매개변수로 key,value를 반환할 수 있는 람다식을 입력해야 한다. 예시에서는 각각 member의 이름과 나이를 반환하여 key,value로 사용한 것이다.

자, 그렇다면 LinkedListTreeMap처럼 다른 컬렉션으로 변환하려면 어떻게 해야 할까?
toCollection()을 호출하면 되는데 변환하고자 하는 컬렉션을 매개변수에 생성자 참조로 입력하면 된다.

예를 들어, TreeSet으로 변환하려면

Set<Member> memberSet = memberStream.collect(Collectors.toCollection(TreeSet::new));

TreeSet을 구현체로 생성자 참조하면 되는 것이다.

배열로 변환하는 것도 이와 비슷한데 toArray()를 호출하고 매개변수를 지정하지 않으면 기본적으로 Object[]로 변환된다.
다른 T[] 배열로 변환하려면 toArray()의 매개변수로 변환하려는 T타입의 생성자 참조를 지정하면 된다.
String타입의 배열로 변환하려면 매개변수를 String::new로 지정하면 되는 것이다.

2) 통계 수집

스트림 요소들의 개수, 합, 평균 또는 통계 데이터를 수집할 수 있다.
통계 데이터를 수집할 경우 해당 객체에서 메서드를 통해 데이터들의 합, 평균, 최대값, 최소값 등을 반환할 수 있다.

  • counting() : 스트림 요소의 개수 반환
Stream<Member> memberStream = Stream.of(new Member("Kim",36),new Member("Lee",30),new Member("Park",23));
long count = memberStream.collect(Collectors.counting());
System.out.println(count); //3

반환 타입이 long이기 때문에 변수 타입도 동일하게 선언해야 한다.


  • summingInt(),summingDouble(),summingLong() : 스트림 요소의 합 반환
Stream<Member> memberStream = Stream.of(new Member("Kim",36),new Member("Lee",30),new Member("Park",23));
int sum = memberStream.collect(Collectors.summingInt(Member::getAge));
System.out.println(sum); //89

반환타입에 맞는 메서드를 호출하면 된다. 예시에서는 반환타입을 int로 선언하였다.
매개변수로 ToIntFunction,ToDoubleFunction,ToLongFunction이 지정되어 있는데 각 요소에 적용할 함수를 입력해야 한다.
예시에서는 각 요소에 대해 Member객체의 age를 반환하도록 함수를 입력하였다.


  • averagingInt(),averagingLong(),averagingDouble() : 스트림 요소들의 평균값 반환

반환타입은 모두 double이다. 하지만, 내부에서 처리되는 방식에서 차이가 있다.

메서드입력 스트림 요소 타입내부 연산 방식반환 타입
averagingIntint정수 덧셈 -> 평균계산double
averagingLonglong정수 덧셈 -> 평균계산double
averagingDoubledouble실수 덧셈 -> 평균계산double

averagingIntaveragingLong는 둘 다 정수 덧셈이지만 입력 타입이 다르기 때문에 입력 요소의 범위를 고려하여 사용하면 된다.
예를 들어, int 범위는 (-2,147,483,648 ~ 2,147,483,647)이므로 이를 넘어서는 데이터를 입력할 경우 데이터 손실이 발생할 수 있어 이러한 경우에는 long을 사용해야 한다.

Stream<Member> memberStream = Stream.of(new Member("Kim",36),new Member("Lee",30),new Member("Park",23));
double average = memberStream.collect(Collectors.averagingInt(Member::getAge));
System.out.println(average); //29.666666666666668

위와 마찬가지로 입력매개변수로 각 스트림에 적용할 함수를 지정하면 된다.


  • summarizingInt,summarizingLong,summarizingDouble : 통계 요약 객체 반환

각각 통계 요약 객체인 IntSummaryStatistics,LongSummaryStatistics,DoubleSummaryStatistics를 반환한다.
공통적으로 사용할 수 있는 메서드는 동일하며 통계 요약 객체로부터 스트림 요소의 개수,합,평균,최대,최소 등을 얻을 수 있다.

Stream<Member> memberStream = Stream.of(new Member("Kim",36),new Member("Lee",30),new Member("Park",23));
//스트림 요소들에 대한 통계 데이터들의 집합인 통계 요약 객체 생성
IntSummaryStatistics summaryStatistics = memberStream.collect(Collectors.summarizingInt(Member::getAge));
System.out.println("Member Count : " + summaryStatistics.getCount());
System.out.println("Member age average : " + summaryStatistics.getAverage());
System.out.println("Member age sum : " + summaryStatistics.getSum());
System.out.println("Member age Max : " + summaryStatistics.getMax());
System.out.println("Member age Min : " + summaryStatistics.getMin());

Member Count : 3
Member age average : 29.666666666666668
Member age sum : 89
Member age Max : 36
Member age Min : 23

통계 요약 객체를 얻는 메서드는 입력 데이터 타입에 따라 적절한 메서드를 호출하면 된다.
예를 들어, summarizingLonglong타입의 데이터에 최적화 되어 있기 때문에 다른 타입의 입력 데이터를 사용하면 컴파일 에러가 발생한다.

3) 리듀싱

앞에서 설명했던 리듀싱과 비슷하지만 내부적인 차이가 있다.
간단한 연산의 경우 스트림의 reduce()를 사용하는 편이 효율적이지만, 병렬 처리나 한 번에 변환과 리듀싱을 같이 수행할 경우 collect()를 사용한 리듀싱이 더 좋다.

기본적으로 리듀싱 방법은 비슷하며 Collectors.reducing()을 호출하여 사용한다.
초기값이 주어지거나 주어지지 않는 2가지 경우는 위의 설명과 동일하며 collect()를 사용할 경우 변환과 리듀싱을 동시에 할 수 있다는 장점이 있다.

        List<String> words = List.of("hello", "world", "java");
        // 문자열 길이 합계 계산
		
		//map + reduce
        //int totalLength = words.stream().map(String::length).reduce(0,(a,b)->a+b);

		//collect() 리듀싱
        int totalLength = words.stream().collect(Collectors.reducing(0, String::length, Integer::sum));
        System.out.println(totalLength); //14

스트림만 사용할 경우 문자열 길이로 변환을 한 후 리듀싱을 해야하지만 collect()를 사용하면 매개변수로 변환과 리듀싱을 어떻게 할 지 지정이 가능하다.

Collector reducing(BinaryOperator<T> op) //초기값x 누적기
Collector reducing(T identity, BinaryOperator<T> op) //초기값 + 누적기
Collector reducing(U identity, Function<T,U> mapper, BinaryOperator<U> op) // 초기값 + 변환기 + 누적기

이 3번째 메서드가 바로 변환과 리듀싱을 동시에 할 수 있는 메서드이고 오버로딩을 통해 서로 다르게 사용이 가능하다.

4) 문자열 결합

Collectors.joining()을 오버로딩하여 다양하게 문자열 결합이 가능하다.

  • joining() : 구분자 없이 요소를 그대로 이어 붙인다
  • joining("구분자") : 각 요소를 구분자로 연결한다
  • joining("구분자","접두사","접미사") : 접두사와 접미사를 추가하고 각 요소를 구분자로 연결한다

예제를 통해 사용법을 알아보자.

        List<String> words = List.of("hello", "world", "java");
        String result1 = words.stream().collect(Collectors.joining());
        String result2 = words.stream().collect(Collectors.joining("/ "));
        String result3 = words.stream().collect(Collectors.joining("/ ","(",")"));
        System.out.println("result1 = " + result1); //result1 = helloworldjava
        System.out.println("result2 = " + result2); //result2 = hello/ world/ java
        System.out.println("result3 = " + result3); //result3 = (hello/ world/ java)

5) 분할과 그룹화

collect()의 가장 큰 장점은 바로 데이터 분류에 있어 굉장히 유용한 점이다.
데이터를 분류하는 방법은 분할을 하거나 그룹화를 하는 것인데, 둘의 차이는 분류 기준이 어떻게 되는가이다.

분할은 truefalse만 기준이 되어 두 그룹으로 분류되는 것으로, Collectors.partitionBy()를 호출하여 사용할 수 있다.
그룹화는 기준이 될 수 있는 것들이 여러가지이며 이에 따라 여러 그룹으로 분류가 가능하며,Collectors.groupingBy()를 호출하여 사용한다.
두 메서드에 지정되는 매개변수의 타입을 보면 왜 이렇게 분류가 되는지 알 수 있다.

먼저, partitionBy()에 들어가는 매개변수 타입은 Predicate로 반환 타입이 boolean타입이다. 이와 다르게 groupingBy()Function이 매개변수 타입으로 반환 타입이 기준이 된다.

두 메서드 모두 스트림의 최종연산으로, 반환값은 Map<T,R>이 되고 key값이 기준, value가 기준에 따른 분류 집단이 되는 것이다. 그래서 분류 이후에 원하는 집단을 가져오려면 get(key)을 호출하면 된다.


  • partitionBy()에 의한 분할

우선, 앞에서 언급했던 Student 클래스를 다음과 같이 변경해보자.

class Student{
    String name;
    boolean isMale;
    int clasnNum;
    int score;
}

Constructor,Getter 등은 실제로 사용하지만 여기서는 분량 문제로 생략하였다.
필드값인 isMale을 통해 학생들은 성별로 분류하고자 한다.

데이터가 필요하니 Student 스트림을 생성해보겠다.

//학생 스트림 생성
List<Student> studentList = Arrays.asList(
                new Student("Kim",true,1,200),
                new Student("Lee",true,1,300),
                new Student("Park", false,2,300),
                new Student("Kang",true,2,400),
                new Student("Choi",false,2,250),
                new Student("Jeong",false,2,500),
                new Student("Han",true,2,390));
Stream<Student> studentStream = studentList.stream();

이제 성별에 따라 분할에 보겠다.

//성별에 따른 분할
Map<Boolean,List<Student>> splitByGender = studentStream.collect(Collectors.partitioningBy(Student::isMale));
System.out.println("남학생 집단 : " + splitByGender.get(true));
System.out.println("여학생 집단 : " + splitByGender.get(false));

남학생 집단 : [Student{name = 'Kim', isMale = true, clasnNum = 1, score = 200}, Student{name = 'Lee', isMale = true, clasnNum = 1, score = 300}, Student{name = 'Kang', isMale = true, clasnNum = 2, score = 400}, Student{name = 'Han', isMale = true, clasnNum = 2, score = 390}]
여학생 집단 : [Student{name = 'Park', isMale = false, clasnNum = 2, score = 300}, Student{name = 'Choi', isMale = false, clasnNum = 2, score = 250}, Student{name = 'Jeong', isMale = false, clasnNum = 2, score = 500}]

key값인 성별에 따라 분할이 되고 value값으로 List<Studet>가 저장되었다.

매개변수를 더 지정한다면 분할 후 필요한 데이터 형태로 변환이 가능하다.
예를 들어, 남학생과 여학생으로 분할 후 각각 몇명인지 분류하고자 한다면 다음과 같이 partitionBy()에 매개변수를 추가하면 된다.

Map<Boolean,Long> splitByGender = studentStream.collect(Collectors.partitioningBy(Student::isMale,Collectors.counting()));
System.out.println("남학생 집단 : " + splitByGender.get(true) + "명"); //남학생 집단 : 4명
System.out.println("여학생 집단 : " + splitByGender.get(false) + "명"); //여학생 집단 : 3명

그림과 같이 이해하면 쉽다.

주의해야 할 점은 분할 후 저장된 데이터 타입이 List<Student>가 아닌 long이기 때문에 splitByGender의 제네릭 타입도 이게 맞게 선언해야한다.

마지막으로 더 응용해보자면, 분할 후 다시 한 번 분할이 가능하다. 예를 들어, 성별로 학생들을 분할 후 점수가 300점이 넘냐 아니냐로 다시 분할이 가능하다.

Map<Boolean,Map<Boolean,List<Student>>> splitByGender = studentStream.collect(Collectors.partitioningBy(Student::isMale,partitioningBy(s->s.getScore() > 300)));
System.out.println("300점 초과인 남학생 집단 : " + splitByGender.get(true).get(true));
System.out.println("300점 초과인 여학생 집단 : " + splitByGender.get(false).get(true));
System.out.println("300점 미만인 남학생 집단 : " + splitByGender.get(false).get(false));
System.out.println("300점 미만인 여학생 집단 : " + splitByGender.get(false).get(false));

300점 초과인 남학생 집단 : [Student{name = 'Kang', isMale = true, clasnNum = 2, score = 400}, Student{name = 'Han', isMale = true, clasnNum = 2, score = 390}]
300점 초과인 여학생 집단 : [Student{name = 'Jeong', isMale = false, clasnNum = 2, score = 500}]
300점 미만인 남학생 집단 : [Student{name = 'Park', isMale = false, clasnNum = 2, score = 300}, Student{name = 'Choi', isMale = false, clasnNum = 2, score = 250}]
300점 미만인 여학생 집단 : [Student{name = 'Park', isMale = false, clasnNum = 2, score = 300}, Student{name = 'Choi', isMale = false, clasnNum = 2, score = 250}]

partitionBy() 매개변수에 partitionBy()가 다시 한 번 들어가 분할을 한 것이다.
분할에 분할이기 때문에 splitByGender의 선언 타입도 Map<Boolean,Map<Boolean,List<Student>>>로 선언해야 한다.

partitionBy()메서드를 자바 API 문서에서 찾아보면 다음과 같이 정의되어 있다.

  • Collector partitionBy(Predicate predicate)
  • Collector partitionBy(Predicate predicate, Collector downstream)

한 가지 기준으로 분할을 하거나 또는 분할 후 2번째 매개변수를 활용하여 분할한 데이터를 변환할 수 있는 것이다. 2번째 매개변수 타입이 Collector이기 때문에 Collectors.partitionBy()가 지정될 수 있는 것이고, counting()이나 분할 후 분할 등이 가능한 것이다.


  • groupingBy()에 의한 그룹화

그룹화는 분할과 다르게 기준을 더 많이 지정할 수 있어 여러 그룹을 만들 수 있다.
기본적으로 그룹화 후에 List<T>에 데이터를 담으며, 필요하다면 매개변수에 Collectors.toCollection()을 활용하여 다른 데이터 형태로 변환할 수 있다.

분할에서는 성별을 기준으로 집단을 분류했었는데, 이번에는 점수에 따라 분류를 해보겠다.
기준은 점수가 400점 이상은 A, 300점 이상이고 400점 미만은 B, 300점 미만은 C로 그룹화를 해보자.

//점수별 그룹화
Stream<Student> studentStream = studentList.stream();
Map<String,List<Student>> groupByScore = studentStream.collect(Collectors.groupingBy(s->{
            if(s.getScore() >= 400)
                return "A";
            else if(s.getScore() >= 300)
                return "B";
            else return "C";
        }));
System.out.println(groupByScore.get("A"));
System.out.println(groupByScore.get("B"));
System.out.println(groupByScore.get("C"));

매개변수로 Function이 들어가기 때문에 반환값이 기준이 되며, 여기서는 반환 타입이 String이기 때문에 Map의 제네릭 타입도 그에 맞게 지정하였다.
이분적인 기준을 가진 분할과 다르게 그룹화는 A,B,C 처럼 다양한 기준을 key로 사용하고 그에 맞는 value값으로 그룹을 저장할 수 있다.

분할과 마찬가지로 그룹화도 다수준 그룹화가 가능하다.
예를 들어, 학급별로 그룹화 후 점수별로 그룹화하는 것이 가능하다.

Stream<Student> studentStream = studentList.stream();
Map<Integer, Map<String,List<Student>>> groupByScore = studentStream.collect(Collectors.groupingBy(Student::getClasnNum,groupingBy(s->{						if(s.getScore() >= 400)
                return "A";
            else if(s.getScore() >= 300)
                return "B";
            else return "C";
        })));
System.out.println("1반 중 400점 이상 : " + groupByScore.get(1).get("A"));
System.out.println("1반 중 300점 이상 : " + groupByScore.get(1).get("B"));
System.out.println("1반 중 300점 미만 : " + groupByScore.get(1).get("C"));
System.out.println("2반 중 400점 이상 : " + groupByScore.get(2).get("A"));
System.out.println("2반 중 300점 이상 : " + groupByScore.get(2).get("B"));
System.out.println("2반 중 300점 미만 : " + groupByScore.get(2).get("C"));

1반 중 400점 이상 : null
1반 중 300점 이상 : [Student{name = 'Lee', isMale = true, clasnNum = 1, score = 300}]
1반 중 300점 미만 : [Student{name = 'Kim', isMale = true, clasnNum = 1, score = 200}]
2반 중 400점 이상 : [Student{name = 'Kang', isMale = true, clasnNum = 2, score = 400}, Student{name = 'Jeong', isMale = false, clasnNum = 2, score = 500}]
2반 중 300점 이상 : [Student{name = 'Park', isMale = false, clasnNum = 2, score = 300}, Student{name = 'Han', isMale = true, clasnNum = 2, score = 390}]
2반 중 300점 미만 : [Student{name = 'Choi', isMale = false, clasnNum = 2, score = 250}]

정리하자면 결국 분할이나 그룹화나 기준에 따라 분류한다는 점은 동일하고, 그 기준이 true/false로만 할 경우에는 분할을, 그 외에 다양한 기준으로 분류할 경우에는 그룹화를 사용하는 것이다.
그래서, 어렵게 생각할 필요는 없고 기준에 맞게 제네릭 타입도 적절하게 바꾸어주면 되는 것이다.

0개의 댓글