구리

[JavaScript] Iterable, Iterator 그리고 Generator 본문

JavaScript

[JavaScript] Iterable, Iterator 그리고 Generator

guriguriguri 2023. 7. 23. 00:01

ES6(ES2015) 이전에는 통일된 규칙 없이 배열, 문자열, DOM 컬렉션들은 각자의 구조를 가지고 for문, for...in문, forEach를 사용해 요소들을 순회했었습니다.

ES6에서는 순회 가능한 데이터 컬렉션들을 이터레이션 프로토콜을 준수하는 이터러블로 통일해 for...of문, 스프레드 문법, 디스트럭처링 할당(구조 분해 할당)의 대상으로 사용할 수 있도록 일원화했으며 이터러블 객체를 쉽게 생성할 수 있는 제너레이터 함수를 사용할 수 있습니다.

이터러블 객체를 만들기 위해선 이터레이션 프로토콜에 대해 알아야 합니다.

이터레이션 프로토콜에는 이터러블 프로토콜, 이터레이터 프로토콜이 있습니다.

이터러블 프로토콜 (Iterable Protocol)

  • 이터러블이란 이터레이터를 반환하는 Symbol.iterator를 프로퍼티 키로 갖는 메서드를 가진 객체
  • Symbol.iterator를 프로퍼티 키로 사용한 메서드를 직접 구현하거나, 프로토타입 체인을 통해 상속 받을 수 있음
  • 위 규약을 이터러블 프로토콜, 이터러블 프로토콜을 준수한 객체를 이터러블이라고 함
  • 이터러블은 for...of문으로 순회할 수 있고 스프레드 문법, 구조 분해 할당의 대상으로 사용 가능

이터레이터 프로토콜 (Iterator Protocol)

  • 이터레이터란 { value, done }라는 이터레이터 리절트 객체를 반환하는 next 메서드를 소유하는 객체
  • 이터레이터는 이터러블의 요소를 탐색하기 위한 포인터 역할
  • 위 규약을 이터레이터 프로토콜, 이터레이터 프로토콜을 준수한 객체를 이터레이터라고 함

그러면 이터레이터, 이터러블, 제너레이터에 대해 자세히 알아봅니다.

 

이터러블

이터러블이란 이터러블 프로토콜을 준수한 객체를 의미하며 Symbol.iterator를 프로퍼티 키로 사용한 메서드를 구현했거나 프로토타입 체인을 통해 상속받은 객체를 말합니다.

이터러블을 for...of문 순회 및 스프레드 문법, 구조 분해 할당이 가능합니다. 하지만 Symbol.iterator 메서드를 구현하지 않았거나 상속받지 않은 일반 객체는 이터러블 프로토콜을 준수하기 않았기에 위 문법을 사용할 수 없습니다.

하지만 TC39 프로세스의 stage 4(Finished)에 제안된 스프레드 프로퍼티 제안은 일반 객체에 스프레드 문법 사용을 허용합니다.

그리고 일반 객체도 이터러블 프로토콜을 준수하도록 구현하면 이터러블이 됩니다.

const arr = [1, 2, 3];

console.log(Symbol.iterator in arr); // true
for (const item of arr) {
  console.log(item); // 1 2 3
}
const [a, ...rest] = arr;
console.log(a, rest); // 1, [2, 3]
console.log([...arr]); // [1, 2, 3]

const obj = {
  a: 1,
  b: 2,
  c: 3,
};

console.log(Symbol.iterator in obj); // false
for (const item of obj) {
  console.log(item); // obj is not iterable
}
const [a, b] = obj // // obj is not iterable
console.log([...obj]); // obj is not iterable

// 스프레드 프로퍼티 제안은 객체 리터럴 내부에서 스프레드 문법의 사용을 허용
console.log({ ...obj }); // { a: 1, b: 2, c: 3 }
TC39
TC39는 Technical Committee number 39의 약자로 ECMAScript 내 위원회로서 ECMAScript 언어를 발전시키고 사양을 작성합니다.
ECMAScript의 스펙을 변경하는 프로세스는 TC39에 의해 이뤄지며 이를 TC39 프로세스라고 합니다.
TC39 프로세스는 stage 0부터 5개의 stage로 구성되며 다음 stage로 넘어가기 위해선 위원회의 승인이 필요합니다.
위에서 작성한 stage 4는 모든 단계를 거치고 다음 표준에 포함되어 발표되기를 기다리는 단계로 4단계까지 올라온 제안은 별다른 이변이 없는 이상 새 표준에 포함되어 발표됩니다.

 

이터레이터

