1. ConcurrentModificationException 문제
이 강의에는 (거의) 새로운 내용이 없습니다. 하지만 매우 중요합니다. 부주의한 데이터 삭제는 특히 프로덕션에서 가장 돌이킬 수 없는 오류 중 하나이기 때문입니다. 그리고 아마 여러분이 좋아하게 될 흥미로운 클래스 하나도 등장합니다!
그러니 다시 한 번 강조합니다: 삭제할 때는 for-each를 절대 쓰지 마세요! 아무리 익숙하더라도요.
많은 초보자(그리고 초보자만도 아님)에게 고통을 주는 고전적인 예제부터 시작해 봅시다:
List<Integer> numbers = new ArrayList<>(List.of(1, 2, 3, 4, 5, 6));
// 짝수 모두 삭제를 시도
for (Integer n : numbers) {
if (n % 2 == 0) {
numbers.remove(n); // 펑! ConcurrentModificationException
}
}
겉으로는 잘 동작할 것 같지만 실제로는 예외가 발생합니다:
Exception in thread "main" java.util.ConcurrentModificationException
이제 여기서 무슨 일이 있었는지 자세히 살펴봅시다. for-each(또는 일반 Iterator)로 컬렉션을 순회할 때, 컬렉션 내부에는 특별한 “변경 카운터”가 유지됩니다. 순회 중에 컬렉션이 해당 이터레이터를 거치지 않고 변경되면, 이 카운터가 “외부 개입”을 감지하고 예외를 던집니다. 이는 손상된 데이터 구조로 동작하지 않도록 하는 보호 장치입니다.
2. Iterator 사용
순회 중에 요소를 올바르게 삭제하는 방법?
다시 상기하자면, Iterator는 컬렉션을 순회하고 “진행 중”에 요소를 안전하게 삭제할 수 있게 해주는 특별한 객체입니다. 마치 식탁을 돌며 서빙도 하고, 동시에 접시도 치울 수 있는 웨이터와 같습니다.
이터레이터 얻기
Iterator<Integer> it = numbers.iterator();
while로 순회하고 it.remove()로 삭제
다음은 리스트에서 모든 짝수를 올바르게 삭제하는 방법입니다:
List<Integer> numbers = new ArrayList<>(List.of(1, 2, 3, 4, 5, 6));
Iterator<Integer> it = numbers.iterator();
while (it.hasNext()) {
Integer n = it.next();
if (n % 2 == 0) {
it.remove(); // 현재 요소를 안전하게 삭제
}
}
System.out.println(numbers); // [1, 3, 5]
중요한 점: 요소 삭제는 오직 이터레이터(it.remove())를 통해서만 가능하며, it.next() 호출 이후에만 할 수 있습니다. next() 없이 remove()를 연속 두 번 호출하면 IllegalStateException이 발생합니다.
3. ListIterator: 확장된 기능
자, 처음에 예고했던 신기능입니다! ListIterator는 리스트(List) 전용의 “강화된” 이터레이터로, 삭제뿐 아니라 순회 도중에 요소를 추가할 수 있고, 양방향(앞/뒤)으로 이동할 수 있습니다.
일반 Iterator와의 차이
- Iterator — 직진만 가능: 앞으로만 이동, 삭제만 가능.
- ListIterator — 유연하고 기민함: 앞/뒤 이동, 삭제, add()로 추가, 그리고 set()으로 현재 요소 교체도 가능.
예시: 요소 삭제와 추가
List<String> words = new ArrayList<>(List.of("cat", "dog", "bird"));
ListIterator<String> it = words.listIterator();
while (it.hasNext()) {
String word = it.next();
if (word.length() == 3) {
it.remove(); // 길이 3인 단어 삭제
it.add("pet"); // 바로 그 자리 뒤에 "pet"을 추가
}
}
System.out.println(words); // [pet, pet, bird]
참고: it.add()로 추가하면 이터레이터의 현재 위치 바로 다음에 요소가 삽입됩니다.
4. removeIf로 삭제
Java 8부터 간결하고 편리한 removeIf 메서드가 추가되었습니다. 이 메서드는 람다 표현식(혹은 임의의 Predicate)을 받아, 조건이 true를 반환하는 모든 요소를 삭제합니다.
예시: 모든 짝수 삭제
List<Integer> numbers = new ArrayList<>(List.of(1, 2, 3, 4, 5, 6));
numbers.removeIf(n -> n % 2 == 0);
System.out.println(numbers); // [1, 3, 5]
더 짧을 뿐 아니라 안전합니다. 내부적으로 올바른 이터레이터를 사용하므로 ConcurrentModificationException이 발생하지 않습니다.
예시: 길이가 3 미만인 문자열 삭제
List<String> words = new ArrayList<>(List.of("hi", "cat", "no", "elephant"));
words.removeIf(word -> word.length() < 3);
System.out.println(words); // [cat, elephant]
팁: 조건에 따라 요소만 삭제하면 되는 경우라면 removeIf를 사용하세요. 가장 간결하고 현대적인 방법입니다.
5. 실전 권장 사항
어떤 방법이 더 좋을까요?
- 복잡한 조건으로 삭제해야 하고 Java 8+를 사용한다면: removeIf를 사용하세요 — 짧고, 명확하며, 안전합니다.
- 오래된 Java 버전을 사용하거나 더 복잡한 순회 로직이 필요하다면: Iterator와 그 remove() 메서드를 사용하세요.
- List에서 순회 중 삭제뿐 아니라 추가도 하고 싶다면: ListIterator를 사용하세요.
컬렉션 타입별 특징
- List: 여기서 설명한 모든 접근(Iterator, ListIterator, removeIf)을 지원합니다.
- Set: 인덱스는 없지만 표준 Iterator와 removeIf가 동작합니다.
- Map: 조건부 삭제에는 entrySet()에 대한 이터레이터를 사용하세요:
그리고 Java 8+에서는 훨씬 더 간단합니다:Map<String, Integer> map = new HashMap<>(Map.of("a", 1, "b", 2, "c", 3)); Iterator<Map.Entry<String, Integer>> it = map.entrySet().iterator(); while (it.hasNext()) { Map.Entry<String, Integer> entry = it.next(); if (entry.getValue() % 2 == 0) { it.remove(); } } System.out.println(map); // {a=1, c=3}map.entrySet().removeIf(entry -> entry.getValue() % 2 == 0);
6. 실전 예시: 사용자 필터링
사용자 목록이 있고, 18세 미만 사용자를 모두 삭제하고 싶다고 합시다.
class User {
String name;
int age;
User(String name, int age) {
this.name = name;
this.age = age;
}
@Override
public String toString() {
return name + " (" + age + ")";
}
}
List<User> users = new ArrayList<>(List.of(
new User("아냐", 17),
new User("보리스", 20),
new User("비카", 15),
new User("글레브", 25)
));
// removeIf로 미성년자 삭제
users.removeIf(user -> user.age < 18);
System.out.println(users); // [보리스 (20), 글레브 (25)]
7. 접근 방식 비교
간단한 표로 정리해 봅시다. 이런 형식이 머릿속에 잘 남습니다.
| 방법 | 지원 버전 | 간결함 | 안전성 | 유연성 |
|---|---|---|---|---|
|
Java 5+ | - | ❌ | - |
|
Java 5+ | + | ✅ | + |
|
Java 5+ | + | ✅ | ++ |
|
Java 8+ | ++ | ✅ | + |
8. 컬렉션에서 요소를 삭제할 때의 흔한 실수
오류 №1: for-each에서 요소를 삭제하려는 시도
for (String s : list) {
if (s.equals("test")) {
list.remove(s);
}
}
이미 아시다시피 이렇게 하면 안 됩니다 — ConcurrentModificationException이 발생합니다! 이터레이터나 removeIf를 사용하세요.
오류 №2: next() 없이 이터레이터의 remove() 호출
Iterator<String> it = list.iterator();
it.remove(); // IllegalStateException — next()를 호출하기 전에는 삭제할 수 없습니다
오류 №3: 변경 불가능한 컬렉션에서 요소를 삭제하려는 시도
List<String> immutable = List.of("a", "b", "c");
immutable.removeIf(s -> s.equals("a")); // UnsupportedOperationException
불변(immutable) 컬렉션에서는 삭제 메서드가 지원되지 않습니다.
오류 №4: 이터레이터 없이 values() 또는 keySet()을 통해 Map에서 요소를 삭제하려는 시도
for (String key : map.keySet()) {
if (key.startsWith("a")) {
map.remove(key); // ConcurrentModificationException!
}
}
entrySet()에 대한 이터레이터를 사용하거나 removeIf를 사용하세요.
GO TO FULL VERSION