15. Iterator — 대기열 내부가 배열이든 큐든 상관없이 순서대로 처리한다

작성일:2026.05.21|수정일:2026.07.07|조회수:0

15. Iterator — 대기열 내부가 배열이든 큐든 상관없이 순서대로 처리한다

에이든 피자의 주문량이 늘어나면서, 우리는 주문을 쌓아두고 하나씩 꺼내 처리하는 '주문 대기열(Order Queue)'을 만들었다. 처음에는 아주 단순하게 배열을 사용했다. 주문이 들어오면 push()로 넣고, 처리할 때는 for 문으로 인덱스를 돌리며 하나씩 꺼내면 그만이었다. 하지만 매장이 바빠지면서 상황이 달라졌다.

어떤 날은 먼저 온 주문을 먼저 처리하는 일반 큐가 필요했고, 어떤 날은 VIP 손님의 주문을 먼저 처리하는 우선순위 큐가 필요했다. 때로는 메모리 절약을 위해 링크드리스트(Linked List)로 구조를 바꿔야 할 때도 있었다. 문제는 대기열의 자료구조를 바꿀 때마다, 그 대기열을 사용하는 모든 코드가 비명을 지르며 깨져나갔다는 점이다.

TS
class OrderQueue {  // 처음엔 배열이었다  public orders: Order[] = [];}// 대기열을 순회하는 코드const queue = new OrderQueue();for (let i = 0; i < queue.orders.length; i++) {  const order = queue.orders[i];  process(order);}

배열을 사용하던 코드는 length와 대괄호 인덱스([i])에 강하게 의존하고 있었다. 만약 우리가 대기열을 링크드리스트나 트리 구조로 바꾼다면, 이 for 문은 더 이상 동작하지 않는다. 대기열 내부가 어떻게 생겼는지 외부 코드가 내부 구조를 너무 많이 알고 있는 바람에, 내부 구조를 조금 고치는 일도 부담스러워진 것이다.

무엇이 불편한가

지금의 가장 큰 불편함은 "데이터를 담는 방식"과 "데이터를 훑어보는 방식"이 너무 끈적하게 달라붙어 있다는 점이다. 클라이언트는 주문을 처리하고 싶을 뿐인데, 그러기 위해서 주문이 배열에 담겨 있는지, 링크드리스트에 담겨 있는지, 아니면 이름 모를 고차원 자료구조에 담겨 있는지까지 알아야 한다. 이건 마치 손님이 피자를 먹으러 왔는데, 냉장고 어느 칸에 어떤 순서로 재료가 담겨 있는지 알아야만 주문을 할 수 있는 것과 다를 바 없다.

또한, 순회 로직이 여러 곳에 흩어져 중복된다는 문제도 있다. 대기열을 훑으며 요리하는 코드, 대기열을 훑으며 정산하는 코드, 대기열을 훑으며 화면에 띄우는 코드... 이 모든 곳에 인덱스를 다루는 지저분한 코드가 복사되어 있다. 만약 순회 순서를 거꾸로 바꾸거나 특정 조건에 따라 건너뛰어야 한다면, 우리는 이 모든 곳을 찾아다니며 수정해야 한다.

우리는 대기열의 속살을 감추고 싶었다. 대기열 내부가 배열이든, 큐든, 아니면 마법의 주머니든 상관없이, 외부에서는 그저 "다음 주문 주세요", "아직 주문 남았나요?"라고 물어보기만 하면 되는 표준화된 소통 방식이 간절했다.

Iterator 패턴

Iterator 패턴은 집합 객체의 내부 표현을 노출하지 않고도 그 원소들을 순차적으로 접근할 수 있는 방법을 제공하는 패턴이다. GoF의 원래 의도는 다음과 같다.

집합 객체의 내부 표현을 드러내지 않고 그 원소들에 순차적으로 접근할 수 있는 방법을 제공한다.

에이든 피자에 대입해 보면, OrderQueue는 더 이상 자신의 배열을 직접 보여주지 않는다. 대신 createIterator()라는 메서드를 통해 '순찰자(Iterator)'를 하나 내보낸다. 클라이언트는 이 순찰자에게만 말을 건다. "다음 주문이 있나요?(hasNext) 있다면 주세요(next)".

이렇게 하면 대기열이 배열에서 트리 구조로 바뀌더라도, 우리는 그 구조에 맞는 새로운 순찰자만 만들어서 보내주면 된다. 클라이언트 입장에서는 어제 쓰던 순찰자나 오늘 쓰는 순찰자나 물어보는 방식이 똑같으니 코드를 고칠 이유가 전혀 없다. 자료구조의 변경이라는 폭풍우로부터 클라이언트 코드를 안전한 방파제 안으로 옮기는 것이다.

표준 순찰자 고용하기

전통적인 방식의 Iterator를 먼저 구현해 보자. 먼저 순찰자가 지켜야 할 규칙인 인터페이스를 정의한다.

TS
interface OrderIterator {  hasNext(): boolean;  next(): Order | null;}

이제 배열 대기열을 위한 구체적인 순찰자를 만든다.

TS
class ArrayOrderIterator implements OrderIterator {  private position = 0;  constructor(private orders: Order[]) {}  hasNext(): boolean {    return this.position < this.orders.length;  }  next(): Order | null {    return this.hasNext() ? this.orders[this.position++] : null;  }}

마지막으로 대기열 클래스는 자신의 내부 구조를 숨긴 채 순찰자만 생성해서 넘겨준다.

TS
class OrderQueue {  private orders: Order[] = [];  addOrder(order: Order) {    this.orders.push(order);  }  createIterator(): OrderIterator {    return new ArrayOrderIterator(this.orders);  }}

클라이언트는 이제 내부가 어떻게 생겼는지 궁금해하지 않는다. 그저 순찰자에게 묻는다. "다음 주문 있나요? 있으면 주세요."

TypeScript 포인트: Symbol.iterator와 제너레이터

디자인 패턴의 고전적인 구현도 좋지만, 현대적인 TypeScript(JavaScript) 환경에서는 언어 차원에서 제공하는 더 강력한 도구가 있다. 바로 Symbol.iterator와 제너레이터(function*)다.

전통적인 hasNext/next 방식은 명확하지만, for...of 문이나 스프레드 연산자(...) 같은 편리한 문법을 사용할 수 없다는 단점이 있다. TypeScript에서 [Symbol.iterator] 메서드를 구현하면, 우리가 만든 클래스를 내장 배열처럼 다룰 수 있게 된다.

특히 제너레이터를 사용하면 Iterator 구현이 놀라울 정도로 간결해진다. 복잡한 상태 관리(현재 인덱스 등)를 컴파일러가 대신 해주기 때문이다.

TS
class OrderQueue {  private orders: Order[] = [];  // 제너레이터를 이용한 이터레이터 구현  *[Symbol.iterator](): Generator<Order> {    for (const order of this.orders) {      yield order;    }  }}// 이제 에이든 피자 대기열을 바로 for...of로 돌릴 수 있다!for (const order of queue) {  process(order);}

이 방식은 단순히 코드가 짧아지는 데서 끝나지 않는다. Iterable이라는 언어 표준 인터페이스를 따르기 때문에 여러 내장 문법과 라이브러리 코드와도 자연스럽게 맞물린다. 에이든 피자에서는 전통적인 Iterator의 원리를 이해한 뒤, 실전 코드에서는 이 제너레이터 방식을 적극적으로 사용하기로 했다.

트레이드오프

Iterator 패턴의 핵심 이득은 '책임의 분리'다. 집합 객체는 오직 데이터를 관리하는 일에만 집중하고, 순회하는 로직은 Iterator가 담당한다. 덕분에 자료구조가 아무리 복잡하게 변하더라도 외부 코드는 평온을 유지할 수 있다. 또한 하나의 집합 객체에 대해 여러 개의 순찰자를 동시에 돌릴 수도 있다. (누군가는 앞에서부터, 누군가는 뒤에서부터 훑는 식이다.)

하지만 소규모 프로젝트에서 단순한 배열만 사용한다면, Iterator 패턴을 직접 구현하는 것은 '과잉 설계(Over Engineering)'가 될 수 있다. 이미 Array.prototype.forEachmap 같은 훌륭한 고차 함수들이 존재하기 때문이다. Iterator 패턴이 진짜 빛을 발하는 순간은 내부 구조가 배열 이상의 복잡함을 가질 때, 혹은 내부 구조를 절대로 외부에 드러내고 싶지 않은 엄격한 캡슐화가 필요할 때라는 점을 기억해야 한다.

전체 코드

TS
interface Order {  id: string;  menu: string;}/** * 1. 고전적인 Iterator 인터페이스 */interface OrderIterator {  hasNext(): boolean;  next(): Order | null;}/** * 2. 구체적인 Iterator 구현 (배열용) */class ArrayOrderIterator implements OrderIterator {  private index = 0;  constructor(private items: Order[]) {}  public hasNext(): boolean {    return this.index < this.items.length;  }  public next(): Order | null {    return this.hasNext() ? this.items[this.index++] : null;  }}/** * 3. Iterable 객체: 주문 대기열 */class OrderQueue implements Iterable<Order> {  private orders: Order[] = [];  public addOrder(id: string, menu: string): void {    this.orders.push({ id, menu });  }  /**   * 고전적 방식: 전용 이터레이터 반환   */  public getLegacyIterator(): OrderIterator {    return new ArrayOrderIterator(this.orders);  }  /**   * 현대적 방식: Symbol.iterator 구현   * 제너레이터를 사용하면 상태 관리가 매우 쉬워진다.   */  public *[Symbol.iterator](): Generator<Order, void, unknown> {    for (const order of this.orders) {      // 복잡한 순회 로직이나 필터링을 여기서 한 번에 관리할 수 있다.      yield order;    }  }}// 매장 운영 시뮬레이션const queue = new OrderQueue();queue.addOrder("Q-01", "치즈 피자");queue.addOrder("Q-02", "페퍼로니 피자");queue.addOrder("Q-03", "하와이안 피자");console.log("--- 고전적 방식으로 순회 ---");const iterator = queue.getLegacyIterator();while (iterator.hasNext()) {  const order = iterator.next();  if (order) console.log(`처리 중: ${order.id} - ${order.menu}`);}console.log("\n--- 현대적 방식 (for...of)으로 순회 ---");// 내부 구조가 무엇인지 몰라도 순회 가능하다.for (const order of queue) {  console.log(`처리 중: ${order.id} - ${order.menu}`);}/** * TypeScript 포인트: 제너레이터의 강력함 * 예를 들어, 거꾸로 순회하는 이터레이터도 쉽게 만들 수 있다. */function* reverseIterator(orders: Order[]): Generator<Order> {  for (let i = orders.length - 1; i >= 0; i--) {    yield orders[i];  }}

대기열을 다루는 표준이 생기면서 에이든 피자의 주방과 홀은 한층 더 질서 정연해졌다. 이제 대기열 내부가 어떻게 바뀌든, 우리는 for...of 한 줄로 모든 주문을 같은 방식으로 처리할 수 있다. 우아하다고까지 말할지는 모르겠지만, 적어도 덜 지저분하다.

하지만 매장이 유명해질수록 새로운 골칫거리가 생겼다. 바로 외부 배달 앱과의 연동이다. 배민, 쿠팡이츠, 요기요... 이들은 모두 자신들만의 독특한 주문 접수 방식을 고집한다. 어떤 곳은 데이터를 JSON으로 보내고, 어떤 곳은 XML로 보낸다. 우리 매장의 세련된 시스템에 이 투박한 외부 시스템들을 어떻게든 끼워 맞춰야 한다. 서로 맞지 않는 플러그와 콘센트를 연결해 줄 '변환 어댑터'가 필요한 시점이다.

댓글

댓글을 불러오는 중...