필요 지식
#node #async

Javascript 는 싱글 스레드 특징 때문에 비동기 함수에 의한 콜백 함수를 많이 사용하게 됩니다. 그러한 특징 때문에 시점 처리에 대한 이슈가 발생할 수 있는데 멋진 대안책을 주는 async 패키지를 살펴보겠습니다.

경로 : https://www.npmjs.com/package/async
api : https://caolan.github.io/async/docs.html
설치 : npm install async
버전 : 2.6.0

api의 좌측 메뉴를 보면 크게 Collections와 Control Flow로 나뉜것을 볼 수 있습니다. Collections는 데이터를 기준으로 어떤 행위를 결정하고 싶을때, Control Flow는 로직의 흐름을 결정하고 싶을때 사용하는 항목 입니다.

아래와 같은 데이터가 있다고 가정하고 시작하겠습니다.

  1. var list = [
  2. {idx:0, artist: "엘키(CLC)", title: "Idream"},
  3. {idx:1, artist: "펀치", title: "LoveisYou"},
  4. {idx:2, artist: "여자친구", title: "Youarenotalone"},
  5. {idx:3, artist: "Ben", title: "결혼해줘"},
  6. {idx:4, artist: "정승환", title: "그대내게다시"},
  7. {idx:5, artist: "로시", title: "다핀꽃"},
  8. {idx:6, artist: "위위", title: "만나러갈게"},
  9. {idx:7, artist: "황치열", title: "반대말(HeartlessWord)"},
  10. {idx:8, artist: "김필", title: "사랑한다면서"},
  11. {idx:9, artist: "루시아", title: "아플래"},
  12. ];

mapSeries() 의 사용

list 변수의 값을 차례대로 출력하려면 어떻게 하면 될까요? 아래처럼 하면 될까요?

  1. for(let i = 0; i < list.length; i++) {
  2. let data = list[i];
  3. console.log(data.idx + ' ' + data.artist + ' - ' + data.title);
  4. }

아마 일반적인 경우라면 위의 방법이 맞을겁니다. 그러나 단순 출력이 아닌 아래와 같은 함수를 호출하는 것이라면 어떻게 될까요?

  1. function asyncFn(data, cb) {
  2. let delay = Math.random() * 100;
  3. setTimeout(function() {
  4. console.log(data.idx + ' ' + data.artist + ' - ' + data.title);
  5. if(cb)
  6. cb(null, data.idx);
  7. }, delay);
  8. }

asyncFn() 이라는 함수는 비동기 함수인 setTimeout() 함수를 호출하고 있습니다. 따라서 asyncFn() 함수 또한 비동기 함수가 됩니다. 이 함수를 호출하는 형태로 변경하면 아래와 같이 될 것입니다.

  1. let cnt = 0;
  2. for(let i = 0; i < list.length; i++) {
  3. asyncFn(list[i]);
  4. }

asyncFn() 함수는 for 루프에서 분명 차례대로 호출하지만 setTimeout() 의 실행 시간이 랜덤으로 만들어지기 때문에 값은 순서없이 출력될 것입니다. 이와 같이 특정 데이터를 처리하기 위한 기능이 비동기 함수이고 순서대로 처리 하고 싶을때 mapSeries() 함수를 사용하면 됩니다.

  1. const async = require('async');
  2. async.mapSeries(list, function iteratee(data, cb) {
  3. asyncFn(data, cb);
  4. }, function(err, results) {
  5. console.log(err);
  6. console.log(results);
  7. console.log('run2 done ----------------------');
  8. });

mapSeries() 와 eachSeries() 의 차이점

api의 목록을 살펴보면 mapSeries()와 비슷한 eachSeries()함수도 있는것을 보실수 있습니다. 무슨 차이가 있을까요?

  • mapSeries()
    The same as map but runs only a single async operation at a time.
  • eachSeries()
    The same as each but runs only a single async operation at a time.

api 설명을 보면 두개의 함수는 완전히 동일해 보입니다. 이 두개의 함수는 바로 callback 함수의 기능에 차이가 있습니다.
위에서 선언한 asyncFn()함수를 보면 콜백함수를 호출할때 아래와 같이 호출하고 있습니다.

  1. cb(null, data.idx);

