자바스크립트 완벽 가이드(JavaScript: The Definitive Guide 7/E) - 13장 : 비동기 자바스크립트

SangHyun Park·2023년 11월 1일
0

JavaScript

목록 보기
7/9

비동기적의 뜻은 데이터가 들어오거나 어떤 이벤트가 일어날 때까지 계산을 멈추고 대기하는 일이 잦다는 뜻. 웹 브라우저의 JS 프로그램은 일반적으로 이벤트 주도적이다. 즉, 프로그램이 실제로 무언가를 실행하기 전에 사용자가 뭔가 클릭하거나 탭하기를 기다린다.

일반적으로 서버는 네트워크를 통해 클라이언트 요청이 들어온 후에야 작업을 시작한다.

ES6에서 도입한 Promise, ES7 async와 await 를 도입했다. ES20218에서는 비동기 이터레이터와 for/await 루프를 도입 해서 동기적인 것처럼 보이는 단순한 루프에서 비동기 이벤트 스트림을 다룰 수 있게 된다.

프라미스, async, await, for/await를 이해하기 위해서 클라이언트 사이드, 서버 사이드를 보면서 웹 브라우저와 노드의 비동기적 기능에 대해 이해 해야한다.

13.1 콜백과 비동기 프로그래밍

  • JS에서 가장 기본적인 비동기 프로그래밍은 콜백을 통해 이뤄진다.
  • 콜백은 다른 함수에 전달하는 함수
  • 콜백을 전달받은 함수는 어떤 조건을 만족하거나 어떤(비동기) 이벤트가 일어나면 여러분이 제공한 함수를 호출(콜백) 한다.

13.1.1 타이머

  • 일정 시간이 지나면 코드를 실행하는 것도 단순한 비동기 프로그램 유형 중 하나.
setTimeout(checkForUpdates, 60000);
  • 위 코드는 setTimeout()을 호출하고 1분이 지나면 checkForUpdates() 함수를 호출한다.
  • checkForUpdates()는 여러분이 프로그램에서 정의한 콜백 함수이며 setTimeout()은 콜백 함수를 등록하고 호출할 비동기 조건을 지정하기 위해 호출 하는 함수.
  • setTimeout()은 지정된 콜백 함수를 한 번 호출 하고 나서 잊어버린다. 반복적으로 실행하는 함수가 필요하다면 setInterval()을 사용.
//checkForUpdates를 1분 뒤에 호출하고 1분마다 다시 호출
let updateIntervalId = setInterval(checkForUpdates, 60000);

// setInterval()이 반환하는 값을 clearInterval()에 넘겨 반복 호출을 중단할 수 있따.
function stopCheckingForUpdates() {
  clearInterval(updateIntervalId);
}

13.1.2 이벤트

  • 클라이언트 사이드 JS 프로그램은 거의 대부분 이벤트 주도적이다.
  • 사용자가 뭔가 하길 기다렸다가 그 행동에 반응.
  • 웹 브라우저는 지정된 이벤트가 일어날 때마다 함수를 호출하고 이런 콜백 함수를 이벤트 핸들러, 이벤트 리스터라고 부르며 addEventListener()를 통해 등록한다.
let okey = document.querySelector("#confirmUpdateDialog button.okay");

// 사용자가 버튼을 클릭하며 호출될 콜백 함수를 등록한다.
okey.addEventListener("click", applyUpdate);
  • 위 예제의 applyUpdate()는 어딘가에 만들었다고 가정한 가상의 콜백 함수.

13.1.3 네트워크 이벤트

  • 네트워크 요청 역시 비동기 유형 중 하나.
function getCurrentVersionNumber(versionCallback) {// 콜백을 인자로 받는다.
  // 백엔드의 버전 API에 HTTP 요청은 한다.

  let request = new XMLHttpRequest();
  request.open("GET", "http://~");
  request.send();

  // 응답을 받았을 때 호출할 콜백을 등록

  request.onload = function () {
    if (request.status === 200) {
      // HTTP 상태가 Ok 이면 버전 번호를 가져와서 콜백을 호출
      let currentVersion = parseFloat(request.responseText);
      versionCallback(null, currentVersion);
    } else {
      versionCallback(this.response.statusText, null);
    }
  };

  // 네트워크 에러가 생겼을 때 호출할 다른 콜백을 등록한다.
  request.onerror = request.ontimeout = function (e) {
    versionCallback(e.type, null);
  };
}
  • 클라이언트 사이드 JS 코드는 XMLHttpRequest 클래스와 콜백 함수를 사용해 HTTP 요청을 보내고 서버의 응답을 비동기적으로 처리할 수있다. 최신에는 fetch() API를 사용
  • getCurrentVersionNumber() 함수는 비동기로 요청을 보내기 때문에 현재 버전 번호를 동기적으로 반환할 수가 없다. 대신 호출자는 결과를 받거나 에러가 일어나면 호출될 콜백 함수를 전달한다. 이 콜백 함수는 인자 두개를 받는다. XMLHttpRequest가 정확히 동작하면 getCurrentVersionNumber() 는 콜백을 호출 하면서 첫 번째 인자로 null, 두 번째는 버전 번호를 전달. 에러가 일어나면 반대.

