이터레이터는 끝이 날 때까지 다음(next)이라는 문으로 계속 들어갈 수 있는 객체입니다. 출처: copilot으로 제작

Iterator

이터레이터는 반복 가능한 객체를 순회할 수 있게 해주는 객체입니다. 조금 더 구체적으로 표현하자면 이터레이터는 이터러블 객체를 반환하는 Symbol.iterator 메서드를 구현한 객체입니다.

 

이터러블 객체(Iterable Object)는 자바스크립트에서 반복 가능한 객체를 의미합니다. 이터러블 객체는 반복 작업을 지원하며, for...of 루프나 스프레드 연산자, 배열 디스트럭처링 등 다양한 자바스크립트 문법에서 사용할 수 있습니다.

 

이터러블 객체는 Symbol.iterator라는 특수한 메서드를 가지고 있습니다. 이 메서드는 이터레이터를 반환해야 하며, 이터레이터는 next 메서드를 통해 반복 작업을 처리합니다. next 메서드는 { value, done } 형태의 객체를 반환하며, 다음 요소의 값과 반복이 끝났는지 여부를 나타냅니다.

 

자바스크립트에는 기본적으로 다양한 이터러블 객체가 있으며, 배열 (Array), 문자열 (String), 맵 (Map), 셋 (Set) 등이 있습니다.

Symbol.iterator의 작동 방식

이터러블 객체를 직접 만들려면 Symbol.iterator 메서드를 정의해야 합니다. 이 Symbol.iterator는 특수 내장 심볼로 for..of가 시작될 때 호출하는 메서드입니다. 만약 이 Symbol.iterator가 없는 객체를 for..of문에 사용한다면 iterator가 아니라는 오류가 발생합니다.

// TypeError: myObject is not iterable

이후 for..of는 반환된 객체(이터레이터)를 대상으로 동작합니다. for..of 블록의 함수가 종료되고 나면 for..of는 이터레이터의 next메서드를 호출합니다. 그리고 next가 반환한 객체를 대상으로 동작을 반복하게 됩니다. 이때 next의 반환 값은 {done: Boolean, value: any}와 같은 형태입니다. value에는 for..of 블록에서 사용할 값들이 들어있고 done은 반복이 종료되었는지 즉, 모든 객체에 대해 순회가 완료되었는지를 알리는 플래그가 됩니다. done의 값이 true면 반복이 종료되었음을 의미합니다. done이 false일땐 value에 다음 값이 존재하여 해당 값을 대상으로 반복이 계속됩니다.

위의 과정을 조금 더 쉽게 정리해보겠습니다.

  1. for..of는 먼저 Symbol.iterator에서 반환된 객체 사용하여 순회를 시작
  2. for..of는 next 메서드를 호출
  3. Symbol.iterator가 없거나 이터레이터를 반환하지 않으면 오류
  4. 반환된 객체의 done 속성이 false면 반복 종료
  5. for..of는 반환된 객체(이터레이터)의 vlaue를 사용하여 블록 내부의 코드 실행
  6. 블록 내부의 코드가 전부 실행된 이후 2번부터 다시 반복
    특이한 점은 for..of가 첫 반복에도 next 메서드를 호출한다는 점과 Symbol.iterator는 단 한번만 호출한다는 점 입니다.

Symbol.iterator 예제

Symbol.iterator를 이용하여 Python의 range 함수를 구현해보도록 하겠습니다. Python의 range 함수는 숫자 시퀀스를 생성하는 데 사용됩니다. 시퀀스는 메모리 효율적으로 숫자 범위를 제공하며, 실제로 메모리에 모든 숫자를 저장하지 않습니다. 같은 범위의 배열(Python에서는 list)을 생성하는 것과는 다른 것입니다.

 

