Collectors.groupingBy와 partitioningBy 예제로 배우기

1. 개요

Stream으로 작업할 때 요소들을 특정 기준에 따라 그룹화해야 할 경우가 많습니다. 예를 들어, 도시별 주문, 고객별 총액, 또는 큰 주문과 작은 주문 분류 같은 것들이죠. Collectors.groupingBy와 Collectors.partitioningBy는 정확히 이런 경우를 위해 만들어졌습니다.

이 글에서는 자주 사용하는 형태들을 구체적인 결과와 함께 살펴봅니다. downstream collector를 조합하는 방법, 반환할 Map의 타입을 선택하는 방법, 그리고 흔한 실수들을 피하는 방법을 배우게 될 거예요.

2. 샘플 데이터

모든 예제에서 사용하는 공통 주문 데이터입니다:

import java.util.List;

enum OrderStatus { PENDING, PAID, CANCELLED }

record Order(String id, String customer, String city, OrderStatus status, long amount) {}

class OrderFixtures {
    static List<Order> sample() {
        return List.of(
            new Order("O1", "An",   "Hà Nội",      OrderStatus.PAID,      500_000),
            new Order("O2", "Bình", "Đà Nẵng",     OrderStatus.PAID,      1_200_000),
            new Order("O3", "An",   "Hà Nội",      OrderStatus.CANCELLED, 300_000),
            new Order("O4", "Chi",  "Hồ Chí Minh", OrderStatus.PAID,      2_000_000),
            new Order("O5", "Bình", "Đà Nẵng",     OrderStatus.PENDING,   150_000),
            new Order("O6", "Chi",  "Hồ Chí Minh", OrderStatus.PAID,      800_000)
        );
    }
}

amount는 VND 단위이며 간편성을 위해 long 타입을 사용합니다. 실제 시스템에서는 화폐를 다루기 때문에 BigDecimal을 사용해야 합니다.

아래 예제들은 import static java.util.stream.Collectors.*;와 List<Order> orders = OrderFixtures.sample();이 이미 선언되어 있다고 가정합니다.

3. 기본 groupingBy

가장 간단한 형태는 분류 함수(classifier) 하나만 받습니다:

Map<String, List<Order>> byCity = orders.stream()
        .collect(groupingBy(Order::city));

byCity.forEach((city, list) ->
        System.out.println(city + " -> " + list.stream().map(Order::id).toList()));
Hà Nội -> [O1, O3]
Đà Nẵng -> [O2, O5]
Hồ Chí Minh -> [O4, O6]

Order::city가 key를 결정합니다. 각 key는 같은 값을 가진 주문들의 List로 매핑됩니다. 내부적으로 이 형태는 groupingBy(Order::city, HashMap::new, toList())와 동등합니다.

주의: 반환되는 Map은 HashMap이므로 key의 순서가 보장되지 않습니다. 각 머신에서의 출력이 다를 수 있습니다. 순서가 필요하면 섹션 5를 참고하세요.

4. Downstream Collector

groupingBy의 두 번째 파라미터는 또 다른 Collector인 downstream입니다. 이것은 같은 그룹 내의 요소들을 어떻게 처리할지 결정합니다. 이것이 groupingBy를 정말 강력하게 만드는 부분입니다.

4.1. 개수 세기와 합계

Map<OrderStatus, Long> countByStatus = orders.stream()
        .collect(groupingBy(Order::status, counting()));

Map<String, Long> totalByCustomer = orders.stream()
        .collect(groupingBy(Order::customer, summingLong(Order::amount)));

System.out.println(countByStatus);
System.out.println(totalByCustomer);
{PAID=4, PENDING=1, CANCELLED=1}
{An=800000, Bình=1350000, Chi=2800000}

counting()은 항상 Long을 반환합니다. Integer가 아닙니다. 변수 타입을 선언할 때 이 점을 기억해야 합니다. summingLong 외에도 summingInt, summingDouble, averagingLong, summarizingLong이 있습니다. 마지막 collector는 LongSummaryStatistics를 반환하는데, 이는 count, sum, min, max, average를 모두 포함합니다.

4.2. mapping으로 요소 변환

