비동기 함수: 프로미스 친화적 만들기

비동기 함수를 사용하면 마치 동기 함수인 것처럼 프라미스 기반 코드를 작성할 수 있습니다.

Jake Archibald
Jake Archibald

비동기 함수는 Chrome, Edge, Firefox, Safari에서 기본적으로 사용 설정되어 있으며 정말 놀라운 기능입니다. 비동기 함수를 사용하면 메인 스레드를 차단하지 않고도 마치 동기 함수인 것처럼 프라미스 기반 코드를 작성할 수 있습니다. 이 함수를 통해 비동기 코드를 덜 '똑똑'하고 더 읽기 쉽게 만들 수 있습니다.

비동기 함수는 다음과 같이 작동합니다.

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

함수 정의 앞에 async 키워드를 사용하면 함수 내에 await를 사용할 수 있습니다. 프라미스를 await하면 함수는 프라미스가 결정될 때까지 방해하지 않는 방식으로 일시 중지됩니다. 프로미스가 이행되면 값을 반환합니다. 프라미스가 거부되면 거부된 값이 발생합니다.

브라우저 지원

브라우저 지원

  • Chrome: 55
  • Edge: 15.
  • Firefox: 52.
  • Safari: 10.1

소스

예: 가져오기 로깅

URL을 가져와서 응답을 텍스트로 로깅하려고 한다고 가정해 보겠습니다. 프로미스를 사용하는 방법은 다음과 같습니다.

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

다음은 똑같은 결과를 내지만 비동기 함수를 사용할 때의 예입니다.

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

줄 개수는 같지만 콜백이 전부 사라졌습니다. 이렇게 하면 특히 Promise에 익숙하지 않은 사용자가 읽기 쉽습니다.

비동기 반환 값

비동기 함수는 await 사용 여부와 관계없이 항상 프로미스를 반환합니다. 이 프라미스는 비동기 함수가 반환하는 것과 함께 해결되거나 비동기 함수가 발생시키는 것과 함께 거부됩니다. 따라서 다음과 같이

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

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

hello()를 호출하면 "world"처리하는 프로미스가 반환됩니다.

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

foo()를 호출하면 Error('bar')와 함께 거부되는 프로미스가 반환됩니다.

예: 응답 스트리밍

비동기 함수의 이점은 복잡한 예시에서 더욱 두드러집니다. 청크를 로그아웃하는 동안 응답을 스트리밍하고 최종 크기를 반환하려는 경우를 생각해봅시다.

다음은 프라미스를 사용하는 예입니다.

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 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 루프로 바뀌었죠. 훨씬 낫습니다. 앞으로는 비동기 반복기를 사용할 수 있게 됩니다. 이 반복기를 사용하면 while 루프가 for-of 루프로 대체되어 훨씬 더 깔끔해집니다.

기타 비동기 함수 구문

async function() {}를 이미 보여드렸지만 async 키워드는 다른 함수 구문과 함께 사용할 수 있습니다.

화살표 함수

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

객체 메서드

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(…);

단, 너무 순차적이지 않도록 하세요

동기 함수처럼 보이는 코드를 작성하는 중이지만, 병렬로 처리할 수 있는 기회를 놓치지 마세요.

async function series() {
  await wait
(500); // Wait 500ms…
  await wait
(500); // …then wait another 500ms.
 
return 'done!';
}

위의 코드는 완료하는 데 1, 000ms가 걸립니다.

async function parallel() {
 
const wait1 = wait(500); // Start a 500ms timer asynchronously…
 
const wait2 = wait(500); // …meaning this timer happens in parallel.
  await
Promise.all([wait1, wait2]); // Wait for both timers in parallel.
 
return 'done!';
}

위 작업은 완료하는 데 500밀리초가 걸립니다. 두 대기가 동시에 발생하기 때문입니다. 실제 예를 살펴보겠습니다.

예: 가져오기(fetch)를 순서대로 출력

