관련 지식
#javascript #node.js #async #promise

싱글스레드로 동작하는 자바스크립트에서 비동기 함수를 사용하는 것은 매우 흔한 일입니다. Front-end 에서는 jquery의 ajax() 함수가 대표적이고, Back-end 에서는 많은 함수가 비동기로 동작하죠.
그렇다면 비동기 함수를 중첩해서 사용하는 것은 이제는 보편적일 것입니다.

Promise를 왜 쓸까

매우 간단한 예제 하나를 보겠습니다. URL ‘/api/etc/sample/wait1’ 을 ajax 로 호출하고 그 결과값을 받은 후에 Y이면 ‘/api/etc/sample/wait1.5’ 를 호출하는 내용입니다.

ajax 2개를 동기식으로 호출했을 때)

  1. var data = {};
  2. $.ajax({
  3. url: "/api/etc/sample/wait1",
  4. dataType : "json",
  5. async: false, //동기
  6. success : function(ret) {
  7. data.success = ret.success;
  8. }
  9. });
  10. if(data.success == "Y") {
  11. $.ajax({
  12. url: "/api/etc/sample/wait1.5",
  13. dataType : "json",
  14. async: false, //동기
  15. success : function(ret) {
  16. data.success = ret.success;
  17. }
  18. });
  19. }

URL이름에 맞게 각각 1초, 1.5초의 대기 시간이 순서대로 발생합니다. 아래 이미지 처럼요.

동기호출

‘/api/etc/sample/wait1’의 결과 값으로 ‘/api/etc/sample/wait1.5’ 를 호출하므로 동기/비동기 어떤 방식을 선택해도 실행 시간엔 크게 차이가 없습니다.(query1.jsp 실행 시간 + process.jsp 실행시간) 그러나 동기식으로 호출했을때 응답이 늦게 올 경우 그만큼 화면이 멈춰있게 되므로 비동기로 호출하는게 좋습니다.

ajax 2개를 비동기로 호출했을 때)

  1. var data = {};
  2. $.ajax({
  3. url: "/api/etc/sample/wait1",
  4. dataType : "json",
  5. async: true, //비동기
  6. success : function(ret) {
  7. if(ret.success == "Y") {
  8. $.ajax({
  9. url: "/api/etc/sample/wait1.5",
  10. dataType : "json",
  11. async: true, //비동기
  12. success : function(ret) {
  13. data.success = ret.success;
  14. }
  15. });
  16. }
  17. }
  18. });

비동기 형태로 바꾸었기 때문에 콜백 중첩을 사용하였습니다. 일반적으로 많이 사용하는 형태라 낯설지 않겠지만 만약 ‘wait1.5’를 호출하기 위한 조건으로 ‘wait2’를 하나 더 호출해야 한다면 어떨까요?(보기 안 좋은것은 일단 넘어가죠)

ajax 3개를 동기식으로 호출)

  1. var data = {};
  2. $.ajax({
  3. url: "/api/etc/sample/wait1",
  4. dataType : "json",
  5. async: false, //동기
  6. success : function(ret) {
  7. data.success1 = ret.success;
  8. }
  9. });
  10. $.ajax({
  11. url: "/api/etc/sample/wait2",
  12. dataType : "json",
  13. async: false, //동기
  14. success : function(ret) {
  15. data.success2 = ret.success;
  16. }
  17. });
  18. if(data.success1 == "Y" && data.success2 == "Y") {
  19. $.ajax({
  20. url: "/api/etc/sample/wait1.5",
  21. dataType : "json",
  22. async: false, //동기
  23. success : function(ret) {
  24. data.success = ret.success;
  25. }
  26. });
  27. }

동기식으로 ajax를 호출할 경우 하나씩 실행해서 결과를 받으면 됩니다. 다만 각각의 url이 1초, 2초, 1.5초 에 걸쳐 차례대로 호출되기 때문에 총 4.5초의 시간이 걸리게 됩니다. 동기 호출이기 때문에 4.5초 동안 화면이 멈춰있겠네요 ㄷㄷㄷ

동기ajax3

물론 예시를 들기위해 조금 극단적인 시간이 발생한다고 가정 했습니다만 작업이 중첩될수록 시간 소요가 커지는건 피할수 없을 것입니다.

그런데 ‘wait1’ 과 ‘wait2’는 서로 상관 관계가 없으니 굳이 차례대로 호출할 필요가 없을것 같습니다. ‘wait1.5’는 두 url의 실행 결과 값을 받아야 하지만 ‘wait2’는 ‘wait1’ 실행이 끝날때까지 기다릴 필요가 없죠. 그래서 코드를 바꾸겠습니다.

