Async/await 장점

으라차차·2020년 5월 25일
2
post-thumbnail

async 와 await 키워드는 2017년 버전의 언어 명세서의 일부로 자바스크립트에 새로 추가되었습니다. 함수는 function() {} 또는() => {} 같은 화살표 함수 표기법으로 표현하는데, async 키워드는 일반 함수를 비동기 함수로 바꾸어 놓습니다. 이렇게 만든 비동기 함수는 await 키워드를 사용해서, promise가 "정착" 상태가 될 때 까지 그 실행을 일시 중지 시킬 수 있습니다. 아래 함수에서, await 키워드는 함수의 실행을 약 1초 간 일시 정지 시킵니다.

// example 1.1

async function test() {
  // test()는 1초 후 "Hello, World!"를 출력합니다. 
  await new Promise(resolve => setTimeout(() => resolve(), 1000));

  console.log('Hello, World!'); 
} 

test();

await 키워드는 비동기 함수의 본문 내부 어디에서도 사용할 수 있습니다. 즉, if 문, loop 및 try/catch 블록 내부에서도 일시 중지를 위해 await을 사용할 수 있습니다. 다음은 비동기 함수의 실행을 약 1초 간 일시 중지 시키는 또 다른 방법입니다.

// example 1.2

async function test() {   
    // 100ms를 10회 기다립니다.
    //  이 함수는 1초를 기다린 후 'Hello, World!' 출력합니다.
    for (let i = 0; i < 10; ++i) {
        await new Promise(resolve => setTimeout(resolve, 100));   
    }
    
    console.log('Hello, World!'); 
} 

test();

await를 사용할 때 가장 큰 제약 사항은, async로 표시한 함수의 본체 내부에서만 await를 사용할 수 있다는 점입니다. 아래 코드는 관련된 SyntaxError 오류를 발생 시킵니다.

// example 1.3

function test() {   
    const p = new Promise(resolve => setTimeout(resolve, 1000));   
    
    // SyntaxError: Unexpected identifier
    await p; 
} 

test();

특히, 클로저(closure) 함수도 async를 사용한 비동기 함수가 아니라면, async 함수에 내장된 클로저에 await를 사용할 수 없습니다. 아래 코드는 관련된 SyntaxError 오류를 발생 시킵니다.

// example 1.4

const assert = require('assert'); 

async function test() {
    const p = Promise.resolve('test');  
    assert.doesNotThrow( function() {    
    
        // "SyntaxError: Unexpected identifier" because the above
        // 이 함수는 async로 선언되지 않았습니다. 클로저 == 함수 내부의 함
        await p;   
    } ); 
}

새로운 함수를 만들지 않는 한, 많은 if, loop문장 안에 await을 사용할 수 있습니다.

// example 1.5

async function test() {
  while (true) {
    // Convoluted way to print out "Hello, World!" once per
    // second by pausing execution for 200ms 5 times
    for (let i = 0; i < 10; ++i) {
      if (i % 2 === 0) {
        await new Promise((resolve) => setTimeout(resolve, 200));
      }
    }
    console.log("Hello, World!");
  }
}

Return Values: 반환값들

async/await는 단순히 코드 실행을 일시 중지하는 것 이상으로 활용됩니다. await의 반환값은 promise가 완료 상태일 때 값입니다. 즉, 코드는 동기식으로 보이지만, 비동기적으로 나중에 수행을 마치고 완료된 값을 변수에 할당할 수 있습니다.

// example 1.6

async function test() {
  // promise가 아닌 값/객체에도 await을 할 수는 있습니다.
  let res = await "Hello World!";
  console.log(res); // "Hello, World!"

  const promise = new Promise((resolve) => {
    // promise는 1초 후에 "Hello, World!"가 완료된/이행된 값이 됩니다. 
    setTimeout(() => resolve("Hello, World!"), 1000);
  });
  
  res = await promise; 

  // "Hello, World!"가 출력됩니다. res는 promise의 "이행값"입니다.
  console.log(res);
  // "Hello, World!"가 출력됩니다. 함수 파라미터에 'await'을 사용할 수 있습니다.
  console.log(await promise);
}

