본문 바로가기
개발 이야기/NodeJS

NodeJS 콜백을 사용한 비동기 제어 흐름 패턴

by 농개 2019. 3. 9.
반응형

참고문헌 : NodeJS Design Pattern Second Edition


동기식 프로그래밍 스타일을 사용하다가 Node.js와 같은 비동기 api일반적으로 사용되는 플랫폼에 적응하는 것은 쉽지 않다.

일반적인 실수 하나가 call back hell이다. 간단한 동작도 가독성이 떨어지게 하고, 관리하기 힘들게 한다.


아래와 같은 방법을 통해 비동기 코드를 얼마나 쉽고 간단하게 만드는지 알아본다.


- 규칙과 패턴을 적용

- Async같은 제어흐름라이브러리를 활용



01. 비동기 프로그래밍의 어려움

불행히도 모듈화, 재사용성, 유지보수성과 같은 특성을 희생시키면 금방 콜백이 중첩되고 코드가 엉망이된다.

01-1. 간단한 웹 스파이더

./index.js
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
"use strict";
 
const request = require('request');
const fs = require('fs');
const mkdirp = require('mkdirp');
const path = require('path');
const utilities = require('./utilities');
 
function spider(url, callback) {
  const filename = utilities.urlToFilename(url);
  fs.exists(filename, exists => {        //[1] 해당파일이 생성되었는지 확인
    if(!exists) {
      console.log(`Downloading ${url}`);
      request(url, (err, response, body) => {      //[2] 파일이 없다면 url 다운로드
        if(err) {
          callback(err);
        } else {
          mkdirp(path.dirname(filename), err => {    //[3] 파일을 저장할 디렉터리 확인
            if(err) {
              callback(err);
            } else {
              fs.writeFile(filename, body, err => { //[4] 응답을 파일에 쓰기
                if(err) {
                  callback(err);
                } else {
                  callback(null, filename, true);
                }
              });
            }
          });
        }
      });
    } else {
      callback(null, filename, false);
    }
  });
}
 
spider(process.argv[2], (err, filename, downloaded) => {
  if(err) {
    console.log(err);
  } else if(downloaded){
    console.log(`Completed the download of "${filename}"`);
  } else {
    console.log(`"${filename}" was already downloaded`);
  }
});
 
cs


./utilities.js

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
"use strict";
 
const urlParse = require('url').parse;
const slug = require('slug');
const path = require('path');
 
module.exports.urlToFilename = function urlToFilename(url) {
  const parsedUrl = urlParse(url);
  const urlPath = parsedUrl.path.split('/')
    .filter(function(component) {
      return component !== '';
    })
    .map(function(component) {
      return slug(component, { remove: null });
    })
    .join('/');
  let filename = path.join(parsedUrl.hostname, urlPath);
  if(!path.extname(filename).match(/htm/)) {
    filename += '.html';
  }
  return filename;
};
 
cs





위의 애플리케이션은 인자로 http://naver.com 과 같은 url을 입력받아 로컬파일로 다운로드하는 것이다.(html파일이 생성됨)
아래와 같은 npm 라이브러리를 사용.
  • request : http호출
  • mkdirp : 재귀적으로 디렉터리를 만드는 단순 유틸

실행 시켜보면
1
2
3
$ > node .\index.js http://google.com
Downloading http://google.com
Completed the download of "google.com.html"
cs





01-2. 콜백헬

위에서 구현한 spider함수는 정말 간단한 알고리즘이지만 코드의 들여쓰기가 많이 중첩되어 읽기가 매우 어렵다는 것을 알 수 있다.
이러한 많은 클로저와 내부 콜백정의가 코드를 읽고, 관리하기 힘들게 만드는 상황을 콜백헬(Callback hell)이라고 한다.
이는 심각한 안티패턴 중 하나이다.
또한 피라미드 형식 같다고 하여 죽음의 피라미드(pyramid of dooom)이라는 별칭을 같기도 한다.
문제점은 아래와 같다.

  • 각 스코프에서 사용된 변수이름 중복
  • 가독성 떨어짐
  • 디버깅시 추적 불가


02. 일반 Javscript 사용

외부라이브러리를 사용하지 않고 특정 패턴과 기법만으로 제어흐름을 구현해보자


02-1. 콜백규칙

위의 콜백헬 개선에 중요한 패턴규칙은 아래와 같다.
  • 가능한 빨리 종료. return, continue, break 등을 사용하여 현재문을 즉시 종료
  • 콜백을 위해 명명된 함수 생성하여 클로저 바깥에 배치. 스택추적시 더 잘 보이게된다.
  • 코드 모듈화. 가능한 작게