13.1.4 노드의 콜백과 이벤트

  • 서버 사이드 JS 환경인 노드는 비동기적으로 만들어져 있으며 많은 API가 콜백과 이벤트를 사용한다. 예를 들어 파일 콘텐츠를 읽는 기본 API도 비동기적이며 파일 콘텐츠를 읽으면 콜백 함수를 호출한다.
const fs = require("fs");

let options = {
  // 기본 옵션
};

fs.readFinle("config.json", "utf-8", (err, text) => {
  if (err) {
    // 에러가 있으면 경고를 표시하고 계속 진행
    console.warn("Err", err);
  } else {
    // 에러가 없으면 파일 콘텐츠를 분석하고 옵션 객체에 할당
    Object.assign(options, JSON.parse(text));
  }

  // 어느 쪽이든 이제 프로그램을 실행할 수 있다.
});
  • 다음 함수는 노드에서 URL에 HTTP 요청을 보내는 방법이다. 이벤트 리스너로 처리하는 비동기 코드 계층이 두 개 있다. 노드는 addEventListener() 대신 on() 메서드를 사용해 이벤트 리스너를 등록
const https = require("https");

//URL의 텍스트 콘텐츠를 읽고 비동기적으로 콜백에 전달한다.
function getText(url, callback) {
  // URL에 HTTP GET 요청을 시작
  request = https.get(url);

  request.on("response", (response) => {
    // 응답 이벤트가 있다는 것은 응답 헤더를 받았다는 의미
    let httpStatus = response.statusCode;

    // HTTP 응답의 바디는 아직 받지 못했으므로
    // 바디를 받았을 때 호출할 이벤트 핸들러를 등록

    response.setEncoding("utf-8");
    let body = "";

    // 바디의 텍스트 덩어리를 사용할 수 있게 되면 이 이벤트 핸들러를 호출
    response.on("data", (chunk) => {
      body += chunk;
    });

    // 응답이 완료되면 이 이벤트 핸들러르 호출
    response.on("end", () => {
      if (httpStatus === 200) {
        callback(null, body);
      } else {
        callback(httpStatus, null);
      }
    });
  });

  // 저수준 네트워크 에러를 처리할 이벤트 핸들러도 등록
  request.on("error", (err) => {
    callback(err, null);
  });
}

13.2 프라미스

비동기 프로그래밍을 단순화하도록 설계된 코어 기능인 프라미스.

  • 프라미스는 비동기 작업의 결과를 나타내는 객체
  • 준비가 되었을 수도 않았을 수도 있는데, 프라미스 API는 이를 의도적으로 막연하게 표현한다.
  • 프라미스를 단순하게 말하자면 콜백을 사용하는 새로운 방법.
  • 콜백 기반 비동기 프로그래밍의 심각한 문제는 콜백 안에 콜백이 있는 콜백 지옥이 발생한다.
  • 프라미스는 이런 중첩된 콜백을 선형에 가까운 프라미스 체인을 바꿔 주어 읽고 이해하기 편게 만들어준다.
  • 또한 콜백의 다른 문제는 에러 처리가 어렵다.

프라미스는 비동기 작업 하나가 앞으로 어떤 결과를 보일지 나타낸다.

  • HTML 버튼의 click 이벤트 핸들러에는 일반적으로 프라미스를 사용하지 않는다. 사용자가 버튼을 한 번 클릭하면 그 이후에는 대응할 수 없기 때문이다.

13.2.1 프라미스 사용.

앞선 getText()함수를 변형해서 JSON인 HTTP 응답 바디를 분석하고 콜백 인자를 받는 대신 프라미스를 반환하는 getJSON() 함수를 만든다.

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

