async 함수 - 친근한 프로미스 만들기


원글
Jake Archibald, https://developers.google.com/web/fundamentals/primers/async-functions

async 함수는 크롬55 버전부터 사용할 수 있다. 이 함수는 프로미스 기반의 코드들을 메인 스레드의 블로킹 없이 동기화 형식으로 사용할 수 있게 한다. 비동기 코드를 "덜 영리하게"하고 읽기 쉽게 만들어준다.

async 함수는 다음과 같이 사용한다.

async function myFirstAsyncFunction() {
  try {
    const fulfilledValue = await promise;
  }
  catch (rejectedValue) {
    // …
  }
}

async 키워드를 함수 정의 앞에 사용한다면, 함수 내부에서 await라는 키워드를 사용할 수 있다. 프로미스를 await하고 있을 때, 그 함수는 논-블로킹 상태에서 프로미스가 해결(resolve 또는 reject)될 때까지 일시 정지 상태가 된다. 그리고 프로미스가 완료된 후 그 값을 돌려 받는다. 만약 프로미스가 거절되면 해당 값(또는 에러)이 던져(throw)진다.

예시: fetch 로깅

URL을 fetch하고, 응답 텍스트를 로깅 한다 가정해보자. 프로미스를 사용하는 방식은 다음과 같다.

function logFetch(url) {
  return fetch(url)
    .then(response => response.text())
    .then(text => {
      console.log(text);
    })
    .catch(err => {
      console.error('fetch failed', err);
    });
}

그리고 다음은 async 함수를 사용한 예시다.

async function logFetch(url) {
  try {
    const response = await fetch(url);
    const text = await response.text();
    console.log(text);
  }
  catch (err) {
    console.log('fetch failed', err);
  }
}

라인 수는 거의 비슷하지만, 모든 callback이 사라졌다. 특히 프로미스에 덜 익숙한 사람들에게 가독성이 높아졌다.

참고: await로 기다리는 모든 것들을 Promise.resolve()로 처리하기 때문에 꼭 기존 함수의 반환 값이 프로미스가 아니어도 괜찮다.

비동기 반환 값

async 함수들은 await와 별개로 항상 프로미스를 반환한다. async 함수가 반환하는 것을 항상 resolve로 처리하고, 에러는 reject로 처리한다.

// wait ms milliseconds
function wait(ms) {
  return new Promise(r => setTimeout(r, ms));
}

async function hello() {
  await wait(500);
  return 'world';
}

hello() 호출은 Promise.resolve('world')를 반환한다.

async function foo() {
  await wait(500);
  throw Error('bar');
}

foo() 호출은 Promise.reject(Error('bar'))를 반환한다.

예시: 응답 스트리밍

더욱 복잡한 예시에서 async 함수의 이점이 돋보인다. 스트리밍 응답에서 청크를 로깅하고 마지막에 최종 크기를 반환한다고 가정해 보자.

다음은 프로미스 기반의 코드이다.

function getResponseSize(url) {
  return fetch(url).then(response => {
    const reader = response.body.getReader();
    let total = 0;

    return reader.read().then(function processResult(result) {
      if (result.done) return total;

      const value = result.value;
      total += value.length;
      console.log('Received chunk', value);

      return reader.read().then(processResult);
    })
  });
}

비동기 루프를 수행하기 위해 processResult함수를 재귀호출하였다. 저렇게 작성한 코드는 스스로가 "매우 똑똑"하다고 느끼게 한다. 그러나 대부분의 "똑똑한" 코드는, 이해하기 위해서 마치 90년대의 매직아이 그림처럼 꽤 오랫동안 지켜봐야 한다.

이제 async 함수를 사용해보자.

async function getResponseSize(url) {
  const response = await fetch(url);
  const reader = response.body.getReader();
  let result = await reader.read();
  let total = 0;

  while (!result.done) {
    const value = result.value;
    total += value.length;
    console.log('Received chunk', value);
    // get the next result
    result = await reader.read();
  }

  return total;
}

모든 "똑똑함"은 사라졌다. 이제 스스로 똑똑하고 잘난 체하게 만들었던 비동기 루프를 믿을만하면서 지루한 while-loop로 변경했다. 이게 훨씬 낫다. 앞으로는 while-loop를 for-of 루프로 대체하는 async iterators를 사용하여 더 깔끔하게 만들 것이다.

async 함수 문법

지금까지 async function() {} 문법에 대해서만 살펴보았지만 async 키워드를 다른 함수 문법들에도 사용할 수 있다.

화살표 함수

// map some URLs to json-promises
const jsonPromises = urls.map(async url => {
  const response = await fetch(url);
  return response.json();
});

참고: array.map(func)는 async 함수를 따로 고려하지 않는다. 즉, map 이후 배열의 원소는 모두 프로미스 객체이며, 각 이터레이션은 그 전 이터레이션의 수행이 끝날 때까지 기다리지 않는다.

객체 메서드

const storage = {
  async getAvatar(name) {
    const cache = await caches.open('avatars');
    return cache.match(`/avatars/${name}.jpg`);
  }
};

storage.getAvatar('jaffathecake').then();

