자바스크립트 셀프 QnA(1): All About The Async!

9rganizedChaos·2021년 10월 24일
1
post-thumbnail

(<자바스크립트 셀프 QnA> 시리즈에 작성된 포스팅들은 각 주제에 해당하는 <모던 자바스크립트 딥다이브> 챕터를 읽으며 요약한 내용입니다. 더 자세한 내용은 <모던 자바스크립트 딥다이브>를 참고해주세요.)

Q1 비동기 처리란 무엇인가요?

  • 함수를 호출하면 실행 컨텍스트가 생성되고, 이 실행 컨텍스트는 콜스택에 푸시됩니다. 함수의 실행이 종료되면 비로소 해당 컨텍스트가 콜스택에서 pop되어 제거되고 다음 실행 컨텍스트가 실행되지요.
  • 자바스크립트 엔진은 싱글 스레드이고, 동시에 두 개의 함수를 처리할 수 없습니다. 때문에 시간이 소요되는 태스크를 실행하면 블로킹이 일어나죠!
  • 이 때, 현재 실행 중인 태스크가 종료될 때까지 다음에 실행될 태스크가 대기하는 방식을 동기 처리,
  • 현재 실행 중인 태스크가 종료되지 않은 상태라도 다음 태스크를 곧바로 실행하는 방식을 비동기 처리라고 합니다.
  • 동기처리는 순서가 보장되지만, 블로킹이 발생하며, 비동기처리는 순서가 보장되지 않지만 블로킹이 발생하지 않습니다.

  • 비동기 함수는 전통적으로 콜백패턴을 사용하며,
  • 타이머 함수인 setTimeoutsetInterval, HTTP 요청, 이벤트 핸들러가 비동기 처리방식으로 동작합니다.

Q2 Event Loop이란 무엇인가요? 브라우저와 NodeJS에서의 비동기처리를 그림으로 그려서 설명할 수 있나요?

  • 자바스크립트 엔진은 싱글 스레드로 동작하지만, 실제 브라우저, NodeJS 등의 실행환경에서는 많은 태스크가 동시에 처리됩니다.
  • 브라우저와 NodeJS 등이 자바스크립트에 동시성을 지원하기 위해 제공하는 것이 바로 이벤트 루프와 태스크큐 입니다.

  • 태스크 큐는 비동기함수의 콜백함수 또는 이벤트 햄들러가 일시적으로 보관되는 영역이며,
  • 이벤트 루프는 콜 스택에 현재 실행 중인 실행 컨텍스트가 있는지 태스크 큐에 대기 중인 함수가 있는지 반복해서 확인하고, 콜 스택이 비어있을 경우 태스크 큐에 대기중인 함수를 콜스택으로 이동시키는 역할을 합니다.

  • 브라우저는 자바스크립트 엔진 외에도 렌더링 엔진과 Web API를 제공합니다. DOM API와 타이머 함수, HTTP 요청과 같은 것들이 모두 Web API이지요.

  • 예를 들어 개발자 도구에서 아래와 같은 코드를 실행한다고 생각해봅시다.
function foo(){
  console.log("foo")
};

function bar(){
  console.log("bar")
}

setTimeout(foo, 3 * 1000);
bar();
  • 가장 먼저, 전역 실행 콘텍스트가 콜 스택에 추가 됩니다.
  • setTimeout 함수의 실행콘텍스트가 콜 스택에 추가 됩니다.
  • setTimeout은 비동기 처리되므로, setTimeout의 콜백함수 foo는 콜백 큐(태스크 큐)에 추가되고 setTimeout의 실행콘텍스트는 제거됩니다.
  • 그리고 bar 함수의 실행콘텍스트가 콜스택에 추가되고, console에 "bar"라는 로그가 찍힙니다.
  • bar 함수가 종료되어 다시 콜 스택이 비게 되면, 이때 콜백 큐에서 대기하던 foo함수의 실행 콘텍스트가 스택에 추가되고 콘솔에 "foo"라는 로그가 찍히게 됩니다.

