영권's

Java - Stream 본문

자바/JAVA

Java - Stream

ykkkk 2021. 9. 19. 05:57

Java Stream

Stream은 어떤 연속된 데이터를 처리하는데 필요한 오퍼레이션의 모음이라고 생각하면 좋다.

그 자체가 데이터가 아니고 컬렉션 안에 있는 데이터들을 소스로 사용해서 어떤 처리를 하는것이다.

예를 들어 어떤 데이터가 있다고 가정하고 어떤 컨베이어 벨트에 흘려 보내는데 데이터에 각 작업을 한다.

1. 색을 칠하고

2 반으로 나누고

3. 컨테이너에 담는 과정을 하는 작업.

 

여기서 처리하는 것 자체가 Stream이라고 생각하면 좋다.

 

  1. 중개 오퍼레이션
    • Stream을 리턴한다.
    • Stateless / Stateful 오퍼레이션으로 더 상세하게 구분할 수 있다.(대부분은 Stateless지만 distinct나 sorted 처럼 이전 이전 소스 데이터를 참조해야 하는 오퍼레이션은 Stateful 오퍼레이션이다.)
    • filter, map, limit, skip, sorted, ...
  2. 종료 오퍼레이션
    • Stream을 리턴하지 않는다. 
    • collect, allMatch, count, forEach, min, max, ...

Stream의 특징

  • 데이터를 담는 저장소(컬렉션)가 아니다.
  • Functional in nature, 스트림이 처리하는 데이터 소스를 변경하지 않는다.

예시)

package stream;

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

public class App {
    public static void main(String[] args) {
        List<String> names = new ArrayList<>();
        names.add("choi");
        names.add("young");
        names.add("kwon");

        // names의 stream을 가져와서 map 안에서 문자열을 대문자로 변환.
        Stream<String> stringStream = names.stream().map(String::toUpperCase);

        // 스트림을 통해 받아온 결과 값은 대문자로 변환
        stringStream.forEach(System.out::println);

        // names안에 있는 값은 변경되지 않는다.
        names.forEach(System.out::println);

    }
}

코드 결과

  •  스트림으로 처리하는 데이터는 오직 한번만 처리한다.
    • 컨베이어 벨트에 지나가듯이 그냥 데이터가 지나가고 스트림 작업이 진행된다.
    • 스트림은 한번 사용하면 닫혀서 다시 사용할 수 없다. 필요하다면 스트림을 다시 생성해야한다.
  • 무제한일 수 있다.
    • 스트림 자체가 무제한으로 실시간으로 넘어온 데이터를 스트림으로 받아서 처리할 수 있다.
    • short Circuit 메서드를 사용해서 제한할 수 있다.
      • 단축시키는 메서드로 예를 들어 첫번째 10개의 데이터만 수행하겠다 와 같이 무제한이지만 제한할 수 있다.
  • 중개 오퍼레이션은 근본적으로 lazy하다.
    • 스트림에서 사용하는 메서드들은 크게 중개 오퍼레이션, 종료 오퍼레이션으로 나눌 수 있다.
    • 두가지의 가장 큰 차이점은 Stream을 리턴하는지 유무이다.

중개 오퍼레이션은 lazy 하다의 의미 :

1. 중개 오퍼레이션은 종료 오퍼레이션을 만나기 전까지 실행되지 않는다.

2. 다 작업을 처리 하기 전에 중간에 끝낼 수 있다.

public class App {
    public static void main(String[] args) {
        List<String> names = new ArrayList<>();
        names.add("choi");
        names.add("young");
        names.add("kwon");

        // names의 stream을 가져와서 map 안에서 문자열을 대문자로 변환.
        // 중개 오퍼레이션은 lazy 하다 : 이 경우 출력이 되지 않는다.
        Stream<String> stringStream = names.stream().map(s -> {
            System.out.println(s);
            return s.toUpperCase();
        });


        System.out.println("===========");

        // names안에 있는 값은 변경되지 않는다.
        names.forEach(System.out::println);

    }
}

코드 결과

  • 스트림의 파이프 라인은 중개 오퍼레이션이 0 또는 여러개가 올 수 있고 종료 오퍼레이션은 반드시 하나가 와야하는데
  • 종료 오퍼레이션이 실행하기 전까지는 중개 오퍼레이션은 실행하지 않기 때문에 사실 무의미하다.

스트림 파이프 라인의 구조

package stream;

import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
import java.util.stream.Collectors;
import java.util.stream.Stream;

public class App {
    public static void main(String[] args) {
        List<String> names = new ArrayList<>();
        names.add("choi");
        names.add("young");
        names.add("kwon");

        // names의 stream을 가져와서 map 안에서 문자열을 대문자로 변환.
        // 종료 오퍼레이션인 collect를 사용
        // 중개 오퍼레이션들이 실행됨.
        List<String> collect = names.stream().map(s -> {
            System.out.println(s);
            return s.toUpperCase();
        }).collect(Collectors.toList());
        collect.forEach(System.out::println);

        System.out.println("===========");

        // names안에 있는 값은 변경되지 않는다.
        names.forEach(System.out::println);

    }
}

