Promise란 무엇인가

Siyoon's dev blog·2023년 10월 23일
post-thumbnail

들어가기에 앞서

이번 포스트는 "Promise 객체"에 대한 개념을 깊게 이해하기 위해 필요한 동기/비동기에 관한 설명 그리고 Prmoise객체를 실제 사용하며 제가 궁금했던 부분들 위주로 다룹니다.

Promise 객체에 대한 내용을 다루기 이전에 Prmosie 객체가 왜 등장하게 되었는지, 다시 말해 프로그램을 개발하며 Prmoise 객체가 왜 필요한지에 대해 이해하기 위해 "동기와 비동기 개념"에 대해 먼저 알아보려 합니다.

동기 (synchronous)

동기란 무엇일까요?

어떤 사람은 동시에 발생하는 것, 어떤 사람은 특정한 클럭을 정해 통신하는 것, 어떤 사람은 상태를 통일하게 만드는 것 등 동기라는 단어의 쓰임새가 다양한 만큼 다양한 해석들이 존재합니다.

synchronous의 사전적 의미 "동시에 발생하는"이라는 형용사입니다. 저 역시 동기란 동시에 발생하는 것이라고 알고있었는데, 컴퓨터 공학에서의 동기와 뉘앙스가 약간은 다르다는 것을 뒤늦게 파악했습니다.

컴퓨터 공학에서의 동기

"자바스크립트는 동기적으로 동작한다." 라는 명제를 처음 들었을 때, 제가 원래 알고있던 "동기" 의 의미를 무작정 대입하여 해석하였습니다.
"자바스크립트가 동기적으로 동작한다는건, 현재 작업의 요청들이 동시에 발생한다는 뜻이구나!" 라구요.
아래 그림처럼 말이죠. (잘못된 해석과 그림입니다)

바보같은 해석이라고 생각하시는 분들도 있겠지만, 저와 같이 이해하신 분들이 있을 거라고 생각합니다. ㅎㅎ
위 그림과 같이 동기의 개념을 정반대로 이해하게 된 이유는 주어가 잘못되었기 때문이었습니다.

("현재 작업의 요청들이 동시에 발생한다")
그렇다면 무엇이 동시에 발생한다는 걸까요?
구글링을 통해 아주 명쾌한 답을 얻을 수 있었습니다.

"현재 작업의 요청"들이 동시에 발생한다가 아니라 "현재 작업의 응답과 다음 작업의 요청이" 동시에 발생한다고 정의하면 보다 직관적으로 이해할 수 있게 되더라구요.

위 그림처럼 말이죠.
즉,컴퓨터 공학에서 "동기적으로 동작한다"는 것은 현재의 작업의 응답이 발생함과 동시에 다음 작업을 요청한다는 것과 같이 어떠한 순서를 가지고 진행된다는 것을 의미합니다.

비동기 (asynchronous)

비동기 방식은 말 그대로 동기 방식이 아니라는 의미이기 때문에 반대로 생각하면 됩니다.즉, 현재 작업의 응답과 다음 작업의 요청의 타이밍이 일치하지 않아도 된다는 것입니다.

동기 방식은 상위 프로세스가 하위 프로세스에게 작업을 지시할 때 작업의 종료 시점을 알고 있어야하기 때문에 항상 작업을 시킨 상위 프로세스가 신경을 쓰고 있습니다.
반대로 비동기 방식에서는 상위프로세스가 작업을 일단 지시했으면 그 다음부터는 작업이 진행 중이든 종료가 되었든 신경을 쓰지 않습니다. 즉, 작업의 종료가 순차적으로 이루어지는 것을 보장하지 않습니다.

비동기는 언제, 왜 사용할까?

동기와 비동기는 프로세스의 수행 순서 보장에 대한 메커니즘입니다.
동기 방식은 간단하고 직관적이지만, 작업이 오래 걸리거나 응답이 늦어지는 경우에는 전체적인 성능과 사용자 경험에 영향을 주게됩니다. 예를 들어 서버에 데이터를 요청하고 응답을 받아야하는 작업이 있다면, 응답이 올 때까지 다른 작업을 하지 못하고 대기해야 합니다.

