어쩌면 알지도 모르는 JS - Promise , 김기표

TEAM.새싹·2020년 8월 20일
1

웹은 과거에서 현재로 발전해 오면서 많은 기술이 정립되고 구현되었다. 그중 가장 혁신적인 기술은 ajax라고 할 수 있다. 이 라이브러리가 생기면서 웹은 부분부분 역동적으로 변할 수 있었다. 그런데 이 기술은 비동기로 구현되었고, 이러한 비동기 기능들은 날이 갈수록 많아졌다. 때문에 기존의 방식인 콜백으로 동기를 맞추려는 경우 수많은 콜백을 중첩해서 사용할 수밖에 없었다. 이러한 것은 코드에 대한 가독성을 매우 떨어뜨렸고 이를 효과적으로 제어해 줄 만한 것이 필요해졌다. 이를 해결하기위해 다양한 진영해서 Promise와 비슷한 개념들을 발표했고 마침내 ES6에 Promise가 정식으로 스펙에 등록되었다.

Promise?

우리의 삶의 관점에서 Promise를 바라본다면 약속이라 할 수 있다. 그럼 이 약속의 과정을 간단한 예시를 보고 생각해보자.

  1. 나는 내일 친구와 점심을 먹기로 약속했다. 단, 비가 오지 않는다면 말이다.
  2. 다음날 다행히 비가 오지 않았고 친구와 밥을 먹었다.
  3. 혹은 비가 와서 약속을 파투낼 수도 있었다.

이 간단한 예시를 보면 약속에는 세 가지 상태가 있다는 것을 알 수가 있다. 바로 약속을 하고 기다리는 상태, 약속이 성공적으로 이행된 상태, 약속이 파기된 상태다.

이처럼 자바스크립트의 특별한 객체인 Promise도 이러한 상태를 가진다. 약속을 하고 기다리는 상태를 Pending 상태라 하고, 약속이 이행된 상태를 Fullfilled 상태, 약속이 파기된 상태를 Rejected라고 한다.

그래서 Promise가 선언되면 코드가 작동 되지 않고 Pending이라는 상태를 가지고 내부는 비어있는 상태가 된다.

const promise = () => new Promise((resolve, reject)=>{    
  //...
});

만약 3초 뒤에 3을 반환하는 Promise를 만든다면 다음과 같이 만들 수 있을 것이다. 그리고 이 결과를 출력하고 싶어서 아래 실행에 Promise를 작성한다 해도, 결과를 받지 못하고 Promise([pending])이라는 것만 받게 될 것이다. 왜냐하면, 아직 내부가 비어있는 상태이기 때문이다.

const promise = () => new Promise((resolve, reject)=> { 		
  setTimeout(()=> resolve(3), 3000)
})
promise(); // pending Promise([pending])

그렇다면 3초 뒤에 반환되는 값을 어떻게 받고 사용할 수 있을까? 다음과 같이 then이라는 함수를 사용하게 되면 사용할 수 있다. 이 함수가 동작하면 비어있던 Promise 안에 이미 선언된 내부 코드들이 실행되게 되고, resolve 된 반환 값을 인자로 하는 콜백함수가 then 함수의 인자로 채워지면서 수행된다. 그리고 이 Promise는 fullfilled 상태가 된다.

promise().then((res)=>console.log(res));

두 가지 상태를 알아보았으니, 마지막으로 Rejected 상태에 대해 살펴보자.

const promise = () => new Promise((resolve, reject)=> { 
  setTimeout(()=> resolve(3), 3000); 
  setTimeout(()=> reject(new Error("message")),2000);
});

Promise의 콜백 인자에서 reject(두 번째 인자)는 개발자가 설정한 상황에서 Promise를 끝내고 에러를 반환하도록 만들어졌다. 그래서 다음 실행 코드에서 then 함수는 동작하지 않게 되고 에러가 발생한다.

promise().then((res)=>console.log(res)); // none-excute;

