toggle menu

[JavaScript] 바보들을 위한 Promise 강의 - 도대체 Promise는 어떻게 쓰는거야?

2014. 12. 23. 14:47 JavaScript

들어가며


JavaScript의 세계에서는 거의 대부분의 작업들이 비동기로 이루어진다. 어떤 작업을 요청하면서 콜백 함수를 등록하면, 작업이 수행되고 나서 결과를 나중에 콜백 함수를 통해 알려주는 식이다. 실제 비동기 작업이 아니더라도 JavaScript의 세계에서는 결과를 콜백으로 알려주는 패턴이 매우 흔하게 사용된다.

초기의 JavaScript의 경우 버튼이 눌렸을 때(이벤트 발생) 특정 작업을 수행(콜백 함수 호출)하는 정도의 수준이었기 때문에 복잡도가 높지 않았지만 최근에는 프론트엔드의 규모가 상당히 커져서 JavaScript로 작성하는 코드를 단순하게 바라볼 수준은 넘어선지 오래다.

이렇게 복잡도가 높아지는 상황에서 특히 어려워지는 케이스는 콜백이 중첩되는 경우이다. 하나의 작업을 콜백으로 결과를 받은 뒤 순차적으로 다음 작업을 진행하고자 할 때 이러한 콜백 중첩, 이른바 콜백 지옥을 만나게 되는 것이다.



이런 상황을 극복하기 위해 오래전부터 Promise 라는 패턴이 제안되어 왔다. jQuery에서는 Deferred 라는 이름으로 완전하진 않지만 Promise 패턴이 사용되었고, 그외에도 Q, Vow, Bluebird 등 다양한 라이브러리를 통해 Promise 패턴을 구현해서 콜백 중첩으로 인한 어려움들을 해소해왔다.

Promise 패턴을 사용하면 비동기 작업들을 순차적으로 진행하거나, 병렬로 진행하는 등의 컨트럴이 보다 수월해지고 코드의 가독성이 좋아진다(물론 잘 짜야지만..). 또 내부적으로 예외처리에 대한 구조가 탄탄하기 때문에 오류가 발생했을 때 오류 처리 등에 대해 보다 가시적으로 관리해줄 수 있는 장점이 있다.


현재 Promise 패턴은 ECMA Script 6 스펙에 정식으로 포함되었고, 2013년 12월경 Chrome 브라우저에서도 32 버전부터 본격적으로 native promise 가 지원되기 시작했다.

Node.js의 경우 0.11.13 버전 이후부터 native promise 를 지원할 예정이다. 2014년 10월 현재 0.11 버전은 베타 테스트 중에 있다. (곧 나올 io.js 의 경우 0.12 버전을 기본으로 하고 있으니 io.js에서는 바로 promise 사용이 가능해질 것으로 보인다). 0.11 버전이 공식 릴리즈 되기 전이라도 bluebird와 같은 모듈 설치를 통해 Node.js에서도 Promise를 동일하게 사용 가능하며, Native Promise 에 비해 bulebird 같은 모듈은 추가적인 API도 지원하고 있기 때문에 사용성면에서 더 추천할만 하다.


Chrome 자체에 내장되어 있는 ES6 표준 Promise 에 대해 하나하나 뜯어보며 막연하게 다가서기 어려웠던 Promise를 낱낱이 파헤쳐보자.




Promise 기초


아래의 코드를 크롬 콘솔에서 타이핑해서 결과를 확인해보자.

//Promise 선언
var _promise = function (param) {

	return new Promise(function (resolve, reject) {

		// 비동기를 표현하기 위해 setTimeout 함수를 사용 
		window.setTimeout(function () {

			// 파라메터가 참이면, 
			if (param) {

				// 해결됨 
				resolve("해결 완료");
			}

			// 파라메터가 거짓이면, 
			else {

				// 실패 
				reject(Error("실패!!"));
			}
		}, 3000);
	});
};

//Promise 실행
_promise(true)
.then(function (text) {
	// 성공시
	console.log(text);
}, function (error) {
	// 실패시 
	console.error(error);
});


실행 결과는 콘솔로그로 "해결 완료"가 뜨는 것을 볼 수 있다.


몇 줄 되지 않지만, Promise 의 기초에 대해서 가장 명확하게 이해할 수 있는 코드다.

위의 코드는 크게 Promise 선언과 실행 두 부분으로 나눌 수 있는데, 하나씩 깊숙히 확인해보자.



Promise 선언부

Promise는 말 그대로 "약속"이다. "지금은 없으니까 이따가 줄게~" 라는 약속이다. 더 정확히는 "지금은 없는데 이상없으면 이따가 주고 없으면 알려줄게~" 라는 약속이다. 
 