rAF

Q3 비동기 함수에서 콜백패턴을 사용하는 이유는 무엇인가요?

  • 비동기 함수를 호출하면 함수 내부의 비동기로 동작하는 코드의 실행이 완료되지 않아도, 해당 콜백함수 등의 비동기 코드를 콜백 큐로 넘겨주고, 비동기 함수는 종료된다.
  • 즉 비동기 함수 내부의 비동기로 동작하는 코드는 비동기 함수가 이미 종료된 후에 완료된다.
  • 비동기 함수 내부의 비동기로 동작하는 코드에서 처리 결과를 외부로 반환하거나 상위 스코프의 변수에 할당하면 기대한 대로 동작하지 않는다. (아래 코드가 그 예시! return 역시 불가하다, 상위 실행 콘텍스트가 이미 종료되었으므로 값을 반환할 환경이 없는 것!)
let g = 0;
setTimeout(() => { g = 100; }, 0);

console.log(g); // 0
  • 따라서, 비동기 함수의 처리 결과에 대한 후속처리는 콜백 함수를 전달해, 후에 따로 실행 컨텍스트를 생성해 내부의 코드가 실행될 수 있도록 한다.
const get = (url, successCallback, failureCallback) => {
  const xhr = new XMLHttpRequest();
  xhr.open("GET", url);
  xhr.send();

  xhr.onload = () => {
    if (xhr.status === 200) {
      successCallback(JSON.parse(xhr.response));
    } else {
      failureCallback(xhr.status);
    }
  };
};

const response = get(
  "https://jsonplaceholder.typicode.com/post/1",
  console.log,
  console.err
);

Q4 Callback Hell은 무엇인가요? 어떻게 이를 피할 수 있을까요?

  • 앞서 왜 비동기 함수의 경우 callback 함수를 전달해 비동기 처리를 해야 하는지 알아보았다.
  • 비동기 처리 결과에 대한 후속 처리를 수행하는 비동기 함수가 비동기 처리 결과를 갖고 또 다시 비동기 함수를 호출해야 한다면 콜백 함수 호출이 중첩되어 복잡도가 높아진다.
  • 이것을 바로, 콜백 헬이라고 한다.

  • 콜백 패턴의 가장 큰 문제점은 에러처리가 곤란하다는 점이다.
try {
  setTimeout(() => {
    throw new Error("Error!");
  }, 1000);
} catch {
  console.log("캐치한 에러", e);
}
  • 에러는 호출자 방향으로 전파되는데 비동기 코드의 경우 해당 호출자의 실행 콘텍스트가 이미 종료되어 에러를 전달받을 컨텍스트가 없는 것이다.
  • 이 문제를 해결하기 위해 ES6에서 프로미스가 도입되었다. (ES8에서 도입된 async/await과 제너레이터 함수도 대안이 될 수 있겠다!)

Q5 Promise에 대해 설명해주세요.

  • Promise 생성자 함수는 비동기 처리를 수행할 콜백함수를 인자로 받는데, 이 콜백함수는 resolve와 reject 함수를 인자로 받는다.
  • 비동기 처리가 성공하면 비동기 처리 결과를 resolve 함수에 인수로 전달해 호출하고, 실패하면 에러를 reject함수에 인수로 전달해 호출한다.
  • resolve 함수와 reject 함수는 각각 fulfilled 상태, rejected 상태의 프로미스 객체를 생성해 반환한다.
  • 프로미스 객체는 비동기 처리 상태와 처리 결과를 관리하는 객체이다.

  • Promise 객체는 후속처리 메서드 then, catch, finally를 제공한다.
  • 비동기 처리 상태가 변화하면 후속 처리 메서드에 인수로 전달한 콜백 함수가 선택적으로 호출된다.
  • 하나 주의할 점은 catch 메서드는 내부적으로는 then(undefined, onRejected)와 동일하다는 점, 그리고 finally는 성공 실패, 처리 상태에 관계없이 무조건 한 번 호출된다는 점!

  • then, catch, finally 메서드는 콜백함수가 프로미스가 아닌 값을 반환하더라도 암묵적으로 그 값을 reject 또는 resolve 하여 프로미스를 생성해 반환한다.
  • then, catch, finally 메서들이 늘 프로미스 객체를 반환하므로 연속적으로 호출할 수 있다. 이걸 프로미스 체이닝이라고 한다.
