Asynchronous Javascript

Most real-world JavaScript programming is asynchronous.

Asynchronous Programming with Callbacks

비동기 코드를 처리하는 가장 기초적인 방법은 콜백입니다. 비동기 함수는 작업을 마치고 값을 동기적으로 반환(return)할 수 없기에 콜백을 통해 반환값을 전달합니다.

콜백의 예시들을 살펴볼게요. 우선 타이머에요. 동기 작업들이 우선 끝나는 것을 확인하실 수 있어요:

// ✍️ timeout 시간을 바꿔보세요.
console.log(1);
setTimeout(() => console.log(2), 2000);
console.log(3);

다음으로는 이벤트에요. 클라이언드 사이드 자바스크립트는 이벤트 중심으로 동작합니다. 정해진 작업들을 하기보다는 유저의 행동에 따라 해야 할 작업이 정해집니다. 이를 위해 브라우저는 유저의 행동에 따라 이벤트를 발생시키고 JS 프로그램은 이벤트마다 콜백을 등록해놓습니다. 이때 콜백을 event handler 혹은 event listener라고 합니다.

<button style="position: fixed">button</button>
<script>
  let button = document.querySelector('button');

  button.addEventListener('click', () => {
    button.style.left = Math.random() * window.innerWidth + 'px';
    button.style.top = Math.random() * window.innerHeight + 'px';
  });
</script>

마지막으로 네트워크 이벤트에요. 콜백을 사용하는 옛날 옛적 XMLHttpRequest를 들고왔어요:

let getVersion = (callback) => {
  let request = new XMLHttpRequest();
  request.open('GET', 'https://example.com/api/version');
  request.send();

  request.onload = function () {
    if (request.status === 200) callback(null, request.responseText);
    else callback(response.statusText, null);
  };

  request.onerror = request.ontimeout = (e) => callback(e.type, null);
};

Promises

프로미스는 비동기 작업의 결과를 표현하는 객체입니다. 결과가 준비되었는지 아닌지는 콜백을 통해서만 접근할 수 있게 설계되었어요.

콜백은 아래처럼 중첩되어 콜백 지옥을 만들냅니다:

콜백 지옥 이미지

반면 프로미스 체인은 선형입니다:

Promise.resolve()
  .then(() => console.log(1))
  .then(() => console.log(2))
  .then(() => console.log(3));

콜백은 에러 처리가 어렵습니다. 비동기 코드에서 에러가 던져져도 바깥으로 에러가 전파될 수 없습니다.

let asyncFunc = () =>
  setTimeout(() => {
    throw new Error();
  }, 1000);

try {
  asyncFunc();
} catch (e) {
  console.log('에러가 잡히지 않습니다.');
}

반면 프로미스는 에러 처리 방법을 표준화해 제공합니다:

Promise.reject()
  .then(() => console.log(1))
  .catch(() => console.log(2))
  .then(() => console.log(3));

참고로 프로미스는 하나의 비동기 작업을 표현합니다. 따라서 비동기 작업이더라도 click 이벤트처럼 반복되는 작업은 프로미스로 다룰 수 없습니다.

Using Promises

일단 프로미스를 사용해봅시다.

예제를 위해 wait(ms)을 정의했어요. 구현은 보지 마시고 ms 밀리초 이후 완료되는 작업을 표현한 비동기 함수라고 생각해주세요.

비동기 함수는 프로미스를 반환해요:

let wait = (ms) => new Promise((res) => setTimeout(res, ms));

console.log(wait(1000) instanceof Promise);

then()으로 비동기 작업 완료 후 불릴 콜백을 등록할 수 있습니다:

let wait = (ms) => new Promise((res) => setTimeout(res, ms));

wait(1000).then(() => console.log('👍'));

then을 부르기 전에 비동기 작업이 끝나더라도 콜백은 무사히 실행됩니다. 이때도 콜백은 비동기적으로 실행됩니다:

// 즉시 완료되는 비동기함수입니다.
let f = () => Promise.resolve();

f().then(() => console.log('1'));
console.log('2');

동기 코드가 실행될 때 에러가 던져지면 catch문이 있을 때까지 콜 스택을 거슬러 올라갑니다. 하지만 비동기 코드가 실행될 시점에는 호출자가 스택에 없으므로 호출자로 에러를 건네줄 방법이 없습니다. 프로미스 기반 비동기 처리에서는 대신 catch()로 에러를 처리합니다:

let asyncFunc = () => Promise.resolve();
let throwingCallback = () => {
  throw new Error();
};

