자바스크립트 딥다이브 - 제너레이터 async/await

ChoiYongHyeun·2024년 1월 13일
0

제너레이터

제너레이터는 코드 블록의 실행을 일시 중지 했다가 필요한 시점에 재개 할 수 있는 특수한 함수이다.

생각해보면 일반적으로 함수가 호출 된 순간 함수의 동작은 우리가 제어 할 수 없다.

function countNum() {
  console.log(1);
  console.log(2);
  console.log(3);
}

countNum(); // 1 2 3

countNum 은 호출되는 순간 엔진에 의해 1,2,3 을 순차적으로 호출한다.

하지만 제너레이터를 이용하면 함수를 호출하더라도 함수의 실행 시점을 마음대로 제어 할 수 있다.

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

const generator = generatorCountNum();

console.log(generator.next().value); // 1
console.log(generator.next().value); // 2
console.log('흠.. 다음꺼를 호출할까 말까 .. '); //  흠.. 다음꺼를 호출할까 말까 ..
console.log(generator.next().value); // 3

그 이유는 이터레이터와 큰 연관이 있다.

또한 함수는 함수 호출자와 상태를 주고 받을 수 없다.

함수는 인수들을 호출 시에 전달 받고 전달 받은 상태에서 호출 된 이후로는 외부의 값을 변경 시키기만 할 수 있을 뿐 새로운 값을 호출되는 도중 전달 받을 수 없다.

하지만 제네레이터는 가능하다.

function* geneFunc() {
  const x = yield 'x값을 넣는다요';
  const y = yield 'y값을 넣는다요';

  yield x + y;
}

const generator = geneFunc();

console.log(generator.next()); // { value: 'x값을 넣는다요', done: false }
console.log(generator.next(10)); // { value: 'y값을 넣는다요', done: false }
console.log(generator.next(20)); // { value: 30, done: false }
console.log(generator.next()); // { value: undefined, done: true }

함수를 호출 한 후 함수 외부에서 값을 넣어주었다.

이것이 가능한 이유는

제네레이터 함수를 호출하면 이터러블이면서 동시에 이터레이터인 제네레이터 객체를 반환하기 때문이다.

next 를 사용하는 것을 보면서 이터레이터가 사용되었음을 짐작 할 수 있었을 것이다.


이터레이터

제네레이터에 대해 공부하기전에 이터레이터를 복습해보자

이터레이터는 Symbol 의 메소드로 , 어떤 객체가 [Symbol.iterator] 메소드를 가지고 있다면 해당 객체는 이터러블 한 객체이며, 반복문에서 이터러블한 객체를 사용하게 되면 [Symbol.iterator] 메소드가 실행되어 이터레이터 객체 를 반환한다.

const iteratorFibo = () => {
  let [pre, cur] = [0, 1];
  return {
    [Symbol.iterator]() {
      return this;
    },

    next() {
      [pre, cur] = [cur, pre + cur];
      return { value: cur, done: false };
    },
  };
};
const fibo = iteratorFibo();
console.log(fibo);
/*
{
  next: [Function: next],
  [Symbol(Symbol.iterator)]: [Function: [Symbol.iterator]]
}
*/
console.log(fibo.next()); // { value: 1, done: false }
console.log(fibo.next()); // { value: 2, done: false }
console.log(fibo.next()); // { value: 3, done: false }
console.log(fibo.next()); // { value: 5, done: false }
console.log(fibo.next()); //{ value: 8, done: false }

맨 처음 반환된 fibo 객체는 이터러블 한 객체이다.

이터러블 한 객체라는 것은 내부 메소드로 [Symbol.iterator] 를 가지고 있으며 next 메소드가 존재하는 객체이다.

next 메소드가 호출 될 때 마다 next 에서 정의된 코드 블록이 실행되며 값이 반환된다.

반환값은 {value , num } 프로퍼티를 갖는다.

이터레이터는 반복문으로 사용되면 value 값을 반환한다.

const iteratorFibo = () => {
  let [pre, cur] = [0, 1];
  return {
    [Symbol.iterator]() {
      return this;
    },

    next() {
      [pre, cur] = [cur, pre + cur];
      return { value: cur, done: false };
    },
  };
};