range는 시작 숫자(start)와 종료 숫자(stop), 그리고 각 숫자 사이의 간격(step)을 설정할 수 있습니다. step은 음수로 두어 숫자가 작아지는 시퀀스도 만들 수 있어야 합니다. 이때 step이 음수인데 start가 stop보다 작거나, step이 양수인데 start가 stop보다 크면 즉시 종료합니다. 그럼 자바스크립트를 이용해 해당 함수를 이터러블한 객체의 형태로 구현해보겠습니다.

const range = {
  start: 1,
  stop: 12,
  step: 3,

  // for..of 최초 호출 시, Symbol.iterator의 next()가 호출됩니다.
  // Symbol.iterator는 이터레이터 객체를 반환합니다.
  [Symbol.iterator]() {
    console.log("Symbol.iterator 호출");
    // for..of 반복문은 반환된 이터레이터 객체의 next() 메서드를 호출하여 반복을 수행합니다.
    return {
      current: this.start,
      last: this.stop,
      step: this.step ?? 1,
      cnt: this.cnt ?? 1,

      // for..of 반복문에 의해 이번 for 블록이 종료되고 다음 반복이 시작되어야 할 때 next()가 호출됩니다.
      // 반복문이 계속 실행되는 동안, next() 메서드는 반복적으로 호출됩니다.
      next() {
        console.log(`${this.cnt}번째 반복`);
        this.cnt++;
        const current = this.current;
        // next()는 값을 { done: boolean, value: any } 형태로 반환해야 합니다.
        if ((this.step > 0 && current < this.last) || (this.step < 0 && current > this.last)) {
          this.current += this.step;
          return { done: false, value: current };
        } else {
          console.log(`${this.cnt}번째 반복은 실행하지 않고 종료`);
          return { done: true };
        }
      },
    };
  },
};

for (const num of range) {
  console.log(num); // 1 then 4, 7, 10
}

이터러블 객체의 핵심은 '관심사의 분리(Separation of concern, SoC)'에 있습니다. range엔 메서드 next()가 없습니다. 대신 range[Symbol.iterator]()를 호출하여 이터레이터 객체를 만들어 내어 사용합니다. for..of에서 Symbol.iterator 메서드가 최초 1회에 실행되는 이유가 됩니다(반면 next() 메서드는 순회를 위해 반복문 내부의 블록 실행이 끝날 때마다 호출됩니다.). 이렇게 하면 이터레이터 객체와 반복 대상인 객체를 분리할 수 있습니다.

관심사가 분리되지 않은 이터레이터

반면 이터레이터 객체와 반복 대상 객체를 합쳐서 range 자체를 이터레이터로 만들면 코드가 더 간단해집니다.

const range = {
  start: 1,
  stop: 13,
  step: 2,

  // Symbol.iterator는 자기 자신을 반환합니다.
  [Symbol.iterator]() {
    console.log("Symbol.iterator 실행");
    this.current = this.start;
    return this;
  },

  // next는 Symbol.iterator에서 자기 자신을 반환했으므로 계속 1번 줄에 할당된 range에서 호출되는 것입니다.
  next() {
    const current = this.current;

    if ((this.step > 0 && current < this.stop) || (this.step < 0 && current > this.stop)) {
      this.current += this.step;
      return { done: false, value: current };
    } else {
      console.log("마지막 반복");
      return { done: true };
    }
  },
};

for (let num of range) {
  console.log(num); // 1, then 2, 3, 4, 5, 7, 9, 11
}

이제 range[Symbol.iterator]()가 객체 자신을 반환합니다. 덕분에 더 짧은 코드로 이터레이터를 만들 수 있습니다. Symbol.iterator 및 next가 매번 새 객체를 만들지 않으니 메모리도 조금은 더 절약될 것입니다. 하지만 range[Symbol.iterator]()가 자기 자신을 반환하기 때문에 for..of를 중첩한다면 이상한 결과가 나타날 것입니다. 중첩된 반복문 모두 같은 객체를 보며 반복을 진행하기 때문입니다. 위의 코드를 이용해 하나의 객체를 이용해 이중 반목문을 실행시킨다면 결과는 다음과 같이 나오게 됩니다.

