이터러블.. 이터레이터.. 제너레이터?!

HyunHo Lee·2023년 1월 1일
4

JavaScript

목록 보기
20/20

하~ 이거 기억 안나는데

제목을 보고, 한 번이라도 고개를 갸웃거린 개발자가 들어왔을 것이다. 자신이 알고 있는 개념이 정확한지 또는 이참에 확실하게 알기 위해서 말이다. 이참에 나도 확실하게 정리하고 넘어가야겠다.

이터러블 객체 (Iterable Object)

모두 iterable의 뜻은 알고 있을 것이다. "반복 가능" 이라는 단어다.
절대 몰라서 구글 번역기 쳐본거 아니다

iterable 객체는 ES6에 추가된 개념으로 반복 가능한 객체이다. Array는 물론이고 String, Set, Map과 같은 다수의 내장 객체도 이터러블 이다.


const str = "Ayaan";

for(let char of str) {
  console.log(char);
}

보통은 string이 배열이 아니기 때문에 for 반복문을 돌릴 수 없다고 생각한다. 하지만 string은 이터러블 객체중 하나이기 때문에, for..of를 사용하여 반복할 수 있다.


const arr = [1, 2, 3];

// spread poerator
const copyArr = [...arr];

// destructuring assignment
const [a, b] = ["a", "b"];

이 외에도 이터러블과 함께 사용되는 문법은 spread poerator([...arr]), yield*, destructuring assignment이 있다.


이렇게 우리는 평소에 사용하던 것들이 iterable 객체일 수 있다는 것을 알았다. 그렇다면 이터러블 객체가 되기 위한 조건은 무엇일까?

iterable은 기본적으로 Iteration protocols을 만족한다. 그리고 Iteration protocols 에는 iterable protocoliterator protocol이 있다.

iterable protocol에는 위에서 설명한 for..of가 사용 가능 해야하며, 이를 위해 이터레이터(iterator) 객체를 반환하는 @@iterator메소드가 구현되어야 한다. @@iterator메소드가 이터레이터 객체를 반환하지 않는 비정형 이터레이터의 경우, 예상치 못한 버그를 발생시킬 수 있기 때문에 권장하지 않는다.

이터러블에 이어서 새로운 개념인 이터레이터가 등장하는 순간이다.
(이제부터 iterable 객체를 그냥 iterable이라고 부르겠다.)


이터레이터 (iterator)

const str = "Ayaan";
const eStr = str[Symbol.iterator]();

console.log(eStr.next());
console.log(eStr.next());
console.log(eStr.next());
console.log(eStr.next());
console.log(eStr.next());
console.log(eStr.next());

Ayaan 이라는 string 타입이 선언된 str 변수는 iterable이므로 for..of문을 사용할 수 있다. 이것은 특수 내장 심볼인 Symbol.iterator가 내부적으로 구현이 되어 있기 때문에 가능한 것이다.

Symbol.iterator는 반드시 iterator 객체를 반환하는 iterator 메소드가 있어야 한다. 이제 iterator메소드를 통해 next()를 사용하고, iterator 객체가 반환되는지 콘솔로 확인해보자.

출력되는 모양을 보니 next()가 어떤식으로 만들어져 있는지 살짝 감이온다. next()를 사용할 때마다 한 step을 리턴받고 있는 모양이다. 현재의 value와 반복이 끝났는지에 대한 done말이다.

즉, iterator는 valuedone이 포함된 객체를 반환한다. 반드시 next()메소드가 존재해야 한다.

또한, iterator protocol을 만족해야 한다. 마지막 반복이 아닐 경우 value에 알맞은 값을 리턴하고 donefalse를 리턴한다. 그리고 마지막 반복인 경우에 donetrue가 되는 규칙을 만족하면 된다.


이터러블 객체 만들어 보기

이터러블과 이터레이터에 대한 간단한 개념들을 살펴보았다.
이번에는 직접 이터러블 객체를 만들어보면서 완벽하게 이해해보자.