그렇다면 에러가 발생했을 때, 이를 효과적으로 제어할 방법은 없을까? Promise 스펙에서는 이를 catch를 통해 구현했다. 만약 다양한 프로그래밍 언어에서 try ... catch 구문을 적극적으로 활용해보았다면 이 함수의 역할도 쉽게 유추할 수 있다. 단어 그대로 잡는 것이고, Promise에서는 에러를 잡는 역할을 가진다.

promise()
  .then((res) => console.log(res)) // none-excute;    
  .catch((err) => console.log(err)); // excute;

위 다이어그램이 Promise 스펙을 정말 잘 설명한다고 볼 수는 없지만 위 다이어그램처럼 동작한다고 이해하면 쉽니다. Promise는 사실 끝이 정해져 있는 객체가 아니다. then을 통해 반환된 값도 결국 Promise의 형태를 가지기 때문이다. 이러한 사실을 근거로 우리는 다양한 방법으로 Promise를 응용할 수 있다.

Promise Chaining

위에서 말했듯이 Promise는 자바스크립트에서 매우 특별한 객체이다. 그래서 다양한 응용이 가능하다.

우리가 다양한 비동기 함수나 기능들을 이용한다고 가정해 보자. 그런데 이 과정에서 우리는 특정한 순서로 이를 실행해야만 한다. 그렇다면 어떻게 이를 수행할 수 있을까? 다음 예시를 통해 알아보자

const async1 = (value) => new Promise((resolve, reject)=> {
  setTimeout(()=>resolve(value),3000)
});
const async2 = (value) => new Promise((resolve, reject)=> { 
  setTimeout(()=>resolve(value*2),2000)
});
const async3 = (value) => new Promise((resolve, reject)=> {
  setTimeout(()=>resolve(value-3),1000)
})
const first = async1(2).then();
const second = async2(first).then();
const result = async3(second).then();
// 제대로 동작하지 않는 코드

우리는 async1 함수부터 순서대로 async3 함수까지 실행해서 결과를 화면에 출력하고 싶다. 그러나 위의 코드처럼 실행한다면 우리가 원하는 결과를 얻을 수 없을 것이다. 하지만 Promise의 실행은 결국 Promise를 반환한다는 특징만 잘 기억한다면 이를 쉽게 해결할 수 있다. 바로 Promise chaining을 이용하는 것이다.

우리는 then이라는 함수에서 이 함수의 인자는 함수라는 것을 알 수 있다. 그런데 이 함수의 첫 번째 인자는 이전 Promise가 resolve 한 값이다. 그렇다면 async1이라는 함수의 then 함수 부분에 async2라는 함수를 넣게 되면 async2는 인자로 async1이 resolve 한 값을 얻게되고 Promise를 반환하게 될 것이다. 이 과정에서 Promise가 반환되었기 때문에 또다시 then이라는 함수를 사용할 수 있을 것이다. 이를 반복적으로 한다면 우리가 원하는 순서대로 비동기 함수들을 실행시켜 동기적인 결과를 얻을 수 있다.

async1(2)
  .then(async2)
  .then(async3)
  .then((res)=>console.log(res)); // 1 !Success! :>

정말 간단하고도 멋지게 동작한다.

Promise의 다양한 함수

Promise array처럼 다양한 함수들을 가지고 있다. 우리는 이를 활용하여 비동기함수들이 모두 무사히 끝마쳤을 때 결과를 받기 원하거나, 가장 빨리 수행되는 결과만 받고 싶다 던 지 혹은 모든 비동기 함수들이 에러가 생겼든 무사히 수행했던 결과나 에러를 모두 받는 것을 간단히 수행할 수 있다.

Promise.all(iterable)
const promise1 =  new Promise((resolve, reject) => {  
  setTimeout(() => resolve(3), 100);
});
const promise2 = Promise.resolve(2);
Promise.all([promise1, promise2])
  .then(values => console.log(values));
// [3,2] (0.1초 후 출력)

