Java 개발자라면 한 번쯤 마주쳤을 법한 ConcurrentModificationException. 이 예외는 "동시 수정 예외"라는 이름처럼, 컬렉션을 순회(iterate)하는 도중에 컬렉션을 수정할 때 발생합니다. 겉보기에는 간단해 보이지만, 예상치 못한 상황에서 발생하여 디버깅을 어렵게 만들기도 하죠. 이 글에서는 ConcurrentModificationException의 근본적인 원인을 파헤치고, 상황별로 적용할 수 있는 다양한 해결 방법을 상세히 알아보겠습니다.
ConcurrentModificationException의 근본적인 원인
이 예외는 Java 컬렉션의 **Fail-Fast** 동작 방식 때문에 발생합니다. 대부분의 컬렉션 클래스는 내부적으로 'modCount'라는 카운터 변수를 가지고 있습니다. 이 변수는 컬렉션의 구조가 변경될 때마다(요소 추가, 제거 등) 증가합니다.
컬렉션에 대한 반복자(Iterator)를 생성할 때, 이 반복자는 당시의 modCount 값을 저장해둡니다. 이후 반복자가 next() 메소드를 호출하여 다음 요소를 가져올 때마다, 현재 컬렉션의 modCount와 저장해둔 값을 비교합니다. 만약 두 값이 다르다면, **"순회 도중 컬렉션이 수정되었다"**고 판단하고 즉시 ConcurrentModificationException을 발생시킵니다.
이러한 동작은 문제가 발생했을 때 빠르게 실패하도록 설계되어 있어, 예상치 못한 동작을 방지하는 중요한 역할을 합니다. 이 예외는 주로 다음과 같은 두 가지 시나리오에서 발생합니다.
- 단일 스레드 환경:
for-each루프를 사용하여 리스트를 순회하면서list.remove()와 같은 메소드로 요소를 제거하는 경우.for-each는 내부적으로 반복자를 사용하기 때문에 예외가 발생합니다. - 멀티스레드 환경: 한 스레드가 컬렉션을 순회하는 동안, 다른 스레드가 해당 컬렉션의 요소를 추가하거나 제거하는 경우.
상황별 해결 방법: 안전하게 컬렉션 수정하기
1. Iterator의 remove() 메소드 활용
가장 고전적이고 확실한 해결책입니다. 반복자를 직접 사용하여 요소를 순회하고, 제거가 필요할 때는 반드시 **반복자의 remove() 메소드**를 호출해야 합니다. 이 메소드는 modCount를 안전하게 관리하여 예외를 발생시키지 않습니다.
List<String> list = new ArrayList<>(Arrays.asList("A", "B", "C"));
Iterator<String> iterator = list.iterator();
while (iterator.hasNext()) {
String item = iterator.next();
if (item.equals("B")) {
// Iterator의 remove() 사용: 안전하게 요소 제거
iterator.remove();
}
}
// 결과: [A, C]
2. 새로운 컬렉션에 결과 저장 (Stream API)
원본 컬렉션을 직접 수정하는 대신, 필터링된 결과를 새로운 컬렉션에 담는 방식입니다. 이 방법은 원본 컬렉션의 불변성(immutability)을 유지하므로, 코드가 훨씬 안전하고 예측 가능해집니다. Java 8의 Stream API를 활용하면 더욱 간결하게 구현할 수 있습니다.
List<String> list = new ArrayList<>(Arrays.asList("A", "B", "C"));
List<String> newList = list.stream()
.filter(item -> !item.equals("B"))
.collect(Collectors.toList());
// 결과: newList = [A, C]
3. removeIf() 메소드 사용 (Java 8 이상)
Java 8부터 도입된 removeIf() 메소드는 조건에 맞는 모든 요소를 안전하게 제거하는 편리한 방법입니다. 내부적으로는 Iterator를 사용하면서 modCount를 올바르게 처리하므로 예외가 발생하지 않습니다.
List<String> list = new ArrayList<>(Arrays.asList("A", "B", "C"));
list.removeIf(item -> item.equals("B"));
// 결과: [A, C]
4. 동시성 컬렉션 사용 (멀티스레드 환경)
여러 스레드가 동시에 컬렉션을 수정하는 멀티스레드 환경에서는 ConcurrentHashMap, CopyOnWriteArrayList와 같은 **동시성 컬렉션(Concurrent Collections)**을 사용해야 합니다. 이 컬렉션들은 내부적으로 동시성 문제를 해결하도록 설계되어 있어, ConcurrentModificationException이 발생하지 않습니다.
// 멀티스레드 환경에서 안전한 맵
Map<String, Integer> map = new ConcurrentHashMap<>();
map.put("key1", 1);
map.put("key2", 2);
// 여러 스레드에서 자유롭게 읽고 쓸 수 있음
주의: CopyOnWriteArrayList는 수정이 자주 발생하는 환경에서는 성능이 저하될 수 있습니다. 쓰기 작업 시 매번 새로운 배열을 생성하기 때문입니다.
5. 동기화 블록 사용
멀티스레드 환경에서 동시성 컬렉션을 사용할 수 없는 경우, synchronized 키워드를 사용하여 컬렉션에 대한 접근을 동기화할 수 있습니다. 하지만 이는 성능 저하를 일으킬 수 있으며, 데드락(Deadlock)의 위험이 있어 신중하게 사용해야 합니다.
List<String> list = Collections.synchronizedList(new ArrayList<>());
// 또는...
// List<String> list = new ArrayList<>();
synchronized (list) {
for (String item : list) {
// 이 블록 안에서만 안전하게 컬렉션 접근
}
}
모범 사례 요약 및 선택 가이드
- 단일 스레드에서 제거 시: **
Iterator.remove()** 또는 **removeIf()**를 사용하세요. 코드가 간결하고 안전합니다. - 불변성(Immutability)이 중요할 때: Stream API를 활용하여 **새로운 컬렉션**을 생성하는 방식을 선호하세요. 원본 데이터의 오염을 막을 수 있습니다.
- 멀티스레드 환경에서 쓰기 작업이 잦을 때: 동시성 컬렉션(
ConcurrentHashMap)을 사용하세요. 동기화 블록보다 훨씬 좋은 성능을 보여줍니다. - 멀티스레드 환경에서 읽기 작업이 압도적으로 많을 때:
CopyOnWriteArrayList도 좋은 대안이 될 수 있습니다.
결론
ConcurrentModificationException은 컬렉션의 동작 원리를 이해하면 쉽게 해결할 수 있는 예외입니다. 중요한 것은 **순회와 수정은 동시에 일어나서는 안 된다**는 원칙을 지키는 것입니다. 상황에 따라 가장 적합한 방법을 선택하여, 안전하고 효율적인 코드를 작성하시길 바랍니다.