부트캠프/자바스크립트 완벽 가이드

13장. 비동기 자바스크립트

하이고니 2023. 2. 28. 00:43

 

 

 

Promise: 비동기 작업의 결과를 나타내는 객체

 

결과가 준비됐을 수도 있고 준비되지 않았을 수도 있는데 Promise API는 이를 의도적으로 막연하게(?) 표현한다.

Promise의 값을 동기적으로 가져올 수 있는 방법은 존재하지 않는다. 값이 준비됐을 때 콜백 함수를 호출하도록 Promise에 요청할 수 있을 뿐.

 

콜백 기반 비동기 프로그래밍의 심각한 문제는 콜백 지옥이 생긴다는 것인데, 콜백 지옥이란 콜백 안에 콜백, 그 안에 또 콜백이 이어져 들여쓰기가 너무 심하게 되는 형태를 말한다. Promise는 이런 중첩된 콜백을 좀 더 선형에 가까운 Promise 체인으로 바꿔주기 때문에 코드의 가독성을 높여준다.

 

콜백에는 에러 처리가 어렵다는 문제도 존재한다. 비동기 함수(또는 비동기적으로 호출된 콜백)에서는 예외가 일어났을 때 그 예외를 비동기 동작의 최초 실행자(initiator)에게 전달할 수 있는 방법이 없다.

 

 

Promise 사용


우선 Promise를 반환하는 유틸리티 함수를 어떻게 사용하는지부터 살펴보자.

 

getJSON(url).then(jsonData => {
	// JSON 값을 받아 분석하면 비동기적으로 호출되는 콜백 함수
});

 

위 코드에서 getJSON(url)을 Promise 객체라고 가정하자. Promise 객체에는 then() 인스턴스 메서드가 있는데, 이 메서드에 콜백 함수를 전달한다(콜백함수를 getJSON()에 직접 전달하는 것이 아님). HTTP 응답이 도착하면 응답 바디를 JSON으로 분석하고 분석된 값을 then() 메서드의 인자인 콜백 함수에 전달한다.

 

then() 메서드는 클라이언트 사이드 자바스크립트에서 이벤트 핸들러를 등록할 때 사용하는 addEventListener() 메서드와 비슷한 콜백 등록 메서드라고 생각할 수 있다. Promise 객체에서 then() 메서드를 여러 번 호출하면 각 콜백은 비동기 작업이 완료될 때 호출된다.

 

하지만 대부분의 이벤트 리스너와 달리 Promise는 단 한 가지 작업의 결과일 뿐이며, then()에 등록된 각 함수는 단 한 번만 호출된다. 설령 then()을 호출할 때 비동기 작업이 이미 완료된 상태라 하더라도 then()에 전달된 함수는 비동기적으로 호출된다.

 

 

Promise 체인


Promise의 가장 중요한 장점 중 하나는 비동기 작업 시퀀스를 then()의 체인으로 이어서 콜백 헬을 방지한다는 점이다.

다음은 가상의 Promise 체인이다.

 

fetch(documentURL)				// HTTP 요청을 보낸다.
    .then(response => response.json())		// 응답의 JSON 바디를 가져온다.
    .then(document => {				// JSON 분석이 끝나면
    	return render(document);		// 문서를 사용자에게 표시한다.
    })
    .then(rendered => {				// 문서 렌더링이 끝나면
    	cacheInDatabase(rendered);		// 로컬 데이터베이스에 캐시한다.
    })	
    .catch(error => handle(error));		// 에러를 처리한다.

 

HTTP 요청에 Promise 체인을 사용하는 아이디어부터 살펴보자. Fetch API는 fetch() 함수 하나뿐이다. fetch()는 URL을 받고 Promise를 반환한다. 그 Promise는 HTTP 응답이 도착하기 시작해 HTTP 상태와 헤더를 일으면 이행된다.

 

fetch("/api/user/profile").then((response) => {
  // Promise가 해석되면 상태와 헤더가 존대한다.
  if (
    response.ok &&
    response.headers.get("Content-Type") === "application/json"
  ) {
    // 여기서 뭘 할 수 있을까? 아직 응답 바디는 받지 못했다.
  }
});

 

fetch()가 반환하는 Promise가 이행되면 Promise는 then() 메서드의 인자인 함수에 응답 객체를 전달한다. 이 응답 객체는 요청 상태와 헤더에 접근을 허용하며, 응답 바디에 각각 텍스트와 JSON 형태로 접근할 수 있는 text() 메서드와 json() 메서드도 가지고 있다. 초기 Promise가 이행되긴 했지만 응답 바디는 아직 도착하지 않았을 수도 있다. 따라서 응답 바디에 접근하는 text()와 json() 메서드 역시 Promise를 반환한다. 다음과 같이 fetch()와 response.json() 메서드를 사용해 HTTP 응답의 바디를 가져올 수는 있다.

 

fetch(url).then(response => {
  response.json().then(data => {  // JSON으로 분석된 바디를 요청한다.
    // 응답 바디를 받으면 자동으로 JSON으로 전달하고 이 함수의 인자로 입력한다.
    printData(data);
  });
});

 

이렇게 Promise를 콜백처럼 중첩해서 사용하는 것은 Promise의 설계 목적에 부합하지 않는다.

연속적인 Promise 체인으로 사용하는 것이 더 적절하다.

 

fetch(url)
  .then(response => response.json())
  .then(data => printData(data));

 

