Published on

Java Stream groupBy NPE 6가지 해법

Authors

서버/배치 코드에서 Stream.collect(Collectors.groupingBy(...)) 를 쓰다 보면, 분명 데이터는 정상 같았는데 갑자기 NullPointerException 이 터지는 순간이 있습니다. 특히 레거시 DB, 외부 API, 혹은 nullable 필드가 섞인 도메인에서 자주 재현됩니다.

이 글은 groupingBy 에서 NPE가 발생하는 대표 원인을 분해하고, 6가지 해법을 각각 언제 쓰는지까지 정리합니다. (코드는 모두 컴파일 가능한 형태로 제시합니다.)

참고: 성능/안정성 이슈는 대체로 “원인 파악이 늦어서” 커집니다. 운영 장애를 줄이는 디버깅 루틴은 systemd 서비스 재시작 루프 진단 - 로그·유닛·쉘 같은 글의 접근처럼, 증상보다 “재현 가능한 원인”을 먼저 고정하는 게 좋습니다.

0) groupingBy NPE가 나는 대표 지점 3가지

NPE는 보통 아래 중 하나에서 발생합니다.

  1. 스트림 요소 자체가 null 인데 classifier 에서 메서드 호출
  2. classifiernull 키를 반환 (특히 JDK 버전/구현에 따라 HashMap 이더라도 groupingBy 가 null 키를 허용하지 않는 경로가 있음)
  3. downstream collector 내부가 null에 취약 (예: toMap merge 함수, reducing 등)

아래 예시 도메인을 기준으로 설명하겠습니다.

import java.util.*;
import java.util.stream.*;

class Order {
    private final String userId;   // nullable
    private final String status;   // nullable
    private final Integer amount;  // nullable

    Order(String userId, String status, Integer amount) {
        this.userId = userId;
        this.status = status;
        this.amount = amount;
    }

    public String getUserId() { return userId; }
    public String getStatus() { return status; }
    public Integer getAmount() { return amount; }
}

샘플 데이터:

List<Order> orders = Arrays.asList(
    new Order("u1", "PAID", 100),
    new Order("u1", null, 50),
    null,
    new Order(null, "PAID", 70),
    new Order("u2", "CANCEL", null)
);

1) 해법 1: 스트림 요소 null 제거 (filter(Objects::nonNull))

가장 흔한 케이스는 리스트 안에 null 객체가 섞인 경우입니다. 이때는 classifierOrder::getUserId 같은 메서드 레퍼런스면 즉시 NPE가 납니다.

Map<String, List<Order>> byUser = orders.stream()
    .filter(Objects::nonNull)
    .collect(Collectors.groupingBy(Order::getUserId));

언제 쓰나

  • 컬렉션이 외부 입력(역직렬화, DB 매핑, CSV 파싱 등)에서 오고 null 레코드가 섞일 수 있을 때
  • null 오더는 무시”가 비즈니스적으로 타당할 때

주의

  • 이 방식은 데이터 손실이 발생합니다. 만약 null 이 들어오면 “버그”로 봐야 한다면 6번(검증/예외) 해법이 더 맞습니다.

2) 해법 2: classifier 결과의 null을 기본 키로 치환 (Objects.toString / Optional)

요소는 정상인데 getUserId()null 이라서 문제가 나는 경우가 많습니다. 이때는 null 키를 ‘의미 있는 값’으로 치환합니다.

Map<String, List<Order>> byUser = orders.stream()
    .filter(Objects::nonNull)
    .collect(Collectors.groupingBy(o -> Objects.toString(o.getUserId(), "__UNKNOWN__")));

또는 Optional 로 표현해도 됩니다.

Map<String, List<Order>> byUser = orders.stream()
    .filter(Objects::nonNull)
    .collect(Collectors.groupingBy(o -> Optional.ofNullable(o.getUserId()).orElse("__UNKNOWN__")));

언제 쓰나

  • “미지정 사용자” 같은 버킷이 필요한 리포팅/통계
  • API 응답에서 누락된 필드를 하나의 그룹으로 모아야 할 때

  • 기본 키 문자열은 상수로 빼고, 로그/메트릭으로 카운트하면 데이터 품질 모니터링에 도움이 됩니다.

3) 해법 3: null 키를 아예 제외하고 그룹핑 (의도적으로 drop)

null 키를 별도 버킷으로 모으는 게 아니라, 그룹핑 대상에서 제외하고 싶을 때가 있습니다.

Map<String, List<Order>> byUser = orders.stream()
    .filter(Objects::nonNull)
    .filter(o -> o.getUserId() != null)
    .collect(Collectors.groupingBy(Order::getUserId));

언제 쓰나

  • null 키 레코드는 “불완전 데이터”로 간주하고 후속 파이프라인에서 다루지 않을 때
  • 다운스트림에서 키가 반드시 유효해야 하는 경우(캐시 키, 파티셔닝 키 등)

트레이드오프

  • 이 또한 데이터 손실입니다. 대신 아래처럼 별도로 수집하면 운영에서 원인 추적이 쉬워집니다.
List<Order> invalid = orders.stream()
    .filter(Objects::nonNull)
    .filter(o -> o.getUserId() == null)
    .toList();

4) 해법 4: groupingBy 대신 partitioningBy 로 2분할 후 처리

키가 null일 수도 있고 아닐 수도 있는 구조라면, 애초에 두 갈래로 분기하는 게 가장 명확합니다.

Map<Boolean, List<Order>> parts = orders.stream()
    .filter(Objects::nonNull)
    .collect(Collectors.partitioningBy(o -> o.getUserId() != null));

