java, stream의 collect 사용하기 - 특히 Map 위주로

stream의 collect과 다양한 최종 연산 타입 - toList, toSet, joining, summarizing 등

  • 자바의 stream api을 최종 연산할 때 대표적으로 collect 메서드를 사용한다.
  • List로 반환하는 Collector.toList()와 Set을 반환하는 Collectors.toSet()이 대표적이다.
List<Integer> list = IntStream.range(0, 5).boxed().collect(Collectors.toList());
// List<Integer> list = IntStream.range(0, 5).boxed().toList(); // 자바 16 이후로 축약 가능하다. 
assertThat(list).containsExactly(0,1,2,3,4);

Set<Integer> set = IntStream.of(1, 2, 3, 3).boxed().collect(Collectors.toSet());
assertThat(set).size().isEqualTo(3);
assertThat(set).containsExactly(1,2,3);
  • Collectors가 지원하는 여러 팩터리 메서드가 존재한다. 필요로한 메서드를 찾아서 연산을 수행해보자.
// String 배열을 합성하는 joining
String joining = Stream.of("kim", "lee", "choi")
        .collect(Collectors.joining(", "));
assertThat(joining).isEqualTo("kim, lee, choi");

// 산수에 대한 다양한 결과값을 제공하는 summarizing
IntSummaryStatistics statistics = IntStream.of(1, 2, 3, 4).boxed()
        .collect(Collectors.summarizingInt(i -> i));
assertThat(statistics.getAverage()).isEqualTo(2.5);
assertThat(statistics.getSum()).isEqualTo(10);
assertThat(statistics.getMax()).isEqualTo(4);

Map과 groupingBy

map으로 최종연산 과정에서 key와 NPE 문제

  • list와 set으로 최종연산을 수행할 때, 직관적이며 고려해야 할 부분이 적다.
  • 한편, map을 구현하는 방식은 다른 컬렉션과 달리 다소 복잡하다.
  • 아래는 collect에 직접 인자를 삽입하여 map을 최종연산하였다.
// 이하 예제에 사용할 객체와 이넘
class Person{
    private String name;
    private int age;
    private City city;

    // constructor
    Person(String name, int age, City city) {...}

    // getter 생략
}

enum City{
    SEOUL, PUSAN, DAEJEON
}
Map<City, List<Person>> collect = Stream.of(new Person("kim", 15, SEOUL), new Person("lee", 20, SEOUL), new Person("choi", 12, DAEJEON))
        .collect(
            HashMap::new
            , (map, person) -> {
                if(!map.containsKey(person.getCity())){
                    map.put(person.getCity(), new ArrayList<>());
                }
                map.get(person.getCity()).add(person);
            }
            , HashMap::putAll
        );

assertThat(collect.get(SEOUL)).size().isEqualTo(2);
assertThat(collect.get(DAEJEON)).size().isEqualTo(1);
assertThat(collect.get(PUSAN)).isNull();
  • 위의 코드는 여러 가지의 문제를 가지고 있다.
  • (collect의) 두 번째 인자가 함수형 프로그래밍 답지 않게 작성되었다. 구현에 초점이 맞춰져 있다.
  • 두 번째 인자는 두 가지 기능을 가진다. 1) map에 key와 value를 put하고 2) 인자가 가진 key에 해당하는 value에 자기 자신을 삽입한다.
  • NPE가 발생할 수 있다. enum을 사용했기 때문에 모든 enum에 대응되는 키가 존재할 것이라 기대할 수 있다. 하지만 PUSAN을 조회한 결과 null을 반환한다.

  • toMap을 사용하면 collect를 사용하는 것보다 더 좋은 코드를 작성할 수 있다. 아래 예제를 확인하자.
Map<City, List<Person>> collect = Stream.of(new Person("kim", 15, SEOUL), new Person("lee", 20, SEOUL), new Person("choi", 12, DAEJEON))
                .collect(Collectors.groupingBy(Person::getCity));

assertThat(collect.get(SEOUL)).size().isEqualTo(2);
assertThat(collect.get(DAEJEON)).size().isEqualTo(1);
assertThat(collect.get(PUSAN)).isNull();
  • Collectors.groupingBy(Person::getCity) 코드 한 줄로 장황한 collect 코드가 사라진다. 앞의 예제와 달리 Person::getCity라는 단 하나의 역할만을 수행한다.
  • 하지만 NPE 문제는 해결되지 않았다.
  • 다음 예제는 groupingBy에 추가 인자를 삽입하며 NPE문제를 해결한다.