all 함수는 iterable 인자를 받는데, 대게 배열이다. 단 이 배열 안의 모든 값은 Promise여야 한다. 이 함수는 내부의 모든 Promise가 resolve 하게 되면 결과를 반환하고, 이 값을 배열의 형태로 전달한다. 단 내부의 Promise 중 하나라도 reject 되면 가장 처음 reject 된 에러를 반환한다.

Promise.allSettled(iterable)
const promise1 = new Promise((resolve, reject) => { 
  setTimeout(() => resolve(3), 100);
});
const promise2 = Promise.reject(new Error("error"));
Promise.all([promise1, promise2])
  .then(values => values.forEach((v)=>console.log(v.status)));
// fullfilled
// rejected
// 0.1초후 출력

allSettled 함수는 all과 비슷하지만 reject 한 Promise도 같이 묶어서 Promise 배열 형태로 결과를 반환한다.

Promise.race(iterable)
const promise1 = new Promise((resolve, reject) => { 
  setTimeout(() => resolve(3), 100);
});
const promise2 = new Promise((resolve, reject) => { 
  setTimeout(() => resolve(2), 50);
});
Promise.all([promise1, promise2])
  .then(res => console.log(res))
// 2    0.05초 후 출력

race함수는 이름에서 알 수 있듯이 가장 빨리 마무리된 결과나 에러를 반환한다. 만약 가장 빨리 수행되는 비동기 함수만 실행하고 싶을 때 유용하게 사용할 수 있다. 다만 이 경우에 다른 비동기 함수들을 정상적으로 종료시켜야한다.

Promise를 반환하는 함수와 선언된 Promise

예시를 보면 어떤 것은 Promise 객체를 반환하는 함수로 만들었고, 어떤 것은 선언한 것을 볼 수 있다. 이 둘에는 명백히 차이점이 존재한다. 우선 함수로 만들어 Promise를 반환하는 함수는 반환하기 전에 Promise의 내부는 실행되지 않는 상태로 존재한다. 그래서 대부분의 비동기 함수들의 동기성을 맞추어 주기 위해선 함수가 실행되기 전에는 내부 비동기 함수가 실행되지 않는 상태가 되어야 한다.

반면 바로 선언된 Promise 객체는 바로 내부에 구현된 함수들을 바로 실행한다. 때문에 결과를 미리 저장하고 있다. 그래서, 해당 변수를 언제 선언하더라도 이미 결과가 선언된 시점에 저장되기 때문에 항상 같은 결과를 가지게 된다.

여담

복잡한 스펙을 가진 Promise를 간단하게 알아보았다. Promise는 특정 시점에 특정 값을 반환한다는 점에서 비동기 함수가 많은 자바스크립트 생태계에서 우리에게 더 좋은 질의 코드를 생산하는 데 도움을 준다.

Promise가 처음 나왔을 때는 Promise를 내부적으로 지원하는 비동기 라이브러리들이 매우 적었다. 그러나 현재는 대부분의 비동기 라이브러리들은 내부 함수들이 Promise를 반환하도록 지원한다. 그래서 우리는 별도의 new Promise를 선언할 필요 없이 Promise가 어떻게 동작하는지 안다면 해당 기능들을 편히 사용할 수 있다.

이에 더해서 ES6 이후에 추가된 async/await은 Promise와 더불어 비동기 함수들을 더 쉽게 제어할 수 있게 되었다. 이 기능으로 코드상으로 Promise를 볼 기회는 줄었지만, 이 기능도 결국 Promise의 확장판이라, Promise를 찾아 익히고 공부하였다.

여러분들이 Promise에 대해 이해했길 바라며, 다음에는 더 좋은 글로 찾아뵙도록 하겠습니다. 이 글에 오류가 있거나 궁금한 점이 있다면 댓글과 아래 메일을 통해 연락 부탁합니다.

이메일 : kimgipyo95@gmail.com

profile
전남대 출신 개발 스터디 그룹 새싹팀 입니다. @ECONOVATION

0개의 댓글