[JS 딥다이브] Iterable 객체

·

9 min read

개요

JS 개발을 하다보면, Iterator, Array-like 같은 키워드를 볼 수 있고, 이런 자료형이어야 작동하는 프로세스들을 볼 수 있었을 것이다.

JS ES6+ 에는 새로운 문법이나 Built-in Object 뿐만 아니라, protocols(표현법들)도 추가되었다. 이 protocol은 일정 규칙만 충족한다면, 어떠한 객체에서 의해서도 구현될 수 있다.

이러한 프로토콜은 2가지가 있다.

  • Iterable protocol
  • Iterator protocol

공통된 동일한 반복가능한(Iterable, 이터러블) 특징을 가진, 객체는 배열을 일반화한 객체이다.

이터러블 이라는 개념을 사용하면, 어떤 객체에든 for - of 문 같은 반복문을 적용할 수 있다.

  • 배열이 가장 대표적인 이터러블에 속하고
  • 배열이 아닌 객체, 이 객체가 어떤 것들의 컬렉션(Map, Set 등)을 나타내고 있는 경우, for - of 문을 적용할 수만 있다면, 컬렉션을 순회하는데 유용할 것이다.

이것을 가능하게 할 수 있도록 지켜야하는 Iterable, Iterator protocol 을 지켜서 생성되는 이터러블객체(Iterable) 에 대해 알아본다.

본론

📍 Iterable Protocol

Iterable protocol 은 Javascript 객체들이, for~of 문 같은 어떠한 value 들이 loop 되는 것과 같은 iteration 동작을 정의하거나, 사용자 정의하는 것을 허용하는 것에 대한 약속이다.

  • 기본적으로 일반 JS Object type 에서는 이런 반복문 기능을 지원하지 않는 반면에, 특정 Array, Map 과 같은 Built-in Object는 default 가 Iteration 동작으로 허용된, Built-in iterables 이다.

  • 그래서, for~of 문 같은 것은, 사용할 수 있는 것이다.

  /**
  * 기본적인 Object type 은 Iterable 이 아니다.
  * 그래서, for-of 문을 지원하지 않는다.
  */
  let range = {
    from: 1,
    to: 5,
  };

  for (let num of range) {
    console.log(num);
  }
  // TypeError: range is not iterable

Symbol.iterator 🔍

iterable(반복 가능) 하기 위해서, object는 @@iterator 메소드를 구현해야하고 이는 곧, Symbol.iterator 프로퍼티가 존재해야 한다는 것이다. 이를 만족하는 반복 가능한 객체가, iterable 이다.

  • Symbol.iterator 키에 대한 값은 Function 이다.
  • 이 function 은 Object를 반환
  • arguments(인자)가 없다.
  • iterator protocol을 따른다.

결국, 어떤 object(객체)가 반복가능(iterate)되어야 한다면, 이 객체의 @@iterator 메소드가 인수없이 호출되고, 반환된 iterator반복을 통해서 획들할 값들을 얻을 때 사용된다.

내장된 Built-in Object 중 Iterable 객체를 default 로 만들어내는 것에는 다음이 같다.

  • String
  • Array
  • TypeArray
  • Map
  • Set

그리고, 어떤 객체가 Iterable 이라면, 그 객체에 대해 지원하는 기능들은 대표적으로 다음과 같다.

  • for ~ of 문
  • spread 연산자
  • Destructuring Assignment
  • 기타 iterable 을 arguments 로 받는 함수
  /**
   * String Object 도 default Iterable 객체 중 하나이다.
   * 고로, for ~ of 문 같은 기능을 지원한다.
   */
  const myStr = "hi";
  console.log(myStr[Symbol.iterator]); // [Function: [Symbol.iterator]]

  for (let chr of myStr) {
    console.log(chr);
  }
  // h
  // i

이어서 그럼, 부가설명에 있는 것 중에 "Iterator protocol을 따른다." 에 대해 알아야 한다.

  • 💡 IterableIterator 를 혼동할 수 있으니, 구분을 잘 해야한다.

📍 Iterator Protocol

앞에서

iterable 객체는 iterable protocol 을 만족한다. 이것은 즉, Symbol.iterator 프로퍼티에 특별한 형태의 함수가 저장되어 있다. 라는 것과 같다라고 했다.

