본문 바로가기

[자바스크립트] Node.js 논블로킹 탐구하기

들어가며

여행을 떠나보면, 언젠가 왜 이 여행을 하려 했을까 하는 생각이 들 때가 있었습니다. 공부를 할 때도 마찬가지였습니다. Node.js를 활용해서 개발을 하고 있는데, 왜 Node.js를 활용하는지 문득 궁금했습니다. Node.js의 특징, 장단점, 그리고 다른 언어와의 차이를 알아보며 Node.js에 대해 이해해야겠다고 생각했습니다. 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 


 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

Node.js는 무엇인가?

블로킹과 논블로킹, 동기와 비동기에 대해 정리한 글에서, Node.js는 이벤트 기반, 논 블로킹 I/O 모델을 사용해서 가볍고 효율적이라고 했는데, 그러면 논 블로킹 I/O 모델을 사용한 게 왜 가볍고 효율적인 것인지를 알아보고 싶었습니다. 그럼 지금부터 왜 논 블로킹 IO가 Node.js의 특장점인지, CPU 코어의 활용을 극대화할 수 있는지를 이해하려면 먼저 IO의 특성과 블로킹, 논 블로킹하는 코드들에 대한 이해가 필요합니다.

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 


 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

I/O in general

I/O는 Input/Output의 줄임말입니다. 하지만 컴퓨터과학에서 그 의미는 범위에 따라 아예 다른 작업을 뜻할 수도 있습니다. 어떤 데이터가 CPU에서 처리되기 위해서는 자신이 있는 위치부터 메모리 계층의 최상위에 있는 레지스터까지 전달되어야 합니다. 몇몇 데이터 소스는 실행을 심각하게 늦추지 않는 선에서 레지스터까지의 데이터 전달을 보장합니다. 하지만 디스크와 네트워크를 통한 데이터 교환을 할 때, 대부분의 데이터 소스는 데이터를 요청했을 때 일정 시간 안에 데이터를 받을 수 있을 거라는 보장이 없기 때문에, 프로그램은 어떤 방식으로든 데이터를 받기까지 실행이 심각하게 늦춰진다고 느껴질 만큼 대기하는 시간이 생깁니다. 어떤 I/O가 블로킹하는지, 논 블로킹하는지는 주로 디스크 혹은 네트워크로부터 데이터를 가져오려고 할 때 프로그래밍 언어 혹은 런타임이 데이터가 도착하기를 대기하는지에 따라 구분됩니다. 그러면, 논 블로킹 I/O를 보기 전, 블로킹 I/O는 어떤 특징이 있는지 살펴보겠습니다.

 

 

 

 

 

 

 

 

 

 

 

 

 

 


 

 

 

 

 

 

 

 

 

Blocking I/O

Block은 막는다는 뜻으로 blocking I/O는 직역하여 I/O 작업 시 프로그램의 실행을 막는다는 뜻으로 해석될 수 있습니다. 전통적인 블로킹 I/O 프로그래밍에서는, I/O 요청에 해당하는 함수의 작업이 완료될 때까지 스레드의 실행이 차단됩니다. 실제로 blocking 하는 프로그래밍 언어들이나 런타임들은 디스크나 네트워크를 통한 데이터가 레지스터까지 도달하는 동안 그 줄에서 명령을 더 실행하지 않고 대기합니다. 실제로 어떤 명령을 수행하고 있는 것이 아니라, 실행 의미가 없는 idle 명령을 계속 반복하고 있는 것입니다. 자동차로 치면 공회전하고 있는 것입니다. 결과적으로 I/O와 상관없는 직업들도 I/O 작업이 병목이 되어 CPU 사이클을 낭비하게 됩니다. 

 

따라서 블로킹 IO를 사용하여 구현된 웹서버에서는, 한 개의 스레드에서 여러 개의 연결을 처리할 수 없으므로 성능이 상당히 떨어지게 됩니다. 이러한 이유로 동시성을 처리하기 위해 각각의 동시 연결에 새로운 스레드를 할당하거나 스레드 풀을 재사용하는 등의 방법을 쒔습니다. 하지만 이러한 방법은 상당한 리소스(콘텍스트 스위칭, 메모리 할당 등)를 사용하게 됩니다. 이와 반대로 논 블로킹 I/O는 I/O 작업을 막지 않습니다. 조금 더 자세하게 알아보겠습니다.

 

 

 

 

 

 

 

 

 

 

 

 


 

 

 

 

 

 

 

 

 

 

 

 

 

 