fetch().then().then()

 

이렇게 표현식 하나에서 메서드를 하나 이상 호출하는 것을 '메서드 체인이라 부른다. fetch() 함수는 Promise 객체를 반환하고 첫 번째 .then()은 반환된 Promise 객체의 메서드를 호출한다. 이 체인에는 두 번째 .then()도 있으므로 첫 번째 then() 메서드 또한 반드시 Promise를 반환해야 한다.

 

fetch(theURL)			// 작업 1. Promise 1을 반환
    .then(callback1)		// 작업 2. Promise 2를 반환
    .then(callback2);		// 작업 3. Promise 3을 반환

 

1. fetch() 호출. fetch()는 theURL에 HTTP GET 요청을 보내고 Promise 1을 반환한다. (작업 1)

2. 두 번째 행에서 Promise 1의 then() 메서드를 호출하고, 그 인자로는 Promise 1이 이행됐을 때 호출할 callback1 함수를 전달한다. then() 메서드는 콜백을 어딘가에 저장하고 Promise 2를 반환한다. (작업 2는 callback1이 호출될 때 시작된다고 하자)

3. 세 번째 행에서 Promise 2의 then() 메서드를 호출하면서 Promise 2가 이행됐을 때 호출할 callback2 함수를 전달한다. 이 then() 메서드는 콜백을 기억하며 Promise 3을 반환한다. (작업 3은 callback2가 호출될 때 시작된다고 하자. 사실 Promise 3은 사용될 일이 없기 때문에 이름을 붙일 필요도 없다.)

 

4. 위 세 단계는 표현식을 처음 실행할 당시 모두 동기적으로 진행된다. 작업 1에서 보낸 HTTP 요청이 인터넷을 통해 전송되는 동안 코드는 비동기적으로 일시 중지된다.

5. HTTP 응답이 도착하기 시작한다. fetch() 호출의 비동기적 부분은 HTTP 상태와 헤더를 감싼 응답 객체를 값으로 Promise 1을 이행한다.

6. Promise1이 이행되면 그 값인 응답 객체가 callback1()에 전달되며 작업 2가 시작된다. 이 작업의 목적은 응답 객체를 입력으로 삼아 응답 바디를 JSON 객체로 가져오는 것이다.

7. 작업 2가 정상적으로 완료됐고 HTTP 응답 바디를 분석해 JSON 객체를 만들 수 있다고 가정하자. 이 JSON 객체가 Promise 2를 이행시킨다.

8. Promise 2를 이행시키는 값은 callback2() 함수에 전달되면서 작업 3의 입력값이 된다. 작업 3은 아직 명시되지 않은 방법을 통해 사용자에게 데이터를 표시한다. 작업 3이 정상적으로 완료되면 Promise 3이 이행된다. 하지만 Promise 3으로는 아무 것도 하지 않았으므로 이 Promise가 완료될 때는 아무 일도 일어나지 않으며 비동기 작업 체인은 여기서 끝난다.

 

 

Promise 해석

 

사실은 네 번째 Promise 객체도 존재한다.

fetch()는 Promise를 반환하며 Promise가 이행될 때 등록된 콜백 함수에 응답 객체를 전달한다. 이 응답 객체에는 .text(), .json(), 기타 HTTP 응답 바디를 다양한 형태로 요청하는 메서드들이 존재한다. 하지만 바디가 아직 도착하지 않았으므로 이 메서드들도 반드시 Promise 객체를 반환해야 한다. 앞에서는 .json() 메서드를 호출하고 그 값을 반환했다. 이 값이 네 번째 Promise 객체이며 그 반환 값은 callback1() 함수다.

 

콜백과 Promise를 좀 더 세부적으로 작성해보자.

 

function c1(response) {               // 콜백 1
  let p4 = response.json();
  return p4;                          // Promise 4를 반환
}

function c2(data) {                   // 콜백 2
  printData(data);
}

let p1 = fetch("/api/user/profile");  // Promise 1, 작업 1
let p2 = p1.then(c1);                 // Promise 2, 작업 2
let p3 = p2.then(c2);                 // Promise 3, 작업 3

 

 

 

 

Promise 체인을 유용하게 사용하려면 작업 2의 출력은 반드시 작업 3의 입력이 돼야 한다.

이 예제에서 작업 3의 입력은 fetch()로 가져온 URL 바디를 JSON 객체로 분석한 결과이다. 하지만 콜백 c1의 반환 값은 JSON 객체가 아니라 그 JSON 객체를 나타내는 Promise p4다.

 

p1이 이행되면 c1이 호출되고 작업 2가 시작된다. p2가 이행되면 c2가 호출되고 작업 3이 시작된다. 하지만 c1이 호출될 때 작업 2가 시작된다는 말이 c1이 반환될 때 작업 2가 반드시 끝나야 한다는 의미는 아니다. Promise는 비동기 작업을 관리하도록 설계됐으며 작업 2가 비동기라면 콜백이 반환되는 시점에 완료되지 않았을 수도 있다.

 

 

 

추가 설명

 

