즐겨찾기 목록을 화면에 뿌리는 코드를 짠다고 하자. 처음엔 ArrayList에 담았으니 for (int i = 0; i < list.size(); i++)로 인덱스를 돌린다. 잘 돌아간다. 그런데 조회 성능 때문에 내부를 LinkedList로 바꾸기로 한다. 인덱스 접근이 느려지니 순회 코드도 노드를 따라가는 방식으로 고쳐야 한다. 다시, 중복을 막으려 HashSet으로 바꾼다. 이번엔 인덱스라는 개념 자체가 없어 순회 코드를 또 갈아엎는다. 목록을 어떻게 담았는가가 바뀔 때마다, 그 목록을 훑는 코드가 줄줄이 함께 깨진다.

문제의 뿌리는 클라이언트가 집합체의 내부 표현을 알고 있다는 것이다. list.get(i)라고 쓰는 순간 클라이언트는 “이건 인덱스로 접근되는 순차 자료구조”라는 사실에 결합된다. 반복자(Iterator) 패턴은 이 순회 책임을 집합체에서 떼어 내 별도의 객체에게 맡긴다. 클라이언트는 “다음 원소가 있나?(hasNext)”, “다음 걸 다오(next)“라는 두 마디만 알면 되고, 그 뒤에서 배열을 훑는지 노드를 따라가는지는 반복자가 감춘다.

무엇이 문제인가 — 동기

컬렉션을 훑는 코드를 클라이언트가 직접 짜면 세 가지가 동시에 아프다.

첫째, 내부 표현이 새어 나온다. list.get(i), array[i], node = node.next는 모두 “이 집합체가 안으로 무엇인지”를 클라이언트 코드에 새긴다. 배열이면 인덱스 루프, 연결 리스트면 노드 추적, 트리면 재귀 — 순회 코드의 모양이 곧 자료구조의 자백서다. 집합체가 표현을 바꾸면(배열→리스트→해시) 클라이언트가 함께 무너진다. 캡슐화가 여기서 뚫린다. 담는 방식을 숨기려고 클래스를 만들어 놓고, 훑는 순간 그 방식을 도로 드러내는 셈이다.

둘째, 순회 로직이 클라이언트마다 중복된다. 같은 컬렉션을 화면에 뿌리는 코드, 검색하는 코드, 통계 내는 코드가 각자 인덱스 루프를 다시 짠다. 순회의 세부(경계 조건, 다음으로 넘어가는 규칙)가 여러 곳에 복제되고, 자료구조가 바뀌면 그 모든 사본을 찾아 고쳐야 한다.

셋째, 한 번에 한 방식으로만 훑는다. 인덱스 루프는 앞에서 뒤로 한 방향이다. 뒤에서 앞으로 훑거나, 짝수 번째만 훑거나, 같은 리스트를 두 커서로 동시에 훑으려면(중첩 루프로 모든 쌍 비교) 클라이언트가 순회 상태(어디까지 갔는지)를 직접 여러 벌 관리해야 한다. 순회의 진행 상태를 담을 곳이 집합체 자신밖에 없으면, 동시에 두 번 훑는 일이 불가능하거나 지저분해진다.

판단 기준: “집합체의 담는 방식을 바꿔도 그걸 쓰는 코드가 안 흔들려야 한다”거나 “같은 컬렉션을 여러 방식으로 훑어야 한다”가 보이면 반복자를 의심한다. 함정: 자바를 쓴다면 대개 Iterable/Iterator가 이미 그 자리에 있다 — 표준 컬렉션을 쓰면서 순회기를 새로 발명하는 건 바퀴를 다시 깎는 짓이다.

패턴의 구조 — 누가 무엇을 하나

반복자는 네 배역으로 이뤄진다. 핵심은 “담는 자”와 “훑는 자”를 갈라놓는 것이다.

Iterator(반복자 인터페이스). 순회의 계약이다. 자바로는 hasNext()(다음이 있나)와 next()(다음을 꺼내며 커서를 전진)의 두 마디. 클라이언트는 오직 이 얼굴만 안다. 뒤에서 배열 인덱스를 세는지 노드 포인터를 옮기는지는 이 인터페이스에 가려진다.

ConcreteIterator(구체 반복자). 특정 집합체를 실제로 훑는 객체다. 순회의 진행 상태(커서)를 자기 안에 들고 있다 — 이게 결정적이다. 어디까지 훑었는지를 집합체가 아니라 반복자가 기억하므로, 같은 집합체에 반복자를 여럿 만들면 서로 독립적인 커서 여럿이 생긴다. 배열용 반복자는 인덱스를, 리스트용 반복자는 현재 노드를, 트리용 반복자는 방문할 노드 스택을 상태로 갖는다.

