본문 바로가기
이론/Frontend

자바스크립트로 비동기 처리하기 : Callback과 Promise

by 유세지 2023. 10. 24.

 

 

 

들어가며

자바스크립트는 한 번에 하나의 작업만 처리할 수 있는 싱글 쓰레드 언어입니다. 따라서 모든 작업은 동기적으로 처리되는것을 기본으로 합니다. 그러나 실제로는 네트워크 통신 등 비동기적인 처리가 꼭 필요한 상황들이 많기 때문에, 자바스크립트는 런타임의 도움을 받아 이러한 비동기 처리를 지원해왔습니다.

 

여기서의 런타임은 브라우저나 Node.js와 같은 실행 환경을 의미하는데, 브라우저의 경우 Web API라는 인터페이스를 지원하여 이러한 기능들을 수행할 수 있는 런타임에 접근할 수 있습니다.

 

정확히 어떤 과정을 거쳐 비동기 작업이 진행되는지는 아래 포스팅에 정리해두었으니, 아래 내용을 읽기 전 확인해보시면 이해에 도움이 될 것 같습니다.

 

 

오늘 살펴볼 것은 자바스크립트에서 구체적으로 어떤 방법들을 이용해야 이러한 비동기 작업을 수행하고, 제어할 수 있는지에 대해서입니다.

 

자바스크립트에서 비동기를 처리하는 방법에는 크게 세 가지가 있습니다. 각각 callback, promise, async/await입니다. 각각의 키워드들은 나열된 순서대로 자바스크립트 스펙에 등장하였습니다. 각 키워드가 등장한 시간순으로 하나씩 자세히 알아보겠습니다.

 

 

Callback

가장 먼저 살펴볼 것은 콜백(Callback)입니다. 

 

우리가 어떤 작업을 비동기로 수행하게 되면, 해당 기능은 코드의 동작 흐름에서 벗어나 브라우저의 (혹은 Node.js, 여기서는 편의를 위해 런타임을 브라우저로 통일하겠습니다.) 이벤트 루프로 이동하게 됩니다. 여기에서 작업이 완료되면 그 결과를 토대로 다음 할 일을 지시해주어야 할텐데, 이미 그 기능은 코드 동작 흐름에서 벗어났기때문에 프로그래머가 이를 제어하기는 어렵습니다.

 

따라서 작업을 수행하기 전에 그 뒤의 할 일을 미리 정해서 이벤트 루프로 함께 넘겨주어야했고, 이때 그 할 일들을 지정한 것을 바로 콜백 함수라고 부릅니다.

 

코드로 예시를 보겠습니다.

 

// 대표적인 Web API, setTimeout() 입니다.
setTimeout(() => {
  console.log("실행 완료");
}, 1000);

 

setTimeout()은 대표적인 Web API 중 하나입니다. 이 메서드는 두 개의 인자를 받는데 첫 번째 인자에는 실행할 동작을 받고, 두 번째 인자에는 동작 수행까지 기다릴 시간을 받습니다.

 

위 코드에서 () => { console.log("실행 완료"); }는 실행할 동작(콜백 함수)이고, 1000은 기다릴 시간을 의미합니다. 즉, 1000ms 뒤에 실행할 동작을 미리 받아가서, 해당 시간이 지나면(작업이 완료되면) 받아간 동작인 콘솔 로그에 "실행 완료"를 출력하라를 수행하게 됩니다.

 

이처럼 콜백 함수는 비동기 작업에 반드시 필요한 요소임을 알 수 있었습니다. 비단 비동기 작업 뿐만 아니라 특정한 동작을 넘긴다는 개념 자체가 굉장히 유용해서, 지금도 다양한 곳에 널리 쓰이고 있는 기능입니다.

 

 

그러나 비동기 작업에서의 콜백은 치명적인 단점이 있었습니다.

바로 끔찍한 가독성입니다.

 

 

Callback의 단점 : 가독성

다음 작업을 수행할 코드를 작성해보겠습니다.

 

  1. 1000ms 가 지나면, "안녕하세요." 를 출력한다.
  2. 1번이 출력되면, 2000ms 뒤에 "반가워요." 를 출력한다.
  3. 2번이 출력되면, 3000ms 뒤에 "오늘은 날씨가 좋네요." 를 출력한다.
  4. 3번이 출력되면...

 

이를 코드로 옮기면 대략 이런 모양입니다.

 