이터러블의 Symbol.iterator 메서드 호출시 이터레이터 프로토콜을 준수한 이터레이터 객체를 반환합니다. 이때 이터레이트는 next 메서드를 가집니다.

이터레이트의 next 메서드는 이터러블의 각 요소를 순회하기 위한 포인터 역할로, next 메서드 호출시 순회 결과를 나타내는 이터레이터 리절트 객체를 반환합니다.

이터레이터 리절트 객체는 현재 순회 중인 이러터블의 값을 나타내는 value, 이터러블의 순회 완료 여부를 나타내는 done 프로퍼티를 가집니다.

const arr = [1, 2, 3];
const iterator = arr[Symbol.iterator]();
console.log(iterator.next()); // { value: 1, done: false }
console.log(iterator.next()); // { value: 2, done: false }
console.log(iterator.next()); // { value: 3, done: false }
console.log(iterator.next()); // { value: undefined, done: true }

 

이터러블, 이터레이터 구현

한 객체에 아예 이터레이터 형식을 정의하는 예시로, 아래 range 객체는 이터러블 객체이자 이터레이터 객체 역할을 모두 수행할 수 있습니다.

let range = {
  from: 1,
  to: 5,

  [Symbol.iterator]() {
    // 생성자
    this.current = this.from;
    this.last = this.to;
    return this;
  },

  next() {
    if (this.current <= this.to) {
      return { done: false, value: this.current++ };
    } else {
      return { done: true };
    }
  },
};

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

 

for...of문

for...in문은 객체의 프로토타입 체인 상에 존재하는 모든 프로토타입의 프로퍼티 중에서 문자열로 키가 지정되었으며 프로퍼티 속성 [[Enumerable]]의 값이 true인 프로퍼티를 순회하며 열거합니다. for...in문은 문자열로 키가 지정된 프로퍼티만 열거하기에 심벌이 키값인 프로퍼티는 열거 대상에서 제외됩니다.

for...of문은 내부적으로 이터레이터의 next 메서드를 호출해 이터러블을 순회하며 next 메서드가 반환한 이터레이터 리절트 객체의 value 프로퍼티 값을 for...of문의 변수에 할당합니다.

이터레이터 리절트 객체의 done 프로퍼티 값이 false면 이터러블 순회를 진행하며, true면 순회를 중단합니다.

아래는 for...of문의 내부 동작을 for문으로 표현한 것입니다.

const iterable = [1, 2, 3];
const iterator = iterable[Symbol.iterator]();

for (;;) {
  const res = iterator.next();
  if (res.done) break;

  const item = res.value;
  console.log(item);
}

 

이터러블과 유사 배열 객체

유사 배열 객체란 length 프로퍼티를 가지며 인덱스를 나타내는 숫자 형식의 문자열 프로퍼티를 가지는 객체로 length 프로퍼티가 있기에 for문 순회가 가능하며 배열처럼 인덱스로 프로퍼티 값에 접근할 수 있습니다. 하지만 Symbol.iterator 메서드가 없어 이터러블은 아니기에 for...of문 순회는 불가합니다.

ES6에선 이터러블이 도입되면서 유사 배열 객체인 arguments, NodeList, HTMLCollection 객체는 Symbol.iterator 메서드를 구현해 이들은 유사 배열 객체임과 동시에 이터러블입니다.

 

지연 평가

아래는 무한 이터러블을 생성하는 무한 수열 함수의 예시입니다.

배열이나 문자열 등은 모든 데이터를 메모리에 미리 확보 후 데이터를 공급하지만 아래 예시는 지연 평가를 통해 데이터를 생성합니다.

지연 평가는 미리 데이터를 생성하지 않고 데이터가 실제로 필요한 시점에 데이터를 생성하는 기법으로 불필요한 데이터를 미리 생성하지 않고 필요한 순간에 데이터를 생성하기에 빠른 실행 속도를 기대할 수 있고 불필요한 메모리를 소비하지 않습니다.

아래 예시의 infiniteFibonacci 함수는 무한 이터러블을 생성하지만 for...of문이나 디스트럭처링 할당이 실행되기 전까진 데이터를 생성하지 않으며 for...of문의 경우, 이터러블 순회시 내부에서 next 메서드를 호출해야만 데이터가 생성됩니다.

const infiniteFibonacci = function () {
  let [pre, cur] = [0, 1];
  return {
    [Symbol.iterator]() {
      return this;
    },
    next() {
      [pre, cur] = [cur, cur + pre];
      // 무한 이터러블이므로 done 프로퍼티 생략
      return { value: cur };
    },
  };
};

const [val1, val2, val3] = infiniteFibonacci();
console.log(val1, val2, val3); // 1 2 3

 