따라서 promise는 다음 중 하나의 상태(state)가 될 것이다.



pending

아직 약속을 수행 중인 상태(fulfilled 혹은 reject가 되기 전)이다.


fulfilled

약속(promise)이 지켜진 상태이다.


rejected

약속(promise)가 어떤 이유에서 못 지켜진 상태이다.


settled

약속이 지켜졌든 안지켜졌든 일단 결론이 난 상태이다.



var _promise = function (param) {

	return new Promise(function (resolve, reject) {

		// 비동기를 표현하기 위해 setTimeout 함수를 사용 
		window.setTimeout(function () {

			// 파라메터가 참이면, 
			if (param) {

				// 해결됨 
				resolve("해결 완료");
			}

			// 파라메터가 거짓이면, 
			else {

				// 실패 
				reject(Error("실패!!"));
			}
		}, 3000);
	});
};

위의 Promise 선언부를 보면, 나중에 Promise 객체를 생성하기 위해 Promise 객체를 리턴하도록 함수로 감싸고 있다.

Promise 객체만 보면 파라메터로 익명함수를 담고 있고, 익명 함수는 resolve 와 reject를 파라메터로 받고 있다.


일단 new Promise 로 Promise 가 생성되는 직후부터 resolve 나 reject 가 호출되기 전까지의 순간을 pending 상태라고 볼 수 있다.

이후 비동기 작업이 마친뒤 결과물을 약속대로 잘 줄 수 있다면 첫번째 파라메터로 주입되는 resolve 함수를 호출하고, 실패했다면 두번째 파라메터로 주입되는 reject 함수를 호출한다는 것이 promise의 주요 개념(!)이다.


위의 예제에서는 비동기 작업을 시뮬레이션하기 위해 setTimeout 함수를 사용했다.





Promise 실행부

_promise(true)
.then(function (text) {
	// 성공시
	console.log(text);
}, function (error) {
	// 실패시 
	console.error(error);
});


실행하는 부분은 더욱 심플하다. _promise() 를 호출하면 Promise 객체가 리턴된다. Promise 객체에는 정상적으로 비동기작업이 완료되었을 때 호출하는 then 이라는 API가 존재한다. 위의 예제는 하나의(!) then API를 호출해서 비동기 작업이 완료되면 결과에 따라 성공 혹은 실패 메시지를 콘솔로그로 찍어주게 된다. 


then API는 첫번째 파라메터에 성공시 호출할 함수를, 두번째 파라메터에 실패시 호출할 함수를 선언하면 Promise 의 상태에 따라 수행하게 된다.

앞서 선언부에서 Promise 객체를 생성할 때 resolve와 reject 파라메터를 받았는데 그 파라메터가 실행부의 함수와 동일하다.




에러를 잡는 Promise.catch API

만약 체이닝형태로 연결된 상태에서 비동기 작업이 중간에 에러가 나면 어떻게 처리해야할까? 그때를 위해 존재하는 API가 catch API 이다. .then(null, function(){ })  을 메서드 형태로 바꾼 거라고 생각해도 좋다.


아래 예제를 살펴보자.


_promise(true)
	.then(JSON.parse)
	.catch(function () { 
		window.alert('체이닝 중간에 에러가!!');
	})
	.then(function (text) {
		console.log(text);
	});

앞서 _promise 에서 만든 객체는 성공 혹은 실패시 JSON 객체가 아닌 String을 리턴하므로 JSON.parse 에서 Error가 나게된다. 따라서 다음 then으로 이동하지 못하고 catch 에서 받게 된다. catch는 이와같이 promise가 연결되어 있을 때 발생하는 오류를 처리해주는 역할을 한다.


아래의 예제는 HTML5Rocks 에서 보이고 있는 좋은 예제이다.

asyncThing1()
	.then(function() { return asyncThing2();})
	.then(function() { return asyncThing3();})
	.catch(function(err) { return asyncRecovery1();})

	.then(function() { return asyncThing4();}, function(err) { return asyncRecovery2(); })
	.catch(function(err) { console.log("Don't worry about it");})

	.then(function() { console.log("All done!");});

위의 로직을 순서도로 표현하면 아래와 같다고 한다.






여러 프로미스가 모두 완료될 때 실행하려면? - Promise.all API

여러개의 비동기 작업들이 존재하고 이들이 모두 완료되었을 때 작업을 진행하고 싶다면, Promise.all API를 활용하면 된다.


아래의 코드를 살펴보자.


var promise1 = new Promise(function (resolve, reject) {

	// 비동기를 표현하기 위해 setTimeout 함수를 사용 
	window.setTimeout(function () {

		// 해결됨 
		console.log("첫번째 Promise 완료");
		resolve("11111");

	}, Math.random() * 20000 + 1000);
});

