Node.js의 Reactor 패턴

Node.js의 Reactor 패턴에 대해서 알아봅니다.

Jun 27, 2024

김현기

#NODE.JS#SERVER

들어가며

Node.js 디자인 패턴 바이블 이라는 책을 읽고 있습니다.

블로그를 통해 책 초반부에 나오는 Node.js의 리액터 패턴에 대해 이야기해 보려 합니다.

Node.js는 어떻게 작동하는가?

I/O는 느리다.

책에서 I/O는 컴퓨터의 기본적인 동작들 중에서 가장 느리다고 합니다. 느리다는 개념이 확 와닿지 않아 I/O에 대해 알아봤습니다.

  1. 컴퓨터의 I/O

컴퓨터가 외부 세계와 통신하는 방식을 의미합니다. 컴퓨터는 입력 장치를 통해 정보를 입력하고 출력 장치를 통해 정보를 출력합니다.

입력 장치는 키보드, 마우스, 마이크, 카메라 등이 있으며, 출력 장치는 모니터, 프린터, 스피커 등이 있습니다.

이러한 장치들은 CPU, RAM에 비해 속도가 느린데, CPU의 속도는 기가헤르츠(GHz) 단위로 측정되고, RAM은 나노초(ns) 또는 마이크로초(µs)인데 반해,

I/O 작업에 사용되는 입력, 출력 장치, 하드 드라이브, 네트워크 등은 기본적으로 MB/s 또는 GB/s 단위로 다양하게 측정됩니다.

또한 책에서는 인간이라는 요소를 고려해야 한다고 하는데 사람이 하는 마우스 클릭처럼 애플리케이션의 입력이 일어나는 많은 상황들에서 I/O 속도와 빈도는 기술적인 측면에만 의존하지 않으며 디스크나 네트워크보다 느릴 수 있다고 합니다.

  1. Node.js의 I/O

Node.js의 I/O는 파일 시스템, 네트워크, 데이터베이스, API 요청 등과의 상호작용을 의미합니다

이 동작들은 디스크, 네트워크등에 접근해야 하므로 CPU, RAM에 비해 접근하는데 시간이 오래 걸립니다.

블로킹 I/O

전통적인 블로킹 I/O 프로그래밍에서는 I/O를 요청하는 함수의 호출은 작업이 완료될 때까지 스레드의 실행을 차단합니다. 차단 시간은 위 설명한 것처럼 디스크 접근, 네트워크 접근 등의 I/O 작업이 끝날 때까지 기다려야 하므로 시간이 오래 걸리고, 사용자 액션에 의해 데이터가 생성되는 경우 몇 분까지 소요되기도 합니다. 블로킹 I/O를 사용하여 구현된 웹 서버가 같은 스레드 내에서 여러 연결을 처리하지 못하는 것은 당연한 일이기 때문에 이 문제를 해결하기 위한 전통적인 접근 방법은 각각의 동시 연결을 처리하기 위해서 개별의 스레드 또는 프로세스를 사용하는 것입니다.


다중 커넥션을 처리하기 위한 다중 스레드

위 이미지에서 주의 깊게 볼 것은 새로운 데이터를 받기 위해 각 스레드가 유휴 시간을 갖는 부분입니다.

I/O의 작업 결과를 위해서 스레드가 꽤 많이 블로킹된다는 것을 알 수 있습니다.

스레드는 커넥션 처리를 위해 메모리를 소모하고 컨텍스트 전환을 유발하여 대부분의 시간 동안 사용하지 않은 장시간 실행 스레드를 가지게 됨으로써 메모리와 CPU 사이클을 낭비하게 됩니다.

논 블로킹 I/O

대부분의 최신 운영체제는 리소스에 접근하기 위해 블로킹 I/O 외에도 논 블로킹 I/O라고 불리는 다른 메커니즘을 지원합니다. 이 운영 모드에서는 데이터가 읽혀지거나 쓰여지기를 기다리지 않고 항상 즉시 반환됩니다. 이러한 종류의 논 블로킹 I/O를 다루는 가장 기본적인 패턴은 실제 데이터가 반환될 때까지 루프 내에서 리소스를 적극적으로 폴링(poll)하는 것이라고 하는데요 이것을 바쁜 대기(busy-waiting)이라고 합니다.

책에 예제 코드가 나오는데 이 코드를 보시면 바쁜 대기가 어떤 뜻인지 이해하실 수 있을겁니다.

resources = [socketA, socketB, fileA]
while (!resources.isEmpty()) {
  for (resource of resources) {
    // 읽기를 시도
    data = resource.read()
    if (data === NO_DATA_AVAILABLE) {
      // 이 순간에는 읽을 데이터가 없음
      continue
    }
    if (data === RESOURCE_CLOSED) {
      // 리소스가 닫히고 리스트에서 삭제
      resources.remove(i)
    } else {
      // 데이터를 받고 처리
      consumeData(data)
    }
  }
}

코드를 보시면 while의 조건으로 resources가 비어 있지 않을때까지 for 루프를 계속 돌면서 리소스를 폴링하고 있는걸 확인할 수 있습니다.

