abonglog logoabonglog

이터레이터와 이터러블, 제네레이터, 비동기 이터러블 의 썸네일

이터레이터와 이터러블, 제네레이터, 비동기 이터러블

함수형 자바스크립트
프로필 이미지
yonghyeun

신명나게 멀티패러다임 프로그래밍 책을 N회독하면서 이터레이터를 이해하면 책의 내용 및 라이브러리인 FxTs 뿐 아니라 함수를 생성 할 때에도 유용하게 쓸 수 있을 거 같아 한 번 MDN 을 통해 공부해본다.

이 개념들이 왜 유용할까?

우선 기본적으로 이터러블 프로토콜들은 내장된 기능인 for of 문으로 순회 가능하거나 스프레드 연산들을 가능하게 하는 프로토콜이기에 알고 있어야 한다.

다만 더 나아가 이 개념들을 활용하면 다음과 같은 기능들을 할 수 있다.

  1. 지연 평가를 이용한 메모리 관리

만약 무한하거나 무한에 가깝게 거대한 데이터 스트림을 다뤄야 한다고 생각해보자

이 데이터 스트림들은 매우 크기가 크기 때문에 변수에 저장해두면 메모리에 많은 값을 저장하고 있게 된다.

하지만 이터레이터들을 직접 활용하면 메모리 공간을 크게 사용하지 않고 데이터를 다룰 수 있게 된다.

  1. 지연평가를 통한 코드의 선언적 처리

해당 코드는 이터레이터를 활용하여 구현된 함수형 라이브러리인 FxTs 의 코드 예시이다.

원하는 값을 N 개만 취하는 take 메소드
const iter = take(2, [0, 1, 2, 3, 4, 5, 6]);
iter.next() // {done:false, value: 0}
iter.next() // {done:false, value: 1}
iter.next() // {done:true, value: undefined}
 
// with pipe
pipe(
 [0, 1, 2, 3, 4, 5, 6], // N개의 데이터에 대해
 filter((n)=> n % 2), // 홀수에 대하여
 take(2), // 2개만 취하여
 toArray, // 배열로 변환
); // [1,3]

지연평가를 이용하면 데이터 셋을 모두 순회할 필요 없이 원하는 시점까지만 데이터를 취하는 것이 가능하다.

예를 들어 위 코드에서 이터러블인 [0,1,...6] 에 대해서 배열을 생성하는데 take(2) 는 2개의 값이 찰 때 까지만 filter(n => n%2)([0,1,2,...6]) 이 반환하는 이터러블을 순회한다.

0 -> 1 (take) -> 2 -> 3(take) -> 배열로 변경 [1,3] 이렇게만 하고 종료된다.

만일 일반적인 배열의 메소드 체이닝이였다면 모든 값을 순회해야했을 것이다.

이터레이터를 직접 다루는 것의 아름다움은 지연평가를 통해 원하는 시점에 원하는 개수만큼만 코드를 실행 시킬 수 있다는 것에 있다고 생각한다.

이를 통해 선언적으로 작성된 함수들이 명령형 코드로 작성된 코드 만큼 효율적이면서 원하는 호출 시점에 값을 취할 수 있다는 함수로서의 장점도 가져갈 수 있다.

이터러블 프로토콜 (순회가능 프로토콜)

이터러블 프로토콜은 자바스크립트에서 for of 문을 이용하여 순회 가능한 (이터러블) 객체이기 위한 프로토콜을 의미한다.

  • [Symbol.iterator] 를 통해 이터레이터를 반환하는 메소드가 있어야 한다.
ES2015에 명시된 이터러블 인터페이스
interface Iterable<T, TReturn = any, TNext = any> {
    [Symbol.iterator](): Iterator<T, TReturn, TNext>;
}

우리가 for of 문으로 순회하거나 스프레드 연산이 가능한 값들은 이터러블 프로토콜을 따르는 이터러블 객체에 해당한다.