let range = {
  from: 1,
  to: 3
};

range라는 일반 객체를 선언했다. 일반 객체는 이터러블이 아니지만, 우리가 코드를 붙여줘서 이터러블하게 만들어보자.

for...of 문을 사용하면 내부적으로 가장 먼저 Symbol.iterator을 호출하고, Symbol.iterator는 이터레이터를 반환하게 된다. 이제 for...of는 앞에서 반환된 이터레이터만을 대상으로 동작하게 된다. 우리가 해야할 일은 Symbol.iterator을 만들고, next()의 반환값을 { done: Boolean, value: any }와 같은 iterator result 객체로 만드는 것이다.


let range = {
  from: 1,
  to: 3,
  [Symbol.iterator]() {
  	this.current = this.from;
    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);
}

Symbol.iterator에서 현재 값을 기억하기 위해 this.current = this.from;를 수행하고, return this로 현재의 모든 상태들을 반환하고 있다. next()는 호출될 때마다 this.currentthis.to에 도달했는지 체크하여 상황에 맞는 값을 반환한다. 이제 for..of문을 사용해보면 1 2 3이 정상적으로 출력되는 것을 볼 수 있다.

Symbol.iterator을 range 객체에 함께 작성하는 것이 아니라 따로 만드는 것도 가능하다. 위의 방법이 코드가 간결해진다는 장점이 있지만, 하나의 객체에서 2중 for..of문을 사용할 수 없다는 단점도 있긴 하다. 보통 그런 구조는 잘 사용하지 않지만, 상황에 맞는 방식을 선택하면 된다.


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

  *[Symbol.iterator]() {
    for(let value = this.from; value <= this.to; value++) {
      yield value;
    }
  }
};

앞에서 직접 만든 이터러블 객체를 이렇게 간단하게 만들 수도 있다. *yield라는 새로운 개념들이 보인다. 이것이 제너레이터 (generator)에서 사용되는 것들이다.

이해가지 않는 부분이나 더 궁금한 사항이 있다면 자바스크립트 튜토리얼 이터러블을 참고해보자! 유사배열과의 차이점이 설명되어 있다. poiemaweb에는 무한 이터러블과 지연 평가에 대해서 알 수 있다.


제너레이터 (generator)

우리는 이터러블을 생성하기 위해 Iteration protocols를 준수했었다. 하지만 제너레이터를 사용한다면 더 쉽게 이터러블을 구현할 수 있다. 심지어 제너레이터 함수는 비동기 처리에도 유용하게 사용되기 때문에 꼭 살펴보고 넘어가자.


function example() {
  let i = 0;
  while(true) {
    i++;
  }
}

여기에 호출되면 영원히 끝나지 않는 함수가 하나 있다. while의 조건이 항상 true이므로, 계속 반복문을 돌아 끝나지 않는다. 그렇다면 아래의 코드를 봐보자.


function* example() {
  let i = 0;
  while (true) {
  	yield ++i;
  }
}

const generator = example();

console.log(generator.next().value);
console.log(generator.next().value);
console.log(generator.next().value);

이 함수도 무한 반복하는 형태일까? 제너레이터 함수는 함수의 코드 블록을 한 번에 실행하지 않고, 함수 코드 블록의 실행을 일시 중지했다가 필요한 시점에 해당 부분부터 다시 시작할 수 있게 해준다.

next()를 호출하면 어떤 일이 일어나는지 알아보자. 가장 가까운 yield <value>를 만날 때까지 실행을 지속하며 만나게 되면 값을 리턴하고, 함수의 실행을 일시 중지한다. 이로인해 위의 코드는 순서대로 1, 2, 3이 출력된다.

제너레이터 함수를 사용하기 위해서는 *yield가 필요하다. *은 함수 이름 사이에 아무데나 붙을 수 있지만, 암묵적으로 function 바로 뒤에 붙인다. 또한, 제너레이터도 이터러블이므로 for..of를 사용할 수 있는 것과 같은 특징들을 똑같이 갖는다.