const fibo = iteratorFibo();

for (const num of fibo) {
  if (num > 100) break;
  console.log(num); // 1 2 3 5 ... 34 55 89
}

정리하자면 이터레이터{value , done} 값을 반환하는 객체이며 next() 메소드를 가지고 있다. next() 메소드는 실행 될 때 마다 {value , done} 값을 반환하며 done 프로퍼티가 true 인 경우 반복이 중단된다.

이터러블 한 객체는 이터레이터 객체를 반환하는 메소드인 [Symbol.iterator] 를 내부 메소드로 가지고 있는 객체를 의미한다.

정리

그러면 제너레이터 는 이터러블이면서 동시에 이터레이터인 제너레이터 객체를 반환한다는 것이 어떤 의미인지 대충 짐작이 간다.
제너레이터 객체는 next 메소드와 [Symbol.iterator] 메소드를 가지고 있으며 값을 반환하는구나

근데 반환하는 양식이 조금 다르다. yield 라는 개념이 사용된다.


제너레이터 함수 정의

제너레이터 함수는 function* 키워드로 선언한다. 그리고 하나 이상의 yield 표현식을 포함한다.

이것을 제외하곤 일반 함수와 정의법이 같다.

??? : "팡숀? 웬만하면 사용하지 마세요 . 화살표 함수와 메소드를 이용하세요"

그랬지만 제너레이터는 function 으로 정의해야 한다. 화살표 함수로는 정의 할 수 없다.

* 에스터리스크의 위치는 함수와 키워드 사이라면 어디든 상관없다. 근데 일관성 유지를 위해 function* 을 추천한다.

function* 함수명 , function *함수명 모두 가능

제너레이터 객체

const generator = (function* geneFunc() {
  yield 1;
  yield 2;
  yield 3;
})();

console.log(generator.next()); // { value: 1, done: false }
console.log(generator.next()); // { value: 2, done: false }
console.log(generator.next()); // { value: 3, done: false }

제너레이터 객체는 이터레이터를 상속받기 때문에 next() 메소드를 가지고 있고 {value ,done} 이터레이터 리절트 객체를 반환한다.

추가로 이터레이터에는 없는 return , throw 메소드를 가지고 있다.

console.log(generator.return('End!')); // { value: 'End!', done: true }

return 메소드는 전달받은 인수를 value 로 , donetrue 로 한 이터레이터 리절트 객체를 반환한다.

throw 메소드는 전달받은 인수를 에러명으로 하여 에러를 생성하고 {value : undefined done : true} 인 이터레이터 리절트 객체를 반환한다.


제너레이터의 일시 중지와 재개

제너레이터는 이터레이터면서 제너레이터라고 했는데 .. 그럼 저기 보이는 yield 는 뭘까 ?

사전적 의미는 이런 느낌인데 .. 코드에서는 양도 하다 이런 느낌으로 쓰인다.

제너레이터의 next() 메소드가 실행 됐을 때 yield 가 있는데 걔는 뭘까 ? return 하고 뭐가 다를까?

yieldnext 메소드가 실행 됐을 때의 중단점과 같다.

function* geneFunc() {
  console.log('첫 번째 넥스트 실행합니다');
  yield 1;
  console.log('두 번째 넥스트 실행합니다');
  yield 2;
  console.log('세 번째 넥스트 실행합니다');
  yield 3;
  console.log('끝났습니다');
}

const generator = geneFunc();
console.log(generator.next());
/*
첫 번째 넥스트 실행합니다
{ value: 1, done: false }
*/
console.log(generator.next());
/*
두 번째 넥스트 실행합니다
{ value: 2, done: false }
*/
console.log(generator.next());
/*
세 번째 넥스트 실행합니다
{ value: 3, done: false }
*/
console.log(generator.next());
/*
끝났습니다
{ value: undefined, done: true }
*/

next 메소드를 실행 할 때 제너레이터는 yield 를 만나기 전까지의 코드블록을 실행하고 yield 에 있는 값을 반환한다.

그러니 제너레이터 객체의 next 메소드를 실행하면 yield 문 전까지 실행 후 yield 의 값을 이터레이터 리절트 객체 에 담아 반환하고 일시 중지(suspend) 된다. 이 때 함수의 제어권이 호출자로 양도(yield) 된다.