이터레이션 프로토콜의 중요성

이터레이션 프로토콜이 규정되고 여러 자료 구조(Set, Map, Array 등)가 이터레이션 프로토콜의 형식을 취하면서, 다양한 데이터 공급자(자료 구조)가 하나의 순회 방식을 갖도록 규정되었습니다. 이는 데이터 취득과 사용의 효율성을 높이는 방식입니다.

 

 


 

 

제너레이터 함수 정의

  • 함수 실행시, 코드 블록 내부가 실행되는 것이 아닌 제너레이터 객체 반환
  • function* 키워드로 선언하며 1개 이상의 yield 표현식을 포함
  • 애스터리스크(*)는 function 키워드와 함수명 사이에 사용 (일관성을 위해 function 키워드 바로 뒤에 붙이는 것을 권장)
  • 화살표 함수로 정의, new 연산자와 함께 생성자 함수로 호출 불가
  • 함수 실행시, 코드 블록 내부가 실행되는 것이 아닌 제너레이터 객체 반환
function* genDecFunc() {
  yield 1;
}

function * genDecFunc() {
  yield 1;
}

const genExpFunc = function* () {
  yield 1
}

const obj = {
  * genObjMethod() {
    yield 1
  }
}

 

제너레이터 객체

제너레이터 객체는 Symbol.iterator 메서드를 상속받는 이터러블이면서 value, done 프로퍼티를 갖는 이터레이터 리절트 객체를 반환하는 next 메서드를 소유하는 이터레이터입니다.

따라서 Symbol.iterator 메서드로 이터레이터를 별도 생성할 필요가 없으며 Symbol.iterator 메서드 호출시 자기 자신인 이터레이터를 반환합니다.

function* genFunc() {
  yield 1;
}

const iterator = genFunc();
console.log(iterator[Symbol.iterator]() === iterator); // true

그러나 이터레이터이면서 이터레이터에 없는 return, throw 메서드도 가지며 각 메서드 호출시 동작 과정은 다음과 같습니다.

  • next - 제너레이터 함수의 yield 표현식까지 코드 블록 실행 후 value 프로퍼티에는 yield된 값을, done 프로퍼티에는 제너레이터 함수 내의 모든 yield문이 실행되었는지의 여부를 나타내는 값을 담은 이터레이터 리절트 객체 반환
  • return - value 프로퍼티에는 인수로 전달받은 값을, done 프로퍼티에는 true인 이터레이터 리절트 객체 반환
  • throw - 인수로 전달 받은 에러를 발생시키고 value 프로퍼티에는 undefined를, done 프로퍼티에는 true인 이터레이터 리절트 객체 반환
function* genFunc() {
  try {
    yield 1;
    yield 2;
    yield 3;
    yield 4;
  } catch (e) {
    console.error(e);
  }
}

const generator = genFunc();
console.log(generator.next()); // { value: 1, done: false }
console.log(generator.throw("error!")); // { value: undefined, done: true }
console.log(generator.return("return!")); // { value: 'return!', done: true }

 

yield/next

yield 키워드는 제너레이터 함수의 실행을 일시 중지하며, yield 키워드 뒤에 오는 표현식의 평가 결과를 caller에게 반환합니다.

next 매서드 호출시 yield 표현식까지 실행되고 일시 중지되며, { value, done } 프로퍼티를 갖는 이터레이터 리절트 객체를 반환합니다. 

즉, value 프로퍼티는 yield문이 반환한 값이고 done 프로퍼티는 제너레이터 함수 내의 모든 yield 문이 실행되었는지의 여부를 나타내는 boolean 타입의 값입니다.

제너레이터 함수가 끝까지 실행되면 value 프로퍼티에는 제너레이터 함수 반환값을, done 프로퍼티에는 true인 값으로 구성된 객체를 반환합니다.

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

const iterator = genFunc();
console.log(iterator.next()); // { value: 1, done: false }
console.log(iterator.next()); // { value: 2, done: false }
console.log(iterator.next()); // { value: 3, done: false }
console.log(iterator.next()); // { value: 4, done: true }

이터레이터의 next 메서드와는 달리 제너레이터 객체의 next 메서드에는 인자를 전달할 수 있으며 제너레이터 함수의 yield 표현식을 할당받는 변수에 할당됩니다. (해당 변수에는 yield 표현식의 평가 결과를 할당 받는 것이 아닙니다.)

function* genFunc() {
  const x = yield 1;
  const y = yield x + 10;
  return x + y;
}