var promise2 = new Promise(function (resolve, reject) {

	// 비동기를 표현하기 위해 setTimeout 함수를 사용 
	window.setTimeout(function () {

		// 해결됨 
		console.log("두번째 Promise 완료");
		resolve("222222");

	}, Math.random() * 10000 + 1000);
});


Promise.all([promise1, promise2]).then(function (values) {
	console.log("모두 완료됨", values);
});


두번째 Promise 가 완료된 뒤, 시간이 흘러 첫번째 Promise 가 완료되면 최종적으로 전체값을 보여준다.



return 하지 않고 바로 new Promise로 생성하기

항상 new Promise 를 return 하는 형태로 사용하다가 바로 위의 Promise.all 에 대해 설명할 때는 return 이 아닌 바로 new Promise 를 할당하는 형태로 사용했다. 어떤 차이가 있을까?


아래의 코드를 실행했을 때는 어떻게 될지 예측해보자.

var _promise = new Promise(function(resolve, reject) {
	
	// 여기에서는 무엇인가 수행 

	// 50프로 확률로 resolve 
	if (+new Date()%2 === 0) {
		resolve("Stuff worked!");  
	}
	else {
		reject(Error("It broke"));
	}
});

위와 같이 선언할 경우 Promise 객체에 파라메터로 넘겨준 익명함수는 즉각 실행된다.

즉각 실행되므로 _promise.then(alert) 등의 형태로 사용할 수 있다.


이후 여러차례 _promise.then(alert) 를 호출해도 이미 한번 수행이 되었기 때문에 계속해서 resolve 혹은 reject 가 수행될 것이다.


한번 테스트 삼아서 _promise.then(alert).catch(alert); 를 여러차례 수행해보자.

한번 "Stuff worked!"가 나왔다면, 몇 번을 반복해서 수행해도 계속 "Stuff worked!"가 나오게 된다.


Promise 객체를 new로 바로 생성할 경우, 아래와 같은 형태로도 사용가능 할 것이다.

new Promise(function(resolve, reject) {

	// 50프로 확률로 resolve 
	if (+new Date()%2 === 0) {
		resolve("Stuff worked!");  
	}
	else {
		reject(Error("It broke"));
	}
}).then(alert).catch(alert);


이번에는 앞서 Promise.all 에 대한 예제를 Promise 를 return 하는 형태로 바꿀경우 어떻게 변하는지 확인해보자.

var promise1 = function () {

	return new Promise(function (resolve, reject) {

		// 비동기를 표현하기 위해 setTimeout 함수를 사용 
		window.setTimeout(function () {

			// 해결됨 
			console.log("첫번째 Promise 완료");
			resolve("11111");

		}, Math.random() * 20000 + 1000);
	});
};

var promise2 = function () {

	return new Promise(function (resolve, reject) {

		// 비동기를 표현하기 위해 setTimeout 함수를 사용 
		window.setTimeout(function () {

			// 해결됨 
			console.log("두번째 Promise 완료");
			resolve("2222");

		}, Math.random() * 10000 + 1000);
	});
};

위와 같이 Promise 객체를 return 하는 형태로 바꿀 경우 위에서처럼

Promise.all([promise1, promise2]).then(function (values) {
	console.log("모두 완료됨", values);
});

와 같은 형태로는 Promise.all API를 사용할 수 없다. Promise 객체가 아니기 때문에 오류 메시지를 만나게 된다.

따라서 아래와 같이 실행해야 정상적으로 Promise.all API를 호출할 수 있다.


Promise.all([promise1(), promise2()]).then(function (values) {
	console.log("모두 완료됨", values);
});





결론


지금까지 크롬에 포함된 Promise 를 기반으로 비동기 로직을 처리하는 방법에 대해서 살펴보았다. 크롬에 내장된 Promise 보다 강력한 기능을 담고 있는 Promise 라이브러리들이 많이 존재한다. 그 중에서도 bluebird 라는 라이브러리가 기능과 성능면에서 주목받고 있다.

http://bluebirdjs.com/docs/benchmarks.html


Promise는 크롬에 기본 내장된 만큼 향후 V8엔진을 사용하는 노드에도 기본 지원될 확률이 높다. 비동기 로직이 불가피한 자바스크립트 코딩에서 효율적으로 비동기 로직을 처리할 수 있는 Promise 를 손에 익혀둔다면 좀더 가독성있는 코드를 만드는데 도움이 될 수 있을 것이다.




참조
JavaScript Promises
ES6-compatible and Promises/A+ implementation for Node.js and browsers

 


JavaScript 관련 포스팅 더보기