본문 바로가기
빌드노트 검색

빌드노트

HashMap은 null 키를 받는데 groupingBy는 NPE가 난다

HashMap이 null 키를 받아도 groupingBy의 분류 함수가 null을 반환하면 NPE가 날 수 있다. OpenJDK 17.0.2에서 Map 공급자를 바꾼 결과를 비교하고, Optional 그룹은 3행을 보존하며 명시 필터는 1행을 제외하는 차이를 확인한다.

팀 값이 없는 행을 Collectors.groupingBy(Row::team)으로 묶다가 NullPointerException이 나면, 먼저 Row::team이 반환하는 값을 확인한다. 분류 함수가 null을 반환하면, 이번에 확인한 OpenJDK 17.0.2의 groupingBy는 Map에 넣기 전에 거절한다. 세 인자 오버로드에 HashMap::new를 넘겨도 같은 검사를 거친다. 팀이 없는 행까지 별도 그룹으로 남길지, 집계 대상에서 제외할지 정해야 수집 코드를 고를 수 있다.

실제 코드의 분류 함수가 r -> normalize(r.team())라면 원본 필드만 보지 말고 normalize의 최종 반환값을 확인한다. 원본에 값이 있어도 변환 뒤 null이 될 수 있기 때문이다. 아래에서는 변환 없이 팀 필드만 읽어 원인을 좁힌다.

같은 세 행을 put과 groupingBy에 넘기면

준비물은 JDK 17에 포함된 JShell (새 탭에서 열림)이다. 터미널에서 java -version으로 버전을 확인하고 jshell --execution local로 시작한다. jshell을 찾지 못하면 JDK의 bin이 실행 경로에 있는지 확인한다. 다음 입력은 팀이 없는 행의 ID와 HashMap에 직접 넣은 결과를 비교한다.

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

record Row(int id, String team) {}
var rows = List.of(new Row(1, "dev"), new Row(2, null), new Row(3, "미분류"));
System.out.println(rows.stream().filter(r -> r.team() == null).map(Row::id).toList());

var direct = new HashMap<String, Row>();
rows.forEach(r -> direct.put(r.team(), r));
System.out.println("null key=" + direct.containsKey(null) + ", rows=" + direct.size());

Windows의 OpenJDK 17.0.2, 런타임 빌드 17.0.2+8-86에서 출력은 다음과 같았다. 이후 출력에서도 JShell의 변수 선언 안내와 프롬프트는 생략한다.

[2]
null key=true, rows=3

ID 2의 팀이 null이고, 직접 만든 HashMap에는 null 키를 포함한 세 항목이 들어갔다. HashMap의 Java 17 문서 (새 탭에서 열림)는 null 키와 null 값을 허용한다고 명시한다. 이 입력은 세 키가 서로 달라 세 행이 남은 것이며, put이 같은 팀의 여러 행을 그룹으로 모아 주는 것은 아니다.

같은 rows를 유지하고 다음 두 줄을 차례로 실행해, Map 공급자를 지정하면 결과가 바뀌는지 확인한다.

rows.stream().collect(Collectors.groupingBy(Row::team));
rows.stream().collect(Collectors.groupingBy(Row::team, HashMap::new, Collectors.toList()));

두 호출 모두 아래 예외를 냈다. 첫 호출이 실패해도 JShell 세션은 유지되므로 다음 줄을 실행할 수 있다. 스택 트레이스의 나머지 부분은 생략했다.

java.lang.NullPointerException: element cannot be mapped to a null key

실행 버전과 대응하는 OpenJDK 17.0.2 소스 (새 탭에서 열림)를 보면 세 인자 groupingBy는 classifier.apply(t) 결과를 Objects.requireNonNull로 검사한 뒤에야 Map.computeIfAbsent를 호출한다. 기본 호출도 이 경로로 이어지고, 이 버전은 기본 공급자로 이미 HashMap을 사용한다. HashMap::new를 명시하는 것으로는 분류값 검사를 바꿀 수 없다.

Java 17의 groupingBy 문서 (새 탭에서 열림)는 분류 함수에 따른 그룹과 반환 구조를 설명하며, 기본 반환 Map의 구현 종류를 보장하지 않는다. 공급자를 받는 오버로드 (새 탭에서 열림)는 공급자로 Map을 만든다고 설명한다. 이 메서드 설명들에는 분류 함수의 null 반환 처리나 위 예외 문구가 명시돼 있지 않다. 따라서 거절 위치와 메시지는 여기서 확인한 구현·실행 결과로 구분한다.