코드 결과

 

  • 손쉽게 병렬처리 할 수 있다.
    • 스트림을 쓰지 않고 반복처리를 할때 가장 큰 차이
      • 스트림을 사용하지 않고 반복 처리를 하면 병렬 처리를 하기 힘든데 스트림의 경우 parallelStream을 사용하면 내부적으로 쉽게 병렬처리가 된다.

parallelStream() 사용

package stream;

import java.util.ArrayList;
import java.util.List;
import java.util.stream.Collectors;

public class App {
    public static void main(String[] args) {
        List<String> names = new ArrayList<>();
        names.add("choi");
        names.add("young");
        names.add("kwon");

        // names의 stream을 가져와서 map 안에서 문자열을 대문자로 변환.
        List<String> collect = names.parallelStream().map(s -> {
                    System.out.println(s + " >> " + Thread.currentThread().getName());
                    return s.toUpperCase();
                }).collect(Collectors.toList());
        collect.forEach(System.out::println);
    }
}

 

코드 결과

parallelStream 을 사용했을 때 ForkJoinPool을 사용해서 여러 쓰레드에서 작업이 처리되는 것을 볼 수 있다.

 

Stream() 사용

package stream;

import java.util.ArrayList;
import java.util.List;
import java.util.stream.Collectors;

public class App {
    public static void main(String[] args) {
        List<String> names = new ArrayList<>();
        names.add("choi");
        names.add("young");
        names.add("kwon");

        // names의 stream을 가져와서 map 안에서 문자열을 대문자로 변환.
        List<String> collect = names.stream().map(s -> {
                    System.out.println(s + " >> " + Thread.currentThread().getName());
                    return s.toUpperCase();
                }).collect(Collectors.toList());
        collect.forEach(System.out::println);
    }
}

기존 stream을 사용했을 때는 main에서 다 처리

 

주의사항으로는 parallelStream을 사용한다고 해서 모두 다 빨라지는 것이 아니다. 오히려 더 느려질 수 있다.

 

테스트

100만개의 리스트 데이터를 stream과 parallelStream에서 간단한 처리를 했을 때

package stream;

import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.List;
import java.util.stream.Collectors;

public class App {
    public static void main(String[] args) {
        List<String> names = new ArrayList<>();
        /*names.add("choi");
        names.add("young");
        names.add("kwon");*/

        for (int i = 0; i < 1000000; i++) {
            names.add(i + " test");
        }

        // names의 stream을 가져와서 map 안에서 문자열을 대문자로 변환.

        long start = System.currentTimeMillis();
        List<String> collect = names.stream().map(s -> {
            //System.out.println(s + " >> " + Thread.currentThread().getName());
            return s.toUpperCase();
        }).collect(Collectors.toList());
        long end = System.currentTimeMillis();

        long parallelStart = System.currentTimeMillis();
        List<String> parallelStreamCollect = names.parallelStream().map(s -> {
            //System.out.println(s + " >> " + Thread.currentThread().getName());
            return s.toUpperCase();
        }).collect(Collectors.toList());
        long parallelEnd = System.currentTimeMillis();

        System.out.println("default Stream ===>> " + (end - start));
        System.out.println("parallel Stream ===>> " + (parallelEnd - parallelStart));
    }
}

100만개 데이터 일반 Stream 결과
100만개 데이터 parallel Stream 사용 결과

parallelStream()을 사용 했을 때 오히려 느린것을 알 수 있다.

이런 결과가 나오는 이유를 생각해보자면 쓰레드를 만들어서 처리하는데 필요한 비용이 분명히 든다.

예를 들어, 쓰레드를 만들고 쓰레드 스위칭 비용, 작업들을 모아서 합치는 비용 등등이 필요할 수 있는데 이런 비용 때문에 하나의 쓰레드에서 처리하는 것보다 여러 쓰레드에서 나눠서 처리하는 것이 더 느릴 수 있다.

 

 

같은 코드에서 list 내의 데이터를 늘려서 1000만개의 데이터를 추가해봤다.

Stream() 결과
parallel Stream 사용 결과

1000만개의 데이터를 추가 후 간단한 작업을 처리해봤을 때는 parallelStream()을 사용했을 때가 좀 더 빠른것을 알 수 있다.

정말 데이터가 클 경우 유용하게 사용할 수 도 있을 것 같다.

 

주의할 점은 꼭 100만개의 데이터를 처리하는데 parallelStream이 느리고 그냥 Stream이 빠르다는 것은 아니고 어떤 작업을 하느냐에 따라서 결과값은 달라질 수 있지만 "꼭 parallelStream을 사용한다고 해서 무조건 빨라지는 것은 아니다" 를 명심하자.

 

스트림 API

 

스트림 만들기