일시 중지한 함수의 호출은 이제 함수 호출자 (위 예시에선 generator)가 next 메소드를 이용해 제어 할 수 있다.

이 때 next() 메소드에는 인수를 전달 할 수 있는데 전달한 인수는 yield 표현식을 할당받는 변수에게 할당된다.

function* geneFunc() {
  const x = yield 'x 값 설정';
  const y = yield 'y 값 설정';

  yield x + y;
}

const generator = geneFunc();
console.log(generator.next()); // { value: 'x 값 설정', done: false }
console.log(generator.next(1)); // 전달 받은 인수를 x 에다가 설정
// { value: 'y 값 설정', done: false }
console.log(generator.next(2)); // 전달 받은 인수를 y 에다가 설정
// { value: 3, done: false }
console.log(generator.next()); // { value: undefined, done: true }

순서가 좀 헷갈릴 수 있지만 변수에 값이 할당 되는 순서를 살펴보면 헷갈리지 않는다.
const x = yield 'x 값 설정' 에서 x 에 값을 할당 할 때 우선 우항인 yield 'x값 설정' 표현식을 평가한다.

평가된 표현식은 yield 가 있으니 suspend 되고 'x값 설정' 이란 문구 리터럴을 이터레이터 리절트 객체에 담아 반환한다.
현재 x 에 담긴 값은 undefined 이다.

그 다음 next(1) 으로 suspended 된 상태의 제너레이터를 실행하면 전달받은 인수를 const x 값에 할당한다.


제너레이터 활용

제너레이터는 이터레이터를 상속 받은 객체이기 때문에 이터레이터와 거의 유사하게 사용 될 수 있다.

이터레이터 대안

const fibo = (function* generatorFibo() {
  let [pre, cur] = [0, 1];

  while (true) {
    [pre, cur] = [cur, pre + cur];
    yield cur;
  }
})();

for (const num of fibo) {
  if (num > 100) break;
  console.log(num);
}

만약 해당 피보나치를 이터레이터 객체를 생성해서 만들었으면 다음과 같다.

const fibo = (() => {
  let [pre, cur] = [0, 1];
  return {
    [Symbol.iterator]() {
      return this;
    },

    next() {
      [pre, cur] = [cur, pre + cur];
      return { value: cur, done: false };
    },
  };
})();

for (const num of fibo) {
  if (num > 100) break;
  console.log(num);
}

우우우 ~~ 제너레이터 구우웃 ㅋㅋ

근데 제너레이터가 가장 쓸모있게 쓰이는 부분은 바로 비동기 함수 처리 이다.

비동기 함수 처리

이전 챕터에서 비동기 함수 처리를 위해 then , catch , finally 와 같은 비동기 함수를 사용했다.

이번에 실습할 비동기 함수 처리는 다음과 같다.

  <script>
    const url = 'https://jsonplaceholder.typicode.com/todos/1';

    fetch(url)
      .then((res) => res.json())
      .then(console.log)
      .catch(console.error);
  </script>

이것을 제네레이터를 이용해서 사용해보자

  <script>
    const async = (generatorFunc) => {
      const generator = generatorFunc();

      const onResolved = (arg) => {
        const result = generator.next(arg);

        return result.done
          ? result.value
          : result.value.then((res) => onResolved(res));
      };

      return onResolved;
    };

    const asyncFetch = async(function* fetchTodo() {
      const url = 'https://jsonplaceholder.typicode.com/todos/1';
      const response = yield fetch(url);
      const todo = yield response.json();

      console.log(todo);
    });

    asyncFetch();
  </script>

으아악 코드가 더 어려워졌는디요 ?

우선 async 를 보자

    const async = (generatorFunc) => {
      const generator = generatorFunc();

      const onResolved = (arg) => {
        const result = generator.next(arg);

        return result.done
          ? result.value
          : result.value.then((res) => onResolved(res));
      };

      return onResolved;
    };

async 는 제너레이터 함수를 인수로 받아 generator 변수에 담은 후 generator 변수를 기억하는 클로저 함수인 onResolved 라는 함수를 반환한다.