Supplier<EnumMap<City, List<Person>>> fulfilled = () -> {
    EnumMap<City, List<Person>> map = new EnumMap<>(City.class);
    for (City c : values()) {
        map.put(c, new ArrayList<>());
    }
    return map;
};

Map<City, List<Person>> collect = Stream.of(new Person("kim", 15, SEOUL), new Person("lee", 20, SEOUL), new Person("choi", 12, DAEJEON))
        .collect(Collectors.groupingBy(Person::getCity, fulfilled, Collectors.toList()));

assertThat(collect.get(SEOUL)).size().isEqualTo(2);
assertThat(collect.get(DAEJEON)).size().isEqualTo(1);
assertThat(collect.get(PUSAN)).size().isEqualTo(0);
  • map 인스턴스에 필요로 한 key와 value을 먼저 초기화한다(fulfilled, Collectors.toList()). 그 후 인자를 평가한다.
    • 필요로한 key를 미리 map에 삽입한다. 이를 통해 NPE 문제를 최소화한다.
    • 다양한 역할을 수행했던 코드를 잘게 분리할 수 있다.

groupingBy 내부의 추가 연산 - mapping, filtering, groupingBy

  • groupingBy의 value에 대한 추가적인 조작을 할 수 있다. Collector.mapping, Collector.filtering, Collector.groupingBy가 사용된다.
    • mapping은 value의 타입을 조작한다. 중간 연산인 stream.map에 대응한다.
    • filtering은 value를 필터링한다. 중간 연산인 stream.filter에 대응한다.
  • 아래는 mapping에 대한 예제이며 Person을 String(getName)으로 변환한다.
Map<City, List<String>> collect = Stream.of(new Person("kim", 15, SEOUL), new Person("lee", 20, SEOUL), new Person("choi", 12, DAEJEON))
                .collect(Collectors.groupingBy(Person::getCity, Collectors.mapping(Person::getName, Collectors.toList())));

assertThat(collect.get(SEOUL)).containsExactlyInAnyOrder("kim", "lee");
assertThat(collect.get(DAEJEON)).containsExactlyInAnyOrder("choi");
  • groupingBy를 중복하여 사용할 수도 있다.
  • City - Age로 두 단계를 구분한다.
enum Age {
    KID, ADULT
}

Function<Person, Age> classifyWithAge = p -> {
            if (p.getAge() > 19) return ADULT;
            return KID;
        };
Map<City, Map<Age, List<Person>>> collect = Stream.of(new Person("kim", 15, SEOUL), new Person("lee", 20, SEOUL), new Person("choi", 12, DAEJEON))
        .collect(Collectors.groupingBy(Person::getCity, Collectors.groupingBy(classifyWithAge)));

assertThat(collect.get(SEOUL).get(ADULT)).size().isEqualTo(1);
assertThat(collect.get(SEOUL).get(KID)).size().isEqualTo(1);
assertThat(collect.get(DAEJEON).get(KID)).size().isEqualTo(1);

map과 partitioningBy

  • groupingBy는 값을 기준으로 나눈다. partitioningBy는 boolean을 기준으로 나눈다.
Map<Boolean, List<Person>> collect = Stream.of(new Person("kim", 15, SEOUL), new Person("lee", 20, SEOUL), new Person("choi", 12, DAEJEON))
                .collect(Collectors.partitioningBy(p -> p.getAge() > 16));

assertThat(collect.get(true)).size().isEqualTo(1);
assertThat(collect.get(false)).size().isEqualTo(2);
  • partitioningBy의 분류 결과 모든 값이 true라 하더라도 false 값에 대한 NPE가 발생하지 않는다.
Map<Boolean, List<Person>> collect = Stream.of(new Person("kim", 15, SEOUL), new Person("lee", 20, SEOUL), new Person("choi", 12, DAEJEON))
                .collect(Collectors.partitioningBy(p -> p.getAge() > 30));

assertThat(collect.get(true)).size().isEqualTo(0);
assertThat(collect.get(false)).size().isEqualTo(3);