자바스크립트의 Promise 기초공략

숲은 아름답고, 어둡고 깊다.
하지만 지켜야할 약속이 있어.
잠들기 전에 가야 할 길이 있다.
– 로버트 프로스트 <가지 않은 길>

Promise는 자바스크립스 ES6버젼에 추가된 가장 흥미로운 기능중에 하나입니다. 기존에 자바스크립트는 여러개의 함수를 순차적으로 실행하기위해 callback이라는 컨셉을 이용해서 비동기 프로그램을 구현하도록 설계되어 있었는데요. 이 callback을 이용하면 호출하는 연결고리가 분산되어서 다중으로 여러번 호출하게되는 경우에 코드가 매우 지저분해지고 로직이 체계적이지 못한 모습으로 변해가는 callback지옥을 만들어내는 경우가 허다했습니다. ES6에서 소개된 promise를 이용하면 callback을 사용하지 않고도 동기화된 코드를 작성하실수 있습니다.

본 게시물에서는 promise가 무엇이고 어떻게 효과적으로 사용할수 있을지에 대해서 알아보도록하겠습니다.

Promise가 무엇인가?

간단하게 말하자면, promise란 앞으로 사용하고자 하는 데이타를 저장하고있는 하나의 컨테이너라고 할수 있습니다. promise가 사실 약속이라는 뜻이자나요. 생각해보면 우리가 평소에 사용하는 약속이라는 단어의 의미와 일맥상통한다고 볼수 있어요. 예를 들어서 여러분이 인도에 가려고 비행기티켓을 예매했다고 생각해보세요. 그러면 여러분이 예약을 함과 동시에 티켓을 받습니다. 그 티켓에는 항공사, 좌석번호, 출발날짜등 앞으로 실제로 사용하게 될 서비스의 정보를 담아두는 하나의 컨테이너라고 볼수 있죠. 다른 예로, 여러분이 여러분의 친구에게 어떤 책을 읽고 돌려주겠다고 약속을 했다고 칩시다. 그러면 그 약속안에는 “책”이라는 값이 담겨져 있는거에요. 그밖에 다른 약속들도 얼마든지 적용해 볼수 있어요. 식당을 예약했다던지, 병원예약을 했다던지, 서점에서 책을 대여하고 돌려주어야하는 경우에도 약속은 앞으로 실제 일어날 일들에 대한 정보를 담고 있거든요. 백문이 불여일견입니다. 코드를 통해서 실제 promise가 어떻게 구현되는지 알아볼까요?

Promise 만들기

promise는요 어떤 작업이 얼마나 걸릴지 언제 끝날지 확실하게 모를경우에 만들게 되는데요. 예를 들어, 코드 중간에 외부 서버에서 데이타를 가져와야서 보여줘야하는 경우에 그 서버에 정보를 요청하게 되는데요. 해당 서버의 응답속도나, 사용자의 인터넷속도에 따라서 결과를 받아오는 속도가 다르니까, 예를들어 몇초뒤에 결과를 보여주는 함수를 실행해라 이렇게 코딩하면 절대 안되겠죠? 언제가 됐든 결과가 도착하면 그때 실행해야하는 코드를 어떤 컨테이너안에 만들어 두고 그걸 요청과 함께 전송하면 되는거에요.

promise를 만드는 코드는 아래와 같습니다.

const myPromise = new Promise((resolve, reject) => {
    if (Math.random() * 10 <= 9) {
        resolve('열에 아홉은 약속을 지키지^^')
    }
    reject(new Error('약속을 못지켰어ㅜㅜ'));
})

Promise클래스를 객체로 구현할때 생성자에 넘겨주는 함수에 주목해주세요. 이 함수의 인자는 resolve와 reject두개구요. 이 함수가 정의된 부분에는 resolve를 호출해서 성공적으로 끝낼지, reject를 호출해서 실패로 끝낼지를 계산하는 코드가 기술되어있어요.

Promise의 생성자의 인자로 전달되는 함수는 언제나 resolve와 reject라는 이름의 두개의 함수를 인자로 갖게 되는데요. 두개의 함수 모두 결과값으로는 Promise객체를 반환합니다. Promise객체에 전달된 함수의 코드를 실행하다가 성공적으로 약속을 지킬수 있게 된 경우에는 resolve를 호출하고, 약속을 못지켜서 약속이 깨져버린 경우에는 reject함수를 호출해서 해당 약속이 지켜지지 못했음을 알리는것이죠.

Promise 사용하기

위의 코드에서 생성한 Promise를 myPromise라고 이름했습니다. 그렇다면 resolve와 reject에 전달되는 결과값을 어떻게 접근할수 있는걸까요? 자, 모든 Promise객체에는 .then()이라는 메소드가 있어요. 아래코드를 보면서 설명해드릴게요.

