JS

Promise 비동기

프도의길 2022. 1. 8. 01:54

Promise

Promise 는 비동기 작업의 단위 입니다. 지금까지 이야기했던 것들은 자바스크립트에서 동시에 여러 가지 작업을 할 수 있다는 개념을 비동기로 은근슬쩍 설명했고, 지금부터는 Promise 를 통해 어떻게 비동기 작업들을 쉽게 관리할 수 있는지를 본격적으로 알아보겠습니다.

기본 사용법

우선 Promise 로 관리할 비동기 작업을 만들 때에는, Promise 에서 요구하는 방법대로 만들어야 합니다. 여러가지 방법이 있지만 제일 정석적인 방법은 new Promise(...) 하는 것입니다. 아래 예제 코드를 봐주세요.

const promise1 = new Promise((resolve, reject) => {
  // 비동기 작업
});
복사하기

문법적으로 보충 설명해보겠습니다.

  1. 변수의 이름은 promise1 이며, const 로 선언했기 때문에 재할당이 되지 않습니다. 하나의 변수로 끝까지 해당 Promise 를 관리하는 것이 가독성도 좋고 유지보수도 하기 좋습니다.
  2. new Promise(...)  Promise 객체를 새롭게 만들었습니다. 생성자는 함수이므로 괄호() 를 써서 함수를 호출하는 것과 동일한 모습입니다.
  3. 생성자는 특별한 함수 하나를 인자로 받습니다. (여기서 인자로 들어가는 함수의 형태는 화살표 함수입니다.)

 특별한 함수를 공식 문서에서는 executor 라는 이름으로 부릅니다. 이 함수에 대해 더 자세히 설명하면 다음과 같습니다.

  1. executor는 첫번째 인수로 resolve 이며, 두번째 인수로 reject 를 받습니다. (이름은 우리가 정의하는 거라서 아무렇게나 해도 사실 상관은 없지만, 국룰을 따르도록 합시다.)
  2. resolve  executor 내에서 호출할 수 있는 또 다른 함수입니다. resolve 를 호출하게 된다면 “이 비동기 작업이 성공했어!” 라는 뜻입니다.
  3. reject 또한 executor 내에서 호출할 수 있는 또 다른 함수입니다. reject 를 호출하게 된다면 “이 비동기 작업이 실패했어…” 라는 뜻입니다.

으악! 생성자 내에 함수 내에 또 다른 함수를 호출한다니 이게 무슨 말입니까! 이게 콜백보다 훨씬 복잡하면 복잡했지 결코 간단해보이지는 않는데요? 아니에요.. 편하다구요… 진짜 믿어주세요. async-await 까지 가야 Promise의 진면모가 드러난답니다. 하여튼 우리는 promise1 이라는 Promise 객체를 얻게 되었습니다!

Promise 의 특징은 new Promise(...) 하는 순간 여기에 할당된 비동기 작업은 바로 시작됩니다. 비동기 작업의 특징은 작업이 언제 끝날지 모르기 때문에 일단 배를 떠나보낸다고 이야기했습니다. 그럼 그 이후에 이 작업이 성공하거나 실패하는 순간에 우리가 또 뒷처리를 해줘야겠죠? Promise 가 끝나고 난 다음의 동작을 우리가 설정해줄 수 있는데, 그것이 바로 then 메소드 catch 메소드입니다.

  • then 메소드는 해당 Promise 가 성공했을 때의 동작을 지정합니다. 인자로 함수를 받습니다.
  • catch 메소드는 해당 Promise 가 실패했을 때의 동작을 지정합니다. 인자로 함수를 받습니다.
  • 위 함수들은 체인 형태로 활용할 수 있습니다. (연속적으로 호출할 수 있습니다. 아래 예제에서 확인하도록 합니다.)

executor 로 새로운 Promise 를 만든 다음 then  catch 를 이용하여 후속 동작까지 지정해줘야 어느정도 제대로 돌아가는 Promise를 만나보실 수 있습니다. 그래서 우선 설명을 엄청 나열했구요, 이제 드디어 예시 코드를 확인해봅시다.