이런 이터러블 객체는 배열부터 맵,셋 ... 등등 다양한 객체들이 존재한다.

이터러블한 객체와 스프레드 연산
const arr = [1, 2, 3, 4, 5];
const map = new Map([
  ["a", 1],
  ["b", 2],
  ["c", 3],
]);
const set = new Set([1, 2, 3, 4, 5]);
 
console.log(...arr); // 1 2 3 4 5
console.log(...map); // [ 'a', 1 ] [ 'b', 2 ] [ 'c', 3 ]
console.log(...set); // 1 2 3 4 5
 
console.log(arr[Symbol.iterator]); // [Function: values]
console.log(map[Symbol.iterator]); // [Function: entries]
console.log(set[Symbol.iterator]); // [Function: values]

아! 그런데 예외적인게 객체는 이터러블이 아님에도 불구하고 스프레드 연산이 가능하다.

ES2018 부터 객체 리터럴에서 이터러블 프로토콜을 따르지 않지만 열거 가능한 속성들에 대한 스프레드 구문이 사용 가능하다.

객체는 이터러블이 아니지만 스프레드 연산이 예외적으로 가능
const obj = {
  name: "John",
  age: 30,
  city: "New York",
};
 
console.log(typeof obj[Symbol.iterator]); // undefined
console.log({ ...obj }); // { name: 'John', age: 30, city: 'New York' }

이렇게 이터러블 프로토콜을 지키는 객체 (이하 이터러블) 들에 대해서 [Symbol.iterator] 를 통해 이터레이터 객체를 반환받을 수 있다.

이터러블에게서 이터레이터를 받는 예시
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 }
 
for (const value of arr) {
  // for..of 문은 내부적으로 Symbol.iterator를 사용하여 반복합니다.
  console.log(value); // 1, 2, 3
}

for of 문을 통한 순회는 이런 이터러블들에 대해서 [Symbol.iterator] 를 호출하여 이터레이터 객체를 받은 후 이터레이터의 값들이 모두 종료 될 때 까지 값을 호출한다.

이터레이터 프로토콜 (반복자 프로토콜)

ES2015 이터레이터의 인터페이스
interface IteratorYieldResult<TYield> {
    done?: false;
    value: TYield;
}
 
interface IteratorReturnResult<TReturn> {
    done: true;
    value: TReturn;
}
 
type IteratorResult<T, TReturn = any> = IteratorYieldResult<T> | IteratorReturnResult<TReturn>;
 
interface Iterator<T, TReturn = any, TNext = any> {
    // NOTE: 'next' is defined using a tuple to ensure we report the correct assignability errors in all places.
    next(...[value]: [] | [TNext]): IteratorResult<T, TReturn>;
    return?(value?: TReturn): IteratorResult<T, TReturn>;
    throw?(e?: any): IteratorResult<T, TReturn>;
}

이터레이터 프로토콜은 다음과 같은 규약을 지키는 객체를 의미하며 이런 객체들을 이터레이터라 한다.

  • next() 메소드를 통해 IteratorResult 를 반환한다.
  • IteratorResultdone,value 값을 가지며 다음 next() 호출 때 value 값을 반환 가능 여부를 done 에 표시하고 value 는 각 순회마다의 값을 반환한다.
  • IteratorResultreturn , throw 하는 방식 중 원하는 어떤 방식으로 구현 가능하다.

예시를 보는편이 훨씬 편하기에 직접 이터레이터 객체를 구현해보자

무한한 값을 반환하는 이터레이터
const naturalIterator = {
  value: 0,
 
  next() : IteratorResult<number> {
    return { done: false, value: this.value++ };
  },
};
 
console.log(naturalIterator.next()); // { done: false, value: 0 }
console.log(naturalIterator.next()); // { done: false, value: 1 }
console.log(naturalIterator.next()); // { done: false, value: 2 }
console.log(naturalIterator.next()); // { done: false, value: 3 }