getJSON()은 URL에 비동기 HTTP 요청을 보내고 응답을 대기하면서 프라미스 객체를 반환한다. 이 객체에는 then() 인스턴스 메서드가 있다. 콜백 함수는 getJSON()에 직접 전달하지 않고 then() 메서드에 전달한다. HTTP 응답이 도착하면 응답 바디를 JSON()으로 분석하고 분석된 값을 then()에 전달한 함수에 전달

  • 프라미스를 반환하는 함수, 프라미스 결과를 사용하는 함수 모두 이름을 동사 형태로 짓는 관행이 있다.
// 사용자 프로필을 표시하는 함수
function displayUserProfile(profile) {
  
}

getJSON('/api/user/profile').then(displayUserProfile)

프라미스의 예외 처리

getJSON("/api/user/profile").then(displayUserProfile, handleProfileError);
  • then() 메서드에 두 번째 함수를 전달해 에러를 처리할 수 있다.
  • 그치만 이 방법은 잘 사용되지 않는다.

catch() 이용

getJSON("/api/user/profile").then(displayUserProfile).catch(handleProfileError);

13.2.2 프라미스 체인

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

fetch(documentURL)
  .then((response) => response.json())
  .then((document) => {
    return render(document);
  })
  .then((rendered) => cacheInDatabase(rendered))
  .catch((error) => handle(error));
  • XMLHttpRequrest 대안으로 나온 프라미스 기반 Fetch API는 fetch() 함수로 URL을 받고 프라미스를 반환한다.

fetch().then().then() 같은 표현식 하나에 메서드를 하나 이상 호출하는 것을 메서드 체인이라 부른다.

  • .then()을 체인을 호출할 때는 프라미스 객체 하나에 여러 개의 콜백을 등록하지 않는다. 대신 then() 메서드를 호출할 때마다 새 프라미스 객체를 반환한다.
  • 새 프라미스 객체는 then()에 전달된 함수가 완료되기 전에는 이행되지 않는다.

13.2.3 프라미스 해석

  • fetch()는 프라미스 객체를 반환하며 프라미스가 이행될 때 등록된 콜백 함수에 응답 객체를 전달한다. 이 응답 객체는 .text(), .json(), 기타 HTTP 응답 바디를 다양한 형태로 요청하는 메서드가 존재. 하지만 바디가 아직 도착하지 않았으므로 이 메서드들도 반드시 프라미스 객체를 반환해야한다.
function c1(response) {
  // 콜백1
  let p4 = response.json();
  return p4; // 프라미스 4를 반환
}

function c2(profile) {
  // 콜백2
  displayUserProfile(profile);
}

let p1 = fetch("/api/user/profile"); // 프라미스 1, 작업 1
let p2 = p1.then(c1); // 프라미스 2, 작업 2
let p3 = p2.then(c2); // 프라미스 3, 작업 3
  • p1이 이행되면 c1이 호출되고 작업 2가 시작
  • p2가 이행되면 c2가 호출되고 작업 3가 시작
  • 하지만 c1이 호출 될 때 작업 2가 시작된다는 말이 c1이 반환될 때 작업 2가 반드시 끝나야 한다는 의미가 아니다.
  • 어쨌든 프라미스는 비동기 작업을 관리하도록 설계되었으며 작업 2가 비동기라면 콜백이 반환되는 시점에 완료되지 않을 수도 있다.

Untitled

13.2.4 프라미스와 에러

  • 프라미스 관련 에러는 일반적으로 프라미스 체인에 .catch() 메서드를 추가하는 식으로 처리
  • 비동기 코드에서는 처리되지 않는 예외가 아무런 경고 없이 사라지는 경우가 많아 디버그하기 매우 어렵다. 그렇기에 예외처리를 해줘야 한다.

catch와 finally 메서드

프라미스의 .catch() 메서드는 null을 첫 번째 인자로, 에러 처리 콜백을 두 번째 인자로 전달하여 .then()을 호출하는 것을 축약한 형태.

p.then(null, c);

p.catch(c);

프라미스 체인에 .finally()를 추가하면 호출한 프라미스가 완료될 때 .finally()가 호출 된다.