콜백함수로 호출시 전달하는 첫번째 파라미터는 에러유무(null일때 에러 아님)를 의미합니다. 그리고 두번째 파라미터는 mapSeries() 함수가 모두 실행된후 호출되는 콜백함수의 results 객체로 전달하기 위한 값이 됩니다.
그럼 mapSeries() 함수와 동일하게 eachSeries() 함수를 호출해볼까요?

  1. async.eachSeries(list, function iteratee(data, cb) {
  2. asyncFn(data, cb);
  3. }, function(err, results) {
  4. console.log(err);
  5. console.log(results); //값 없음
  6. console.log('run3 done ----------------------');
  7. });

주석으로 설명을 붙였습니다. eachSeries() 함수는 콜백함수에 results 객체가 전달되지 않습니다. 단지 그 차이 뿐이냐고 하실분도 계실듯 합니다. 넵 그뿐입니다. eachSeries() 함수와 eachOfSeries() 함수도 아주 미묘하게 차이가 납니다. async 패키지는 아주 작은 차이로 기능이 달라야 할때 각기 다른 함수를 호출하여 역할을 구분하는 것이 철학인듯 합니다. 아니면 말구요.

whilst() 의 사용

데이터 전부가 아니라 일부만 처리하고 싶을때도 있을 겁니다. 순서도 띄엄띄엄 호출하고 싶을수도 있을듯 하네요. 아래 소스는 0번째 값부터 두칸씩 건너뛴 값을 출력합니다.

  1. let i = 0;
  2. async.whilst(function test() { //반복조건
  3. return i < list.length;
  4. }, function iteratee(cb) { //반복실행 함수
  5. asyncFn(list[i], cb);
  6. i += 3;
  7. }, function(err, n) { //최종 콜백함수
  8. console.log(err);
  9. console.log(n);
  10. console.log('run4 done ----------------------');
  11. });

whilst() 함수는 이름처럼 while 문법과 비슷한 성격을 가집니다. whilst()의 첫번째 파라미터는 반복하기 위한 조건, 두번째 파라미터는 반복 실행할 함수, 세번째 파라미터는 모든 루프가 종료된 후 실행될 콜백함수가 됩니다. 역시 불필요하게 이름을 붙인 함수로 작성했지만 그냥 익명함수로 만들면 됩니다.

waterfall() 의 사용

위에서 다룬 whilst()mapSeries() 함수는 데이터를 중심으로 함수 반복수행을 제어하는 것이었고 이제 살펴볼 waterfall() 함수와 parallel() 함수는 로직 흐름제어에 중점을 둔 함수입니다. 그래서 카테고리도 Control Flow로 분류된 것이죠.

아래와 같은 함수가 있습니다.(asyncFn() 함수와 같은 내용입니다.) 각 함수는 별다른 상관 관계가 없지만 반드시 a->b->c 함수 순으로 실행/종료 되어야 한다고 가정합시다.

  1. function a(cb) {
  2. let delay = Math.random() * 100;
  3. setTimeout(function() {
  4. console.log('aaa');
  5. if(cb)
  6. cb(null);
  7. }, delay);
  8. }
  9. function b(cb) {
  10. let delay = Math.random() * 100;
  11. setTimeout(function() {
  12. console.log('bbb');
  13. if(cb)
  14. cb(null);
  15. }, delay);
  16. }
  17. function c(cb) {
  18. let delay = Math.random() * 100;
  19. setTimeout(function() {
  20. console.log('ccc');
  21. if(cb)
  22. cb(null);
  23. }, delay);
  24. }

비동기 함수이기 때문에 아래처럼 실행한다고 차례대로 출력되진 않습니다.

  1. a();
  2. b();
  3. c();

자바스크립트의 특징을 아신다면 제 의도와 다르게 이렇게 작성하실수도 있습니다.

  1. async.mapSeries([a, b, c], function(data, cb) {
  2. data(cb);
  3. });

하지만 실제 상황에선 저렇게 서로 다른 함수가 같은 파라미터에 같은 내용을 가지고 있진 않겠죠? 이럴때 사용하는것이 waterfall() 함수 입니다.

  1. async.waterfall([a, b, c], function(err, result) {
  2. console.log('run5 done ----------------------');
  3. });