const promise1 = new Promise((resolve, reject) => {
  resolve();
});
promise1
  .then(() => {
    console.log("then!");
  })
  .catch(() => {
    console.log("catch!");
  });
복사하기

이것의 실행 결과는 다음과 같습니다.

then!
복사하기

then 에 함수를 넣어주었고, 연속적으로 catch 에도 함수를 넣어줬습니다. 이 Promise 에서는 바로 resolve 가 호출되었기 때문에 성공으로 간주하여 then 에 있는 동작만 실행됩니다. 이제 아래와 같은 코드를 쓴다면 어떻게 될까요? resolve 부분을 reject 로 수정했습니다.

const promise1 = new Promise((resolve, reject) => {
  reject();
});
promise1
  .then(() => {
    console.log("then!");
  })
  .catch(() => {
    console.log("catch!");
  });
복사하기

이것의 실행 결과는 다음과 같습니다.

catch!
복사하기

예상대로 catch! 만 출력됩니다.

위 예제에서는 비동기 작업이라고 해도 뭔가 기다리는 작업은 하나도 하지 않았습니다. 기다리는 작업은 아까 얘기했듯이 인터넷으로부터 데이터를 가져오는 작업이라든지, 파일을 읽고 쓰는 작업을 의미합니다. 기다리는 작업이 하나도 없다면 비동기를 쓸 이유는 없습니다. 위 예제들은 어디까지나 Promise 의 동작 방식을 설명하기 위한 예제임을 반드시 기억해주세요.

재사용하기

new Promise(...) 를 하는 순간 비동기 작업이 시작되는데, 비슷한 비동기 작업을 수행할 때마다 매번 new Promise(...) 를 해줘야 할까요? 그렇지 않습니다. 그럴 때는 그냥 new Promise(...) 한 것을 그대로 리턴하는 함수를 만들어 사용하면 됩니다. 아래 함수는 age 인자를 받아서 그 값에 따라 resolve 또는 reject를 호출합니다.

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

setTimeout(() => {
  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!");
    });
}, 1000);
복사하기

이것의 실행 결과는 다음과 같습니다.

1 then!
2 catch!
복사하기

이제 startAsync 함수를 호출하는 순간 new Promise(...) 가 실행하게 되어 비동기 작업이 시작됩니다. 비동기 작업이 성공할지 실패할지는 장담할 수 없으므로 이전 예제와 동일하게 then  catch 로 후속 동작을 지정해 두었습니다.

promise1 은 성공하고, promise2 는 실패하도록 만들었는데요, 그래서 promise1 에서는 catch 으로 등록했던 함수는 실행되지 않고 then 으로 지정한 동작만 수행합니다. promise2 는 반대로 then 동작은 수행하지 않고 catch 동작만 수행합니다.

작업 결과를 전달하기

우리는 resolve, reject 함수에 인자를 전달함으로써 then  catch 함수에서 비동기 작업으로부터 정보를 얻을 수 있습니다. 바로 아래 예제를 보시죠.

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

setTimeout(() => {
  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);
    });
}, 1000);
복사하기

이것의 실행 결과는 다음과 같습니다.

25 success
Error: 15 is not over 20
    at /home/taehoon/Desktop/playground-nodejs/index.js:4:17
    at new Promise (<anonymous>)
    at startAsync (/home/taehoon/Desktop/playground-nodejs/index.js:2:10)
    at Timeout._onTimeout (/home/taehoon/Desktop/playground-nodejs/index.js:17:20)
    at listOnTimeout (internal/timers.js:554:17)
    at processTimers (internal/timers.js:497:7)
복사하기

기타 고려사항

이외 executor 를 만들때 조금 고려해야 할 부분은 다음과 같습니다. 이는 좀 더 자세한 내용이므로 빨리 async – await 의 매력을 느끼고 싶다면 넘어가도 좋습니다.

  • executor 내부에서 에러가 throw 된다면 해당 에러로 reject 가 수행됩니다.
  • executor 의 리턴 값은 무시됩니다.
  • 첫 번째 reject 혹은 resolve 만 유효합니다. (두 번째부터는 무시됩니다. 이미 해당 함수가 호출되었다면 throw 또한 무시됩니다.)