Aggregate(집합체 인터페이스). “나를 훑을 반복자를 하나 만들어 다오”라는 팩토리 메서드 하나(iterator())를 선언한다. 클라이언트는 구체 반복자의 클래스 이름을 몰라도, 집합체에게 반복자 생성을 부탁해 받는다.

ConcreteAggregate(구체 집합체). 실제로 원소를 담는 자료구조다. iterator()를 구현해 자신에게 맞는 구체 반복자를 반환한다. 배열 집합체는 배열용 반복자를, 트리는 트리용 반복자를 건넨다.

협력의 결정적 지점은 커서를 누가 쥐느냐다. 순회 상태를 반복자에 두면(외부 반복자, external iterator) 클라이언트가 hasNext/next를 직접 돌리며 진행을 통제한다 — 도중에 멈추거나, 두 반복자를 나란히 전진시켜 비교하는 일이 자유롭다. 반대로 상태를 집합체 쪽 메서드에 두고 “각 원소에 이 동작을 해라”라고 함수를 넘기면(내부 반복자, internal iterator — 자바의 forEach) 클라이언트는 순회를 통제하지 않고 결과만 받는다. 전자는 유연하지만 클라이언트가 루프를 짜야 하고, 후자는 간결하지만 순회 도중 빠져나오기가 어렵다.

판단 기준: 순회를 도중에 멈추거나 여러 컬렉션을 나란히 훑어야 하면 외부 반복자(커서를 밖에서 돌린다), 그저 전부에 한 동작을 적용할 뿐이면 내부 반복자(forEach)가 짧고 안전하다. 함정: 순회 상태를 집합체 자신에 필드로 두면(currentIndex) 같은 집합체를 동시에 두 번 훑는 순간 커서가 충돌한다 — 상태는 반드시 반복자마다 따로 있어야 한다.

코드로 — 패턴이 자라나는 과정

내부 표현을 그대로 드러내는 순회에서 출발해, 반복자 인터페이스를 뽑고, 여러 순회 방식을 얹은 뒤, 자바 Iterable에 연결하는 과정을 단계로 따라간다.

