본문 바로가기
이론/Frontend

자바스크립트로 비동기 처리하기 : Generator

by 유세지 2023. 11. 28.

 

 

 

 

 

 

들어가며

 

지난 시간까지 자바스크립트에서 효과적인 비동기 작업을 위해 사용하는 PromiseAsync / Await 키워드에 대해 알아보았는데, 여기에 이어 오늘은 Generator 까지 한 번 공부해보겠습니다.

 

Generator는 흐름상 async / await 과 함께 소개되는 것이 자연스러웠으나,

이전 글은 Promise에서 async / await으로 이어지는 흐름에 집중했던 글이라

간단히만 짚고 넘어가게 되었습니다.

 

거기에 따로 담고 싶은 내용도 있었던터라 이렇게 포스팅 하나를 할애하게 되었네요.

바로 시작하겠습니다.

 

 

 

Generator란?

Generator는 실행 중 일시 정지와 재개가 가능한 함수로,

순서에 따른 동작들을 담기에 용이한 데이터 구조를 갖고 있습니다.

 

function* generator(msg) {
  yield msg;
  yield msg+msg;
  yield msg+msg+msg;
}

const gen = generator('안녕');

for(let task of gen) {
  console.log(task)
}

// for ... of 문 대신 이렇게도 사용 가능합니다.
// gen.next()
// gen.next()
// gen.next()

// 출력 결과 값:
// 안녕
// 안녕안녕
// 안녕안녕안녕

 

 

function에 * 를 함께 사용하여 제너레이터 함수를 선언할 수 있고,

함수 내부에 yield 키워드로 반환값을 정의하여 사용합니다.

 

이렇게 만든 제너레이터 함수는 for ... of 문과 함께 사용하거나

Generator.next() 메서드를 이용해 한 단계씩 진행할 수 있습니다.

 

제너레이터는 단순히 반복 작업만을 위해 사용될 수도 있지만,

아래처럼 비동기 작업과 함께 쓰였을때 특히 더 유용해집니다.

 

// generator와 promise(fetch)를 사용한 비동기 작업
function* asyncFunction() {
  try {
    const resultData = yield fetch('https://api.sampleapis.com/coffee/hot');
    const data = yield resultData.json();
    return data;
  }
  catch(error) {
    console.log(error);
  }
}

const asyncIterator = asyncFunction();
let ret;

function run() {
  ret = asyncIterator.next();
  
  if (!ret.done) {
    ret.value.then(run);
  else {
    console.log('data is :', ret.value);
  }
}

 

 

이전 포스팅에 쓰였던 promise만 활용한 비동기 작업 코드를 제너레이터를 사용한 코드로 변환한 결과물입니다.

promise의 then 체이닝이 사라지고, try ... catch 도 다시 사용할 수 있게 되었습니다.

 

여기서 눈여겨볼건 제너레이터 함수 내부의 형태인데,

비동기 작업임에도 마치 동기 코드를 사용하는 것처럼 작성되어 있는 모습입니다.

 

function* asyncFunction() {
  try {
    const resultData = yield fetch('https://api.sampleapis.com/coffee/hot');
    const data = yield resultData.json();
    return data;
  }
  catch(error) {
    console.log(error);
  }
}

 

 

그런데, 어디선가 본 것 같은 코드이지 않나요?

맞습니다. 지난 시간에 작성한 async / await 코드와 아주 흡사한 모양입니다.

 

async function asyncFunction() {
  try {
    const resultData = await fetch('https://api.sampleapis.com/coffee/hot');
    return resultData.json();
  } catch(error) {
    console.log(error);
  }
}

 

 

*  키워드가 async 로, yield 키워드가 await 으로 바뀐걸 제외하면 거의 완벽하게 똑같습니다.

당연합니다. 실제로도 async / await 또한 내부에서는 제너레이터로 동작하니까요.

이렇게 두 코드를 직접 보시고나면 훨씬 직관적으로 와닿으실거라 생각합니다.

 

자, 제너레이터의 사용법과 async / await 과의 관계를 알아봤으니 조금 더 깊게 들어가볼까요?

제너레이터가 어떻게 비동기 작업들을 동기적으로 수행할 수 있게 되었는지

실행 컨텍스트에서의 동작 과정과 함께 알아봅시다.

 

 

 

실행 컨텍스트에서의 Generator

자바스크립트는 실행 컨텍스트를 기반으로 동작합니다. Callback과 Promise가 그랬듯 Generator 또한 마찬가지로 실행 컨텍스트 위에서 동작하는데, 다른 비동기 작업과는 다르게 이전의 값들을 기억하고 있으면서 일시정지(pause)와 재개(resume)의 상태를 자유롭게 넘나들 수 있습니다. 이 말인 즉, 언제든 작업을 멈췄다가 다시 시작하는데에 문제가 없다는 의미이기도 합니다.

 

자바스크립트 엔진은 효율적인 동작을 위해 더 이상 사용되지 않는 정보들을 삭제하는 가비지 컬렉팅을 자동으로 하고 있습니다. 일찍이 클로저(closures)의 예시에서 본 적이 있으실겁니다. 제너레이터가 사용하는 데이터들도 당연히 가비지 컬렉팅의 대상입니다. 단, 제너레이터가 완전히 종료된 이후에만 그렇습니다.

 

 

제너레이터는 바로 사라지지 않습니다.

 

 

크롬 디버거를 통해 확인해보겠습니다.

 

 

Generator가 아직 진행중일때 (끝나지 않았을때)

 

 

맨 위에서 작성한 예시의 코드를 실행시켰을때,

제너레이터 인스턴스인 gen은 실행에 필요한 정보들을 [[Scopes]]에 모두 갖고 있습니다.

반복문 내에서 task의 출력이 끝났는데도 이 데이터는 그대로 유지됩니다.

 

즉, 가비지 컬렉팅이 되지 않고 있는 상태입니다.

 

 

Generator가 종료되었을때

 

 

 

이번에는 for문 외부에서 debugger를 찍어 본 모습입니다.

제너레이터가 순회를 모두 마치고, 더 이상 사용되지 않는 위치에서 확인한 결과입니다.

 

[[GeneratorState]] 속성이 "suspended" 에서 "closed" 로 바뀌었고,

원래 가지고 있던 [[Scopes]]는 사라졌습니다.

 

제너레이터가 완전히 종료된 이후에야 가지고 있던 값들도 사라지는 것을 확인할 수 있었습니다.

 

 

 

Generator 객체와 Well-formed iterable

제너레이터 함수 키워드인 function* 를 이용하면 Generator 객체를 만들 수 있습니다.

이렇게 만들어진 Generator 객체는 이터러블 프로토콜과 이터레이터 프로토콜을 동시에 만족하는 객체로,

well-formed iterable의 조건을 충족하고 있습니다.

 

이터러블 프로토콜이란, for ... of과 같은 구조에서 순회 동작을 지정할 수 있도록 하는 규약입니다.

어떤 객체가 순회 가능하려면 순서에 따라 호출될 값을 알고 있어야 하는데, @@iterator 메서드에 해당 값을 반환하는 함수가 구현되어 있다면 이터러블 프로토콜을 만족한다고 할 수 있습니다.

 

MDN에서는 순회 가능 프로토콜로 번역되고 있습니다.

 

 

우리가 만든 제너레이터 객체에도 Symbol.iterator가 구현되어 있습니다.

 

 

추가로, @@Symbol. 과 같습니다.

자바스크립트 내부에서 사용되는 몇몇 잘 알려진 심볼들을 간단히 부르는 별명이라고 생각하시면 되겠습니다.

ex ) @@split = Symbol.split

 

 