Iterable protocol 을 만족하려면, Symbol.iterator 속성에 저장되어 있는 함수는 Iterator 객체를 반환 해야한다.

🔍 Iterator 객체

iterator protocolvalue(finite Or Infinite) 들의 Sequence 를 만드는 표준 약속을 정의한다.

  • 이는, 객체가 next( ) 메소드를 가지고 있고, 다음 규칙에 따라 구현되어만 있다면, 그 객체는 Iterator 이다.

  /**
   * 일반 객체 -> 이터러블로 변환
   */
  let range = {
    from: 1,
    to: 5,

    // Symbol.iterator 프로퍼티 존재 🔍
    [Symbol.iterator]() {
      this.current = this.from;
      return this;
    },

    // next 메소드 프로퍼티 존재
    // for - of 반복마다, 이 next( ) 가 호출 됨 🔍
    next() {
      if (this.current <= this.to) {
        return { done: false, value: this.current++ };
      } else {
        return { done: true };
      }
    },
  };

  // 객체 -> 이터러블 변환 후, 이터러블 기능 중 for - of 문 사용가능
  // range 객체 -> range 이터러블
  for (let num of range) {
    console.log(num);
  }
  // 1
  // 2
  // 3
  // 4
  // 5

위에 코드에는 부가적인 설명이 필요하다.