콜백 c를 then() 메서드에 전달하면 then()은 Promise p를 반환하고 나중에 c를 비동기적으로 호출할 수 있도록 준비한다. 콜백은 작업을 마치면 값 v를 반환한다. 콜백이 완료되면 pv로 해석된다. Promise가 Promise 아닌 값으로 해석되면 Promise는 그 값으로 즉시 이행된다. 따라서 c가 Promise 아닌 값을 반환하면 그 반환 값은 p의 값이 되고, p가 이행되면서 끝난다. 반면 반환 값 v가 역시 Promise라면 p는 '해석되긴 했지만 이행되지는 않은 상태'다. 이 시점에서 p는 Promise v가 완료되기 전에는 완료될 수 없다. v가 이행되면 p는 같은 값으로 이행된다. v가 거부되면 p는 같은 이유로 거부된다.

 

Promise가 '해석된 상태'라는 말은 Promise가 다른 Promise와 연결됐다는 의미다. p가 이행됐는지 거부됐는지는 아직 몰라도 c에게는 아무 권한도 없다. p는 이제 Promise v에서 어떤 일이 일어나느냐에 따라 달라지며, 그런 의미에서 '해석됐다' 라는 표현을 쓴다. 

 

 

function c1(response) {               // 콜백 1
  let p4 = response.json();
  return p4;                          // Promise 4를 반환
}

function c2(data) {                   // 콜백 2
  printData(data);
}

let p1 = fetch("/api/user/profile");  // Promise 1, 작업 1
let p2 = p1.then(c1);                 // Promise 2, 작업 2
let p3 = p2.then(c2);                 // Promise 3, 작업 3

 

c1이 p4를 반환하면 p2는 해석된다. 하지만 해석됐다는 말이 이행됐다는 말은 아니므로 작업 3은 아직 시작되지 않았다. HTTP 응답 바디 전체를 사용할 수 있게 되면 .json() 메서드에서 바디를 분석한 값으로 p4를 이행할 수 있다. p4가 이행되면 p2 역시 자동으로 이행되며 그 값은 마찬가지로 JSON을 분석한 값이다. 이 시점에서 분석된 JSON 객체가 c2에 전달되며 작업 3이 시작된다.

 

 

 

 

 

 

Promise와 Error


catch / finally

 

Promise의 .catch() 메서드는 null을 첫 번째 인자로, 에러 처리 콜백을 두 번째 인자로 전달하여 .then()을 호출하는 것과 같다. Promise p와 콜백 c가 있을 때 다음 두 행은 동등하다.

 

// 아래 두 행은 동등하다.
p.then(null,c);
p.catch(c);

 

ES2018 이후 Promise 객체에는 try / catch / finally 문의 finally 절과 비슷한 목적을 가진 .finally() 메서드가 생겼다. Promise 체인에 .finally()를 추가하면 호출한 Promise가 완료될 때 .finally()가 호출된다. 이 콜백은 Promise가 이행되거나 거부될 때 호출되며 아무 인자도 받지 않으므로 콜백 안에서 Promise가 이행됐는지 거부됐는지 알 수는 없다. 하지만 Promise의 이행 여부와 관계 없이 파일이나 네트워크 연결을 닫는 것과 같은 정리 작업을 해야 한다면 .finally() 콜백이 이상적이다.

 

HTTP 요청으로 user의 profile을 GET 하는 과정을 코드로 작성해보자.

 

fetch("/api/user/profile")  // HTTP 요청 시작
  .then((response) => {     // 상태와 헤더를 받으면 호출
    if (!response.ok) {     // 404 또는 비슷한 에러를 받았다면
      return null;          // 사용자가 로그아웃했을 수 있다. 빈 프로필 반환.
    }

    // 헤더를 체크해서 서버가 JSON을 보냈는지 확인
    // 아니라면 서버에서 뭔가가 잘못된 상황. 에러 throw
    let type = response.headers.get("content-type");
    if (type !== "application/json") {
      throw new TypeError(`Expected JSON, got ${type}`);
    }

    // 여기까지 도달했다면 2xx 상태와 함께 JSON 콘텐츠 타입을 받은 것이니
    // 응답 바디를 JSON 객체로 파싱하는 Promise를 반환해도 괜찮다.
    return response.json();
  })
  .then((profile) => {    // 분석된 응답 바디 또는 null로 호출
    if (profile) {
      displayUserProfile(profile);
    } else {              // 위에서 404 에러를 받고 null을 반환했다면 여기가 끝
      displayLoggedOutProfilePage();
    }
  })
  .catch((e) => {
    if (e instanceof NetworkError) {
      // 인터넷 연결이 끊겼다면 fetch()가 이런 식으로 실패할 수 있다.
      displayErrorMessage("Check your internet connection.");
    } else if (e instanceof TypeError) {
      // 위에서 TypeError를 일으킨 경우
      displayErrorMessage("Something is wrong with our server!");
    } else {
      // 예상하지 못한 에러를 잡는 용도로만 사용
      // 예상할 수 있는 에러를 이런 식으로 처리해서는 안 된다.
      console.error(e);
    }
  });

 

 

위 코드에서는, 일반적인 동기적 throw 문으로 발생된 Error 객체가 Promise 체인에 있는 .catch() 메서드에서 비동기적으로 처리된다.

이걸 보면 왜 .then()에 두 번째 인자를 전달하는 것보다 단축 메서드를 쓰는 것이 더 나은지, Promise 체인을 거의 대부분 .catch()로 끝내는 이유가 무엇인지 명확히 알 수 있다.

 

병렬 Promise


