- Published on
Java Stream groupBy NPE 6가지 해법
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
서버/배치 코드에서 Stream.collect(Collectors.groupingBy(...)) 를 쓰다 보면, 분명 데이터는 정상 같았는데 갑자기 NullPointerException 이 터지는 순간이 있습니다. 특히 레거시 DB, 외부 API, 혹은 nullable 필드가 섞인 도메인에서 자주 재현됩니다.
이 글은 groupingBy 에서 NPE가 발생하는 대표 원인을 분해하고, 6가지 해법을 각각 언제 쓰는지까지 정리합니다. (코드는 모두 컴파일 가능한 형태로 제시합니다.)
참고: 성능/안정성 이슈는 대체로 “원인 파악이 늦어서” 커집니다. 운영 장애를 줄이는 디버깅 루틴은 systemd 서비스 재시작 루프 진단 - 로그·유닛·쉘 같은 글의 접근처럼, 증상보다 “재현 가능한 원인”을 먼저 고정하는 게 좋습니다.
0) groupingBy NPE가 나는 대표 지점 3가지
NPE는 보통 아래 중 하나에서 발생합니다.
- 스트림 요소 자체가
null인데classifier에서 메서드 호출 classifier가null키를 반환 (특히 JDK 버전/구현에 따라HashMap이더라도groupingBy가 null 키를 허용하지 않는 경로가 있음)- downstream collector 내부가 null에 취약 (예:
toMapmerge 함수,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 객체가 섞인 경우입니다. 이때는 classifier 가 Order::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가지 패턴 중 하나로 팀의 규칙을 정해두면, 같은 유형의 장애가 반복되는 걸 크게 줄일 수 있습니다.