일련의 URL을 가져와서 최대한 빨리 올바른 순서대로 로그에 기록하려는 경우를 생각해봅시다.

심호흡 - 프라미스를 사용하면 다음과 같은 형태가 됩니다.

function markHandled(promise) {
  promise
.catch(() => {});
 
return promise;
}

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

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

네, 생각하신 대로 프라미스의 순서를 체인으로 연결하려고 reduce를 사용하고 있어요. 정말 똑똑해. 하지만 이 코드는 너무 스마트해서 사용하지 않는 것이 좋습니다.

하지만 위 코드를 비동기 함수로 변환할 때는 너무 순차적으로 작성하고 싶은 유혹이 생길 것입니다.

권장하지 않음 - 너무 순차적임
async function logInOrder(urls) {
 
for (const url of urls) {
   
const response = await fetch(url);
    console
.log(await response.text());
 
}
}
훨씬 더 깔끔해 보이지만 첫 번째 가져오기를 완전히 읽은 후에야 두 번째 가져오기가 시작되고 계속 그런 식으로 진행된다는 게 단점입니다. 이렇게 하면 병렬로 가져오기를 실행하는 프라미스 예시보다 훨씬 느려집니다. 다행히 그 중간에서 취할 수 있는 이상적인 방법이 있답니다.
권장 - 훌륭하게 병렬 처리
function markHandled(...promises) {
 
Promise.allSettled(promises);
}

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

  markHandled
(...textPromises);

 
// log them in sequence
 
for (const textPromise of textPromises) {
    console
.log(await textPromise);
 
}
}
이 예시에서는 URL을 병렬로 가져와서 읽습니다. 다만, '스마트한' reduce 비트가 쉽게 읽을 수 있지만 일반적이고 따분해 보이는 for 루프로 바뀌어 있죠.

브라우저 지원 해결 방법: 생성기

생성기(모든 주요 브라우저의 최신 버전 포함)를 지원하는 브라우저를 타겟팅하는 경우 비동기 함수를 폴리필(polyfill)할 수 있습니다.

Babel이 이 작업을 대신 처리해 줄 것입니다. Babel REPL을 통한 예시를 참고하세요

  • 트랜스파일된 코드가 얼마나 비슷한지 유심히 살펴보세요. 이 변환은 Babel의 es2017 프리셋의 일부입니다.

트랜스파일 방식을 사용하는 것이 좋습니다. 대상 브라우저에서 비동기 함수를 지원하면 이 방법을 사용 중지할 수 있지만 정말 트랜스파일러를 사용하지 않으려면 Babel의 폴리필을 가져와 직접 사용하면 됩니다. 다음을 대신해서 사용합니다.

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

폴리필을 포함하고 다음과 같이 작성하면 됩니다.

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

제너레이터 (function*)를 createAsyncFunction에 전달하고 await 대신 yield를 사용해야 합니다. 그 밖의 다른 것은 똑같이 작동합니다.

해결 방법: 리제너레이터

이전 브라우저를 타겟팅하는 경우 Babel은 제너레이터도 트랜스파일할 수 있으므로, IE8까지는 비동기 함수를 사용할 수 있습니다. 이를 위해서는 Babel의 es2017 프리셋es2015 프리셋이 필요합니다.

출력이 그렇게 예쁘지 않으므로 코드 팽창에 주의합니다.

모든 것을 비동기화합니다.

앞으로 모든 브라우저에서 비동기 함수가 지원되면 프라미스를 반환하는 모든 함수에 비동기 함수를 사용하세요. 그러면 코드가 더 깔끔해질 뿐 아니라 함수가 항상 프라미스를 반환하도록 할 수 있습니다.

저는 2014년에 비동기 함수를 접하고서 정말 열광했답니다. 그런데 실제로 각종 브라우저에 안착하는 모습을 보니 감격스럽네요. 정말 굉장한 경험입니다!