전체 객체 대신 특정 속성만 필요하다면 mapping을 사용하세요:

Map<String, Set<String>> customersByCity = orders.stream()
        .collect(groupingBy(Order::city, mapping(Order::customer, toSet())));

System.out.println(customersByCity);
{Hà Nội=[An], Đà Nẵng=[Bình], Hồ Chí Minh=[Chi]}

mapping(Order::customer, toSet())은 각 주문에서 고객명을 추출하고 Set으로 모아서 중복을 제거합니다. An이 하노이에서 두 개의 주문을 가지고 있지만 한 번만 나타납니다.

4.3. 그룹 내에서 최댓값 얻기

maxBy는 이론상 그룹이 비어있을 수 있으므로 Optional<Order>을 반환합니다. 하지만 groupingBy에서는 최소한 하나의 요소가 있을 때만 그룹이 생성됩니다. 따라서 collectingAndThen으로 Optional을 “열 수” 있습니다:

Map<String, Order> biggestByCustomer = orders.stream()
        .collect(groupingBy(
                Order::customer,
                collectingAndThen(maxBy(Comparator.comparingLong(Order::amount)), Optional::get)
        ));

biggestByCustomer.forEach((customer, order) ->
        System.out.println(customer + " -> " + order.id() + " (" + order.amount() + ")"));
An -> O1 (500000)
Bình -> O2 (1200000)
Chi -> O4 (2000000)

collectingAndThen은 먼저 내부 collector를 실행한 후, 그 결과에 마무리 함수(Optional::get)를 적용합니다.

또 다른 방법도 자주 사용됩니다: toMap(Order::customer, Function.identity(), BinaryOperator.maxBy(...))입니다. 이 방법도 같은 결과를 주지만 Optional을 거칠 필요가 없습니다.

4.4. filtering으로 그룹 내 필터링

Collectors.filtering은 Java 9부터 사용 가능합니다. 이는 그룹화하기 전에 .filter()를 호출하는 것과 다릅니다:

Map<String, Long> cancelledPerCustomer = orders.stream()
        .collect(groupingBy(Order::customer,
                filtering(o -> o.status() == OrderStatus.CANCELLED, counting())));

Map<String, Long> filteredFirst = orders.stream()
        .filter(o -> o.status() == OrderStatus.CANCELLED)
        .collect(groupingBy(Order::customer, counting()));

System.out.println(cancelledPerCustomer);
System.out.println(filteredFirst);
{An=1, Bình=0, Chi=0}
{An=1}

filtering을 사용하면, 모든 고객이 결과에 나타나며, 개수가 0이어도 마찬가지입니다. 반면에 먼저 .filter()를 호출하면 Bình과 Chi는 완전히 사라집니다. 필요에 따라 선택하세요: 전체 목록을 포함한 보고서가 필요하면 filtering을 사용합니다.

유사하게, flatMapping (Java 9)은 각 그룹 내의 collection을 평탄화하는 데 도움이 됩니다.

5. 반환 Map의 타입 선택

세 개의 파라미터를 받는 버전을 사용하면 Map을 생성하는 Supplier를 전달할 수 있습니다:

Map<String, Long> sortedTotals = orders.stream()
        .collect(groupingBy(Order::customer, TreeMap::new, summingLong(Order::amount)));

Map<OrderStatus, Long> statusCounts = orders.stream()
        .collect(groupingBy(Order::status,
                () -> new EnumMap<>(OrderStatus.class), counting()));

System.out.println(sortedTotals);
System.out.println(statusCounts);
{An=800000, Bình=1350000, Chi=2800000}
{PENDING=1, PAID=4, CANCELLED=1}

TreeMap은 key를 자연 순서대로 정렬합니다. EnumMap은 key를 enum 선언 순서대로 유지하며 key가 enum일 때 HashMap보다 빠릅니다. stream의 첫 등장 순서를 유지하려면 LinkedHashMap::new을 사용하세요.

주의: String을 key로 하는 TreeMap은 유니코드 코드 포인트 순서대로 정렬하며, 한국어(또는 다른 언어) 규칙에 따르지 않습니다. 한국어 정렬이 필요하면 () -> new TreeMap<>(Collator.getInstance(Locale.of("ko")))를 전달하세요.