아래 간단한 예시들로 살펴봅시다.

// catch 로 연결됩니다.
const throwError = new Promise((resolve, reject) => {
  throw Error("error");
});
throwError
  .then(() => console.log("throwError success"))
  .catch(() => console.log("throwError catched"));

// 아무런 영향이 없습니다.
const ret = new Promise((resolve, reject) => {
  return "returned";
});
ret
  .then(() => console.log("ret success"))
  .catch(() => console.log("ret catched"));

// resolve 만 됩니다.
const several1 = new Promise((resolve, reject) => {
  resolve();
  reject();
});
several1
  .then(() => console.log("several1 success"))
  .catch(() => console.log("several1 catched"));

// reject 만 됩니다.
const several2 = new Promise((resolve, reject) => {
  reject();
  resolve();
});
several2
  .then(() => console.log("several2 success"))
  .catch(() => console.log("several2 catched"));

// resolve 만 됩니다.
const several3 = new Promise((resolve, reject) => {
  resolve();
  throw new Error("error");
});
several3
  .then(() => console.log("several3 success"))
  .catch(() => console.log("several3 catched"));
복사하기

아래는 그 결과입니다.

several1 success
several3 success
throwError catched
several2 catched
복사하기

어차피 첫 번째 resolve, reject 만 영향을 주기 때문에, 한 번 해당 함수가 호출되면 바로 return 을 하여 비동기 작업을 빠져나가는 것이 여러모로 정신 건강에 도움이 됩니다. 아래는 이전에 작성했던 startAsync 함수를 살짝 손을 본 겁니다.

function startAsync(age) {
  return new Promise((resolve, reject) => {
    if (age > 20) {
      // 뭔가를 합니다.
      return resolve(`${age} success`);
    }
    // 또 뭔가를 합니다.
    return reject(new Error(`${age} is not over 20`));
    // 여기 있는 코드는 "명확하게" 실행되지 않습니다.
  });
}
복사하기

간단한 동작 원리와 의의

지금까지의 설명을 (이해하기 쉬운 형태로 왜곡하여) 그림으로 표현하자면 아래와 같습니다.

왼쪽은 단순한 비동기 작업을 나타내었고 오른쪽은 Promise 가 어떤 구조를 가지고 있는지를 나타내었습니다.

Promise는 세 가지 상태를 지닙니다. 바로 대기(pending), 이행(fulfilled), 거부(rejected) 이며 이행 상태일 때 then, 거부 상태일 때 catch 로 등록한 동작들이 실행됩니다. 지금까지 “성공” 이라고 이야기한 것들은 사실 “이행 상태“와 연계된 것이고, “실패“는 “거부“와 동일한 상태인 것입니다. 이러한 상태들이 실제로 어떤지 우리가 직접 확인할 수 있을까요? 아쉽게도 그런 방법은 없습니다. 자바스크립트를 실행하는 브라우저 혹은 Node.js 에서 알아서 관리하는 녀석들이라 베일에 싸여 있습니다.

Promise 의 의의를 한마디로 이야기해보죠. Promise  비동기 작업을 생성/시작하는 부분(new Promise(...))과 작업 이후의 동작 지정 부분(then, catch)을 분리함으로써 기존의 러프한 비동기 작업보다 유연한 설계를 가능토록 합니다.

요약

지금까지 열심히 달려왔습니다. Promise 를 만드는 순간 비동기 작업이 시작되며, 비동기 작업을 성공으로 간주하고 싶을 때 resolve를 호출하고, 실패라 간주하고 싶다면 reject 함수를 호출합니다. 이 비동기 작업이 성공했을 때 후속 조치를 지정하고 싶다면 then으로, 실패 시의 후속 조치는 catch 로 지정하는 것까지 함께 살펴보았습니다.