const generator = genFunc();
let res = generator.next(5);
console.log(res); // { value: 1, done: false }
// 첫번쨰 next에 5를 전달했지만 첫번째 yield 표현식까지 실행 후 일시 중지됨
// 따라서 x 변수에는 아무것도 할당되지 않음

res = generator.next(10);
console.log(res); // { value: 20, done: false }

res = generator.next(20);
console.log(res); // { value: 30, done: true }

 

yield*와 재귀 제너레이터

yield* 키워드는 yield처럼 값 하나를 전달하는게 아니라 이터러블 객체를 순회하면서 각각의 값을 전달합니다.

yield* 는 어떤 이터러블 객체와도 함꼐 사용할 수 있으며 해당 키워드를 사용해 재귀 제너레이터를 만들 수 있고, 이런 특징을 활용해 재귀적으로 정의된 트리 구조에 비재귀적 순회를 수행할 수 있습니다.

아래는 0-9, A-Z, a-z를 차례로 생성하는 함수 예시입니다.

function* generateAlphaNum() {
  for (let i = 48; i <= 57; i++) yield i; // 0123456789

  for (let i = 65; i <= 90; i++) yield i; // ABCDEFGHIJKLMNOPQRSTUVWXYZ

  for (let i = 97; i <= 122; i++) yield i; // abcdefghijklmnopqrstuvwxyz
}

let str = "";
for (let code of generateAlphaNum()) {
  str += String.fromCharCode(code);
}

console.log(str); // 0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz

위 코드를 yield* 를 이용해 더 쉽게 표현할 수 있습니다.

function* generateSequence(start, end) {
  // 시작과 끝을 정해서 순회하는 제너레이터
  for (let i = start; i <= end; i++) yield i;
}

function* generatePasswordCodes() {
  yield* generateSequence(48, 57);

  // A..Z
  yield* generateSequence(65, 90);

  // a..z
  yield* generateSequence(97, 122);
}

let str = "";
for (let code of generatePasswordCodes()) {
  str += String.fromCharCode(code);
}

console.log(str); // 0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz

 

아래는  인자로 받은 n부터 0까지 값을 생성하는 계산을 재귀적으로 수행한 예시 코드입니다.

function* myGenerator(n) {
  if (n <= 0) {
    // base case: return the initial value
    return 0;
  } else {
    // recursive case: yield the current value and call the generator recursively
    yield n;
    yield* myGenerator(n - 1);
  }
}

for (const result of myGenerator(5)) {
  console.log(result); // 5 4 3 2 1
}

 

제너레이터 함수와 일반 함수의 차이

ES6에 도입된 제너레이터는 코드 블록의 실행을 일시 중지했다가 필요한 시점에 재개할 수 있는 함수로 일반 함수와는 차이점은 다음과 같습니다.

  • 함수 호출자에게 함수 실행 제어권 양도
    • 일반 함수 호출시, 함수 호출자(caller)는 이후의 함수 실행을 제어할 수 없지만, 제너레이터 함수는 caller가 제너레이터 함수 실행을 일시 중지 혹은 재개할 수 있음 
    • 제너레이터 객체의 next 메서드를 통해 제너레이터 함수 실행 재개
  • 함수 호출자와 양방향으로 상태를 주고 받음
    • 일반 함수는 실행되는 동안 함수 외부에서 내부로 값을 전달해 함수 상태를 변경할 수 없음
    • 제너레이터 객체의 next 메서드 실행 결과인 이터레이터 리절트 객체(상태)를 반환하며 caller는 next 메서드에 인자를 전달해 yield 표현식을 할당받는 변수의 값을 전달할 수 있음
  • 제너레이터 함수 호출시 제너레이터 객체 반환
    • 일반 함수는 호출시 값을 반환하지만 제너레이터 함수는 함수 코드 실행이 아닌 이터러블이면서 이터레이터인 제너레이터 객체 반환

 

제너레이터 지연 평가

위에서도 보다시피 배열의 경우 메모리에 모든 값을 다 할당하고 이후의 코드가 실행되지만 for...of문의 경우, 이터레이터의 next() 메서드를 호출하는 시점에 값이 생기기 때문에 지연 평가를 통해 불필요한 메모리를 사용하지 않으며 빠른 실행 속도도 기대할 수 있습니다.

제너레이터를 활용한 성능 측정을 통해 알아보겠습니다.

아래는 1부터 N까지 숫자 중, 10의 배수를 작은 순서대로 5개를 찾아 반환하는 코드이며 일반 함수, 제너레이터 함수를 실행해 비교해보겠습니다.

function dividableWith10(iter) {
  const arr = [];
  for (const elem of iter) {
    if (arr.length === 5) break;
    if (elem % 10 === 0) {
      arr.push(elem);
    }
  }
  return arr;
}