async 함수는 항상 promise를 반환합니다. async 함수 내부에서 값을 return 하면, 자바스크립트는 여러분이 반환한 그 값을 promise의 이행값으로 만듭니다 이것은 async 함수 내부에서 다른 async 함수를 호출하는 것이 매우 자연스럽다는 것을 의미합니다. 즉, 내부의 async 함수 호출에 대해 await하면 그 async 함수의 처리완료된 "반환값"을 얻을 수 있습니다.

// example 1.7

async function computeValue() {
  await new Promise((resolve) => setTimeout(resolve, 1000)); 
  
  // "Hello, World"는 이 함수 호출이 "완료상태가 되었을때 값"입니다.
  return "Hello, World!";
}

async function test() {
  // 1초 후 "Hello, World!"를 출력합니다. computeValue()는 promise를 반환하며 반환값에
  //   await을 하면 처리완료된 값 "Hello, World!"가 됩니다.
  console.log(await computeValue());
}

이 책은 async 함수에서 여러분이 반환한 값을 "해결값(resolved)"이라고 부를 것입니다. 위 computeValue() 에서 "Hello, world!"는 해결값이지만 computeValue()는 여전히 promise을 반환합니다. 이 구분("반환값"과 "해결값")은 미묘하지만 매우 중요합니다: async 함수 본체에서 여러분이 반환하는 그 값은 await 없이 computeValue()를 호출할 때 반환되는 그 값이 아닙니다. 또한 async 함수에서 여러분이 promise을 직접 반환할 수도 있습니다. 이 경우, async 함수가 반환하는 promise는 또 다른 promise이기 때문에 언제든 또다시 해결 또는 취소될 수 있습니다. 다음은 1초 후, "Hello, World!"를 해결 값으로 갖는 async 함수이다.

// example 1.8

async function computeValue() {
  // The resolved value is a promise. The promise returned from
  // `computeValue()` will be fulfilled with 'Hello, World!'
  // promise가 '해결값' 입니다. 
  //   computeValue()에서 반환된 promise는 'Hello, World!'로 '이행'됩니다.
  return new Promise((resolve) => {
    setTimeout(() => resolve("Hello, World!"), 1000);
  });
}

async 함수에서 여러분이 promise를 반환하면, 그 promise의 해결값과 여러분의 반환값은 여전히 같지 않을 겁니다. 아래 사례는 함수에서 반환된 resolvedValue라는 promise가 computeValue()의 반환값과 같지 않음을 보여줍니다.

// example 1.9

let resolvedValue = Promise.resolve("Hello, World!");
const computeValue = async () => resolvedValue;

async function test() {
  // await 없이 호출하면, returnValue는 promise입니다.
  const returnValue = computeValue(); 

    // `false`. Return value never strictly equals resolved value.
  // 'false'. 둘은 같지 않습니다.
  console.log(returnValue === resolvedValue);
}

async/await 초보자가 자주 실수하는 점은 async 함수에서 자신이 직접 promise을 반환해야 한다고 생각한다는 점입니다. async 함수는 항상 promise를 반환하니 promise의 반환 책임이 자신에게 있다고 읽은 것 같습니다. async 함수가 항상 promise을 반환하기는 하지만, 예 1.9와 같이 JavaScript가 여러분을 위해 반환될 promise를 만들어 냅니다.

// example 1.10

async function computeValue() {
  // 아래 Promise.resolve()는 불필요한 코드입니다.
  // "불필요한 코드가 생각만큼 해롭지는 않지만, 
       그것이 필요할 수도 있다는 잘못된 신호를 주게 됩니다." - Paul Graham
  return Promise.resolve("Hello, World!");
}

Error Handling: 오류 처리

async/await의 중요한 특징들 중 하나는 try/catch를 이용해서 비동기적 오류를 처리할 수 있다는 것입니다. promise는 이행 또는 실패 할 수 있다는 점을 기억하세요. promise p가 "이행" 이면, 자바스크립트는 await p를 그 promise의 값으로 평가합니다. 그런데, 만약 p 가 "실패"라면 어떨까요?

// example 1.11

async function test() {
  try {
    const p = Promise.reject(new Error("Oops!")); // The below `await` throws
    await p;
  } catch (error) {
    console.log(error.message); // "Oops!"
  }
}

p가 실패라면 await p는 자바스크립트의 try/catch에서 볼 수 있는 예외를 발생시킵니다. await 문은 오류를 "throw"하지 promise를 만들지 않는다라는 점에 유의해야 합니다. 이러한 try/catch 동작은 오류 처리를 통합하는 강력한 도구입니다. 위의 try/catch 블럭은 비동기 오류 뿐만 아니라 동기 오류도 처리할 수 있습니다. TypeError: cannot read property 'x' of undefined 오류를 발생시키는 코드가 있다고 가정해 봅시다.