ajax 3개를 비동기로 호출)

  1. var data = {};
  2. $.ajax({
  3. url: "/api/etc/sample/wait1",
  4. dataType : "json",
  5. async: true, //비동기
  6. success : function(ret) {
  7. data.success1 = ret.success;
  8. $.ajax({
  9. url: "/api/etc/sample/wait2",
  10. dataType : "json",
  11. async: true, //비동기
  12. success : function(ret) {
  13. data.success2 = ret.success;
  14. if(data.success1 == "Y" && data.success2 == "Y") {
  15. $.ajax({
  16. url: "/api/etc/sample/wait1.5",
  17. dataType : "json",
  18. async: true, //비동기
  19. success : function(ret) {
  20. data.success = ret.success;
  21. }
  22. });
  23. }
  24. }
  25. });
  26. }
  27. });

중첩 콜백을 이용하여 비동기 호출로 바꾸었지만 개선된 효과를 볼수는 없습니다. 호출 방식만 비동기 일뿐 논리적인 흐름은 순차 호출이기 때문입니다. 저렇게 상하관계가 없는 ajax를 빠르게 호출하기 위해선 아래와 같은 형태가 되어야 합니다.

콜백 중첩 없는 ajax 비동기 호출)

  1. var data = {};
  2. $.ajax({
  3. url: "/api/etc/sample/wait1",
  4. dataType : "json",
  5. async: true, //비동기
  6. success : function(ret) {
  7. data.success1 = ret.success;
  8. }
  9. });
  10. $.ajax({
  11. url: "/api/etc/sample/wait2",
  12. dataType : "json",
  13. async: true, //비동기
  14. success : function(ret) {
  15. data.success2 = ret.success;
  16. }
  17. });
  18. //???????????????????????
  19. if(data.success1 == "Y" && data.success2 == "Y") {
  20. $.ajax({
  21. url: "/api/etc/sample/wait1.5",
  22. dataType : "json",
  23. async: true, //비동기
  24. success : function(ret) {
  25. data.success = ret.success;
  26. }
  27. });
  28. }

위와 같은 형태로 만들면 ‘wait1’ 을 호출하고 응답이 오기전에 ‘wait2’ 를 호출하므로 ajax 두개의 응답 시간은 최대 2초가 될 것입니다.(병렬 실행) 다만 문제는 각 ajax를 호출하고 응답을 받기 전에 if 조건이 실행되므로 원하는 결과를 얻을수 없다는 것입니다.

이 문제를 해결하기 위해서는 ‘wait1.5’ 호출을 각 url 호출의 콜백함수에 추가로 하는 등 소스를 수정해서 해결할 수 있습니다. 그러나 다중 실행된 ajax중 일부가 오류났을 경우나, 특정 ajax가 오류났을때 롤백 기능을 실행해야 한다는 조건이 있으면 개발량이 상당히 많아질 것입니다.

이러한 비동기 함수 흐름 제어에 대한 이슈는 굉장히 중요한 문제이기 때문에 node.js에서는 async 패키지가, jQuery에서는 $.Deferred() 함수 등 이슈를 해결할수 있는 다양한 기능이 만들어졌습니다. 그리고 마침내 ECMA6에 공식적으로 Promise 가 추가 되었습니다.

Promise 사용법

먼저 Promise 가 적용되기 전의 비동기 함수를 보겠습니다. 제가 선호하는 형태의 콜백함수는 아니지만 이후에 볼 Promise 호출 방식과 비슷하게 작성하였습니다. 그리고 함수 호출 부분에선 익명함수로 작성하면 될 콜백함수에 굳이 이름도 붙였습니다.

Promise 적용 전)

  1. function getTicket(resolve, reject) {
  2. console.log("실행");
  3. setTimeout(function() {
  4. var rand = Math.random();
  5. if(rand > 0.5) {
  6. resolve("성공");
  7. }
  8. else {
  9. reject("실패");
  10. }
  11. }, 1000);
  12. }
  13. getTicket(function success(text) { // 익명함수로 작성하면 됨
  14. console.log(text);
  15. }, function error(err) { // 익명함수로 작성하면 됨
  16. console.error(err);
  17. });

setTimeout()가 1초 후에 실행되도록 되어있습니다. 1초 후에 실행되는 함수에서는 랜덤 숫자를 뽑아내고 0.5보다 클 경우 resolve() 함수를, 이하일땐 reject() 함수를 호출하도록 되어있습니다. 동일한 기능을 유지하며 Promise를 적용하면 아래와 같이 됩니다.

Promise 적용 후)

  1. function getTicket() {
  2. return new Promise(function(resolve, reject) { //여기
  3. console.log("실행");
  4. setTimeout(function() {
  5. var rand = Math.random();
  6. if(rand > 0.5) {
  7. resolve("성공");
  8. }
  9. else {
  10. reject("실패");
  11. }
  12. }, 1000);
  13. }); //여기
  14. }
  15. getTicket().then(function success(text) { //여기
  16. console.log(text);
  17. }, function error(err) {
  18. console.error(err);
  19. });