Non-Blocking I/O는 무엇일까?

논 블로킹은 블로킹과 반대로 I/O 작업을 막지 않습니다. 이 모드에서는, 데이터가 읽히거나 쓰일 때까지 기다리지 않고 즉시 시스템 호출을 반환합니다. 이 시점에 결과를 리턴할 준비가 안되어 있을 경우, 미리 정해진 상수를 반환하여 그 순간에 아직 반환할 수 있는 데이터가 없음을 나타냅니다. 이러한 논블로킹 I/O 처리를 위한 기본적인 패턴은, 실제 데이터가 반환될 때까지 루프 내에서 리소스를 계속해서 폴링 하는 것입니다. 이를 busy-waiting이라고 합니다. 이런 논 블로킹 I/O을 활용하면, 의미 있는 I/O 결과가 언제 반환될지 모르기에 다음과 같은 과정을 거칩니다.

 

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)
    }
  }
}

 

스레드는 리소스 Queue를 바라보는데, Queue가 비워져 있지 않다면, 리소스가 존재한다는 뜻이므로 루프 문을 돌립니다. 만약 반환된 데이터가 없다면 다시 루프 문으로 이동하고, 데이터 리소스가 닫히면 리소스 목록에서 제거합니다. 만약 위 작업을 반복하다가 데이터가 반환된다면 완료처리를 합니다. 위 예제의 루프는 대부분의 시간을 사용할 수 없는 리소스를 반복하기 위해 귀중한 CPU 자원만 소비합니다.

 

그래서, 최신 OS는 Busy-Waiting 기술이 아닌, "이벤트 디멀티플렉싱" 메커니즘을 제공합니다. 이 메커니즘을 '동기 이벤트 디멀티플렉서', '이벤트 통지 인터페이스'라고 합니다. 그럼 이벤트 디멀티플렉싱에 대해 알아보겠습니다.

 

 

 

 

 

 

 

 

 

 

 

 

 


 

 

 

 

 

 

 

 

 

 

 

 

 

이벤트 디멀티플렉싱은 무엇인가?

이 메커니즘은 감시된 일련의 리소스들로부터 들어오는 I/O 이벤트를 수집하여 큐에 넣고 처리할 수 있는 새 이벤트가 있을 때까지 차단합니다. 아래는 동기 이벤트 디멀티플렉서를 사용하는 알고리즘의 코드입니다.

 

socketA, pipeB;
watchedList.add(socketA, FOR_READ); //[1]
while(events = demultiplexer.watch(watchedList)) { //[2]
    // Event Loop
    foreach(event in events) { //[3]
        // read 는 블록되지 않으며 비어 있을지언정, 항상 데이터를 반환함.
        data = event.resource.read();
        if(data === RESOURCE_CLOSED) demultiplexer.unwatch(event.resource);
        else resolve(data);
    }
}

 

[2]에서 디멀티플렉서에 감시할 것들을 추가합니다. 이 호출은 동기식이며, 감시 대상 중 하나라도 데이터를 리턴하기 전까지 차단됩니다. 이때 이벤트 디멀티플렉서는 호출로부터 복귀하여 새로운 이벤트들을 처리할 수 있게 됩니다(비동기). [3]에서 디멀티플렉서가 반환한 이벤트가 처리됩니다. 여기를 Event Loop라고 부르며, 이곳에 도달했다는 것은 역으로 읽기 작업이 완료되었다는 것이므로 차단되지 않는 상황이라는 것이 보증됩니다. 모든 이벤트가 처리되면 다시 차단되고 디멀티플렉서를 기다리게 됩니다.

 

조금 더 구체적으로 설명하자면, 바로 값을 가져올 수 없는 작업형 함수를 만날 경우(I/O) 일단 약속된 상수값을 리턴 시키고 해당 함수를 디멀티플렉서에 추가합니다. 이때 완료 후 호출될 핸들러(callback)와 이벤트가 디멀티플렉서에 들어가게 됩니다. 이벤트가 완료될 경우 디멀티플렉서가 이벤트를 반환합니다. 반환된 이벤트는 이벤트 큐에 push 되고, 이벤트 루프는 이벤트 큐를 순회하며 각각의 이벤트들에 대한 핸들러를 실행합니다.

 

 

출처 : 이벤트 디멀티플렉싱과 Reactor 패턴 그리고 Node.js의 구조

 

 