setTimeout(() => {
  console.log("안녕하세요.");
  setTimeout(() => {
    console.log("반가워요.");
    setTimeout(() => {
      console.log("오늘은 날씨가 좋네요.");
      ...
        ...
        ...
      ...
    }, 3000);
  }, 2000);
}, 1000);

 

흔히 콜백 지옥(Callback hell)이라고 불리는 위의 코드 형태는 가독성을 떨어뜨리고,

프로그래머로 하여금 코드를 이해하기 어렵게 만듭니다.

 

지금은 각 콜백 함수마다 console.log() 하나만 들어가 있어 큰 문제는 없지만

실제 프로그램의 로직은 훨씬 더 길고 복잡하기 때문에 이러한 형태는 피하는 것이 좋습니다.

 

코드 복잡도야 로직 분리를 통해 어떻게든 관리한다고 하더라도, 더 큰 문제는 사실 제어권이 넘어가는데에 있습니다.

아래 코드를 보겠습니다.

 

 

Callback의 단점 : 제어 역전

// Javascript
function buyProduct(productID) {
  payment.requestPayment(productID, (state) => {
    if (!state) {
      alert("오류가 발생하였습니다.");
      return;
    }
    
    alert("상품 결제가 완료되었습니다.");
    location.href = `${baseURL}`;
  });
}

// HTML
<button onClick={(item) => buyProduct(item.id)}>구매하기</button>

 

콜백으로 구현 된 쇼핑몰의 결제 코드라고 생각해보겠습니다.

 

서드 파티 라이브러리인 paymentrequestPayment라는 메서드를 통해 결제를 요청했고,

상품 아이디인 productID와 이후의 동작을 콜백으로 보낸 모습입니다.

 

해당 라이브러리에서는 state 인자를 통해 결제가 정상적으로 완료되었는지 여부를 알려주기로 했는데,

어느 날 모듈의 스펙이 변경되어 boolean 타입의 true와 false가 아닌 "true"와 "false"가 들어오게 되었다고 합시다.

 

자바스크립트의 특성상 비어있지 않은 문자열은 내용에 관계없이 truthy(true로 취급) 합니다. 위 코드에서처럼 if (!state) 로 조건을 판별하면 state가 false일때는 조건문에서 걸러졌겠지만, state가 "false" 라면 오류가 발생했음에도 상품 결제가 완료되었다는 알림창을 받게 됩니다.

 

이런식으로 코드의 제어권이 외부로 넘어간 경우 프로그래머의 입장에서는 대처하기가 까다로워집니다. 이런 사태에 대비하여 코드를 안전하고 보수적으로 작성하려고 하면 할수록, 그 여파로 길고 복잡해진 로직을 맞닥뜨려야 하기 때문입니다.

 

 

Callback은 자바스크립트에서 비동기 작업을 가능하게 해주는 없어서는 안될 방식이었지만, 이렇듯 분명한 단점들을 가지고 있었습니다. 시간이 갈수록 프로그래머들은 이에 불편함을 느꼈고 이러한 기존의 단점들을 해소한 새로운 방법을 필요로 하게 되었습니다.

 

 

Promise

Promise는 Callback의 불편함을 해소할 수 있는 비동기 작업을 위한 객체로, ES6에서 처음 등장했습니다. 기존의 콜백이  작업 이후의 동작을 넘겨주는 방식이었던 것과 다르게, Promise는 반대로 작업의 상태를 넘겨받는 방식으로 작동합니다.

 

Promise에서 작업의 상태는 세 가지로 구분됩니다. 차례로 대기(pending), 완료(fulfilled), 거부(rejected) 입니다. 작업이 진행중이거나 시작하지 않았을때는 대기 상태가 되고, 작업이 완료되어 성공했다면 완료 상태가, 실패했다면 거부 상태가 됩니다.

 

아까의 결제 코드를 callback 대신 Promise를 사용한 버전으로 다시 보겠습니다.

 

// Javascript
// 전제 : requestPayment는 Promise 객체를 반환함
function buyProduct(productID) {
  payment.requestPayment(productID)
    .then(() => {
      alert("상품 결제가 완료되었습니다.");
      location.href = `${baseURL}`;
    })
    .catch((error) => {
      alert(error + "오류가 발생하였습니다.");
    });
}

// HTML
<button onClick={(item) => buyProduct(item.id)}>구매하기</button>

 