// example 1.12

async function test() {
  try {
    const bad = undefined;
    
    bad.x;
    
    const p = Promise.reject(new Error("Oops!"));
    
    await p;
  } catch (error) {
    // "cannot read property 'x' of undefined"
    console.log(error.message);
  }
}

callback 기반 코드에서는 비동기 오류와 별도로 TypeError와 같은 동기 오류를 감시해야 했습니다. 이것은 많은 서버 다운과 유명한 크롬 콘솔 오류인 "빨간팬 오류~"를 야기합니다. 왜냐하면 규칙에 맞지 않기 때문입니다.
async/await 대신 callback 기반의 접근 방식을 고려해 봅시다. callback을 단일 인수로 받는 블랙박스 함수 test() 가정합시다. 가능한 모든 오류를 파악하려면 test()와 callback() 주위에 두 번의 try/catch 블럭이 필요합니다.
또한 test() 가 오류를 가진 callback을 호출했는지 확인할 필요도 있습니다. 즉, 모든 비동기 동작에는 3가지 고유한 오류 처리 패턴이 필요합니다!

// example 1.13

function testWrapper(callback) {
  try {
    // (1) test() 수행중 동기적 오류가 발생 할 수 있음
    test(function (error, res) {
      
      // (2) test()는 error를 가진 callback()을 호촐할 수 있음.
      if (error) {
        return callback(error);
      } 
      
      // (3) res.x를 다루거나 callback()이 예외를 발생시키지 않는지 주의할 필요가 있습니다.***
      try {
        return callback(null, res.x);
      } catch (error) {
        return callback(error);
      }
    });
  } catch {
    }
}

오류 처리를 위해 코드가 이렇게 장황하면 아무리 철저하고 단련된 개발자라도 결국 핵심를 놓치게 됩니다. 그 결과, 잡지 못하는 오류, 서버 다운, 버그 같은 사용자 인터페이스가 만들어지게 됩니다. 아래는 async/await를 사용한 유사 사례입니다. 단일 패턴을 사용해서 예제 1.12에서 보인 세 가지 오류 사례를 처리할 수 있습니다.

// example 1.14

async function testWrapper() {
  try {
    // `try/catch`는 test()내부에서 발생하는 동기적 오류, 
    //  비동기 수행이 완료되었으나 rejected인 경우, 
    //  그리고 res.x를 접근할 때 발생하는 동기적 오류를 모두 처리합니다.
    const res = await test();
    return res.x;
  } catch (error) {
    throw error;
  }
}

이제 try/catch가 어떻게 작동하는지 살펴보았으니, throw 키워드가 비동기 함수와 어떻게 작동하는지 살펴봅시다. 비동기 함수에서 오류를 throw 하면, 자바스크립트는 반환된 promise를 거절합니다. 비동기 함수에서 여러분이 반환한 값을 "해결값" 이라고 표현했다는 점을 기억하세요. 비슷하게, 이 책은 async 함수에서 여러분이 throw한 (오류)값을 "거절값"이라고 표현합니다.

// example 1.15

async function computeValue() {
  // `err` is the "rejected value"
  const err = new Error("Oops!");
  throw err;
}

async function test() {
  try {
    const res = await computeValue(); // Never runs
    console.log(res);
  } catch (error) {
    console.log(error.message); // "Oops!"
  }
}

computeValue() 함수 호출 자체는 test() 내부에서 오류를 발생하지 않습니다. await 키워드를 통해서 비로소 try/catch가 처리할 수 있는 오류를 발생 시킵니다. 아래 코드에서 await 문의 주석을 해제하지 않는 한, "No Error"가 출력 됩니다.

// example 1.16

async function computeValue() {
  throw new Error("Oops!");
}

async function test() {
  try {
    const promise = computeValue(); 

    // 아래 주석을 없애면 어떤 오류도 발생하지 않습니다.
    // await promise;
    console.log("No Error");
  } catch (error) {
    console.log(error.message); // Won't run
  }
}

promise 주변에 try/catch를 반드시 적용할 필요는 없습니다. async 함수는 항상 promise를 반환하기 때문에 .catch()로 오류를 처리할 수 있습니다.

