빌드노트
HashMap을 Map.of로 바꾼 뒤 null 입력에서 NPE가 난다
응답용 HashMap을 Map.of로 바꾸면 null 키·값이 생성 시점의 NPE로 이어진다. 실제 Java·JShell 비교로 Map.copyOf와 중복 키 실패를 구분하고, 항목 생략과 명시 null 보존을 API 의미에 맞게 선택한다.
응답용 HashMap을 Map.of("nickname", nickname)으로 줄인 뒤, 선택 입력인 별명이 null일 때 NullPointerException이 난다면 생성 호출의 인자를 확인한다. Map.of는 null 키와 null 값을 허용하지 않는다. Map을 반환받기 전에 실패하므로, 그 뒤의 조회에 getOrDefault를 붙여도 이 예외를 막을 수 없다.
Java 21의 Map 문서 (새 탭에서 열림)는 이 팩토리의 null 거절 계약을 명시한다. 반면 HashMap 문서 (새 탭에서 열림)는 null 키와 null 값을 허용한다. 짧게 쓰는 과정에서 입력으로 받을 수 있는 값의 범위도 바뀐 것이다. 모든 Map이 null을 금지하는 것은 아니다.
자기 코드에서는 스택 트레이스에 나온 Map.of 호출 줄을 찾아 각 키와 값이 null인지 확인한다. 인자가 메서드 호출이면 그 메서드 안에서 먼저 예외가 났는지도 구분한다. 아래 비교는 이미 계산된 문자열 변수만 전달해 팩토리 자체의 동작을 확인한 것이다.
저장할 수 있던 값을 팩토리에 넘겨 보면
준비물은 JDK 17과 그 안의 JShell (새 탭에서 열림)이다. 터미널에서 jshell --execution local로 시작하고 다음 입력을 순서대로 넣는다. HashMap의 저장 성공, 정상 값의 생성 성공, null이 포함된 생성 실패를 비교할 수 있다.
import java.util.*;
String nickname = null;
var legacy = new HashMap<String, String>();
legacy.put("nickname", nickname);
legacy.containsKey("nickname");
Map.of("nickname", "Mina");
Map.of("nickname", nickname);
Map.copyOf(legacy);
Map.of((String) null, "Mina");
Map.of("nickname", "Mina", "nickname", "Jin");
Windows의 OpenJDK 17.0.2에서 legacy.containsKey("nickname")은 true, 정상 값을 넣은 Map.of는 {nickname=Mina}를 반환했다. 이어지는 세 호출은 모두 java.lang.NullPointerException을 던졌다. 각각 null 값으로 생성한 Map.of, null 값이 든 Map을 복사한 Map.copyOf, null 키로 생성한 Map.of다.
Map.copyOf(legacy)에 전달한 Map 객체 자체는 존재한다. 그 안의 값이 null이어서 복사가 실패했다. Map.copyOf 문서 (새 탭에서 열림)도 원본 Map뿐 아니라 그 안의 키와 값에도 null이 없어야 한다고 정한다. 별도 합성 입력 비교에서는 정상 값만 담은 HashMap의 복사는 성공했고, null 키를 담은 HashMap의 복사는 NPE로 끝났다. HashMap.put은 정상 값·null 키·null 값을 각각 저장했다.
이 실패는 생성 시점에 발생한다. 별도 Java 예제에서 팩토리 호출 다음 줄에 도달했는지 표시해 보니, null 값을 넘긴 Map.of 뒤의 조회 코드는 실행되지 않았다. Map.copyOf의 스택에도 복사 호출이 남았다. 내부 구현 클래스 이름이나 줄 번호보다 자신의 생성·복사 호출 위치와 실제 인자를 확인하는 편이 좋다.
마지막 줄은 null 없이 키만 두 번 넣은 대조다. 실제 예외는 java.lang.IllegalArgumentException: duplicate key: nickname이었다. 같은 키에 다시 put한 HashMap은 새 값 "Jin"으로 교체됐지만, Map.of는 중복 키를 거절했다. null과 중복을 함께 넣으면 원인을 분리하기 어려우므로 입력을 따로 비교한다. 이번 비교로 두 조건이 동시에 있을 때 어떤 예외가 먼저 나오는지는 판단하지 않았다.
별명이 없을 때 항목 자체를 빼도 되는가
API 계약이 “입력하지 않은 별명은 응답 Map에 넣지 않는다”라면 null일 때 해당 항목을 추가하지 않는다. 앞의 예외가 난 뒤에도 같은 JShell 세션에서 다음 입력을 실행해 키가 빠졌는지 확인할 수 있다.
var fields = new HashMap<String, String>();
if (nickname != null) fields.put("nickname", nickname);
Map<String, String> response = Map.copyOf(fields);
response.containsKey("nickname");
nickname이 null인 실행에서 response는 빈 Map이었고 마지막 결과는 false였다. 별도 비교에서 입력을 "Mina"로 바꾸면 키가 존재하고 값도 유지됐다. 다른 항목을 추가한다면 그 키와 값에도 null이 없어야 Map.copyOf가 성공한다.
이 코드는 별명 항목의 생략을 선택한 것이다. 호출자가 키의 부재와 명시적인 null을 구분해야 한다면 이 방식으로 합치면 안 된다. NPE를 피하려고 null을 빈 문자열이나 문자열 "null"로 바꾸는 것 역시 응답의 의미를 바꾸는 선택이다.
키를 남기고 null 값을 보존해야 한다면
값이 없다는 표시로 키와 Java의 null 값을 함께 남겨야 하고 변경 가능한 Map을 반환해도 된다면, null을 허용하는 HashMap을 유지할 수 있다. 같은 세션에서 다음 입력으로 키의 존재와 저장된 값을 확인하고, 이후 값 변경도 비교한다.
var responseWithNull = new HashMap<String, String>();
responseWithNull.put("nickname", nickname);
responseWithNull.containsKey("nickname");
responseWithNull.get("nickname");
responseWithNull.put("nickname", "Mina");
responseWithNull.get("nickname");
조회 결과는 차례로 true, null이었고, 마지막 조회는 "Mina"였다. 여기서 Map.copyOf(responseWithNull)을 null 값이 남아 있는 시점에 적용하면 다시 같은 NPE가 발생한다.
변경 가능성도 함께 정해야 한다. Map.of와 Map.copyOf가 반환하는 Map은 항목을 추가·삭제·교체할 수 없다. Java 17의 Map 문서 (새 탭에서 열림)에도 이 계약이 있으며, 정상 값으로 생성한 두 Map에 put을 시도한 별도 실행은 모두 UnsupportedOperationException으로 끝났다. 생성 뒤 값을 채워야 한다면 HashMap 같은 변경 가능한 Map이 필요하다. 반환 후 변경을 금지해야 하는 API라면 HashMap을 그대로 공개해도 되는지까지 결정해야 한다.
확인을 마치면 JShell에 /exit를 입력한다. jshell을 찾지 못하면 JDK 설치와 bin 경로부터 확인한다. 코드가 컴파일되지 않으면 import와 변수 선언을 같은 세션에서 실행했는지 본다. null을 제거해도 실패한다면 예외 종류와 중복 키, 생성 이후의 변경 시도를 다시 구분한다.
새 Java·JShell 프로세스에서 다시 확인한 범위는 Windows의 OpenJDK 17.0.2에서 합성 문자열로 만든 Map이다. Java 21의 계약은 문서로 대조했으며 JDK 21 런타임은 실행하지 않았다. 실제 HTTP 응답이나 JSON 직렬화는 검증하지 않았다. Java Map에 null 항목을 남기는 것과 JSON에 그 항목이 표시되는 것은 직렬화 설정까지 확인해야 구분할 수 있다.