6. 중첩된 그룹화

downstream은 어떤 collector든 될 수 있으므로, 또 다른 groupingBy를 중첩시킬 수 있습니다:

Map<String, Map<OrderStatus, Long>> cityThenStatus = orders.stream()
        .collect(groupingBy(Order::city, TreeMap::new,
                groupingBy(Order::status, counting())));

System.out.println(cityThenStatus);
{Hà Nội={PAID=1, CANCELLED=1}, Hồ Chí Minh={PAID=2}, Đà Nẵng={PAID=1, PENDING=1}}

결과는 두 단계의 Map입니다. 이 방식은 피벗 테이블 같은 보고서에 매우 유용합니다. 그러나 세 단계 이상이 되면 데이터 타입이 읽기 어려워집니다. 이 경우 복합 key를 사용하는 것을 고려하세요. 예를 들어 record CityStatus(String city, OrderStatus status)를 만든 후 groupingBy(o -> new CityStatus(o.city(), o.status()))를 사용합니다.

7. partitioningBy

partitioningBy는 그룹화의 특별한 경우입니다. Classifier는 Predicate이고, key는 항상 Boolean입니다:

Map<Boolean, List<String>> bigOrders = orders.stream()
        .collect(partitioningBy(o -> o.amount() >= 1_000_000,
                mapping(Order::id, toList())));

Map<Boolean, Long> bigOrderCount = orders.stream()
        .collect(partitioningBy(o -> o.amount() >= 1_000_000, counting()));

System.out.println(bigOrders);
System.out.println(bigOrderCount);
System.out.println("큰 주문: " + bigOrders.get(true));
{false=[O1, O3, O5, O6], true=[O2, O4]}
{false=4, true=2}
큰 주문: [O2, O4]

partitioningBy도 groupingBy와 똑같이 downstream collector를 받습니다. 하지만 Map을 생성하는 Supplier를 받는 버전은 없습니다. 왜냐하면 반환되는 Map은 내부적으로 정확히 두 개의 key만 가지는 구현이기 때문입니다.

7.1. 중요한 차이: key가 항상 존재함

이것이 많은 사람들이 두 collector 중에서 잘못된 선택을 하게 만드는 이유입니다:

List<Order> smallOnly = orders.stream().filter(o -> o.amount() < 1_000_000).toList();

Map<Boolean, List<Order>> partitioned = smallOnly.stream()
        .collect(partitioningBy(o -> o.amount() >= 1_000_000));

Map<Boolean, List<Order>> grouped = smallOnly.stream()
        .collect(groupingBy(o -> o.amount() >= 1_000_000));

System.out.println(partitioned.get(true));
System.out.println(grouped.get(true));
[]
null

partitioningBy는 한쪽이 비어있더라도 항상 true와 false 둘 다 포함합니다. groupingBy는 최소한 하나의 요소가 있을 때만 key를 생성합니다. 여기서 grouped.get(true).size()를 호출하면 NullPointerException이 발생합니다.

따라서 기준이 참/거짓일 때는 partitioningBy를 사용하세요. 코드의 의도가 더 명확하고, 누락된 key에 대해 걱정할 필요가 없습니다.

8. 그룹화 로직 테스트 작성

JUnit 5와 AssertJ를 pom.xml에 추가하세요. Spring Boot 3.x를 사용하는 경우, spring-boot-starter-test에 둘 다 포함되어 있습니다:

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-test</artifactId>
    <scope>test</scope>
</dependency>

테스트하기 쉽게 하려면 보고서 로직을 별도의 service로 분리하세요:

class OrderReportService {

    Map<String, Long> paidTotalByCustomer(List<Order> orders) {
        return orders.stream()
                .collect(groupingBy(Order::customer, TreeMap::new,
                        filtering(o -> o.status() == OrderStatus.PAID,
                                summingLong(Order::amount))));
    }

    Map<Boolean, Long> countBigOrders(List<Order> orders, long threshold) {
        return orders.stream()
                .collect(partitioningBy(o -> o.amount() >= threshold, counting()));
    }
}
import org.junit.jupiter.api.Test;
import static org.assertj.core.api.Assertions.*;

