🐥 F-Lab 프론트엔드 멘토링에서 정재남님의 코어 자바스크립트 4장 콜백함수를 읽고 정리한 내용입니다

콜백함수란?

콜백함수는 다른 함수에 인자로 전달되는 함수를 의미한다.

이때 아래에 대한 제어권 을 다른 함수에 넘겨준다

  • 콜백함수가 언제 호출될지
  • 콜백함수를 호출할때 인자에 어떤 값들을 어떤 순서로 넘길지
  • 콜백함수 내부에서 사용될 this

콜백함수와 비동기

비동기란 특정 코드의 연산이 끝날 때까지 기다리지 않고 다음 코드를 실행하는 것을 의미한다.

반대로, 특정 작업이 완료될때까지 기다렸다가 다음 코드를 실행하는 것을 동기 처리라고 한다.

즉, 동기 처리 방식은 태스크를 순서대로 하나씩 처리하므로 실행 순서가 보장된다는 장점이 있지만, 앞선 태스크가 종료할 때까지 이후 태스크들이 블로킹되는 단점이 있다.

비동기 처리를 하면 특정 작업이 완료되었을때 해야할 일을 미리 정의해두고, 다음 코드를 먼저 실행하기 때문에 시간이 오래 걸리는 작업이더라도 메인 스레드를 차단하지 않고, 사용자는 웹 어플리케이션을 계속 사용할 수 있다.

그리고 특정 작업이 완료되었을때 해야 할 일을 정의하기 위해 콜백함수를 넘겨준다.

콜백함수를 호출하는 함수에서 콜백함수의 호출 시점에 대한 제어권을 갖기 때문에 작업이 완료되었을 시점에서 콜백함수를 호출하도록 할 수 있기 때문이다.

하지만, 이러한 콜백함수도 항상 만능은 아니다. 🥸

콜백함수 기반의 비동기 처리가 가진 한계

  1. 비동기 실행 흐름의 제어

콜백함수를 사용하는 비동기 작업들은 독립적으로 실행되므로 특정 순서로 비동기 작업들을 제어하거나, 전체 작업이 완료되었는지 파악하기 어려워진다.

let count = 0;
const total = 2;

asyncFunction1(params, function(err, result) {
    if (err) {
        console.error('Error:', err);
    } else {
        // 개별 작업이 정상적으로 완료되면 해야할 동작
        count++;
        if (count === total) {
            // 모든 비동기 처리가 완료되면 해야할 동작
        }
    }
});

asyncFunction2(params, function(err, result) {
    if (err) {
        console.error('Error:', err);
    } else {
        // 개별 작업이 정상적으로 완료되면 해야할 동작
        count++;
        if (count === total) {
            // 모든 비동기 처리가 완료되면 해야할 동작
        }
    }
});
  1. 에러 처리의 어려움
    콜백함수가 많아질 수록 예외처리가 복잡해질 수 있다.

setTimeout 에 전달된 콜백함수는 비동기로 처리되고, try catch 문은 동기적으로 실행되며 try 블록 내에서 직접 실행한 코드에서 발생한 예외를 캐치할 수 있다. setTimeout 에 전달된 콜백함수가 비동기적으로 처리되었을 시점에 try catch 문은 이미 실행을 종료한 상태일 것이다. 이를 방지하기 위해 콜백 함수 내부에서 직접 예외를 핸들링해야 한다.

// BAD

try {
  setTimeout(() => {throw new Error('에러를 뱉어주세요!!!')}, 1000)
} catch (err) {
  console.log('에러', err)
}

// GOOD
setTimeout(() => {
  try {
    throw new Error('ERROR');
  } catch (e) {
    console.log('에러를 캐치했습니다'); // 에러를 캐치했습니다
    console.log(e); // Error: ERROR
  }
}, 1000);
  1. 콜백 지옥 .. 👾 비동기 로직이 복잡해지면서 콜백함수를 중첩해서 사용하면 코드의 가독성이 떨어지고, 디버깅도 어려워진다
step1(function(value1) {
    step2(value1, function(value2) {
        step3(value2, function(value3) {
            step4(value3, function(result) {
                // Do something with the result.
            });
        });
    });
});