// 콜백의 에러가 처리되지 않습니다.
// 이 방식은 잘 쓰이지 않아요
asyncFunc().then(throwingCallback, () => console.log('1'));

// 아래 두 개는 동일하며 콜백의 에러도 처리합니다.
asyncFunc()
  .then(throwingCallback)
  .catch(() => console.log('2'));

asyncFunc()
  .then(throwingCallback)
  .then(null, () => console.log('3'));

프로미스는 단어 그대로 약속에 비유할 수 있어요:

  • 약속이 어떻게 됐는지 알게 됨: settled
    • 약속이 지켜짐: fulfilled
      • then()의 첫번째 인자로 전달된 콜백을 실행
      • 콜백의 인자로 작업의 반환값을 전달
    • 약속을 어김: rejected
      • then()의 두번째 인자로 전달된 콜백을 실행
      • 콜백의 인자로 에러를 전달
  • 아직 약속이 어떻게 됐는지 모름: pending

위의 상태를 말고도 resolved라는 상태가 있는데 곧 살펴볼게요.

Chaining Promises

프로미스의 중요한 장점 중 하나는 일련의 비동기 작업을 체인 형태로 자연스럽게 표현할 수 있다는 것입니다:

let wait = (ms) => new Promise((res) => setTimeout(res, ms));

wait(1000)
  .then(() => console.log('1'))
  .then(() => wait(1000))
  .then(() => console.log('2'));

따라서 then()이 프로미스를 반환함을 알 수 있어요.

보통 이런 method chaining을 구현할 때는 모든 메서드가 같은 객체를 리턴해요. 하지만 프로미스 체인에서 then()은 모두 다른 객체를 반환합니다:

let p1 = Promise.resolve(1);
let p2 = p1.then(() => {});
console.log(p1 instanceof Promise, p2 instanceof Promise);
console.log(p1 === p2);

즉 then 체이닝은 하나의 프로미스에 여러 콜백을 등록하는게 아니에요. then에 전달된 콜백이 완료되어 fulfilled되면 다음 then의 콜백이 실행됩니다.

'콜백이 완료된다'는 이제 배울 resolved와 연관되어있어요.

Resolving Promises

콜백 c가 있고 then(c)이 반환한 프로미스가 p라고 할 때...

  • c가 프로미스가 아닌 값 v을 반환하면
    • p는 v로 resolve되고 즉시 fulfilled됩니다.
  • c가 프로미스 v를 반환하면
    • p는 resolve됐지만 fulfilled되지는 않습니다. p의 상태는 이제 v의 상태를 따라갑니다.

This is what the “resolved” state of a Promise means: the Promise has become associated with, or “locked onto,” another Promise. We don’t know yet whether p will be fulfilled or rejected, but our callback c no longer has any control over that. p is “resolved” in the sense that its fate now depends entirely on what happens to Promise v

아래 코드를 이해해보세요:

let asyncFunc = () => Promise.resolve();
let asyncFunc2 = () => Promise.resolve();

function c1(response) {
  console.log('c1');
  let p4 = asyncFunc2();
  return p4;
}

function c2(profile) {
  console.log('c2');
}

let p1 = asyncFunc();
console.log('p1');
let p2 = p1.then(c1);
console.log('p2');
let p3 = p2.then(c2);
console.log('p3');
  1. asyncFunc에서 비동기 작업을 시작하고 p1을 반환합니다.
  2. p1에 c1을 등록하고 p2를 반환합니다.
  3. p2에 c2를 등록하고 p3를 반환합니다.
  4. (asyncFunc 작업 끝)
  5. p1이 fulfilled되고 c1이 호출됩니다.
  6. c1이 p4를 반환하고 p2는 resolve됩니다.
  7. (asyncFunc2 작업 끝)
  8. p2와 p4가 fulfilled됩니다.
  9. c2가 호출됩니다.

More on Promise and Errors

동기 코드와 다르게 비동기 코드의 에러는 잡지 않아도 조용히 넘어갈 수도 있으니 잘 처리해야돼요.

동기 코드에서 에러 발생 시 콜 스택을 거슬러 올라가는 것처럼 프로미스에서는 .catch()를 찾을 때까지 체인을 따라 내려갑니다:

let asyncFunc = () => Promise.reject(new Error('😠'));

asyncFunc()
  .then(() => console.log(1))
  .then(() => console.log(2))
  .catch((e) => console.log(e.message))
  .then(() => console.log(4));

.finally()도 있어요:

