Fatal Exception: java.util.ConcurrentModificationException에 대한 간단 정리

2024. 2. 16. 10:37Programming/Android

반응형

  비정상 종료를 줄이기 위해서 앱에 파이어베이스를 추가하고나서, 간간히 java.util.ConcurrentModificationException 예외가 발생한 것을 볼 수 있었다. 이름에 Concurrent가 들어가있는만큼, 비동기 문제겠거니...하고 추측하고 있었는데 아무리봐도 해당 코드는 메인 스레드에서 리스트 내용을 업데이트하는 코드가 아닌가. 동시성 문제가 발생할만한 상황이 아닌데 어째서 이런 예외가 발생하는거지... 하는 의문에 빠져서 java.util.ConcurrentModificationException에 대해 찾아봤다.

 

  ConcurrentModificationException을 구글에 검색하면 수많은 블로그가 나온다. 다들 숱하게 경험한 문제란 얘기인 셈. 누군가 잘 정리해놓은 자료를 보는 것도 좋지만, 혹시나 몰라서 공식 문서의 ConcurrentModificationException 페이지를 찾아봤다. 간단하게 정리하면 모두가 이름에서 유추할 수 있듯, '동시에 수정할 수 없는 상황에서 수정하는 경우에 던지는 예외'라고 한다. 그리고 스레드와 관련된 예제와, 이터레이터에 대한 예제가 소개되어있다.

Java 문서에서 ConcurrentModificationException에 대한 내용을 DeepL 플러그인으로 번역해봤다. 물론 원문이 이해가 더 빠르다(...)

 

  스레드를 사용한 동시성 문제는 누구나 짐작할만한 내용이므로 건너뛰고, 아래의 내용을 살펴보자. 아래의 내용은 Java 문서에 기술된 ConcurrentModificationException에 대한 내용 중, 싱글 스레드에서 ConcurrentModificationException이 발생할 수 있는 대표적인 예시로 이터레이터를 소개하는 내용이다.

  Note that this exception does not always indicate that an object has been concurrently modified by a different thread. If a single thread issues a sequence of method invocations that violates the contract of an object, the object may throw this exception. For example, if a thread modifies a collection directly while it is iterating over the collection with a fail-fast iterator, the iterator will throw this exception.

  이 예외가 항상 객체가 다른 스레드에 의해 동시에 수정되었음을 나타내는 것은 아니라는 점에 유의하세요. 단일 스레드가 객체의 컨트랙트를 위반하는 메서드 호출 시퀀스를 발행하면 객체가 이 예외를 던질 수 있습니다. 예를 들어, 스레드가 실패 빠른 이터레이터를 사용하여 컬렉션을 반복하는 동안 컬렉션을 직접 수정하면 이터레이터가 이 예외를 던집니다.

  

  당연하다면 당연한 이야기다. 컬렉션을 처음부터 끝까지 순회하는 동안 중간에 값이 추가되거나 삭제되어버리면, 다음에 참조해야 할 값이 변경되거나 혹은 삭제된 값이기 때문에 사이드 이펙트가 발생할 수 있기 때문이다. 컬렉션 객체에서는 이와 같은 상황이 발생하면 컬렉션을 순회하는데 실패했다고 판단해서, 빠르게 ConcurrentModificationException을 던지는 것이다. 나같은 경우는 ArrayList의 forEach를 사용해서 리스트를 조작하는 중, 직접 ArrayList를 조작하는 무식하면서 용감한 코드에서 이와 같은 문제가 발생했으므로, ArrayList.java에 forEach가 어떤 방식으로 구현됐는지 살펴보기로 했다.

    /**
     * @throws NullPointerException {@inheritDoc}
     */
    @Override
    public void forEach(Consumer<? super E> action) {
        Objects.requireNonNull(action);
        final int expectedModCount = modCount;
        final Object[] es = elementData;
        final int size = this.size;
        for (int i = 0; modCount == expectedModCount && i < size; i++)
            action.accept(elementAt(es, i));
        if (modCount != expectedModCount)
            throw new ConcurrentModificationException();
    }

  보면 단순히 인덱스 값(i)를 가지고 배열을 순회하면서 인자로 전달받은 action에 데이터를 전달해주는 것을 볼 수 있다. 다만 ArrayList.forEach() 실행 시점에 ArrayList의 modCount값을 expectedModCount값에 저장해놓는데, 중간에 이 값이 달라지면 ConcurrentModificationException을 던지는 것을 볼 수 있다.

  그렇다면 modCount의 역할은 뭘까? modCount는 AbstractList.java에 정의되어있는데, 따라가보면 JavaDoc으로 빼곡하게(...) 기술되어있다. 동일한 내용을 Java 문서의 AbstractList.java 페이지에서도 찾아볼 수 있다.

Java 문서의 AbstractList.java 페이지에 기술된 modCount 내용. modCount라는 이름 그대로, 수정된 횟수를 의미한다.

  modCount라는 이름에서 짐작할 수 있듯, 이 값은 수정된 횟수를 의미한다. 다만 그냥 수정된 횟수는 아니고, 구조적으로 수정된 횟수를 의미한다. 요컨데 이터레이터가 값을 순회하는 도중 리스트의 구조가 변경됐다면, 이터레이터를 통해서 값을 순회할 때 문제가 발생할 수 있으므로, 이러한 상황을 빠르게 판단하기 위한 값이라고 볼 수 있다. 

  ArrayList에서 modCount 값을 업데이트하는 메서드를 찾아보면 다음과 같다. 즉, 다음에 해당하는 메서드들이 ArrayList를 구조적으로 변경한다고 볼 수 있다. 메서드 이름만 훑어봐도 알 수 있듯, 리스트를 구성하는 값들의 순서가 변경되거나 추가되거나 삭제되는 메서드들이다. 이런 내용들을 통해서 리스트가 구조적으로 수정됐다는 내용에 대해서도 어렵지 않게 이해할 수 있다.

ArrayList에서 modCount에 값을 쓰는 메서드를 찾아보면 위와 같다. (...)


  ConcurrentModificationException은 동시성 문제로 인해 발생하는 예외다. 다만 여러개의 스레드에서 하나의 자원을 수정할 때만 발생하는 예외는 아니다. 컬렉션 객체를 순회하는 도중 컬렉션 객체 자체에 구조적인 수정이 가해지면, 더 이상 순차적인 순회를 할 수 없다고 빠르게 판단했을 때도 발생한다. 따라서 이런 경우에는 이터레이터를 통해서 직접 순회중인 요소값을 조작하거나, 순회하려하는 컬렉션을 복사한 뒤에 순회하여 예외가 발생하지 않도록 할 수 있다.


참고:

https://docs.oracle.com/javase/8/docs/api/java/util/ConcurrentModificationException.html

https://docs.oracle.com/javase/8/docs/api/java/util/AbstractList.html

반응형