for (let num of range) { // ... ①
  console.log(`외부 반복의 값 ${num}`);
  for (let num2 of range) { // ... ②
    console.log(`내부 반복의 값 ${num2}`);
  }
}

`
외부 반복의 값 1
내부 반복의 값 1
내부 반복의 값 3
내부 반복의 값 5
내부 반복의 값 7
내부 반복의 값 9
내부 반복의 값 11
`

먼저 ① 반복에서 Symbol.iterator를 호출해 start를 1로 초기화합니다. 그리고 num은 1이므로 1을 출력학게 됩니다. 이후 ② 반복에서 다시 Symbol.iterator를 호출하기 때문에 start는 1로 초기화됩니다. 이때 외부 반복과 내부 반복 모두 Symbol.iterator는 같은 객체 즉, 첫줄에 선언 및 할당된 range를 보고 있습니다. 그래고 내부 반복에서 next를 반복적으로 호출하면서 range는 {done: true}를 반환하게 됩니다. 그런데 외부 반복에서 사용하는 이터러블 객체 역시 range이므로 외부 반복에서 next를 호출하면 다시 {done: true}를 반환하게 되며 전체 for..in문이 종료됩니다. 그런데 동시에 두 개의 for..of를 사용하는 것은 비동기 처리에서도 흔한 케이스는 아닙니다.

Generator

제너레이터는 반복되는 작업을 하는 기계와 같습니다. 출처 : copilot으로 제작

제너레이터(Generator)는 JavaScript에서 비동기 프로그래밍과 반복 작업을 더 쉽게 다룰 수 있도록 도와줍니다. 제너레이터는 함수의 실행을 중단하고 다시 시작할 수 있기도 합니다. 제너레이터와 이터러블 객체를 함께 사용하면 손쉽게 데이터 스트림을 만들 수 있습니다.

Generator 함수

제너레이터를 만들려면 '제너레이터 함수’라 불리는 특별한 문법 구조, function*이 필요합니다. 기존의 함수에서 *문자를 추가하기만 하면 제너레이터 함수가 됩니다. 제너레이터 함수는 일반 함수와 동작 방식이 다릅니다. 제너레이터 함수를 호출하면 코드가 실행되지 않고, 대신 실행을 처리하는 특별 객체, '제너레이터 객체’가 반환됩니다.

 

function *f(…)와 같은 방식으로도 제너레이터 함수를 만들 수 있지만 *는 제너레이터 함수를 나타내므로 일반적으로 function* f(…)이 선호됩니다. *는 종류를 나타내는 것이지 이름을 나타내는 것이 아니기 때문입니다.

function* generateSequence() {
  yield 1;
  yield 2;
  return 3;
}

// '제너레이터 함수'는 '제너레이터 객체'를 생성합니다.
let generator = generateSequence();
console.log(generator); // Object [Generator] {}

이때 generateSequence의 본문은 아직 실행되기 이전의 상태입니다!

next 메서드

이터레이터와 마찬가지로 제너레이터 또한 next 메서드를 가지고 있습니다. next()를 호출하면 가장 가까운 yield <value>문을 만날 때까지 실행이 지속됩니다. yield <value>문을 만나면 실행이 멈추고(일시정지) 산출하고자 하는 값인 value가 바깥 코드에 반환됩니다. yield는 return과 비슷한 역할을 하지만 return과 달리 yield를 만났다고 함수의 전체 실행이 끝난 것은 아닙니다. next를 다시 호출하게 되면 이전에 만났던 yield부터 다시 시작하여 다음 yield를 만날 때까지 함수를 실행합니다.

 

제너레이터의 next()는 이터레이터의 next() 메서드와 마찬가지로 value와 done을 가진 객체를 반환합니다.

