(13장) 비동기 자바스크립트 [자바스크립트 완벽 가이드 7판]

iberis2·2023년 2월 28일
0

13장 비동기 자바스크립트


웹 브라우저의 자바스크립트 프로그램은 일반적으로

  • 프로그램이 실행되기 전에 사용자가 클릭하거나 탭하기를 기다리거나
  • (자바스크립트를 사용하는 서버는) 네트워크를 통해 클라이언트의 요청이 들어온 후에야 작업을 시작하는 것처럼

이벤트 주도적이어서, 비동기 프로그래밍이 필요할 때가 많다.

1. 콜백과 비동기 프로그래밍

[ ✨Chapter1. 요약 ]
콜백(다른 함수에 전달하는 함수)이벤트는 자바스크립트의 가장 기본적인 비동기 프로그래밍 방법이다. 하지만 이 방법은

  • 콜백헬(콜백이 다른 콜백 안으로 끝없이 중첩되는 일)을 막을 수 없고
  • 빈틈없는 에러 처리가 어렵다는 단점이 있다.

⏰ 타이머

setTimeout(()=>{ }, 60000); // 60초 후 콜백 함수를 실행
setInterval(()=>{ }, 10000); // 10초 마다 콜백 함수를 실행 

setTimeout()은 콜백 함수를 등록하고, 호출할 비동기 조건을 지정하기 위해 호출하는 함수이다.
한 번 호출하는 함수가 아닌, 업데이트를 체크하는 함수가 필요하면 반복적으로 실행하는 setInterval()을 사용한다.

🖱 이벤트

let okayButton = document.querySelector('#버튼ID');
okayButton.addEventListener('click', ()=>{ }); // 클릭할 때마다 콜백함수 실행

addEventListener
이벤트 주도 자바스크립트 프로그램은 지정된 컨텍스트에 지정된 타입의 이벤트를 처리할 콜백 함수를 등록하고,
웹 브라우저는 지정된 이벤트가 일어날 때마다 함수를 호출한다. 이러한 콜백 함수를 이벤트 핸들러, 이벤트 리스너라고 부르며 addEventListener()를 통해 등록한다.

🌐 네트워크 이벤트

클라이언트 사이드 자바스크립트 코드는 XMLHttpRequest 클래스콜백 함수를 사용해 HTTP 요청을 보내고 서버의 응답을 비동기적으로 처리할 수 있다.
(이름은 XMLHttpRequest지만, XML과는 별 상관이 없다.)

하지만 최신 클라이언트 사이드 자바스크립트에서는 XMLHttpRequest가 아닌 fetch() API를 대부분 사용한다.

☎️ 노드의 콜백과 이벤트

서버 사이드 자바스크립트 환경인 노드비동기적으로 만들어져 있으며, 많은 API가 콜백과 이벤트를 사용한다.

  • fs.readFile('파일이름', '인코딩', (error, data)=>{ } )

노드는 addEventListener() 대신 on() 메서드를 이용해 이벤트 리스너를 등록한다.

2. 프로미스

Promise는 비동기 작업 결과를 나타내는 객체이다.
프로미스의 값이 준비됐을 때 콜백 함수를 호출하도록 프로미스에 요청할 수 있다.

Promise는 다음 중 하나의 상태를 가진다.

대기(pending) : 이행하지도, 거부하지도 않은 초기 상태
이행(fulfilled) : 연산이 성공적으로 완료됨
거부(rejected) : 연산이 실패함

  • 프라미스는 절대 이행되는 동시에 거부될 수 없다.
  • 완료된 프라미스는 절대 이행이나 거부 상태로 바뀔 수 없다.

[ ✨Chapter2. 요약 ]
프로미스를 정확히 사용한다면 중첩을 거듭했을 비동기 코드를 then()의 선형 체인으로 변환해 비동기 단계들을 이어지게 만들 수 있다.(코드의 가독성이 좋아진다. → 콜백헬 해결)
then() 호출 마지막에 catch()를 붙여 에러 처리 코드를 하나로 모을 수 있다.