let asyncFunc = () => Promise.reject(new Error('😠'));

asyncFunc()
  .then(() => console.log(1))
  // ✍️ 아래 줄을 지워 에러 처리를 안해도 finally의 콜백이 호출되는지 확인해보세요
  .catch(() => console.log(2))
  .finally(() => console.log(3));

finally에 전달된 콜백의 반환값은 보통 무시되고 프로미스의 상태를 그대로 따라가지만 콜백에서 에러를 던지면 reject됩니다:

let asyncFunc = () => Promise.resolve(1);

asyncFunc()
  .finally(() => 2)
  .then(console.log);

asyncFunc()
  .finally(() => {
    throw new Error();
  })
  .catch(console.log);

왜 출력 순서가 반대지???? Promise.prototype.finally(onfinally)는 아래코드와 유사하게 구현됩니다. 중간에 한 단계가 더 있기에 순서가 반대가 됐어요.

promise.then(
  (value) => Promise.resolve(onFinally()).then(() => value),
  (reason) =>
    Promise.resolve(onFinally()).then(() => {
      throw reason;
    }),
);

Promise in Parallel

여러 비동기 작업을 then 체인 순서대로가 아닌 병렬로 실행해봅시다.

Promise.all입니다:

let asyncFunc = (n) => Promise.resolve(n);
let throwingAsyncFunc = (n) => Promise.reject(n);

Promise.all([asyncFunc(1), asyncFunc(2), asyncFunc(3)]).then(console.log);

// 배열에 프로미스가 아닌게 섞여있어도 됩니다.
Promise.all([asyncFunc(1), 2, asyncFunc(3)]).then(console.log);

// 하나라도 리젝되면 전체가 리젝됩니다
Promise.all([asyncFunc(1), throwingAsyncFunc(2), asyncFunc(3)])
  .then(console.log)
  .catch(() => console.log('error'));

Promise.allSettled는 하나가 리젝돼도 나머지를 기다려요:

let asyncFunc = (n) => Promise.resolve(n);
let throwingAsyncFunc = (n) => Promise.reject(n);

Promise.allSettled([asyncFunc(1), throwingAsyncFunc(2), asyncFunc(3)])
  .then((x) => x.forEach((y) => console.log(y)))
  .catch(() => console.log('error'));

Promise.race는 가장 먼저 settle되는 프로미스에 따라요. 반면 Promise.any는 가장 먼저 fulfill되는 프로미스에 따르고 모두 reject되어야 reject돼요:

let waitFulfill = (ms) =>
  new Promise((resolve) => setTimeout(() => resolve(ms), ms));
let waitReject = (ms) =>
  new Promise((_, reject) => setTimeout(() => reject(ms), ms));

// ✍️ waitFulfill(1)을 waitReject(1)으로 바꿔보세요
Promise.race([waitFulfill(1), waitFulfill(2), waitFulfill(3)])
  .then((x) => console.log(`then: ${x}`))
  .catch((x) => console.log(`catch: ${x}`));

// ✍️ waitFulfill(2)를 waitReject(2)으로 바꿔보세요
Promise.any([waitReject(1), waitFulfill(2), waitReject(3)])
  .then((x) => console.log(`then: ${x}`))
  .catch((x) => console.log(`catch: ${x}`));

Making Promises

비동기 함수 내부에 비동기가 아닌 작업이 있다면 해당 작업의 결과물도 프로미스 형태로 반환하는 것이 낫습니다. (섞이면 함수 호출자에서 처리하기 곤란)

이럴 때 Promise.resolve()Promsie.reject()를 활용합니다:

Promise.resolve(1).then(console.log);
Promise.reject(2).catch(console.log);
// resolve, reject를 사용하더라도 동기 코드가 먼저 실행됩니다.
console.log('sync code');

Promise 생성자로 다양한 비동기 패턴을 구현할 수 있어요:

function wait(ms) {
  // 인자 이름이 Promise.resolve와 Promise.reject를 떠오르게하네요
  return new Promise((resolve, reject) => {
    // resolve에 프로미스가 아닌 값을 건네면 해당 값으로 fulfill됩니다.
    // 프로미스를 건네면 해당 값으로 resolve됩니다.

    // ...그래서 인자명이 fulfill이 아니라 resolve인가봐요.

    if (ms < 0) reject(new Error('...'));

    setTimeout(resolve, ms);
  });
}

// ✍️ 숫자를 음수로 바꿔보세요
wait(1000)
  .then(() => console.log('⏰'))
  .catch(() => console.log('🤯'));