onResolved 함수는 generatornext() 값을 반환하는데 만약 반환값이 done : false 라면 재귀적으로 done : true 가 될 때 까지 반복한다.

return result.done ? result.value : result.value.then((res) => onResolved(res)) 를 보면 then 을 이용하기 때문에 res 값이 프로미스 객체 일 경우 비동기 처리가 완료된 후 재귀함수가 시작된다.

    const asyncFetch = async(function* fetchTodo() {
      const url = 'https://jsonplaceholder.typicode.com/todos/1';
      const response = yield fetch(url);
      const todo = yield response.json();

      console.log(todo);
    });

    asyncFetch();

결국 이 부분을 보면 fetchTodo 라는 제너레이터 함수를 이용해 asyncFetch 라는 객체를 만든다.

asyncFetchnext 가 호출 될 때 마다 fetch(url) 을 반환하여 첫 프로미스 객체를 반환하고

그 다음 호출 때엔 promise 객체 body 프로퍼티에 존재하는 결과값을 json 메소드를 이용해 객체 타입으로 todo 라는 값에 저장 후 반환한다.

  <script>
    const async = (generatorFunc) => {
      const generator = generatorFunc();

      const onResolved = (arg) => {
        const result = generator.next(arg);

        return result.done
          ? result.value
          : result.value.then((res) => onResolved(res));
      };

      return onResolved;
    };

    const asyncFetch = async(function* fetchTodo() {
      const url = 'https://jsonplaceholder.typicode.com/todos/1';
      const response = yield fetch(url);
      const todo = yield response.json();

      console.log(todo);
    });

    asyncFetch();
  </script>

결국 전체 코드를 보면 fetch(url) 부터 시작해서 생기는 비동기 처리들을 마치 동기 처리 되는 함수들의 형식과 비슷하게 나타낼 수 있었다.

비동기 처리되고 결과값에 따라 then 을 이용하기 때문에 비동기 처리들을 사용한다는 점은 동일하다 .

  <script>
    const url = 'https://jsonplaceholder.typicode.com/todos/1';

    fetch(url)
      .then((res) => res.json())
      .then(console.log)
      .catch(console.error);
  </script>

프로미스 체이닝을 썼던 then , catch , finally 는 일반적인 문법과 달랐기 때문에 어쩌면 프로미스 체이닝보다 위의 제너레이터가 우리에겐 익숙 할 수 있다.

솔직히 난 더 어려워보인다. then , catch , finally 가 더 직관적인거 같은디 .. 장점이 뭘까 ?


async/await

위에선 async / await 를 간략하게 구현했지만 코드가 무척이고 장황하고 가독성도 나빠졌다.

ㅇㅈ

ES8(ECAMScript 2017) 에서는 제너레이터보다 간단하고 가독성이 좋게 비동기 처리를 동기 처리처럼 동작 하게 구현 할 수 있는 async/await 가 도입되었다.

async/await 는 프로미스 객체를 기반으로 동작한다. async/await 는 프로미스의 then , catch , finally 와 같이 프로미스 체이닝을 이용하지 않고 마치 동기 처리처럼 프로미스가 처리 결과를 반환 할 수록 구현 할 수 있다.

  <script>
    async function fetchTodo() {
      const url = 'https://jsonplaceholder.typicode.com/todos/1';
      const response = await fetch(url);
      const todo = await response.json();
      console.log(todo); 
    // {userId: 1, id: 1, title: 'delectus aut autem', completed: false}
    }

    fetchTodo();
  </script>

와우!!!!!!!!!!!!!!!!!!!!!!! 굿

async

async 키워드는 언제나 Promise 객체를 반환한다.

만약 async 키워드 후 선언된 함수들이 Promise 객체를 생성하지 않는 것들이라 하더라도 무조건 Promise 객체로 생성하고 반환값을 resolve 하는 프로미스를 반환한다.

  <script>
    const foo = async () => {
      return 1;
    };

    console.log(foo());
  </script>

async 는 함수 선언문, 표현식, 화살표 함수, 메소드에서 사용 가능하지만 클래스 내부 constructor 메소드에선 사용이 불가하다.


    class Example {
      async constructor() {}
    }

    const ex = new Example() 
    // Uncaught SyntaxError: Class constructor may not be an async method 

