빌드노트
HashMap 순회 중 map.remove를 호출하면 왜 다음 반복에서 예외가 날까
enhanced for 안의 map.remove는 반복자 밖에서 Map 구조를 바꾼다. 세 엔트리의 부분 삭제와 예외 시점을 비교하고, 조건부 삭제에는 entrySet().removeIf 또는 같은 반복자의 remove를 선택한다. 단일 스레드 재현과 fail-fast의 한계도 구분한다.
HashMap의 entrySet()을 enhanced for로 돌면서 map.remove(key)를 호출하면, 삭제는 됐는데 다음 반복에서 ConcurrentModificationException이 날 수 있다. 먼저 같은 Map을 순회하는 반복문 안에서 직접 삭제하는 곳을 찾는다. 조건에 맞는 엔트리만 지우면 entrySet().removeIf(...), 순회 중 다른 작업도 해야 하면 그 순회에 쓰는 Iterator.remove()를 선택한다.
문제는 반복자가 만들어진 뒤 그 반복자를 거치지 않고 엔트리를 없앤 데 있다. HashMap 문서 (새 탭에서 열림)는 매핑의 추가·삭제를 구조 변경으로 구분한다. 이미 있는 키의 값만 바꾸는 것은 구조 변경에 해당하지 않는다. 삭제 메서드의 이름보다, 순회와 삭제가 같은 반복자를 통해 이뤄지는지가 중요하다.
삭제 뒤의 출력까지 나왔는데 반복문이 멈춘다
아래는 실제 업무 데이터 대신 세 엔트리를 넣어 비교한 예다. 실행 환경은 Windows의 OpenJDK 17.0.2다. JDK가 설치되어 있고 jshell --version으로 버전을 확인할 수 있다면, 터미널에서 jshell을 열고 Java 입력 블록을 순서대로 붙여 넣을 수 있다.
출력 순서를 읽기 위해 기본 생성자로 만든 LinkedHashMap을 쓴다. 이 생성자는 삽입 순서를 유지 (새 탭에서 열림)하므로 a, b, c 순서로 방문한다. HashMap에는 이 순서 보장이 없다. LinkedHashMap으로 바꾸는 것 자체가 삭제 문제를 해결하지는 않는다.
먼저 값이 0인 a와 c를 지우려 할 때, 어디까지 실행되는지 확인한다. fresh()는 비교할 때마다 같은 세 엔트리를 가진 새 Map을 만드는 작은 준비 함수다.
import java.util.*;
LinkedHashMap<String, Integer> fresh() {
var m = new LinkedHashMap<String, Integer>();
m.put("a", 0); m.put("b", 2); m.put("c", 0);
return m;
}
var map = fresh();
for (var e : map.entrySet()) {
System.out.println("visit=" + e.getKey());
if (e.getValue() == 0) {
map.remove(e.getKey());
System.out.println("removed=" + e.getKey());
}
}
System.out.println(map);
JShell이 표시한 핵심 출력은 다음과 같다. 선언·대입 확인 메시지와 예외의 스택 추적은 생략했다.
visit=a
removed=a
Exception java.util.ConcurrentModificationException
{b=2, c=0}
removed=a가 찍혔으므로 map.remove와 그 다음 출력은 끝났다. 예외가 삭제를 되돌려 주지도 않았다. a는 사라졌고, 지우려던 또 다른 엔트리 c는 남았다. visit=b는 나오지 않았다.
Java 언어 명세의 enhanced for 설명 (새 탭에서 열림)에 따르면 Iterable을 대상으로 한 이 반복문은 반복자의 hasNext()와 next()를 사용하는 형태로 동작한다. 이번 스택 추적에는 LinkedHashMap$LinkedHashIterator.nextNode와 LinkedEntryIterator.next가 있었다. 별도로 호출을 나눠 확인했을 때도 삭제 뒤 hasNext()는 true였고, 다음 next()에서 예외가 났다. 반복문 머리에서 오류가 보여도 그 전에 실행한 직접 삭제를 확인해야 하는 이유다.
이 재현은 컬렉션에 접근하는 스레드 하나에서 실행했다. 예외 문서 (새 탭에서 열림)도 단일 스레드의 잘못된 호출 순서로 이 예외가 발생할 수 있다고 설명한다. 예외 이름만으로 다른 스레드가 Map을 수정했다고 판단할 수 없다.
삭제 조건만 있다면 removeIf에 맡긴다
값이 0인 엔트리를 모두 제거하는 목적이라면 반복문 전체를 removeIf로 바꾼다. 다음 입력은 앞의 JShell에서 실행하며, 실패 후 남은 Map을 이어 쓰지 않도록 fresh()로 다시 시작한다.
map = fresh();
System.out.println(map.entrySet().removeIf(e -> e.getValue() == 0));
System.out.println(map);
실제 출력은 true, 이어서 {b=2}였다. true는 하나 이상 제거했다는 뜻이다. a와 c가 모두 없어졌고 b의 값은 2로 유지됐다.
entrySet()은 원래 Map에 연결된 뷰이므로 그 뷰에서의 삭제가 Map에도 반영된다. Collection.removeIf 문서 (새 탭에서 열림)는 기본 구현이 반복자로 순회하고, 조건을 만족한 요소를 그 반복자의 remove()로 지우도록 정한다. 람다 안에는 삭제할지 결정하는 조건을 쓴다. 그 안에서 다시 map.remove를 호출하면 반복자 밖에서 Map을 바꾸는 문제가 되풀이된다.
실제 값에 null이 들어갈 수 있다면 조건도 그에 맞춰야 한다. 여기서 쓴 e.getValue() == 0은 값이 null이 아닌 정수라는 전제다. 이 비교의 입력에는 null을 넣지 않았다.
순회 작업을 유지해야 하면 Iterator.remove를 쓴다
각 엔트리를 읽으며 여러 분기를 처리하는 반복문을 유지하려면 반복자를 직접 잡는다. 다음 입력으로 같은 삭제 조건을 반복자의 제거 연산으로 옮겨 확인할 수 있다.
map = fresh();
var it = map.entrySet().iterator();
while (it.hasNext()) {
var e = it.next();
if (e.getValue() == 0) it.remove();
}
System.out.println(map);
이번에도 출력은 {b=2}였다. 별도 방문 기록에서도 세 엔트리를 모두 읽고 a와 c를 제거했다. it.remove()는 키를 인자로 받지 않는다. Iterator.remove 문서 (새 탭에서 열림)에 따라 바로 앞의 next()가 반환한 요소를 제거하며, 한 번의 next()에 대해 한 번만 호출할 수 있다. 이 Map의 반복자에서 next() 전이나 같은 요소에 두 번 호출한 대조는 IllegalStateException이었다.
기존 코드에 적용할 때는 it.next()로 얻은 e를 기준으로 조건을 검사하고 그 it으로 삭제한다. 반복문에서 부르는 다른 메서드가 같은 Map에 엔트리를 추가하거나 직접 지우는지도 확인한다. 삭제 한 줄만 고쳐도 다른 구조 변경이 남아 있으면 원인은 사라지지 않는다.
예외가 안 난 입력도 안전의 증거가 되지는 않는다
같은 JDK에서 기본 생성자의 LinkedHashMap과 HashMap을 각각 새로 만들어 비교했다. 둘 다 직접 삭제에서는 일부만 지운 뒤 예외가 났고, 반복자 삭제와 removeIf에서는 {b=2}가 남았다. 다만 HashMap의 방문 순서를 이 출력으로 일반화할 수는 없다.
추가로 LinkedHashMap의 마지막 엔트리 c만 map.remove로 지우게 하자, 그 실행은 예외 없이 끝나고 {a=0, b=2}가 남았다. 마지막 항목 뒤에는 다음 요소를 읽는 next()가 호출되지 않는다. 처음 예제에서 관찰한 “다음 next()의 예외”를 모든 입력에서 반드시 발생하는 규칙으로 바꿀 수 없는 사례다. HashMap의 fail-fast 설명 (새 탭에서 열림)은 이를 최선의 노력으로 버그를 탐지하는 동작으로 설명하며, 프로그램의 정확성을 그 예외 발생에 의존시키지 말라고 명시한다.
이 글에서 실행한 범위는 OpenJDK 17.0.2의 작은 합성 Map과 단일 스레드 순회다. Java 21 문서에서도 관련 계약을 대조했지만 21 런타임과 여러 스레드의 동시 접근은 실행하지 않았다. 자기 코드에서 출력이나 예외 지점이 달라도, 반복자가 살아 있는 동안 누가 매핑을 추가·삭제하는지 확인하고 삭제 경로를 정하는 기준은 같다.