
이번 글에서는 이어서, Async/Await에 대해 작성해보려고 합니다!
TypeScript Deep Dive라는 책을 보면, "async/await은 Generator를 사용하여 구현됩니다" 라는 구절이 있습니다. async/await에 숨겨진 원리가 있다니, 바로 파헤쳐 보도록 하겠습니다.✨
먼저, Generator에 대해서 자세히 알아보겠습니다. 일반적인 함수는 즉시 실행되지만, generator는 지연성이 있어 원하는 시점에 실행을 일시 중지했다가, 필요한 시점에 재개할 수 있는 특수한 함수입니다. generator를 사용하려면 function* 이렇게 작성하면 됩니다.
아래와 같은 코드가 있습니다. 일반적인 함수 normalFunction()과 generator인 generatorFunction()을작성하고, 그 두 함수를 차례대로 호출하는 코드입니다.
function normalFunction() {
console.log("This is a normal function");
}
function* generatorFunction() {
console.log("This is a generator function");
}
normalFunction();
generatorFunction();
결과는 예상과 다르게, normalFunction()만 실행된 것처럼 나왔습니다. generator는 일반적인 함수처럼 호출한다고 실행되는 게 아니었나 봅니다.
This is a normal function
사실 앞서 작성한 generatorFunction()의 반환값을 자세히 보면, 우리는 아무것도 return 하지 않았는데도 반환값이 존재한다는 것을 발견할 수 있습니다. 바로 Generator<never, void, ?> 를 반환하고 있었습니다. 우리는 자연스럽게 어딘가에 할당하고 사용할 수 있는 건가? 라는 추측을 할 수 있습니다.

