자바스크립트의 비동기 처리 톺아보기 - (1) Callback, Promise

wookhyung·2022년 9월 18일
2

톺아보기

목록 보기
1/8
post-thumbnail

발표 스터디 1주차, '자바스크립트의 비동기 처리 톺아보기'라는 주제로 발표를 진행하게 되어 정리한 글입니다.

먼저, 비동기란 무엇일까요? 🤔

비동기 프로그래밍은 작업이 완료될 때까지 기다리지 않고 ( ... ) 해당 작업이 실행되는 동안에도 다른 이벤트에 응답할 수 있게 하는 기술입니다.

여기서 생략된 빈칸을 설명하기에 앞서, 먼저 동기 프로그래밍의 예제 코드를 살펴봅시다.

console.log(1);
console.log(2);
console.log(3);

다음과 같은 코드의 출력 결과는 어떻게 될까요? 당연하게도 1, 2, 3 이 순서대로 출력될 것입니다. 브라우저는 코드를 순차적으로 한 줄씩 실행합니다. 즉, 한 라인의 작업이 끝나야 다음 라인의 코드가 실행됩니다. 이를 동기 프로그래밍이라고 부릅니다.

이번에는 함수가 포함된 예제를 살펴봅시다.

function add(x, y) {
  return x + y;
}

const a = 1;
const b = 2;
const sum = add(a, b);

console.log(sum);
// 3

여기서 add()는 동기 함수입니다. add라는 함수가 작업을 마칠 때까지 다음 라인으로 넘어가지 않고, 값을 반환한 뒤에 console.log가 실행되어 3이라는 값이 정상적으로 나왔습니다.

지금까지 봤을 때, 작업이 순차적으로 일어나는 건 당연한 일이고 별다른 문제가 없어 보입니다. 그런데도 왜 비동기 프로그래밍이 필요할까요?

이제 처음 봤던 문장으로 돌아가서, 비어있던 빈칸을 채워 볼 차례입니다.

비동기 프로그래밍은 작업이 완료될 때까지 기다리지 않고 잠재적으로 시간이 오래 걸릴 수 있는 작업을 시작하여 해당 작업이 실행되는 동안에도 다른 이벤트에 응답할 수 있게 하는 기술입니다.

현대의 웹 페이지는 단순히 정보 전달만 하는 페이지에서 사용자와의 상호 작용을 이끄는 앱으로 발전하고 있습니다. 따라서 프론트엔드 영역에서는 주기적으로 발생하는 인터랙션을 처리하는 일이 많아졌고, 사용자에게 연속적으로 변경되는 화면을 실시간으로 보여줄 수 있어야 하게 되었습니다.

이런 상황에서 모든 인터랙션을 동기적으로 처리한다면, 특히나 잠재적으로 시간이 오래 걸릴 수 있는 여러 작업을 동기적으로 처리한다면 어떻게 될까요?

시간이 오래 걸리는 작업 아래에 있는 코드들은 몇 초 뒤에나 실행되는 상황이 일어날 수도 있습니다. 브라우저가 제공하는 많은 기능, 타이머를 이용한 이벤트나 HTTP를 이용한 네트워크 통신 같은 작업은 시간이 오래 걸릴 수 있으므로 비동기 프로그래밍이 필요하게 되었습니다.


자바스크립트로 비동기 프로그래밍하기 ⚙️

위와 같은 문제점을 해결하기 위해 여러 가지 비동기 프로그래밍 방법이 생겼고 크게 콜백(Callback), Promise, async/await 패턴이 등장했습니다.

1️⃣ 콜백(Callback)

콜백은 다른 함수의 파라미터로 넘겨지는 함수를 말하며, 작업을 순차적으로 실행하고 싶을 때 사용합니다.

function loginUser(callbackFn) {
  loginRequest('url', function (data) {
    callbackFn(data);
  }); 
}

자바스크립트 개발자들은 꽤 오래 비동기 처리를 위해 콜백 함수를 사용해왔지만, 최근에는 몇 가지 문제점들로 인해 단순히 콜백 함수만을 사용한 비동기 처리를 거의 하지 않고 있습니다.

❌ 콜백 함수의 문제점

1. 제어권 역전

function userTasks() {
  setUser();
  loginUser(refreshToken);
}

function loginUser(callbackFn) {
  // ajaxRequest -> 비동기 요청을 수행하는 외부 라이브러리의 메서드
  ajaxRequest('url', function (data) {
    callbackFn(data);
  }); 
}

function refreshToken(data) {
	somethingFunc(data);
}

userTasks();

다음과 같은 예제 코드가 실행되었다고 생각해봅시다.

처음 userTasks 가 호출되었을 때, setUser 함수는 제어의 대상이 됩니다. 그러나 loginUser 에서 ajaxRequest 라는 외부 라이브러리의 메서드에서 비동기 요청을 처리하게 됐을 때, 함께 전달 된 콜백 함수는 외부 라이브러리에 의존성을 가지게 되어 제어권의 주체가 뒤바뀝니다.