function makeArray(n) {
  const arr = [];
  let idx = 1;
  while (idx <= n) {
    arr.push(idx++);
  }
  return arr;
}

function* makeIterable(n) {
  let idx = 1;
  while (idx <= n) {
    yield idx++;
  }
}

// 일반 함수 테스트
console.time("strict");
const arr = makeArray(50000000);
console.log(dividableWith10(arr));
console.timeEnd("strict");

// 제너레이터 함수 테스트
console.time("iterable");
const iter = makeIterable(50000000);
console.log(dividableWith10(iter));
console.timeEnd("iterable");

일반 함수, 제너레이터 함수 성능 비교

N이 커질 수록 두 코드의 실행속도에는 차이가 생기게 됩니다. 일반 함수는 1부터 N까지 담긴 전체 배열을 생성 후 전달하지만 제너레이터 객체는 for...of문을 순회하면서 필요한 범위 내의 값만을 호출해 사용하기에 성능 차이가 발생합니다.

 

제너레이터 활용

이터러블 구현

제너레이터 함수를 이용해 간단히 이터러블을 구현할 수 있습니다. 그러면 이터레이션 프로토콜을 준수한 이터러블을 생성하는 방식과 비교해보겠습니다.

아는 이터레이션 프로토콜을 준수한 무한 피보나치 수열을 생성하는 함수입니다.

// 무한 이터러블을 생성하는 함수
const infiniteFibonacci = (function () {
  let [pre, cur] = [0, 1];
  return {
    [Symbol.iterator]() {
      return this;
    },
    next() {
      [pre, cur] = [cur, cur + pre];
      // 무한 이터러블이므로 done 프로퍼티 생략
      return { value: cur };
    },
  };
})();

for (const num of infiniteFibonacci) {
  if (num > 1000) break;
  console.log(num); // 1 2 3 5 8 13 21 .. 377 610 987
}

제너레이터 함수를 사용하면 이터레이션 프로토콜을 준수한 이터러블을 생성하는 방식보다 간단하게 이터러블을 구현할 수 있습니다.

const infiniteFibonacci = (function* () {
  let [pre, cur] = [0, 1];
  while (true) {
    [pre, cur] = [cur, pre + cur];
    yield cur;
  }
})();

for (const num of infiniteFibonacci) {
  if (num > 1000) break;
  console.log(num); // 1 2 3 5 8 13 21 .. 377 610 987
}

또한 이터러블 객체에서 Symbol.iterator 대신 제너레이터 함수를 사용하면, 제너레이터 함수로 반복이 가능합니다. 제너레이터를 이용해 위 예시인 range 객체를 더 압축할 수 있습니다.

let range = {
  from: 1,
  to: 5,

  *[Symbol.iterator]() {
    // [Symbol.iterator]: function*()를 짧게 줄임
    for (let value = this.from; value <= this.to; value++) {
      yield value;
    }
  },
};

console.log([...range]); // [1, 2, 3, 4, 5]

 

 

비동기 처리

제너레이터를 사용해 비동기 처리를 구현할 수 있습니다. 하지만 ES7에 도입된 async/await을 통해 더 간편하게 처리할 수 있습니다.

function getUser(genObj, username) {
  fetch(`https://api.github.com/users/${username}`)
    .then((res) => res.json())
    // ① 제너레이터 객체에 비동기 처리 결과를 전달한다.
    .then((user) => genObj.next(user.name));
}

// 제너레이터 객체 한방 생성
const g = (function* () {
  let user;
  // ② 비동기 처리 함수가 결과를 반환한다.
  // 비동기 처리의 순서가 보장된다.
  user = yield getUser(g, "jeresig"); // genObj.next(user.name)에 의해서 유저이름이 반환된다.
  console.log(user); // John Resig

  user = yield getUser(g, "ahejlsberg");
  console.log(user); // Anders Hejlsberg

  user = yield getUser(g, "ungmo2");
  console.log(user); // Ungmo Lee
})();

// 제너레이터 함수 시작
g.next();

 

 


참고 자료

https://developer.mozilla.org/ko/docs/Web/JavaScript/Reference/Iteration_protocols#iterable

https://tc39.es/process-document/

https://github.com/tc39/proposals

https://developer.mozilla.org/ko/docs/Web/JavaScript/Reference/Global_Objects/Generator

 

Generator - JavaScript | MDN

Generator 객체는 generator function 으로부터 반환되며, 반복 가능한 프로토콜과 반복자 프로토콜을 모두 준수합니다.

developer.mozilla.org