자바 ConcurrentModificationException 원인과 해결 — 리스트 반복 중 삭제
ConcurrentModificationException은 컬렉션을 for-each로 돌면서 그 안에서 add()·remove()로 원소를 바꿀 때 납니다. 이름에 "Concurrent(동시)"가 붙어 멀티스레드 문제 같지만, 스레드 하나에서도 순회 중에 리스트를 고치면 바로 발생합니다. 해결은 간단합니다: 반복 중 삭제는 Iterator.remove()나 removeIf()를 쓰세요. 아래에서 원인과 세 가지 해결법, 그리고 예외조차 안 나면서 원소를 건너뛰는 더 위험한 함정까지 실제 실행 결과로 정리합니다. (Java 21 기준 확인)
1. 언제 터지나 — 재현 코드
가장 흔한 패턴입니다. 리스트를 for (String s : list)로 돌면서 조건에 맞는 원소를 list.remove(s)로 지우는 코드입니다.
List<String> list = new ArrayList<>(List.of("a", "b", "c", "d"));
for (String s : list) {
if (s.equals("b")) {
list.remove(s); // 순회 중에 원본 리스트를 직접 수정
}
}
실행하면 다음 예외가 던져집니다. 메시지가 null이라 원인이 안 보여 더 당황스럽습니다.
Exception in thread "main" java.util.ConcurrentModificationException
at java.base/java.util.ArrayList$Itr.checkForComodification(ArrayList.java:1013)
at java.base/java.util.ArrayList$Itr.next(ArrayList.java:967)
2. 왜 나나 — fail-fast와 modCount
자바의 for-each는 내부적으로 Iterator로 바뀝니다. ArrayList의 이터레이터는 만들어질 때 리스트의 수정 횟수 modCount를 expectedModCount에 복사해 둡니다. 그런데 list.remove()를 직접 부르면 modCount만 올라가고 이터레이터의 expectedModCount는 그대로입니다. 다음 next()가 두 값이 다른 걸 확인하는 순간 예외를 던집니다.
이렇게 이상을 감지하면 곧바로 실패시키는 동작을 fail-fast라고 합니다. Oracle 공식 문서도 "한 스레드가 fail-fast 이터레이터로 순회하는 도중 컬렉션을 직접 수정하면 이터레이터가 이 예외를 던진다"고 못 박습니다. 즉 멀티스레드가 아니어도 납니다.
3. 더 위험한 함정 — 예외도 안 나고 원소를 건너뛴다
모든 위치에서 예외가 나는 게 아닙니다. 끝에서 두 번째 원소를 지우면 예외 없이 조용히 통과하는데, 대신 마지막 원소를 순회에서 건너뜁니다. 이게 예외보다 더 찾기 어렵습니다.
List<String> list = new ArrayList<>(List.of("a", "b", "c", "d"));
List<String> visited = new ArrayList<>();
for (String s : list) {
visited.add(s);
if (s.equals("c")) { // c = 끝에서 두 번째
list.remove(s);
}
}
// 실행 결과:
// visited = [a, b, c] ← d 를 아예 안 봤다
// list = [a, b, d]
이유는 ArrayList 이터레이터의 hasNext()가 커서 != 크기로만 판단하기 때문입니다. c를 지우면 크기가 4→3이 되고, c를 막 지난 커서도 3이라 hasNext()가 false가 됩니다. 루프가 next()를 다시 부르지 않고 끝나서 modCount 검사 자체가 실행되지 않는 겁니다. 예외가 안 나니 버그가 운영까지 흘러갑니다.
4. 해결 방법 3가지
① Iterator.remove() — 순회하며 안전하게 삭제
이터레이터를 직접 잡고 그 remove()로 지우면 expectedModCount도 함께 갱신돼 예외가 나지 않습니다.
List<String> list = new ArrayList<>(List.of("a", "b", "c", "d"));
Iterator<String> it = list.iterator();
while (it.hasNext()) {
if (it.next().equals("b")) {
it.remove(); // list.remove(x) 가 아니라 이터레이터의 remove()
}
}
// 결과: [a, c, d]
② removeIf() — Java 8 이상, 한 줄로 끝
조건 삭제라면 이게 가장 짧고 안전합니다. 내부에서 이터레이터를 올바르게 다뤄 줍니다.
List<String> list = new ArrayList<>(List.of("a", "b", "c", "d"));
list.removeIf(s -> s.equals("b"));
// 결과: [a, c, d]
③ 새 리스트로 걸러 담기
원본을 건드리지 않고 남길 것만 새 컬렉션에 모으는 방법입니다. 스트림으로도 되고(list.stream().filter(...).toList()), 원본을 유지해야 할 때 좋습니다.
| 방법 | 언제 쓰나 |
|---|---|
Iterator.remove() |
순회하며 조건 삭제 + 삭제 중 다른 작업도 해야 할 때 |
removeIf() |
조건만으로 삭제 (Java 8+) — 가장 간단 |
| 새 리스트로 담기 / stream filter | 원본을 그대로 두고 걸러진 결과만 필요할 때 |
CopyOnWriteArrayList |
실제 멀티스레드 환경 (읽기 많고 쓰기 드묾) |
CopyOnWriteArrayList는 순회 중 수정이 허용되지만, 이터레이터가 만들어진 시점의 스냅샷을 봅니다. 순회 도중 지운 원소도 그 루프에서는 계속 보입니다(변경은 원본에만 반영). 단일 스레드에서 CME만 피하려고 쓰는 자료구조가 아니라, 쓰기 비용이 큰 동시성 자료구조입니다.
5. HashMap·HashSet에서도 똑같이 난다
리스트만의 문제가 아닙니다. Map의 keySet()·entrySet()을 돌면서 put()·remove()로 맵을 고쳐도 같은 예외가 납니다.
Map<String, Integer> m = new HashMap<>(Map.of("x", 1, "y", 2, "z", 3));
for (String k : m.keySet()) {
if (k.equals("x")) {
m.put("w", 9); // 순회 중 맵 크기 변경 → ConcurrentModificationException
}
}
맵에서 순회 중 삭제가 필요하면 m.entrySet().iterator()의 remove()를 쓰거나, 값 기준 삭제는 m.values().removeIf(...), 키 기준은 m.keySet().removeIf(...)를 씁니다. 참고로 값을 바꾸기만 하는 map.put(기존키, 새값)은 크기가 안 변해 예외가 나지 않습니다. Map을 다룰 때 자주 헷갈리는 computeIfAbsent와 getOrDefault 차이도 함께 보면 도움이 됩니다.
자주 묻는 질문 (FAQ)
Q. 싱글 스레드인데 왜 "Concurrent" 예외가 나죠?
이름이 오해를 부르지만, 여기서 "concurrent modification"은 "순회와 수정이 동시에 일어났다"는 뜻입니다. 스레드 개수와 무관하게, 이터레이터가 도는 중에 컬렉션을 직접 고치면 납니다.
Q. 향상된 for문(for-each) 말고 인덱스 for문으로 돌면 되나요?
for (int i = 0; i < list.size(); i++)로 돌면 CME는 안 나지만, 지우는 순간 뒤 원소들의 인덱스가 당겨져 바로 다음 원소를 건너뛰는 새 버그가 생깁니다(3장의 함정과 같은 부류). removeIf()를 쓰는 게 안전합니다.
Q. List.of()로 만든 리스트는 removeIf()도 안 되던데요?
List.of()·Arrays.asList()가 준 리스트는 불변이라 삭제 자체가 UnsupportedOperationException으로 막힙니다. 이건 CME와 다른 예외입니다 — List add UnsupportedOperationException 원인과 해결에서 따로 다뤘습니다. 수정하려면 new ArrayList<>(...)로 감싸세요.
마무리
정리하면, ConcurrentModificationException은 순회 중 컬렉션 직접 수정 때문이고, fail-fast로 즉시 던져집니다. 반복하며 지울 땐 removeIf()(가장 간단) 또는 Iterator.remove()를 쓰세요. 예외가 안 났다고 안심하지 말 것 — 끝에서 두 번째 삭제처럼 조용히 원소를 건너뛰는 경우가 더 위험합니다. 예외의 이름·원인 패턴은 자바 ClassCastException 원인과 해결처럼 하나씩 정리해 두면 디버깅이 빨라집니다.
📚 참고 출처 (2026년 7월 23일 확인 · 코드는 Java 21.0.1로 직접 실행)
· Oracle Java SE 17 API — ConcurrentModificationException
· Oracle Java SE 17 API — Collection.removeIf

COMMENTS