따라서 자바스크립트로 여러 작업을 동시에 처리하기 위해 비동기 개념을 도입하여 특정 작업의 완료를 기다리지 않고 다른 작업을 동시에 수행할 수 있도록 하였습니다. 비동기는 메인 스레드가 작업을 다른 곳에 인가하여 처리하게 되고, 그 작업이 완료되면 콜백 함수를 받아 실행하는 방식입니다. 쉽게 말해 작업을 백그라운드에 요청하여 처리하게 되어 멀티로 작업을 동시에 처리하는 것으로 볼 수 있습니다.
(관련 내용을 더 깊게 알기 위해 필요한 자바스크립트 엔진과 브라우저의 이벤트 루프의 개념에 관한 포스팅은 추후에 올리도록 하겠습니다.)

일반적으로 다음과 같은 작업들을 비동기적으로 처리합니다.
1. API 요청: 서버에서 데이터를 받아와야하는 경우 요청을 하고 서버에서 응답을 할 때까지 대기를 해야하기 때문에 비동기적으로 처리합니다.
2. 파일 읽기: 주로 서버 쪽에서 파일을 읽어야 하는 상황에는 비동기적으로 처리합니다.
3. 암호화/복호화: 암호화/복호화를 할 때에도 바로 처리가 되지 않고, 시간이 어느정도 걸리는 경우가 있기 때문에 비동기적으로 처리합니다.
4. 작업 예약: 어떤 작업을 몇초 후에 스케쥴링 해야 하는 상황에는 setTimeout을 사용하여 비동기적으로 처리합니다.

비동기는 응답이 오는 것을 기다리지 않고 병렬로 작업을 동시처리하는 것이 가능해져 프로그램의 흐름이 멈추지 않는다는 장점이 있습니다. 반대로 실행할 작업에 이전에 요청한 작업의 결과가 반드시 필요한 경우에는 문제가 생기게 됩니다.
이렇게 작업의 순서를 맞춰야 하는 경우 어떻게 비동기를 제어하는지 알아보겠습니다.

비동기 제어

자바스크립트에서 비동기를 제어하는 대표적인 3가지 기법 콜백 함수, Promise, async&await에 대해서 알아보겠습니다.

비동기 제어(1) - 콜백함수

콜백 함수는 자바스크립트의 일급 객체 특성을 이용해 함수의 매개변수에 함수 자체를 넘겨, 함수 내에서 매개변수 함수를 실행하는 기법을 말합니다.

아래 예시를 보겠습니다.

function getDB(callback) {
    // 데이터베이스로부터 3초 후에 데이터 값을 받아온 후, 콜백 함수 호출
    setTimeout(() => {
        const value = 100;
        callback(value);
    }, 3000);
}

function main() {
    // 호출할 작업에 콜백 함수를 넘긴다
    getDB(function(value) {
        let data = value * 2;
        console.log('data의 값 : ', data);
    });
}
main();

위 코드에서 main함수에 안에 있는 변수 data에 값을 할당하기 위해서는 value의 값이 필요하고, value값을 알기까지 3초의 시간이 필요합니다. 비동기적으로 동작하는 코드를 별도로 제어하지 않는다면 data의 값이 NaN이 나오는 문제가 발생할겁니다. data의 값이 계산되는 동안 value의 값을 받아오지 못했기 때문이죠. 이를 해결하기 위해 data값을 가져오는 함수를 getDB 함수의 매개 변수에 넣었습니다. 이로써 3초 뒤에 value값을 알고 난 이후 콜백 함수를(data값을 가져오는 함수) 실행하여 비동기를 처리할 수 있게됩니다.

이와 같이 콜백 함수는 작업 순서를 맞춰 비동기를 제어할 수 있습니다. 하지만 너무 복잡하게 얽힌 비동기 처리 때문에 콜백 함수 방식은 코드 복잡도를 증가시켜, 개발자가 어플리케이션의 흐름을 읽기 어려워지는 문제가 생길 수 있습니다.
콜백 지옥
조금 억지스러운 코드지만 콜백 함수들을 복잡하게 중첩하여 구성한다면 위와 같이 어질어질한 코드 모양이 나오게 됩니다.