02-2. 적용

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
function saveFile(filename, contents, callback) { // 일반 함수로 클로저 바깥에 배치
  mkdirp(path.dirname(filename), err => {
    if(err) {
      return callback(err); // return 으로 나머지 함수의 실행을 차단
    }
    fs.writeFile(filename, contents, callback);
  });
}
 
function download(url, filename, callback) {
  console.log(`Downloading ${url}`);
  request(url, (err, response, body) => {
    if(err) {
      return callback(err);
    }
    saveFile(filename, body, err => {
      if(err) {
        return callback(err);
      }
      console.log(`Downloaded and saved: ${url}`);
      callback(null, body);
    });
  });
}
 
function spider(url, callback) {
  const filename = utilities.urlToFilename(url);
  fs.exists(filename, exists => {
    if(exists) {
      return callback(null, filename, false);
    }
    download(url, filename, err => {
      if(err) {
        return callback(err);
      }
      callback(null, filename, true);
    })
  });
}
cs 

이 섹션에서 수행한 리팩토링작업은 클로저와 익명함수를 남용하지 않도록 하는 몇가지 원칙만 적용했지만
saveFile, download를 다른모듈에서 재사용하도록 할 수도 있고, 기능 테스트도 쉽다.



02-3. 순차 실행

일련의 작업을 순차적으로 실행.
알려진 일련의 작업에 대한 순차 실행. spider함수는 순차 실행의 한 예시이며, 다음과 같은 패턴으로 일반화 시킬수도 있다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
"use strict";
 
function asyncOperation(callback) {
  process.nextTick(callback);
}
 
function task1(callback) {
  asyncOperation(() => {
    task2(callback);
  });
}
 
function task2(callback) {
  asyncOperation(() => {
    task3(callback);
  });
}
 
function task3(callback) {
  asyncOperation(() => {
    callback(); //finally executes the callback
  });
}
 
task1(() => {
  //executed when task1, task2 and task3 are completed
  console.log('tasks 1, 2 and 3 executed');
});
 
cs


위와 같이 순차 실행을 코딩 할 수 있다. 만약 순차 실행을 반복하려면?


1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
function spider(url, nesting, callback) {
  const filename = utilities.urlToFilename(url);
  fs.readFile(filename, 'utf8', function(err, body) {
    if(err) {
      if(err.code !== 'ENOENT') {
        return callback(err);
      }
 
      return download(url, filename, function(err, body) {
        if(err) {
          return callback(err);
        }
        spiderLinks(url, body, nesting, callback);
      });
    }
 
    spiderLinks(url, body, nesting, callback);
  });
}
cs




1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
function spiderLinks(currentUrl, body, nesting, callback) {
  if(nesting === 0) {
    return process.nextTick(callback);
  }
 
  let links = utilities.getPageLinks(currentUrl, body);  // 페이지에 포함된 모든 링크 목록 가져옴
  function iterate(index) {
    if(index === links.length) {
      return callback();
    }
 
    spider(links[index], nesting - 1, function(err) {
      if(err) {
        return callback(err);
      }
      iterate(index + 1);
    });
  }
  iterate(0);
}
cs


위처럼 비동기 작업을 사용하면서 컬렉션을 반복하는 작업을 패턴화 할 수 있다.

여러가지 상황에 적용가능하다.


순차 반복자 패턴 : iterator라는 함수 작성. 작업의 목록을 차례로 실행. iterator는 다음에 사용가능한 태스크를 호출하고, 태스크가 완료될때 반복의 다음 단계를 호출 하도록 한다.





02-4. 병렬 실행

일련의 비동기 작업들의 실행 순서가 중요치 않다면 병렬 실행 흐름을 사용하여 보다 효과적으로 처리 할 수 있다.
Node.js는 단일 스레드이지만 하나의 스레드로 동시성을 달성 할 수 있다. 이는 Node.js의 Non-blocking 성질 때문이다.
실제로 작업을 동시에 실행하는것이 아니라 Non-blocking API 위에서 실행되며, 이벤트루프에 의해 인터리브(interleave : 교차 배치)된다. 이를 단순히 표현하기 위해 Node.js에서 병렬이라고 표현한다.



만약 비동기 작업을 하는 task1, task2가 있다면

1. Main함수는 task1, task2를 실행. 비동기작업이 시작되면 Event loop에 반환. 
2. task1의 비동기 작업이 완료되면 Event loop가 제어를 돌려주고, 작업을 완료하면 main함수에 이를 통지(이떄 task1의 자체적인 내부작업은 동기적)
3. task2에 의해 시작된 비동기 작업이 완료되면 Event loop가 해당 콜백을 호출하여 다시 제어를 돌려주고, 끝나면 main함수에 이를 다시 통지