때때로 여러 개의 비동기 작업을 병렬로 실행해야할 때도 있다. Promise.all()이 Promise의 병렬 실행을 담당한다. Promise.all()은 Promise 객체의 배열을 받고 Promsie를 반환한다. 입력 Promise가 모두 이행되면 전체 Promise는 각 입력 Promsie 값으로 이루어진 배열로 이행된다.

 

 

 

Promise.all()에서 입력 배열은 Promise 객체 뿐만 아니라 다른 값도 포함할 수 있다. 배열 요소 중 일부가 Promise가 아니라면 그 값은 이미 이행된 것으로 간주하고 결과 배열에 그대로 복사한다.

 

 

 

 

입력된 Promise 중 하나라도 거부되면 Promise.all()이 반환하는 Promise 역시 거부된다. 결과 Promise는 첫 번째로 거부되는 Promise가 생기는 즉시, 나머지 Promise가 아직 대기 중이더라도 거부된다. ES2020에서는 Promise.all()과 마찬가지로 Promise 배열을 입력 받아 Promise를 반환하는 Promise.allSettled()를 도입했다. Promise.allSettled()는 반환된 Promise를 절대 거부하지 않으며 입력 Promise 전체가 완료되기 전에는 이행되지 않는다. 이 Promise는 객체 배열로 해석되며 각 객체는 입력 Promise다. 각각의 반환된 객체에는 status 프로퍼티가 있고 그 값은 fulfilled 또는 rejected이다.

 

 

 

 

Promise.resolve() / Promise.reject()


함수의 작업 자체에는 비동기 작업이 전혀 없는데도 Promise를 반환하게 해야 할 때도 있다. 이럴 때는 Promise.resolve()Promise.reject()를 사용하면 된다. Promise.resolve()는 인자 하나만 받고 즉시, 그러나 비동기적으로 그 값으로 이행되는 Promise를 반환한다. 마찬가지로 Promise.reject()도 인자 하나만 받고 그 이유로 거부되는 Promise를 반환한다.

 

Promise의 해석과 이행은 다르다. Promise.resolve()를 호출할 때는 일반적으로 이행 값을 전달해서 그 값으로 거의 즉시 이행되는 Promise를 만든다. 하지만 이 메서드의 이름은 Promise.fulfill()이 아니다. Promise p1을 Promise.resolve()에 전달하면 새 Promise p2가 반환된다. p2는 즉시 해석되지만 p1이 이행/거부 되기 전에는 이행/거부 되지 않는다.

 

 

Promise() 생성자


Promise를 반환하는 함수(.then() 과 같은 메서드)를 출발점으로 사용할 수 없는 상황에서 Promise를 반환하는 함수를 만들려면 어떻게 해야 할까? 이런 경우에는 Promise() 생성자를 사용해서 완전히 제어할 수 있는 새 Promise 객체를 생성하면 된다.

 

Promise() 생성자를 호출할 때는 관습적으로 매개변수 resolve, reject를 입력한다. 생성자는 함수를 동기적으로 호출하면서 resolve와 reject 매개변수에 대응할 함수 인자를 전달한다. 호출이 끝나면 Promise() 생성자는 새로 생성된 Promise를 반환한다. 반환된 Promise는 생성자에 전달한 함수의 제어를 받는다.

 

function wait(duration) {
  // 새 Promise를 생성해 반환한다.
  return new Promise((resolve, reject) => {   // Promise를 제어한다.
    // 인자가 유효하지 않으면 Promise를 거부한다.
    if (duration < 0) {
      reject(new Error("Time travel not yet implemented"));
    }
    // 인자가 유효하면 비동기적으로 대기했다가 Promise를 해석한다.
    // setTimeout은 resolve()를 인자 없이 호출하므로
    // 이 Promise는 정의되지 않은 값으로 이행된다.
    setTimeout(resolve, duration);
  })
}

 

resolve()에 Promise를 전달하면 반환된 Promise는 새로운 Promise로 해석된다.

Promise가 아닌 값을 전달하면 반환된 Promise는 그 값으로 이행된다.

 

fetch() API가 내장되지 않은 노드에서 getJSON() 함수를 사용할 수 있도록 코드를 작성해보자.

 

const http = require("http");

function getJSON(url) {
  // 새 Promsie를 생성해서 반환한다.
  return new Promise((resolve, reject) => {
    // 지정된 URL에 HTTP GET 요청을 보낸다.
    request = http.get(url, (response) => {
      // 응답이 시작되면 호출된다.
      // HTTP 상태가 OK가 아니라면 Promise를 거부한다.
      if (response.statuseCode !== 200) {
        reject(new Error(`HTTP status ${response.statusCode}`));
        response.resume(); // 메모리 누수를 방지한다.
      }
      // 응답 헤더가 잘못된 경우에도 거부한다.
      else if (response.headers["content-type"] !== "application/json") {
        reject(new Error("Invalid content-type"));
      } else {
        // 상태가 OK라면 응답 바디를 읽을 이벤트를 등록한다.
        let body = "";
        response.setEncoding("utf-8");
        response.on("data", (chunk) => {
          body += chunk;
        });
        response.on("end", () => {
          // 응답 바디가 완료되면 분석을 시도한다.
          try {
            let paresd = JSON.parse(body);
            // 분석에 성공했다면 Promise를 이행한다.
            resolve(parsed);
          } catch (e) {
            // 분석에 실패하면 Promise를 거부한다.
            reject(e);
          }
        });
      }
    });
    // 응답 자체가 없을 때도 Promise를 거부한다.
    request.on("error", (error) => {
      reject(error);
    });
  });
}

 

