Reactor 패턴이라는 것은 Node.js 비동기 특성의 핵심입니다.
단일 스레드에서의 non-blocking I/O 와 함께 Reactor 패턴에 대해 알아보도록 하겠습니다.
- I/O
일반적으로 I/O 작업으로는 로컬 파일 시스템에서 파일을 읽고 쓰거나, 네트워크 상에서 웹페이지를 여는 등의 작업을 들 수 있습니다.
이러한 작업들을 I/O bounded process 라고 부르기도 하며, 대척점에 있는 개념은 아니지만 CPU bounded process 와 종종 비교를 합니다. 간단히 말해 데이터 입출력에 대한 작업은 I/O, hash 나 압축 등 연산과 관련된 작업은 CPU 로 보고있죠.
I/O 는 컴퓨터의 기본적인 동작 중에서 가장 느립니다. 또한 일부 경우에 대해 입력 이라는 부분은 사람에 의해 진행되므로, I/O 속도는 꼭 기술적인 면에 의해서만 좌우된다고 볼 수 없습니다. 간략하게 blocking 과 non-blocking 의 차이에 대해 보도록 하겠습니다.
- Blocking I/O
전통적인 blocking I/O 에서는 I/O 요청에 해당하는 함수 호출은 작업이 완료될 때까지 스레드의 실행이 차단됩니다.
blocking I/O 를 사용해 구현된 웹서버는 스레드가 블록되니 동일한 스레드에서 여러 연결을 처리할 수 없다는 것은 너무도 당연한 얘기입니다. 이러한 환경에서 동시성을 처리하기 위한 접근 방식은 동시 연결에 대해 새로운 스레드 또는 프로세스를 시작하거나, 미리 만들어놓은 스레드풀에서 스레드를 재사용하는 것입니다.
스레드는 시스템 리소스 측면에서도 메모리를 소모하고 컨텍스트 스위치를 유발하는 등 비용이 싸지 않기 때문에, 장시간 실행되는 스레드를 사용하는 것은 효율적이지 않습니다.
- Non-blocking I/O
대부분의 최신 운영체제에서는 이 방식을 지원합니다. non-blocking I/O 모드에서 시스템 호출은 데이터가 읽히거나 쓰여질 때까지 기다리지 않고 즉시 반환됩니다. 호출하는 순간에 결과를 사용할 수 없는 경우 함수는 미리 정의된 상수를 반환하여 그 순간에 반환할 수 있는 데이터가 없음을 나타냅니다.
non-blocking I/O 에 접근하는 가장 기본적 패턴은 실제 데이터가 반환될 때까지 루프 내에서 리소스를 폴링하는 것(busy-watiing)입니다. 이런 기본적인 패턴을 사용해 동일한 스레드에서 blocking I/O 와 다르게 서로 다른 리소스를 처리할 수 있지만, 이는 효율적이지 않습니다. 폴링 방식은 대부분 CPU 낭비를 초래하기 때문입니다.
보통 blocking 과 non-blocking 을 sync - async 와 많이 혼동하는데, 나중에 이를 따로 정리하는 포스팅을 올려보겠습니다.
- 이벤트 디멀티플렉싱 (Event Demultiplexing)
위에서 얘기한 busy-waiting 은 non-blocking I/O 에서 리소스를 처리하기 위한 이상적인 기술은 아니지만 최신 운영체제는 효율적인 non-blocking 리소스를 처리하기 위한 메커니즘을 제공하는데, 이를 동기 이벤트 디멀티플렉서(Synchronous Event Demultiplexer) 또는 이벤트 통지 인터페이스(Event Notification Interface) 라고 합니다.
동작 방식은, 먼저 리소스를 queue 와 같은 데이터 구조에 추가합니다.
이벤트 통지자에 감시할 리소스 그룹을 설정하는데 이 동작은 동기식이며 감시 대상 자원 중 하나라도 준비가 될 때까지 차단됩니다.
이 때 이벤트 디멀티플렉서는 호출로부터 복귀해 새로운 이벤트들을 처리할 수 있게 됩니다.
이벤트 디멀티플레서에 의해 반환된 각 이벤트가 처리되는데 모든 이벤트가 처리되고 나면 이 흐름은 다시 디멀티플렉서에 의해 처리 가능한 이벤트가 발생할 때까지 차단되며, 이를 Node.JS 에서 자주 들었던 이벤트 루프라고 합니다.
이러한 구조를 통해 하나의 스레드만 사용하더라도 여러 I/O 사용 작업을 동시에 실행할 수 있게 되며, 작업은 여러 스레드로 분산되지 않고 시간에 따라 분산됩니다. 스레드의 유휴시간을 최소화할 수 있으며, 개발자 입장에서 봤을 때 하나의 스레드만 갖는다는 것은 훨씬 간단한 동시성 전략을 사용할 수 있다는 장점이 있습니다. Node.JS 에선 이 구조를 베이스로 둔 특수한 패턴(Reactor)을 사용합니다.
- Reactor 패턴
Reactor 패턴에서의 핵심 개념은 I/O 작업과 관련된 핸들러(callback)를 갖는 것이며, 아래와 같이 동작합니다.
Node.JS 어플리케이션은 이벤트 디멀티플렉서에 요청을 전달함으로써 I/O 작업을 생성합니다. 이때 처리가 완료될 때 호출될
핸들러(callback)를 지정합니다. 이 작업은 non-blocking 이며, 즉시 어플리케이션에 제어를 반환합니다.
위의 I/O 작업들이 완료되면 이벤트 디멀티플렉서는 이벤트 큐에 새로운 이벤트를 집어넣습니다.
이벤트 루프는 이벤트 큐에 들어있는 항목들에 대해 Iterate(반복) 하고 있는데, 각 이벤트에 대해 등록된 핸들러가 호출됩니다.
핸들러는 실행이 완료되면 이벤트 루프에 제어를 되돌리지만, 여기서 새로운 비동기 동작이 요청되면 이벤트 루프로 제어가 돌아가기 전에 새로운 요청이 이벤트 디멀티플렉서에 삽입될 수 있습니다.
이벤트 큐 내의 모든 항목이 처리되면 이벤트 루프는 이벤트 디멀티플렉서에 의해 블록되고 처리 가능한 새로운 이벤트가 들어올 때 위 과정들이 다시 트리거 됩니다.
위 그림은 Reactor 패턴의 동작을 도식화한 것입니다. 어플리케이션은 특정 시점(non-blocking)에서 리소스에 접근하려는 요청을 표시하고, 해당 처리가 완료되는 다른 시점에서 호출될 핸들러를 제공합니다.
- libuv
libuv 는 Node.JS 의 non-blocking 엔진입니다. 여기에 대해 자세히 적자면 내용이 꽤나 길어질테지만..
Linux, mac OS, Windows 등 각 운영체제에는 epoll, kqueue, I/O Completion Port API 와 같은 이벤트 디멀티플렉서에 대한 자체 인터페이스가 있습니다. 그리고 각 I/O 작업은 동일한 OS 내에서도 리소스 유형에 따라 다르게 동작할 수 있습니다.
서로 다른 운영체제에서 발생하는 불일치때문에 이벤트 디멀티플렉서에 대한 높은 수준의 추상화를 Node.JS 에선 필요로 하게 되었습니다. 이러한 니즈에 의해 Node.JS 에선 libuv 라는 C 라이브러리를 만들게 된 것입니다.
이를 통해 모든 주요 플랫폼과 호환되고, 서로 다른 유형의 리소스들에 대한 non-blocking 동작을 표준화할 수 있게 된 것입니다.
libuv 는 오늘날 Node.JS 의 하위 수준 I/O 엔진을 나타냅니다.
libuv 는 기본 시스템 호출을 추상화하는 것 외에도 Reactor 패턴을 구현하고 있어 이벤트 루프를 만들고 이벤트 큐를 관리하며 비동기 I/O 작업 실행 및 queue 에 담기 위한 여러 API 등을 제공합니다. 이러한 libuv 를 포함해 낮은 수준의 기능들을 Javascript 에 래핑하고 사용 가능하도록 도와주는 바인딩 세트와 v8(javascript engine), 상위 수준의 코어 Javascript API 를 묶어서 Node.JS 플랫폼을 구축하기 위한 기본 요소라고 합니다.
이러한 구조때문에 Node.JS 프로그래밍에선 기존 언어들과는 다른 형태의 프로그래밍 스타일을 필요로 합니다.
그런 프로그래밍 스타일을 익히는 것이 엄청 어렵다고 생각하진 않지만, 어떠한 원리에 의해 이런 프로그래밍 방식이 필요한지를 알아두는 것이 더 중요한 것 같습니다. 다른 포스팅에서, libuv 에 대해 좀 더 자세히 다뤄보도록 하겠습니다.
'Backend > Node.js' 카테고리의 다른 글
Callback 패턴 (0) | 2022.07.23 |
---|---|
lockfile 주의점 (0) | 2022.07.16 |
Cluster Module in Node.JS (0) | 2022.04.09 |
SharedArrayBuffer & Atomics (SharedMemory) (0) | 2022.04.03 |
package-lock.json 이란? (0) | 2022.01.22 |