function* generateSequence() {
  console.log("첫 번째 실행");
  yield 1; // ... ①
  console.log("두 번째 실행");
  yield 2; // ... ②
  console.log("세 번째 실행");
  return 3; // ... ③
}

let generator = generateSequence();

console.log(generator.next()); // { value: 1, done: false }
console.log(generator.next()); // { value: 2, done: false }
console.log(generator.next()); // { value: 3, done: true }

각각의 next를 실행할 때마다, 함수의 실행은 일시정지합니다. 첫번째 next를 실행한다면 ①에서 함수는 일시정지하고 1을 반환합니다. 그리고 다시 next를 실행한다면 ②에서 일시정지하고 2를 반환하게 됩니다. 마찬가지로 next를 또 실행한다면 ③에서 정지하고 3을 반환합니다. 이 때 ③은 yield가 아닌 return이므로 함수가 종료되고 반환 값의 done은 true가 됩니다.

제너레이터와 이터레이터

제너레이터의 구조를 보면서 계속 짐작할 수 있듯이 제너레이터는 이터러블 입니다. 따라서 for..of 반복문을 사용해 값을 얻을 수 있습니다. 그런데 주의할 점이 있습니다. return의 값으로 반환된 값은 done이 true가 됩니다. 즉, for..of반복문에서 retrun으로 반환된 값은 무시됩니다. 그러므로 for..of를 사용했을 때 모든 값이 출력되길 원한다면 yield로 모든 값을 반환해야 합니다. 이는 전개구문(spread syntax, ...) 등 다른 이터러블 기능을 이용할 때도 마찬가지입니다.

이터레이터를 제너레이터로 바꾸기

Symbol.iterator 대신 제너레이터 함수를 사용하면, 제너레이터 함수로 반복이 가능합니다. 같은 이터레이터를 만들 수 있지만 더욱 짧고 간편하게 만들 수 있습니다.

const rangeObject = {
  start: 0,
  stop: 10,
  step: 2,

  *[Symbol.iterator]() {
    let current = this.start;
    while ((this.step > 0 && current < this.stop) || (this.step < 0 && current > this.stop)) {
      current += this.step;
      yield current;
    }
  },
};

console.log(...rangeObject);

위에서 구현하였던 range를 함수의 형태로 사용이 가능하며 동시에 훨씬 짧은 코드가 되었습니다. 이터레이터 객체에서 [Symbol.iterator]를 *[Symbol.iterator]() {}와 같은 방식으로 제너레이터 함수를 이용할 수있습니다. 그리고 제너레이터를 함수로 만들어 rangeObject를 생성하는 함수를 만들어 실제 Python의 range와 똑같이 사용할 수 있도록 만들 수 있습니다.

 

다음은 range를 제너레이터 함수로 만든 코드입니다.

function* range(start, stop, step = 1) {
  let current;
  if (!stop) {
    current = 0;
    while (current < start) {
      yield current++;
    }
  } else {
    current = start;
    while ((step > 0 && current < stop) || (step < 0 && current > stop)) {
      yield current;
      current += step;
    }
  }
}

console.log(...range(1, 8, 3)); // 1 4 7

// step의 기본값은 1
console.log(...range(5, 10)); // 5 6 7 8 9

// 인자가 하나일 경우 0부터 해당 수까지
console.log(...range(10)); // 0 1 2 3 4 5 6 7 8 9

제너레이터 컴포지션(generator composition)

제너레이터 컴포지션(generator composition)은 제너레이터 안에 제너레이터를 '임베딩(embedding, composing)'할 수 있는 기능입니다. 이전에 만들어 놓았던 range 제너레이터를 활용하여 특정 범위 안의 수 중 짝수와 홀수만을 각각 가지는 제너레이터를 만들어 보겠습니다.

function* oddNumbers(start, stop) {
  yield* range(start, stop, 2);
}

function* evenNumbers(start, stop) {
  for (const num of range(start, stop - 1, 2)) {
    yield num + 1;
  }
}