Promise Sequence***


Promise.all()을 사용하면 Promise를 원하는 만큼 병렬로 실행할 수 있다. Promise 체인은 일정 숫자의 연속된 Promise를 쉽게 처리할 수 있다. 하지만 임의의 숫자의 연속된 Promise를 쉽게 처리할 수 있다. 하지만 임의의 숫자의 Promise를 순서대로 실행하기는 쉽지 않다. 가져올 URL 배열이 있는데 네트워크 부하를 피하기 위해 한 번에 하나씩만 가져오고 싶다고 하자. 배열 길이가 정해져 있지 않고 콘텐츠의 양도 알 수 없다면 Promise 체인을 미리 만들 수 없으므로 다음과 같이 동적으로 만들어야 한다.

 

function fetchSequentially(urls) {
  // URL 바디를 가져와서 여기 저장한다.
  const bodies = [];

  // 이 함수가 반환하는 Promise는 바디 하나를 가져온다.
  function fetchOne(url) {
    return fetch(url)
      .then((res) => res.text())
      .then((body) => {
        // 바디를 배열에 저장하고 의도적으로 반환 값을 생략한다.
        // undefined를 반환한다.
        bodies.push(body);
      });
  }

  // 즉시 undefined로 이행되는 Promise로 시작한다.
  let p = Promise.resolve(undefined);

  // 원하는 URL을 순회하면서 길이가 정해지지 않은 Promise Chain을 만들고
  // Chain 각 단계에서 URL을 하나씩 가져온다.
  for (let url of urls) {
    p = p.then(() => fetchOne(url));
  }

  // Chain의 마지막 Promise가 이행되면 bodies 배열도 준비된다.
  // bodies 배열을 처리할 Promise를 반환한다.
  // 에러가 호출자에게 전달되길 원하므로 에러 핸들러는 만들지 않는다.
  return p.then(() => bodies);
}

 

 

 

// 이 함수는 입력 값 배열과 함께 'promiseMaker' 함수를 받는다.
// 배열에 포함된 값 x에 대해 promiseMaker(x)는
// 다른 값으로 이행되는 Promise를 반환해야 한다.
// 이 함수는 계산된 출력 값 배열로 이행되는 Promise를 반환한다.

// 하지만 promiseSequence()는 Promise를 한꺼번에 생성해서 병렬로 실행하지 않고
// 한 번에 Promise 하나만 실행하며 이전 Promise가 이행되기 전에는
// promiseMaker()를 호출하지 않는다.

function promiseSequence(inputs, promiseMaker) {
  // 배열의 수정 가능한 비공개 사본을 만든다.
  inputs = [...inputs];

  // Promise 콜백으로 사용할 함수. 반쯤은 재귀적이다.
  function handleNextInput(outputs) {
    if (inputs.length === 0) {
      // 입력이 더 없으면 출력 배열을 반환하면서
      // 이 Promise와 함께, 해석됐지만 미이행된 이전 Promise를 모두 이행한다.
      return outputs;
    } else {
      // 처리할 입력이 남았다면 Promise 객체를 반환한다.
      // 이 객체는 현재 Promise를 새 Promise의 미래 값으로 해석한다.
      let nextInput = inputs.shift(); // 다음 입력 값을 가져온다.
      return promiseMaker(nextInput)
        .then(output => outputs.concat(output))
        .then(handleNextInput); 
    }
  }
  // 빈 배열로 이행되는 Promise로 시작하고 위 함수를 콜백으로 사용한다.
  return Promise.resolve([]).then(handleNextInput);
}

 

 

async  / await


async와 await는 Promise 사용을 극적으로 단순화하며 Promise 기반의 비동기 코드를 동기적 코드처럼 작성할 수 있게 해준다.

비동기 코드는 일반적인 동기적 코드와 같은 방법으로 값을 반환하거나 예외를 발생시킬 수 없다. Promise를 설계한 이유가 그런 차이 때문이다. 이행된 Promise의 값은 동기적 함수의 반환 값과 같다. 거부된 Promise의 값은 동기적 함수에서 일으킨 에러와 같다.

 

async와 await는 효율적인 Promise 기반 코드에서 Promise를 숨겨, (비효율적이지만) 읽기 쉽고 이해하기 쉬운 동기적 코드와 비슷하게 만든다.

 

await 표현식

 

await 키워드는 Promise를 받아서 반환 값이나 예외로 바꾼다. Promise 객체 p가 있을 때 표현식 await p는 p가 완료될 때까지 대기한다. p가 이행되면 await p의 값은 p가 이행된 값이다. p가 거부되면 await p 표현식은 p와 같은 값을 예외로 일으킨다. await는 보통 Promise를 할당한 변수와 함께 사용하기보다는 다음과 같이 Promise를 반환하는 함수와 함께 사용한다.

 

let response = await fetch("/api/user/profile");
let profile = await response.json();

 

await 키워드는 프로그램 흐름을 차단하지 않으며 지정된 Promise가 완료되기 전에는 말 그대로 '아무 일도 하지 않는다'는 점을 이해하는 것이 중요하다. 코드는 여전히 비동기적이다. await는 그 사실이 드러나지 않게 할 뿐이다. 따라서 await를 사용하는 코드는 항상 비동기적이다.

 