프라미스 이행 여부와 관계없이 파일이나 네트워크 연결을 닫는 것과 같은 정리 작업을 해야한다면 .finally() 콜백이 이상적.

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

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

    // 여기 도달했다면 2xx 상태와 함께 JSON 콘텐츠 타입을 받은 것 이므로
    // 응답 바디를 JSON 객체로 파싱하는 프라미스를 반환해도 안전.
    return response.json()
  })
  .then(profile => {
    if (profile) {
      displayUserProfile(profile)
    } else {
      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.log(e)
    }
  
  })

주의사항

.catch((e) => wait(500).then(queryDatabase))

.catch((e) => {wait(500).then(queryDatabase)});

위에 코드에서 첫번째 코드는 함수 바디가 표현식 하나이므로 표현식을 감싼 괄호를 생략할 수 있다. 표현식의 값이 함수의 반환 값이므로 정확한코드이다.

하지만 두번째 코드는 첫 번째 코드와 비슷해 보이지만 괄호로 감싼 형태이므로 어떤 값도 반환하지 않는다.

이를 주의하여 코드를 짜야한다.

13.2.5 병렬 프라미스

  • 때로는 여러 개의 비동기 작업을 병렬로 실행해야 한다.
  • Promise.all()는 프라미스의 병렬 실행을 담당한다. 프라미스 객체의 배열을 받고 프라미스를 반환하며 하나라도 거부되면 반환된 프라미스 역시 거부된다.
const urls = [
  /* 0개 이상의 URL */
];
// 프라미스 객체의 배열로 변환
promises = urls.map((url) => fetch(url).then((r) => r.text()));

Promise.all(promises)
  .then((bodies) => {
    /* 문자열 배열을 사용할 코드 */
  })
  .catch((e) => console.error(e));

13.2.6 프라미스 생성

다른 프라미스에 기반한 프라미스

  • 프라미스를 반환하는 함수가 이미 있다면 이를 기초로 프라미스를 반환하는 함수를 쉽게 만들 수 있다. 프라미스가 있다면 언제든 .then()을 호출해 프라미스를 만들고 반환할 수 있다.
function getJSON(url) {
  return fetch(url).then((response) => response.json());
}

function getHighScore() {
  return getJSON('/api/user/profile').then(profile => profile.highScore);
}

동기적인 값을 기반으로 하는 프라미스

  • 함수의 작업 자체에는 비동기 작업이 전혀 없는데도 프라미스를 반환하게 해야할 때도 있다.
  • 이럴 때는 정적 메서드 Promise.resolve()와 Promise.reject()를 사용.

처음부터 만드는 프라미스

  • 프라미스를 반환하는 다른 함수를 출발점으로 사용할 수 없는 상황에서 프라미스를 반환하는 함수를 만들기 위해서는 Promise() 생성자를 사용.
function wait(duration) {
  return new Promise((resolve, reject) => {
    if (duration < 0) {
      reject(new Error("Time travel not yet implemented"));
    }

    // 인자가 유효하면 비동기적으로 대기했다가 프로미스를 해석.
    // setTimeout은 reslove()를 인자 없이 호출
    // 이 프라미스는 정의되지 않는 값으로 이행
    setTimeout(resolve, duration);
  });
}

13.2.7 프라미스 시퀀스

  • 임의의 숫자의 프라미스를 순서대로 실행하기 쉽지 않다.
  • 가져올 URL 배열이 있는데 네트워크 부하를 피하기 위해 한 번에 하나씩만 가져오고 싶다고 가정한다. 배열 길이가 정해져 있지 않고 콘텐츠도 알 수 없다면 프라미스 체인을 미리 만들 수 없으므로 동적으로 만들어야한다.
function fetchSequentially(urls) {
  const bodies = [];

  function fetchOne(url) {
    return fetch(url)
      .then((response) => response.text())
      .then((body) => {
        bodies.push(body);
      });
  }

  let p = Promise.resolve(undefined);

  for (url of urls) {
    p = p.then(() => fetchOne(url));
  }

  return p.then(() => bodies);
}

fetchSequentially(urls)
  .then((bodies) => {
    /* 문자열 배열을 사용할 코드 */
  })
  .catch((e) => console.error(e));
  • fetchSequentially() 함수는 반환가 동시에 이행되는 프라미스를 생성 초기 프라미스 바탕으로 선형의 긴 프라미스 체인을 만들어 체인의 마지막 프라미스를 반환.

