본문으로 건너뛰기
개발 머꼬
개발 노트Java
hohyeon.dev14

목록을 Map으로 바꾸는 코드가 운영 데이터에서만 IllegalStateException으로 죽은 이유

  • #Common Pitfall
  • #Engineering Note
  • #Java

문제 발생

이메일로 사용자를 찾는 조회용 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와 달리 널 값을 담을 수 없습니다.

해결 방안

  1. 어느 값을 남길지 코드로 정합니다.
Map<String, User> byEmail = users.stream()
    .collect(Collectors.toMap(
        User::email,
        Function.identity(),
        (existing, replacement) -> existing));   // 먼저 온 것을 유지
  1. 중복이 "있으면 안 되는" 데이터라면 예외를 살립니다. 조용히 하나를 버리면 데이터 정합성 문제가 조회 화면의 이상한 값으로 몇 주 뒤에 드러납니다. 병합 함수에서 의미 있는 메시지와 함께 던지는 편이 낫습니다.

  2. 키 하나에 여러 값이 정상이면 groupingBy가 맞습니다. 애초에 1:1 매핑이 아닙니다.

Map<String, List<User>> byDomain = users.stream()
    .collect(Collectors.groupingBy(User::domain));
  1. null 값이 가능하면 먼저 거릅니다. filter(u -> u.nickname() != null)로 걸러내거나, 값 자체를 Optional이 아닌 기본값으로 정규화합니다.

  2. Map 구현이 필요하면 네 번째 인자를 씁니다. javadoc이 명시하듯 반환되는 Map타입·가변성·직렬화 가능 여부·스레드 안전성은 보장되지 않습니다. 순서가 필요하면 LinkedHashMap::new를 명시적으로 넘깁니다.

공식 문서

마지막 수정

좋아요북마크

댓글0

아직 댓글이 없어요. 첫 의견을 편하게 남겨 보세요.