비동기 제어(2) - Promise 객체

이러한 한계점을 해결하고자 등장한 것이 Promise 객체입니다.
Promise 객체는 비동기 처리를 위한 전용 객체로서 탄생했습니다.

Promise 객체로 관리할 비동기 작업을 만들 때에는 new Promise(...)를 사용합니다. 아래 예제 코드를 봐주세요.

const promise1 = new Promise((resolve, reject) => {
  // 제어하고 싶은 비동기 작업
});

new Promise(...)로 Prmoise 객체를 새롭게 만들었습니다. (생성자는 함수와 동일하게 동작하므로, 괄호()를 써서 함수를 호출하는 것과 같은 모습입니다.)
생성자는 특별한 함수 하나를 인자로 받습니다. (여기서 인자로 들어가는 함수의 형태는 화살표 함수입니다.)
이 특별한 함수는 공식문서에서는 executor라는 이름으로 부릅니다. executor 함수 안에 "제어하고 싶은 비동기 작업"을 넣어주시면 됩니다.

executor는 첫번째 인수로 resolve, 두번째 인수로 reject를 받습니다.
resolve, reject는 executor함수 내에서 호출할 수 있는 또 다른 함수입니다. executor함수가 resolve를 호출한다면 비동기 작업이 성공했다는 뜻입니다. 반대로 reject를 호출한다는건 비동기 작업이 실패했다는 뜻이죠.

Promise 가 끝나고 난 다음의 동작을 우리가 설정해줄 수 있는데, 그것이 바로 then 메소드와 catch 메소드입니다.

then 메소드는 해당 Promise 가 성공했을 때의 동작을 지정합니다. 인자로 함수를 받습니다.
반대로 catch 메소드는 해당 Promise 가 실패했을 때의 동작을 지정합니다. 인자로 함수를 받습니다.

Promise 사용 예시

new Promise(...) 를 하는 순간 비동기 작업이 시작됩니다. 비슷한 비동기 작업을 수행할 때마다 매번 new Promise(...)를 생성할 필요 없이 new Promise(...) 한 것을 그대로 리턴하는 함수를 만들어 사용하면 됩니다. 아래 함수는 age 인자를 받아서 그 값에 따라 resolve 또는 reject를 호출합니다.

function startAsync(age) {
  return new Promise((resolve, reject) => {
    if (age > 20) resolve();
    else reject();
  });
}

const promise1 = startAsync(25);
promise1
  .then(() => {
    console.log("1 then!");
  })
  .catch(() => {
    console.log("1 catch!");
  });

const promise2 = startAsync(15);
promise2
  .then(() => {
    console.log("2 then!");
  })
  .catch(() => {
    console.log("2 catch!");
  });
  