const myPromise = new Promise((resolve, reject) => {
    if (Math.random() * 10 <= 9) {
        resolve('열에 아홉은 약속을 지키지^^')
    }
    reject(new Error('약속을 못지켰어ㅜㅜ'));
})

// 방법1. 두개의 핸들러 함수를 선언해서 변수에 담고
//       해당변수를 then()메소드의 인자로 넘긴다
const onResolved = (resolvedValue) => console.log(resolvedValue);
const onRejected = (error) => console.log(error);
myPromise.then(onResolved, onRejected);

// 방법2. 변수선언없이 바로 두개의 핸들러 함수를 
//       선언과 동시에 then()메소드의 인자로 넘긴다
myPromise.then((resolvedValue) => {
    console.log(resolvedValue);
}, (error) => {
    console.log(error);
});

.then()이라는 메소드가 두개의 callback함수를 받아서 성공했을때는 첫번째 함수를, 실패했을때는 두번째함수를 실행합니다.

Promise는 오직 한번만 성공(resolve)하거나 실패(reject)할수 있다.

이 말은 .then()함수를 호출하면, promise로 선언한 함수의 결과를 가져오는데요, 오직 처음 한번만 결과를 가져오고, 다시 .then()함수를 호출한다고 promise함수가 또 실행되지는 않는다는 뜻이에요. 실험삼아 .then()메소드를 연달아 호출해보세요. 가장 처음 .then()만 resolve에 넘겨준 문자열을 출력하고, 그 뒤로는 resolvedValue 변수가 undefined인것을 눈으로 확인하실수 있으실거에요.

myPromise.then((resolvedValue) => {
    console.log(resolvedValue); // 처음에는 변수를 잘가져옴
}, (error) => {
    console.log(error);
}).then((resolvedValue) => {
    console.log(resolvedValue); // 여기서는 변수가 undefined
}, (error) => {
    console.log(error);
}).then((resolvedValue) => {
    console.log(resolvedValue); // 여기서도 변수가 undefined
}, (error) => {
    console.log(error);
});

결과:
'열에 아홉은 약속을 지키지^^'
undefined
undefined

다시 말해, Promise생성자에 넘겨진 함수는 .then()을 호출할때 실행되는것이 아니라, Promise객체를 생성함과 동시에 이미 실행이 되서 그 결과를 Promise에 가지고 있다가 .then()이 호출이되면, 캐시된 결과값을 넘겨주는 거에요.

Promise 에러잡기

위의 코드를 실행하면 대부분 resolve를 호출하는 성공적인 결과를 봐왔어요. 그렇다면 에러가 일어나는 상황이 생긴다면 어떻게 될지 한번 살펴볼게요.

const myProimse = new Promise((resolve, reject) => {
  if (Math.random() * 10 <= 9) {
    reject(new Error('열의 아홉은 약속을 못지켜요ㅜㅠ'));
  }
  throw new Error('에라났다@.@');
});

myProimse.then(
  null, 
  (error) => console.log(error.message)
);

위의 코드는요 열의 아홉은 reject를 호출해서 약속이 깨지는 상황이 발생하고, 10%의 확률로 에러가 나는 코드입니다. 현재 이 코드에서 성공할 확률은 없으니까 .then()의 첫번째 함수는 실행될 일이 없겠죠? 그리고 90%의 확률로 두번째 함수가 실행될거에요.

그러면, 10%의 확률로 발생하는 에러가 나는 경우에는 어떻게 될까요? 네, 바로 reject발생시 실행되는 두번째 함수를 호출합니다. 다시 말해 promise는 reject을 통해 실패하든, error가 발생해서 실패하든, 실패의 경우는 reject라고 판단하는거죠.

그런 맥락에서 promise는 .catch()메소드를 제공합니다. 보통 .catch()는 에러를 처리하기 위해 사용하는 함수이름인데요. 여기서는 error가 발생된 상황이 reject를 호출한 상황과 동일하게 여겨지기 때문에 .then(null, onReject)로 reject 및 error를 잡아도 되고, .catch(onReject)의 문법으로도 동일한 효과를 얻을수 있습니다. 그래서 위의 코드를 아래와 같이 쓸수 있어요.

myProimse.catch(
  (error) => console.log(error.message)
);

.catch는 편의를 위해서 그저 .then()을 문법적으로 다르게 표현한것일 뿐이지, 이게 뭔가 특별한 다른 기능을 하지는 않는다는거 기억하세요.

Promise 연결고리