// example 1.17

async function computeValue() {
  throw new Error("Oops!");
}

async function test() {
  let err = null;
  await computeValue().catch((_err) => {
    err = _err;
  });

  console.log(err.message);
}

try/catch 와 catch() 는 각자 적당한 자리가 있습니다. 특히, catch()를 사용하면 오류 처리를 보다 쉽게 한 곳으로 모을 수 있습니다. 일반적인 async/await의 초보적인 실수는 try/catch를 모든 단일 함수 호출 주변에 놓는 것입니다. 모든 오류를 다루는 공통의 handleError() 함수를 원한다면 catch()를 사용하는 것이 좋습니다.

// example 1.18

// 이런 방식의 코드를 작성하고 있다고 생각된다면 이젠는 아래 방식을 이용하세요
async function fn1() {
  try {
    /* Bunch of logic here */
  } catch (err) {
    handleError(err);
  }
}

// 이 방식이요~
async function fn2() {
  /* Bunch of logic here */
}

fn2().catch(handleError);

실패한 HTTP Request 재시도

"실패한 요청 재시도"라는 고통스러운 과제를 처리하기 위해 반복문, 반환값, 오류 처리 등을 함께 묶어 보겠습니다. 미덥지 않은 API에 HTTP Request을 해야 한다고 가정해 봅시다. callbacks 또는 promise chains에서 실패한 Request을 재시도 하려면 재귀호출이 필요하며, 재귀호출은 반복문을 활용해서 작성한 동기식 코드보다 읽기가 어렵습니다.

아래 코드는 callbacks과 superagent HTTP client를 사용해서 간략히 작성한 getWithRetry() 함수입니다.

// example 1.19

function getWithRetry(url, numRetries, callback, count) {
  count = count || 0;
  superagent.get(url).end(function (error, res) {
    if (error) {
      if (count >= numRetries) {
        return callback(error);
      }
      return getWithRetry(url, numRetries, callback, count + 1);
    }
    return callback(null, res.body);
  });
}

재귀호출 방식은 반복문에 비해 교묘하고 까다로워 이해하기가 쉽지 않습니다. 또한 위 코드는 동기 오류의 가능성을 무시하고 있습니다. 왜냐하면 example 1.13 에서 강조한 try/catch 를 사용한 스파게티 코드는 이 예제를 읽기 어렵게 만들기 때문입니다. 요컨대, 이런 패턴은 깨지기 쉽고 번거롭습니다.

async/await 사용은 재귀호출이 필요 없으며 동기 및 비동기 오류를 처리하기 위해 한 번정도의 try/catch만 필요합니다. async/await 구현은 for 반복문, try/catch 그리고 초보 개발자도 친숙한 수준의 코드들로 만들 수 있습니다.

// example 1.20

async function getWithRetry(url, numRetries) {
  let lastError = null;
  for (let i = 0; i < numRetries; ++i) {
    try {
      // await superagent.get(url).body이 동작하지 않는다고 생각합시다.
      const res = await superagent.get(url);
      
      // 정상 작동하면 바로 반환합니다.
      return res.body;
    } catch (error) {
      lastError = error;
    }
  }
  throw lastError;
}

일반적으로, async/await를 사용하면 비동기 연산들을 순차적으로 처리하는 것이을 사소해 보입니다. 예를 들어, HTTP API에서 블로그 게시물 목록을 얻은 다음, 각 블로그 게시물에 대한 주석을 얻기 위해 별도의 HTTP 요청을 실행해야 한다고 가정해 봅시다. 이 예는 우수한 테스트 데이터를 제공하는 JSONPlaceholder API를 사용합니다.

// example 1.21

async function run() {
  const root = "https://jsonplaceholder.typicode.com";
  const posts = await getWithRetry(`${root}/posts`, 3);
  for (const { id } of posts) {
    const comments = await getWithRetry(`${root}/comments?postId=${id}`, 3);
    console.log(comments);
  }
}

만약 위 코드가 평이 해 보인다면 , 그것으로 훌륭합니다. 왜냐하면 프로그래밍은 그렇게 되어야 하기 때문입니다. 자바스크립트 커뮤니티는 비동기 작업들을 순차적으로 실행하기 위한 수 많은 도구를 만들어 냈습니다. async.waterfall()에서부터 Redux sagas, zones, co 까지 말입니다. async/await 때문에 이러한 라이브러리가 모두 필요 없어졌습니다. 더 이상 Redux 미들웨어가 필요한가요?

