"디자인은 구현과 인터페이스 모두에서 단순해야 한다. 구현이 인터페이스보다 단순해야 하는 것이 중요하다. 단순함은 설계에서 가장 중요한 고려 사항이다"
- 리차드 가브리엘(Richard P.Garbriel) -
reactor 패턴을 알기 전에 몇 가지 개념들에 대해 먼저 짚고 넘어가려고 한다.
Polling vs Callback
폴링이란, 충돌을 피하거나 동기화 처리 등을 목적으로 다른 장치(또는 프로그램)의 상태를 주기적으로 검사하여, 일정한 조건을 만족할 때 송수신 등의 자료처리를 하는 방식이다. 언뜻 느끼기엔 Callback과 유사해 보이지만, 콜백은 콜백함수를 등록해 놓고, 자신이 호출되기를 기다리는 것임에 반해, 폴링은 원하는 응답을 얻을 때까지 서버에 반복적으로 확인 요청을 하는 것이다.
이벤트 중심 아키텍처에서 자주 등장하는 개념이다.
libuv
Node.js문서를 읽다 보면 한 번쯤은 꼭 등장하는 라이브러리이며, 비동기 I/O에 중점을 두고 있다.. 최초에는 Node.js용으로 만들어졌지만, 지금은 크로스 플랫폼을 지원한다. 여기서 중요한 점은 libuv는 스레드 풀을 사용하여 비동기 I/O 작업을 가능하게 하지만, 네트워크 I/O는 OS에서 처리된다.
위 그림에서 보듯이 쓰레드 풀 위에서 동작하는 것은 File I/O, DNS 조회, 사용자 코드, crypto 등이지, 네트워크 I/O는 아니다. 그렇다면 마음속에 가지고 있던 아래와 같은 궁금증 하나가 약간 해소될 수 있을 것이다.
Q) "NodeJS는 분명 싱글 스레드인데, 어떻게 비동기 처리가 가능하지?"
A) "아하, NodeJS에서 사용하고 있는 C/C++로 만든 libuv 라이브러리 때문에 가능한 거구나?"
Q) "근데 왜 NodeJS를 싱글 스레드 언어라고 하는 거야? 자바스크립트 언어를 사용하는 싱글 스레드 아니야?"
A) "정확히 말하자면 NodeJS는 백그라운드에서 여러 쓰레드를 사용하여 비동기 코드를 실행하는 싱글 스레드 언어!"
libuv 라이브러리는 기본적으로 4개의 thread를 사용할 수 있다(기본적이라는 말은 커스텀도 가능하다는 말이다, 최대 libuv 1.30.0 기준 1024개).
process.env.UV_THREADPOOL_SIZE
다만, 한번 쓰레드 풀을 변경하고 첫 번째 작업이 실행되면, 두 번째 스레드 풀 변경 요청부터는 스레드 풀 크기에 영향을 주지 않는다.
const fs = require('fs');
process.env.UV_THREADPOOL_SIZE = 10; // Works well!
fs.readFile('./jacob.html', function readFile(err, data) {
process.env.UV_THREADPOOL_SIZE = 10; // This won't!
if(err) return throw err;
console.log(data);
}
스레드 풀 변경은 전역적으로 적용되며, 모든 이벤트 루프에서 공유된다.
Blocking I/O vs Non-Blocking I/O
I/O는 컴퓨터의 기본적인 동작 중에서 가장 느린 동작이다. 전통적인 Blocking I/O 프로그래밍에서는 I/O 요청에 해당하는 함수 호출은 작업이 완료될 때까지 스레드를 차단했다. 당연히 블록킹 I/O가 구현된 웹 서버는 여러 연결을 처리할 수 없다는 것이다. 물론 여기에 대한 대안으로 스레드를 늘려서 처리하는 방법이 있겠지만, 메모리를 소모하고 콘텍스트를 유발하는 스레드는 값싼 자원이 아니기 때문에 효율적인 방법이 아니다.
Non-Blocking I/O의 경우 최신 운영체제에서 대부분 지원하는 리소스에 접근하는 새로운 매커니즘이다. Non-Blocking을 구현하는 여러 가지 패턴이 있는데, 가장 기본적인 패턴은 busy-waiting이다. busy-waiting은 실제 데이터가 반환될 때까지 루프 내에서 리소스를 폴링하는 것이다.
// NOTE: pseudocode
function busyWaiting() {
while(something === true){
// waiting ...
}
}
위에 슈도 코드를 봐도 정말 비효율적이라는 게 느껴진다. 사용할 수 없는 리소스를 반복하는데만 cpu 사이클이 계속 사용되고 있는 것이다. 이러한 문제에 대한 대안으로 필요한 것이 Event-Demultiplexing이다. 이벤트 디멀티플랙서(Event-Demultiplexer)는 I/O 이벤트를 수집하여 이벤트 큐(Queue)에 넣고 처리할 수 있는 새 이벤트가 있을 때까지 차단한다. 이 패턴을 사용하면 busy-waiting을 사용하지 않고 단일 스레드 내에서 여러 I/O 작업을 처리할 수 있다.
또한 작업이 여러 쓰레드에 분산되는 것이 아닌, 시간이 따라 분산되어, 스레드의 유휴 시간이 위와 같이 최소화할 수 있다.
위 내용까지가 Node.js에서 사용된 비동기 특성의 핵심인 Reactor pattern을 설명하기 위한 기초였다. 저 개념들을 조합하고 기반으로 하여 다음 글에서는 Reactor pattern에 대해 알아보자.
'Node.js' 카테고리의 다른 글
Typescript로 Koa 서버 구축하기 (2) | 2019.12.28 |
---|