console.log("Odd Numbers:");
for (const num of oddNumbers(1, 11)) {
  console.log(num);  // 1 then 3, 5, 7, 9
}

console.log("Even Numbers:");
console.log(...evenNumbers(1, 11)); // 2 4 6 8 10

제너레이터 안에서 제너레이터를 사용할 때는 for..of문 외에도 yield*라는 특수한 지시자를 사용할 수도 있습니다. yield* 지시자는 실행을 다른 제너레이터에 위임합니다(delegate). 여기서 '위임’은 외부의 제너레이터가 yield*가 모든 값을 산출할 때까지 해당 yield*에서 일시정지된 상태로 값을 하나씩 바깥으로 전달하는 것을 의미합니다. for..of문을 이용하는 것과 yield* 지시자를 이용하는 것의 결과는 차이가 없습니다.

Generator의 양방향 통신(Two-way Communication)

지금까지 배운 제너레이터는 이터러블 객체처럼 값을 생성하는 역할을 했습니다. 하지만 제너레이터는 다른 특이한 기능을 가지고 있습니다. yield가 양방향 통신을 할 수 있는 것인데요. yield는 단순히 값을 바깥으로 전달하는 것뿐만 아니라, 제너레이터 내부로 값을 전달할 수도 있습니다.

function* generator() {
    const x = yield 'Please provide x'; // ... ①
    const y = yield 'Please provide y';
    return x + y;
}

const gen = generator();
console.log(gen.next().value); // Please provide x
console.log(gen.next(2).value); // Please provide y
console.log(gen.next(3).value); // 5

여기서 특이한 점은 generator.next()를 처음 호출할 땐 항상 인수가 없어야 합니다. 인자가 넘어오더라도 무시하게됩니다. ①의 위치에 와서 받을 준비가 되어야만 인수를 받아 사용할 수 있습니다.

generator.throw

외부 코드는 yield의 결과가 될 값을 제너레이터 안에 전달하기도 합니다. 그런데 외부 코드가 에러를 만들거나 던질 수도 있습니다. 에러는 결과의 한 종류이기 때문에 이는 자연스러운 현상입니다. 에러를 yield 안으로 전달하려면 generator.throw(err)를 호출해야 합니다. generator.throw(err)를 호출하게 되면 err는 yield가 있는 줄로 던져집니다. 제너레이터 내부에 try-catch 절이 있다면 그에 따라 에러 처리가 가능합니다.

function* generator() {
  yield "무사히 통과";
  try {
    yield "다음 역은 에러역입니다.";
    yield "못보는 값";
  } catch (e) {
    yield "에러 상황에 반환하는 값";
    yield "에러 상황에 두번째로 반환하는 값";
    try {
      yield "다음 역은 이중 에러역입니다.";
    } catch (e) {
      yield "이중 에러 상황에 반환하는 값";
    }
  }
  return "마지막 값";
}

const gen = generator();

console.log(gen.next().value); // 무사히 통과
console.log(gen.next().value); // 다음 역은 에러역입니다.
console.log(gen.throw(new Error("에러")).value); // 에러 상황에 반환하는 값
console.log(gen.next().value); // 에러 상황에 두번째로 반환하는 값
console.log(gen.next().value); // 다음 역은 이중 에러역입니다.
console.log(gen.throw(new Error("이중 에러")).value); // 이중 에러 상황에 반환하는 값
console.log(gen.next().value); // 마지막 값

모던 자바스크립트에서는 제너레이터를 잘 사용하지 않습니다. 그러나 제너레이터를 사용하면 실행 중에도 제너레이터 호출 코드와 데이터를 교환할 수 있기 때문에 유용한 경우가 종종 있습니다. 그리고 제너레이터를 사용하면 이터러블 객체를 쉽게 만들 수 있다는 장점도 있습니다.

참고 자료

모던 JavaScript 튜토리얼, iterable 객체 제너레이터

+ Recent posts