그 이유는 classasync 는 항상 어떤 객체를 반환해야 하는데 asyncPromise 객체를 반환한다.

이는 class 의 원 기능과 맞지 않는다. classconstructor 내부에서 정의된 인스턴스를 객체 타입 형태로 전달해야 한다.

await

await 키워드는 프로미스가 settled 된 상태까지 대기하다가 settled 상태가 되면 resloved 한 처리 결과를 반환한다.

awiat 키워드는 반드시 Promise 객체 앞에 사용해야 하며, async 함수 내부에서 사용해야 한다.

  <script>
    const fetchTodo = async (id) => {
      const url = `https://jsonplaceholder.typicode.com/todos/${id}`;
      const response = await fetch(url);
      const json = await response.json();
	  console.log(json)
      return json;
    };

    const result = fetchTodo(1);
    console.log(result)
  </script>

다음과 같은 코드가 있을 때 jsonresult 는 어떤 모습으로 로그 될 것 같은가 ?

나는 json{} 태그에 담겨 결과값이 return 되고 result 도 같은 모습으로 return 될 것이라고 생각했다.

결과는

  <script>
	...
	  console.log(json)
	// {userId: 1, id: 1, title: 'delectus aut autem', completed: false}
    };
	...
    console.log(result)
    // Promise {<fulfiled> ..}
  </script>

jsonjson() 화 시킨 객체 그대로 잘 로그되는데 반환값은 Promise 객체이다. 나는 그대로 json 을 바로 반환했는데 말이다.

그건 위에서 설명한 부분으로 설명 가능하다.

async 키워드가 존재하는 함수의 반환값은 항상 Promise 객체였다.

그러니 반환된 json 은 사실 반환 될 때 Promise.resolve(json) 으로 감싸져서 반환되는 것과 같다.

그러니 const json = await response.json(); 에서 json 에 반환되는 값은 JSON 객체일지언정 반환되는 값은 Promise.resolve(json) 인 것이다.

비동기 함수의 반환값을 다른 변수에 동기적으로 설정하는 것은 역시 힘들다.

그럼 Promise 객체로 반환된 값을 꺼내 다루기 위해선

    const fetchTodo = async (id) => {
      const url = `https://jsonplaceholder.typicode.com/todos/${id}`;
      const response = await fetch(url);
      const json = await response.json();
      // 여기서 json 객체를 가지고 할 수 있는 일을 하든지
    };

해당 비동기 처리로 감싸진 async 코드 블록 내에서 하든지

    const logJson = async (id) => {
      const result = await fetchTodo(id);
      console.log(result);
    };

    logJson(1); // {userId: 1, id: 1, title: 'delectus aut autem', completed: false}

다른 비동기 함수를 이용해 await 로 받아온 후 이후 로직들을 실행하면 된다.

아~!니! 여기서도 async 인데 얘는 왜 Promise 객체를 반환하지 않고 JSON 객체를 반환하냐고 ~! fetchTodo 는 이미 Promise 객체를 반환했는데 !!!

awiat 는 반환받은 Promise 객체가 resolved 한 값을 뺴오기 때문에 await fetchTodo()Promise 객체 안에서 resolved 된 값을 꺼내온다.

결국 Promise.prototype.then 과 유사한 느낌이다.

한 번 더 개념을 머리속에 정리하고 가자

await 는 비동기 처리가 완료 될 때 까지 (선언된 Promise 객체가 settled 될 때 까지) 코드 블록을 중지해뒀다가 settled 되면 다음 코드 블록으로 넘어간다.

그렇기 때문에 비동기 처리 함수의 결과값을 이용해서 또 다른 비동기 처리 or 동기 처리를 해야 할 경우 유용하게 사용된다 .

  <script>
    const foo = async () => {
      const a = await new Promise((res) => setTimeout(() => res(1), 1000));
      const b = await new Promise((res) => setTimeout(() => res(a + 1), 2000));
      const c = await new Promise((res) => setTimeout(() => res(b + 2), 3000));

      console.log(c); // 4
    };

    foo();
  </script>