a라는 변수에 generator의 반환값을 할당한 후, next()라는 함수를 실행하면 아래와 같이 함수 내부 로직이 실행되는 것을 볼 수 있습니다. next() 함수? 뭔가 낯이 익습니다.
const a = generatorFunction();
a.next();
This is a generator function
사실 Generator는 Iterator 인터페이스를 준수합니다. 따라서, next() 함수를 실행할 수 있던 것이었죠.
next 메서드를 가지고, next() 메서드는 IteratorResult 객체를 반환합니다. 이때, IteratorResult 객체는 value와 done이라는 프로퍼티를 갖는 객체입니다.
일단 여기까지만 알고, generator를 좀 더 자세히 알아보겠습니다.
앞서 봤듯이 next() 메서드를 통해 generator를 실행할 수 있는 것을 봤습니다. generator가 정확히 어떻게 원하는 시점에 실행하고, 멈출 수 있는지 더 자세히 보겠습니다.
generator의 실행을 일시 정지 하고 싶다면, yield 문을 사용하면 됩니다. next()를 호출하면 yield에 도달할 때까지 동기적으로 generator를 실행하다가 yield를 만난다면 실행을 일시 중지합니다. 다시 next()를 호출하면 yield를 만나 실행을 중지했던 지점부터 다시 실행하는 것이죠.
generator 함수를 실행할 때, 아래와 같은 과정이 반복될 것이라고 예상할 수 있습니다.🧐
next() -> yield -> next() -> yield -> next() ...
한 번 예시 코드를 보겠습니다.
function* evenNumbers() {
let n = 0;
while(true) {
yield n += 2;
}
}
const gen = evenNumbers();
console.log(gen.next().value);
console.log(gen.next().value);
console.log(gen.next().value);
console.log(gen.next().value);
console.log(gen.next().value);
첫 번째 console.log를 실행해보면 출력된 값은 2입니다. yield에 도달할 때까지 실행한다고 했는데, 그 실행 방향은 오른쪽부터 왼쪽이라, n += 2 연산이 실행된 후에 중지되기 떄문입니다.
yield n += 2;
나머지 console.log를 실행해 전체 출력을 보면 다음과 같습니다. yield문의 결과값이 next()메서드의 반환값인 IteratorResult의 value로 들어가고 있다는 것을 확인할 수 있습니다.
2
4
6
8
10
또 다른 예시를 보겠습니다. 이번엔 next() 메서드에 인자를 전달하고 있는 코드입니다.
function* simpleGenerator() {
console.log('Generator Start');
const result: number = yield 1 + 2;
console.log('generator end! result is', result);
}
const generator = simpleGenerator();
const result1 = generator.next();
console.log(result1);
const result2 = generator.next(10);
console.log(result2);
IteratorResult 값을 result1에 할당하고 있습니다.
Generator Start
result1은 출력한 결과는 yield문의 결과값인 value: 3과 아직 generator 함수가 끝나지 않았으니, done: false라는 값을 가지고 있는 IteratorResult의 결과입니다.{ value: 3, done: false }
yield 부분부터 재실행하고 있습니다. 하지만 next() 메서드에 10 이라는 숫자를 인자로 넘겼었는데요, 이 값은 yield가 있는 부분에 들어가, result값이 10이 됩니다.
따라서 아래와 같이 result를 10으로 출력하고 있습니다.
generator end! result is 10
IteratorResult 값은 어떻게 됐는지 보기 위해 console.log로 출력하고 있습니다. generator의 모든 코드가 실행이 끝났기 때문에 done 값은 true로 바뀌었고, value 또한 undefined로 반환하고 있습니다.{ value: undefined, done: true }
사실 async와 await 등장 전에, Generator와 promise를 사용해 비동기 처리를 동기 처리처럼 작성할 수 있는 방식이 있었습니다.
function getUser(genObj, username) {
fetch(`https://api.github.com/users/${username}`)
.then(res => res.json())
.then(user => genObj.next(user.name));
}
const g = (function* () {
let user;
user = yield getUser(g, 'abc');
console.log(user); // John Resig
user = yield getUser(g, 'def');
console.log(user); // Anders Hejlsberg
user = yield getUser(g, 'ghi');
console.log(user); // Ungmo Lee
}());
g.next();
getUser()는 비동기 작업을 처리할 함수입니다. fetch() 메서드를 호출하고 있습니다.g에 generator를 생성한 후 할당합니다.next() 함수를 호출해 실행하고 있습니다.getUser(g, 'abc')가 실행되는데, fetch() 작업이 완료되면, getUser()의 인자로 전달 받았던 generator g의 next() 메서드를 호출해 바로 다음 로직을 수행하게 됩니다.console.log(user)를 실행한 후, 마찬가지로 getUser(g, 'def')를 실행해 앞서 진행했던 과정을 똑같이 진행합니다.console.log(user)를 실행 후, getUser(g, 'ghi')를 실행 후, 모든 동작을 마치게 됩니다!비동기 처리를 제법 동기 처리 방식처럼 작성할 수 있는 방식입니다. 하지만, 해당 패턴을 사용하기 위해서는 generator를 연속적으로 실행해주는 실질적인 로직과 거리가 먼 코드가 필요했고, 개발자는 이를 불편하다고 느꼈습니다. 😬 따라서 나온 방법이 바로 async/ await 방식이죠!
위에서 generator를 사용해 작성했던 비동기 처리 코드는 ES8(ES2017)에서 도입된 async와 await을 사용하면 다음처럼 더 간단하게 바꿀 수 있습니다.
async function getUser(username: string) {
const response = await fetch(`https://api.github.com/users/${username}`);
const user: any = await response.json();
return user.name;
}
async function fetchData() {
let user: string;
user = await getUser('abc');
console.log(user); // John Resig
user = await getUser('def');
console.log(user); // Anders Hejlsberg
user = await getUser('ghi');
console.log(user); // Ungmo Lee
}
fetchData();
ES8에서 도입된 async와 await이므로, 만약 ES2016 버전을 이용해 트랜스파일링하면 아래와 같은 결과가 나옵니다. async와 await이 도입되기 전의 ECMAScript 버전이라 generator를 이용해 async와 await을 실행한다는 것을 확인할 수 있습니다!😮
var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, generator) {
function adopt(value) { return value instanceof P ? value : new P(function (resolve) { resolve(value); }); }
return new (P || (P = Promise))(function (resolve, reject) {
function fulfilled(value) { try { step(generator.next(value)); } catch (e) { reject(e); } }
function rejected(value) { try { step(generator["throw"](value)); } catch (e) { reject(e); } }
function step(result) { result.done ? resolve(result.value) : adopt(result.value).then(fulfilled, rejected); }
step((generator = generator.apply(thisArg, _arguments || [])).next());
});
};
function getUser(username) {
return __awaiter(this, void 0, void 0, function* () {
const response = yield fetch(`https://api.github.com/users/${username}`);
const user = response.json();
return user.name;
});
}
function fetchData() {
return __awaiter(this, void 0, void 0, function* () {
let user;
user = yield getUser('kimhalin');
console.log(user); // John Resig
});
}
fetchData();
return new (P || (P = Promise))(function (resolve, reject) {
//...
});
function (resolve, reject) {
function fulfilled(value) { try { step(generator.next(value)); } catch (e) { reject(e); } }
function rejected(value) { try { step(generator["throw"](value)); } catch (e) { reject(e); } }
function step(result) { result.done ? resolve(result.value) : adopt(result.value).then(fulfilled, rejected); }
step((generator = generator.apply(thisArg, _arguments || [])).next());
}
fulfilled(value): Promise가 성공했을 때, 수행되는 함수입니다. generator.next(value)를 호출해 다음 로직을 실행하고 있습니다.
rejected(value): Promise가 실패했을 때, 수행되는 함수입니다. generator["throw"](value)를 호출해 예외를 처리합니다.
step(result): 현재 generator가 모두 실행됐다면, resolve 함수를 호출해 Promise를 완료시키고, 만약 아니라면 adopt()를 호출합니다.
yield 부분까지 실행을 하고, Promise객체의 상태가 fulfilled가 되면 그때, generator.next() 메서드를 호출해 다음 코드를 실행하도록 하는 구현 방식을 사용하고 있습니다.
사실 쉽게 말하면 async, await이 도입되지 않았던 ES 버전을 이용해 트랜스파일링 하면, generator와 Promise`를 이용한 구현을 사용하도록 하는 것을 알 수 있습니다!🔥
async와 await 사용법 그렇다면 async와 await은 어떻게 사용하는 걸까요?
함수 앞에 async 키워드를 명시한다면, 해당 함수는 무조건 Promise를 반환하게 됩니다. 하지만 Promise가 반환 값이 아닌 함수는 어떻게 될까요?
async function f(){
return 1;
}
이러한 함수를 호출하면 1이 아닌 result가 1인 Promise를 반환하게 됩니다. 바로 Promise가 아니더라도, Promise로 감싸서 반환하는 것이죠.
await 키워드는 async 함수 안에서만 동작합니다. 이 키워드를 작성하면 Promise가 처리될 때까지 기다리라고 명령할 수 있는 것이죠. await 덕분에 Promise.then() 보다 더 깔끔하게 코드를 작성할 수 있게 되었습니다!
async function f() {
const res = await fetch('https://test.com')
return res;
}
아래 코드를 실행할 때, async와 await은 Runtime에 어떻게 동작하는 지 알아봅시다.😎
const one = () => Promise.resolve('One!');
async function myFunc() {
console.log('In function!');
const res = await one();
console.log(res);
}
console.log('Before function!');
myFunc();
console.log('After function!')

console.log()는 call stack에 바로 쌓인 후 실행됩니다.
myFunc()메서드가 call stack에 쌓인 후, 해당 메서드 내에서 첫 번째로 호출되는 console.log()가 이어서 call stack에 쌓인 후 바로 실행됩니다.
one() 이 call stack에 쌓이고, 이어서 바로 resolve된 Promise, Promise.resolve('One!')를 반환하고 있습니다.await 키워드를 만나게 됩니다await 키워드를 만나, async 가 붙어있던 함수의 실행은 일시 중단되고, microtask queue에 할당됩니다.
myFunc() 함수는 call stack에 빠지면서 일시 중단 되었으니, 해당 함수에서 벗어나 함수가 호출됐었던 실행 컨텍스트(이 경우엔 전역입니다.)에서 코드를 이어서 실행하게 됩니다.console.log()를 call stack에 넣고 바로 실행하고 있습니다.
myFunc()이 있어 call stack에 다시 넣고, 이전에 중단되었던 myFunc() 내부 지점부터 다시 실행을 시작합니다.여태까지 TypeScript의 비동기를 정리하며 async와 await이 없는 시기까지 거슬러 올라가 역사를 살펴본 느낌이 나는데요.🙃 다음에는 Promise를 사용할 때 유의해야 할 점들을 정리해볼 것 같습니다.
(예를 들어, 비동기에 익숙하지 않았다면 겪어봤을,, 당연히 list를 반환할 줄 알았던 function이 알고보니 비어있는 Promise를 반환하고 있었다..?와 같은 상황을 방지하기 위한 것..)