JavaScript - Callback, Promise (비동기처리)

Tae Yun Choi·2022년 9월 20일
0

개발새발 JavaScript

목록 보기
2/2
post-thumbnail

Callback

자바스크립트에서 어떠한 일을 비동기적으로 실행할 때, Callback 함수를 사용한다. Callback 함수는 함수를 바로 전달받아서 실행하는 함수가 아니라 특정 조건 혹은 이벤트가 발생했을 때 실행해주는 함수이다. 즉, 어떠한 일을 원하는 때에 처리하기 위해 사용하는 함수이다. 그리고 이러한 콜백함수는 어떤 함수의 인수로 콜백함수의 참조값을 전달하는 것으로 이행된다. 자바스크립트가 일급 객체, 함수를 가지는 언어이기 때문에 가능한 특징이다.


하지만 콜백함수에는 다음과 같은 치명적인 단점이 존재한다. 바로 그 유명한 콜백헬이다.


calculate(function (val1) {
    calculate2(function (val2) {
        calculate3(function (val3) {
            calculate4(function (val4) {
                calculate5(function (val5) {
                    calculate6(function (value6) {
                        // Do something with value6
                    });
                });
            });
        });
    });
});

calculate 함수 내부에서 val1을 통해 실행한 결과로 val2에 값을 받고 이 값을 이용해서 또 다른 콜백함수를 호출한다. 이처럼 비동기적으로 처리된 결과를 가지고 이어서 추가적인 작업을 할 때, 이와 같은 콜백 지옥이 탄생한다.
또 다른 단점도 존재한다.
바로 에러 처리가 힘들다는 점이다.

try{
  setTimeout(() => { throw new Error('Error!'); }, 1000); }
catch (err) {
  console.error(err)
}

위와 같은 코드는 에러를 잡아내지 못한다. 그 이유는 setTimeout함수는 콜스택에서 1초 뒤에 콜백을 TaskQueue에 넣어달라는 요청을 스케줄러에게 보낸 뒤 바로 Pop된다. 그러면 try문에서는 이상없이 잘 실행된 것이고, 아무 문제없이 try문을 빠져 나오기 때문에 catch에 잡히지 않는다. 에러는 1초 뒤에 TaskQueue에 enqueue된 뒤 dequeue되어 콜스택에서 실행되는 콜백함수 내에서 발생하기 때문에, 이미 try-catch문을 빠져나간 프로그램은 에러가 어디서 발생했는지 알 길이 없고, 프로그램은 crash될 것이다.

이처럼 비동기 처리를 위해 사용하는 콜백함수에는 몇몇 치명적인 단점이 존재하며, 이를 극복하기 위해서 ES6에서 Promise가 도입되었다.

Promise

Promise는 빌트인 객체로 new 생성자를 통하여 프로미스 객체를 생성한다.

const promise = new Promise((resolve, reject) => {
   // 비동기적 처리 수행
  if (완료) {
    resolve('결과값');
  } else (실패 || 에러) {
    rejeect('원인');
  }
});

또한 위와 같이, 생성자의 인수로는 내부에서 비동기 처리를 수행하는 콜백함수를 전달받는다.


promise에는 [[PromiseStatus]]라는 내부 슬롯에 프로미스의 상태 정보를 저장하고있다.

프로미스의 상태 정보에는 3가지가 있다.
1. pending
2. fulfilled
3. rejected

  • pending 상태는 아직 비동기 처리가 수행되지 않은 상태를 의미한다. 프로미스가 생성되면 기본으로 갖는 상태이다.
  • fulfilled 상태는 비동기 처리가 수행된 상태 + 비동기처리가 성공한 상태로 resolve 함수 호출 시 fulfilled 상태로 변경된다.
  • rejected 상태는 비동기 처리가 수행된 상태 + 비동기처리가 실패한 상태로 reject 함수 호출시 rejected 상태로 변경된다.

또한 비동기 처리가 수행된 상태(fulfilled || rejected)를 합쳐서 settled 상태라고 하며, settled상태가 되면 더 이상 다른 상태로 변할 수 없다.

Promise 처리

그렇다면 Promise를 쓰는 이점은 무엇이 있을까?

Promise는 then, catch, finally라는 후속 처리 메소드를 갖는다.

이것이 콜백함수를 사용했을 때 발생한 콜백헬과 힘든 에러처리를 해결하게 해주는 메소드들이다.

1. then

then은 두개의 콜백 함수를 인자로 전달받는데, 첫번째 인수 함수는 resolve가 호출 되었을 때 실행되는 함수이고, 두번째 인수 함수는 reject가 호출 되었을 때 실행되는 함수이다. 또한 각각의 콜백함수의 인자로는 resolve와 reject로 전달된 인자를 받는다. 결국 then만을 이용해도 resolve와 reject 모두 처리가 가능하다. 하지만 뒤에 나올 catch메소드를 사용하여 reject를 처리하는 것을 권장한다. 이유는 뒤에 나올 에러처리 때 설명하도록 하겠다.
또한 then 메소드는 언제나 프로미스를 반환하다. then 메소드의 콜백함수가 프로미스를 반환하면 그 프로미스를 그대로 반환하고, 다른 값을 반환하면 그 값을 암묵적으로 resolve or reject해서 프로미스를 생성해 반환한다.