Refactoring Step 내부를 드러내는 순회 — 클라이언트가 표현을 안다
1/4
public class BookShelf {    private Book[] books = new Book[100];    private int last = 0;    public void add(Book book) { books[last++] = book; }    public Book[] getBooks() { return books; }   // 내부 배열을 통째로 넘긴다    public int getLast() { return last; }}// 클라이언트가 "배열"과 "인덱스"를 직접 안다.BookShelf shelf = /* ... */;for (int i = 0; i < shelf.getLast(); i++) {    System.out.println(shelf.getBooks()[i].getName());}// BookShelf를 ArrayList로 바꾸면? getBooks()[i]도, getLast()도 다 깨진다.// 담는 방식(배열+last 인덱스)이 순회 코드에 그대로 새겨져 있다.

두 번째 스텝이 이 패턴의 심장이다. 첫 스텝에서 클라이언트가 알던 “배열”과 “인덱스”가, hasNext/next 뒤로 완전히 사라졌다. 세 번째 스텝은 그 은닉이 벌어 준 자유를 보여준다 — 집합체를 건드리지 않고도 순회 방식을 통째로 갈아 끼우고, 커서가 반복자마다 따로이므로 같은 컬렉션을 동시에 여러 벌 훑는다. 마지막 스텝의 교훈은 실무적이다. 자바에서는 이 모든 걸 손으로 만들 필요가 거의 없다. Iterable 하나만 구현하면 언어의 for-each가 반복자를 대신 돌려 준다 — 패턴이 언어에 흡수된 드문 사례다.

판단 기준: 리팩터링 후 “집합체의 내부 표현을 바꿔도 순회 코드가 0줄 바뀐다”가 성립하면 반복자가 제대로 앉은 것이다. 함정: 반복자를 만들었는데 getBooks() 같은 내부 노출 메서드를 그대로 남겨 두면, 클라이언트가 편한 길로 도로 표현에 결합한다 — 순회 통로를 반복자 하나로 좁혀야 은닉이 완성된다.

결과 — 무엇을 얻고 무엇을 내주나

반복자가 사는 값은 표현의 은닉과 순회의 분리다. 집합체가 안으로 배열인지 트리인지 클라이언트가 몰라도 되니, 담는 방식을 바꿔도 훑는 코드가 견딘다. 순회 로직이 반복자 한 곳에 모여 클라이언트마다 복제되던 인덱스 루프가 사라진다. 그리고 커서가 반복자마다 독립이라, 같은 집합체를 동시에 여러 방식으로·여러 벌 훑는 일이 자연스러워진다. 순회 방식이 늘어도(정방향·역방향·필터링) 집합체는 그대로고 반복자 클래스만 는다 — 여기서도 개방-폐쇄가 산다.

대가는 세 가지다. 첫째, 객체가 는다. 컬렉션 하나에 반복자 클래스가 따라붙고, 순회 방식이 여럿이면 반복자도 여럿이다. 그저 앞에서 뒤로 한 번 훑을 뿐인 작은 배열에까지 반복자를 세우면, 인덱스 for 한 줄이면 될 일에 클래스와 간접 호출을 얹는 것이다. 둘째, 순회 중 컬렉션을 바꾸면 위험하다. 반복자가 커서를 들고 훑는 도중 집합체에 원소를 더하거나 지우면, 커서가 가리키던 위치가 어긋난다 — 자바 표준 컬렉션은 이를 감지해 ConcurrentModificationException을 던진다(fail-fast). 순회하며 삭제하려면 반복자 자신의 remove()를 써야 하고, 이 규칙을 모르면 런타임에 터진다. 셋째, 간접성 비용이다. list.get(i)의 직접 접근이 hasNext/next 호출로 바뀌며 한 겹 얇은 층이 낀다. 대개 무시할 만하지만, 극도로 성능이 민감한 내부 루프에서는 셈에 넣어야 한다.

운영비용 렌즈로 보면 순이득의 조건이 분명하다. 집합체의 표현이 바뀔 여지가 있거나, 여러 클라이언트가 같은 컬렉션을 여러 방식으로 훑는다면, 늘어난 반복자 클래스의 대가보다 “표현이 새지 않는” 이득이 크다. 반대로 표현이 고정이고 순회가 한 방식뿐인 작은 자료라면, 반복자는 간접성만 더한다 — 다만 자바에서는 이 대가마저 표준 Iterable이 이미 치러 놓았으니, 표준 컬렉션을 쓰는 한 거의 언제나 이득 쪽이다.

판단 기준: 표현이 바뀔 수 있거나 여러 방식으로 훑어야 하면 반복자를 앉히되, 자바라면 먼저 표준 Iterable로 해결되는지부터 본다. 함정: 순회 도중 컬렉션을 수정하는 코드를 무심코 짜면 ConcurrentModificationException이 기다린다 — 삭제는 반복자의 remove()로, 대량 변경은 순회가 끝난 뒤로 미룬다.

언제 쓰고, 언제 과용인가

반복자가 정확히 맞는 자리는 이렇다. 집합체의 내부 표현을 감추면서 원소를 훑게 하고 싶을 때. 같은 집합체를 여러 방식으로(정방향·역방향·필터링·정렬순) 순회해야 할 때. 배열·리스트·트리·그래프처럼 서로 다른 자료구조들을 하나의 순회 인터페이스로 통일해, 클라이언트가 구조를 몰라도 똑같이 훑게 하고 싶을 때. 자바의 컬렉션 프레임워크 전체가 이 패턴 위에 서 있다 — List, Set, Map이 저마다 다른 표현을 갖지만 모두 Iterator를 내주므로, for (E e : collection) 한 줄이 무엇에나 걸린다.

과용의 냄새는 반대편에서 난다. 표현이 바뀔 일 없는 작은 고정 배열을 훑으려고 반복자 클래스를 손수 만든다면, 인덱스 for 한 줄이면 될 일에 클래스를 얹는 것이다. 자바에서 표준 컬렉션을 쓰면서 자기만의 Iterator를 새로 구현하는 것도 대개 과용이다 — 언어가 이미 준 것을 다시 깎지 마라. 반대로, 반복자를 만들어 놓고 정작 getInternalList() 같은 내부 노출 메서드를 함께 열어 두면, 은닉이라는 목적 자체가 무너진다.

여기서 이웃 패턴과의 경계를 그어 둘 필요가 있다. [컴포지트]는 반복자와 자주 짝을 이룬다 — 부분-전체를 트리로 담은 컴포지트를 훑을 때, 그 재귀적 순회(전위·후위·너비 우선)를 반복자가 감춘다. 클라이언트는 트리의 깊이도 가지 수도 모른 채 hasNext/next만으로 잎사귀들을 훑는다. [방문자]와도 나란히 선다 — 반복자가 “어떻게 훑을지”(순회 순서와 이동)를 책임진다면, 방문자는 “각 원소에서 무엇을 할지”(연산)를 책임진다. 구조를 훑는 일과 훑으며 할 일을 두 패턴이 나눠 갖는 셈이다. 실제로 복잡한 트리를 처리할 때 반복자로 순서를 정하고 방문자로 노드마다의 연산을 얹으면 둘이 깔끔히 협력한다.

판단 기준: “훑는 순서·이동”을 감추고 싶으면 반복자, “훑으며 각 원소에 할 연산”을 구조 바깥에서 갈아 끼우고 싶으면 방문자 — 순회와 연산은 다른 축이다. 함정: 반복자 안에 순회뿐 아니라 원소마다의 업무 로직까지 욱여넣으면, 순회기가 비대해지고 새 연산마다 반복자를 고치게 된다 — 그 신호가 오면 연산을 방문자로 밀어낸다.

다음으로 중재자