async 함수

 

await를 사용하는 코드는 항상 비동기적이므로 중요한 규칙이 있다. await 키워드는 'async 키워드로 선언된 함수 안에서만' 사용할 수 있다.

 

async function getHighScore() {
    let response = await fetch("/api/user/profile");
    let profile = await response.json();
    return profile.highScore;
}

 

함수를 async로 선언하면 설령 함수 바디에 Promise 관련 코드가 전혀 없더라도 반환 값은 Promise다. async 함수가 정상적으로 완료되면 함수의 실제 반환 값인 Promise 객체는 함수 바디가 반환하는 값으로 해석된다. async가 예외를 일으키면 반환된 Promise 객체 역시 그 예외와 함께 거부된다.

 

getHighScore() 함수는 async로 선언됐으므로 Promise를 반환한다. Promise를 반환하는 함수이므로 그 안에 await 키워드를 사용할 수 있다.

 

displayHighScore(await getHighScore());

 

함수의 최상위 레벨에 있거나 async가 아닌 함수 안에 있다면 await는 사용할 수 없고 반환된 Promise를 일반적인 방법으로 처리해야 한다.

 

 

여러 개의 Promise 대기

 

const getJSON = async (url) => {
  let response = await fetch(url);
  let body = await response.json();
  return body;
}

// 값 두개를 가져온다.
let value1 = await getJSON(url1);
let value2 = await getJSON(url2);

 

이 코드에는 불필요하게 연속적이라는 문제가 있다. 두 번째 URL을 가져오는 작업은 첫 번째 URL을 가져오는 작업이 완료되기 전에는 시작할 수 없다. 두 번째 URL이 첫 번째 URL의 값과 관계가 없다면 두 값을 동시에 가져올 수 있어야 한다. async 함수는 Promise에 기반하므로 Promise.all()을 사용할 수 있다.

 

let [value1, value2] = await Promise.all([getJSON(url1), getJSON(url2)]);

 

async 동작 방식

 

async function f(x) { /* 바디 */}

 

이 함수를 원래 함수를 감싸는 Promise를 반환하는 함수라고 생각해보자.

 

function f(x) {
  return new Promise(function (resolve, reject) {
    try {
      resolve(
        (function (x) {
          /* 바디 */
        })(x)
      );
    } catch (e) {
      reject(e);
    }
  });
}

 

 

비동기 순회


 

앞서 언급했듯이 Promise 하나로는 (클릭과 같은) 비동기 이벤트 시퀀스에 대응할 수 없으므로, async 함수와 await 문으로도 비동기 이벤트 시퀀스에 대응할 수 없다. 하지만 ES2018에서 해결책이 나왔다.

 

for / await 루프

 

코드는 for, forEach의 비동기 작업이 끝나는 것을 대기하지 않음

for / await 루프를 사용해서 동기적 처리가 가능하다.

 

const fs = require("fs");

async function parseFile(filename) {
  let stream = fs.createReadStream(filename, { encoding: "utf-8" });
  for await (let chunk of stream) {
    parseChunk(chunk);  // parseChunk()는 다른 곳에서 선언되었다고 가정한다.
  }
}

 

for / await 루프 역시 Promise 기반이다. 비동기 이터레이터는 Promise를 생성하고, for / await 루프는 Promise가 이행되길 기다렸다가 이행된 값을 루프 변수에 할당하고 루프 바디를 실행한다. 그리고 이터레이터에서 다른 Promise를 받아 새 Promise가 이행되길 기다렸다가 다시 시작한다.

 

URL 배열이 있다고 하자.

 

const urls = [url1, url2, url3];

 

각 URL에 fetch()를 호출해 Promise 배열을 만들 수 있다.

 

const promises = urls.map(url => fetch(url));

 

Promise.all()을 사용하면 배열에 포함된 Promise가 모두 이행될 때까지 기다릴 수 있다고 언급했다. 하지만 첫 번째 URL을 가져오는 즉시 결과가 필요해서 다른 URL을 기다릴 수 없다고 하자. (물론 첫 번째 작업이 다른 작업보다 오래 걸릴 수 있으므로 Promise.all()을 사용하는 것보다 빠르다는 보장은 없다.) 배열은 이터러블이므로 Promise 배열도 일반적인 for/of 루프로 순회할 수 있다.

 

for(const promise of promises) {
  response = await promise;
  handle(response);
}

 

이 예제는 일반적인 for/of 루프와 일반적인 이터레이터를 사용했다 하지만 이 이터레이터는 Promise를 반환하므로 for/await를 사용하면 코드가 좀 더 단순해진다.

 

for await (const promise of promises) {
  handle(response);
}

 

두 예제 다 async로 선언된 함수 안에서만 동작한다.

 

 

비동기 이터레이터


비동기 이터레이터는 일반적인 이터레이터와 거의 비슷하지만 두 가지 중요한 차이점이 있다.

 

우선 비동기 이터러블 객체에는 심벌 이름 Symbol.asyncIterator를 가진 메서드가 있다. for/await는 Symbol.iterator 메서드보다 Symbol.asyncIterator 메서드를 먼저 시도한다.

 

두 번째로, 비동기 이터레이터의 next() 메서드는 직접적으로 순회 결과 객체를 반환하는 것이 아니라 순회 결과 객체로 해석되는 Promise를 반환한다.

 