위 문제를 해결하기 위해 ES6에서 Promise 가 등장했다 ✨

Promise

Promise 는 비동기 작업이 맞이할 미래의 완료 또는 실패와 그 결과 값을 담고 있는 객체이다.

비동기 요청에 대한 세가지 상태를 가진다.

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

위 상태는 한번 변경되고나면 다시 변경할 수 없다!

프로미스를 이행한다, 거부한다는게 무슨 의미일까?

Promise 생성자 함수는 콜백함수를 인자로 전달받는데 이 콜백함수는 resolve , reject 라는 함수를 인수로 전달 받는다.

Promise 생성자 함수는 전달받은 콜백함수 내부에서 비동기 처리를 수행한다.

이때 비동기 처리가 성공하면 resolve 함수를 호출하며 처리 결과를 인수로 전달하고, 비동기 처리가 실패하면 reject 함수를 호출하며 에러를 인수로 전달한다.

즉, 비동기 처리를 성공하거나 실패한다는 의미이다.

image

이미지 출처: https://developer.mozilla.org/ko/docs/Web/JavaScript/Reference/Global_Objects/Promise

new Promise(executer) 생성자 함수로 Promise 객체를 생성한다.

파라미터 executer 함수는 인수로 resolve , reject 를 가진다. resolve , reject 함수는 프로미스를 이행하거나 거부한다.

즉, pending → fulfilled 혹은 pending → rejected 로 상태를 바꾸는 역할을 하고 반환값은 없다.

Promise 가 해결하려한 콜백 패턴의 문제점 상기해보기

  1. 실행 흐름의 제어: Promise.all() 또는 Promise.race() 등의 메소드로 코드의 제어 흐름을 가져올 수 있었다.
  2. 에러 처리: Promise.prototype.catch() 메소드로 한 곳에서 에러를 처리할 수 있게 됐다.
  3. 콜백 지옥: 프로미스 체이닝을 통해 어느정도 가독성을 조금 더 높일 수 있었다.
const isConnectionReady = (isReady) => new Promise((res, rej) => {
    if(!isReady) rej('연결이 원활하지 않습니다');
    setTimeout(() => res('연결되었습니다'), 3000)
})

const waitAllConnections = (promises) => Promise.all(promises);
waitAllConnections([isConnectionReady(true), isConnectionReady(true), isConnectionReady(false)]).then((message) => console.log(message)).catch((message) => console.log(message))

async await 은 가독성을 더 높이고, 에러 처리를 쉽게 할 수 있도록 하여 Promise 를 보다 쉽게 사용할 수 있다.

async / await

Promise 가 가진 한계

  1. 프로미스 지옥 .. 👾
  2. 예외 처리

async await 은 비동기적으로 실행되지만 코드를 동기적으로 작성할 수 있어 가독성을 높였다.

getData() // 비동기 함수
  .then(data => doSomething(data)) // 비동기 함수
  .then(result => doSomethingElse(result)) // 비동기 함수
  .catch(error => console.error(error));


async function asyncFunc() {
  try {
    const data = await getData();
    const result = await doSomething(data);
    const finalResult = await doSomethingElse(result);
    console.log(finalResult);
  } catch (error) {
    console.error(error);
  }
}

asyncFunc();

async await 키워드는 Promise 를 보다 쉽게 사용할 수 있도록 도입된 문법이다. async 가 반환하는 것이 Promise 객체라는 표현이 정확하다.

브라우저가 async await 을 만나면 어떻게 할까?

  1. async 함수가 호출되면 Promise 를 반환한다.
  2. await 키워드를 만나면 async 함수의 실행이 잠시 중단된다.
  3. Promise 가 이행 및 거부되면 async 함수에서 중단되었던 위치부터 다음 코드가 실행된다. 이때 Promise 가 이행되었으면 그 결과값이 await 표현식의 값이 된다. Promise 가 거부되는 경우에 대비해서 예외 처리가 필요하다.

브라우저 엔진마다 asyn/await 을 최적화하는 방법은 다르다고 한다.

댓글남기기