클래스 메서드

class Storage {
  constructor() {
    this.cachePromise = caches.open('avatars');
  }

  async getAvatar(name) {
    const cache = await this.cachePromise;
    return cache.match(`/avatars/${name}.jpg`);
  }
}

const storage = new Storage();
storage.getAvatar('jaffathecake').then(...);

참고: 클래스 생성자와 getter/setter는 async가 될 수 없다.

너무 순차적으로만 생각하지 않도록 조심하기!

비동기를 동기 코드처럼 작성할 수 있지만, 병렬 처리를 할 수 있다는 것은 잊지 말자.

async function series() {
  await wait(500);
  await wait(500);
  return "done!";
}

series 함수는 완료까지 1000ms가 걸린다.

async function parallel() {
  const wait1 = wait(500);
  const wait2 = wait(500);
  await wait1;
  await wait2;
  return "done!";
}

parallel 함수는 완료까지 500ms가 걸린다. 각 대기 시간은 차례대로 수행되는 게 아니라 같은 시간에 병렬로 수행되기 때문이다.

예시: 여러 fetch를 순서대로 출력

일련의 URL들을 가능한 한 빠르게 fetch 하여 순서대로 로깅 한다고 가정해보자.

일단 먼저 심호흡 한번 하고 프로미스 기반의 코드를 보자.

function logInOrder(urls) {
  // fetch all the URLs
  const textPromises = urls.map(url => {
    return fetch(url).then(response => response.text());
  });

  // log them in order
  textPromises.reduce((chain, textPromise) => {
    return chain.then(() => textPromise)
      .then(text => console.log(text));
  }, Promise.resolve());
}

reduce를 사용해서 프로미스 배열을 체이닝 하였다. 매우 "똑똑"하다. 그렇지만 이건 "너무 똑똑"하고, 우리는 이것 없이 더 잘 살 수 있다.

그러나 위의 프로미스 기반의 코드를 async 함수로 변환할 때, 지나치게 순차적인 경우가 있다.

비추천

async function logInOrder(urls) {
  for (const url of urls) {
    const response = await fetch(url);
    console.log(await response.text());
  }
}

꽤 깔끔해 보이지만 두 번째 fetch는 첫 번째 fetch 응답의 text()를 다 처리하기 전까지 시작되지 않는다. 이 코드는 병렬로 fetch를 수행하는 위의 promise 기반의 코드보다 느리다. 하지만 고맙게도 이상적인 중간 지점이 있다.

추천

async function logInOrder(urls) {
  // fetch all the URLs in parallel
  const textPromises = urls.map(async url => {
    const response = await fetch(url);
    return response.text();
  });

  // log them in sequence
  for (const textPromise of textPromises) {
    console.log(await textPromise);
  }
}

urls에서 병렬로 fetch와 text()를 수행한다. 그리고 "똑똑한" reduce를 지루하지만 읽기 편한 표준 for-loop로 대체했다.

브라우저 지원 범위 & 차선책

지금 이 글을 쓰고 있는 시점에, 크롬55에서는 async함수를 바로 사용할 수 있지만, edge, firefox, safari 브라우저들은 아직 반영되지 않았다.

차선책 - 제너레이터

개발하고자 하는 애플리케이션의 지원 브라우저가 제너레이터를 지원한다면 (최신 버전의 메이저 브라우저들은 모두 제너레이터를 지원한다.) async 함수들을 일종의 폴리필처럼 사용할 수 있다.

그리고 Babel이 이 작업을 해줄 것이다. 바벨의 REPL 예시를 통해 변환된 코드가 원래 코드와 얼마나 유사한지도 확인해보자. 이 변환은 babel-preset-es2017의 일부이다.

애플리케이션의 지원 브라우저가 async 함수를 지원한다면 쉽게 기존 변환을 끌 수 있으므로 트랜스파일 방식을 추천한다. 그러나 정말 트랜스파일러를 사용하기 싫다면 Babel의 polyfill을 사용해 다음과 같은 코드를 작성할 수 있다.

기존 async-await으로 작성

async function slowEcho(val) {
  await wait(1000);
  return val;
}

polyfill을 사용하여 작성

const slowEcho = createAsyncFunction(function*(val) {
  yield wait(1000);
  return val;
});

createAysncFunction에 인자로 제너레이터(function*)를 넘겼으며, await 대신에 yield를 사용했다는 것을 참고하자. 이 외에는 모두 같다.

차선책 - regenerator

만약 구버전 브라우저들이 지원 범위에 있다면, 바벨은 제너레이터 또한 트랜스파일 할 수 있으며, async 함수들을 IE8에서까지 사용할 수 있다. 이를 위해서는 babel-preset-es2017babel-preset-es2015이 필요하다.

변환된 코드는 썩 깔끔하지 않기 때문에 코드가 많아지는 것에 주의하자.

모든 것을 async로!

async 함수가 모든 브라우저에 사용되면 프로미스를 반환하는 모든 함수에서 사용하자. 코드를 더 가볍게 만들뿐만 아니라, 함수가 항상 프로미스를 반환한다는 것을 보장한다.

이민규2016.12.26
Back to list