쉽게 말하면, 외부 라이브러리를 사용하는 입장에서는 외부 라이브러리가 콜백 함수를 제대로 호출한다는 것을 보장할 수 없기 때문에 콜백 함수를 그저 잘 처리하기만을 바라는 것입니다.

따라서, 에러에 대처하기 위한 코드를 추가로 작성해야 합니다. 이렇게 되면 제어권이 넘어간 비동기 코드를 작성할 때마다 에러를 대처하기 위해 추가적인 코드를 작성해야 하는 상황에 놓이게 됩니다.

ajaxRequest('url', function(data, error) {
  if (error) {
    handleError(error);
  } else {
    // ...
    ajaxRequest('url', function(data, error) {
      if (error) {
        handleError(error);
      } else {
        // ...
        ajaxRequest('url', function(data, error) {
          if (error) {
            handleError(error);
          } else {
            // ...
          }
        });
      }
    })
  }
});

결과적으로, 신뢰할 수 없는 코드들과 유지보수가 힘든 코드들이 늘어날 가능성이 높습니다.

2. 콜백 지옥

코드만 봐도 충분한 설명이 된다고 생각합니다.

doSomething1(function(something1) {
  doSomething2(function(something2) {
    doSomething3(function(something3) {
      // Do something with something3...
    }, failureCallback);
  }, failureCallback);
}, failureCallback);

다음과 같은 문제점들을 개선하기 위해서 프로미스(Promise)가 등장합니다.


2️⃣ Promise

Promise는 ES6부터 등장한 문법이며, 미래에 어떤 종류의 결과가 반환됨을 약속(promise)해주는 객체라고 볼 수 있습니다. Promise를 통해 콜백 함수의 문제점이었던 제어권 역전을 해결하고자 했습니다. 즉, 코드에 대한 제어권을 되찾고자 하는 목표가 있었습니다.

이러한 목표를 달성하기 위해 Promise는 다음과 같은 특징을 가지고 있습니다.

  1. 비동기 요청 수행에 대한 세 가지(대기 -> 성공 / 실패)의 상태를 가지고 있다.
  2. 내부에서 비동기 요청이 끝나고 나면 결괏값을 연결된 콜백으로 보내준다.
// executor는 new Promise에 의해 자동으로, 즉각적으로 호출됩니다.
// executor는 resolve와 reject를 인자로 받으며, 둘 중 하나는 반드시 호출해야 합니다.
const loginUser = new Promise((resolve, reject) => {
  console.log('something works..');
  ajaxRequest('url', function (data) {
    if (data) {
      resolve(data);
    } else {
      reject(new Error('something went wrong..'));
    }
  }); 
});

loginUser
  .then(function (data) {
    // 요청이 성공했을 경우, 반환된 data를 이용하여 추가적인 작업 수행
    somethingFn(data);
  })
  .catch(function (error) {
    // 요청이 실패했을 경우, 예외 처리
    handleError(error);
  })
  .finally(function () {
    // 성공, 실패 여부와 상관없이 항상 실행
    console.log('finally');
  });
    

이로써, Promise를 통해 비동기 요청의 결과에 따른 처리를 직접 할 수 있게 되었습니다. 외부 라이브러리에서 제어권을 되찾았기 때문에 신뢰할 수 없었던 여러 상황에 대해서 대처할 수 있게 되었으며, 체이닝을 통해 구조화된 코드를 작성할 수 있습니다.

doSomething1
  .then((something1) => doSomething2(something1))
  .then((something2) => doSomething3(something2))
  .then((something3) => doSomething4(something3))
  .catch(failureCallback);

위 코드는 더 정리해서 다음과 같이 표현할 수도 있습니다.

doSomething1
  .then(doSomething2)
  .then(doSomething3)
  .then(doSomething4)
  .catch(failureCallback);

결과적으로 Promise를 통해 콜백의 문제점들이 해결됐으나, 이 역시도 몇 가지 고려할 점이 있습니다.

에러를 잡을 때 몇 번째에서 발생하는지 알아내기 어렵고, 특정 조건에 따른 분기를 나누는 작업이 어려우며, 연관성이 부족하더라도 다음 체이닝으로 넘겨주려면 객체, 배열로 감싸 넘겨주어야 하므로 특정 값을 공유하면서 작업을 처리하는 데 한계가 있었습니다.

하지만, 이를 고려하더라도 Promise가 제공하는 신뢰성과 예측성은 충분한 가치가 있습니다. Promise가 기존의 콜백을 완전히 없애는 것은 아니지만, 프로미스와 콜백을 적절히 사용함으로써 더 좋은 코드를 작성할 수 있게 되었습니다.

다음 포스팅에서 Generator와 async/await을 이어서 다루겠습니다.

참고한 글 및 영상의 출처도 다음 포스팅의 맨 아래에 달아두었습니다. 감사합니다.

profile
Front-end Developer

0개의 댓글