요청에 성공하면 then() 내부의 함수가 실행되고, 실패하면 catch() 내부의 코드가 실행됩니다. 

 

callback과 비교했을때 단순히 로직이 길어진다고 해서 뎁스가 끝도없이 쌓이는 일도 없고, then 체이닝을 이용해 이후 동작들을 단계별로 관리할 수 있기 때문에 인간이 보기에도 훨씬 편한 코드라는 느낌을 줍니다.

 

여기에 requestPayment()에서 엉뚱한 오류를 뱉거나 아예 응답을 하지 않는다고 하더라도, 프로그램의 흐름은 여전히 프로그래머가 관리하고 있기 때문에 적절히 대응해 줄 수 있습니다. 이처럼 프로그램의 제어권을 개발자가 가지고 있기에, 상대적으로 안정적인 코드를 쓸 수 있는것이 Promise의 장점입니다.

 

다음은 Promise 객체가 지원하는 몇 가지 메서드를 통해, 다양한 비동기 동작들을 어떻게 처리할 수 있는지 알아보겠습니다.

 

 

Promise.all()

여러 개의 프로미스를 이행시킬때 사용하는 메서드입니다. 배열과 같은 순회 가능한 객체에 프로미스들을 담아 인자로 넘겨주면 해당 프로미스들을 이행시키고, 그 중 하나라도 실패하게 되는 경우 곧바로 거부(rejected)합니다.

 

이 메서드는 특정 작업을 하기 전, 여러 개의 선행되어야 할 비동기 작업이 있을때 사용합니다.

 

const asyncTask1 = new Promise((resolve, reject) => {
  setTimeout(() => resolve("1번 작업"), 1000);
});

const asyncTask2 = new Promise((resolve, reject) => {
  setTimeout(() => resolve("2번 작업"), 2000);
});

Promise.all([asyncTask1, asyncTask2])
  .then((values) => {
    console.log(values);
  })
  .catch((error) => {
    console.log(error.message);
  });

 // ['1번 작업', '2번 작업']

 

만약 실패 여부에 관계없이 일단 프로미스가 이행되도록 하고싶다면, Promise.all()대신 Promise.allSettled()을 사용할 수 있습니다.

 

한 가지 주의할 점은, 프로미스가 순차적으로 완료되지 않는다는 점입니다. 메서드의 인자로 [1번, 2번, 3번] 의 프로미스 배열을 넣었다고 하여 완료되는 순서 또한 1번, 2번, 3번 순으로 고정된 것은 아닙니다. 우연의 일치로 그럴수는 있지만, 정확히는 완료 시점을 보장하지 않는다고 표현해야겠네요.

 

 

Promise.all()은 병렬적으로 실행됩니다.

 

 

비록 메서드의 결과값이 처음 순서와 동일하긴 하지만, 이는 이행 이후의 결과값을 순서대로 배열에 넣어 반환하는 것일 뿐 내부의 프로미스 자체는 병렬적으로 실행되는 메서드라는 것을 기억해야 합니다.

 

 

Promise.race()

여러 개의 프로미스를 이행시키되, 메서드의 이름처럼 race(경쟁)를 붙여 가장 먼저 완료되는 결과를 반환합니다. 특히 setTimeout()과 함께 사용했을때 활용도가 높습니다.

 

const asyncTask1 = new Promise((resolve, reject) => {
  setTimeout(() => resolve('성공?'), 10000);
});

const asyncTask2 = new Promise((resolve, reject) => {
  setTimeout(() => resolve(new Error("Timeout Error")), 5000);
});

Promise.race([asyncTask1, asyncTask2])
  .then((values) => {
    console.log(values);
  })
  .catch((error) => {
    console.log(error.message);
  });

// Error : Timeout Error

 

위 코드는 먼저 완료되는 asyncTask2의 프로미스 이행값을 반환합니다. 

 

이 경우 5초가 지나면 Timeout 에러를 반환하게 되는데,

이렇게 요청에 제한 시간을 설정함으로써 응답이 늦어지는 경우를 제어 할 수 있게됩니다.

 

 

마치며

오늘은 자바스크립트에서 비동기 작업을 처리하는 Callback과 Promise에 대하여 알아보았습니다. 

다음 시간에는 Promise 이후에 등장한 async / await에 대해서 알아보겠습니다.

 

읽어주셔서 감사합니다.

 

 

참고 문서

반응형

댓글