보다시피 간단한 기법으로 서로 다른 리소스를 같은 스레드 내에서 처리할 수 있지만 여전히 효율적이지 않습니다. 위 예제에서 루프는 사용할 수 없는 리소스를 반복하는 데에 소중한 CPU를 사용하고, 폴링 알고리즘은 엄청난 시간의 낭비를 초래합니다.

이벤트 디멀티플렉싱

바쁜 대기(Busy-waiting)는 논 블로킹 리소스 처리를 위한 이상적인 기법이 아닙니다. 다행히도, 대부분의 운영체제는 논 블로킹 리소스를 효율적인 방법으로 처리하기 위한 기본적인 메커니즘을 제공합니다.

이 메커니즘을 동기 이벤트 디멀티플렉서(Synchronous Event Demultiplexer) 또는 이벤트 통지 인터페이스(Event Notification Interface) 라고 합니다.

책에서도 설명하지만 이 용어가 익숙하지 않습니다. 멀티 플렉싱과 디멀티플렉싱이 뭔지 찾아봤습니다.

  1. 멀티 플렉싱(Multiplexing)

제가 제목에 링크한 위키와 책에서 멀티 플렉싱은 통신분야, 전기통신분야, 네트워크등 여러분야에서 사용되는 개념이라고 합니다.

요약해보자면 하나의 자원을 여러 개의 작업이나 데이터에 동시에 사용하도록 하는 기술입니다. 쉽게 말해, 여러 개의 채널을 하나의 채널로 합쳐서 사용하는 것을 의미합니다.

  1. 디멀티 플렉싱(Demultiplexing)

디멀티 플렉싱은 멀티 플렉싱의 반대 개념이겠죠? 원래의 구성 요소로 다시 분할되는 작업입니다.

다시 돌아와서 동기 이벤트 디멀티플렉서를 책에서는 아래와 같이 설명하며 예제 코드를 보여줍니다.

동기 이벤트 디멀티플렉서는 여러 리소스를 관찰하고 이 리소스들 중에 읽기 또는 쓰기 연간의 실행이 완료되었을때 새로운 이벤트를 반환합니다. 여기서 찾을 수 있는 이점은 동기 이벤트 디멀티플렉서가 처리하기 위한 새로운 이벤트가 있을 때까지 블로킹된다는 것입니다.


while (events = demultiplexer.watch(watchedList)) { // 2
  // 이벤트 루프
  for (event of events) { // 3
    // 블로킹하지 않으며 항상 데이터를 반환
    data = event.resource.read()
    if (data === RESOURCE_CLOSED) {
      // 리소스가 닫히고 관찰되는 리스트에서 삭제
      demultiplexer.unwatch(event.resource)
    } else {
      // 실제 데이터를 받으면 처리
      consumeData(data)
    }
  }
}

아까 코드와 다른점이 보이시나요? while의 조건이 바뀌고, 읽을 데이터를 루프를 돌며(continue를 사용하며) 계속해서 체크하지 않습니다.

책에서 이 코드에 대한 설명을 합니다.

  1. 각 리소스가 데이터 구조(List)에 추가됩니다. 각 리소스를 특정 연산과 연결합니다.

  2. 디멀티플렉서가 관찰될 리소스 그룹과 함께 설정됩니다. demultiplexer.watch()는 동기식으로 관찰되는 리소스들 중에서 읽을 준비가 된 리소스가 있을 때까지 블로킹됩니다. 준비된 리소스가 생기면, 이벤트 디멀티플렉서는 처리를 위한 새로운 이벤트 세트를 반환합니다.

  3. 이벤트 디멀티플렉서에서 반환된 각 이벤트가 처리됩니다. 이 시점에서 각 이벤트와 관련된 리소스는 읽을 준비 및 차단되지 않는 것이 보장됩니다. 모든 이벤트가 처리되고 나면, 이 흐름은 다시 이벤트 디멀티플렉서가 처리 가능한 이벤트를 반환하기 전까지 블로킹됩니다. 이를 이벤트 루프(event loop)라고 합니다.

여기서 흥미로운 점은 우리가 이 패턴을 이용하면 바쁜 대기(Busy-waiting) 기술을 이용하지 않고도 여러 I/O 작업을 단일 스레드 내에서 다룰 수 있다는 것입니다. 이로써 우리가 디멀티플렉싱에 대해 논하는 이유가 명확해졌습니다.

아래 그림에서 보여주듯이 오직 하나의 스레드만을 사용하는 것이 동시적 다중 I/O 사용 작업에 나쁜 영향을 미치지 않습니다.


다중 커넥션을 처리하기 위한 단일 스레드

그리고 책에서는 하나의 스레드를 가진 I/O 모델이 가지는 장점으로 동시성에 접근하는 방식에 이로운 영향을 미칠 수 있다고 하며 책 후반부에 설명한다고 합니다.(추후 포스팅을 해보겠습니다.)

Reactor 패턴

이제 드디어 Reactor 패턴입니다. 책 내용을 볼까요?