fetch("API주소")
  .then((response) => response.json()) // JSON 응답을 JavaScript 객체 리터럴로 파싱
  .then((document) => { /* document를 활용하는 내용 */ })
  .catch((error) => { /* 에러 처리 */ })
  .finally(/* 이행이든 거절이든 실행할 내용 */);

장점

① 프라미스는 중첩된 콜백을 선형에 가까운 프라미스 체인으로 바꿔줘서, 코드를 가독성 좋게 만들어준다.
→ 콜백 기반 비동기 프로그래밍의 콜백 지옥 문제를 해결

② 프로미스는 프로미스 체인을 통해 에러를 정확히 전달하고 처리할 수 있다.
→ 콜백 기반 비동기 프로그래밍의 에러 처리가 어려운 단점도 해결

특징

프라미스는 콜백 함수를 반복적으로 호출하는데 사용할 수 없으므로, setInterval( )을 대신할 수는 없다.

🤙🏻 프로미스 사용

getJSON(url)
  .then((jsonData) => {
    // JSON 값을 받아 분석하면 비동기적으로 호출될 콜백 함수이다.
    // 콜백 함수를 getJSON()에 직접 전달하지 않고 then() 메서드에 전달한다.
    // HTTP 응답이 도착하면 응답 바디를 "JSON"으로 분석하고 분석된 값을 then()의 콜백 함수에 전달한다.
  })
  .then(() => {
    // 프로미스 객체에서 then 메서드를 여러번 호출하면 콜백은 비동기 작업이 완료될 때 호출된다.
  });

🚨 프로미스 예외처리(에러처리)

비동기 코드에서는 처리하지 않은 예외가 아무런 경고 없이 사라질 때가 많고 에러도 조용히 일어날 때가 많아서 디버그하기 어려운데, catch() 메서드로 프로미스의 에러를 쉽게 처리할 수 있다.

getJSON(url).then(
  (response) => { /* Ⓐ 작업이 정상 완료되었을 때 */ },
  (rejected) => { /* 작업이 실패했을 때 */ }
);

// 현실적으로 then에 두 가지 콜백 함수를 전달하는 경우는 거의 없고,
// 만약 그 이전 작업이 아닌, Ⓐ에서 에러가 발생한다면 처리할 수가 없다.
// 따라서 일반적으로 catch 메서드를 주로 사용한다.

getJSON(url)
  .then((response) => { /* 작업이 정상 완료되었을 때 */ })
  .catch((error) => { /* 작업이 실패했을 때 */ });

then() 메서드의
첫 번째 파라미터의 콜백함수로 작업이 정상 완료되었을 때의 결과가 전달된다.
두 번째 파라미터의 콜백함수로 작업이 잘 못되었을 때의 예외가 전달된다.
(일반적으로 Error 객체의 일종이지만 반드시 그런 것은 아니다.)

하지만 일반적으로는 then 메서드에 2개의 콜백 함수를 전달하는 것이 아닌, 메서드 체인의 마지막에 catch 메서드를 사용하여 에러를 처리한다.
(catch()를 만날 때까지 에러가 체인을 따라 내려간다고 비유할 수 있다.)

getJSON(url)
  .then((response) => { /* 작업이 정상 완료되었을 때 */ })
  .catch((error) => { /* 작업이 실패했을 때 */ });
  .finally(() => {/* 이행이든 거절이든 실행할 내용 */ });

프라미스의 이행(fulfilled)/거부(rejected)와 상관없이 처리되어야 하는 작업을 finally 메서드의 콜백으로 처리할 수 있다.

  • then, catch 메서드와 마찬가지로 finally도 새 프라미스 객체를 반환하지만, finally 콜백의 반환 값은 일반적으로 무시된다.
  • finally가 반환하는 프로미스는 보통 finally가 호출된 프로미스가 해석/거부된 값과 같은 값으로 해석/거부된다.
  • 하지만 finally 콜백이 예외를 일으키면 finally가 반환하는 프라미스는 그 예외와 함께 거부된다.
