Lee's Grow up

[Java/java] 스트림과 컬렉터 Stream, Collector 본문

PROGRAMMING/JAVA

[Java/java] 스트림과 컬렉터 Stream, Collector

효기로그 2020. 5. 26. 16:02
반응형

해당 내용은 모던 자바 인 액션의 내용을 참고해서 작성하였습니다.

스트림

스트림은 람다와 마찬가지로 자바8에서 추가된 기능이다. 여기서 스트림이란, 데이터 처리 연산을 지원하도록 소스에서 추출된 연속된 요소로 정의할 수 있다.

스트림과 컬렉션

스트림도 컬렉션과 마찬가지로 연속된 값 집합의 인터페이스를 제공한다. 단 데이터를 언제 계산하느냐가 컬렉션과 스트림의 가장 큰 차이. 컬렉션은 현재 자료구조가 포함하는 모든 값을 메모리에 저장하는 자료구조이다. 그 후, 계산이 가능하다. 그러나 스트림의 경우 요청할 때만 요소를 계산하는 고정된 자료구조이다.
이를 동영상으로 비유하면, 컬렉션은 동영상을 모두 다운로드 후에 볼 수 있고, 스트림의 경우 전체가 아닌 해당 구간만 다운받아서 볼 수 있는 스트리밍 서비스가 있다. 또한 스트림은 한번 사용하면 소멸된다. 재사용이 불가능

또한 스트림은 내부 반복으로써 컬렉션과 같이 ( forEach ) 문등을 통해 반복을 컨트롤 할 필요가 없다. 이를 외부 반복이라 한다. 또한 스트림은 내부 반복을 사용하기 위해, filter, map 등과 같은 다양한 추가 연산자를 제공해준다.

스트림의 연산

스트림은 내부 반복을 위해 파이프라인과 다양한 추가 연산자를 제공해준다. 이런 연산을 크게 2가지로 나눌 수 있는데 중간 연산최종 연산으로 구분할 수 있다.

  • 중간 연산 : 중간 연산의 특징은 단말 연산을 스트림 파이프라인에 실행하기 전까지 아무 연산도 수행하지 않는다. 즉 게으르다는 것이다. 중간 연산을 합친 다음에 최종 연산으로 한 번에 처리하기 때문이다. (filter, map, limit, sorted, distinct 등)
  • 최종 연산 : 스트림 파이프라인에서 결과를 도출한다. (forEach, collect, count 등)

위와 같이 스트림의 게으른 특성 덕분에 몇가지 최적화 효과를 얻을 수 있다. limit를 통해 쇼트서킷 또는 서로 다른 연산자를 한 과정으로 병합할 수 있는 루프퓨전 ( filtermap을 한 연산에서 둘 다 사용 가능 )

정리하자면 스트림은 질의를 수행한 ( 컬렉션 같은 ) 데이터 소스가 필요하고, 중간 연산, 최종 연산의 형태로 이용이 가능하다.
members.stream().filter(m -> m.getAge() > 20 ).limit(3).forEach(System.out::println)
여기서 members는 데이터 소스, filter,limit는 중간연산, forEach는 최종 연산에 해당한다.

스트림의 활용

1. 필터링

스트림은 filter 메서드를 통해, 프리디케이트를 인수로 받아, 필터가 가능하고, distinct를 통해 고유 요소의 중복을 제거 할 수있다. 여기서 고유 여부는 스트림의 hashCode, equals로 결정됨

2. 스트림 슬라이싱

데이터가 무수히 많은 경우, 특정 조건까지의 리스트만 반환하고 싶은 경우
예제에서 members는 10만건의 멤버를 가지고 있다고 나이순으로 정렬이 되어있다고 가정

자바 9 이상부터는 아래의 2가지 방법을 제공

List<Member> result = members.stream().takeWhile(m -> member.getAge() < 20).collect(toList());

