[JAVA] ConcurrentModificationException와 iterator : 탐색 도중 리스트가 변경되는 경우



 자바로 개발을 하거나 코딩 테스트 문제를 풀다보면 아래와 같은 오류 메세지를 종종 만나볼 수 있을 것입니다.


Exception in thread "main" java.util.ConcurrentModificationException
        at java.base/java.util.ArrayList$Itr.checkForComodification(ArrayList.java:1012)
        at java.base/java.util.ArrayList$Itr.next(ArrayList.java:966)
        at swea2022.Test.main(test.java:19)
        

ConcurrentModificationException, 이게 뭘까요? Concurrent는 컴퓨터 분야에서 보통 동시성이라는 의미를 가지고 있습니다. Modification은 변경이란 뜻이고, Exception은 예외란 뜻이죠. "동시성 변경 예외?" 뭔가 아리송한 의미를 가지고 있습니다. 이 오류가 언제 발생하는지, 어떻게 해결할 수 있는지 한 번 알아보겠습니다.


ConcurrentModificationException이란?


ArrayList<String> list = new ArrayList<>();
list.add("hi");
list.add("hello");
list.add("my");
list.add("name");
list.add("is");
list.add("keykat");

for (int i = 0; i < list.size(); i++) {
    if (list.get(i).compareTo("hello") == 0) list.remove(i);
}
 

리스트를 순회하는 중에 "hello"라는 값을 만나면 해당 인덱스를 빼버리는 코드입니다. 크게 어렵지 않을 것입니다. 그런데 for문에서 i = 0 ~ i = list.size() - 1까지 돌고 있는데, 갑자기 remove로 element를 빼버리면 어떻게 될까요?



그림에서 보면 현재 "hi"부터 "keykat" 까지 총 6개의 element가 존재합니다. 이렇게 6개의 element를 탐색하다가 아래의 그림과 같이 "hello"를 빼버린다고 생각해 봅시다.


처음에 size가 6인 리스트가 갑자기 size가 5가 되어버렸습니다. 그렇다면 컴퓨터의 입장에선 어떻게 될까요? 
"아니, 크기가 6인 리스트를 돌라고 명령해서 돌고 있는데 리스트 크기가 갑자기 5가 되어버렸네? 그렇다면 나는 이 for문을 어디까지 돌아야 돼?" 가 되어버리는 것입니다. 우리는 그냥 "현재 리스트의 크기만큼 돌아. 중간에 조금 바뀔 수도 있지. 실시간으로 바뀐 만큼 돌면 되잖아." 라고 생각할 수 있지만 컴퓨터는 그게 이해하기 어렵나 봅니다. 따라서 for문으로 리스트를 돌면서 리스트를 변경해선 안 됩니다. 




ConcurrentModificationException 해결하기 - Iterator


그래서 Iterator라는 개념이 등장하게 됩니다. 아래의 코드를 볼까요?

// list에서의 iterator
Iterator<String> iter = list.iterator();
while (iter.hasNext()) {
    String val = iter.next();
    if (val.compareTo("hello") == 0) iter.remove();
}

// map에서의 iterator
HashMap<Integer, String> map = new HashMap<>();
Iterator<Integer> iter = map.keySet().iterator();
while (iter.hasNext()) {
    int idx = iter.next();
    String val = map.get(idx);
    iter.remove();
}

// set에서의 iterator
HashSet<Integer> set = new HashSet<>();
Iterator<Integer> iter = set.iterator();
while (iter.hasNext()) {
    int idx = iter.next();
    if (idx == 2) iter.remove();
}

ArrayList나 HashSet, 또는 HashMap의 ketset()에는 iterator 라는 것이 존재합니다. 마치 for문처럼 해당 자료구조를 한바퀴 돌아주는 것인데, iter.hasNext()를 통해 다음 element가 존재하는지 체크하고, 존재하면 현재 탐색 중인 위치를 iter.next()로 나타내는 것입니다.
이 때 iter.remove()를 사용하면 해당 위치의 element를 지우고 다음 위치로 이동하게 됩니다. 따라서 크기가 가변적이더라도 내부적으로 처리를 해주기 때문에 안전하게 remove할 수 있게 됩니다.