너무 단순한가요? 샘플 함수의 파라미터와 콜백 구조가 일치해서 너무 간단하게 표현되었네요. 실제 경우에는 아래와 같은 형태가 될 것입니다.

  1. async.waterfall([
  2. function step1(cb) {
  3. let delay = Math.random() * 100;
  4. setTimeout(function() {
  5. console.log('시작');
  6. cb(null, '이것', '저것'); //다음 함수에 '이것', '저것' 전달
  7. }, delay);
  8. },
  9. function step2(arg1, arg2, cb) {
  10. let delay = Math.random() * 100;
  11. setTimeout(function() {
  12. console.log(arg1 + arg2);
  13. cb(null, 'null이 아니면 에러'); //다음 함수에 'null이 아니면 에러' 전달
  14. }, delay);
  15. },
  16. function step3(arg1, cb) {
  17. let delay = Math.random() * 100;
  18. setTimeout(function() {
  19. console.log(arg1);
  20. cb(null, 'ok');
  21. }, delay);
  22. }], function(err, result) {
  23. console.log(err)
  24. console.log(result);
  25. console.log('run6 done ----------------------');
  26. });

각 스텝함수에서 콜백함수로 전달하는 값이 중요합니다. step1() 함수에서는 다음 함수로 파라미터 두개를(이것, 저것) 넘겨주려고 하기 떄문에 step2() 함수 정의에서도 arg1arg2 두개가 있습니다. step2() 함수에서는 파라미터 1개를 다음 함수로 전달하려고 하기 때문에 step3() 함수 정의에서는 arg1만 있습니다. 감이 오시죠?
콜백 함수인 cb()의 첫번째 파라미터는 에러여부이기 때문에 null 이 아닌 다른 값이 들어가면 다음함수로 더 진행하지 않고 최종 콜백함수가 실행됩니다.

waterfall은 비동기 함수의 순차 실행에 멋진 해결법을 주고 있습니다. 콜백함수의 중첩이 너무 많아서 곤란한 경우 꼭 사용하세요.

parallel() 의 사용

실행중인 여러 비동기 함수가 모두 종료 되었을떄 어떤 특정한 기능을 수행하고 싶을수 있습니다. parallel() 함수는 이름 그대로 여러 함수를 동시에 실행 시키고 모두 종료되었을때 콜백함수를 실행 시켜 줍니다.

아래 소스를 보시죠. 공식 api에 있는 예제를 조금 변형하였습니다.

  1. let startTime = Date.now();
  2. async.parallel([
  3. function(callback) {
  4. setTimeout(function() {
  5. callback(null, 'one');
  6. }, 230);
  7. },
  8. function(callback) {
  9. setTimeout(function() {
  10. callback(null, 'two');
  11. }, 120);
  12. },
  13. function(callback) {
  14. setTimeout(function() {
  15. callback(null, 'three');
  16. }, 370);
  17. }
  18. ],
  19. function(err, results) {
  20. console.log(Date.now() - startTime);
  21. console.log('run7 done ----------------------');
  22. });

세개의 setTimeout() 함수는 각각 230, 120, 370 밀리초 후에 실행되게끔 되어있습니다. 만약 각 함수를 순차 실행한다면 종료까지 총 720 밀리초가 필요할 것입니다.
그러나 위 소스를 실행하면 저는 371 밀리초가 출력되네요. 세개의 함수가 동시에 실행되었기 때문입니다.

정리

async 패키지는 비동기 함수의 흐름 제어에 대해 매우 강력하고 직관적인 기능을 제공합니다. 필요한 상황에 맞춰 제공되는 함수를 골라서 쓰기만 하면 됩니다. 익숙해지기 전 까지는 어떤 함수를 쓰면 되는지 고르는게 더 일이겠네요.

그런데 이것보단 Promise나 async/await를 쓰는게 더 좋지 않냐고 물어보실 분도 계시겠네요. 가장 좋은 방법은 async/await 겠지만 기존에 만들어진 Promise가 적용 안된 함수를 사용해야 할 경우, 또는 사용해야 할 api가 Promise 형태가 아닐 경우 async 패키지는 완벽한 해답이 될 것입니다. 고민하지 말고 마음껏 쓰세요.

