안녕하세요! ECMAScript6(ES6)에서 비동기 프로그래밍 표준으로 채택된 Promise가 무엇인지 간단히 설명하고, 실제 개발에서 어떻게 사용하였는지 자세히 설명하겠습니다.
Promise란?
C와 같은 절차 지향 프로그래밍(Procedural Programming)에서는 a를 실행한 다음에, b를 실행하고 싶다면 아래와 같이 코드를 작성해 주면 됩니다. 프로그램은 코드가 짜여진 순서대로 실행이 되고, a가 끝나기 전에 b가 실행되지 않습니다.
var a = getDataA();
var b = getDataB(a);
var c = getDataC(b);
var d = getDataD(c);
이와 다르게, 싱글 드레그 기반의 비동기 프로그래밍 모델인 Node.js는 그 특성상 event driven 방식으로 실행됩니다. Event driven 이란 특정 이벤트가 발생되면 이벤트에 등록해놓은 함수(이하 Callback이라 칭함)가 실행되는 방식을 말합니다. 이벤트가 언제 발생될 지 모르기 때문에 해당 콜백 함수 또한 언제 호출될 지 예측할 수 없습니다.
따라서 비동기 프로그래밍에서 A를 실행한 다음에, B를 실행한 다음에, C를 실행하게끔 하려면 각각의 이벤트에 그에 맞는 Callback 함수를 아래와 같이 등록해 놓아야 합니다.
getDataA(function(err, a) {
if(err) {
failOnDataACallback(err);
return;
}
getDataB(a, function(err, b) {
if(err) {
failOnDataBCallback(err);
return;
}
getDataC(b, function(err, c) {
if(err) {
failOnDataCCallback(err);
return;
}
getDataD(c, function(err, d) {
if(err) {
failOnDataDCallback(err);
return;
}
everythingIsFinallyEndSoICallback(d);
});
});
});
});
이처럼 Node.js 비동기 프로그래밍 모델에는 콜백 지옥 이라는 불편함이 존재합니다. 우리가 하고 싶은 것은 단지 A->B->C->D 실행인데.. 이것이 바로 자바스크립트의 콜백 지옥 입니다. 이러한 콜백 지옥을 해결하기 위해 ES7의 async와 ES6의 Promise 같은 모델이 있습니다.
Promise 는 ECMAScript 6에 표준으로 채택된 비동기 프로그래밍을 위한 패턴입니다. Promise를 통해 이벤트를 순서대로 제어하여 콜백 지옥으로부터 탈출할 수 있습니다. 위에서 보았던 콜백 지옥을 다음과 같은 패턴으로 해결할 수 있습니다.
getDataA()
.then(getDataB)
.then(getDataC)
.then(getDataD)
.then(function(d) {
everythingIsFinallyEndSoICallback(d);
})
.catch(function(err) {
handleError(err);
});
또한 Promise는 병렬 실행도 가능합니다. A~C를 동시에 실행한 다음, D를 실행하고 싶다면 다음과 같이 작성하면 됩니다.
Promise.all([
getDataA, getDataB, getDataC
]).spread(function(a, b, c) {
getDataD(a, b, c, function(d) {
everythingIsFinallyEndSoICallback(d);
});
}).catch(function(err) {
handleError(err);
});
개발기
예제를 보니 Promise가 무엇인지 감이 오시죠?? 그렇다면 제가 실제 개발에 Promise를 어떻게 활용하였는지 공유해 드리겠습니다. 사용자가 입력한 메시지를 분석하기 위해 자연어 처리가 가능한 Node.js 기반의 서버를 구축하고 있었습니다. 입력부터 출력까지 서버의 로직은 다음과 같습니다.
- 사용자는 클라이언트에서 입력한 메시지를 서버로 전달한다.(/voice/:message)
- 서버는 해당 메시지를 형태소 분석을 통해 명사들만 추출한다.
- 데이터베이스에서 명령어들을 불러온다.
- 형태소 분석 결과와 명령어들을 하나의 Data Set으로 구성한다.
- Data Set 내에 있는 형태소 분석 결과와 명령어들 간의 TF-IDF 값을 계산한다.
- 0에서 입력된 메시지가 어떠한 명령인지 TF-IDF 값을 통해 판단하고, 그에 맞는 명령을 수행한다.
Promise 패턴을 도입하기 전에 각 기능들은 로직에 맞게 순서대로 동작하지 않았습니다. 왜냐하면, 위에서 말씀 드렸듯이 Node.js의 콜백은 언제 호출될 지 명확히 알 수 없다는 점 때문입니다. 실제로 위 로직에서 2번의 수행 시간이 3번보다 상대적으로 오래 걸리기 때문에 1->2->3 순서가 아닌 1->3->2 순서로 진행되었습니다. 그래서 저는 순차적인 로직 수행을 위해 Promise 패턴을 도입하였고, 각 기능들은 순차적으로 실행될 수 있도록 Promise 객체로 다음과 같이 구성되어 있습니다.
// Todo 0. parse the message to morpheme.
var parseMessage = function (receivedMsg) {
return new Promise(function (resolve, reject) {
mecab.parse(receivedMsg, function(err, result) {
if (err) {
log.info('[-] Failed to parse the message. error:' + err);
reject(err);
}
else {
result.forEach(function (morpheme) {
message.push(morpheme[0]);
});
console.log(message);
setTimeout(function () {
resolve();
});
}
});
});
}
// Todo 1. DB.find({type: '0'})
var selectDatas = function () {
return new Promise(function (resolve, reject) {
voiceSchema.find({}, function (err, commandSet) {
if (err) {
log.info('[-] Failed to find datas. error:' + err);
reject(err);
}
else {
setTimeout(function () {
resolve(commandSet);
});
}
})
});
}
// Todo 2. set tf-idf document(command).
var tfidfOfCommand = null;
var makeCommandSet = function (_commandSet) {
return new Promise(function (resolve, reject) {
if (!_commandSet) {
reject(new Error(500, 'commandSet does not exist'));
}
else {
tfidfOfCommand = new TfIdf();
var Voice = {};
var commandSet = [];
_commandSet.forEach(function (command) {
var c = {};
c.type = command.type;
c.tag = command.tag.toString().replace(/\s/g, '').split(",");
commandSet.push(c);
tfidfOfCommand.addDocument(c.tag);
});
setTimeout(function () {
Voice.commandSet = commandSet;
resolve(Voice);
});
}
});
}
// Todo 3. calculate tf-idf value(command).
var calculateCommandTFIDF = function (Voice) {
return new Promise(function (resolve, reject) {
if (!Voice || !tfidfOfCommand) {
reject(new Error(500, 'dataSet/tfidf does not exist'));
}
else {
tfidfOfCommand.tfidfs(message, function (i, score) {
Voice.commandSet[i].scoreOfCommand = score;
});
Voice.commandSet.sort(function(a, b){
return a.scoreOfCommand > b.scoreOfCommand ? -1 : a.scoreOfCommand < b.scoreOfCommand ? 1 : 0;
});
Voice.commandSet.forEach(function (command) {
console.log(command.type + ' ' + ' ' + command.scoreOfCommand);
});
setTimeout(function () {
resolve(Voice);
});
}
});
}
// Todo 9. execute the promise code.
var receivedMsg = req.params.message;
var promise = parseMessage(receivedMsg);
promise
.then(selectDatas)
.then(makeCommandSet)
.then(calculateCommandTFIDF)
.then(playMusic)
.then(sendLedInfo)
.then(response)
.then(console.log());
이처럼 비동기적으로 수행할 각 기능들을 Promise 객체로 구성하면 순차적인 실행을 가능하게 할 수 있습니다.
개인적으로 Promise 패턴을 이용하고 나서 느낀 점은 코드가 매우매우 깔끔해지고, 디버깅도 수월해졌습니다. 그래서 지금은 백엔드 뿐만 아니라 프론트 프로그래밍에도 Promise로 각 기능들을 구성하고 있습니다.
이상 Promise가 무엇이고, 실제 개발에 어떻게 적용하였는지 알아보았습니다.
읽어주셔서 감사합니다!ㅎㅎ