제너레이터의 return과 throw

제너레이터 함수에서의 return

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

const generator = example();

console.log(generator.next().value);
console.log(generator.next().value);
console.log(generator.next().value);

제너레이터 함수에는 yield외에도 return 또는 throw도 사용할 수 있다. 우선 return부터 알아보자. 이 코드에서 next()를 세 번 호출하면, 1, 2, 3이 호출된다.

for(let value of generator) {
  console.log(value);
}

하지만 for..of문을 이용하여 콘솔을 찍어보면, 3이 출력되지 않는다. 그 이유는 for..of 이터레이션은 done: true인 경우 value를 무시하기 때문이다.

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

const generator = example();

그래도 return은 보통 함수에서 사용하는 것과 같다. return 아래에서는 yield가 있어도 next().valuefor..of문에 출력되지 않는다.


제너레이터의 return() 메소드

function* example() {
  yield 1;
  yield 2;
  yield 3;
}

const generator = example();

console.log(generator.next().value);
console.log(generator.return(9).value);
console.log(generator.next().value);

이렇게 제너레이터 함수 안에서 사용하는 return 외에도 next()처럼 사용하는 return() 메소드도 있다. 이 메소드는 제너레이터 함수를 종료시킨다. 위 코드는 1, 9, undefined가 출력된다.


function* example() {
  yield 1;

  try {
    yield 2;
  } finally {
    yield 3;
  }
  yield 4;
  yield 5;
}

const generator = example();

console.log(generator.next().value);
console.log(generator.next().value);
console.log(generator.return(9).value);
console.log(generator.next().value);
console.log(generator.next().value);

try문의 finally 타이밍에 return()사용된다면 어떨까?

finally에 해당하는 3이 출력되고, return(9) 메소드가 실행되어 9가 출력된다.

throw

function* example() {
  try {
    yield 1;
  } catch(e) {
    yield e;
  }finally {
    yield 3;
  }
}

const generator = example();

console.log(generator.throw(8).value); // 8

throw() 메소드도 제너레이터 함수를 종료시킬 수 있다. catch의 파라미터 ethrow(8)의 인자 8이 들어간다.


function* example() {
  try {
    yield 1;
  } catch(e) {
    yield e;
  }finally {
    yield 3;
  }
    yield 4;
}

const generator = example();

console.log(generator.next().value); // 1
console.log(generator.throw(8).value); // 8
console.log(generator.next().value); // 3
console.log(generator.next().value); // 4

만약 catch 타이밍에 throw를 사용한다면, next() 메소드를 더 사용할 수 있다. 위의 코드에서는 1, 8, 3, 4가 출력된다.


제너레이터 컴포지션

제너레이터 안에 제너레이터를 embedding할 수 있게 해주는 기능이 있다. yield*과 같이 사용할 수 있는 것이다.

function* example() {
    yield 1;
    yield* [2, 3, 4].map(num => num*2);
}

const generator = example();

console.log(generator.next().value); // 1
console.log(generator.next().value); // 4
console.log(generator.next().value); // 6

코드로 확인해보는게 더 이해가 쉬울 것 같다. map이 한 번에 계산을 하는것이 아니라 위와 같이 동작하게 된다.


마무리

오늘은 이터러블, 이터레이터, 제너레이터에 대해 기본적은 정보만 알아보았다. 부족한 정보나 이해가 어려운 부분은 글 중간중간에 있는 링크들을 확인해보자.

그리고 위의 개념들이 많이 활용하는 경우는 언제가 있을지 고민해보는 것도 좋을 것 같다. Redux Saga에서 제너레이터를 활용하기도 하는데 궁금하다면 찾아보는 것을 추천한다.

profile
함께 일하고 싶은 개발자가 되기 위해 달려나가고 있습니다.

0개의 댓글