Map<String, List<Order>> byUser = parts.get(true).stream()
    .collect(Collectors.groupingBy(Order::getUserId));

List<Order> noUser = parts.get(false);

언제 쓰나

  • null 케이스를 “예외 흐름”으로 분리해 별도 알림/보정 로직을 태우고 싶을 때
  • 코드 리뷰에서 의도가 분명해야 할 때(운영팀/데이터팀 협업)

5) 해법 5: downstream collector에서 null 안전하게 집계하기 (예: 합계)

groupingBy 자체는 통과했는데, 집계 과정에서 NPE가 나는 경우가 있습니다. 대표적으로 금액이 Integer nullable인데 합계를 내면 터집니다.

문제 코드:

Map<String, Integer> sumByUser = orders.stream()
    .filter(Objects::nonNull)
    .filter(o -> o.getUserId() != null)
    .collect(Collectors.groupingBy(
        Order::getUserId,
        Collectors.summingInt(o -> o.getAmount()) // 여기서 오토언박싱 NPE 가능
    ));

해결: null을 0으로 치환하거나, null은 제외한 뒤 합산합니다.

Map<String, Integer> sumByUser = orders.stream()
    .filter(Objects::nonNull)
    .filter(o -> o.getUserId() != null)
    .collect(Collectors.groupingBy(
        Order::getUserId,
        Collectors.summingInt(o -> o.getAmount() == null ? 0 : o.getAmount())
    ));

또는 합계 대상만 필터링하는 방식:

Map<String, Integer> sumByUser = orders.stream()
    .filter(Objects::nonNull)
    .filter(o -> o.getUserId() != null)
    .filter(o -> o.getAmount() != null)
    .collect(Collectors.groupingBy(
        Order::getUserId,
        Collectors.summingInt(Order::getAmount)
    ));

언제 쓰나

  • 금액/수량/점수처럼 nullable 숫자 필드가 섞인 이벤트 로그
  • DB 컬럼이 nullable인데 애플리케이션에서 primitive로 합산할 때

  • “null은 0이다”가 비즈니스적으로 맞는지 반드시 확인하세요. 통계가 조용히 왜곡될 수 있습니다.

6) 해법 6: 실패를 빠르게 드러내는 검증(예외) + 원인 로그 남기기

null을 우회하기만 하면, 나중에 더 큰 장애로 돌아오는 경우가 많습니다. 특히 키가 식별자라면 “unknown 버킷”으로 덮지 말고 즉시 실패시키는 게 낫습니다.

static String requireUserId(Order o) {
    if (o == null) throw new IllegalArgumentException("Order is null");
    if (o.getUserId() == null) throw new IllegalArgumentException("userId is null: status=" + o.getStatus());
    return o.getUserId();
}

Map<String, List<Order>> byUser = orders.stream()
    .collect(Collectors.groupingBy(Main::requireUserId));

운영에서 더 유용하게 하려면, 예외를 던지기 전에 샘플 데이터/상태를 구조적으로 로깅하거나, 메트릭을 올리는 패턴이 좋습니다. 장애가 났을 때 “재시작 루프”로만 보이면 원인 파악이 늦어집니다. 이런 류의 원인 고정은 systemd 서비스 재시작 루프 진단 - 로그·유닛·쉘 에서 말하는 것처럼, 로그를 ‘증거’로 남기는 게 핵심입니다.

언제 쓰나

  • 키가 파티션 키/샤딩 키/정합성에 중요한 식별자일 때
  • null이 들어오면 데이터 파이프라인이 망가지는 구조일 때

보너스: 디버깅을 위한 “NPE 지점” 빠르게 찾는 법

groupingBy 라인이 길어지면 어디서 NPE가 나는지 애매합니다. 아래처럼 단계별로 쪼개면 원인 파악이 빨라집니다.

List<Order> nonNullOrders = orders.stream()
    .filter(Objects::nonNull)
    .toList();

List<Order> nullUserId = nonNullOrders.stream()
    .filter(o -> o.getUserId() == null)
    .toList();

Map<String, List<Order>> grouped = nonNullOrders.stream()
    .filter(o -> o.getUserId() != null)
    .collect(Collectors.groupingBy(Order::getUserId));

또, 시스템 레벨에서 원인 추적이 필요하면 “로그/지표/트레이싱”을 같이 보게 되는데, 프론트엔드 성능 이슈도 결국 같은 방식입니다. 예를 들어 Chrome INP 급락 원인 찾기 - Long Task 추적 처럼 ‘측정→원인 후보 분리→재현’의 흐름을 그대로 적용하면 됩니다.

어떤 해법을 선택해야 하나: 간단 체크리스트

  • 컬렉션에 null 객체가 섞임: 해법 1
  • 키가 nullable지만 “미지정” 그룹이 필요: 해법 2
  • 키가 null인 레코드는 버린다: 해법 3
  • null 케이스를 별도 플로우로 다룬다: 해법 4
  • 그룹핑 후 집계에서 오토언박싱/집계 NPE: 해법 5
  • null은 곧 데이터 오류이며 빨리 실패해야 한다: 해법 6

결론

groupingBy NPE는 “Stream이 위험해서”가 아니라, 입력 데이터의 null 계약이 불명확한 상태에서 한 줄로 처리하려고 할 때 터집니다. 가장 좋은 해결은 null을 숨기는 게 아니라,

  • null을 허용할지(기본 버킷/분기)
  • null을 버릴지(필터)
  • null이면 실패할지(검증)

를 코드로 명시하는 것입니다.

위 6가지 패턴 중 하나로 팀의 규칙을 정해두면, 같은 유형의 장애가 반복되는 걸 크게 줄일 수 있습니다.