위와 같이 사용하면 20세 미만인 멤버 리스트를 반환해준다. 반대로 20세 보다 큰 사람을 탐색하고 싶은 경우, dropWhile를 사용하면 된다.

또는 간단하게 페이징등의 동작을 위해 정해진 갯수로 제한하고 싶을 경우 limit를 사용할 수 있다.
마지막으로 검색된 요소를 n개 건너 뛰고 싶은 경우 skip를 통해 검색된 스트림에서 n개만큼 컨너뛴 요소를 추출할 수 있다.

3. 매핑

어떤 문자열에서 겹치는 문자를 제외해야 하는 요구조건이 생겨서, String.split("")를 통해 중복을 제거하려고 했다면, 스트림의 map대신 flatMap를 사용해야 한다. 이유는 String.getBytes의 경우 리턴값이 String배열이기 때문에, 해당 배열을 평문으로 만들어줄 필요가 생기기 때문이다.

Arrays.asList("banana","apple").stream()
                .map(s -> s.split(""))
                .distinct()
                .forEach(System.out::println);

해당 코드는 중복제거가 되지 않기 때문에 원하는 결과를 얻을 수 없다. flatMap을 통해 Stream<String배열> 부분을 Stream<String타입>로 변경해준다.즉 스트림의 결과를 쉽게 평면화 해준다.

Arrays.asList("banana","apple").stream()
                .map(s -> s.split(""))
                .flatMap(Arrays::stream)
                .distinct()
                .forEach(System.out::println);  
검색과 매칭

스트림은 allMatch, noneMatch, findFirst, findAny등의 연산자를 통해, 검색과 매칭에 효율적으로 처리가 가능해진다. 이는 limit처럼 쇼트서킷을 활용했으며, 해당 기능은 이름대로 수행한다.

allMatch, noneMatch의 경우 프리디케이트가 적어도 일치하냐, 일치하지 않느냐에 대한 수행 결과를 반환해주고,
findFirst, findAny 둘다, 일치하는 첫번 째 항목을 리턴해준다. 둘이 동작이 비슷하지만, 병렬에서는 findAny를 사용하는게 좋으며, 값이 null일 수 있기 때문에 Optional<T>를 리턴해준다.

리듀싱

스트림의 filter을 통해 서울에 사는 멤버들의 리스트를 추출할 수 있게되었다. 나아가 추출 된 멤버들 중 나이가 제일 많은 사람과, 제일 적은 사람을 구하시오와 같은 요구를 처리하기 위해선 모든 요소를 반복적으로 처리해야 하며, 이런 질의를 리듀싱 연산이라고 한다. 아래와 같이 해결할 수 있다.

int max = members.stream()
                    .filter(m -> m.getAge() > 20)
                    .map(Member::getAge)
                    .reduce(0, (a, b) -> a > b ? a : b);

reduce의 0은 초기값이며, 초기값을 생략도 가능한 메소드를 오버로딩해준다. 다만 값이 없을 수도 있기 때문에 Optional로 반환해준다. 나아가 메서드 참조를 통해 아래처럼도 사용 가능

int max = members.stream()
                    .filter(m -> m.getAge() > 20)
                    .map(Member::getAge)
                    .reduce(0, Integer::max);

이렇게 값을 반복적으로 처리하기 위해 내부 상태를 가지고 있어야 하는 스트림을 내부 상태를 갖는 연산 이라고 하며, reduce의 경우 int나 double로 내부 상태로 사용했고, 해당 값은 한정되어 있는 값이다. 또한 소수에서 젤 큰 수를 찾아라와 같은 무한 스트림에서 sorteddistinct의 경우 값을 비교하기 위해 무한적으로 요소가 버퍼에 추가되어야 하기 때문에 문제가 발생한다. 이렇게 내부 상태를 갖는 연산은 사용 시 주의가 필요하다.

내부상태를 갖는 연산의 종류 : distinct, skip, limit, sorted, reduce

기본형 특화