스트림으로 작업 하려면, 스트림이 필요하기 때문에 생성하는 방법부터 알아야하는데 스트림의 소스가 될 수 있는 대상은 배열, 컬렉션, 임의의 수 등 다양하다.

: 스트림을 반환값으로 생성

 

컬렉션에서 스트림 생성

컬렉션의 최고 조상인 Collection에 stream()이 정의되어 있다.

그래서 Collection의 자손인 List와 Set을 구현한 컬렉션들은 모두 이 메서드로 스트림을 생성할 수 있다.

stream()은 해당 컬렉션을 소스로 하는 스트림을 반환한다.

Stream<T> Collection.stream()

 

배열

배열을 소스로 하는 스트림을 생성하는 메서드는 Stream과 Arrays에 static 메서드로 정의되어있다.

Stream<T> Stream.of(T... values) // 가변인자
Stream<T> Stream.of(T[])
Stream<T> Arrays.stream(T[])
Stream<T> Arrays.stream(T[] array, int startInclusive,int endExclusive)

기본형 배열을 소스로 하는 스트림 생성 메서드
IntStream IntStream.of(int... values) //Stream이 아니라 IntStream
IntStream IntStream.of(int[])
IntStream Arrays.stream(int[])
IntStream Arrays.stream(int[] array, int stratInclusive, int endExclusive)

 

파일

java.nio.file.Files는 파일을 다루는데 필요한 유용한 메서드들을 제공하는데, list()는 지정된 디렉토리에 있는 파일의 목록을 소스로 하는 스트림을 생성해서 반환한다.

Stream<Path> Files.list(Path dir)
Stream<String> Files.list(Path path)
Stream<String> Files.list(Path path, Charset cs)
Stream<String> lines() // BufferedReader 클래스의 메서드

 

두 스트림의 연결

Stream의 static 메서드인 concat()을 사용하면, 두 스트림을 하나로 연결할 수 있다. 당연히 연결하려는 두 스트림의 요소는 같은 타입이어야 한다.

String[] str1 = {"123","456","789"};
String[] str2 = {"ABC","abc","DEF"};

Stream<String> strs1 = Stream.of(str1);
Stream<String> strs2 = Stream.of(str2);
Stream<String> strs3 = Stream.concat(strs1,strs2); // 두 스트림을 하나로 연결

 

스트림의 중간연산

스트림의 요소 걸러내기 - filter(), distinct()

distinct()는 스트림에서 중복된 요소들을 제거하고, filter()는 주어진 조건(Predicate)에 맞지 않는 요소를 걸러낸다.

Stream<T> filter(Predicate<? super T> predicate)
Stream<T> distinct()

distinct() 사용 예시

IntStream intStream = IntStream.of(1,2,2,3,3,3,4,5,5,6);
intStream.distinct().forEach(System.out::print); // 123456

 

filter()는 매개변수로 Predicate를 필요로 하는데, 아래와 같이 연산결과가 boolean인 람다식을 사용해도 된다.

그리고 필요하다면 filter()를 다른 조건으로 여러 번 사용할 수 있다.

IntStream intStream = IntStream.rangeClosed(1,10); // 1~10
intStream.filter(i -> i%2 == 0).forEach(System.out::print); // 246810

 

정렬 - sorted()

스트림을 정렬할 때는 sorted()를 사용하면 된다.

sorted()는 지정된 Compator로 스트림을 정렬하는데, Comparator 대신 int 값을 반환하는 람다식을 사용하는 것도 가능하다.

Comparator를 지정하지 않으면 스트림 요소의 기본 정렬 기준(Comparable)으로 정렬한다. 단, 스트림의 요소가 Comparable을 구현한 클래스가 아니면 예외가 발생한다.

문자열 스트림 정렬 방법 출력 결과
strStream.sorted() // 기본 정렬
strStream.sorted((s1,s2) -> s1.compareTo(s2)) // 람다식 사용 가능 
strStream.sorted(String::compareTo) // 위의 문장과 동일
CCaaabccdd
strStream.sorted(Comparator.reverseOrder()) // 기본 정렬의 역순
strStream.sorted(Comparator.<String>naturalOrder().reversed()) // 기본 정렬의 역순
ddccbaaaCC
strStream.sorted(String.CASE_INSENSITIVE_ORDER) // 대소문자 구분 안함 aaabCCccdd
strStream.sorted(String.CASE_INSENSITIVE_ORDER.reversed())  ddCCccbaaa
strStream.sorted(Comparator.comparing(String::length))  // 길이 순 정렬
strStream.sorted(Comparator.comparingInt(String::length))  // no 오토박싱
bddCCccaaa
strStream.sorted(Comparator.comparing(String::length).revered())
aaaddCCccb

 

변환 - map()

스트림의 요소에 저장된 값 중에서 원하는 필드만 뽑아내거나 특정 형태로 변환해야 할 때가 있다.

이 때 사용하는 것이 바로 map()이다.

매개변수로 T타입을 R타입으로 변환해서 반환하는 함수를 지정해야 한다.