소스가 수정된 부분 세군데에 //여기 라고 주석을 붙였습니다. 함수의 내용을 Promise로 감싸고 그것을 리턴하도록 수정, getTicket() 호출하고 then() 함수를 체인으로 호출하는것 외엔 큰 변화가 없습니다. (then() 함수는 물론 Promise 객체에 있는 함수입니다.)
만약 로직중에 resolve()reject() 중 아무것도 호출되지 않는 경우가 있으면 Promise는 끝까지 수행되지 않으므로 주의해야 합니다.

이젠 처음 다루던 이슈에 Promise를 적용해 보겠습니다. 이전 이슈에 적합한 기능은 Promise.all() 함수입니다.

  1. var data = {};
  2. var promise1 = new Promise(function(resolve, reject) {
  3. $.ajax({
  4. url: "/api/etc/sample/wait1",
  5. dataType : "json",
  6. async: true, //비동기
  7. success : function(ret) {
  8. resolve(ret);
  9. }
  10. });
  11. });
  12. var promise2 = new Promise(function(resolve, reject) {
  13. $.ajax({
  14. url: "/api/etc/sample/wait2",
  15. dataType : "json",
  16. async: true, //비동기
  17. success : function(ret) {
  18. resolve(ret);
  19. }
  20. });
  21. });
  22. Promise.all([promise1, promise2]).then(function(values) {
  23. if(values[0].success == "Y" && values[1].success == "Y") {
  24. $.ajax({
  25. url: "/api/etc/sample/wait1.5",
  26. dataType : "json",
  27. async: true, //비동기
  28. success : function(ret) {
  29. data.success = ret.success;
  30. }
  31. });
  32. }
  33. });

promise 적용

편의상 에러 상황에 대한 reject 처리는 생략했습니다만 전체 흐름을 보는데는 부족하지 않을겁니다. 개발자도구 이미지에서 동시에 두개의 url이 호출되고 실행이 끝난 시점에 ‘wait1.5’가 호출됐음에 주목해야 합니다.

만약 Promise를 사용하지 않고 처리하려 했다면 개발공수가 늘거나, 함수간에 결합도가 높아지거나, 다양한 상황에 적용하기 어려워졌겠지만 Promise를 사용하면 간단하게 해결이 가능합니다. 실제로는 Promise를 이용하는 별도 모듈을 사용하는 경우가 더 많을듯 하지만 Promise 의 원리만 알고 있으면 사용하는데 어렵진 않을 것입니다.

아래는 Promise를 사용할 수 없는 환경에서 Promise.all() 함수를 유사하게 구현해본 예제입니다. 한번 보시면 좋을듯 합니다.(Fromis 라고 만들었더니 뭔가 IS에서 온 듯한 느낌이 나네요;)

정리

비동기 함수의 흐름 제어가 필요할때 Promise는 매우 강력한 기능을 제공합니다. 두번 세번 쓰세요. 다른 함수들은 나중에 제가 다루게 될지 모르겠지만 api를 참고하셔도 어렵지 않으실겁니다.

MDN web docs - Promise

Promise를 사용할수 없는 환경에서 직접 만들어보는 예제

  1. function Fromis(fn) {
  2. this.execute = fn;
  3. }
  4. Fromis.all = function(arr) {
  5. var fromisLen = arr.length;
  6. var resolveCnt = 0;
  7. var rejectCnt = 0;
  8. return {
  9. allResolveFn : null,
  10. resultList : [],
  11. then : function(allResolveFn) {
  12. var self = this;
  13. this.allResolveFn = allResolveFn;
  14. for(var i = 0; i < fromisLen; i++) {
  15. (function(idx) {
  16. arr[idx].execute(function(ret) {
  17. self.check(idx, 1, ret);
  18. }, function(ret) {
  19. self.check(idx, -1, ret);
  20. });
  21. })(i)
  22. }
  23. },
  24. check : function(idx, result, msg) {
  25. if(result > 0)
  26. resolveCnt++;
  27. else if(result < 0)
  28. rejectCnt++;
  29. this.resultList[idx] = msg;
  30. if(resolveCnt + rejectCnt == fromisLen)
  31. this.allResolveFn(this.resultList);
  32. }
  33. }
  34. };
  35. var fromis1 = new Fromis(function (resolve, reject) {
  36. // 비동기를 표현하기 위해 setTimeout 함수를 사용
  37. window.setTimeout(function () {
  38. // 해결됨
  39. console.log("첫번째 Promise 완료");
  40. resolve("11111");
  41. }, Math.random() * 3000);
  42. });
  43. var fromis2 = new Fromis(function (resolve, reject) {
  44. // 비동기를 표현하기 위해 setTimeout 함수를 사용
  45. window.setTimeout(function () {
  46. // 해결됨
  47. console.log("두번째 Promise 완료");
  48. resolve("222222");
  49. }, Math.random() * 2000);
  50. });
  51. Fromis.all([fromis1, fromis2]).then(function (values) {
  52. console.log("모두 완료됨", values);
  53. });