JavaScript Promise


솔직히, 나는 여지껏 Promise를 잘못 사용하고 있었다. 이를테면 다음과 같이 사용했다.

1
2
3
4
5
6
7
8
9
10
11
12
13
User
.findById(id)
.then(function(user) {
Project
.findAll({ user: user.id })
.then(function(projects) {
user
.asyncSomething()
.then(function(blah) {
console.log('Done!');
});
});
});

맙소사. 보면 알겠지만 전에도 언급했던 Callback Hell에 비해서 전혀 구조적으로 나은 부분이 없다. 난 왜 진작 이것에 대해 의문을 품지 않았을까? 대략 저 글이 2015년 8월에 작성한 글이니 약 반 년간 Promise를 전혀 이해하지 못한 것이다.

Promise ?

Promise는 이런 Callback Hell을 해결할 수 있는 기법이다. Promise 명세를 따라 정의한 Promise 객체는 모두 then() 메소드를 가지고 있어서 Callback을 사용하지 않고도 절차적으로 동작하는 코드를 짤 수 있다.

여기서 Promise의 명세를 파악할 수 있다.
이 스펙은 언어자체의 스펙이 아니라서(ES2015부터는 언어에 포함) 별도의 라이브러리를 따로 포함해야만 Promise라는 개념을 이용할 수 있다.

Promise를 구현한 라이브러리는 대표적으로 Q.jsbluebird 등이 꼽히는데, 이 글에서는 Q.js만 사용할 것이다. 왜냐하면 AngularJS에도 Q.js로부터 영향을 받은 $q라는 구현체가 존재하기 때문이다.

Promise 객체 생성

다음과 같이 1초 후에 랜덤으로 0에서 1사이의 숫자를 콜백으로 넘기는 함수가 있다고 하자.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
/* 여기부터 사용된 모든 코드는 ES2015의 스펙을 일부 사용합니다.
** Node.js v4.2.4에서 동작을 확인하였습니다.
*/
function getRandomNumber(successCallback, errorCallback) {
setTimeout(function() {
console.log('Delayed Job Complete!');
let rand = Math.random();
if (rand > 0.3) {
successCallback(rand);
} else {
errorCallback({ message: 'Random number is lower than 0.3.' });
}
}, 1000);
}

이걸 실제로 사용하려면 이렇게 해야한다.

1
2
3
4
5
6
7
8
9
10
getRandomNumber(function (result) {
console.log('1st Random number:', result);
getRandomNumber(function (result) {
console.log('2nd Random number:', result);
}, function (err) {
console.log('Error ocuured:', err.message);
});
}, function (err) {
console.log('Error ocuured:', err.message);
});

고작 깊이가 2임에도 불구하고 코드가 이렇게 더러워진다. 게다가 에러 블럭은 두 개가 생기고 똑같은 코드가 중복되는 것을 확인할 수 있다. 누가봐도 절대 좋은 코드라고 말할 수가 없는 상황..

이 문제를 해결하기 위해서 getRandomNumber() 함수를 Promise를 반환하도록 다음과 같이 다시 짤 수 있다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
const Q = require('q');

function getRandomNumber() {
let deferred = Q.defer();
setTimeout(function() {
console.log('Delayed Job Complete!');
let rand = Math.random();
if (rand > 0.3) {
deferred.resolve(rand);
} else {
deferred.reject({ message: 'Random number is lower than 0.3.' });
}
}, 1000);
return deferred.promise;
}

Q.defer()는 “deferred” 객체를 생성하는 함수이다. “deferred” 객체는 다음과 같은 프로퍼티를 포함한다.

  • deferred.promise
  • deferred.resolve()
  • deferred.reject()

그 외에도 deferred.notify(), deferred.makeNodeResolver() 같은 녀석들을 포함하고 있지만 기본적인 Promise 함수 구현에는 위의 세 가지만 알고 있으면 충분하다.

deferred.promise는 명세에 맞게 구현된 Promise 객체이다. Promise를 제대로 사용하려면 마지막에 이 녀석을 리턴해서 함수 외부에서 Promise 객체를 사용할 수 있도록 해야한다. 위의 코드에서도 함수의 마지막에 리턴했다.

deferred.resolve()는 함수의 동작이 정상적으로 완료되면 호출하는 메소드이다. 이 메소드를 호출하면 promise 객체의 상태가 “fulfilled”로 바뀌고 promise.then()안의 함수 블록을 실행시킨다. 결과 값을 파라미터로 넘겨줄 수도 있다. 위의 코드에서는 결과 값인 랜덤 값을 넘겨줬다.

주의: 넘겨주는 값이 “rejected” 상태의 promise 객체인 경우 이 deferred 객체의 promise 객체 역시 “rejected” 상태로 변하기 때문에 deferred.resolve()를 호출해도 promise.catch() 블록을 실행하게 된다.

deferred.reject()는 함수가 정상적으로 동작하지 않으면 호출하는 메소드이다. 일반적으로 에러가 났을 때 호출하게 되는 메소드이다. 역시 파라미터로 데이터를 넘길 수 있는데, 보통은 에러 메시지를 넘기게 된다. 위에서도 에러 메시지를 객체로 넘겼다.

Promise 객체 사용

이런 식으로 Promise를 이용해서 함수를 구현하면 더러웠던 코드를 다음과 같이 개선할 수 있다.

1
2
3
4
5
6
7
8
9
10
11
getRandomNumber()
.then(function(result) {
console.log('1st Random number:', result);
return getRandomNumber();
})
.then(function(result) {
console.log('2nd Random number:', result);
})
.catch(function(err) {
console.log('Error ocuured:', err.message);
});

코드가 훨씬 깔끔해지고 인덴트도 덜 들어가는 모습을 볼 수 있다. 특히 promise.catch()를 통해 에러 핸들링을 더욱 깔끔하게 할 수 있게 되었다.

그리고 내가 도입부에 쓴 코드에서 가장 잘못된 부분인데, Promise는 기본적으로 chain이 가능하다. promise.then()안의 함수 블록에서 다시 promise 객체만 리턴하면 된다. 이걸 몰라서 여태껏 Promise를 Callback처럼 사용하고 있었다.. 그게 이 글을 쓰게 된 계기이기도 하지만.

만약 에러 여부에 관계 없이 마지막에 항상 실행시키고 싶은 부분이 있다면 모든 Promise 체인이 끝난 뒤에 실행되는 promise.finally()를 사용할 수 있다.

마지막으로..

최신 기술은 Promise를 넘어 async/await 패턴을 지원하는 것들도 많다.(그리고 사실은 어떤 때에는 이게 더 편하다.) 대표적으로 ES2016이 그러하고 IcedCoffeeScript같은 것도 있다. 하지만 두 패턴 모두 장단이 있으므로 적당히 사용해보고 취사 선택하면 좋은 코드를 짤 수 있을 것이다.

참고링크