리액터 패턴의 이면에 있는 주된 아이디어는 각 I/O 작업에 연관된 핸들러를 갖는다는 것입니다. Node.js에서의 핸들러는 콜백 함수에 해당합니다. 이 핸들러는 이벤트가 생성되고 이벤트 루프에 의해 처리되는 즉시 호출되게 됩니다. 리액터 패턴의 구조는 다음과 같습니다.


Reactor 패턴
  1. 애플리케이션은 이벤트 디멀티플렉서에 요청을 전달함으로써 새로운 I/O 작업을 생성합니다. 또한, 애플리케이션은 작업이 완료되었을 때 호출될 핸들러를 명시합니다. 이벤트 디멀티플렉서에 새 요청을 전달하는 것은 논 블로킹 호출이며, 제어권은 애플리케이션으로 즉시 반환됩니다.

  2. 일련의 I/O 작업들이 완료되면 이벤트 디멀티플렉서는 대응하는 이벤트 작업들을 이벤트 큐에 집어 넣습니다.

  3. 이 시점에서 이벤트 루프이벤트 큐의 항목들을 순환합니다.

  4. 각 이벤트와 관련된 핸들러가 호출됩니다.

  5. 애플리케이션 코드의 일부인 핸들러의 실행이 완료되면 제어권을 이벤트 루프에 되돌려줍니다(5a). 핸들러 실행 중에 다른 비동기 작업을 요청할 수 있으며(5b), 이는 이벤트 디멀티플렉서에 새로운 항목을 추가하는 것입니다.(1)

앞의 블로킹 I/O, 논 블로킹 I/O, 이벤트 디멀티플렉서에 대한 내용을 전부 이해하셨고 그림을 보신다면 Reactor 패턴을 잘 이해하실 수 있을겁니다.

Reactor 패턴을 이해할 때 유의할 점

이벤트 처리 방식(이벤트 디멀티플렉서)이 동기냐 비동기냐의 차이가 존재하기 때문에

책에서 설명한 이벤트 디멀티플렉싱과 연결지어 Reactor패턴을 이해할 때 유의하면 좋을 것 같습니다.


구분 동기 이벤트 디멀티플렉싱 리액터 패턴
이벤트 처리 방식 순차적 처리 비동기 처리
콜백 함수 실행 방식 이벤트 루프에 의해 순서대로 실행 이벤트 발생 시 등록된 핸들러 실행
장점 구현이 비교적 간단 비동기 처리로 인한 높은 성능
단점 이벤트 처리 순서 변경 어려움 핸들러 관리 복잡성 증가

Libuv, Node.js의 I/O 엔진

마지막으로 이런 I/O 엔진에 대한 각 OS의 차이를 극복하기 위해 Node.js는 Libuv라는 라이브러리를 사용한다고 합니다.

각 운영체제는 Linux의 epoll, macOs의 kqueue, Window의 IOCP(I/O completion port) API와 같은 이벤트 디멀티플렉서를 위한 자체 인터페이스를 가지고 있습니다. 게다가 각 I/O 작업은 동일한 OS 내에서도 리소스 유형에 따라 매우 다르게 동작할 수 있습니다. 예를 들어 Unix에서 일반 파일 시스템은 논 블로킹 작업을 지원하지 않기 때문에 논 블로킹 동작을 위해서는 이벤트 루프 외부에 별도의 스레드를 사용해야 합니다. 서로 다른 운영체제 간의 불일치성은 이벤트 디멀티플렉서를 위해 보다 높은 레벨의 추상화를 필요로 하게 되었습니다.

이러한 이유로 Node.js 코어팀이 Node.js를 주요 운영체제에서 호환되게 해주며 서로 다른 리소스 유형의 논 블로킹 동작을 표준화하기 위해 libuv라고 불리는 C 라이브러리를 만들었습니다. Libuv는 Node.js의 하위 수준의 I/O 엔진을 대표하며 아마도 Node.js의 구성요소 중에서 가장 중요하다고 말할 수 있습니다.

Libuv는 기본 시스템 호출을 추상화하는 것 외에도 리액터 패턴을 구현하고 있으므로 이벤트 루프의 생성, 이벤트 큐의 관리, 비동기 I/O 작업의 실행 및 다른 유형의 작업을 큐에 담기 위한 API들을 제공합니다.

마치며

사실 저는 이 책의 초판을 2020년에 구입해서 Reactor 패턴에 대해 읽었지만 이해가 잘되지 않았습니다.

4년이란 시간이 흘러 다시 보니 사뭇, 그때와는 다른 느낌으로 다가오네요.

저와 같은 경험을 하시는 분들이 많으실 거라 생각합니다.

추천을 받아서 샀거나 선물 받은 책을 읽다가 “악! 도대체 무슨 말인지 하나도 모르겠어!” 하고 덮어뒀는데, 시간이 지나 다시 읽어보면 이해가 되는 경우 말이죠.

이런 경험이 내가 성장했고 발전했다는 것을 확인해 볼 수 있는 기회가 아닐까 합니다.

읽어주셔서 감사합니다.

참고문서 및 이미지 출처

Grow & Glow © 2026

Banner images by undraw.co