이런식으로 무한한 값을 반환하는 이터레이터를 생성 할 수도 있고 필요에 따라 이후의 순회를 종료하는 것도 가능하다.

종료 조건이 존재하는 이터레이터
const naturalUnder3 = {
  value: 0,
 
  next(): IteratorResult<number> {
    return this.value < 3
      ? {
          done: false,
          value: this.value++,
        }
      : {
          done: true,
          value: undefined,
        };
  },
};
 
console.log(naturalUnder3.next()); // { done: false, value: 0 }
console.log(naturalUnder3.next()); // { done: false, value: 1 }
console.log(naturalUnder3.next()); // { done: false, value: 2 }
console.log(naturalUnder3.next()); // { done: true, value: undefined }

이터러블을 구현해보자

그럼 위에서 말했던 이터러블, 이터레이터 프로토콜에 맞춰 이터러블을 구현해보자

이터러블 구현
const naturalIterable = {
  value: 0,
 
  // 1. Symbol.iterator 를 통해 이터레이터를 반환받을 수 있으니
  // naturalIterable은 iterable이다.
  [Symbol.iterator](): Iterator<number> {
    // 2. Symbol.iterator 를 통해 이터레이터 프로토콜을 지키는 객체를 반환한다.
    return {
      next(): IteratorResult<number> {
        return {
          value: this.value++,
          done: this.value > 10, // 예시를 위해 10까지만 반복
        };
      },
    };
  },
};
 
for (const value of naturalIterable) {
  console.log(value); // 0, 1, 2, 3, 4, 5, 6, 7, 8, 9
}

위 객체는 Symbol.iterator 를 통해 Iterator 를 반환하니 이터러블이라 할 수 있다.

이터러블 이터레이터

이터러블 이터레이터는 이터러블이면서 이터레이터인 객체를 의미한다.

ES2015에 구현된 IterableIterator 인터페이스
interface IterableIterator<T, TReturn = any, TNext = any> extends Iterator<T, TReturn, TNext> {
    [Symbol.iterator](): IterableIterator<T, TReturn, TNext>;
}

이터러블이면서 이터레이터란 것은 Symbol.iterator 를 통해 이터레이터를 반환하면서도 본인이 next 메소드를 가진 이터레이란 것인데 이는 단순히 이처럼 구현 가능하다.

이터러블 이터레이터
const naturalIterableIterator = {
  value: 0,
 
  [Symbol.iterator]() {
    return this;
  },
 
  next() {
    return {
      done: this.value > 10,
      value: this.value++,
    };
  },
};
 
// 직접 이터레이터로서 사용 가능
console.log(naturalIterableIterator.next()); // { done: false, value: 0 }
console.log(naturalIterableIterator.next()); // { done: false, value: 1 }
console.log(naturalIterableIterator.next()); // { done: false, value: 2 }
 
// for of 문으로 사용 가능
for (const value of naturalIterableIterator) {
  console.log(value); // 3, 4, 5, 6, 7, 8, 9, 10
}

이터레이터를 반환 할 때 자기자신을 반환하는 이터러블이라면 이터러블 이터레이터라 할 수 있다.

이터러블 이터레이터를 반환하는 제네레이터 함수

이렇게 매번 이터러블 이터레이터를 만들 때 객체 리터럴과 클로저를 이용해서 구현하는 것은 꽤나 번거로운 일이다.

이에 좀 더 쉽게 이터러블 이터레이터인 제네레이터를 반환하는 제네레이터 함수에 대해 알아보자

제네레이터 함수는 다음과 같은 인터페이스를 가진 제네레이터를 반환한다.

Generator interface
interface Generator<T = unknown, TReturn = any, TNext = any> extends IteratorObject<T, TReturn, TNext> {
    // NOTE: 'next' is defined using a tuple to ensure we report the correct assignability errors in all places.
    next(...[value]: [] | [TNext]): IteratorResult<T, TReturn>;
    return(value: TReturn): IteratorResult<T, TReturn>;
    throw(e: any): IteratorResult<T, TReturn>;
    [Symbol.iterator](): Generator<T, TReturn, TNext>;
}

