Java Stream Collectors 공부

     

    Collectors 요약

    stream의 collect() 메서드에는 Collectors 패키지와 관련된 것들을 요구한다. 이 때, Collectors로 할 수 있는 일들을 간략히 요약하면 다음과 같다. 

    • counting
    • maxBy, minBy
    • summingInt, summingLong, summingDouble
    • averagingInt, averagingLong, averagingDouble
    • summarizingInt, summarizingLong, summarizingDouble
    • joining
    • toList, toSet, toCollection
    • 다수준 그룹화
      • groupingBy, collectingAndThen
    • 분할
      • partitioningBy

    Collector를 이용한 count() 연산

    final User user1 = new User("John1", 1000, Position.STAFF);
    final User user2 = new User("John2", 2000, Position.STAFF);
    final User user3 = new User("John3", 3000, Position.CEO);
    final User user4 = new User("John4", 4000, Position.MANAGER);
    
    final List<User> users = List.of(user1, user2, user3, user4);
    
    // Counting 하기
    int count1 = users.size();
    long count2 = users.stream().count();
    Long count3 = users.stream().collect(Collectors.counting());
    
    >>>>
    count1 4 / count2 4 / count3 4
    • Collectors는 counting() 메서드를 지원한다. 이 메서드는 Stream의 element 갯수를 세서 알려주는 역할을 한다.
    • Collectors.counting()으로 바로 쓸 일은 거의 없다. 왜냐하면 stream에서 바로 사용할 수 있도록 count()를 제공해주기 때문이다.
      • Collectors.counting()은 Collectors.groupingBy()의 다운스트림에서 사용됨. 

     


    Collector를 이용한 maxby(), minby() 연산

    // MaxBy, MinBy
    Comparator<User> comparator = Comparator.comparing(User::getSalary); // Comparator 제공
    Optional<User> maxBy = users.stream().collect(Collectors.maxBy(comparator));
    Optional<User> minBy = users.stream().collect(Collectors.minBy(comparator));
    
    System.out.printf("maxBy %s / minBy %s \n", maxBy.get(), minBy.get(), count3);
    >>>
    maxBy User(name=John4, salary=4000, position=MANAGER) / minBy User(name=John1, salary=1000, position=STAFF)

    Collectors.maxBy(), minBy()는 해당 타입의 객체의 최대/최소를 비교로 할 Comparator를 필요로 한다. 그리고 이 값을 기준으로 최대 / 최소값을 구한다.


    Collector를 이용한 sum() 연산

    Integer sum1 = users.stream().collect(Collectors.summingInt(user -> user.getSalary()));
    System.out.printf("sum1 %d \n", sum1);
    >>>
    sum1 10000

    Collectors에는 각 객체들을 더해주는 sum() 계통의 연산이 있으며, Int, Long, Double이 지원된다. 사용 결과는 위와 같다.


    Collector를 이용한 summary 연산

    IntSummaryStatistics summary = users.stream().collect(Collectors.summarizingInt(user -> user.getSalary()));
    System.out.printf("summary %s \n", summary);
    >>>
    summary IntSummaryStatistics{count=4, sum=10000, min=1000, average=2500.000000, max=4000}

    Collector를 이용한 summary 연산을 할 수 있다. Summary 연산은 Stream의 원소에 대한 count, average 같은 것들을 요약해준다.


    Collectors를 이용한 average 연산

    // Summary
    Double average = users.stream().collect(Collectors.averagingInt(user -> user.getSalary()));
    System.out.printf("average %f \n", average);

    Stream의 원소에 대해서 평균을 구하는 연산도 지원되고, averagingInt, Double, Long이 가능하다. 

     

    Collectors를 이용한 toList() 연산

    final String file = "data data2 data3 data4 data";
    
    final Map<String, Long> freq;
    try (Stream<String> words = new Scanner(file).tokens()) {
        freq = words.collect(groupingBy(s -> s, counting()));
    }
    
    final List<String> topTen = freq.keySet().stream()
            .sorted(Comparator.comparing(freq::get).reversed())
            .limit(10)
            .collect(toList());
    
    System.out.println(topTen);
    >>>
    [data, data4, data3, data2]

    Collectors는 toList()를 제공한다. toList()를 이용해 손쉽게 필요한 객체들을 모을 수 있다. 


    Collectors의 toMap() 사용하기 - 스트림 원소가 모두 고유한 키를 가질 때

    스트림의 원소 하나는 키 하나, 값 하나에 연결되어 있다. 그리고 다수의 스트림 원소는 같은 키에 연관될 수 있다. 쉽게 이야기 하면, 스트림 원소에 대해서 getKey()를 호출했더니 모두 A가 나오는 경우 등을 볼 수 있다. 

    public class ToMapExample {
        static enum Operation{
            A, B, C, D, E, F, G, H, I, J, K;
            private static final Map<String, Operation> stringToEnum =
                    Stream.of(values())
                            .collect(Collectors.toMap(Enum::name, operation -> operation));
        }
    
    }

    위의 경우는 스트림의 모든 원소가 각각 고유한 키를 가지고 있는 경우다. 이렇게 사용되면, Stream의 Collectors는 정상적으로 값들을 모은다. 


    Collectors의 toMap() 사용하기 - 스트림 원소가 중복된 키를 가질 때. (last write wins)

    @Getter
    static enum DuplicatedOperation{
        A("A"), a("A");
        public static final Map<String, DuplicatedOperation> stringToEnum =
                Stream.of(values()).collect(toMap(DuplicatedOperation::getSymbol, o -> o));
    
        private final String symbol;
        DuplicatedOperation(String symbol) {
            this.symbol = symbol;
        }
    }
    >>>
    Caused by: java.lang.IllegalStateException: Duplicate key A (attempted merging values A and a)
    ..

    스트림 원소가 중복된 키를 가진 경우가 있을 수 있다. 만약 toMap()을 이용해 각 원소들을 하나의 키로 모아야 하고, 이 때 중복된 키가 생긴다면 toMap() 연산 도중에 Exception을 던진다. Stream 입장에서는 중복된 키를 만났을 때, Value 값을 어떻게 업데이트 해야할지 알 수 없기 때문이다.

    public static final Map<String, DuplicatedOperation> stringToEnum =
            Stream.of(values()).collect(toMap(DuplicatedOperation::getSymbol, o -> o, (oldValue, newValue) -> newValue));

    toMap()에서 중복된 키가 생겼을 때 발생하는 에러를 해결하기 위해서 toMap()에 merge 함수 객체를 전달할 수 있다. 위에서는 merge 함수 객체를 이용해서 가장 마지막에 사용된 값이 남는 형태 (last write wins) 로 구성했다.


    Collectors의 toMap() 사용하기 - 스트림 원소가 중복된 키를 가질 때 (최소, 최대 사용)

    Artist artist = new Artist("Artist1");
    Album albumA = new Album("album-a", artist, 1000);
    Album albumB = new Album("album-b", artist, 5000);
    Album albumC = new Album("album-c", artist, 2000);
    
    List<Album> albums = List.of(albumA, albumB, albumC);
    Map<Artist, Integer> result = albums.stream().collect(toMap(Album::getArtist, Album::getSales, Math::max));
    result.forEach((art, sales) -> System.out.printf("artist : %s / sales : %d", art.getName(), sales));
    
    >>>
    artist : Artist1 / sales : 5000

    toMap() 메서드로 Map Collection으로 Stream의 원소들을 모을 때, 중복된 Key에 대해서 최대 / 최소값을 구해서 적재하도록 merge 함수를 전달할 수도 있다. 위 코드는 아티스트 별로 앨범 중 가장 많은 판매량이 있는 앨범을 구하는 코드다. 


    Collectors의 groupingBy() 이용하기 → 매개변수 1개짜리

    // 원소 1개짜리 GroupingBy
    Map<Artist, List<Album>> collect = albums.stream().collect(groupingBy(Album::getArtist));

    GroupingBy는 분류 함수 (classifier)를 받고, 출력으로는 원소들을 카테고리별로 모아놓은 맵을 반환한다. 가장 간단한 groupingBy()는 매개변수 하나만 받는녀석이다. 이 녀석의 결과는 다음과 같다.

    동일한 Key를 가지는 녀석들을 List로 묶어서 반환함. 

    Collectors의 groupingBy() 이용하기 → 매개변수 2개짜리

    groupingBy()가 반환하는 값이 리스트 외의 값을 갖는 맵을 생성하도록 하려면 분류함수(classfier)와 다운스트림(downstream)도 함께 명시해야한다. 즉, 매개변수 2개짜리 groupingBy()를 호출해야 한다. 

    downStream은 classfier로 분류된 Collection을 하나의 작은 스트림으로 동작한다고 생각하면 좀 더 이해하기 편하다. 

    Album albumA = new Album("album-a", artist, 1000);
    Album albumB = new Album("album-b", artist, 5000);
    Album albumC = new Album("album-c", artist, 2000);
    List<Album> albums = List.of(albumA, albumB, albumC);
    
    // 원소 2개짜리 GroupingBy
    Map<Artist, Long> collect1 = albums.stream().collect(groupingBy(Album::getArtist, counting()));
    >>>
    3

    위 코드는 classfier와 downStream을 매개변수로 전달한 groupingBy() 함수의 처리 결과다. downstream이 같은 Key를 가지는 원소들로 구성된 하나의 하위 스트림이라고 생각하면 좀 더 이해하기 편하다고 했었다. 위의 경우 해석하면 다음과 같다

    • 키:  Artist.
    • 다운스트림 : 같은 Artist로 분류되었으니 albumA, albumB, albumC로 구성된 하위 스트림이다. 

    다운 스트림은 스트림처럼 동작하기 때문에 Collectors.counting() 등을 호출해서 갯수를 모을 수 있다. 여기서 collect1 인스턴스의 Entry를 순회하면서 Value 값을 출력해보면 3이 나온다. 

     


    Collectors의 groupingBy() 이용하기 → groupingBy 중복 이용하기

    final User user1 = new User("John1", 1000, Position.STAFF);
    final User user2 = new User("John2", 2000, Position.STAFF);
    final User user3 = new User("John3", 3000, Position.CEO);
    final User user4 = new User("John4", 4000, Position.MANAGER);
    final List<User> users = List.of(user1, user2, user3, user4);
    
    // 다단계 Grouping
    Map<Position, Map<String, List<User>>> collect3 =
            users.stream()
                    .collect(groupingBy(
                            User::getPosition, groupingBy(User::getName, toList())
                    ));
    
    >>>
    {CEO=
      {John3=[User(name=John3, salary=3000, position=CEO)]},
     MANAGER={
       John4=[User(name=John4, salary=4000, position=MANAGER)]}, 
     STAFF={John1=[User(name=John1, salary=1000, position=STAFF)], John2=[User(name=John2, salary=2000, position=STAFF)]}}

    groupingBy()는 매개변수 2개를 받을 수 있고, 이 때 분류함수(classfier)와 다운스트림을 제공하면 된다고 했다. 그런데 groupingBy() 그 자체도 다운스트림으로 사용될 수 있다. 즉, groupingBy() 내부에서 groupingBy()를 통해서 한번 더 분류할 수 있음을 의미한다. 위의 코드를 해석해보자.

    • 첫번째 groupingBy : User Position으로 User를 분류함. (user1, user2) / (user3) / (user4)로 첫번째 분류가 완료되고, 각각의 하위 스트림으로 동작함. 
    • 두번째 groupingBy : User Name으로 User를 분류함. 
    Map<Position, Map<String, List<User>>> collect3 =

    각각의 하위 스트림에 대해서 다시 한번씩 groupingBy()를 중복해서 할 수 있기 때문에 스트림 연산 결과는 다음과 같아진다.


    Collectors의 partitioningBy() 이용하기

    Map<Boolean, List<User>> collect4 = users
      .stream()
      .collect(partitioningBy(user -> user.getPosition().equals(Position.CEO)));
    >>>
    {false=[
       User(name=John1, salary=1000, position=STAFF), 
       User(name=John2, salary=2000, position=STAFF), 
       User(name=John4, salary=4000, position=MANAGER)], 
     true=[User(name=John3, salary=3000, position=CEO)]}

    partitioningBy()는 groupingBy()와 거의 유사한 형태로 사용된다. partitioningBy()는 조건을 만족하는지 확인하는 predicate를 전달하고, Map<Boolean, ?> 형태의 값을 반환한다. 


    Collectors의 joining() 이용하기

    final User user1 = new User("John1", 1000, Position.STAFF);
    final User user2 = new User("John2", 2000, Position.STAFF);
    final User user3 = new User("John3", 3000, Position.CEO);
    final User user4 = new User("John4", 4000, Position.MANAGER);
    final List<User> users = List.of(user1, user2, user3, user4);
    
    String joinName2 = users.stream().map(User::getName).collect(joining(", "));
    System.out.printf("joinName2 %s \n", joinName2);
    >>>
    joinName2 John1, John2, John3, John4
    • Collectors의 joining()은 문자열에 대해서만 사용할 수 있는 수집기다.
    • 문자열들을 이어붙여서 하나의 문자로 만들어주는 작업을 한다.

    출처 및 참고

    댓글

    Designed by JB FACTORY