class OrderReportServiceTest {

    private final OrderReportService service = new OrderReportService();

    @Test
    void shouldSumOnlyPaidOrdersPerCustomer() {
        var result = service.paidTotalByCustomer(OrderFixtures.sample());

        assertThat(result)
                .containsExactly(
                        entry("An", 500_000L),
                        entry("Bình", 1_200_000L),
                        entry("Chi", 2_800_000L));
    }

    @Test
    void shouldAlwaysContainBothPartitions() {
        var result = service.countBigOrders(OrderFixtures.sample(), 10_000_000);

        assertThat(result)
                .containsEntry(true, 0L)
                .containsEntry(false, 6L);
    }
}

첫 번째 테스트에서 containsExactly를 사용할 수 있는 이유는 결과가 TreeMap이므로 순서가 고정되기 때문입니다. An은 주문 O3가 취소되었으므로 500,000만 합산됩니다. Bình은 O5가 아직 결제 대기 중이므로 1,200,000만 합산됩니다.

두 번째 테스트는 임계값을 초과하는 주문이 없더라도 key true가 여전히 존재하고 값이 0임을 확인합니다.

9. 흔한 실수들

Key가 null인 경우. groupingBy는 classifier가 null을 반환하는 것을 허용하지 않습니다:

var orders = List.of(new Order("O9", "Dung", null, OrderStatus.PAID, 100_000));
orders.stream().collect(groupingBy(Order::city));
java.lang.NullPointerException: element cannot be mapped to a null key

이를 처리하는 방법은 두 가지입니다. 첫 번째는 null을 기본값으로 대체하는 것입니다: groupingBy(o -> Objects.requireNonNullElse(o.city(), "불명")). 두 번째는 그룹화하기 전에 도시가 없는 주문을 필터링하는 것입니다.

타입 반환 오류. counting()은 Long을 반환합니다. averagingLong은 Double을 반환합니다. maxBy는 Optional을 반환합니다. 타입을 잘못 선언하면 읽기 어려운 컴파일 에러가 발생합니다. 확실하지 않을 때는 var를 사용하거나 IDE에 타입 추론을 맡기세요.

각 key가 값 하나만 가질 때 groupingBy 사용. id가 고유한 경우, groupingBy(Order::id)는 각 list가 한 개의 요소만 가지는 Map<String, List<Order>>를 생성합니다. 이 경우 toMap(Order::id, Function.identity())를 사용하세요.

반환된 Map 수정. Javadoc은 기본 버전에서 반환되는 Map의 타입, mutability 또는 thread-safety를 보장하지 않습니다. 나중에 Map을 수정할 계획이라면 명시적으로 Supplier를 전달하세요. 예를 들어 HashMap::new입니다.

주의: Parallel stream에서 groupingBy는 여러 개의 Map을 병합해야 하므로 느릴 수 있습니다. groupingByConcurrent는 ConcurrentHashMap에 직접 쓰므로 더 빠릅니다. 대신 각 그룹 내의 요소 순서는 유지되지 않습니다. Sequential stream에서는 일반 groupingBy를 사용하세요.

10. 결론

groupingBy는 요소들을 임의의 key로 그룹화합니다. partitioningBy는 Predicate에 따라 정확히 두 부분으로 분할하고 true와 false 둘 다 항상 반환합니다. 진정한 힘은 downstream collector에 있습니다: counting, summingLong, mapping, filtering, collectingAndThen 또는 중첩된 groupingBy 말이죠.

이 두 collector를 사용할 때 기억해야 할 세 가지가 있습니다:

  • 순서가 필요하면 적절한 Map 타입(TreeMap, EnumMap, LinkedHashMap)을 선택하세요.
  • 그룹화하기 전에 null key를 처리하세요.
  • 참/거짓 기준에는 partitioningBy를 우선적으로 사용하세요.

이러한 포인트들을 이해하면, 대부분의 보고서 로직을 읽기 쉽고 테스트하기 쉬운 몇 줄의 Stream 코드로 작성할 수 있게 됩니다.

javastreamcollectorsgroupingByjava 21