인터페이스를 살펴보면 IterableIterator 와 동일한 모습을 볼 수 있다. 인터페이스명만 Generator 란 점을 제외하곤 말이다.

GenratorFunction
const naturalGenratorFunction = function* () {
  let i = 0;
  while (i < 10) {
    yield i++;
  }
};
 
const iterable = naturalGenratorFunction(); // Genrator {}
 
console.log(typeof iterable[Symbol.iterator]); // function
console.log(typeof iterable.next); // function
console.log(iterable.next()); // { value: 0, done: false }
for (const value of iterable) {
  console.log(value); // 1, 2, 3, 4, 5, 6, 7, 8, 9
}

제네레이터 함수는 function 키워드 앞에 * 를 붙혀 사용하며 return 이 아닌 yield 를 이용해 IteratorYieldResult 를 반환한다.

이렇게 반환되는 값은 이터레이터의 next() 메소드를 호출했던 것과 동일한 인터페이스를 가진다.

비동기 이터러블, 이터레이터, 제네레이터

위에서 설명했던 개념들만 알고 있다면 비동기 이터러블, 이터레이터, 제네레이터에 대한 것도 쉽게 알 수 있다.

비동기 이터러블,이터레이터, 제네레이터들은 ES2018 에 추가된 스펙으로 다음과 같은 인터페이스를 갖는다.

AsyncIterator , AsyncIterable
interface AsyncIterator<T, TReturn = any, TNext = any> {
    // NOTE: 'next' is defined using a tuple to ensure we report the correct assignability errors in all places.
    next(...[value]: [] | [TNext]): Promise<IteratorResult<T, TReturn>>;
    return?(value?: TReturn | PromiseLike<TReturn>): Promise<IteratorResult<T, TReturn>>;
    throw?(e?: any): Promise<IteratorResult<T, TReturn>>;
}
 
interface AsyncIterable<T, TReturn = any, TNext = any> {
    [Symbol.asyncIterator](): AsyncIterator<T, TReturn, TNext>;
}
 
/**
 * Describes a user-defined {@link AsyncIterator} that is also async iterable.
 */
interface AsyncIterableIterator<T, TReturn = any, TNext = any> extends AsyncIterator<T, TReturn, TNext> {
    [Symbol.asyncIterator](): AsyncIterableIterator<T, TReturn, TNext>;
}
 
// AsyncGenerator
interface AsyncGenerator<T = unknown, TReturn = any, TNext = any> extends AsyncIteratorObject<T, TReturn, TNext> {
    // NOTE: 'next' is defined using a tuple to ensure we report the correct assignability errors in all places.
    next(...[value]: [] | [TNext]): Promise<IteratorResult<T, TReturn>>;
    return(value: TReturn | PromiseLike<TReturn>): Promise<IteratorResult<T, TReturn>>;
    throw(e: any): Promise<IteratorResult<T, TReturn>>;
    [Symbol.asyncIterator](): AsyncGenerator<T, TReturn, TNext>;
}

비동기 이터러블 이터레이터들 모두 일반적인 것과 동일하지만 값들이 모두 Promise 로 감싸져있다는 점만 다르다.

비동기 이터러블들은 모두 for await (const ...) 로 순회 가능하다.

비동기 제네레이터는 async function* 를 통해 생성 가능하다.

비동기 제네레이터 예시
const asyncNaturalGeneratorFunction =
  async function* (): AsyncGenerator<number> {
    let i = 0;
    while (i < 10) {
      // 비동기적 딜레이 추가
      await new Promise((resolve) => setTimeout(resolve, 100));
      yield i++;
    }
  };
 
const asyncGenerator = asyncNaturalGeneratorFunction();
 
(async function () {
  for await (const value of asyncGenerator) {
    console.log(value); // 2. 100ms 마다 0부터 9까지 출력
  }
  console.log("비동기적 코드 실행 완료!"); // 3. 비동기적 코드 실행 완료!
})();
 