스트림은 기본적으로 참조 타입을 가지기 때문에, 오토박싱에 대한 비용이 발생하게 되고, 간단한 누계같은 함수를 바로 사용할 수 없다. 그래서 함수처럼 기본형 특화 스트림을 제공해주며, 기본적인 메소드들이 정의되어 있다.( min, max, range, rangeClosed 등 ) 이전에 최대값을 구하는 방식을 아래처럼 변경이 가능하다

OptionalInt max2 = members.stream()
                .filter(m -> m.getAge() > 20)
                .mapToInt(Member::getAge)
                .max();

여기서 리턴타입은 OptionalInt, OptionalDouble등 특화 참조타입으로 리턴을 해준다.
복원의 경우 boxed 메소드를 사용하면 기본 스트림으로 변환해준다.

스트림 만들기

  1. 임의의 값으로 만들기

    Stream<Integer> stream = Stream.of(10, 20 , 41, 31 ) ;
  2. 빈 스트림 만들기

    Stream<Integer> stream = Stream.empty();
  3. null이 될 수 있는 객체로 스트림 만들기 return값이 null일때 빈 스트림을 반환해준다.

    Stream<String> values = Stream.ofNullable(System.getProperty("hmoe"));
  4. null이 될 수 있는 객체로 스트림 만들기 return값이 null일때 빈 스트림을 반환해준다.

    int[] arr = { 1, 2, 3, 4, 5 };
    int sum = Arrays.stream(arr).sum();
  5. 그외 함수로 무한 스트림 만들기 iterate, generate : 둘의 차이는 값을 연속으로 계산하냐, 매번 인수로 받느냐 차이

Collector 컬렉터

스트림 최종연산으로 collect(toList())를 많이 사용했었는데, 이때 collect 메서드는 Collector 인터페이스의 구현을 매개변수로 받는다. 이 인터페이스의 구현은 스트림의 요소를 어떤 식으로 도출할지 지정한다.
또한 유틸성 클래스로 Collector이 있으며 주로 스트림 요소를 하나의 값으로 리듀스하고, 요약하는 기능과 그룹화/분할 3가지로 구분할 수 있다.

1. 스트림 하나의 값으로 리듀싱

컬렉터를 통해서도 리듀싱 기능 즉 요약 연산이 가능하다.

Long count = members.stream().collect(counting());

해당 코드는 아래처럼 요약이 가능하다.

Long count = members.stream().count();

이외 최대 값, 최소 값을 리턴해주는 maxBy(Comparaotr), minBy(comparator)도 제공해주며, 리턴은 Optional<T> 타입으로 반환을 해준다.

또 많이 사용하는 합계와, 평균값을 구해주는 메소드를 제공해주며 각각 averagingInt(mapper),averagingLong(mapper),averagingDouble(mapper) 을 제공해주며, 동작 방식은 동일하고, 요약하는 데이터 형식이 다르다, 마찬가지로 합계도 summingInt(mapper)과 같은 형태로 3가지를 제공한다.

위 합계, 평균, 카운트, 최소, 최대 값을 한번에 모두 리턴해주는 놀라운 메소드도 제공해주며 이름은 summarizingInt(mapper)로 제공주고 리턴으론 각각 요약 데이터 형식에 따른 객체를 리턴해준다.

IntSummaryStatistics result = members.stream().collect(summarizingInt(Member::getAge));

해당 객체는 다음과 같이 값들이 들어있으며 IntSummaryStatistics result = members.stream().collect(summarizingInt(Member::getAge)) 익숙한 getCount등과 같은 방식으로 값을 꺼낼 수 있다.

마지막으로 문자열의 연산도 제공해주는데 joining()이며, 구분자를 주고 싶은 경우 다음처럼 joinging(", ") 매개변수로 넘겨주면 된다.

2. 범용 리듀싱 요약 연산

위에서 사용한 모든 팩토리 메서드는 reducing로 정의해 직접 사용할 수도 있다. 합계의 경우 다음과 같이 사용 가능
가동성이나, 생산성을 따져서 편리하게 사용하는게 좋음