진정한 비동기 이터레이터는 순회 결과 객체로 Promise를 반환하며 value와 done 프로퍼티가 모두 비동기다. 비동기 이터레이터에서는 순회가 언제 끝나는지 역시 비동기적으로 판단할 수 있다.

 

비동기 제너레이터


async로 비동기 제너레이터 함수를 선언한다. 비동기 제너레이터는 비동기 함수의 특징과 제너레이터의 특징을 모두 가진다. 일반적인 비동기 함수와 마찬가지로 그 안에서 await를 사용할 수 있고, 일반적인 제너레이터와 마찬가지로 yield를 사용할 수 있다. yield로 전달하는 값은 Promise가 된다. 문법은 async function*을 사용한다.

 

다음은 비동기 제너레이터와 for/await 루프를 사용해, setInterval() 콜백 함수를 쓰지 않고 일정 주기로 코드를 실행하는 예제다.

 

// await를 사용할 수 있도록 setTimeout()을 감싸는 Promise 기반 래퍼 함수다.
// 밀리초 단위로 지정된 시간이 지나면 이행되는 Promise를 반환한다.
function elapsedTime(ms) {
  return new Promise(resolve => setTimeout(resolve, ms));
}

// 지정된 횟수만큼(무한히 반복할 수도 있다) 지정된 시간마다
// 카운터를 증가시켜 전달하는 비동기 제너레이터 함수
async function* clock(interval, max=Infinity) {
  for(let count = 1; count <= max; count++) { // 일반적인 for 루프
    await elapsedTime(interval);              // 지정된 시간만큼 대기하고
    yield count;                              // 카운터를 전달한다.
  }
}

// 비동기 제너레이터 for/await를 사용하는 테스트 함수
async function test() {
  for await (let tick of clock(300, 100)) {
    console.log(tick);
  }
}

 

 

비동기 이터레이터 구현


다음 코드는 이전 예제의 clock() 함수를 제너레이터를 사용하지 않고 비동기 이터러블 객체만 반환하도록 고쳐 쓴 것이다. 이 예제의 next() 메서드는 명시적으로 Promise를 반환하지 않는다. 그저 next()를 비동기로 선언하기만 했다.

 

function clock(interval, max=Infinity) {
  // await와 함께 사용할 수 있는 setTimeout의 Promise 버전
  // 인터벌 대신 고정된 시간을 사용한다.
  function until(time) {
    return new Promise(resolve => setTimeout(resolve, time - Date.now()));
  }

  // 비동기 이터러블 객체를 반환한다.
  return {
    startTime: Date.now(),        // 언제 시작했는지 기억한다.
    count: 1,                     // 현재 단계를 기억한다.
    async next() {                // next() 메서드가 있으므로 이터레이터
      if (this.count > max) {     // 끝났다면
        return { done: true };    // done: true
      }
      // 다음 단계를 언제 시작할지 파악한다.
      let targetTime = this.startTime + this.count * interval;
      // 그 시간까지 기다린다.
      await until(targetTime);
      // 순회 결과 객체에 있는 값을 반환한다.
      return { value: this.count++ };
    },
    // 이 메서드가 있으니 이 이터레이터 객체 역시 이터러블이다.
    [Symbol.asyncIterator]() { return this; }
  }
}

async function test() {
  for await (let tick of clock(300, 100)) {
    console.log(tick);
  }
}

test();

// 1
// 2
// 3
// ...
// 100

 

새로 만든 코드에서는 각 단계를 시작해야 할 정확한 시간을 기억하고 그 시간에서 현재 시간을 빼서 setTimeout()에 전달할 인터벌을 계산한다. for/await 루프는 항상 현 단계에서 반환한 Promise가 이행될 때까지 기다렸다가 다음 단계를 시작한다. 하지만 for/await 루프 없이 비동기 이터레이터를 사용하면 언제든 next() 메서드를 호출할 수 있게 된다. clock()의 제너레이터 버전에서 만약 next() 메서드를 연속으로 호출했다면 거의 동시에 세 개의 Promise가 이행될 텐데 이것은 의도와는 다른 결과일 것이다. 위에서 만든 이터레이터 버전에는 그런 문제가 없다.

 

비동기 이터레이터의 장점은 비동기 이벤트나 데이터 스트림을 나타낼 수 있다는 점이다. 위의 예시는 단순했지만 다른 비동기 요인, 예를 들어 이벤트 핸들러 같은 것을 다뤄야 한다면 비동기 이터레이터는 훨씬 만들기 어려워질 것이다. 일반적으로 하나의 이벤트 핸들러 함수가 이벤트에 대응하는데, 이터레이터의 next() 메서드는 호출될 때마다 반드시 별개의 Promise 객체를 반환해야 하며 첫 번째 Promise가 해석되기 전에 next()가 여러 번 호출될 수 있기 때문이다. 따라서 비동기 이벤트가 일어나는 순서대로 Promise를 해석할 수 있도록 내부에 큐를 유지할 수 있어야 한다. 이런 Promise 큐 기능을 AsyncQueue 클래스에 캡슐화할 수 있다면 이 클래스를 활용해 비동기 이터레이터를 훨씬 쉽게 만들 수 있다.

 