console.log("동기적 코드 실행!"); // 1. 동기적 코드 실행!

이터레이터들을 활용하는 방법을 소개하는 FxTs 라이브러리

이렇게 이터러블 이터레이터들을 직접 조작하는 가장 아름다운 방법을 소개하는 라이브러린 FxTs 의 예시를 들어보고 싶다.

여러 함수형 라이브러리에선 pipecompose 를 통해 함수들을 합성하여 사용한다.

마치 우리가 배열을 사용 할 때 [].filter().map().reduce() 처럼 쓰듯이 말이다.

FxTs 라이브러리는 다음과 같은 개념을 통해 함수 합성을 제시한다.

  1. 라이브러리의 대부분의 메소드들 (lazy)은 IterableIterator 를 반환한다.

예를 들어 map(num => num*2, [1,2,3]) 의 경우 [2,4,6] 을 반환하는 것이 아니라 next() 가 호출 될 때 마다 2,4,6 을 차례로 반환한다.

map 메소드의 예시
import { map } from "@fxts/core";
 
const mapped = map((num) => num + 1, [1, 2, 3]);
 
console.log(mapped);
/**
{
  next: [Function: next],
  [Symbol(Symbol.iterator)]: [Function (anonymous)]
}
 */

각 반환되는 값들은 IterableIterator 기 때문에 해당 값들을 이용하여 새로운 이터러블 이터레이터를 생성 할 수 있다. 예를 들어 이처럼 말이다.

IterableIterator 를 받아 새로운 IterableIterator 생성
import { filter, map } from "@fxts/core";
 
const mapped = map((num) => num + 1, [1, 2, 3]);
const filtered = filter((num) => num % 2, mapped);
 
console.log(filtered.next()); // { value: 3, done: false }

이 코드가 실행되면 filtered 의 이터러블 next()가 num %2 를 만족하는 값이 나올 때 까지 mapped 의 next() 를 호출한다.

즉 값이 필요한 시점에 map 의 메소드들을 호출하여 값을 취하는 지연평가를 가능하게 한다.

이런 지연평가는 데이터스트림의 크기가 클 수록 빛을 발한다.

무한한 데이터 스트림에서 10개의 값만 취득
import { filter, map, range, take } from "@fxts/core";
 
const mapped = map((num) => num + 1, range(Infinity));
const filtered = filter((num) => num % 2, mapped);
 
for (const value of take(10, filtered)) {
  console.log(value); // 1, 3, 5, 7, 9, 11, 13, 15, 17, 19
}

이 코드들은 take 에서 10개의 값이 나올 때 까지 filtered(mapped) 로 생성된 이터레이터들의 next() 를 호출한다.

10개의 값이 나올 때 까지 filtered(mapped) 에서 20번의 연산만 일어났다.

만약 배열의 프로토타입 메소드를 이용했다면 무한한 크기의 데이터스트림에 모드 map , filter 연산을 해야 했기에 무한의 시간만큼이나 걸렸을 것이다.

애초에 무한한 크기의 배열을 저장하는 것 조차 불가능했을 것이다.

심지어 이 코드들은 pipe 를 통해 함수 합성을 아주 우아하게 제공한다.

pipe를 이용한 선언적 함수 합성
import { filter, map, range, take, pipe, tap, each } from "@fxts/core";
 
pipe(
  range(Infinity),
  map((num) => num + 1),
  filter((num) => num % 2),
  take(10),
  each(console.log) // 1, 3, 5, 7, 9 ...
);

이 라이브러는 지연평가뿐 아니라 비동기처리를 위한 toAsync, 병렬 처리를 위한 concurrentPool 메소드들도 제공한다.

여러 코드들은 위에서 설명한 이터러블, 이터레이터 개념과 for of 문을 이용해 구현된 아주 단순한 코드들이기에 이해하기 쉽고 효율적이다.

아름다워 아름다워