입력의 세 Row 객체 자체는 모두 존재한다. null인 것은 ID 2에서 꺼낸 팀 값이므로 filter(Objects::nonNull)로 행 객체만 검사해도 이 원인은 제거되지 않는다. 별도 대조에서 ID 2의 팀을 "ops"로 바꾸자 두 오버로드 모두 세 행을 수집했다. 자기 코드에서 다른 위치의 NPE가 난다면 분류 함수 내부나 후속 수집 코드가 먼저 실패한 것은 아닌지 스택을 확인한다.

팀이 없는 행도 남기려면 키에 부재를 표현한다

팀을 아직 배정하지 않은 행도 집계에 필요하다면, 분류 함수가 null 대신 Optional.empty()를 반환하게 만들 수 있다. 같은 세션에서 다음 입력으로 누락 그룹과 실제 팀 이름 "미분류"가 분리되고 총 세 행이 남는지 확인한다.

Map<Optional<String>, List<Row>> groups = rows.stream().collect(
    Collectors.groupingBy(r -> Optional.ofNullable(r.team())));
System.out.println(groups.get(Optional.empty()).stream().map(Row::id).toList());
System.out.println(groups.get(Optional.of("미분류")).stream().map(Row::id).toList());
System.out.println("rows=" + groups.values().stream().mapToLong(List::size).sum());
[2]
[3]
rows=3

Optional.ofNullable 문서 (새 탭에서 열림)에 따라 null은 빈 Optional로, 값이 있는 팀은 그 값을 담은 Optional로 바뀐다. 분류 함수가 반환하는 Optional 객체 자체는 null이 아니다. Optional의 동등성 규칙 (새 탭에서 열림)에 따라 빈 Optional끼리는 같은 키가 되지만, 문자열이 들어 있는 Optional과는 다르다.

이 구분 때문에 null을 임의 문자열로 바꾸는 것보다 의도가 분명하다. 같은 입력에서 분류 함수를 r -> r.team() == null ? "미분류" : r.team()으로 바꿔 실행하자, "미분류" 그룹에는 ID 2와 3이 함께 들어갔다. 누락 표시와 실제 팀 이름이 충돌한 것이다. Optional 방식에서는 ID 2가 부재 그룹에, ID 3은 이름이 있는 그룹에 각각 남았다.

이 방식을 적용하면 Map의 키 타입도 String에서 Optional<String>으로 바뀐다. 사용하는 쪽의 조회 역시 위 코드처럼 바꿔야 한다. 화면에서 부재를 어떤 문구로 보여 줄지는 수집이 끝난 뒤 정할 수 있지만, 내부 키를 다시 같은 문자열로 합치면 구분을 잃는다.

팀 없는 행을 제외한다면 빠진 수를 함께 확인한다

팀이 배정된 행만 집계한다는 요구가 확실하면 수집 전에 분류값을 검사한다. 같은 rows에서 제외할 행 수를 먼저 세고, 나머지를 묶어 입력과 결과의 차이를 확인한다.

long excluded = rows.stream().filter(r -> r.team() == null).count();
var filtered = rows.stream().filter(r -> r.team() != null).collect(
    Collectors.groupingBy(Row::team));
long kept = filtered.values().stream().mapToLong(List::size).sum();
System.out.println("rows=" + kept + ", excluded=" + excluded);
rows=2, excluded=1

예외는 사라졌지만 ID 2는 결과에 없다. 결과 행 수 2와 제외 행 수 1을 더해야 원래 입력 3행이 된다. filtered.size()는 그룹 수이므로, 행 보존 여부를 볼 때는 위처럼 각 그룹 목록의 크기를 더한다. 이 예제의 Row는 값이 변하지 않는 record이며 두 스트림에서 같은 팀 필드를 읽는다. 실제 분류에 변환 함수를 쓴다면 필터와 그룹화가 같은 최종 분류값을 검사하도록 맞춰야 한다.

누락이 잘못된 입력이라면 필터로 조용히 없애는 대신 해당 행의 입력 검증을 고쳐야 한다. 정상적인 누락이라면 별도 그룹으로 남길지, 제외 수를 드러내고 집계에서 뺄지가 기준이다. HashMap으로 바꾸는 것만으로는 이 정책이 정해지지 않는다.

확인을 마치면 JShell에 /exit를 입력한다. 이번 실행은 OpenJDK 17.0.2의 합성 세 행과 순차 stream()을 대상으로 했다. 다른 JDK 버전의 동작과 메시지, 병렬 스트림, Optional 키를 가진 Map의 JSON 직렬화는 실행하지 않았다. Map 출력 순서도 판단 근거로 사용하지 않았다.

← 전체 글 보기