목록을 Map으로 바꾸는 코드가 운영 데이터에서만 IllegalStateException으로 죽은 이유
문제 발생
이메일로 사용자를 찾는 조회용 Map을 만들었습니다.
Map<String, User> byEmail = users.stream()
.collect(Collectors.toMap(User::email, Function.identity()));테스트에서는 잘 돌았는데 운영 데이터에서 터졌습니다.
java.lang.IllegalStateException: Duplicate key [email protected]원인 분석
중복 키를 만났을 때의 기본 동작입니다. javadoc이 그대로 적어둡니다 — 매핑된 키에 (Object.equals(Object) 기준) 중복이 있으면 수집 연산이 수행될 때 IllegalStateException이 던져집니다.
조용히 덮어쓰지 않는 게 핵심입니다. 스트림에는 순서 보장이 없는 경우도 있고 병렬 실행도 가능하므로, "나중 것이 이긴다"를 기본값으로 삼으면 실행할 때마다 결과가 달라질 수 있습니다. 그래서 라이브러리는 규칙을 정하지 않은 채로는 진행하지 않습니다.
javadoc이 제시하는 해법도 명시적입니다 — 중복이 있을 수 있으면 toMap(Function, Function, BinaryOperator)를 대신 쓰라는 것입니다.
여기에 잘 알려지지 않은 함정이 하나 더 있습니다. toMap은 내부적으로 HashMap.merge를 쓰기 때문에 매핑된 값이 null이면 NullPointerException이 납니다. groupingBy와 달리 널 값을 담을 수 없습니다.
해결 방안
- 어느 값을 남길지 코드로 정합니다.
Map<String, User> byEmail = users.stream()
.collect(Collectors.toMap(
User::email,
Function.identity(),
(existing, replacement) -> existing)); // 먼저 온 것을 유지-
중복이 "있으면 안 되는" 데이터라면 예외를 살립니다. 조용히 하나를 버리면 데이터 정합성 문제가 조회 화면의 이상한 값으로 몇 주 뒤에 드러납니다. 병합 함수에서 의미 있는 메시지와 함께 던지는 편이 낫습니다.
-
키 하나에 여러 값이 정상이면
groupingBy가 맞습니다. 애초에 1:1 매핑이 아닙니다.
Map<String, List<User>> byDomain = users.stream()
.collect(Collectors.groupingBy(User::domain));-
null값이 가능하면 먼저 거릅니다.filter(u -> u.nickname() != null)로 걸러내거나, 값 자체를Optional이 아닌 기본값으로 정규화합니다. -
Map 구현이 필요하면 네 번째 인자를 씁니다. javadoc이 명시하듯 반환되는
Map의 타입·가변성·직렬화 가능 여부·스레드 안전성은 보장되지 않습니다. 순서가 필요하면LinkedHashMap::new를 명시적으로 넘깁니다.
댓글0
댓글을 남기려면 로그인이 필요해요. 로그인
아직 댓글이 없어요. 첫 의견을 편하게 남겨 보세요.