Stream<R> map(Function<? super T, ? extends R> mapper

예를 들어 File의 스트림에서 파일의 이름만 뽑아서 출력하고 싶을 때, 아래와 같이 map()을 이용하면 File 객체에서 파일의 이름(String)만 간단히 뽑을 수 있다.

Stream<File> fileStream = Stream.of(new File("Ex1.java"),new File("Ex1"),new File("Ex1.bak"));

// map()으로 Stream<File>을 Stream<String>으로 변환
Stream<String> filenameStream = fileStream.map(File::getName);
filenameStream.forEach(System.out::println); // 스트림의 모든 파일이름을 출력

map() 역시 중간 연산이므로, 연산결과는 String을 요소로 하는 스트림이다.

map(0으로 Stream<File>을 Stream<String>으로 변환했다고 볼 수 있다.

그리고 map()도 filter()처럼 하나의 스트림에 여러번 적용 할 수 있다.

 

 

mapToInt(), mapToLong(), mapToDouble()

map()은 연산의 결과로 Stream<T> 타입의 스트림을 반환하는데, 스트림의 요소를 숫자로 변환하는 경우 IntStream과 같은 기본형 스트림으로 변환하는 것이 더 유용할 수 있다.

Stream<T> 타입의 스트림을 기본형 스트림으로 변환할 때 사용하는 것이다.

DoubleStream mapToDouble(ToDoubleFunction<? super T> mapper)
IntStream mapToInt(ToIntFunction<? super T> mapper)
LongStream mapToLong(ToLongFunction<? super T> mapper)

예를 들어 Stream<Integer>를 사용하는 경우 mapToInt를 사용해서 IntStream타입의 스트림을 생성해서 사용하는 것을 고려할 수 있다.

 

count()만 지원하는 Stream<T>와 달리 IntStream과 같은 기본형 스트림은 아래와 같이 숫자를 다루는 편리한 메서드를 지원한다.

// max()와 min()은 Stream에도 정의되어 있지만, 매개변수로 Comparator를 지정해야 한다는 차이가 있다.
int sum() // 스트림의 모든 요소의 총합
OptionalDouble average() // sum / (double) count()
OptionalInt	 max() // 스트림의 요소 중 제일 큰 값
OptionalInt	 min() // 스트림의 요소 중 제일 작은 값

 

반대로 IntStream을 Stream<T>로 변환할 때는 mapToObj(0를, Stream<Integer>로 변환할 떄는 boxed()를 사용한다.

 

 

스트림의 최종연산

최종 연산은 스트림의 요소를 소모해서 결과를 만들어낸다. 그래서 최종 연산후에는 스트림이 닫히게 되고 더 이상 사용할 수 없다.

최종 연산의 결과는 스트림 요소의 합과 같은 단일 값이거나, 스트림의 요소가 담긴 배열 또는 컬렉션일 수 있다.

 

forEach()

 

forEach()는 peek()과 달리 스트림의 요소를 소모하는 최종 연산이다. 반환 타입이 void이므로 스트림의 요소를 출력하는 용도로 많이 사용한다.

void forEach(Consumer<? super T> action)

 

조건 검사 - allMatch(), anyMatch(), noneMatch(), findFirst(), findAny()

스트림의 요소에 대해 지정된 조건에 모든 요소가 일치하는 지, 일부가 일치하는지 아니면 어떤 요소도 일치하지 않는지 확인하는데 사용할 수 있는 메서드들이다.

이 메서드들은 모두 매개변수로 Predicate를 요구하며, 연산결과로 boolean을 반환한다.

boolean allMatch(Predicate<? super T> predicate)
boolean anyMatch(Predicate<? super T> predicate)
boolean noneMatch(Predicate<? super T> predicate)

 

스트림의 요소 중에서 조건에 일치하는 첫 번째 것을 반환하는 findFirst()가 있는데, 주로 filter()와 함께 사용되어 조건에 맞는 스트림의 요소가 있는지 확인하는데 사용된다. 병렬 스트림의 경우 findFirst()대신 findAny를 사용해야 한다.

Optional<T> findAny();
Optional<T> findFirst();

 

통계 - count(), sum(), average(), max(), min()

기본형 스트림이 아닌 경우에는 통계와 관련된 메서드들이 3가지 뿐이다 하지만 기본형 스트림에는 위와 같이 통계를 위한 좀 더 다양한 메서드를 사용할 수 있다.

// 참고 : 기본형 스트림의 min(), max()와 달리 매개변수로 Comparator를 필요로 한다는 차이가 있다.
long count()
Optional<T> max(Comparator<? super T> comparator)
Optional<T> min(Comparator<? super T> comparator)

대부분의 경우 위의 메서드를 사용하기보다 기본형 스트림으로 변환하거나, reduce()와 collect()를 사용해서 통계 정보를 얻는다.

 

리듀싱 - reduce()

reduce()는 이름에서 알 수 있듯이 스트림의 요소를 줄여나가면서 연산을 수행하고 최종 결과를 반환한다.

그래서 매개변수의 타입이 BinaryOperator<T>인 것이다.

처음 두 요소를 가지고 연산한 결과를 가지고 그 다음 요소와 연산한다.

이 과정에서 스트림의 요소를 하나씩 소모하게 되며, 스트링의 모든 요소를 소모하게 되면 그 결과를 반환한다.

Optional<T> reduce(BinaryOperator<T> accumulator)

 

이 외에도 연산결과의 초기값(identity)을 갖는 reduce()도 있는데, 이 메서드들은 초기값과 스트림의 첫 번째 요소로 연산을 시작한다. 스트림의 요소가 하나도 없는 경우, 초기값이 반환되므로, 반환 타입이 Optional<T>가 아니라 T 이다.

T reduce(T identity, BinaryOperator<T> accumulator);

<U> U reduce(U identity, BiFunction<U, ? super T, U> accumulator, BinaryOperator<U> combiner);

 

앞에서 소개한 최종 연산 count(), sum()등은 내부적으로 모두 reduce()를 이욯해서 아래와 같이 작성된 것이다.

int count = intStream.reduce(0, (a,b) -> a+1); // count();
int sum = intStream.reduce(0, (a,b) -> a+b); // sum();
int max = intStream.reduce(Integer.MIN_VALUE, (a,b) -> a>b ? a : b); // max();
int min = intStream.reduce(Integer.MAX_VALUE, (a,b) -> a<b ? a : b); // min();

 

max()와 min()의 경우, 초기값이 필요 없으므로 Optional<T>를 반환하는 매개변수 하나짜리 reduce()를 사용하는 것이 낫다.

OptionalInt max = intStream.reduce((a,b) -> a > b ? a : b); // max()
OptionalInt min = intStream.reduce((a,b) -> a < b ? a : b); // min()

 

Collect()

collect()는 스트림의 요소를 수집하는 최종 연산으로 리듀싱(reducing)과 유사하다.

collect()가 스트림의 요소를 수집하려면, 어떻게 수집할 것인가에 대한 방법이 정의되어 있어야 하는데, 이방법을 정의한것이 바로 컬렉터(collector)이다.

컬렉터는 Collector 인터페이스를 구현한 것으로, 직접 구현할 수도 있고 미리 작성된 것을 사용할 수도 있다.

Collectors클래스는 미리 작성된 다양한 종류의 컬렉터를 반환하는 static메서드를 가지고 있으며, 이 클래스를 통해 제공되는 컬렉터만으로도 많은 일들을 할 수 있다.

collect() // 스트림의 최종연산, 매개변수로 컬렉터를 필요로 한다.
Collector // 인터페이스 , 컬렉터는 이 인터페이스를 구현해야한다.
Collectors // 클래스, static메서드로 미리 작성된 컬렉터를 제공한다.
Object collect(Collector collector) // Collector를 구현한 클래스의 객체를 매개변수로 받는다.
Object collect(Supplier supplier, BiConsumer accumulator, BiConsumer combiner)

매개변수 3개짜리 collect()는 Collector인터페이스를 구현하지 않고 간단히 람다식으로 수집할 때 사용하면 편리하다.

 

스트림을 컬렉션과 배열로 변환 - toList(), toSet(), toMap(), toCollection(), toArray()

스트림의 모든 요소를 컬렉션에 수집하려면, Collector클래스의 toList()와 같은 메서드를 사용하면 된다.

List나 Set이 아닌 특정 컬렉션을 지정하려면, toCollection()에 해당 컬렉션의 생성자 참조를 매개변수로 넣어주면 된다.

List<String> names = 
	studentStream.map(Student::getName).collect(Collectors.toList());
    
ArrayList<String> list = 
	studentStream.map(Student::getName).collect(Collectors.toCollection(ArrayList::new));
    
Map<String,Person) map = persionStream.collect(Collectors.toMap(p->p.getId(), p->p));

Map의 경우 키와 값의 쌍으로 지정해야 하므로 객체의 어떤 필드를 키로 지정할지와 값으로 사용할지를 지정해줘야 한다.

 

스트림에 저장된 요소를 'T[]'타입의 배열로 변환하려면, toArray()를 사용하면 된다.

해당 타입의 생성자 참조를 매개변수로 지정해줘야 한다. 만약 지정하지 않으면 반환되는 배열의 타입은 Object[]이다.

Student[] names = studentStream.toArray(Student[]::new); // OK
Student[] names = studentStream.toArray(); // 에러
Object[] names = studentStream.toArray(); // OK

 

통계 - counting(), summingInt(), averagingInt(), maxBy(), minBy()

count 등의 최종연산들이 제공하는 통계 정보를 collect()로 똑같이 얻을 수 있다.

Collectors의 static 메서드를 호출할 때는 'Collectors.'를 생략하였다. static import 되어 있다고 가정.

long count = stuStream.count();
long count = stuStream.collect(counting()); // Collectors.counting()

long totalScore = stuStream.mapToInt(Student::getTotalScore).sum();
long totalScore = stuStream.collect(summingInt(Student::getTotalScore));

OptionalInt topScore = stuStream.mapToInt(Student::getTotalScore).max();
Optional<Student> topStudent = stuStream.max(Comparator.comparingInt(Student::getTotalScore));
Optional<Student> topStudent = stuStream.collect(maxBy(Comparator.comparingInt(Student::getTotalScore)));

IntSummaryStatistics stat = stuStream.mapToInt(Student::getTotalScore).summaryStatistics();
IntSummaryStatistics stat = stuStream.collect(summarizingInt(Student::getTotalScore));

 

리듀싱 - reducing()

리듀싱 역시 collect()로 가능하다. IntStream에는 매개변수 3개짜리 collect()만 정의되어 있으므로 boxed()를 통해 IntStream을 Stream<Integer>로 변환해야 매개변수 1개짜리 collect()를 사용할 수 있다.

IntStream intStream = new Random.ints(1,46).distinct().limit(6);

OptionalInt max = intStream.reduce(Integer::max);
Optional<integer> max = intStream.boxed().collect(reducing(Integer::max));

long sum = intStream.reduce(0, (a,b) -> a + b);
long sum = intStream.boxed().collect(reducing(0,(a,b) -> a + b));

int grandTotal = stuStream.map(Student::getTotalScore).reduce(0,Integer::sum);
int grandTotal = stuStream.collect(reducing(0,Student::getTotalScore,Integer::sum));

Collectors.reducing()에는 아래와 같이 3종류가 있다.

세 번째 메서드만 제외하고 reduce()와 같은데 세번째는 map()과 reduce를 하나로 합쳐놓은 것이다.

Collector reducing(BinaryOperator<T> op)
Collector reducing(T identity, BinaryOperator<T> op)
Collector reducing(U identity, Function<T,U> mapper, BinaryOperator<T> op)

 

문자열 결합 - joining()

문자열 스트림의 모든 요소를 하나의 문자열로 연결해서 반환한다. 

구분자, 접두사, 접미사도 지정가능하다.

스트림의 요소가 String이나 StringBuffer처럼 CharSequence의 자손인 경우에만 결합이 가능하므로 스트림의 요소가 문자열이 아닌 경우에는 먼저 map()을 이용해서 스트림의 요소를 문자열로 변환해야 한다.

만약 map() 없이 스트림에 바로 joining() 하면, 스트림의 요소에 toString()을 호출한 결과를 결합한다.

String studentNames = studentStream.map(Student::getName).collect(joining());
String studentNames = studentStream.map(Student::getName).collect(joining(","));
String studentNames = studentStream.map(Student::getName).collect(joining(",","[","]"));

 

 

그룹화와 분할 - groupingBy(), partitioningBy()

그룹화는 스트림의 요소를 특정 기준으로 그룹화하는 것을 의미하고, 분할은 스트림의 요소를 두 가지, 지정된 조건에 일치하는 그룹과 일치하지 않는 그룹으로의 분할을 의미한다.

Collector groupingBy(Function classifier)
Collector groupingBy(Function classifier, Collector downstream)
Collector groupingBy(Function classifier, Supplier mapFactory, Collector downstream)

Collector partitioningBy(Predicate predicate)
Collector partitioningBy(Predicate predicate, Collector downstream)

그룹화는 스트림의 요소를 특정 기준으로 그룹화하는 것을 의미하고, 분할은 스트림의 요소를 두 가지, 지정된 조건에 일치하는 그룹과 일치하지 않는 그룹으로의 분할을 의미 한다.

groupingBy()는 스트림의 요소를 Function으로, partitioningBy()는 Predicate로 분류한다.

스트림을 두개의 그룹으로 나눠야 한다면, partitioningBy()로 분할하는 것이 당연히 빠르다.

그 외는 groupingBy()를 쓰면 된다. 그리고 그룹화와 분할의 결과는 Map에 담겨 반환된다.

 

먼저, 예시에서 사용될 Student 클래스와 stuStream 스트림이다.

package com.example.gccoffee.model;

public class Student {
    String name;        // 이름
    boolean isMale;     // 성별
    int hak;            // 학년
    int ban;            // 반
    int score;          // 점수

    public Student(String name, boolean isMale, int hak, int ban, int score) {
        this.name = name;
        this.isMale = isMale;
        this.hak = hak;
        this.ban = ban;
        this.score = score;
    }

    public String getName() {
        return name;
    }

    public boolean isMale() {
        return isMale;
    }

    public int getHak() {
        return hak;
    }

    public int getBan() {
        return ban;
    }

    public int getScore() {
        return score;
    }

    @Override
    public String toString() {
        return "Student{" +
                "name='" + name + '\'' +
                ", isMale=" + isMale +
                ", hak=" + hak +
                ", ban=" + ban +
                ", score=" + score +
                '}';
    }
    enum Level{HIGH, MID, LOW} // 성적을 상, 중, 하 세단계로 분류
}

 

        Stream<Student> stuStream = Stream.of(
                new Student("나자바", true, 1, 1, 300),
                new Student("김지미", false, 1, 1, 250),
                new Student("김자바", true, 1, 1, 200),
                new Student("이지미", false, 1, 2, 150),
                new Student("남자바", true, 1, 2, 100),
                new Student("안지미", false, 1, 2, 50),
                new Student("황지미", false, 1, 3, 100),
                new Student("강지미", false, 1, 3, 150),
                new Student("이자바", true, 1, 3, 200),

                new Student("나자바", true, 2, 1, 300),
                new Student("김지미", false, 2, 1, 250),
                new Student("김자바", true, 2, 1, 200),
                new Student("이지미", false, 2, 2, 150),
                new Student("남자바", true, 2, 2, 100),
                new Student("안지미", false, 2, 2, 50),
                new Student("황지미", false, 2, 3, 100),
                new Student("강지미", false, 2, 3, 150),
                new Student("이자바", true, 2, 3, 200)
        );

partitioningBy()에 의한 분류

먼저 상대적으로 간단한 partitioningBy를 사용한 예제를 먼저 보면 가장 기본적인 분할은 학생들을 성별로 나누어 List에 담는것이다.

Map<Boolean, List<Student>> stuBySex = stuStream.collect(partitioningBy(Student::isMale)); // 성별로 분할
List<Student> maleStu = stuBySex.get(true); // map에서 남학생 목록 얻음
List<Student> femaleStu = stuBySex.get(true);// map에서 여학생 목록 얻음

 

counting()을 추가해 남학생과 여학생의 수를 구하는 법

// 2. 기본 분할 + 통계 정보
Map<Boolean, Long> stuNumBySex = stuStream.collect(partitioningBy(Student::isMale,counting()));
System.out.println("남학생 수 : " + stuNumBySex.get(true)); // 남학생 수
System.out.println("여학생 수 : " + stuNumBySex.get(false)); // 여학생 수

 

summingLong()을 사용하여, 남학생과 여학생의 총점을 구하기

Map<Boolean,Optional<Student>> topScoreBySex = stuStream.collect(
	partitioningBy(Student::isMale,
		maxBy(comparingInt(Student::getScore))
	)
);

System.out.println("남학생 1등 : OptioanlcoreBySex.get(true)); // 남학생 1등
System.out.println("여학생 1등 : " + topScoreBySex.get(false)); // 여학생 1등

// 남학생 1등 : Optioanl[[나자바, 남, 1, 1, 300]]
// 여학생 1등 : Optioanl[[김지미, 여, 1, 1, 250]]

 

 

Optioanl<Student>가 아닌 Student 타입으로 반환을 받으려면 collectingAndThen()과 Optional::get을 함께 사용하면 된다.

Map<Boolean,Student> topScoreBySex = stuStream.collect(
	partitioningBy(Student::isMale,
    		collectingAndThen(
            	maxBy(comparingInt(Student::getScore)), Optional::get
            )
    )
);

System.out.println("남학생 1등 : OptioanlcoreBySex.get(true)); // 남학생 1등
System.out.println("여학생 1등 : " + topScoreBySex.get(false)); // 여학생 1등

// 남학생 1등 : [나자바, 남, 1, 1, 300]
// 여학생 1등 : [김지미, 여, 1, 1, 250]

 

groupingBy()에 의한 분류

일단 간단한 그룹화를 해보면 stuStream을 반 별로 그룹지어 Map에 저장하는 방법은 다음과 같다.

Map<Integer,List<Student>> stuByBan = stuStream.collect(groupingBy(Student::getBan)); // toList()가 생략됨

groupingBy()를 사용하면 기본적으로 List<T>에 담기 떄문에 toList는 생략할 수 있다.

만약 다른 형태로 담길 원하면 toSet(), toCollection(HashSet::New)를 사용할 수 있다.

 

stuStream을 성적의 등급(Student.Level)로 그룹화 하기

아래의 문장은 모든 학생을 세등급(HIGH,MID,LOW)로 분류하여 집계한다.

Map<Student.Level, Long> stuByLevel = stuStream
	.collect(groupingBy(s -> {
    	if(s.getScore() >= 200)		return Student.Level.HIGH;
        else if(s.getScore() >= 100)	return Student.Level.MID;
        else				return Student.Level.LOW;
        }, counting()
    );

groupingBy()를 여러 번 사용하면, 다수준 그룹화가 가능하다.

만일 학년별로 그룹화 한 후에 다시 반별로 그룹화하고 싶으면 다음과 같이 한다.

Map<Integer,Map<Integer,List<Student>>> stuByHakAndBan = 
			stuStream.collect(groupingBy(Student::getHak, groupingBy(Student::getBan)));

 

만약 각 반의 1등을 출력하고 싶다면, collectingAndThen()과 maxBy()를 써서 다음과 같이 하면 된다.

Map<Integer, Map<Integer, Student>> topStuByHakAndBan = 
		stuStream.collect(groupingBy(Student::getHak, 
        	groupingBy(Student::getBan, 
            	collectingAndThen(
                	maxBy(comparingInt(Student::getScore)),
                    Optional::get
                    )
             )
         ));

 

 

Collector 구현하기

Collector 인터페이스를 구현하는것을 의미하는데 해당 인터페이스는 다음과 같이 정의되어 있다.

public interface Collector<T,A,R> {
	Supplier<A> supplier();
    BiConsumer<A,T> accumulator();
    BinaryOperator<A> combiner();
    Function<A,R> finisher();
    
    Set<Characteristics> characteristics(); // 컬렉터의 특성이 담긴 set을 반환
}

직접 구현해야하는 것은 위의 5개의 메서드인데, characteristics()를 제외하면 모두 반환 타입이 함수형 인터페이스이다.

즉, 4개의 람다식을 작성하면 되는 것이다.

supplier() // 작업 결과를 저장할 공간을 제공
accumulator // 스트림의 요소를 수집(collect)할 방법을 제공
combiner() // 두 저장공간을 병합할 방법을 제공(병렬 스트림)
finisher() // 결과를 최종적으로 반환할 방법을 제공
  • supplier() : 수집 결과를 저장할 공간을 제공하기 위한 것이다.
  • accumulator() : 스트림의 요소를 어떻게 supplier()가 제공한 공간에 누적할 것인지를 정의한다.
  • combiner() : 병렬 스트림인 경우, 여러 쓰레드에 의해 처리된 결과를 어떻게 합칠 것인가를 정의한다.
  • finisher() : 작업 결과를 변환하는 일을 하는데 변환이 필요 없다면, 항등 함수인 Function.identity()를 반환하면 된다.

characteristics()는 컬렉터가 수행하는 작업의 속성에 대한 정보를 제공하기 위한 것이다.

Characteristics.CONCURRENT // 병렬로 처리할 수 있는 작업
Characteristics.UNORDERED // 스트림의 요소의 순서가 유지될 필요가 없는 작업
Characteristics.IDENTITY_FINISH // finisher()가 항등함수인 작업

위 3가지 속성 중 해당하는 것을 set에 담아서 반환하도록 구현하면 된다.

public Set<Characteristics> characteristics(){
	return Collections.unmodifiableSet(EnumSet.of(
    	Collector.Characteristics.CONCURRENT,
        Collector.Characteristics.UNORDERED
    ));
}


// 아무런 속성을 지정하고 싶지 않은 경우
public Set<Characteristics> characteristics(){
	return Collections.emptySet(); // 지정할 특성이 없는 경우 비어있는 set반환
}

 

reduce()와 collect()는 근본적으로 하는 일이 같다. collect()는 앞서 살펴 본 것처럼, 그룹화와 분할, 집계 등에 유용하게 쓰이고, 병렬화에 있어서 reduce()보다 collect()가 더 유리하다.

 

public class CollectorEx1 {
    public static void main(String[] args) {
        String[] strArr = {"aaa","bbb","ccc"};
        Stream<String> stringStream = Stream.of(strArr);

        String result = stringStream.collect(new ConcatCollector());

        System.out.println("Arrays.toString(strArr) = " + Arrays.toString(strArr));
        System.out.println("result = " + result);
    }
}
class ConcatCollector implements Collector<String, StringBuilder, String>{

    @Override
    public Supplier<StringBuilder> supplier() {
        return StringBuilder::new;
    }

    @Override
    public BiConsumer<StringBuilder, String> accumulator() {
        return StringBuilder::append;
    }

    @Override
    public BinaryOperator<StringBuilder> combiner() {
        return StringBuilder::append;
    }

    @Override
    public Function<StringBuilder, String> finisher() {
        return StringBuilder::toString;
    }

    @Override
    public Set<Characteristics> characteristics() {
        return Collections.emptySet();
    }
}

결과

 

스트림의 변환


참고 자료:

도서) 자바의 정석

 

인프런 강의 

https://www.inflearn.com/course/the-java-java8/dashboard

 

더 자바, Java 8 - 인프런 | 강의

자바 8에 추가된 기능들은 자바가 제공하는 API는 물론이고 스프링 같은 제 3의 라이브러리 및 프레임워크에서도 널리 사용되고 있습니다. 이 시대의 자바 개발자라면 반드시 알아야 합니다. 이

www.inflearn.com

 

'자바 > JAVA' 카테고리의 다른 글

인프런 JPA - 기본편 정리(EntityManager,EntityManagerFactory, 영속성 컨텍스트)  (0) 2021.10.07
Java16 - record  (0) 2021.09.19
CompletableFuture  (0) 2021.08.09
자바 (Optional)  (0) 2021.08.01
JAVA 인터뷰 대비  (0) 2020.10.25
Comments