최종 샘플

  1. const async = require('async');
  2. const list = [
  3. {idx:0, artist: "엘키(CLC)", title: "Idream"},
  4. {idx:1, artist: "펀치", title: "LoveisYou"},
  5. {idx:2, artist: "여자친구", title: "Youarenotalone"},
  6. {idx:3, artist: "Ben", title: "결혼해줘"},
  7. {idx:4, artist: "정승환", title: "그대내게다시"},
  8. {idx:5, artist: "로시", title: "다핀꽃"},
  9. {idx:6, artist: "위위", title: "만나러갈게"},
  10. {idx:7, artist: "황치열", title: "반대말(HeartlessWord)"},
  11. {idx:8, artist: "김필", title: "사랑한다면서"},
  12. {idx:9, artist: "루시아", title: "아플래"},
  13. ];
  14. function asyncFn(data, cb) {
  15. let delay = Math.random() * 100;
  16. setTimeout(function() {
  17. console.log(data.idx + ' ' + data.artist + ' - ' + data.title);
  18. if(cb)
  19. cb(null, data.idx);
  20. }, delay);
  21. }
  22. function run1(callback) {
  23. let cnt = 0;
  24. for(let i = 0; i < list.length; i++) {
  25. asyncFn(list[i], function() {
  26. cnt++;
  27. if(cnt == 10) {
  28. console.log('run1 done ----------------------');
  29. callback && callback();
  30. }
  31. });
  32. }
  33. }
  34. function run2(callback) {
  35. async.mapSeries(list, function iteratee(data, cb) {
  36. asyncFn(data, cb);
  37. }, function(err, results) {
  38. console.log(err);
  39. console.log(results);
  40. console.log('run2 done ----------------------');
  41. callback && callback();
  42. });
  43. }
  44. function run3(callback) {
  45. async.eachSeries(list, function iteratee(data, cb) {
  46. asyncFn(data, cb);
  47. }, function(err, results) {
  48. console.log(err);
  49. console.log(results); //값 없음
  50. console.log('run3 done ----------------------');
  51. callback && callback();
  52. });
  53. }
  54. function run4(callback) {
  55. let i = 0;
  56. async.whilst(function test() { //반복조건
  57. return i < list.length;
  58. }, function iteratee(cb) { //반복실행 함수
  59. asyncFn(list[i], cb);
  60. i += 3;
  61. }, function(err, n) { //최종 콜백함수
  62. console.log(err);
  63. console.log(n);
  64. console.log('run4 done ----------------------');
  65. callback && callback();
  66. });
  67. }
  68. function run5(callback) {
  69. function a(cb) {
  70. let delay = Math.random() * 100;
  71. setTimeout(function() {
  72. console.log('aaa');
  73. if(cb)
  74. cb(null);
  75. }, delay);
  76. }
  77. function b(cb) {
  78. let delay = Math.random() * 100;
  79. setTimeout(function() {
  80. console.log('bbb');
  81. if(cb)
  82. cb(null);
  83. }, delay);
  84. }
  85. function c(cb) {
  86. let delay = Math.random() * 100;
  87. setTimeout(function() {
  88. console.log('ccc');
  89. if(cb)
  90. cb(null);
  91. }, delay);
  92. }
  93. async.waterfall([a, b, c], function(err, result) {
  94. console.log('run5 done ----------------------');
  95. callback && callback();
  96. });
  97. }
  98. function run6(callback) {
  99. async.waterfall([
  100. function step1(cb) {
  101. let delay = Math.random() * 100;
  102. setTimeout(function() {
  103. console.log('시작');
  104. cb(null, '이것', '저것'); //다음 함수에 '이것', '저것' 전달
  105. }, delay);
  106. },
  107. function step2(arg1, arg2, cb) {
  108. let delay = Math.random() * 100;
  109. setTimeout(function() {
  110. console.log(arg1 + arg2);
  111. cb(null, 'null이 아니면 에러'); //다음 함수에 'null이 아니면 에러' 전달
  112. }, delay);
  113. },
  114. function step3(arg1, cb) {
  115. let delay = Math.random() * 100;
  116. setTimeout(function() {
  117. console.log(arg1);
  118. cb(null, 'ok');
  119. }, delay);
  120. }], function(err, result) {
  121. console.log(err)
  122. console.log(result);
  123. console.log('run6 done ----------------------');
  124. callback && callback();
  125. });
  126. }
  127. function run7(callback) {
  128. let startTime = Date.now();
  129. async.parallel([
  130. function(callback) {
  131. setTimeout(function() {
  132. callback(null, 'one');
  133. }, 230);
  134. },
  135. function(callback) {
  136. setTimeout(function() {
  137. callback(null, 'two');
  138. }, 120);
  139. },
  140. function(callback) {
  141. setTimeout(function() {
  142. callback(null, 'three');
  143. }, 370);
  144. }
  145. ],
  146. function(err, results) {
  147. console.log(Date.now() - startTime);
  148. console.log('run7 done ----------------------');
  149. callback && callback();
  150. });
  151. }
  152. async.waterfall([run1, run2, run3, run4, run5, run6, run7]);