CATEGORY

카테고리 (607)
AI (12)
Language & Specs (259)
FrameWork (34)
Library (19)
App (41)
Git (8)
Build & Dependency (2)
AWS (15)
DataBase (45)
OS (33)
Tool (16)
IT (120)
반응형
SEEMINGLY ONLINE

Seemingly
Online

이모저모 방방곡곡 두루두루 개발지식 저장소

RECENT POSTS

Language & Specs/Java

자바 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의 이터레이터는 만들어질 때 리스트의 수정 횟수 modCountexpectedModCount에 복사해 둡니다. 그런데 list.remove()를 직접 부르면 modCount만 올라가고 이터레이터의 expectedModCount는 그대로입니다. 다음 next()가 두 값이 다른 걸 확인하는 순간 예외를 던집니다.

이렇게 이상을 감지하면 곧바로 실패시키는 동작을 fail-fast라고 합니다. Oracle 공식 문서도 "한 스레드가 fail-fast 이터레이터로 순회하는 도중 컬렉션을 직접 수정하면 이터레이터가 이 예외를 던진다"고 못 박습니다. 즉 멀티스레드가 아니어도 납니다.

💡 공식 문서는 fail-fast가 "보장(guarantee)이 아니라 최선의 노력(best-effort)"이라고도 적습니다. 예외가 안 났다고 그 코드가 안전한 게 아닙니다 — 바로 다음 함정이 그 증거입니다.

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에서도 똑같이 난다

리스트만의 문제가 아닙니다. MapkeySet()·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