이 설명들이 async/await의 전부가 아닙니다. 이번 장에서는 promise가 어떻게 async/await와 통합되며 두 비동기 함수들이 동시에 실행될 때 어떤 일이 발생하는지 등, 여러 가지 중요한 세부 사항들을 얼버무렸습니다. 2장은 "resolved: 해결된"과 "fulfilled: 이행된"의 차이점을 포함해서 promise의 내막에 초점을 맞추고, 왜 promise가 async/await에 완벽하게 적합한 가를 설명합니다.


Exercise 1: HTTP Request 반복하기

이번 연습 문제의 목적은 반복문과 선택문에서 async/await를 사용하는데 익숙해지는 것입니다. thecodebarbarian.com에서 블로그 게시물 목록을 얻기 위해 fetch() API를 사용하고, 각각의 블로그 게시물에 대한 원시 markdown 콘텐츠를 얻기 위해 별도의 fetch()이 필요할 것입니다. 아래는 Google 클라우드에서 호스팅되는 API 접속 주소입니다.

https://us-central1-mastering-async-await.cloudfunctions.net.

  • /posts gets a list of blog posts. Below is an example post:
  • /posts는 블로그 게시물들의 목록을 가져옵니다. 아래는 게시물 예입니다.
{ 
    "src":"./lib/posts/20160304_circle_ci.md",   
    "title":"Setting Up Circle CI With Node.js",   
    "date":"2016-03-04T00:00:00.000Z",   
    "tags":["NodeJS"],   
    "id":51 
}
{
    "content": "*This post was featured as a guest blog post..."
}

블로그 게시물과 "async/wait hell" 문자열을 포함하는 첫 게시물의 ID를 반복합니다. 아래는 초기 코드입니다. 이 코드를 복사하여 node.js에서 node-fetch npm 모듈을 사용하여 실행하거나, 브라우저로 Codepen(http://bit.ly/async-await-exercise-1)에 접속하여 이 문제를 완성할 수도 있습니다.

const root = "https://" + "us-central1-mastering-async-await.cloudfunctions.net";

async function run() {
  // Example of using `fetch()` API
  const res = await fetch(`${root}/posts`);
  console.log(await res.json());
}

run().catch((error) => console.error(error.stack));

Exercise 2: 실패한 Requests 재시도 하기

이 연습 문제의 목적은 async/await를 사용하여 실패한 HTTP 요청을 재시도 하고 오류를 처리하기 위해 try/catch하는 함수를 구현하는 것입니다. 이 예는 연습 1.1의 답에 기초하지만, 다른 모든 fetch() 요청이 실패한다는 몇 가지 장애가 추가되어 있습니다. 이 연습을 위해서는 아래의 getWithRetry() 함수를 구현해야 합니다. 이 함수는 url을 fetch()하며, 요청이 실패할 경우 이 함수는 최대 numRetries 횟수까지 요청을 재시도 해야 합니다. "정답: 76"이 보이면 성공입니다.

Example 1.1 같이, 아래 코드를 복사하고 node-fetch npm 모듈을 사용하여 로컬에서 이 연습을 완료할 수 있습니다. 또한 다음 URL의 CodePen에서 브라우저에서 이 연습을 완료할 수 있습니다

async function getWithRetry(url, numRetries) {
  return fetch(url).then((res) => res.json());
}

// Correct answer for exercise 1.1 below
async function run() {
  const root = "https://" + "us-central1-mastering-async-await.cloudfunctions.net/post";
  const posts = await getWithRetry(`${root}/posts`, 3);
  for (const p of posts) {
    console.log(`Fetch post ${p.id}`);
    const content = await getWithRetry(`${root}?id=${p.id}`, 3);
    if (content.content.includes("async/await hell")) {
      console.log(`Correct answer: ${p.id}`);
      break;
    }
  }
}

run().catch((error) => console.error(error.stack));
// This makes every 2nd `fetch()` fail
const _fetch = fetch;
let calls = 0;

(window || global).fetch = function (url) {
  const err = new Error("Hard-coded fetch() error");
  return ++calls % 2 === 0 ? Promise.reject(err) : _fetch(url);
};
profile
만만세~

0개의 댓글