int sum = members.stream().collect(
                reducing(0, Member::getAge , Integer::sum  
            );

또한 예제에서는 Collector을 한개만 사용했는데 진정한 강력함은 다른 Collector과 함께 사용할 때 나타난다.

3. collect 와reduce

둘다 요약 연산을 하며, 결과를 도출해주는 메소드이다. 다만 reduce의 경우 불변 값을 결합하여 새로운 값을 생성하는 연산이고, collect는 컨테이너 를 변경 하여 생성해야하는 결과를 축적 하도록 설계되었습니다.
쉽게 말해, List와 같이 가변 컨테이너 관련 작업이면서, 병렬성을 확보하기 위해선 collect를 사용하는 방법이 바람직합니다.

4. 그룹화

예제에서 성을 기준으로 멤버들을 그룹화하고 싶은 경우 groupingBy를 사용 하면 된다.

Map<LastName, List<Member>> result = members.stream().collect(groupingBy(Member::getLastname));

결과는 다음과 같다 {PARK=[[age=15]], KIM=[[age=41], [age=61], [age=7]], LEE=[[age=23]]}

이제 위 예제에서 성을 기준으로 그룹화하고, 성인인 사람을 필터링하고 싶다면? 아래와 같이 사용할 수 있다.

Map<LastName, List<Member>> result = members.stream()
                                        .filter(m -> m.getAge() > 19)
                                        .collect(groupingBy(Member::getLastname));

결과는 다음과 같다. {LEE=[[age=23]], KIM=[[age=41], [age=61]]}

위에서 잠깐 언급한, collect는 컬렉터를 다른 컬렉터와 사용할 경우 막강한 기능을 제공한다고 소개했었다. 위 2개의 결과는 조금 차이가 있다. 바로 filter에 해당하는 값이 없다면, 최종연산에 포함되지 않기 때문에 성이 PARK 타입은 그룹에서 제외가 되었다. 그래서 아래처럼 변경을 해본다.

Map<LastName, List<Member>> result = members.stream()
                                            .collect(groupingBy(Member::getLastname
                                            ,filtering( m -> m.getAge() > 19, toList())));

결과는 다음과 같이 없는 경우에도 버킷을 생성한 결과를 리턴해준다. {PARK=[], KIM=[[age=41], [age=61]], LEE=[[age=23]]}

위처럼 동작하는 이유는 collec가 최종 연산에 해당하기 때문에, 스트림의 결과에서 그룹화, 필터링을 진행하기 때문에 다른 연산 결과를 보여준다. 여기서 filtering를 썻다는거는 groupingBy도 사용 가능하다는 뜻이고, 다음과 같이 다수준으로 그룹화가도 가능해진다.

Map<LastName, Map<Object, List<Member>>> result = members.stream()
                    .collect(groupingBy(Member::getLastname,
                            groupingBy(m -> {
                                if(m.getAge() > 19) return AgeGroup.ADULT ;
                                else return AgeGroup.CHILD;
                                }
                            )));

결과는 {LEE={ADULT=[[age=23]]}, PARK={CHILD=[[age=15]]}, KIM={CHILD=[[age=7]], ADULT=[[age=41], [age=61]]}}

이렇게 외부 컬렉터로 넘겨주는 컬렉터에는 제한이 없이, 다 사용이 가능하다는 강점이있다.

5. 분할

분할 함수라 불리는 프리디케이트를 분류 함수로 사용하는 특수한 그룹화 기능으로, Boolean 값 결과를 토대로 그룹화를 한다. 다음과 같이 사용할 수 있다.

Map<Boolean, List<Member>> result = 
                                members.stream()
                                    .collect(partitioningBy(
                                        m -> m.getLastname().equals(KIM)
                                    ));

결과는 {false=[[age=23], [age=15]], true=[[age=41], [age=61], [age=7]]}

반응형
Comments