// output
25 success
Error: 15 is not over 20
    at startAsync (file:///Users/th.kim/Desktop/easy-promise-async-await/src/13.mjs:11:14)
    at file:///Users/th.kim/Desktop/easy-promise-async-await/src/13.mjs:23:18
    at ModuleJob.run (node:internal/modules/esm/module_job:193:25)
    at async Promise.all (index 0)
    at async ESMLoader.import (node:internal/modules/esm/loader:526:24)
    at async loadESM (node:internal/process/esm_loader:91:5)
    at async handleMainPromise (node:internal/modules/run_main:65:12)

startAsync 함수를 호출하는 순간 new Promise(...) 가 실행하게 되어 비동기 작업이 시작됩니다. 비동기 작업이 성공할지 실패할지 여부에 따라 then 동작 또는 catch 동작이 실행됩니다.
(promise1 은 성공하고, promise2 는 실패하도록 만들었는데요, 그래서 promise1 에서는 catch 으로 등록했던 함수는 실행되지 않고 then 으로 지정한 동작만 수행합니다. promise2 는 반대로 then 동작은 수행하지 않고 catch 동작만 수행합니다.)

하지만 비동기를 제어하는데 있어서 Promise도 완벽한 해결책은 아닙니다. 앞서 본 콜백 지옥이 있듯이 지나친 then 핸들러 함수의 남용으로 인한 Promise 지옥이 존재하기 때문입니다. 즉, Promise가 여러 개 연결되면 코드가 길어지며 가독성이 떨어질 수 있습니다. 그래서 자바스크립트에는 async/await 문법이 추가되었습니다. 비동기 작업을 쉽게 읽고 이해할 수 있게 해주는 async/await에 대해서 알아보겠습니다.

비동기 제어(3) - async/await

async키워드는 함수를 선언할 때 붙여줄 수 있습니다. 바로 예시 코드를 보겠습니다.

// Prmoise 예시 코드 
// function startAsync(age) {
//   return new Promise((resolve, reject) => {
//     if (age > 20) resolve(`${age} success`);
//     else reject(new Error(`${age} is not over 20`));
//   });
// }

async function startAsync(age) {
  if (age > 20) return `${age} success`;
  else throw new Error(`${age} is not over 20`);
}

const promise1 = startAsync(25);
promise1
  .then((value) => {
    console.log(value);
  })
  .catch((error) => {
    console.error(error);
  });

const promise2 = startAsync(15);
promise2
  .then((value) => {
    console.log(value);
  })
  .catch((error) => {
    console.error(error);
  });
  
// output
25 success
Error: 15 is not over 20
    at startAsync (file:///Users/th.kim/Desktop/easy-promise-async-await/src/13.mjs:11:14)
    at file:///Users/th.kim/Desktop/easy-promise-async-await/src/13.mjs:23:18
    at ModuleJob.run (node:internal/modules/esm/module_job:193:25)
    at async Promise.all (index 0)
    at async ESMLoader.import (node:internal/modules/esm/loader:526:24)
    at async loadESM (node:internal/process/esm_loader:91:5)
    at async handleMainPromise (node:internal/modules/run_main:65:12)

위 Promise 예제에서 다뤘던 startAsync함수를 async/await 버전으로 변경해보았습니다.
Promise 예제와 똑같은 결과가 리턴되는 것을 확인할 수 있습니다. async 키워드를 함수에 붙여주면 리턴 값은 무조건 Promise 객체가 됩니다. 즉, async 함수를 실행시킨 뒤 then과 catch를 활용하여 흐름을 제어할 수 있다는 뜻입니다.
앞서 본 Promise에서 꼽았던 단점이 then 핸들러 함수의 남용으로 인한 Promise 지옥이었는데요. async함수에서도 then/catch를 사용한다면 Promise와 크게 다를게 없을겁니다. 그래서 우리는 async 함수 안에서 await을 사용합니다. await은 한국어로 "기다리다"라는 뜻입니다.

async function startAsync(age) {
  if (age > 20) return `${age} success`;
  else throw new Error(`${age} is not over 20`);
}

async function startAsyncJobs() {
  await setTimeoutPromise(1000);
  const promise1 = startAsync(25);

  try {
    const value = await promise1;
    console.log(value);
  } catch (e) {
    console.error(e);![](https://velog.velcdn.com/images/ksyoon0718/post/1118da30-20f7-4d9c-878b-cfe7d4c83569/image.png)

  }

  const promise2 = startAsync(15);

  try {
    const value = await promise2;
    console.log(value);
  } catch (e) {
    console.error(e);
  }
}

startAsyncJobs();

25 success
Error: 15 is not over 20
    at startAsync (/home/taehoon/Desktop/playground-nodejs/index.js:17:14)
    at startAsyncJobs (/home/taehoon/Desktop/playground-nodejs/index.js:29:20)

이번 예제에서는 then/catch대신 await과 try-catch구문을 사용했습니다.
await은 Promise 객체가 fulfilled 되든지 rejected가 되든지 끝날때까지 기다려주는 역할을 합니다.

await은 async함수 내부에서만 사용할 수 있는데, Promise가 resolve한 값을 내놓게 됩니다. 만약 Promise에서 reject가 발생한다면 예외가 발생하게 되고, 예외 처리를 하기 위해 일반적으로 try-catch구문을 사용합니다. async/await은 Promise를 기반이지만 마치 동기 코드처럼 작성할 수 있어서 가독성이 좋습니다.

Promise와 async/await의 차이

자바스크립트에서는 Promise와 async/await을 이용해 비동기 작업을 처리할 수 있습니다.
그렇다면 이 둘의 차이점은 무엇이며 어떤 상황에서 어떤 걸 쓰는게 더 합리적일까요?

기능적인 측면

기능적인 측면으로 볼때, Promise와 async/await은 실행 컨택스트에서 차이가 있습니다.

Promise가 생성되고 나면 이후 코드는 동기적으로 실행됩니다. 그러다가 Promise가 resolved되거나 rejected 되는 순간 생성된 Promise에 체이닝으로 이어지는 콜백함수(then/catch)들은 microtask-queue에 추가됩니다.
microtask-queue에 추가된 콜백함수들은 다음에 실행하기로 예정되어있던 동기 작업보다 먼저 실행됩니다. 이후 실행 예정되어있던 콜스택에 있던 작업들이 microtask-queue에 들어있는 작업이 끝나기 전까지 멈춰있는거죠. (이걸 블록킹이라고 합니다)

반대로 async/await에서는 await 키워드가 등장하는 순간 async함수의 실행만 일시적으로 멈춥니다. await으로 처리중인 작업이 microtask queue에 들어가기 되고, async 함수는 작업이 resolve될때까지 멈춰있지만, async함수 이후에 실행하기로 예정되어있던 콜스택에 쌓인 동기 작업들은 순서대로 실행되게 됩니다. 즉, async함수 내에서는 await끼리의 순서만 보장됩니다. (이걸 논블록킹이라고하며 블로킹/논블로킹 개념은 추후에 더 자세히 정리하겠습니다.)

사용적인 측면

비동기와 관련된 아티클을 읽다보면 async/await을 Syntactic sugar라고 표현한 문장을 꽤 볼 수 있습니다. (컴퓨터 과학에서 Syntactic sugar은 더 쉽게 읽을 수 있도록 설계된 프로그래밍 언어 내의 구문입니다. 이와 같은 구문은 개발자들이 기능을 "더 달콤"하게 사용할 수 있도록 도와줍니다.)

즉, async/await은 Promise와 같은 기능을 제공하며 비동기 코드를 동기 코드처럼 사용할 수 있게 해줍니다. 보다 읽기 쉽고 이해할 수 있게 해주기 때문에 비동기 작업을 처리할 일이 있다면 대게 async/await 방식을 쓰는 것이 일반적입니다.

그렇다고 비동기 처리는 항상 async/await이 정답은 아닙니다.
오래된 라이브러리를 사용하는 경우 async/await보다는 Promise와 호환이 잘되는 경우가 있습니다. 또한 여러 비동기 요청을 병렬적으로 처리하고 싶은 경우, Promise.all()을 사용해 더 간결하게 처리할 수 있습니다.
따라서, Promise와 async/await은 상황에 따라서 용도에 맞게 적절히 사용해야 합니다.

마무리

팀원의 코드를 리뷰하며 비동기 처리를 async/await이 아닌 Promise로 처리한 이유가 궁금했습니다. async/await과 Promise의 차이에 대해 구글링하며 이런 저런 설명을 보다보니 동기와 비동기의 기본 개념에 대해서도 더 혼란스러워졌고, 제가 굉장히 단편적인 내용만을 알고 개발을 하고 있었다고 느꼈습니다.
이해가 안되었던 개념들을 문서와 블로그를 보며 공부하고 저의 언어로 정리를 하고나니 약간은 이해가 가는 것 같습니다.
수정할 부분이 있다면 부디 코멘트 부탁드립니다 🙏

참고

profile
직접 글을 쓰는 지루함을 즐기기 위한 블로그

0개의 댓글