Iterable과 Iterator
Iterable과 Iterator를 직접 구현하며 향상된 for문이 동작하는 방식과 hasNext·next의 규칙을 익히고, 순회 중에 목록을 바꾸면 생기는 ConcurrentModificationException과 안전하게 지우는 방법을 배웁니다.
향상된 for문은 ArrayList에도, LinkedList에도, HashSet에도 같은 모양으로 씁니다. 그런데 14장에서 동적 배열과 단방향 연결 목록을 직접 만들어 보면서 확인했듯이, 배열 목록은 인덱스를 하나씩 늘려 가며 원소를 읽고 연결 목록은 노드의 next 참조를 따라가며 읽습니다. 저장 방식이 이렇게 다른데도 원소를 꺼내 쓰는 코드가 똑같을 수 있는 것은, 자바가 '처음부터 끝까지 하나씩 꺼내기'라는 일을 저장 구조에서 떼어 내 두 인터페이스에 맡겼기 때문입니다.
이 글에서는 그 두 인터페이스 Iterable과 Iterator를 직접 구현합니다. 배열 목록과 연결 목록에 반복자를 붙여 같은 향상된 for문으로 순회하고, hasNext()가 상태를 바꾸면 무엇이 틀어지는지 확인합니다. 마지막으로 순회하는 도중에 목록에서 원소를 지우면 왜 ConcurrentModificationException이 나는지, 지워야 할 때는 어떻게 해야 하는지 살펴봅니다.
Iterable과 Iterator가 나눠 맡는 일
Iterable은 '순회를 시작할 수 있는 대상'을 나타내는 인터페이스이고, 핵심 메서드는 새 반복자를 돌려주는 iterator() 하나입니다. 반복자(iterator)는 '지금 어디까지 읽었는지 기억하는 순회 도구'로, Iterator 인터페이스로 표현합니다. 다음 원소가 남아 있는지 묻는 hasNext()와, 그 원소를 돌려주면서 한 칸 나아가는 next()가 반복자의 핵심입니다.
// java.lang.Iterable의 핵심
public interface Iterable<T> {
Iterator<T> iterator();
}
// java.util.Iterator의 핵심
public interface Iterator<E> {
boolean hasNext();
E next();
}실제 인터페이스에는 forEach, remove처럼 구현이 미리 들어 있는 default 메서드가 더 있지만, 순회에 꼭 필요한 것은 위의 세 메서드입니다. remove는 이 글의 뒷부분에서 다룹니다.
두 역할을 나눈 이유는 한 목록을 여러 번 순회하거나 두 순회를 겹쳐서 진행할 수 있게 하기 위해서입니다. 읽은 위치를 목록이 직접 들고 있다면 한 번 끝까지 읽은 뒤에는 위치를 되돌려야 하고, 반복문 안에서 같은 목록을 다시 돌면 두 순회의 위치가 뒤섞입니다. 위치를 반복자 객체에 따로 두면 iterator()를 부를 때마다 처음부터 시작하는 새 순회가 생기고, 반복자끼리는 서로 영향을 주지 않습니다. 반복자 하나가 끝까지 읽었다고 목록의 원소가 사라지는 것도 아닙니다. 끝난 것은 그 반복자의 순회일 뿐입니다.
향상된 for문이 실제로 하는 일
향상된 for문에 Iterable 객체를 넣으면, 컴파일러는 그 반복문을 반복자를 쓰는 평범한 for문으로 바꿉니다. 예를 들어 titles가 Iterable<String>일 때 아래 두 반복문은 같은 뜻입니다. 위가 우리가 쓰는 코드이고, 아래가 컴파일러가 바꾼 모양입니다.
// 우리가 쓰는 코드
for (String title : titles) {
System.out.println(title);
}
// 컴파일러가 바꾼 모양
for (Iterator<String> it = titles.iterator(); it.hasNext(); ) {
String title = it.next();
System.out.println(title);
}반복을 시작할 때 iterator()를 한 번 불러 새 반복자를 얻고, 매 바퀴 hasNext()로 남은 원소가 있는지 물은 뒤 next()로 꺼낸 원소를 반복 변수에 담습니다. 위 코드의 it은 설명하려고 붙인 이름일 뿐이고, 실제 반복자는 컴파일러가 만든 숨은 변수라서 반복문 본문에서 쓸 수 없습니다. 이 변환 규칙은 Java 언어 명세 14.14.2절에 정해져 있으며, 배열을 넣은 향상된 for문은 반복자 대신 인덱스를 0부터 늘려 가는 반복문으로 바뀝니다.
그래서 향상된 for문의 콜론 오른쪽에는 배열이나 Iterable을 구현한 객체만 올 수 있습니다. 아무 인터페이스도 구현하지 않은 Box 객체를 넣어 보면 컴파일러가 거부합니다.
public final class NotIterable {
public static void main(String[] args) {
Box box = new Box();
for (String s : box) System.out.println(s);
}
static final class Box {}
}Box에는 iterator() 메서드도 없고 Iterable 선언도 없으므로, 이 코드는 실행까지 가지 못하고 컴파일 단계에서 멈춥니다. 오류 메시지가 콜론 오른쪽에 무엇을 요구하는지 보세요.
NotIterable.java:4: error: for-each not applicable to expression type
for (String s : box) System.out.println(s);
^
required: array or java.lang.Iterable
found: Box
1 error오류 메시지의 required: array or java.lang.Iterable이 바로 그 조건입니다. 거꾸로 말하면 어떤 클래스든 Iterable을 구현해 iterator()에서 제대로 된 반복자를 돌려주기만 하면 향상된 for문에 쓸 수 있습니다. 이제 직접 만든 배열 목록에 반복자를 붙여 보겠습니다.
배열 목록에 반복자 붙이기
배열 목록의 반복자에게 필요한 상태는 '다음에 돌려줄 원소의 인덱스' 하나뿐이고, 이 값을 cursor라고 부르겠습니다. 처음 cursor가 0이라는 것은 아직 아무것도 돌려주지 않았다는 뜻입니다. hasNext()는 cursor < size인지만 계산하고, next()는 남은 원소가 있는지 확인한 다음 cursor 위치의 값을 돌려주고 cursor를 하나 늘립니다. 원소가 없는데 next()를 부르면 Iterator 문서가 정한 대로 NoSuchElementException을 던집니다.
import java.util.Arrays;
import java.util.Iterator;
import java.util.NoSuchElementException;
public final class IterableArrayList {
public static void main(String[] args) {
MyArrayList<String> titles = new MyArrayList<>();
titles.add("iterator");
titles.add("iterable");
titles.add("for-each");
for (String title : titles) {
System.out.println(title);
}
Iterator<String> first = titles.iterator();
Iterator<String> second = titles.iterator();
System.out.println("independent=" + first.next() + "/" + second.next());
}
private static final class MyArrayList<E> implements Iterable<E> {
private Object[] values = new Object[2];
private int size;
void add(E value) {
if (size == values.length) values = Arrays.copyOf(values, size * 2);
values[size++] = value;
}
@SuppressWarnings("unchecked")
E get(int index) {
if (index < 0 || index >= size) throw new IndexOutOfBoundsException(index);
return (E) values[index];
}
public Iterator<E> iterator() {
return new Iterator<>() {
private int cursor;
public boolean hasNext() {
return cursor < size;
}
public E next() {
if (!hasNext()) throw new NoSuchElementException();
return get(cursor++);
}
};
}
}
}main은 향상된 for문으로 세 제목을 출력한 뒤, iterator()를 두 번 불러 받은 두 반복자에서 각각 next()를 한 번씩 부릅니다. 마지막 줄은 두 반복자가 위치를 따로 기억하는지 확인하는 출력입니다.
iterator
iterable
for-each
independent=iterator/iteratoriterator() 안의 new Iterator<>() { ... }는 익명 클래스입니다. 익명 클래스는 바깥 목록의 size와 get을 그대로 쓸 수 있으므로, 반복자는 위치만 따로 들고 원소는 목록에서 읽어 옵니다. get은 배열 칸을 바로 읽으니 next() 한 번은 상수 시간이고, 전체 순회는 원소 수에 비례하는 O(n)입니다.
세 값 순회 뒤 first와 second는 각각 처음에서 시작한다
같은 목록에서 for-each와 별도 두 Iterator의 커서가 독립 전진합니다. 도구를 고정하고 각 호출의 cursor 변화만 보여 주면 저장값·커서 분리가 선명합니다.
향상된 for문은 iterator, iterable, for-each를 순서대로 출력합니다. 그 뒤 새로 만든 first와 second는 각각 cursor 0에서 iterator를 반환합니다. 커서 상태는 코드 추적입니다.
ITERABLE · 독립 커서
세 값 순회 뒤 first와 second는 각각 처음에서 시작한다
for-each의 커서가 끝나도 새 반복자 first와 second는 각각 첫 원소에서 시작합니다.
| 반복자·실제 next 호출 | cursor 전 · 코드 추적 | 반환값 | cursor 후 · 코드 추적 |
|---|---|---|---|
| for-each · 1 | 0 | iterator | 1 |
| for-each · 2 | 1 | iterable | 2 |
| for-each · 3 | 2 | for-each | 3 |
first.next() | 0 | iterator | 1 |
second.next() | 0 | iterator | 1 |
- for-each · 1
- cursor 전 · 코드 추적
0- 반환값
iterator- cursor 후 · 코드 추적
1
- for-each · 2
- cursor 전 · 코드 추적
1- 반환값
iterable- cursor 후 · 코드 추적
2
- for-each · 3
- cursor 전 · 코드 추적
2- 반환값
for-each- cursor 후 · 코드 추적
3
first.next()- cursor 전 · 코드 추적
0- 반환값
iterator- cursor 후 · 코드 추적
1
second.next()- cursor 전 · 코드 추적
0- 반환값
iterator- cursor 후 · 코드 추적
1
이 예의 조건
- 세 순회 도구는 같은 세 값을 별도 커서로 읽는다.
관계도의 보충 설명
| 구분 | 내용 |
|---|---|
| 2 | 세 영역은 for-each, first, second의 별도 Iterator입니다. 각각 cursor 0에서 시작하며 first.next와 second.next가 모두 문자열 iterator를 반환합니다. 소진된 순회는 목록을 비우거나 다른 커서를 옮기지 않습니다. |
| 구분 | 내용 |
|---|---|
| 설명 | 세 영역은 for-each, first, second의 별도 Iterator입니다. 각각 cursor 0에서 시작하며 first.next와 second.next가 모두 문자열 iterator를 반환합니다. 소진된 순회는 목록을 비우거나 다른 커서를 옮기지 않습니다. |
| 전체 읽기 정보 | 세 값 순회 뒤 first와 second는 각각 처음에서 시작한다 세 값 순회 뒤 first와 second는 각각 처음에서 시작한다 향상된 for문은 iterator, iterable, for-each를 순서대로 출력합니다. 그 뒤 새로 만든 first와 second는 각각 cursor 0에서 iterator를 반환합니다. 커서 상태는 코드 추적입니다. ITERABLE · 독립 커서 세 값 순회 뒤 first와 second는 각각 처음에서 시작한다 for-each의 커서가 끝나도 새 반복자 first와 second는 각각 첫 원소에서 시작합니다. 세 값 순회 뒤 first와 second는 각각 처음에서 시작한다 반복자·실제 next 호출 cursor 전 · 코드 추적 반환값 cursor 후 · 코드 추적 for-each · 1 0 iterator 1 for-each · 2 1 iterable 2 for-each · 3 2 for-each 3 first.next() 0 iterator 1 second.next() 0 iterator 1 for-each · 1 cursor 전 · 코드 추적 0 반환값 iterator cursor 후 · 코드 추적 1 for-each · 2 cursor 전 · 코드 추적 1 반환값 iterable cursor 후 · 코드 추적 2 for-each · 3 cursor 전 · 코드 추적 2 반환값 for-each cursor 후 · 코드 추적 3 first.next() cursor 전 · 코드 추적 0 반환값 iterator cursor 후 · 코드 추적 1 second.next() cursor 전 · 코드 추적 0 반환값 iterator cursor 후 · 코드 추적 1 |
| 관찰·상황 | 값·보충 정보 | 변화·이유 |
|---|---|---|
| 독립 시작 for-each/first/second가 각각 cursor 0을 만듭니다. |
|
|
| 각 소비 for-each는 세 값을 읽고 first/second는 각각 첫 값을 읽습니다. |
|
|
| 저장 보존 목록과 독립 두 커서 상태를 확인합니다. |
|
|
세 값 순회 뒤 first와 second는 각각 처음에서 시작한다
같은 목록에서 for-each와 별도 두 Iterator의 커서가 독립 전진합니다. 도구를 고정하고 각 호출의 cursor 변화만 보여 주면 저장값·커서 분리가 선명합니다.
향상된 for문은 iterator, iterable, for-each를 순서대로 출력합니다. 그 뒤 새로 만든 first와 second는 각각 cursor 0에서 iterator를 반환합니다. 커서 상태는 코드 추적입니다.
ITERABLE · 독립 커서
세 값 순회 뒤 first와 second는 각각 처음에서 시작한다
for-each의 커서가 끝나도 새 반복자 first와 second는 각각 첫 원소에서 시작합니다.
| 반복자·실제 next 호출 | cursor 전 · 코드 추적 | 반환값 | cursor 후 · 코드 추적 |
|---|---|---|---|
| for-each · 1 | 0 | iterator | 1 |
| for-each · 2 | 1 | iterable | 2 |
| for-each · 3 | 2 | for-each | 3 |
first.next() | 0 | iterator | 1 |
second.next() | 0 | iterator | 1 |
- for-each · 1
- cursor 전 · 코드 추적
0- 반환값
iterator- cursor 후 · 코드 추적
1
- for-each · 2
- cursor 전 · 코드 추적
1- 반환값
iterable- cursor 후 · 코드 추적
2
- for-each · 3
- cursor 전 · 코드 추적
2- 반환값
for-each- cursor 후 · 코드 추적
3
first.next()- cursor 전 · 코드 추적
0- 반환값
iterator- cursor 후 · 코드 추적
1
second.next()- cursor 전 · 코드 추적
0- 반환값
iterator- cursor 후 · 코드 추적
1
이 예의 조건
- 세 순회 도구는 같은 세 값을 별도 커서로 읽는다.
관계도의 보충 설명
| 구분 | 내용 |
|---|---|
| 2 | 세 영역은 for-each, first, second의 별도 Iterator입니다. 각각 cursor 0에서 시작하며 first.next와 second.next가 모두 문자열 iterator를 반환합니다. 소진된 순회는 목록을 비우거나 다른 커서를 옮기지 않습니다. |
| 구분 | 내용 |
|---|---|
| 설명 | 세 영역은 for-each, first, second의 별도 Iterator입니다. 각각 cursor 0에서 시작하며 first.next와 second.next가 모두 문자열 iterator를 반환합니다. 소진된 순회는 목록을 비우거나 다른 커서를 옮기지 않습니다. |
| 전체 읽기 정보 | 세 값 순회 뒤 first와 second는 각각 처음에서 시작한다 세 값 순회 뒤 first와 second는 각각 처음에서 시작한다 향상된 for문은 iterator, iterable, for-each를 순서대로 출력합니다. 그 뒤 새로 만든 first와 second는 각각 cursor 0에서 iterator를 반환합니다. 커서 상태는 코드 추적입니다. ITERABLE · 독립 커서 세 값 순회 뒤 first와 second는 각각 처음에서 시작한다 for-each의 커서가 끝나도 새 반복자 first와 second는 각각 첫 원소에서 시작합니다. 세 값 순회 뒤 first와 second는 각각 처음에서 시작한다 반복자·실제 next 호출 cursor 전 · 코드 추적 반환값 cursor 후 · 코드 추적 for-each · 1 0 iterator 1 for-each · 2 1 iterable 2 for-each · 3 2 for-each 3 first.next() 0 iterator 1 second.next() 0 iterator 1 for-each · 1 cursor 전 · 코드 추적 0 반환값 iterator cursor 후 · 코드 추적 1 for-each · 2 cursor 전 · 코드 추적 1 반환값 iterable cursor 후 · 코드 추적 2 for-each · 3 cursor 전 · 코드 추적 2 반환값 for-each cursor 후 · 코드 추적 3 first.next() cursor 전 · 코드 추적 0 반환값 iterator cursor 후 · 코드 추적 1 second.next() cursor 전 · 코드 추적 0 반환값 iterator cursor 후 · 코드 추적 1 |
| 관찰·상황 | 값·보충 정보 | 변화·이유 |
|---|---|---|
| 독립 시작 for-each/first/second가 각각 cursor 0을 만듭니다. |
|
|
| 각 소비 for-each는 세 값을 읽고 first/second는 각각 첫 값을 읽습니다. |
|
|
| 저장 보존 목록과 독립 두 커서 상태를 확인합니다. |
|
|
처음 세 줄은 향상된 for문이 만든 반복자가 cursor 0, 1, 2에서 차례로 값을 돌려준 결과입니다. cursor가 3이 되면 hasNext()가 false를 돌려주어 반복이 끝납니다. 마지막 줄에서 first와 second가 둘 다 iterator를 돌려준 것은, 앞의 순회가 끝난 뒤 새로 얻은 두 반복자가 각자 cursor 0에서 시작했기 때문입니다. 먼저 끝난 순회도, 옆의 반복자도 서로의 위치를 바꾸지 않습니다.
hasNext가 커서를 옮기면 생기는 일
hasNext()는 묻기만 하고, 원소를 소비하며 나아가는 것은 next()의 일입니다. 이 구분을 어기면 어떻게 되는지 보겠습니다. 아래 Broken은 hasNext()에서 ++cursor로 커서를 먼저 늘리고, next()에서도 cursor++로 한 번 더 늘립니다.
import java.util.Iterator;
public final class AdvancingHasNextFailure {
public static void main(String[] args) {
Iterator<Integer> iterator = new Broken(new int[] {10, 20, 30, 40});
while (iterator.hasNext()) {
System.out.println(iterator.next());
}
}
private static final class Broken implements Iterator<Integer> {
private final int[] values;
private int cursor;
Broken(int[] values) {
this.values = values;
}
public boolean hasNext() {
return ++cursor < values.length;
}
public Integer next() {
return values[cursor++];
}
}
}main의 while문은 반복자를 쓰는 평범한 모양이고, 배열에는 원소가 네 개 있습니다. 출력에 원소가 몇 개 나오는지 세어 보세요.
20
40잘못된 hasNext가 10과 30을 건너뛴다
hasNext가 커서를 잘못 전진시켜 10·30을 건너뛰고 20·40만 반환합니다. 다섯 호출의 cursor 변화가 계약 위반의 직접 원인입니다.
원문 main의 다섯 호출을 따라가면 hasNext와 next가 모두 커서를 증가시켜 20과 40을 출력하고 정상 종료합니다. 각 호출 전후 cursor는 코드에서 추적한 상태입니다.
ITERATOR · 잘못된 커서 이동
잘못된 hasNext가 10과 30을 건너뛴다
hasNext와 next가 모두 커서를 증가시키면, 출력은 20·40만 남습니다.
실선: 원 다섯 호출 순서질문과 반환이 모두 전진20·40만 stdout에 출력
| 실제 호출 순서 | cursor 전 · 코드 추적 | 반환·결과 | cursor 후 · 코드 추적 |
|---|---|---|---|
hasNext() 1 | 0 | true | 1 |
next() 1 | 1 | 20 출력 | 2 |
hasNext() 2 | 2 | true | 3 |
next() 2 | 3 | 40 출력 | 4 |
hasNext() 3 | 4 | false · 정상 종료 | 5 |
hasNext()1- cursor 전 · 코드 추적
0- 반환·결과
true- cursor 후 · 코드 추적
1
next()1- cursor 전 · 코드 추적
1- 반환·결과
20출력- cursor 후 · 코드 추적
2
hasNext()2- cursor 전 · 코드 추적
2- 반환·결과
true- cursor 후 · 코드 추적
3
next()2- cursor 전 · 코드 추적
3- 반환·결과
40출력- cursor 후 · 코드 추적
4
hasNext()3- cursor 전 · 코드 추적
4- 반환·결과
false· 정상 종료- cursor 후 · 코드 추적
5
이 예의 조건
- 잘못된 hasNext가 커서를 전진시키는 원 호출 순서이다.
관계도의 보충 설명
| 구분 | 내용 |
|---|---|
| 2 | 원 Broken의 다섯 호출을 실제 순서로 연결합니다. hasNext가 0→1과 2→3으로 전진하여 10과 30을 건너뛰고 next는 20·40을 반환합니다. 마지막 cursor 5와 false는 정상 종료입니다. |
| 4 | cursor 0→1 |
| 8 | index 1의 20 반환 |
| 11 | cursor 2→3 |
| 15 | index 3의 40 반환 |
| 18 | cursor 4→5 |
| 구분 | 내용 |
|---|---|
| 설명 | 원 Broken의 다섯 호출을 실제 순서로 연결합니다. hasNext가 0→1과 2→3으로 전진하여 10과 30을 건너뛰고 next는 20·40을 반환합니다. 마지막 cursor 5와 false는 정상 종료입니다. |
| 전체 읽기 정보 | 잘못된 hasNext가 10과 30을 건너뛴다 잘못된 hasNext가 10과 30을 건너뛴다 원문 main의 다섯 호출을 따라가면 hasNext와 next가 모두 커서를 증가시켜 20과 40을 출력하고 정상 종료합니다. 각 호출 전후 cursor는 코드에서 추적한 상태입니다. ITERATOR · 잘못된 커서 이동 잘못된 hasNext가 10과 30을 건너뛴다 hasNext와 next가 모두 커서를 증가시키면, 출력은 20·40만 남습니다. 잘못된 hasNext가 10과 30을 건너뛴다 실제 호출 순서 cursor 전 · 코드 추적 반환·결과 cursor 후 · 코드 추적 hasNext() 1 0 true 1 next() 1 1 20 출력 2 hasNext() 2 2 true 3 next() 2 3 40 출력 4 hasNext() 3 4 false · 정상 종료 5 hasNext() 1 cursor 전 · 코드 추적 0 반환·결과 true cursor 후 · 코드 추적 1 next() 1 cursor 전 · 코드 추적 1 반환·결과 20 출력 cursor 후 · 코드 추적 2 hasNext() 2 cursor 전 · 코드 추적 2 반환·결과 true cursor 후 · 코드 추적 3 next() 2 cursor 전 · 코드 추적 3 반환·결과 40 출력 cursor 후 · 코드 추적 4 hasNext() 3 cursor 전 · 코드 추적 4 반환·결과 false · 정상 종료 cursor 후 · 코드 추적 5 |
| 관찰·상황 | 값·보충 정보 | 변화·이유 |
|---|---|---|
| 첫 질문/읽기 hasNext가 10을 건너뛰고 next가 20을 반환합니다. |
|
|
| 둘째 질문/읽기 다시 질문이 30을 건너뛰고 next가 40을 반환합니다. |
|
|
| 마지막 질문 커서 4→5로 움직이고 false가 됩니다. |
|
|
잘못된 hasNext가 10과 30을 건너뛴다
hasNext가 커서를 잘못 전진시켜 10·30을 건너뛰고 20·40만 반환합니다. 다섯 호출의 cursor 변화가 계약 위반의 직접 원인입니다.
원문 main의 다섯 호출을 따라가면 hasNext와 next가 모두 커서를 증가시켜 20과 40을 출력하고 정상 종료합니다. 각 호출 전후 cursor는 코드에서 추적한 상태입니다.
ITERATOR · 잘못된 커서 이동
잘못된 hasNext가 10과 30을 건너뛴다
hasNext와 next가 모두 커서를 증가시키면, 출력은 20·40만 남습니다.
실선: 원 다섯 호출 순서질문과 반환이 모두 전진20·40만 stdout에 출력
| 실제 호출 순서 | cursor 전 · 코드 추적 | 반환·결과 | cursor 후 · 코드 추적 |
|---|---|---|---|
hasNext() 1 | 0 | true | 1 |
next() 1 | 1 | 20 출력 | 2 |
hasNext() 2 | 2 | true | 3 |
next() 2 | 3 | 40 출력 | 4 |
hasNext() 3 | 4 | false · 정상 종료 | 5 |
hasNext()1- cursor 전 · 코드 추적
0- 반환·결과
true- cursor 후 · 코드 추적
1
next()1- cursor 전 · 코드 추적
1- 반환·결과
20출력- cursor 후 · 코드 추적
2
hasNext()2- cursor 전 · 코드 추적
2- 반환·결과
true- cursor 후 · 코드 추적
3
next()2- cursor 전 · 코드 추적
3- 반환·결과
40출력- cursor 후 · 코드 추적
4
hasNext()3- cursor 전 · 코드 추적
4- 반환·결과
false· 정상 종료- cursor 후 · 코드 추적
5
이 예의 조건
- 잘못된 hasNext가 커서를 전진시키는 원 호출 순서이다.
관계도의 보충 설명
| 구분 | 내용 |
|---|---|
| 2 | 원 Broken의 다섯 호출을 실제 순서로 연결합니다. hasNext가 0→1과 2→3으로 전진하여 10과 30을 건너뛰고 next는 20·40을 반환합니다. 마지막 cursor 5와 false는 정상 종료입니다. |
| 4 | cursor 0→1 |
| 8 | index 1의 20 반환 |
| 11 | cursor 2→3 |
| 15 | index 3의 40 반환 |
| 18 | cursor 4→5 |
| 구분 | 내용 |
|---|---|
| 설명 | 원 Broken의 다섯 호출을 실제 순서로 연결합니다. hasNext가 0→1과 2→3으로 전진하여 10과 30을 건너뛰고 next는 20·40을 반환합니다. 마지막 cursor 5와 false는 정상 종료입니다. |
| 전체 읽기 정보 | 잘못된 hasNext가 10과 30을 건너뛴다 잘못된 hasNext가 10과 30을 건너뛴다 원문 main의 다섯 호출을 따라가면 hasNext와 next가 모두 커서를 증가시켜 20과 40을 출력하고 정상 종료합니다. 각 호출 전후 cursor는 코드에서 추적한 상태입니다. ITERATOR · 잘못된 커서 이동 잘못된 hasNext가 10과 30을 건너뛴다 hasNext와 next가 모두 커서를 증가시키면, 출력은 20·40만 남습니다. 잘못된 hasNext가 10과 30을 건너뛴다 실제 호출 순서 cursor 전 · 코드 추적 반환·결과 cursor 후 · 코드 추적 hasNext() 1 0 true 1 next() 1 1 20 출력 2 hasNext() 2 2 true 3 next() 2 3 40 출력 4 hasNext() 3 4 false · 정상 종료 5 hasNext() 1 cursor 전 · 코드 추적 0 반환·결과 true cursor 후 · 코드 추적 1 next() 1 cursor 전 · 코드 추적 1 반환·결과 20 출력 cursor 후 · 코드 추적 2 hasNext() 2 cursor 전 · 코드 추적 2 반환·결과 true cursor 후 · 코드 추적 3 next() 2 cursor 전 · 코드 추적 3 반환·결과 40 출력 cursor 후 · 코드 추적 4 hasNext() 3 cursor 전 · 코드 추적 4 반환·결과 false · 정상 종료 cursor 후 · 코드 추적 5 |
| 관찰·상황 | 값·보충 정보 | 변화·이유 |
|---|---|---|
| 첫 질문/읽기 hasNext가 10을 건너뛰고 next가 20을 반환합니다. |
|
|
| 둘째 질문/읽기 다시 질문이 30을 건너뛰고 next가 40을 반환합니다. |
|
|
| 마지막 질문 커서 4→5로 움직이고 false가 됩니다. |
|
|
네 원소 가운데 20과 40만 출력되고 프로그램은 예외 없이 끝났습니다. 호출 순서대로 cursor를 따라가면 원인이 보입니다.
| 호출 | 호출 전 cursor | 결과 | 호출 후 cursor |
|---|---|---|---|
hasNext() | 0 | true | 1 |
next() | 1 | 20을 돌려줌 | 2 |
hasNext() | 2 | true | 3 |
next() | 3 | 40을 돌려줌 | 4 |
hasNext() | 4 | false | 5 |
hasNext()가 불릴 때마다 커서를 한 칸씩 밀어 버리니, 인덱스 0의 10과 인덱스 2의 30은 아무도 읽지 않은 채 지나갑니다. 게다가 hasNext()는 늘린 위치가 배열 안에 있을 때만 true를 돌려주므로 next()가 배열 범위를 벗어나는 일도 없습니다. 예외 없이 조용히 원소를 잃기 때문에, 출력만 대충 보면 버그를 알아차리기 어렵습니다.
더 근본적인 문제는 질문하는 횟수가 결과를 바꾼다는 점입니다. main의 본문을 아래처럼 바꿔, 새 Broken 반복자에 hasNext()를 두 번 연달아 부른 뒤 next()를 불러 보세요.
Iterator<Integer> iterator = new Broken(new int[] {10, 20, 30, 40});
System.out.println(iterator.hasNext()); // true
System.out.println(iterator.hasNext()); // true
System.out.println(iterator.next()); // 30두 질문이 모두 true를 돌려주는 동안 cursor는 0에서 2로 움직였고, next()는 첫 원소 10이 아니라 30을 돌려줍니다. Iterator 문서는 hasNext()를 'next()를 부르면 예외 대신 원소가 나올 때 true를 돌려주는 메서드'로 정의합니다. 즉 hasNext()는 지금 next()를 부르면 어떻게 될지에 대한 대답일 뿐이므로, 대답하는 행위가 next()의 결과를 바꿔서는 안 됩니다. 반복자를 쓰는 코드도 hasNext()를 몇 번 부르든 결과가 같다고 믿고 작성됩니다. 그러니 커서는 next()에서만 한 칸 움직여야 합니다.
연결 목록에 반복자 붙이기
연결 목록에는 인덱스로 바로 찾아가는 방법이 없습니다. 그래서 직접 만든 단방향 연결 목록의 반복자가 배열 목록처럼 get(cursor)를 부르면, 원소 하나를 읽을 때마다 head부터 다시 걸어가야 합니다. 원소 n개를 get(0), get(1), …, get(n-1) 순서로 읽으면 노드를 건너가는 횟수는 0 + 1 + … + (n−1) = n(n−1)/2번입니다. 원소가 3개면 3번이지만 1,000개면 499,500번으로, 원소 수의 제곱에 비례해 늘어납니다.
그래서 연결 목록의 반복자는 인덱스 대신 '다음에 돌려줄 노드'를 가리키는 current를 직접 들고 다닙니다. hasNext()는 current가 null이 아닌지만 보고, next()는 지금 노드의 값을 꺼내 둔 뒤 current = current.next로 한 칸 옮기고 그 값을 돌려줍니다. 노드마다 다음 참조를 한 번씩만 따라가므로 전체 순회가 O(n)이고, 목록의 크기를 몰라도 null을 만날 때까지 읽을 수 있습니다.
import java.util.Iterator;
import java.util.NoSuchElementException;
public final class LinkedNodeIterator {
public static void main(String[] args) {
Chain<Integer> chain = new Chain<>();
chain.addLast(30);
chain.addLast(40);
chain.addLast(50);
int total = 0;
for (int value : chain) {
total += value;
}
System.out.println("total=" + total);
}
private static final class Chain<E> implements Iterable<E> {
private Node<E> head, tail;
void addLast(E value) {
Node<E> n = new Node<>(value);
if (tail == null) head = tail = n;
else {
tail.next = n;
tail = n;
}
}
public Iterator<E> iterator() {
return new Iterator<>() {
private Node<E> current = head;
public boolean hasNext() {
return current != null;
}
public E next() {
if (current == null) throw new NoSuchElementException();
E value = current.value;
current = current.next;
return value;
}
};
}
}
private static final class Node<E> {
final E value;
Node<E> next;
Node(E value) {
this.value = value;
}
}
}main의 향상된 for문은 Chain의 반복자로 30, 40, 50을 차례로 읽어 total에 더합니다. 노드를 따라가던 반복자가 마지막 노드 뒤에서 어떻게 멈추는지는 출력 다음의 단계별 도표로 따라가 봅니다.
total=120current는 30·40·50을 지나 null에 도달한다
next마다 current가 30→40→50→null로 이동하며 total 120을 만듭니다. 고정 next 구조에서 커서·반환값을 단계로 추적합니다.
연결 목록의 next는 현재 노드의 값을 반환하고 반복자의 current를 다음 노드로 옮깁니다. 마지막 값 50 뒤에는 current가 null이 되어 순회가 끝납니다. 노드 상태는 코드 추적이며 실제 출력은 total=120입니다.
ITERATOR · 연결 노드
current는 30·40·50을 지나 null에 도달한다
next는 현재 노드의 값을 반환한 뒤 다음 노드로 이동합니다. 50 이후에는 current가 null입니다.
실선: Node.next 참조점선: 초기 current 참조next마다 current를 다음으로반환30+40+50=total120
next()1- current 전 · 코드 추적
- 값
30의 노드 - 반환·판정
30- current 후 · 코드 추적
- 값
40의 노드
next()2- current 전 · 코드 추적
- 값
40의 노드 - 반환·판정
40- current 후 · 코드 추적
- 값
50의 노드
next()3- current 전 · 코드 추적
- 값
50의 노드 - 반환·판정
50- current 후 · 코드 추적
null
마지막
hasNext()- current 전 · 코드 추적
null- 반환·판정
false· 순회 종료- current 후 · 코드 추적
null
이 예의 조건
- null은 노드 객체가 아닌 종단 참조이다.
관계도의 보충 설명
| 구분 | 내용 |
|---|---|
| 2 | 실선은 30→40→50→null의 Node.next 참조이며 점선은 처음 current가 30의 Node를 가리키는 참조입니다. next마다 current를 다음 Node로 옮겨 세 값을 반환하고 total 120입니다. null은 노드가 아닙니다. |
| 18 | 반환 30+40+50=total 120 |
| 구분 | 내용 |
|---|---|
| 설명 | 실선은 30→40→50→null의 Node.next 참조이며 점선은 처음 current가 30의 Node를 가리키는 참조입니다. next마다 current를 다음 Node로 옮겨 세 값을 반환하고 total 120입니다. null은 노드가 아닙니다. |
| 전체 읽기 정보 | current는 30·40·50을 지나 null에 도달한다 current는 30·40·50을 지나 null에 도달한다 연결 목록의 next는 현재 노드의 값을 반환하고 반복자의 current를 다음 노드로 옮깁니다. 마지막 값 50 뒤에는 current가 null이 되어 순회가 끝납니다. 노드 상태는 코드 추적이며 실제 출력은 total=120입니다. ITERATOR · 연결 노드 current는 30·40·50을 지나 null에 도달한다 next는 현재 노드의 값을 반환한 뒤 다음 노드로 이동합니다. 50 이후에는 current가 null입니다. next() 1 current 전 · 코드 추적 값 30 의 노드 반환·판정 30 current 후 · 코드 추적 값 40 의 노드 next() 2 current 전 · 코드 추적 값 40 의 노드 반환·판정 40 current 후 · 코드 추적 값 50 의 노드 next() 3 current 전 · 코드 추적 값 50 의 노드 반환·판정 50 current 후 · 코드 추적 null 마지막 hasNext() current 전 · 코드 추적 null 반환·판정 false · 순회 종료 current 후 · 코드 추적 null |
| 관찰·상황 | 값·보충 정보 | 변화·이유 |
|---|---|---|
| 첫 next current 30에서 값을 반환하고 40으로 이동합니다. |
|
|
| 둘째 next 40을 반환하고 50으로 이동합니다. |
|
|
| 끝 소비 50을 반환한 뒤 null에 도달합니다. |
|
|
current는 30·40·50을 지나 null에 도달한다
next마다 current가 30→40→50→null로 이동하며 total 120을 만듭니다. 고정 next 구조에서 커서·반환값을 단계로 추적합니다.
연결 목록의 next는 현재 노드의 값을 반환하고 반복자의 current를 다음 노드로 옮깁니다. 마지막 값 50 뒤에는 current가 null이 되어 순회가 끝납니다. 노드 상태는 코드 추적이며 실제 출력은 total=120입니다.
ITERATOR · 연결 노드
current는 30·40·50을 지나 null에 도달한다
next는 현재 노드의 값을 반환한 뒤 다음 노드로 이동합니다. 50 이후에는 current가 null입니다.
실선: Node.next 참조점선: 초기 current 참조next마다 current를 다음으로반환30+40+50=total120
next()1- current 전 · 코드 추적
- 값
30의 노드 - 반환·판정
30- current 후 · 코드 추적
- 값
40의 노드
next()2- current 전 · 코드 추적
- 값
40의 노드 - 반환·판정
40- current 후 · 코드 추적
- 값
50의 노드
next()3- current 전 · 코드 추적
- 값
50의 노드 - 반환·판정
50- current 후 · 코드 추적
null
마지막
hasNext()- current 전 · 코드 추적
null- 반환·판정
false· 순회 종료- current 후 · 코드 추적
null
이 예의 조건
- null은 노드 객체가 아닌 종단 참조이다.
관계도의 보충 설명
| 구분 | 내용 |
|---|---|
| 2 | 실선은 30→40→50→null의 Node.next 참조이며 점선은 처음 current가 30의 Node를 가리키는 참조입니다. next마다 current를 다음 Node로 옮겨 세 값을 반환하고 total 120입니다. null은 노드가 아닙니다. |
| 18 | 반환 30+40+50=total 120 |
| 구분 | 내용 |
|---|---|
| 설명 | 실선은 30→40→50→null의 Node.next 참조이며 점선은 처음 current가 30의 Node를 가리키는 참조입니다. next마다 current를 다음 Node로 옮겨 세 값을 반환하고 total 120입니다. null은 노드가 아닙니다. |
| 전체 읽기 정보 | current는 30·40·50을 지나 null에 도달한다 current는 30·40·50을 지나 null에 도달한다 연결 목록의 next는 현재 노드의 값을 반환하고 반복자의 current를 다음 노드로 옮깁니다. 마지막 값 50 뒤에는 current가 null이 되어 순회가 끝납니다. 노드 상태는 코드 추적이며 실제 출력은 total=120입니다. ITERATOR · 연결 노드 current는 30·40·50을 지나 null에 도달한다 next는 현재 노드의 값을 반환한 뒤 다음 노드로 이동합니다. 50 이후에는 current가 null입니다. next() 1 current 전 · 코드 추적 값 30 의 노드 반환·판정 30 current 후 · 코드 추적 값 40 의 노드 next() 2 current 전 · 코드 추적 값 40 의 노드 반환·판정 40 current 후 · 코드 추적 값 50 의 노드 next() 3 current 전 · 코드 추적 값 50 의 노드 반환·판정 50 current 후 · 코드 추적 null 마지막 hasNext() current 전 · 코드 추적 null 반환·판정 false · 순회 종료 current 후 · 코드 추적 null |
| 관찰·상황 | 값·보충 정보 | 변화·이유 |
|---|---|---|
| 첫 next current 30에서 값을 반환하고 40으로 이동합니다. |
|
|
| 둘째 next 40을 반환하고 50으로 이동합니다. |
|
|
| 끝 소비 50을 반환한 뒤 null에 도달합니다. |
|
|
current는 처음에 값 30의 노드를 가리키고, next()가 불릴 때마다 40, 50의 노드로 옮겨 갑니다. 50을 돌려줄 때는 마지막 노드의 next가 null이므로 current도 null이 되고, 이어지는 hasNext()가 false를 돌려줘 반복이 끝납니다. 여기서 null은 노드가 아니라 '더 이상 노드가 없다'는 표시입니다. 반복자는 노드의 연결을 건드리지 않으므로 순회가 끝나도 목록은 그대로이고, 세 값을 더한 120이 출력됩니다.
main의 향상된 for문은 배열 목록 예제와 같은 모양입니다. 반복 변수를 int value로 받았기 때문에 next()가 돌려준 Integer가 자동으로 언박싱된다는 점만 다릅니다. 원소를 쓰는 쪽은 hasNext()와 next()만 알면 되고, 인덱스를 셀지 노드를 따라갈지는 각 반복자가 정합니다.
iterator()가 부를 때마다 새 반복자를 돌려주는 이유
지금까지의 iterator()는 부를 때마다 new로 새 반복자를 만들었습니다. 이것이 번거롭다고 목록 클래스가 Iterator까지 직접 구현하고 iterator()에서 this를 돌려주면 어떻게 될까요? 아래 Titles가 그렇게 만든 클래스입니다.
import java.util.Iterator;
import java.util.NoSuchElementException;
public final class SharedCursorBug {
public static void main(String[] args) {
Titles titles = new Titles("iterator", "iterable");
for (String title : titles) {
System.out.println("first " + title);
}
for (String title : titles) {
System.out.println("second " + title);
}
System.out.println("end");
}
// 목록과 반복자를 한 객체가 겸하는 잘못된 설계
private static final class Titles implements Iterable<String>, Iterator<String> {
private final String[] values;
private int cursor;
Titles(String... values) {
this.values = values;
}
public Iterator<String> iterator() {
return this; // 매번 같은 반복자를 돌려준다
}
public boolean hasNext() {
return cursor < values.length;
}
public String next() {
if (!hasNext()) throw new NoSuchElementException();
return values[cursor++];
}
}
}main은 같은 titles 객체를 향상된 for문으로 두 번 돌고, 마지막에 end를 출력합니다. 정상이라면 first로 시작하는 두 줄과 second로 시작하는 두 줄이 모두 나와야 합니다.
first iterator
first iterable
end두 번째 반복문은 한 줄도 출력하지 않았습니다. 두 반복문이 같은 객체를 반복자로 받았는데, 그 객체의 cursor는 첫 반복이 끝났을 때 이미 2였기 때문입니다. 원소는 그대로 있는데도 두 번째 순회는 시작하자마자 끝납니다. 같은 목록을 두 겹으로 도는 중첩 반복도 망가집니다. 원소가 a, b, c인 Titles로 모든 쌍을 출력하려고 반복문을 겹치면, 안쪽 반복이 바깥쪽과 같은 커서를 끝까지 밀어 버려서 아홉 쌍이 아니라 ab, ac 두 쌍만 나오고 끝납니다.
한 번의 순회 위치는 그 순회를 위해 만든 반복자 안에만 있어야 합니다. 그래서 컬렉션의 iterator()는 부를 때마다 독립된 새 반복자를 돌려주고, 사용하는 쪽도 같은 컬렉션을 몇 번이든 처음부터 다시 순회할 수 있다고 기대합니다. 반대로 Iterator 객체 하나는 한 번 쓰고 버리는 도구입니다. Iterator 인터페이스에는 끝까지 읽은 반복자를 처음으로 되돌리는 메서드가 없으므로, 다시 읽으려면 iterator()를 다시 불러 새 반복자를 얻어야 합니다.
더 깊이: 한 번만 순회할 수 있는 Iterable
Iterable 문서가 여러 번 순회할 수 있어야 한다고 못 박아 둔 것은 아닙니다. 예를 들어 디렉터리의 항목을 읽는 java.nio.file.DirectoryStream은 Iterable을 구현하지만 반복자를 하나만 내주며, 두 번째로 iterator()를 부르면 IllegalStateException을 던진다고 문서에 밝혀 두었습니다. 열린 디렉터리 하나에서 항목을 차례로 읽어 오는 객체라서, 처음부터 다시 읽는 기능을 두지 않은 것입니다. 이런 예가 있으므로, 직접 만든 Iterable이 한 번만 순회할 수 있다면 그 사실을 문서에 분명히 적어 두어야 합니다.
향상된 for문 안에서 원소를 지우면
게시글 제목 목록에서 draft로 시작하는 임시 글을 모두 지운다고 해 봅시다. 가장 먼저 떠오르는 코드는 향상된 for문으로 돌면서 조건에 맞는 원소를 목록의 remove로 지우는 것입니다. 아래 프로그램은 이 코드를 담은 removeDrafts를 서로 다른 두 목록에 실행합니다.
import java.util.ArrayList;
import java.util.ConcurrentModificationException;
import java.util.List;
public final class RemoveDuringForEach {
public static void main(String[] args) {
removeDrafts(new ArrayList<>(List.of("draft-a", "post-b", "draft-c", "post-d")));
removeDrafts(new ArrayList<>(List.of("post-a", "draft-b", "draft-c")));
}
static void removeDrafts(List<String> titles) {
try {
for (String title : titles) {
if (title.startsWith("draft")) {
titles.remove(title);
}
}
System.out.println("no exception: " + titles);
} catch (ConcurrentModificationException e) {
System.out.println("CME: " + titles);
}
}
}두 목록에는 draft로 시작하는 제목이 두 개씩 들어 있습니다. 같은 메서드를 실행했는데도 두 목록의 결과가 어떻게 갈리는지 보세요.
CME: [post-b, draft-c, post-d]
no exception: [post-a, draft-c]첫 목록에서는 ConcurrentModificationException이 났습니다. 첫 원소 draft-a를 지운 뒤, 반복자가 다음 next()에서 '내가 만들어진 뒤 목록의 구조가 바뀌었다'는 것을 알아채고 예외를 던진 것입니다. 이처럼 잘못된 상태를 알아챈 즉시 멈추는 방식을 즉시 실패(fail-fast)라고 합니다. 예외 덕분에 버그가 있다는 사실은 바로 드러나지만, 목록에는 지웠어야 할 draft-c가 남았습니다.
두 번째 목록이 더 위험합니다. 예외 없이 끝났는데도 draft-c가 지워지지 않았습니다. 반복자가 다음에 읽을 위치를 따라가 보면 이유가 보입니다.
| 시점 | 목록 | 다음에 읽을 위치 | 일어난 일 |
|---|---|---|---|
next()가 post-a를 돌려준 뒤 | [post-a, draft-b, draft-c] | 1 | 조건에 맞지 않아 그냥 지나감 |
next()가 draft-b를 돌려준 뒤 | [post-a, draft-b, draft-c] | 2 | remove("draft-b") 호출 |
remove가 끝난 뒤 | [post-a, draft-c] | 2 | 크기가 2로 줄고 draft-c가 인덱스 1로 당겨짐 |
다음 hasNext() | [post-a, draft-c] | 2 | 위치 2가 크기 2와 같아 false, 반복 종료 |
draft-b를 지우자 뒤에 있던 draft-c가 한 칸 당겨져, 반복자가 이미 지나온 인덱스 1로 옮겨 갔습니다. 반복자는 위치 2를 읽을 차례인데 크기가 2이니 hasNext()가 false를 돌려주고, next()가 다시 불리지 않으니 변경을 검사할 기회도 없습니다. 결국 draft-c는 조건 검사조차 받지 못한 채 남습니다.
두 결과는 즉시 실패가 어디까지 믿을 만한지를 보여 줍니다. ArrayList 문서는 즉시 실패 반복자가 이 예외를 '최선을 다하는 수준(best-effort basis)'으로만 던진다고 밝힙니다. 그리고 이 예외에 기대어 프로그램의 정확성을 맞추는 것은 잘못이며, 버그를 찾는 데만 쓰라고 말합니다. 예외가 나지 않았다고 해서 순회 중 수정이 안전했던 것은 아니라는 뜻입니다.
예외 이름에 '동시(concurrent)'가 들어 있지만, 위 예제처럼 스레드 하나만으로도 일어납니다. ConcurrentModificationException 문서도 이 예외가 꼭 다른 스레드의 수정을 뜻하지는 않는다고 설명합니다. 반대로 여러 스레드가 한 목록을 동시에 고칠 때 이 예외가 반드시 난다는 보장도 없으므로, 즉시 실패를 스레드 안전 장치로 여기면 안 됩니다. 여러 스레드가 함께 쓰는 컬렉션은 ConcurrentHashMap과 동시성 컬렉션에서 따로 다룹니다.
어떤 변경이 문제인지도 구분해야 합니다. ArrayList 문서는 원소를 더하거나 지우는 연산, 그리고 내부 배열의 크기를 직접 바꾸는 연산을 구조 변경(structural modification)이라고 부르고, 원소의 값만 바꾸는 것은 구조 변경이 아니라고 정합니다. 그래서 순회 중에 set으로 원소를 바꾸는 것은 예외를 일으키지 않습니다. 반면 add나 remove는 반복자를 거치지 않는 한 순회가 끝난 뒤로 미뤄야 합니다.
순회하면서 원소 지우기: Iterator.remove와 removeIf
순회하면서 지워야 한다면 목록이 아니라 반복자에게 지우라고 시키면 됩니다. Iterator의 remove()는 방금 next()가 돌려준 원소를 목록에서 지웁니다. 반복자가 직접 지우므로 자기가 다음에 읽을 위치를 함께 맞추고, 변경 검사에도 걸리지 않습니다. Iterator 문서도 순회 도중에 이 메서드가 아닌 방법으로 컬렉션을 바꾸면, 구현 클래스가 따로 정책을 밝혀 두지 않은 한 반복자가 어떻게 동작할지 정해져 있지 않다고 말합니다. 향상된 for문은 반복자를 숨기므로, 이때는 반복자를 직접 꺼내 while문으로 돕니다.
import java.util.ArrayList;
import java.util.Iterator;
import java.util.List;
public final class RemoveWithIterator {
public static void main(String[] args) {
List<String> titles = new ArrayList<>(List.of("draft-a", "post-b", "draft-c", "post-d"));
Iterator<String> it = titles.iterator();
while (it.hasNext()) {
String title = it.next();
if (title.startsWith("draft")) {
it.remove(); // 방금 next()가 돌려준 원소를 지운다
}
}
System.out.println("iterator.remove: " + titles);
List<String> others = new ArrayList<>(List.of("draft-a", "post-b", "draft-c", "post-d"));
others.removeIf(title -> title.startsWith("draft"));
System.out.println("removeIf: " + others);
}
}두 목록은 RemoveDuringForEach의 첫 목록과 같은 네 제목으로 시작합니다. 그때는 draft-c가 남았는데, 이번에는 draft로 시작하는 두 제목이 모두 지워지는지 보세요.
iterator.remove: [post-b, post-d]
removeIf: [post-b, post-d]두 방법 모두 임시 글 두 개를 빠짐없이 지웠습니다. removeIf는 Java 8부터 Collection에 들어 있는 메서드로, 조건을 넘기면 그 조건을 만족하는 원소를 모두 지웁니다. title -> title.startsWith("draft")는 '제목을 받아 draft로 시작하는지 돌려주는 함수'를 짧게 적은 람다식이며, 자세한 문법은 30장 람다와 함수형 인터페이스에서 다룹니다. 조건에 맞는 원소를 지우기만 하면 되는 경우라면, 순회와 삭제를 컬렉션에 맡기는 removeIf가 가장 짧고 실수할 여지도 적습니다.
Iterator.remove()에는 지켜야 할 규칙이 있습니다. next() 한 번에 remove()는 한 번만 부를 수 있어서, next()를 부르기 전에 remove()를 부르거나 같은 원소에 두 번 부르면 IllegalStateException이 납니다. 또 remove()는 선택 기능(optional operation)이라 모든 반복자가 지원하지는 않습니다. List.of로 만든 수정 불가능한 목록의 반복자는 UnsupportedOperationException을 던지고, Iterator 인터페이스의 기본 구현도 아무것도 지우지 않고 같은 예외를 던집니다. 이 글에서 앞서 만든 반복자들은 remove()를 구현하지 않았으므로 이 기본 구현을 그대로 물려받습니다.
더 깊이: 반복자는 목록이 바뀐 것을 어떻게 알아챌까
ArrayList가 상속하는 AbstractList에는 구조 변경이 일어날 때마다 늘어나는 modCount 필드가 있습니다. ArrayList의 반복자는 만들어질 때 이 값을 복사해 두고, next()와 remove()에서 지금 값과 비교해 다르면 ConcurrentModificationException을 던집니다. 반복자 자신의 remove()는 목록에서 원소를 지운 뒤 복사해 둔 값을 새 modCount로 맞추기 때문에 검사에 걸리지 않습니다. 앞에서 만든 배열 목록에 같은 장치를 넣으면 다음과 같습니다.
import java.util.Arrays;
import java.util.ConcurrentModificationException;
import java.util.Iterator;
import java.util.NoSuchElementException;
public final class FailFastArrayList {
public static void main(String[] args) {
MyArrayList<String> titles = new MyArrayList<>();
titles.add("draft-a");
titles.add("post-b");
titles.add("draft-c");
Iterator<String> it = titles.iterator();
while (it.hasNext()) {
if (it.next().startsWith("draft")) it.remove();
}
System.out.println("after iterator.remove: " + titles);
try {
for (String title : titles) {
titles.add(title + "-copy");
}
} catch (ConcurrentModificationException e) {
System.out.println("caught " + e.getClass().getSimpleName());
}
}
private static final class MyArrayList<E> implements Iterable<E> {
private Object[] values = new Object[2];
private int size;
private int modCount; // 구조가 바뀐 횟수
void add(E value) {
if (size == values.length) values = Arrays.copyOf(values, size * 2);
values[size++] = value;
modCount++;
}
void removeAt(int index) {
if (index < 0 || index >= size) throw new IndexOutOfBoundsException(index);
System.arraycopy(values, index + 1, values, index, size - index - 1);
values[--size] = null;
modCount++;
}
@SuppressWarnings("unchecked")
E get(int index) {
if (index < 0 || index >= size) throw new IndexOutOfBoundsException(index);
return (E) values[index];
}
@Override
public String toString() {
return Arrays.toString(Arrays.copyOf(values, size));
}
public Iterator<E> iterator() {
return new Iterator<>() {
private int cursor;
private int lastReturned = -1; // remove()로 지울 위치, 없으면 -1
private int expectedModCount = modCount;
public boolean hasNext() {
return cursor < size;
}
public E next() {
checkForModification();
if (!hasNext()) throw new NoSuchElementException();
lastReturned = cursor;
return get(cursor++);
}
public void remove() {
if (lastReturned < 0) throw new IllegalStateException();
checkForModification();
removeAt(lastReturned);
cursor = lastReturned; // 뒤 원소가 당겨졌으니 같은 자리부터 다시 읽는다
lastReturned = -1; // next() 한 번에 remove() 한 번
expectedModCount = modCount; // 반복자 자신이 한 변경은 허용한다
}
private void checkForModification() {
if (modCount != expectedModCount) throw new ConcurrentModificationException();
}
};
}
}
}main의 앞부분은 반복자의 remove()로 임시 글을 지우고, 뒷부분은 향상된 for문 안에서 목록의 add를 호출합니다. 출력의 두 줄이 각각 앞부분과 뒷부분의 결과입니다.
after iterator.remove: [post-b]
caught ConcurrentModificationException첫 줄은 반복자의 remove()가 cursor를 지운 자리로 되돌리고 expectedModCount를 맞춘 덕분에, 세 원소를 빠짐없이 검사해 두 임시 글을 지운 결과입니다. 두 번째 줄은 향상된 for문 안의 add가 modCount를 늘린 탓에 다음 next()의 검사에 걸린 결과입니다. 변경 감지가 없는 앞의 IterableArrayList에서 같은 일을 하면, hasNext()가 늘어난 size를 보고 계속 true를 돌려주므로 반복이 끝나지 않습니다. 힙을 작게 잡고 실행해 보면 결국 OutOfMemoryError로 멈춥니다.
이 구현에서 hasNext()가 검사 없이 위치와 크기만 비교한다는 점도 눈여겨보세요. ArrayList의 hasNext()도 마찬가지라서, 앞 절의 두 번째 목록처럼 반복이 hasNext()에서 끝나 버리면 검사가 한 번도 실행되지 않습니다. 그 목록이 예외 없이 원소를 건너뛴 것도 이 때문입니다. 여러 스레드가 동기화 없이 목록을 고치는 경우라면, 한 스레드가 늘린 modCount가 반복자를 쓰는 스레드에 언제 보일지조차 정해져 있지 않습니다.
즉시 실패는 여러 정책 가운데 하나일 뿐입니다. java.util.concurrent 패키지의 CopyOnWriteArrayList는 반복자를 만든 순간의 배열을 그대로 읽는 스냅샷(snapshot) 방식이라, 순회 중에 원소를 더하거나 지워도 예외 없이 처음 상태를 끝까지 읽습니다. 대신 그 반복자의 remove()는 지원하지 않습니다. ConcurrentHashMap의 반복자는 약한 일관성(weakly consistent)을 따르므로 예외를 던지지 않으며, 반복자를 만든 뒤의 변경은 보일 수도 있고 보이지 않을 수도 있습니다. 컬렉션을 순회하며 고쳐야 한다면, 먼저 그 컬렉션의 문서에서 반복자가 어떤 정책을 따르는지 확인하세요.
매개변수를 Iterable로 받기
반복자를 직접 만들 일은 많지 않지만, Iterable이라는 타입은 메서드 매개변수로 쓸모가 많습니다. 게시글 조회 수의 합을 구하는 아래 total은 원소를 처음부터 끝까지 한 번씩 읽기만 합니다.
import java.util.List;
public final class IterableBoardSummary {
public static void main(String[] args) {
Iterable<Entry> entries = List.of(new Entry("iterator", 45), new Entry("for-each", 55));
System.out.println("total=" + total(entries));
}
static int total(Iterable<Entry> entries) {
int sum = 0;
for (Entry e : entries) {
sum += e.viewCount();
}
return sum;
}
private record Entry(String title, int viewCount) {}
}main은 List.of로 만든 목록을 Iterable<Entry> 타입 변수에 담아 total에 넘깁니다. List는 Iterable을 구현하므로 이 대입은 그대로 컴파일됩니다.
total=100total은 List가 아니라 Iterable<Entry>를 받습니다. 이 메서드는 add, get, size 가운데 어느 것도 쓰지 않고 향상된 for문 하나로 원소를 읽기 때문입니다. 필요한 기능만 요구했으니 List.of로 만든 목록뿐 아니라 Set, 그리고 이 글의 Chain처럼 Iterable을 구현한 어떤 타입도 넘길 수 있습니다. 출력의 100은 두 게시글의 조회 수 45와 55를 더한 값입니다.
정리
Iterable은iterator()로 새 순회를 시작할 수 있는 대상이고,Iterator는 한 번의 순회 위치를 기억하며hasNext()와next()를 제공합니다.- 향상된
for문은iterator()를 한 번 부르고hasNext()와next()를 반복하는 코드로 바뀝니다. 그래서 배열이나Iterable에만 쓸 수 있습니다. hasNext()는 몇 번을 불러도 상태를 바꾸지 않아야 하고, 커서는next()에서만 한 칸 움직입니다. 원소가 없을 때next()는NoSuchElementException을 던집니다.- 배열 목록은 인덱스로, 연결 목록은 현재 노드 참조로 전진해야 전체 순회가 O(n)입니다.
iterator()는 부를 때마다 독립된 새 반복자를 돌려줘야 같은 컬렉션을 다시 순회하거나 겹쳐서 순회할 수 있습니다.- 향상된
for문 안에서 목록에add나remove를 하면ConcurrentModificationException이 날 수 있지만, 이 검사는 최선을 다하는 수준이라 예외 없이 원소를 건너뛰기도 합니다. 순회하며 지울 때는Iterator.remove()나removeIf를 씁니다.
연습 문제
- 3부터 7까지의 정수를 차례로 돌려주는
Range를Iterable<Integer>로 구현하세요. 같은Range객체를 향상된for문으로 두 번 돌려도 매번 3, 4, 5, 6, 7이 나와야 하고, 다 읽은 반복자에서는hasNext()가 false를 돌려주고next()가NoSuchElementException을 던져야 합니다. - 아래 프로그램은
target과 같은 원소를 향상된for문 안에서 목록의remove로 지웁니다.target이a,b,c,d일 때 각각 예외가 나는지, 끝난 뒤의 목록과 방문한 원소가 무엇인지 실행하기 전에 예측하세요.
import java.util.ArrayList;
import java.util.ConcurrentModificationException;
import java.util.List;
public final class RemoveTargetQuiz {
public static void main(String[] args) {
run("a");
run("b");
run("c");
run("d");
}
static void run(String target) {
List<String> list = new ArrayList<>(List.of("a", "b", "c", "d"));
List<String> visited = new ArrayList<>();
try {
for (String s : list) {
visited.add(s);
if (s.equals(target)) {
list.remove(s);
}
}
System.out.println(target + ": no exception, list=" + list + ", visited=" + visited);
} catch (ConcurrentModificationException e) {
System.out.println(target + ": CME, list=" + list + ", visited=" + visited);
}
}
}힌트
Range객체에는 시작과 끝만 두고, 커서는iterator()가 만드는 반복자 안에 두세요.ArrayList의 반복자는hasNext()에서 '다음에 읽을 위치가size와 다른가'만 확인하고, 변경 검사는next()에서 합니다. 원소를 지운 직후의 위치와 크기를 비교해 보세요.
1번 정답과 해설
Range는 바뀌지 않는 시작과 끝만 보관하고, 커서는 iterator()가 만드는 익명 반복자 안에 둡니다. 그래서 iterator()를 부를 때마다 커서가 start인 새 반복자가 생기고, 반복자끼리 서로 영향을 주지 않습니다.
import java.util.Iterator;
import java.util.NoSuchElementException;
public final class RangeIteratorSolution {
public static void main(String[] args) {
Range r = new Range(3, 7);
for (int v : r) {
System.out.print(v);
}
System.out.print('/');
for (int v : r) {
System.out.print(v);
}
}
private record Range(int start, int end) implements Iterable<Integer> {
Range {
if (start > end) throw new IllegalArgumentException();
}
public Iterator<Integer> iterator() {
return new Iterator<>() {
private int cursor = start;
public boolean hasNext() {
return cursor <= end;
}
public Integer next() {
if (!hasNext()) throw new NoSuchElementException();
return cursor++;
}
};
}
}
}실행 결과:
34567/34567두 번째 반복문도 3부터 시작하는 것은 향상된 for문이 iterator()를 다시 불러 새 반복자를 받았기 때문입니다. hasNext()는 cursor <= end를 계산하기만 하므로 몇 번을 불러도 커서가 움직이지 않고, 커서는 next()에서만 늘어납니다.
이 Range는 3부터 7까지에서는 올바르지만 한 가지 함정이 있습니다. 생성자가 start > end만 거부하므로 end가 Integer.MAX_VALUE인 범위도 만들 수 있는데, 이때 마지막 값 2147483647을 돌려준 뒤 cursor++가 넘쳐 Integer.MIN_VALUE가 됩니다. 그러면 cursor <= end가 다시 true가 되어 순회가 끝나지 않습니다. int로는 Integer.MAX_VALUE보다 1 큰 값을 나타낼 수 없으니, '끝보다 1 커지면 멈춘다'는 방식이 통하지 않는 것입니다. 이런 범위까지 받으려면 다음처럼 남은 원소가 있는지를 따로 기록합니다.
import java.util.Iterator;
import java.util.NoSuchElementException;
public final class ExhaustionSafeRange {
public static void main(String[] args) {
Iterator<Integer> values = new Range(Integer.MAX_VALUE - 1, Integer.MAX_VALUE).iterator();
System.out.println(values.next());
System.out.println(values.next());
System.out.println("has-next=" + values.hasNext());
try {
values.next();
} catch (NoSuchElementException expected) {
System.out.println("exhausted");
}
}
private record Range(int start, int end) implements Iterable<Integer> {
Range {
if (start > end) throw new IllegalArgumentException();
}
public Iterator<Integer> iterator() {
return new Iterator<>() {
private int cursor = start;
private boolean remaining = true;
public boolean hasNext() { return remaining; }
public Integer next() {
if (!remaining) throw new NoSuchElementException();
int value = cursor;
if (cursor == end) remaining = false;
else cursor++;
return value;
}
};
}
}
}실행 결과:
2147483646
2147483647
has-next=false
exhausted마지막 값을 돌려줄 때는 cursor를 늘리지 않고 remaining을 false로 바꾸므로 넘침이 생기지 않습니다. remaining도 반복자마다 따로 있으니 반복자끼리 독립적이고, start와 end가 같은 한 원소 범위도 값을 한 번 돌려준 뒤 끝납니다.
2번 정답과 해설
실행 결과:
a: CME, list=[b, c, d], visited=[a]
b: CME, list=[a, c, d], visited=[a, b]
c: no exception, list=[a, b, d], visited=[a, b, c]
d: CME, list=[a, b, c], visited=[a, b, c, d]네 경우 가운데 셋은 예외가 나고 c만 예외 없이 끝납니다. 열쇠는 원소를 지운 직후 반복자가 다음에 읽을 위치와 줄어든 크기의 관계입니다. 원소를 읽을 때마다 위치가 하나씩 늘어나므로, k번째 원소를 읽고 지운 직후의 위치는 k이고 크기는 3입니다.
target | 지운 직후 다음 위치 | 지운 직후 크기 | hasNext() | 결과 |
|---|---|---|---|---|
a | 1 | 3 | true | 다음 next()의 검사에서 예외 |
b | 2 | 3 | true | 다음 next()의 검사에서 예외 |
c | 3 | 3 | false | 예외 없이 종료, d는 방문하지 않음 |
d | 4 | 3 | true | 다음 next()의 검사에서 예외 |
c를 지우면 d가 인덱스 2로 당겨지는데, 반복자는 위치 3을 읽을 차례이고 크기도 3이라 반복이 그대로 끝납니다. 그래서 visited에 d가 없습니다. 가장 헷갈리는 것은 d입니다. 마지막 원소까지 읽고 지웠으니 더 읽을 것이 없는데도, 위치 4가 크기 3과 '다르다'는 이유로 hasNext()가 true를 돌려주고 이어진 next()의 검사에서 예외가 납니다.
이 결과는 이 글에서 실행한 OpenJDK 21의 구현을 따른 것이지 명세가 약속한 동작이 아닙니다. 같은 실수가 어떤 위치에서는 예외를 내고 어떤 위치에서는 조용히 원소를 건너뛴다는 것이 바로 즉시 실패가 '최선을 다하는 수준'이라는 말의 뜻입니다. 순회하면서 지워야 한다면 결과를 예측하려 애쓰기보다 Iterator.remove()나 removeIf를 쓰는 편이 맞습니다.
다음 글
Comparable과 Comparator에서는 순회할 원소들의 순서를 정하는 방법을 다룹니다. 원소 스스로 기본 순서를 갖게 하는 Comparable과 쓰는 곳마다 다른 기준을 넘기는 Comparator를 비교하고, 비교 결과가 0일 때 정렬과 TreeSet이 어떻게 다르게 동작하는지 확인합니다.