new Promise((resolve, reject) => {
  setTimeout(() => { resolve("첫번째 프로미스"); }, 1000);
})
  .then((res) => {
    console.log(res);
    return "두번째 프로미스";
  })
  .then((res) => {
    console.log(res);
    return new Promise((resolve, reject) => {
      setTimeout(() => {
        resolve("세번째 프로미스");
      }, 1000);
    });
  })
  .then((res) => {
    console.log(res);
    return new Promise((resolve, reject) => {
      setTimeout(() => {
        reject("네번째 프로미스");
      }, 1000);
    });
  })
  .then((res) => {
    console.log(res);
  })
  .catch((err) => {
    console.error(err);
    return new Error("이 에러는 then에 잡힙니다");
  })
  .then((res) => {
    console.log(res);
    throw new Error("이 에러는 catch에 잡힙니다.");
  })
  .then((res) => {
    console.log("출력x");
  })
  .catch((err) => {
    console.error(err);
  });

프라미스 체인은 대부분 .catch()로 끝내서 체인에서 발생한 에러를 처리하거나 최소한 로그라도 남기는 형태로 사용하지만, 사실 .catch()는 프라미스 체인 어디에서 사용해도 유효하다.

프라미스 체인에서 에러가 발생할 수 있는데, 이 에러가 복구할 수 있는 에러여서 체인의 나머지 단계에 전달되는 것을 막고 싶다면 .catch()를 중간에 삽입해도 된다. 일단 .catch()에 전달된 에러는 프로미스 체인을 타고 내려가지 않는다.

.catch()에 전달되는 콜백은 이전 단계에서 에러가 일어났을 때만 호출된다. 콜백이 정상적으로 완료됐다면 .catch()는 건너뛰고 반환 값을 다음 .then()에 전달한다.

🔗 프로미스 체인

// response, document, rendered는 변수명일 뿐으로 다른 이름으로 지정해도 문제 없다.
fetch(documentURL) // HTTP 요청을 보낸다.
  .then((response) => response.jon()) // 응답의 JSON 바디를 가져온다.
  .then((document) => render(document)) // // JSON 분석이 끝나면 문서를 사용자에게 표시한다.
  .then((rendered) => catchInDatabase(rendered)) // 문서 렌더링이 끝나면 로컬 데이터베이스에 캐시한다.
  .catch((error) => handle(error)); // 에러를 처리한다.

현재는 대부분 XMLHttpRequest 클래스와 콜백 함수가 아닌
Fetch API 즉, fetch() 함수를 이용해서 HTTP 요청을 보낸다.

  • 비동기 작업으로 처리해야할 작업 목록들을 .then().then().then() 과 같은 then 메서드 체인으로 이어서 콜백 지옥을 방지할 수 있다.

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

📄 프로미스 해석

👥 병렬 프로미스

Promise.all()

프로미스의 병렬 실행을 담당한다.

// 프로미스 객체 배열을 파라미터로 받고 프로미스를 반환한다.
const urls = [ /* URL 여러개 */ ];
promise = url.map((url) => fetch(url).then((r) => r.text()));

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

입력 프라미스 중 하나라도 거부되면 반환된 프라미스 역시 거부된다.

  • 첫 번째로 거부되는 프로미스가 생기는 즉시 나머지 프로미스가 아직 대기 중이더라도 거부된다.

입력 프라미스 모두가 이행되면 전체 프라미스는 각 입력 프라미스 값으로 이루어진 배열로 이행된다.

입력 배열의 요소로 프로미스 객체 외의 값도 포함될 수 있다.
프로미스 값이 아닌 값은 이미 이행된 것으로 간주하고 결과 배열에 그대로 복사한다.

Promise.allSettled()

ES2020에서 Promise.allSettled()를 도입했다.