이벤트 디멀티플렉싱 패턴을 사용하면 Busy-waiting을 사용하지 않고도 단일 스레드 내에서 여러 I/O 작업을 처리할 수 있습니다. 기존의 동기식 블로킹 I/O가 스레드를 만들어 다중 I/O 작업을 처리했다면 이는 하나의 스레드만 사용하여 시간에 따라 작업을 분리해 처리합니다. 따라서 위의 그림처럼 유휴시간(파란색)을 최소로 줄일 수 있습니다. 또한 프로세스 간의 경쟁 혹은 여러 스레드들의 동기화 걱정이 없는 훨씬 간단한 동시성 전략을 사용할 수 있습니다.

 

그럼, 이벤트 디멀티플렉싱을 활용하는 패턴인 리엑터 패턴에 대해 알아보겠습니다.

 

 

 

 

 

 

 

 

 

 


 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

반응자 패턴 (Reactor Pattern)

리엑터 패턴의 핵심은 각 I/O 작업과 관련된 핸들러(callback)를 갖는 것입니다. 이 핸들러는 이벤트 루프에 의해 처리되는 즉시 호출됩니다. 리엑터 패턴은 위에서 설명한 이벤트 디멀티플렉싱을 활용하는 패턴입니다. 작업 처리 순서는 아래와 같습니다.

 

 

 

  1. 1. 애플리케이션이 요청을 이벤트 디멀티플렉서에 제출하여, 새로운 IO 작업을 생성한다. 애플리케이션은 또한 작업이 완료되면 호출할 핸들러(콜백)를 지정한다. 새로운 요청을 이벤트 디멀티플렉서에 제출하는 것은 논블로킹 요청으로, 즉시 애플리케이션에 통제권을 반환한다.
  2. 2. IO 작업 세트가 완료되면, 이벤트 디멀티플렉서가 해당 이벤트 세트를 이벤트 큐로 푸시 한다.
  3. 3. 이 때, 이벤트 루프는 이벤트 큐 항목에서 반복된다.
  4. 4. 각 이벤트에 연결된 핸들러가 호출된다.
  5. 5a. 애플리케이션 코드의 일부인 핸들러(콜백) 실행이 완료되면, 이벤트 루프를 다시 제어한다. 5b. 콜백이 실행되는 동안, 새로운 비동기 작업을 요청할 수 있으며, 이는 이벤트 디멀티플렉서에 새로운 항목 추가를 야기한다.
  6. 6. 이벤트 큐의 모든항목이 처리되면, 이벤트 루프는 이벤트 디멀티플렉서를 다시 차단하며, 이 경우 새로운 이벤트가 가능해질 때 다시 트리거한다.

 

즉, 리엑터 패턴은 관찰 대상 리소스가 반응(콜백)하면 해당 이벤트의 핸들러를 추적해 실행하는 디자인 패턴입니다. 노드의 전신이라 할 수 있는 패턴입니다. 

 

출처 : 이벤트 디멀티플렉싱과 Reactor 패턴 그리고 Node.js의 구조

 

 

 Node.js는 Reactor 패턴을 사용하며 JS Core API, V8 및 libuv에 의존하는 구조라고 할 수 있습니다.

 

  • Binding: libuv 외 로우레벨 기능들을 JS에 랩핑하고 사용 가능하게 만들어 줌.
  • V8: 구글에서 만든 크롬용 JS 엔진. V8 덕에 Node.js 가 매우 빠르고 효율적으로 작동한다.
  • JS Core API: Node.js API 를 구현하는 자바스크립트 코어

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 


 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

마치며

간단하게 노드의 핵심 동작 패턴과 구조를 알아볼 수 있었습니다. 하지만 이벤트 루프가 어떻게 구현되어 있는지, 어떻게 동작하는지 깊이 있게 보지 못했습니다. 그리고 V8, libuv, Binding 등에 대해서도 제대로 살펴보지 못했습니다. 다음에는 이 부분에 대해 살펴보려 합니다. 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 


 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

출처

 

이벤트 디멀티플렉싱과 Reactor 패턴 그리고 Node.js의 구조 - 1ilsang

Developer who will be a Legend

1ilsang.dev

 

블록킹과 논블록킹 살펴보기 | Node.js

Node.js® is a JavaScript runtime built on Chrome's V8 JavaScript engine.

nodejs.org

 

NodeJS의 Non-Blocking IO와 멀티코어 프로세싱

A dev blog of appleseed

blog.appleseed.dev