1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
function spiderLinks(currentUrl, body, nesting, callback) {
  if(nesting === 0) {
    return process.nextTick(callback);
  }
 
  const links = utilities.getPageLinks(currentUrl, body); 
  if(links.length === 0) {
    return process.nextTick(callback);
  }
 
  let completed = 0, hasErrors = false;
 
  function done(err) {
    if(err) {
      hasErrors = true;
      return callback(err);
    }
    if(++completed === links.length && !hasErrors) {
      return callback();
    }
  }
 
  links.forEach(function(link) { // 병렬실행
    spider(link, nesting - 1, done);
  });
}
cs


무제한 병렬 실행 패턴 : 한번에 모든 항목을 생성하여 일련의 비동기작업을 병렬 실행한 다음, 콜백이 호출된 횟수를 계산하여 모든 작업이 완료되기를 기다린다.



* 동시작업에서 경쟁(race) 조건 조정

멀티스레드 환경에서 Non-blocking I/O를 사용하여 여러 작업을 병렬로 실행할 때 문제가 발생 할 수있다.

보통 잠금, 뮤텍스, 세마포어 같은 구조를 사용하여 수행되고 병렬화 성능에 상당한 영향을 미치며, 복잡한 측면도 있다.

그러나 Node.js에서는 단일 스레드에서 실행되기 때문에 복잡한 동기화 메커니즘이 필요치 않으며, 비동기작업을 병렬로 실행하는 것은 리소스 측면에서 직관적이며 비용도 적게 든다.(강점)


하지만 동기화 메커니즘을 생각하지 않는것은 경쟁조건을 가지지 않는 것을 뜻하며, 이는 일반적일 수 있으나 문제는 비동기 작업 호출과 그 결과 통지 사이에 생기는 지연이다.


만약 동일한 URL에 대해서 두개의 spider 작업을 수행한다면 같은 파일을 다운 받게 된다.

이를 제어하기 위해 상호 배제할 변수를 두면 해결할 수 있다.


1
2
3
4
5
6
7
8
9
10
11
12
let spidering = new Map(); // 같은 작업을 막는 변수
function spider(url, nesting, callback) {
  if(spidering.has(url)) {
    return process.nextTick(callback);
  }
  spidering.set(url, true);
 
  const filename = utilities.urlToFilename(url);
  fs.readFile(filename, 'utf8'function(err, body) {
    ...
  });
}
cs



경쟁상황은 단일 스레드 환경에서 많은 문제를 야기 할 수 있다. 특성상 디버그하기도 어렵다. 따라서 병렬실행시는 이러한 유형을 명확하게 확인하는 것이 필요하다.



02-5. 제한된 병렬 실행

만약 수천개의 파일을 읽거나 URL을 다운로드한다면 과부하가 걸릴수 있다.
웹어플리케이션의 경우 Dos(Denial of Service) 공격으로 악용될 수 있는 취약점이 발생한다.



방법1. 동시실행 제한

1
2
3
4
5
6
7
le concurrency = 2
fucntion next(){
    while(running < concurraency && index < tasks.length){
        ...
        running++;
    }
}
cs


방법2. 큐를 사용한 해결
TaskQueue를 구현하여 앞서 구현한 알고리즘과 결합한다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
class TaskQueue {
  constructor (concurrency) {
    this.concurrency = concurrency;
    this.running = 0;
    this.queue = [];
  }
 
  pushTask (task) {
    this.queue.push(task);
    this.next();
  }
 
  next() {
    while (this.running < this.concurrency && this.queue.length) {
      const task = this.queue.shift();
      task (() => {
        this.running--;
        this.next();
      });
      this.running++;
    }
  }
};
 
cs



1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
let downloadQueue = new TaskQueue(2);
 
function spiderLinks(currentUrl, body, nesting, callback) {
  if(nesting === 0) {
    return process.nextTick(callback);
  }
 
  const links = utilities.getPageLinks(currentUrl, body);
  if(links.length === 0) {
    return process.nextTick(callback);
  }
 
  let completed = 0, hasErrors = false;
  links.forEach(link => {
    downloadQueue.pushTask(done => {
      spider(link, nesting - 1, err => {
        if(err) {
          hasErrors = true;
          return callback(err);
        }
        if(++completed === links.length && !hasErrors) {
          callback();
        }
        done();
      });
    });
  });
}
cs


동시 실행 제어를 TaskQueue에 위임한다.






03. Async 라이브러리

Async 라이브러리는 컬랙션을 비동기적으로 처리 할 수 있는 유용한 헬퍼들을 제공한다.

(Node는 7.6 버전부터 async/await 를 별도의 도구없이도 지원하기 시작했다.)


  • npm install async
  • const async = require('async')
  • 순차 실행 : async.series()
  • 순차 반복 실행 : async.eachSeries()
  • 병렬실행 : async.each()
  • 제한된 병렬실행 : async.eachLimit()






반응형