2. catch

catch 메소드는 한개의 콜백 함수를 인자로 전달받는데, 이는 프로미스가 reject인 상태에서 호출된다. reject함수를 통해 전달받은 인자의 메시지로 에러를 처리해주면 된다. catch 메소드 또한 then 메서드와 같이 프로미스를 반환한다.


3. finally

finally 메소드는 한 개의 콜백 함수를 인자로 전달 받는데, fulfilled 상태든, rejected 상태든 무조건 한번만 호출된다. 비동기적 처리가 성공 혹은 실패와 상관없이 공통적으로 처리해야하는 일이 있을 때 사용하면 좋다. finally 또한 프로미스를 반환한다.


에러처리

then 메서드를 통해서도 에러처리를 할 수 있다고 했지만, 에러처리는 catch로 하는 것을 권장한다. 그 이유는 then으로 에러처리를 하면 비동기적으로 발생한 에러는 잡을 수 있지만, then 내부에서 발생한 에러를 캐치하지 못하고 가독성 또한 떨어진다.
하지만 catch를 사용하면 then 내부에서 발생한 에러 또한 버블링되어서 catch에서 잡을 수 있다. 가독성 또한 좋기 때문에, then과 catch로 나누어서 사용하자.


프로미스에는 정적메소드 또한 존재하는데,

Promise.resolve([1, 2, 3]).then(console.log) // [1, 2, 3]

Promise.reject(new Error('Error!')); // Error: Error!

const delay = (cnt) => 
	new Promise((resolve) => 
       setTimeout(() => resolve(cnt), cnt * 1000));

Promise.all([delay(1), delay(2), delay(3)])
  .then(console.log) // [1, 2, 3], 3초 소요

Promise.race([delay(1), delay(2), delay(3)])
  .then(console.log) // 1 (가장 빨리 끝난 것)

Promise.allSettled([
  delay(1),
  new Promise((_, reject) => 
      setTimeout(() => reject(new Error('Error!')), 1000))
  		.then(console.log)
  /*
  [
  	{ status : "fulfilled", value: 1 },
    { status : "rejected", reason: Error: Error! at ~ }
  ]
 */
  • Promise.all은 배열 프로미스의 원소 중 한개라도 rejected 상태가 되면 즉시 종료한다.
  • Promise.race 또한 Promise.all과 같이 작동한다.
  • Promise.allSettled 메서드는 배열 내 프로미스들의 fulfilled 또는 rejected 상태와는 상관없이 모든 프로미스 처리 결과를 나타낸다.

정적 메소드 중 자주 쓰이는 Promise.all 함수를 내부 동작 원리를 추측하여 구현해 보았다.

tryThis - Promise.all 함수 구현하기

Promise.all은 프로미스 배열을 인수로 전달받아, 배열 내부의 비동기적 처리가 마무리되면 성공한 값들을 배열에 담아 반환해주는 함수이며, async & await과 함께 많이 쓰인다.

function promiseAll(arr) {
  if (!arr?.length) return Promise.reject('No promise!');
  return new Promise((resolve, reject) => {
    const data = [];
    let pending = arr.length;
    arr.map((el, idx) =>
      el(idx + 1)
        .then((res) => {
      // push를 사용하면 순서가 보장되지 않기 때문에 idx를 사용하였다.
          data[idx] = res;
          pending--;
          console.time('Async!');
          if (pending === 0) {
            resolve(data);
            console.timeEnd('Async!');
          }
        })
        .catch((err) => reject(err))
    );
  });
}

Microtask Queue

이벤트 루프가 관망하는 Queue에는 이벤트 발생 및 타이머함수 등으로 인해 발생하는 비동기적 처리를 담당하는 TaskQueue 외에도, 프로미스 후속 처리 메소드의 콜백 함수를 저장하는 Microtask Queue가 있다. Microtask Queue는 Task Queue보다 우선순위가 높다. 이벤트 루프가 콜스택이 비어있을 시, Microtask Queue를 먼저 확인한 뒤, 큐가 비어있다면 Task Queue를 확인한다.

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

Promise.resolve()
	.then(() => console.log(2))
	.then(() => console.log(3));

위와 같은 코드는 언뜻 보면 1, 2, 3으로 출력될 것 같지만, microtask queue에 2를 출력하는 함수와, 3을 출력하는 함수가 들어가있고, task queue에 1을 출력하는 함수가 들어가있는 상황이다. 따라서 이벤트 루프는 우선순위가 더 높은 마이크로 태스크큐를 확인, 큐 내에 두개의 함수가 있으므로 2를 출력하는 함수를 콜스택으로 옮겨주고 실행해준다. 그 뒤 3을 출력하는 함수를 콜스택으로 옮겨주고 실행한다. 그 다음에서야 마이크로 태스크큐가 비었기 때문에 태스크큐를 확인하고, 1을 출력하는 함수를 콜스택으로 옮겨주고 실행한다.

출처: 모던자바스크립트 DeepDive

profile
hello dev!!

0개의 댓글