const url = "https://jsonplaceholder.typicode.com";

promiseGet(`${url}/post/1`)
  .then(({ userId }) => promiseGet(`${url}/users/${userId}`))
  .then(userInfo => console.log(userInfo))
  .catch((err) => console.error(err))
  .finally(() => console.log("Bye!"));
  • 프로미스에는 다양한 정적 메서드가 존재한다.
  • Promise.all: 이터러블을 인자로 받아 여러 개의 비동기 처리를 병렬처리한다. 전달 받은 모든 프로미스가 fulfilled 상태가 되면 모든 처리 결과를 배열에 저장해 새로운 프로미스를 반환! 처리 순서가 보장된다는 특징이 있으며, 인수로 전달 받은 배열의 프로미스가 하나라도 rejected 상태가 되면 나머지 프로미스가 fulfilled 상태가 되는 것을 기다리지 않고 즉시 종료된다.
  • Promise.race: Promise.all과 비슷하지만, 모든 프로미스가 fulfilled 상태가 되는 것을 기다리는 것이 아니라 가장 먼저 fulfilled 상태가 된 프로미스 처리 결과를 resolve하는 새로운 프로미스를 반환한다.
  • Promise.allSettled: 이터러블을 인수로 전달 받는다. 전달 받은 프로미스가 모두 settled 상태가 되면 처리 결과를 배열로 반환한다. 에러와 응답을 같이 배열로 받아 반환받을 수 있다.

setTimeout(() => console.log(1), 0);

Promise.resolve()
.then(() => console.log(2))
.then(() => console.log(3))
  • 위 코드는 1, 2, 3이 순서대로 콘솔에 찍힐 것 같지만 2, 3, 1 순서로 콘솔이 찍힌다.
  • 프로미스의 후속처리 메서드의 콜백함수는 태스크 큐가 아니라 마이크로태스크 큐에 저장되고, 마이크로태스크 큐는 태스크 큐보다 우선순위가 높다.

Q6 async/await에 대해 설명해주세요.

  • 제너레이터 함수를 사용해 비동기 처리를 동기처리 처럼 동작하도록 구현했지만, 코드가 장황해지고 가독성이 나빠졌습니다. ES8에서는 제너레이터보다 간단하고 가독성이 좋은 async/await이 도입되었습니다.
  • 프로미스에서는 then, catch, finally 등의 후속 처리 메서드에 콜백 함수를 전달했다면, async/await을 활용하면 동기 처리처럼 프로미스를 사용할 수 있습니다.
const fetch = require("node-fetch");

const getGithubUserName = async id => {
  const res = await fetch(url);
  const { name } = await res.json();
  console.log(name)
}

getGithubUserName("아이디")
  • await 키워드는 다음 실행을 일시 중지시켰다가 프로미스가 settled 상태가 되면 다시 재개합니다.
  • async/await에서는 try...catch를 활용할 수 있다. 콜백함수를 인수로 전달 받는 비동기함수와는 달리 프로미스를 반환하는 비동기함수는 명시적으로 호출할 수 있기 때문에 호출자가 명확하다. (그 호출자가 ㄴ구?)
  • async 내에서 catch문을 활용해서 에러를 캐치하지 않으면 async 함수는 발생한 에러를 reject하는 프로미스를 반환한다.
profile
부정확한 정보나 잘못된 정보는 댓글로 알려주시면 빠르게 수정토록 하겠습니다, 감사합니다!

0개의 댓글