이터러블 객체를 생성하는데 핵심은 관심사의 분리(SoC, Separation of Concern에 있다.

위에 코드는 이를 고려한 것이다.

  • range 객체에는 메소드 next() 가 없다.
  • 대신 range[Symbol.iterator]()호출해서 만든 이터레이터 객체와 이 객체의 메소드인 next() 에서 반복에 사용될 값을 만들어낸다.
  • 이렇게 하면, 이터레이터 객체와 반복 대상인 객체를 분리할 수 있다.

또 다른 예를 보자.

 /**
   * "generator" 로 생성한 iterable 테스트
   */
  let strIterator = "hello"[Symbol.iterator]();
  while (true) {
    let result = strIterator.next();
    if (result.done) break;
    console.log(result.value);
  }
  // h
  // e
  // l
  // l
  // o
  • 명시적으로 이터레이터를 호출하는 경우는 거의 없긴 하다.
  • 하지만, 이 방법을 사용하면, for-of 문 을 사용하는 것보단 반복 과정을 더 잘 통제 할 수 있다는 장점이 있다.
  • 물론, for-of문 도중에도 break 같은 키워드로 중간제어 등이 가능하긴 하다.

📍 Generator 함수

위에 관심사의 분리(SoC) 방식으로, iterable 객체를 만드는 방법을 확인했었다.

그런데, 이렇게, Iterator 객체를 반환하는 규칙을 지켜서 정석으로 Iterable 객체를 생성하면 코드자체가 길고, 더 복잡해 질 수 있다.

그래서 Generator 함수 를 사용하면 좀 더 간편하게 Iterable 객체를 생성할 수 있다.

Generator 함수 역시, 직접 Iterable 객체를 생성할 수 있는 방법 이다. 즉, Generator 함수는 iterable 객체를 반환 하는 특별한 함수이다.

Generator 함수 정의 🔍

// generator 함수 선언하기
function* gen1() {
  // ...
}

// 표현식으로 사용하기
const gen2 = function* () {
  // ...
}

// 메소드 문법으로 사용하기
const obj = {
  * gen3() {
    // ...
  }
}
  • Generator 함수를 호출하 객체가 생성되는 데, 이 객체는 Iterable protocol를 만족한다.
  • 즉, Symbol.iterator 프로퍼티를 갖고 있다.
function* gen1() {
  // ...
}

// `gen1`를 호출하면 iterable이 반환됩니다.
const iterable = gen1();

iterable[Symbol.iterator]; // [Function]

yield 키워드 🔍

Generator 함수 안에서는 yield 라는 특별한 키워드를 사용할 수 있다.

  • yield 키워드는 return 키워드와 유사한 역할을 한다.
  • iterable의 기능을 사용할 때, yield 키워드 뒤에 있는 값들을 순서대로 넘겨준다.
  /**
   * Generator 예제
   * [형식]
   * - function* GenName() {} 
   */
  function* numberGen() {
    yield 1;
    yield 2;
    yield 3;
  }

  let myNumberGen = numberGen();
  console.log(myNumberGen); // Object [Generator] {}

  for (let n of myNumberGen) {
    console.log(n);
  }
  // 1
  // 2
  // 3

yield* 표현식을 사용하면, 다른 Generator 함수에서 넘겨준 값을 대신 넘겨줄 수도 있다.

  function* numberGen1() {
    yield 1;
    yield 2;
  }
  function* numberAndStrGen() {
    yield* numberGen1();
    yield "a";
    yield "b";
  }

  let myGen = numberAndStrGen();
  console.log(myGen); // Object [Generator] {}

  for (let value of myGen) {
    console.log(value);
  }
  // 1
  // 2
  // a
  // b

Generator 사용시 주의점 🔍

  • Generator 함수로부터 생성된 iterable 은 한 번만 사용할 수 있다.
  • Generator 함수 내부에서 정의된 일반 함수에서는 yield 키워드를 사용할 수 없다.
// Generator 함수로부터 생성된 iterable은 한 번만 사용될 수 있습니다.
function* gen() {
  yield 1;
  yield 2;
  yield 3;
}

const iter = gen();

for (let n of iter) {
  // 잘 출력됩니다.
  console.log(n);
}
for (let n of iter) {
  // `iter`는 한 번 사용되었으므로, 이 코드는 실행되지 않습니다.
  console.log(n);
}
// Generator 함수 내부에서 정의된 일반 함수에서는 `yield` 키워드를 사용할 수 없습니다.
function* gen2() {
  // 아예 문법 오류가 납니다. (Unexpected token)
  function fakeGen() {
    yield 1;
    yield 2;
    yield 3;
  }
  fakeGen();
}

📍 Generator 와 Iterator

Generator 함수로부터 만들어진 객체는 일반적인 iterable처럼 쓸 수 잇지만, iterator와 관련된 특별한 성질을 갖고 있다.

  1. iterable protocol 과 iterator protocol을 동시에 만족한다.

  2. 즉, Symbol.iterator 프로퍼티를 생성해서, iterator 를 생성하는 과정 같은 것이 필요없이, 바로 next 메소드를 호출할 수 있다.

function* gen() {
  // ...
}

const genObj = gen();
genObj[Symbol.iterator]().next === genObj.next; // true
function* gen() {
  yield 1;
  yield 2;
  yield 3;
}

const iter = gen();

iter.next(); // { value: 1, done: false }
iter.next(); // { value: 2, done: false }
iter.next(); // { value: 3, done: false }
iter.next(); // { value: undefined, done: true }
  1. generator 함수 안에서 return 키워드를 사용하면, 반복이 바로 끝나면서 next 메소드에서 반환되는 객체의 속성에 앞의 반환값이 저장된다. 다만, return 을 통해 반환된 값이 반복 절차에 포함되지는 않는다.
  function* gen1() {
    yield 1;
    return 2; // generator 함수는 여기서 종료
    yield 3;
  }

  const iter1 = gen1();

  iter.next(); // { value: 1, done: false }
  iter.next(); // { value: 2, done: true }
  iter.next(); // { value: undefined, done: true }

  for (let v of gen1()) {
    console.log(v);
  }
  // 1
  1. generator 함수로부터 생성된 객체의 next 메소드에 인자를 주어서 호출하면, generator 함수가 멈췄던 부분의 yield 표현식의 결과값은 앞에서 받은 인자가 된다.
  function* gen2() {
    const received = yield 1;
    console.log(received);
  }

  const iter2 = gen2();
  console.log(iter2.next()); // { value: 1, done: false }

  console.log(iter2.next("hello"));
  // hello << 내부 generator 에서의 console 출력
  // { value: undefined, done: true }    << 기존에 실질적인 iterator 객체순회는 끝났었으므로, undefined

📍 유사배열 과 이터러블

비슷해 보이지만, 헷갈리기 쉬운 두 개념이 있다. 짚고가자.

  • 이터러블(Iterable) : Symbol.iterator가 구현된 객체
  • 유사배열(Array-like) : 인덱스length 프로퍼티가 있어서 배열처럼 보이는 객체

JS로 문제를 해결할 때, 이터러블 객체나, 유사 배열 객체 또는 둘 다인 객체를 만날 수 있다.

  • 둘 다인 경우는 이터러블 객체이면서, 유사배열 객체인 String 이 대표적
  • 이터러블 객체라고, 유사 배열 객체가 되는 건 아니고
  • 유사 배열 객체라고, 이터러블 객체가 되는 것도 아니다.
  • 이터러블 객체에는 length 프로퍼티 같은 것은 없고
  • 유사 배열 객체에는 for-of문 같은 iterable 제공기능을 사용할 수 없는 것과 같다.

또한 이터러블, 유사배열은 Array 가 아니기 때문에, Array 메소드등을 원래는 지원하지 않는다.

하지만, 평소에 개발하면서, Array 에서 제공하는 메소드들을 사용해야 하는 경우가 많다. 이터러블, 유사배열에서도 마찬가지인데, 이때 어떻게 할 수 있을까 ?

📍 Array.from

범용 메소드 Array.from은 이터러블이나 유사배열을 받아 진짜 Array 를 만들어준다.

이 메소드 처리를 해주면, 이터러블이나 유사배열에 배열 메소드들을 사용할 수 있다.

  /**
   * Array.from(Iterable 또는 Array-like)
   */
  function* myGenerator1() {
    yield 1;
    yield 2;
  }
  let myGeneratorInst = myGenerator1();
  let myArrayLike = {
    0: "a",
    1: "b",
    length: 2,
  };

  // myGeneratorInst.push(1); // TypeError: myGeneratorInst.push is not a function
  // myArrayLike.push("c"); // TypeError: myArrayLike.push is not a function

  let arrFromMyGeneratorInst = Array.from(myGeneratorInst);
  let arrFromMyArrayLike = Array.from(myArrayLike);

  arrFromMyGeneratorInst.push(3);    // 성공
  arrFromMyArrayLike.push("c");    // 성공

  console.log(arrFromMyGeneratorInst); // [ 1, 2, 3 ]
  console.log(arrFromMyArrayLike); // [ 'a', 'b', 'c' ]
  • Array.from객체를 받아 이터러블이나 유사 배열인지 조사한다.
  • 넘겨 받은 인자가 이터러블이나 유사 배열인 경우, 새로운 배열을 만들고 객체의 모든 요소를 새롭게 만들 배열로 복사한다.

Array.from 매핑(Mapping) 🔍

Array.from(obj[, mapFn, thisArg])

  • mapFn을 두 번째 인자로 넘겨주면, 새로운 배열에 obj요소를 추가하기 전에 각 요소를 대상으로 mapFn 을 적용할 수 있다.
  • 새로운 배열엔 mapFn을 적용하고 반환된 값이 추가된다.
  • thisArg는 **각 요소의 this를 지정할 수 있도록 해주는 것이다.
// 각 숫자를 제곱
let arr = Array.from(range, num => num * num);

console.log(arr); // 1,4,9,16,25

결론

쉬운 개념은 아니라서, 글이 길어졌다. 정리하자.

  • for - of 문 같은 몇몇 반복가능한 기능들을 객체에서 할 수 있도록 만들어진 객체를 이터러블(iterable) 객체라고 한다.
  • 이터러블객체에는 반드시 Symbol.iterator 프로퍼티가 반드시 구현되어 있어야한다.
  • Symbol.iterator 메소드의 결과를 이터레이터(Iterator)객체 라고하고, 이터레이터는 이어지는 반복 과정을 처리한다.
  • 이터레이터 객체에는 {done: Boolean, value: any}를 반환하는 메소드인 next()반드시 구현되어 있어야 한다.
  • 이터레이터와 이터러블을 동시에 만족시켜, 이터러블객체를 비교적 간편하게 만들수 있는 Generator 함수가 있다.
  • 이터러블이나 유사배열Array 가 아니다. 이것을 해결해줘서, Array 처럼 사용할 수 있게 하는 범용 메소드인 Array.from() 이 있다.

기본적으로 반복의 개념이 없는 일반 Object 에 대해, 반복이 가능하도록 생긴 개념인 이터러블(Iterable)은 JS를 개발하면서 사용하는 것들 중간중간에 많이 숨어있다.

그만큼 한 번 정리하고가면 좋은 이슈라 포스팅했다.

다음은, 잠시 언급한 느낌만 났던, 유사 배열(Array-like)에 대해 정리한다.


참고