Promise.allSettled([Promise.resolve(1), Promise.reject(2), 3]).then(
  (results) => {
    console.log(results[0]); // { status: 'fulfilled', value: 1 }
    console.log(results[1]); // { status: 'rejected', reason: 2 }
    console.log(results[2]); // { status: 'fulfilled', value: 3 }
  }
);

Promise.allSettled()는 반환된 프라미스를 거부하지 않으며 입력 프라미스 전체가 완료되기 전에는 이행되지 않는다.
프라미스는 객체 배열로 해석되며 각 객체는 입력 프라미스이다.

  • 각각의 반환된 객체에는 status 프로퍼티가 있고 그 값은 fulfilled 또는 rejected이다.
    • '이행(fulfilled)' 상태인 객체에는 value 프로퍼티가 존재하며 이행된 값이다.
    • '거부(rejected)' 상태의 객체에는 reason 프로퍼티가 존재하며 대응하는 프라미스의 에러 또는 거부 값이다.

Promise.race()

입력 배열에서 처음으로 이행/거부되는 프로미스와 함께 이행/거부되는 프로미스를 반환한다.

  • 만약 입력 배열에 프로미스 아닌 값이 있다면 아닌 값들 중 첫 번째를 반환한다.

⛓ 프로미스 시퀀스

3. async와 await

[ ✨Chapter3. 요약 ]
async와 await는 프라미스 사용을 단순화하며 프라미스 기반의 비동기 코드를 동기적 코드처럼 작성할 수 있게 한다. (코드 가독성이 좋아진다.)

async 함수로 선언된 함수는 묵시적으로 프로미스를 반환한다.

  • 이행된(fulfilled) 프라미스의 값은 동기적 함수의 반환 값과 같다.
  • 거부된(rejected) 프라미스의 값은 동기적 함수에서 일으킨 에러와 같다.

async 함수 내부에서는 await로 프라미스 값이 동기적으로 계산된 것처럼 프로미스를(또는 프로미스를 반환하는 함수를) 기다릴 수 있다.

📚 await 표현식

await 키워드는 프로미스를 받아 반환 값이나 예외로 바꾼다.
await를 사용하는 코드는 항상 비동기적이다.

let response = await fetch(url);
let data = await response.json();

await는 프로미스를 할당하는 변수와 함께 사용하기 보다는 주로 프로미스를 반환하는 함수와 함께 사용한다.

await 키워드는 프로그램 흐름을 차단하지 않으며, 지정된 프로미스가 완료되기 전에는 아무 일도 하지 않는다.

📚 async 함수

// 이전에는 async와 즉시 호출 함수로 감싸지 않으면 
// SyntaxError: await is only valid in async function 가 발생했는데,
(async function() {
  await Promise.resolve(console.log('Hello World'));  
}()); // Hello World

// 바로 사용할 수 있게 됐다.
await Promise.resolve(console.log('Hello World')); // Hello World

await 키워드는 async 키워드로 선언된 함수 안에서만 사용할 수 있었는데,
ECMAScript 2022에서 최상위 레벨에서 async 없이 await 사용을 승인했다.

[주의] 최상위 레벨에서 await을 async없이 사용할수 있다는 것이지, 함수 내에서 await을 쓸때에는 여전히 async가 필요함을 잊지말자!

함수를 async로 선언하면 설령 함수 바디에 프라미스 관련 코드가 전혀 없더라도 반환 값은 프로미스이다.

  • async 함수가 정상적으로 완료되면 함수의 실제 반환 값인 프라미스 객체는 함수 바디가 반환하는 값으로 해석된다.
  • async가 예외를 일으키면 반환된 프라미스 객체 역시 그 예외와 함께 거부된다.

📚 여러 개의 프라미스 대기

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

// getJSON 함수로 JSON 값 2개를 가지고 온다면,
// 프로미스를 직접 사용할 때처럼 Promise.all()을 사용하면 된다.

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

4. 비동기 순회

프로미스는 여러번 일어날 수 있는 비동기 작업(setInterval(), click 이벤트 등)에는 적합하지 않다.
이 문제를 ES2018에서 나온 for / await과 함께 사용하는 비동기 이터레이터로 해결할 수 있다.