이를 마냥 then , catch 문을 이용했다면 다음처럼 작성해야 한다.

  <script>
    const foo = () => {
      new Promise((res) => setTimeout(() => res(1), 1000))
        .then((a) => setTimeout((a) => a + 1), 2000)
        .then((b) => setTimeout((b) => b + 2), 3000)
        .then(console.log); // 4
    };
    foo();
  </script>

async/await 의 에러처리

이전 비동기 처리의 에러처리는 어렵다고 했었다.

    try {
      setTimeout(() => {
        throw new Error('error!');
      }, 1000);
    } catch (err) {
      () => console.error(err);
    }
// Uncaught Error: error!
// 에러를 잡지 못하고 에러가 발생함

에러는 호출자 방향으로 전파되는데 try 문에서 호출된 비동기 함수인 setTimeout 은 호출 후 try 블록을 빠져 나가고 , setTImeout 내부에 존재하는 throw new Errortry 문이 아닌 이벤트 루프에서 호출되기 때문에 에러의 방향이 이벤트 루프를 향한다.

그렇기 때문에 try 문으로 향하는 에러를 catch 하러는 catch(err) 는 에러를 잡지 못한다.

하지만 async/await 를 사용하면 비동기 함수를 호출하는 존재가 try 블록 내부에 존재하기 때문에 에러를 캐치 할 수 있다.

  <script>
    const asyncError = async () => {
      try {
        await new Promise((_, reject) => {
          setTimeout(() => {
            reject(new Error('error!'));
          }, 1000);
        });
      } catch (err) {
        console.log(err); // Error: error!
      }
    };

    asyncError();
  </script>

async / awaitthen , catch , finally 보다 추천되는 이유

  • 가독성 및 간결성

async/await 를 사용하면 비동기 코드를 우리에게 익숙한 동기 코드 처럼 작성 할 수 있다. 이로인해 가독성이 올라간다.

  • then / catch / finally 패턴은 콜백 함수의 중첩이 발생 할 수 있고 코드를 이해하기 어렵게 할 수 있다.

  • 에러 처리
    async/awaittry/catch 블록을 사용 할 수 있어 에러를 처리하는 것이 가능하다.

    then 절 마다 에러를 캐치하고 싶다면 then.catch.then.catch.then.catch 처럼 사용해야 하는데 벌써부터 어지럽다.

  • 변수 범위 및 값 전달
    async/await 를 사용하면 비동기 처리의 결과값을 스코프 내에서 할당하고 처리하는 것이 가능하였다.

        const foo = async () => {
          const a = await new Promise((res) => setTimeout(() => res(1),
    1000)); // 비동기 처리의 결과값을 foo 함수의 렉시컬 스코프에서 할당 가능
          ...

    이는 변수들의 범위를 제한함으로서 더욱 안전하게 변수를 사용 할 수 있다.

  • 디버깅
    async/await 를 사용하면 스택 트레이스가 더 명확히 나타나기 때문에 비동기 작업의 위치를 추적하기 쉽다.
    then 을 사용하면 then 블록 내부에서 발생한 오류를 추적하기 어려울 수 있다.

  • 조합성과 유연성
    async/await 는 기존의 동기 코드와 쉽게 통합되며, 기존 코드를 수정하지 않고도 비동기 코드를 추가 할 수 있다.

    then , catch 로 프로미스 체이닝을 하다가 새로운 비동기 작업을 추가하려고 하면 체이닝 사이에 then, catch 를 넣어줘야 한다.

종합적으로 async/await 는 기존의 동기 코드와 쉽게 통합되며, 기존 코드를 수정하지 않고도 비동기 코드를 추가 할 수 있다.

또한 비동기처리를 위한 콜백 패턴을을 사용하지 않고, 동기 처리 양식으로 사용해야 할 수 있기 때문에 추천된다고 한다.


회고

사람들이 자주 쓴느 async/await 가 대체 뭘까 궁금했는데 이번에 좀 알 수 있었다.
확실한 것은 공용 API 를 가져오는 토이프로젝트 를 하면서 then , catch 를 사용 할 떄와 async/await 를 사용 할 때의 장단점을 더 체감 할 수 있도록 해야겠다.

profile
빨리 가는 유일한 방법은 제대로 가는 것이다

0개의 댓글