위의 .then()이나 .catch()메소드들은 언제나 Promise객체을 결과값으로 반환합니다. 그래서 결과를 받아서 또다시 해당 메소드를 호출할수가 있어요. 예를 들어서 설명을 해보도록할게요. 아래에 Promise객체를 하나 선언했습니다. 이 Promise는 시간을 인자로 받아서, 그 시간만큼 기다린뒤에 단순히 Promise의 resolve함수를 실행하는 반환하는 코드에요.

const delay = (ms) => new Promise(
  (resolve) => setTimeout(resolve, ms)
);
delay(5000).then(() => console.log('5초뒤에 성공했다'));

참고로, 아까 Promise는 resolve와 reject두개의 인자를 받는 함수를 생성자에 전달한다고 위에서 이야기 했잖아요? 그런데 reject를 사용하지 않는 경우에는 위의 코드처럼 resolve만 인자로 받아도 됩니다.

다시 위의 코드로 돌아가서 이 Promise객체가 호출이 되면, 일정시간뒤에 resolve를 실행하는데, resolve나 reject를 실행하면 이 함수는 Promise 를 반환한다고 아까 제가 말씀드렸죠. 그러니까 결과로 받은 그 Promise안에는 당연히 .then()이랑 .catch()등 Promise에서 제공하는 모든 메소드들이 다 들어가 있겠죠? 그러면 받은 결과에 또 위의 메소드를 붙여서 또 쓰고, 또 쓰고 할수 있어요.

let delay = (ms) => new Promise(
  (resolve) => setTimeout(resolve, ms)
);

delay(2000)
  .then(() => {
    console.log('2초뒤 실행')
    return delay(1500);
  })
  .then(() => {
    console.log('1.5초뒤에 또 실행');
    return delay(3000);
  }).then(() => {
    console.log('그뒤에 3초뒤에 또 실행');
    throw new Error();
  }).catch(() => {
    console.log('에러 났다');
  }).then(() => {
    console.log('끝');
  });

// 2초뒤 실행
// 1.5초뒤에 또 실행
// 그뒤에 3초뒤에 또 실행
// 에러 났다
// 끝

처음에 delay함수를 2000 마이크로초, 즉 2초를 기다리게 한뒤에 resolve를 실행하도록 호출했죠? 그리고 나서 성공했을때 .then()의 첫번째 함수를 호출하죠? 여기서 결과를 출력하고, 다시 한번 delay함수를 호출합니다. 이번에는 1.5초뒤에 resolve를 반환하도록 1500을 인자로 주고 받아온 값을 return합니다. 만약에 여기서 return을 안했다면 아래 코드와 같이 들여썼어야했을거에요.

// resolve함수에서 return을 안해서 코드가 읽기 힘들어진 나쁜예
delay(2000)
  .then(() => {
    console.log('2초뒤 실행')
    delay(1500).then(() => {
        console.log('1.5초뒤에 또 실행');
        delay(3000).then(() => {
            console.log('그뒤에 3초뒤에 또 실행');
            throw new Error();
        }).catch(() => {
            console.log('에러 났다');
        }).then(() => {
            console.log('끝');
        });
    });
  })

그런데, 만약에 연결해서 실행해야하는 프로세스가 여러개인 경우에 저렇게 들여쓰기를 해야한다면, 10번째 실행하는 코드는 앞에 너무 많은 공백때문에 코드를 읽기가 힘들어 지니까, 다음 처리는 부모함수에서 제어할수 있도록 return을 해주는 겁니다. 그러면 각 단위별 실행코드가 연결고리처럼 1차원으로 나열될수 있겠죠.

위에서 보면 .then()이 resolve와 reject를 모두 받을수 있지만, 왠지 .then()은 성공했을때 실행할 코드를, .catch()는 실패나 에러가 났을때 코드를 정의해놓으면 왠지 코드가 더 깔끔할것 같아요. 우리가 이미 다른언어에서 .catch()를 에러나 실패용도로 이미 사용해 왔기때문에 좀더 의미도 명확하구요.

const myPromise = new Promise((resolve, reject) => {
    if (Math.random() * 10 <= 9) {
        resolve('열에 아홉은 약속을 지키지^^')
    }
    reject(new Error('약속을 못지켰어ㅜㅜ'));
})

// 이렇게 쓰는것보다
myPromise().then(
  () => { console.log('성공') },
  (error) => console.log('실패'),
);

// 이렇게 쓰는게 코드도 읽기쉽고, 의미도 명확하고, 좋지않나요?
myPromise()
  .then(() => {
    console.log('성공');
  }).catch(error => {
    console.log('실패');
  });

이상 Promise의 기초개념 및 사용에 대해서 공부해보는 시간이었습니다. 공부하느라 고생하셨습니다. 도움이 되셨길 바래요^^

본 아티클은 아래 원문의 번역본입니다.
원문: https://codeburst.io/a-simple-guide-to-es6-promises-d71bacd2e13a