[ ✨Chapter4. 요약 ]
[Symbol.asyncIterator]( ) 메서드를 만들거나 async function* 제너레이터 함수를 호출해서 비동기 이터러블 객체를 만들 수 있다.

비동기 이터러블 객체는 for / await 루프와 함께 사용할 수 있다.

비동기 이터레이터는 노드 스트림의 '데이터' 이벤트의 대안이며 클라이언트 사이드 자바스크립트에서는 사용자 입력 이벤트를 스트림으로 표현하는데 사용할 수 있다.

♻️ for/await 루프

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 루프는 프라미스가 이행되길 기다렸다가 이행된 값을 루프 변수에 할당하고 루프 바디를 실행한다.
그리고 이터레이터에서 다른 프라미스를 받아 새 프라미스가 이행되길 기다렸다가 다시 시작한다.

♻️ 비동기 이터레이터

  • 비동기 이터러블 객체에는 심벌 이름 Symbol.asynciterator를 가진 메서드가 있다.
    • for/await는 일반적인 이터러블 객체와도 호환되지만 비동기 이터러블 객체를 더 선호한다. 즉, Symbol.iterator 메서드보다 Symbol.asynciterator 메서드를 먼저 시도한다.
  • 비동기 이터레이터의 next() 메서드는 직접적으로 순회 결과 객체를 반환하는 것이 아니라 순회 결과 객체로 해석되는 프라미스를 반환한다.
    • value 와 done 프로퍼티 모두 비동기이다.
    • 순회가 언제끝나는지 역시 비동기적으로 판단

♻️ 비동기 제너레이터

async function* ( ){ }
비동기 함수의 특징과 제너레이터의 특징을 모두 가진다.

  • 함수 바디에서 await를 사용할 수 있다.
  • 일반적인 제너레이터와 마찬가지로 yield를 사용할 수 있다.
  • yield로 전달하는 값은 자 동으로 프라미스가 된다.
// await를 사용할 수 있도록 setTimeout()을 감싸는 프라미스 기반 래퍼 함수
// 밀리초 단위로 지정된 시간이 지나면 이행되는 프라미스를 반환한다.
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; // 카운터를 전달합니다.
  }
}

// 비동기 제너레이터와 for/await를 사용하는 테스트 함수
async function test() { // for/await를 쓸 수 있어야 하므로 비동기로 만듦
  for await (let tick of clock(300, 100)) {  // 위 비동기 제너레이터 clock()에 따라 300밀리초마다 100번 반복합니다
    console.log(tick);
  }
}

♻️ 비동기 이터레이터 구현

Symbol.asyncIterator() 메서드가 반환하는 객체의 next() 메서드가 순회 결과 객체로 해석되는 프로미스를 반환하도록 만들면,
비동기 제너레이터를 사용하지 않아도 비동기 이터레이터를 직접 만들 수 있다.

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

  let startTime = Date.now(); // 언제 시작했는지 기억
  let count = 0; // 현재 단계를 기억

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

각 단계를 시작해야 할 정확한 시간을 기억하고 그 시간에서 현재 시간을 빼서 setTimeout()에 전달할 인터벌을 계산한다.
clock()을 for/await 루프에서 사용한다면 루프 바디를 실제로 실행할 때 걸리는 시간까지 고려하므로 더 정확히 실행된다.

for/await 루프 없이 비동기 이터레이터를 사용하면 언제든 next() 메서드를 호출할 수 있게 된다.

  • for/await 루프는 항상 현 단계에서 반환한 프로미스가 이행될 때까지 기다렸다가 다음 단계를 시직한다.
  • clock()의 제너레이터 버전에서 만약 next() 메서드를 연속으로 세 번 호출했다면 (아마 이렇게 의도하지 않은 결과겠지만) 거의 동시에 세 개의 프라미스가 이행된다.
profile
React, Next.js, TypeScript 로 개발 중인 프론트엔드 개발자

0개의 댓글