다른 방법도 있다. 프라미스를 미리 생성하지 않고 각 프라미스 콜백이 다음 프라미스를 생성해 반환하게 할 수 있다.

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

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

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

  function handleNextInput(outputs) {
    if (inputs.length === 0) {
      // 입력이 더 없으면 출력 배열을 반환하면서
      // 이 프라미스와 함께, 해석됐지만 미이행된 이전 프라미스를 모두 이행한다.
      return outputs;
    } else {
      // 처리할 입력이 남았으면 프라미스 객체를 ㅂ나환한다.
      // 이 객체는 현재 프라미스를 새 프라미스의 미래 값으로 해석
      let nextInput = inputs.shift(); // 다음 입력 값을 가져온다.

      return promiseMaker(nextInput)
        .then((output) => outputs.concat(output))
        .then(handleNextInput);
    }
  }

  // 빈배열로 이행되는 프라미스로 시작하고 위 함수를 콜백으로 사용.
  return Promise.resolve([]).then(handleNextInput);
}

13.3 async와 await

  • 프라미스 사용을 단순화하며 프라미스 기반의 비동기 코드를 동기적 코드처럼 작성 할 수 있게된다.

13.3.1 await 표현식

  • await 키워드는 프라미스를 받아 반환 값이나 예외로 바꾼다. 프라미스 객체 p가 있을 때 표현식 await p는 p가 완료될 때 까지 대기. p가 이행되면 await p의 값은 p가 이행된 값. p가 거부되면 await p 표현식은 p와 같은 값을 예외로 일으킨다.
let response = await fetch("~")
let profile = await response.json()

13.3.2 async 함수

await는 async 키워드로 선언된 함수에만 사용할 수 있는 규칙이 있다.

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

displayHighScore(await getHighScore());

13.3.3 여러 개의 프라미스 대기

getJSON()함수를 async로 고쳐 썼을 떄

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

로 변환 된다.

let value1 = await getJSON(url1);
let value2 = await getJSON(url2);

JSON 값 두개를 가져오기위해 위의 코드를 썼을 때 불필요하게 연속적인 문제가 있다.

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

로 바꿔쓴다.

13.3.4 세부 사항

async function f(x) {
  
}
// ==> 
function f(x) {
  return new Promise(function (reslove, reject) {
    try {
      resolve((function (x) {
        
      }))
    } catch (e) {
      reject(e)
    }
  })
}

13.4 비동기 순회

프라미스는 setInterval()이나 웹 브라우저의 ‘클릭’ 이벤트 처럼 여러 번 일어날 수 있는 비동기 작업에는 적합하지 않다.

하지만 ES2018 부터 해결책이 나왔다. 비동기 이터레이터는 for/await로 사용 할 수 있다.

13.4.1 for/await 루프

노드 12는 리더블(readable) 스트림을 비동기적으로 이터러블로 만든다. 따라서 for/await 루프로 스트림의 연속적인 데이터 덩어리를 읽을 수 있다.

const fs = require("fs");

async function parseFile(filename) {
  let stream = fs.createReadStream(filename, { encoding: "utf-8" });
  for await (let chunk of stream) {
    parseChunc(chunk);
  }
}
  • 대략적으로 비동기 이터레이터는 프라미스를 생성, for/await 루프는 프라미스가 이행되길 기다렸다가 이행된 값을 루프 변수에 할당하고 루프 바디를 실행한다.
  • 그리고 이터레이터에서 다른 프라미스를 받아 새 프라미스가 이행되길 기다렸다가 시작.

13.4.2 비동기 이터레이터

  • 비동기 이터러블 객체에는 심벌 이름 Symbol.asyncIterator를 가진 메서드가 있다. Symbol.Iterator 메서드 보다 Symbol.asyncIterator를 먼저 시도
  • 그리고 next() 메서드는 직접적으로 순회 결과 객체를 반환하는 것이 아닌 순회 결과 객체로 해석되는 프라미스를 반환.

13.4.3 비동기 제네레이터

  • 제네레이터는 이터레이터를 만드는 가장 쉬운 방법.
  • 다음은 비동기 제네레이터와 for/await 루프를 사용해 setInterval() 콜백 함수 대신 루프 문법으로 코드를 일정 주기로 실행하는 예제
function elapsedTime(ms) {
  return new Promise((resolve) => setTimeout(resolve, ms));
}

async function* clock(interval, max=Infinity) {
  for (let count = 1; count <= max, count++){
    await elapsedTime(interval);
    yield(count)
  }
}

async function test() {
  for await (let tick of clock(300, 100)) {
    console.log(tick)
  }
}
profile
마라토너

0개의 댓글