Iterator에서 add - 안전한 추가는 불가능하다?

ListIterator

위에서 remove는 iterator를 통해 안전하게 사용할 수 있다고 했지만, 어쩐 일인지 iterator는 add를 지원하지 않습니다. 생각해보면, 삭제는 어쨌든 탐색 중인 자료구조의 사이즈가 결과적으로 줄어들지만, 추가의 경우 while (iter.hasNext())를 사용한다고 할 때 뒤에 계속 새로운 값이 추가되기 때문에 자칫하면 무한 루프에 빠질 수 있습니다. 

특이하게도 list 형태의 자료구조에 대해서는 ListIterator를 통해 remove와 add를 모두 사용할 수 있게끔 지원하고 있습니다. 아래의 코드와 같이 Iterator를 쓰듯이 사용하면 됩니다. ListIterator는 기본적인 Iterator보다 기능이 좀 더 많은데, 사용할 수 있는 메서드를 정리한 링크를 남겨두겠습니다. (사실 IDE에서 확인할 수 있긴 합니다.)


ListIterator<String> iter = list.listIterator();
while (iter.hasNext()) {
    String val = iter.next();
    if (val.compareTo("hello") == 0) iter.remove();
    else if (val.compareTo("keykat") == 0) iter.add(val);
}

http://www.tcpschool.com/java/java_collectionFramework_iterator



map과 set에서의 iterator

아쉽게도 map과 set은 ListIterator같은 것이 존재하지 않습니다. 따라서 remove는 iter.remove를 통해 어찌저찌 사용할 수 있어도, add나 put은 사용할 수 없습니다. 

그런데 생각해보면, add할 내용을 임시 저장할 새로운 map이나 set을 만들어서 여기에 add를 하고, 모든 탐색이 끝난 후에 새로 만든 map이나 set을 다시 돌면서 추가해주면 굳이 실시간으로 add를 할 필요가 없긴 합니다. 무슨 말이냐, 


HashMap<Integer, String> map = new HashMap<>();
HashMap<Integer, String> tmap = new HashMap<>();
map.put(1, "hi");
map.put(2, "hello");
map.put(3, "my");
map.put(4, "name");
map.put(5, "is");
map.put(6, "keykat");
Iterator<Integer> iter = map.keySet().iterator();
while (iter.hasNext()) {
    int idx = iter.next();
    String val = map.get(idx);
    tmap.put(idx * 5381, val);
}

Iterator<Integer> titer = tmap.keySet().iterator();
while (titer.hasNext()) {
    int idx = titer.next();
    String val = tmap.get(idx);
    map.put(idx, val);
}

/*
map[1] = hi
map[2] = hello
map[3] = my
map[4] = name
map[21524] = name
map[5] = is
map[5381] = hi
map[6] = keykat
map[26905] = is
map[10762] = hello
map[32286] = keykat
map[16143] = my
*/

괜히 코드가 좀 길긴 한데, 별로 어려운 내용이 아닙니다. map에 추가하고 싶은 데이터를 임시로 tmap에 저장하고, map을 다 돌고나면 tmap을 돌면서 map에 다시 저장해준다는 것입니다. 이렇게 하면 시간이 오래 걸리긴 할텐데, map의 keySet 크기가 n이라고 한다면 worst라고 해봐야 O(2n)입니다. 이렇게 해서 시간이 오래 걸린다면 애당초 다른 방법으로 푸는 것이 맞을지도... 하여튼 나쁜 방법은 아니라는 것이 제 생각입니다.




마치며..

오늘은 좀 간단한 내용에 대해서 글을 써보았습니다. 자바를 워낙 잘 안 쓰다가 등장한 오류에 호기심이 생겨 정리해 보았는데, 자바를 너무 버린 것이 아닌가 싶네요. 자바로 코딩 테스트를 준비하는 분들이라면 한 번 쯤은 생각해보고 넘어갈만한 내용이 아닌가 싶습니다. 피드백과 질문은 언제나 환영합니다. 오늘도 즐거운 코딩하세요!

댓글