다른 비동기 프로그래밍 패턴(이벤트 핸들러)을 프로미스 생성자를 활용해 감싸기:

<button>button</button>
<script>
  let button = document.querySelector('button');

  // 버튼이 클릭될 때까지 기다립니다
  let waitClick = new Promise((res, rej) => {
    let handler = () => {
      // ✍️ rej로 바꿔보세요
      res();
      button.removeEventListener('click', handler);
    };
    button.addEventListener('click', handler);
  });

  waitClick.then(
    () => console.log('👍'),
    () => console.log('👎'),
  );
</script>

Promises in Sequence

위에서 봤듯이 프로미스 체인은 고정 개수의 프로미스를 순차적으로 실행시킵니다. 이를 확장시켜 임의 개수의 프로미스도 순차적으로 실행해봅시다.

let promiseSequence = (inputs, promiseMaker) => {
  return inputs.reduce(
    (acc, cur) => acc.then(() => promiseMaker(cur)),
    Promise.resolve([]),
  );
};

let wait = (ms) => new Promise((res) => setTimeout(res, ms));
let waitAndLog = (sec) => wait(sec * 1000).then(() => console.log(sec));

promiseSequence([1, 2, 3, 4, 5], waitAndLog);

async and await

async, await 키워드는 프로미스 기반 코드에서 프로미스를 숨겨 동기 코드처럼 읽히게 합니다.

await는 프로미스가 settle되기를 기다립니다. await 키워드는 async함수 내부에서만 사용할 수 있어요.

let asyncFunc = () => Promise.resolve(1);
let throwingAsyncFunc = () => Promise.reject(2);

async function foo() {
  try {
    // ✍️ throwingAsyncFunc으로 바꿔보세요
    let val = await asyncFunc();
    console.log(val);
  } catch (e) {
    console.log(e);
  }
}

foo();

async 함수는 프로미스가 아닌 값을 반환해도 프로미스에 래핑됩니다:

async function foo() {
  return 1;
}

console.log(foo() instanceof Promise);
foo().then(console.log);

async 함수 본문 전체가 비동기적으로 실행되는게 아니에요:

async function foo() {
  console.log('1');
  // 여기까지는 동기적으로 실행돼요
  await Promise.resolve();
  console.log('2');
}

async function bar() {
  console.log('3');
  await foo();
  console.log('4');
}

console.log('5');
bar();
console.log('6');

Asynchronous Iteration

단일한 비동기 이벤트가 아닌 이벤트의 스트림을 표현하는 방법을 배워봅시다.

for/await loop의 예제에요:

let promiseStream = [1, 2, 3, 4, 5].map((x) => Promise.resolve(x));

async function foo() {
  for await (let val of promiseStream) {
    console.log(val);
  }
}

foo();

위에서는 for/await를 동기 이터러블인 promiseStream과 함께 썼지만, 비동기 이터러블과 쓰면 더 유용해요.

비동기 이터러블은 Symbol.iterator대신 Symbol.asyncIterator에 정의되며 next()메서드가 프로미스를 반환합니다:

let wait = () => new Promise((res) => setTimeout(res, 1000));

let clock = () => ({
  count: 0,

  // 반환값 자체가 Promise이기에 value뿐만 아니라 done 여부도 비동기적으로만 알 수 있습니다.
  async next() {
    if (5 <= this.count) return { done: true };
    await wait(1000);
    return { value: this.count++ };
  },

  [Symbol.asyncIterator]() {
    return this;
  },
});

async function foo() {
  for await (let tick of clock()) {
    console.log(tick);
  }
}

foo();

제너레이터를 통해 간결하게 만들 수 있어요:

let wait = () => new Promise((res) => setTimeout(res, 1000));

async function* clock() {
  for (let i = 0; i < 5; i++) {
    await wait();
    yield i;
  }
}

async function foo() {
  for await (let tick of clock()) {
    console.log(tick);
  }
}

foo();

Summary

읽어보면 좋은 링크들

stackoverflow.comWhy is my async function being executed before an earlier promise is being fulfilled?I've written a small program that compares the fulfillment of promises between the .then() approach and the async/await approach. The code runs correctly, however, the output was received in an

blog.scottlogic.comWhat happens if you await a Promise twice?JavaScript provides a Promise abstraction that can be used to express 'give me the result later'. What happens if you ask for the result twice?

developer.mozilla.orgPromise - JavaScript | MDNThe Promise object represents the eventual completion (or failure) of an asynchronous operation and its resulting value.