- 에러 로그
아래 코드는 ArrayList를 반복하면서 내부에서 요소를 제거하고 있습니다. 실행 결과 컬렉션을 순회하던 중 예외가 발생하여 프로그램이 종료됩니다. 예외 메시지는 다음과 같습니다.
Exception in thread "main" java.util.ConcurrentModificationException
at java.base/java.util.ArrayList$Itr.checkForComodification(ArrayList.java:937)
at java.base/java.util.ArrayList$Itr.next(ArrayList.java:891)
at Example.main(Example.java:15)
위 예외는 컬렉션을 반복(iteration)하는 도중에 그 구조를 변경(요소 추가/삭제 등)하면 발생하는 런타임 오류입니다. 흔히 **"반복 중 컬렉션 변경 오류"**로 불리며, 단일 스레드 환경에서도 발생할 수 있습니다 (ConcurrentModificationException 은 어디서, 언제 throw 되는걸까?). 아래에서 이 예외의 원인과 대표적인 발생 상황을 알아보고, 해결 방법을 단계별로 설명하겠습니다.
- 원인
ConcurrentModificationException은 자바 컬렉션을 순회하는 동안 컬렉션에 구조적 변화(Structural Modification)가 일어날 때 발생합니다. 구조적 변화란 컬렉션의 요소를 추가하거나 제거하여 컬렉션의 크기나 구조가 변경되는 것을 말합니다. 자바의 대부분 컬렉션은 Fail-Fast 특성을 가지는데, 한 컬렉션에 대해 Iterator(반복자)를 사용해 순회중일 때 해당 컬렉션이 변경되면 즉시 예외를 발생시켜 문제를 알려줍니다.
대표적인 발생 상황은 다음과 같습니다.
- 단일 스레드에서의 반복 중 제거/추가: 향상된 for loop(for-each)이나 Iterator를 이용해 컬렉션을 순회하면서 직접 컬렉션의 add()나 remove()를 호출하면 예외가 발생합니다. 예를 들어, 아래 코드는 리스트를 for-each로 순회하면서 "B" 값을 제거하려고 할 때 오류가 발생합니다. 이때 for-each 구문은 내부적으로 Iterator를 사용하므로, list.remove() 호출은 Iterator 모르게 리스트를 변경하여 예외를 일으킵니다 (ConcurrentModificationException 은 어디서, 언제 throw 되는걸까?).
- List<String> list = new ArrayList<>(List.of("A", "B", "C")); for (String s : list) { if (s.equals("B")) { list.remove(s); // 이 위치에서 ConcurrentModificationException 발생 } }
- 멀티 스레드 환경에서의 동시 수정: 한 스레드가 컬렉션을 반복(iteration)하고 있는 동안, 다른 스레드가 동일한 컬렉션에 요소를 추가하거나 삭제하면 예외가 발생합니다. 예를 들어, 스레드 A가 list를 순회 중인데 스레드 B가 같은 list에 add()를 호출하면, 다음 요소를 가져오는 시점에 ConcurrentModificationException이 throw됩니다.
위 상황들에서 공통적으로 **"컬렉션을 순회 중 변경이 일어났다"**는 조건이 성립하면 예외가 발생합니다. 자바 컬렉션 구현체들은 이러한 동시 변경을 감지하기 위해 내부적으로 modCount와 같은 변경 횟수 값을 관리하고, Iterator는 컬렉션의 예상 변경 횟수(expectedModCount)를 기억합니다. 순회 중에 컬렉션의 modCount가 예상값과 다르면 checkForComodification() 메서드가 ConcurrentModificationException을 발생시킵니다. 이는 **버그 조기 발견(fail-fast)**을 위한 것으로, 잘못된 동작이 진행되기 전에 개발자에게 알려주는 장치입니다.
- 해결 방법
이 예외를 피하기 위해서는 컬렉션을 반복하는 동안 해당 컬렉션을 직접 수정하지 않는 것이 원칙입니다. 하지만 실무에서는 반복 중 요소를 제거하거나 추가해야 하는 경우가 종종 있으므로, 아래와 같은 해결책을 사용할 수 있습니다 (Java ConcurrentModificationException — 꾸준히 성장하는 개발자스토리). 상황에 따라 적절한 방법을 선택하세요.
- Iterator 사용하기 (안전한 요소 제거)
컬렉션을 순회하며 요소를 삭제해야 한다면 Iterator의 remove() 메서드를 사용하는 것이 가장 간단한 해결책입니다 (Java ConcurrentModificationException — 꾸준히 성장하는 개발자스토리). Iterator는 자신의 현재 가리키는 요소를 안전하게 제거할 수 있도록 설계되어 있어, 이를 사용하면 순회 중에도 예외가 발생하지 않습니다. 단, Iterator.remove()는 한 번 next()로 가져온 현재 요소만 제거할 수 있으며, 임의의 다른 요소를 건너뛰어 제거하는 것은 안 됩니다.위 코드에서는 Iterator를 사용해 "B" 요소를 제거했으므로 ConcurrentModificationException이 발생하지 않습니다. 향상된 for문으로 순회할 때 요소를 제거해야 한다면, 위와 같이 명시적으로 Iterator를 사용하거나 다음의 대안을 사용할 수 있습니다. 자바 8 이상에서는 컬렉션의 removeIf() 메서드를 활용하면 내부적으로 Iterator를 사용하여 조건에 맞는 요소들을 손쉽게 제거할 수 있습니다. 예를 들어 list.removeIf(s -> s.startsWith("A"))처럼 사용하면 "A"로 시작하는 요소들을 일괄 제거할 수 있으며, 이 역시 안전하게 동작합니다. 또한, 반복 중 요소 추가까지 필요하다면 ListIterator를 사용하면 됩니다. ListIterator는 순회 중에도 add()를 제공하므로 요소 삽입과 삭제를 모두 안전하게 처리할 수 있습니다 (Java ConcurrentModificationException — 꾸준히 성장하는 개발자스토리). (다만 코드가 다소 복잡해질 수 있어 필요한 경우에만 사용합니다.) - List<String> list = new ArrayList<>(Arrays.asList("A", "B", "C")); Iterator<String> it = list.iterator(); while (it.hasNext()) { String s = it.next(); if (s.equals("B")) { it.remove(); // Iterator를 통한 안전한 제거 } } System.out.println(list); // 출력: [A, C]
- 동시성 컬렉션 사용하기 (Fail-Safe 컬렉션)
멀티스레드 환경에서 여러 스레드가 동시에 컬렉션을 접근/변경해야 한다면, java.util.concurrent 패키지의 컬렉션 클래스들을 사용하는 것이 좋습니다 (Java ConcurrentModificationException — 꾸준히 성장하는 개발자스토리). 이러한 컬렉션들은 Fail-Safe 특성을 가지고 있어 반복 중에 컬렉션이 변경되더라도 ConcurrentModificationException을 발생시키지 않습니다.
위 코드에서는 CopyOnWriteArrayList를 사용했기 때문에 "B"를 제거해도 예외가 발생하지 않습니다. 출력 결과를 보면 "B" 제거 이후에도 나머지 요소들에 대해 정상적으로 반복이 수행되며, 최종 리스트에는 "B"가 빠져 있는 것을 확인할 수 있습니다. 다만 CopyOnWriteArrayList는 변경 시마다 내부 배열을 복사하기 때문에 쓰기 작업이 빈번한 경우 성능 저하가 있을 수 있습니다. 반대로 반복은 자주 하지만 변경은 드문 상황에 적합합니다. ConcurrentHashMap이나 ConcurrentLinkedQueue 등 다른 동시성 컬렉션들도 사용 시의 특성과 성능을 고려하여 선택하면 됩니다.CopyOnWriteArrayList<String> safeList = new CopyOnWriteArrayList<>(List.of("A", "B", "C", "D")); for (String s : safeList) { System.out.println("처리 중: " + s); if (s.equals("B")) { safeList.remove(s); // Fail-Safe 컬렉션에서는 예외 발생 안 함 } } System.out.println("처리 후 리스트: " + safeList);
- 참고: Collections.synchronizedList(new ArrayList<>())처럼 동기화 래퍼를 씌운 컬렉션은 단일 스레드에서의 Fail-Fast 동작 자체를 바꾸지는 않습니다. 이러한 컬렉션을 멀티스레드에서 사용할 때는 Iteration과 수정 작업 자체를 개발자가 적절히 동기화해주거나, 애초에 위의 동시성 컬렉션을 사용하는 것이 안전합니다.
- 대표적인 예로 CopyOnWriteArrayList, ConcurrentHashMap 등이 있습니다. CopyOnWriteArrayList는 요소가 변경될 때 내부에서 새로운 복사본을 만들어 처리함으로써, Iteration 중 발생한 변경이 기존 반복에 영향을 주지 않도록 합니다. ConcurrentHashMap의 이터레이터 또한 현재 구조를 복사하거나 분리된 방식으로 처리하여 예외를 방지하고, **약한 일관성(Weakly Consistent)**을 갖는 결과를 반환합니다 (반복 중 일부 최신 변경 내용을 실시간 반영할 수도 있지만, Fail-Fast처럼 오류를 던지지는 않습니다).
- 컬렉션 복사 후 변경하기 (컬렉션 스냅샷)
컬렉션을 복사본으로 한 번에 처리하는 방법도 흔히 사용됩니다. 현재 컬렉션을 복제한 후 그 복제본을 대상으로 반복을 수행하면, 원본 컬렉션을 안전하게 수정할 수 있습니다. 이 방법은 단일 스레드 환경에서 반복 중 여러 요소를 제거하거나 변경해야 할 때 유용합니다.
위 방법처럼 애초에 변경 대상이 될 컬렉션을 복사해서 그 복사본으로 반복을 돌리면, 원본 컬렉션은 반복에 사용되지 않으므로 자유롭게 수정할 수 있습니다. 또는 복사본에 변경을 가한 뒤 작업이 끝나면 복사본으로 원본을 교체하거나, 삭제할 항목들을 모아뒀다가 original.removeAll(toRemoveList)를 호출하는 방식도 있습니다. 이 접근법은 컬렉션 크기가 아주 크다면 복사 비용이 들 수 있지만, 일반적인 경우에는 큰 문제가 없으며 코드도 직관적입니다.List<String> original = new ArrayList<>(List.of("가", "나", "다", "라")); List<String> copy = new ArrayList<>(original); // 원본의 복사본 생성 for (String s : copy) { if (s.compareTo("다") > 0) { // 임의 조건: "다"보다 큰 문자들 original.remove(s); // 원본 리스트에서 제거 } } System.out.println(original); // 출력: [가, 나] ("다", "라" 제거됨)
- 예를 들어, 아래 코드는 리스트를 복사한 후 복사본을 순회하면서 원본 리스트에서 제거 작업을 수행합니다. 이렇게 하면 원본 리스트를 직접 순회하는 것이 아니므로 예외가 발생하지 않습니다.
- 상황별 추천 방법
마지막으로, 상황에 따른 권장 해결책을 정리합니다.
- 단일 스레드에서 컬렉션 요소 삭제: 가능하면 **Iterator의 remove()**를 사용하거나 자바 8+에서는 removeIf() 메서드를 사용하세요. 이렇게 하면 반복 중 안전하게 요소를 제거할 수 있습니다. 만약 요소 추가까지 필요하다면 ListIterator 사용을 고려합니다. 간단한 경우라면, 위의 복사본을 사용한 방법도 코드 이해가 쉬워 유용합니다.
- 멀티 스레드 환경에서 컬렉션 공유: 여러 스레드가 컬렉션을 변경할 가능성이 있다면 **동시성 지원 컬렉션(ConcurrentHashMap, CopyOnWriteArrayList 등)**을 사용하는 것을 가장 권장합니다 (Java ConcurrentModificationException — 꾸준히 성장하는 개발자스토리). 이러한 컬렉션은 내부에서 동시 수정에 대비한 구조를 갖추고 있어 ConcurrentModificationException을 피할 수 있습니다. 부득이하게 일반 컬렉션을 사용해야 한다면, 적절한 동기화(synchronized 블럭 또는 Lock)를 통해 한 스레드가 순회를 완료할 때까지 다른 스레드의 수정 동작을 막아야 합니다. 하지만 이런 수동 동기화는 오류를 유발하기 쉽고 성능도 저하시키므로, 가능하면 Concurrent 컬렉션으로의 변경을 고려하세요.
- 컬렉션 순회 중 대량 수정: 현재 컬렉션을 복사하여 작업하는 방법을 사용하면 가장 단순하게 문제를 피해갈 수 있습니다. 복사본을 이용해 반복하면서 원본을 수정하거나, 또는 변경 사항을 임시 저장해 두었다가 반복이 끝난 후 한꺼번에 적용하는 방식입니다. 이 방법은 단일 스레드에서 안전하고 구현이 쉬워서, 성능 요구가 엄격하지 않은 경우에 흔히 사용됩니다.
ConcurrentModificationException 예외를 예방하고 안전하게 컬렉션을 다룰 수 있습니다. 상황에 맞는 방법을 선택하여 적용하면 컬렉션 반복 작업 중에 발생하는 오류를 효과적으로 해결할 수 있을 것입니다.
'IT > Java' 카테고리의 다른 글
클래스 로드 오류 해결 방법 (ClassNotFoundException 및 NoClassDefFoundError) (0) | 2025.03.11 |
---|---|
Java OutOfMemoryError (메모리 부족 오류) 원인과 해결 방법소개 (1) | 2025.03.10 |
Java 인증서 추가 방법 ( SunCertPathBuilderException 해결 ) (0) | 2021.10.27 |
Quartz 스케줄러 사용하기 (0) | 2020.06.15 |
댓글