AsyncQueue 클래스에는 큐 클래스에 있을 법한 enqueue()dequeue() 메서드가 있다. dequeue() 메서드는 실제 값이 아니라 Promise를 반환하므로 enqueue()를 호출하기 전에 dequeue()를 호출해도 문제는 발생하지 않는다. 또한 AsyncQueue 클래스는 비동기 이터레이터이며 새 값이 비동기적으로 큐에 추가될 때마다 바디를 실행하는 for/await 루프와 함께 사용할 의도로 만들어졌다. AsyncQueue에는 close() 메서드가 있다. close()를 호출하면 큐에 값을 더는 추가할 수 없다. 닫힌 큐가 비었다면 for/await 루프는 순회를 종료한다.

 

AsyncQueue는 async나 await를 사용하지 않고 직접 Promise를 사용한다.

 

/**
 * 비동기 이터러블 큐 클래스. enqueue()로 값을 추가하고 dequeue()로 제거한다.
 * dequeue()는 Promise를 반환하므로 값을 큐에 추가하기도 전에 제거할 수 있다.
 * 이 클래스에는 [Symbol.asyncIterator]와 next()가 있으므로
 * for/await 루프와 함께 사용할 수 있다.
 * (이 루프는 close()를 호출하기 전에는 끝나지 않는다.)
 */

class AsyncQueue {
  constructor() {
    // 큐에 추가된 값을 여기 저장한다.
    this.values = [];
    // 대응하는 값이 큐에 추가되기 전에 Promise를 큐에서 제거하면
    // resolve 메서드가 여기 저장된다.
    this.resolvers = [];
    // 클래스를 닫으면 값을 더 이상 큐에 추가할 수 없고 이행되지 않은 Promise는
    // 반환되지 않는다.
    this.closed = false;
  }

  enqueue(value) {
    if (this.closed) {
      throw new Error("AsyncQueue closed");
    }
    if (this.resolvers.length > 0) {
      // 이 값이 이미 Promise가 됐다면 그 Promise를 해석한다.
      const resolve = this.resolvers.shift();
      resolve(value);
    }
    else {
      // Promise가 되지 않았다면 큐에 추가한다.
      this.values.push(value);
    }
  }

  dequeue() {
    if (this.values.length > 0) {
      // 큐에 값이 있으면 해석된 Promise를 반환한다.
      const value = this.values.shift();
      return Promise.resolve(value);
    }
    else if (this.closed) {
      // 큐에 값이 없고 인스턴스가 닫혔으면 '스트림의 끝(EOS)' 마커로 해석된
      // Promise를 반환한다.
      return Promise.resolve(AsyncQueue.EOS);
    }
    else {
      // 그렇지 않다면 나중에 사용할 수 있도록 해석 함수를 큐에 추가하는
      // 해석되지 않은 Promise를 반환한다.
      return new Promise((resolve) => { this.resolvers.push(resolve); });
    }
  }

  close() {
    // 큐가 닫히면 더 이상 값을 큐에 추가할 수 없다.
    // 따라서 대기 중인 Promise를 모두 스트림의 끝 마커로 해석한다.
    while(this.resolvers.length > 0) {
      this.resolvers.shift()(AsyncQueue.EOS);
    }
    this.closed = true;
  }

  // 이 클래스를 비동기 이터러블로 만드는 메서드를 정의한다.
  [Symbol.asyncIterator]() { return this; }

  // 이 클래스를 비동기 이터러블로 만드는 메서드를 정의한다.
  // dequeue() Promise는 값으로 해석되거나, 큐가 닫혔다면 EOS 감시자(sentinel)로
  // 해석된다. 여기서는 순회 결과 객체로 해석되는 Promise를 반환해야 한다.
  next() {
    return this.dequeue().then(value => (value === AsyncQueue.EOS)
                            ? { value: undefined, done: true }
                            : { value: value, done: false });
  }
}

// 큐를 닫았을 때 '스트림의 끝'을 표시할 수 있도록 dequeue()에서 반환하는 감시자 값
AsyncQueue.EOS = Symbol("end-of-stream");

 

AsyncQueue 클래스에서 비동기 순회의 기초를 만들어주므로 값을 비동기적으로 큐에 추가하기만 해도 비동기 이터레이터를 만들 수 있다. 다음은 AsyncQueue를 사용해 웹 브라우저 이벤트를 for/await 루프에서 처리할 수 있는 스트림을 만드는 예제다.

 

// 지정된 문서의 지정된 타입의 이벤트를 AsyncQueue 객체에 추가하고
// 이벤트 스트림으로 사용할 큐를 반환한다.
function eventStream(elt, type) {
  const q = new AsyncQueue();                   // 큐를 생성한다.
  elt.addEventListener(type, e=>q.enqueue(e));  // 이벤트를 큐에 추가한다.
  return q;
}

async function handleKeys() {
  // keypress 이벤트 스트림을 만들어 순회한다.
  for await (const event of eventStream(document, "keypress")) {
    console.log(event.key);
  }
}

 

 

1. 이벤트루프는 콜스택이 비어있을 때만 돈다.

2. Microtasks queue가 Tasks queue보다 우선이다.

3. Promise가 .then()으로 또다른 Promise를 반환하면 그 체인이 끝날 때까지 나머지 task는 멈춘다.

4. Tasks queue 는 여러 개의 콜백을 갖고 있어도 하나씩만 돌기 때문에 중간에 Promise가 끼면 다시 Microtasks queue 비워질 때까지 나머지는 멈춘다.

 

**setTimeout 은 지정해준 시간이 지나야 Tasks queue에 들어간다.