다음으로 이터레이터 프로토콜이란, 반복상에서 값을 생성하는 방법을 정의하는 규약입니다.

Iterator가 next 메서드를 구현하고 있어야하고, 해당 메서드가 IteratorResult 인터페이스를 만족해야합니다.

IteratorResult 인터페이스는 done value 를 속성으로 가집니다.

 

MDN에서는 반복자 프로토콜로 번역되고 있습니다.

 

자연어로 적어놓으니 뭔가 복잡해보이는데, 코드로 보면 한결 편합니다.

 

interface Iterator {
  next(value?: any): IteratorResult;
  return?(value?: any): IteratorResult;
  throw?(exception?: any): IteratorResult;
}

interface IteratorResult {
  done?: boolean;
  value?: boolean;
}

 

 

따라서 위 인터페이스들이 곧 이터레이터(반복자) 프로토콜이라고 할 수 있겠습니다.

 

 

well-formed iterable을 설명하기 위해 다소 길게 돌아왔는데, 결국 well-formed iterable 이란 '순회' 라는 의도에 맞게 동작할 수 있도록 프로토콜을 정상적으로 만족하고 있는 이터러블을 의미합니다.

 

분명히 @@iterable 은 구현되어 되어있으나, 해당 함수가 제대로 된 반복자를 반환하지 않는 경우 Non-well-formed iterables, 잘못 구성된 이터러블이라고 합니다.

 

간혹 well-formed iterable이 이터러블한 이터레이터로 설명되는 경우가 있는데, 제너레이터 객체가 이터러블한 이터레이터를 만족하는 동시에 well-formed iterable일 뿐 (위 캡쳐에서 확인할 수 있듯 @@iterable이 자기 자신을 반환합니다.) 그 역은 성립하지 않기 때문에 엄밀히는 틀린 설명입니다.

 

 

 

마치며

제너레이터에 대해 정리하려니 이터레이션 프로토콜에 대한 내용도 들어가야 할 것 같아,

이렇게 한 포스트를 따로 할애하게 되었네요.

 

긴 글 읽어주신 분들, 놓쳤던 내용이나 새로운 시각을 알려주시는 분들께도 늘 감사드립니다.

 

Special thanks. 시지프님, Sming님, jsonkim님

 

 

참고문서

Iteration protocols -  MDN Docs

이터레이터 - TypeScript Deep Dive

제너레이터와 실행컨텍스트 - Sming님
well-formed iterable에 대한 오해 - jsonkim님

 

 

반응형

댓글