[Node.js 디자인패턴] Node.js는 어떻게 동작하는가

DaiVernon·2021년 6월 29일
1

Node.js 디자인패턴

목록 보기
1/2
post-thumbnail

Node.js의 배경이 되는 기본 철학은 이벤트기반프로그래밍(event-driven programming)입니다. 어떠한 이벤트가 발생하면 그 이벤트에 대해 어떻게 반응해야 할지 프로그래머가 충분히 이해하고 있어야 한다는 의미입니다. 이벤트는 대표적으로 I/O동작이 있습니다. 예를 들어 사용자가 뭔가를 클릭하게 되면, 이벤트로 인식되기 때문에 프로그래머는 클릭 이벤트를 처리하게 됩니다.

I/O 동작들은 컴퓨터의 동작들 중에 가장 느립니다. RAM에 접근하는데에는 보통 나노초가 걸리는 반면, 디스크와 네트워크에 접근하는데는 밀리초가 걸립니다. CPU 에서 I/O 작업이 많은 리소스를 사용하지는 않지만, 요청이 보내지고 작업이 완료될 때까지 지연이 생기게 됩니다. 거기다가 인간이라는 요소까지 고려해보면, 더욱 복잡해지게 됩니다. 따라서 실제로는 사람이하는 마우스 클릭이나 키보드 입력같은 I/O 작업의 속도와 빈도는 기술적인 부분으로만 작동되지 않으며, 디스크나 네트워크 작업보다 느린 경우도 있습니다.

블로킹 I/O


블로킹 I/O는 블로킹이란 이름에서 알 수 있듯이 I/O 작업을 요청하는 함수가 호출되면 작업이 끝날 때까지 스레드의 실행이 멈추게 됩니다.

// 소켓 작업이 실행되는 스레드가 블로킹 되는 코드
// data 가 사용가능해질 때까지 스레드를 블로킹
data = socket.read();
// data 사용 가능
print(data)

블로킹 I/O를 사용하게 되면 당연하게도 웹 서버가 하나의 스레드 내에서 여러 작업을 한번에 처리할 수 없게됩니다. 각 작업의 실행이 완료될 때까지 다른 작업의 실행은 차단되기 때문입니다. 이 문제를 해결하기 위한 가장 쉬운 방법은 여러 개의 쓰레드를 사용하거나 여러 개의 프로세스를 사용하는 것입니다.

여러 개의 쓰레드를 사용하게 되면 각각의 스레드에서 I/O 작업들을 처리해줄 수 있기때문에 블로킹된 스레드가 다른 작업의 실행을 방해하지 않게 됩니다.



위 사진은 새로운 데이터를 받기 위해 유휴상태의 있는 각 스레드의 처리시간을 보여주고 있습니다. 데이터베이스나 파일시스템과 상호작용할 때와 같이 모든 유형의 I/O가 요청의 처리를 차단할 수 있는 것을 고려해 볼때 블로킹 방식은 결과를 위해서 스레드가 상당히 많이 블로킹 된다는 것을 생각해볼 수 있습니다. 스레드는 리소스를 적게 사용하지 않습니다. 메모리를 소모하고 컨텍스트 전환을 유발하며, 많은 시간 동안 사용하지도 않는 스레드를 종료하지 않고 가지고 있게 되고, 메모리와 CPU 사이클을 낭비하게 될 것입니다.

논 블로킹 I/O


최신 운영체제에서는 논블로킹 I/O 라고 불리는 메커니즘 역시 지원합니다. 시스템 호출은 데이터를 읽거나 쓰는 작업의 완료를 기다리지 않고 항상 즉시 반환합니다. 호출 순간에 반환 가능한 결과가 없는 경우, 함수는 단순히 미리 정의된 상수를 반환하여 지금 사용 가능한 데이터가 없다는 것을 알립니다.

이런 논 블로킹 I/O를 다루는 가장 기본적인 패턴은 실제 데이터가 반환 될 때까지 루프 내에서 리소스를 계속해서 폴링(poll)하는 것입니다. 이것을 busy-waiting 이라고 합니다.

// 논블로킹 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(resource)
    } else {
      // 데이터를 받고 처리
      consumeData(data)
    }
  }
}

간단하게 서로 다른 리소스를 하나의 스레드에서 처리할 수 있지만 아직도 효율적이지 못합니다. 이 예제에서는 아직도 사용할 수 없는 리소스를 반복하는데 계속해서 CPU 사이클을 낭비합니다. 폴링 알고리즘은 CPU 시간을 엄청나게 낭비합니다.

이벤트 디멀티플렉싱


busy-waiting 은 논 블로킹 리소스를 처리하기 위해 가장 좋은 방법은 아닙니다. 대부분의 운영체제는 논 블로킹 리소스를 더 효율적으로 처리할수 있는 동기 이벤트 디멀티플렉서 또는 이벤트 통지 인터페이스로 불리는 기본 메커니즘을 제공하고 있습니다.

멀티플렉싱이란 전기통신 용어로서 여러 신호들을 하나로 합성하여 제한된 수용범위 내에서 매개체를 통해 쉽게 전달하는 방법을 나타냅니다.

반대의 뜻을 지닌 디멀티플렉싱이란 신호가 원래 구성요소로 다시 분할되는 작업으로 두 용어는 비디오 관련 분야를 포함한 여러 분야에서 서로 다른 요소들을 합성과 분할하는 작업을 설명하기 위해 사용되었습니다.

동기 이벤트 디멀티플렉서는 여러 리소스를 지속적으로 관찰하고 리소스 읽기 또는 쓰기 작업이 완료 되었을 때 새로운 이벤트를 반환해줍니다. 동기 이벤트 디멀티플렉서의 이점은 새로운 이벤트가 있을 때까지 리소스를 낭비하지 않고 블로킹된다는 것입니다.

// 이벤트 디멀티플렉서 (이벤트 루프가 타고있어요.)
// 각 리소스가 데이터구조(List)에 추가됩니다. 각 리소스를 특정 연산과 연결합니다.
watchedList.add(socketA, FOR_READ)
watchedList.add(fileB, FOR_READ)

// 디멀티플렉서가 관찰될 리소스 그룹과 함께 설정됩니다. 
// demutiplexer.watch()는 동기식으로 관찰되는 리소스들 중에서 읽을 준비가 된 리소스가 있을 때까지 블로킹됩니다.
// 준비된 리소스가 생기면, 이벤트 디멀티플렉서는 처리를 위한 새로운 이벤트 세트를 반환합니다.
while (events = demultiplexer.watch(watchList)) {
    // **이벤트 루프(event loop)**
    // 이벤트 디멀티플렉서에서 반환된 각 이벤트가 처리됩니다. 이 시점에서 각 이벤트와 관련된 리소스는 읽을 준비 및 차단되지 않는 것이 보장됩니다.
    // 모든 이벤트가 처리되고 나면, 이 흐름은 다시 이벤트 디멀티플렉서가 처리 가능한 이벤트를 반환하기 전까지 블로킹됩니다.
    // 이를 **이벤트 루프(event loop)**라고 합니다.
    for (event of events) {
        // 블로킹하지 않으며 항상 데이터를 반환된 각 이벤트가 처리됩니다.
        data = event.resource.read()
        if (data === RESOURCE_CLOSED) {
            // 리소스가 닫히고 관찰되는 리스트에서 삭제
            demultiplexer.unwatch(event.resource)
        } else {
            // 실제 데이터를 받으면 처리
            cosumeData(data)
        }
    }
}

이벤트 디멀티플렉싱 패턴을 사용하면 busy-waiting 을 사용하지 않고도 다수의 I/O 작업을 하나의 쓰레드 내에서 해결할 수 있습니다.




하나의 쓰레드 만을 사용하는 것이 동시적으로 이뤄지는 I/O 작업에 영향을 주지는 않습니다. I/O작업들은 각각의 쓰레드를 가지는 대신 하나의 쓰레드에 시간에 따라 분산됩니다. 실제로 싱글스레드는 여러작업을 동시에 처리하는데 여러가지 이점을 가지고 있습니다. 경쟁상태(Race Condition)와 멀티 스레드의 동기화 문제가 없다는 것은 여러 작업을 더 간단하게 동시에 처리할 수 있게 해줍니다

리액터(Reactor) 패턴


동기 이벤트 디멀티플렉서에 특화된 리액터(Reactor) 패턴이란게 존재합니다. 리액터 패턴의 주된 아이디어는 각 I/O 작업이 연관된 핸들러를 가지게 된다는 것입니다. Node.js에서 핸들러는 콜백함수에 해당합니다.

// 핸들러(콜백 함수) 실행
function handler(event) {
    // 이벤트 처리
}

// 이벤트가 발생하면 핸들러 호출
eventEmitter.on('event', handler);

핸들러(콜백함수)는 이벤트가 생성된 후 이벤트 루프에 의해 처리되는 즉시 호출되게 됩니다.



  1. 애플리케이션은 이벤트 디멀티플렉서에 요청을 전달함으로써 새로운 I/O 작업을 생성합니다. 또한 애플리케이션은 작업이 완료되었을 때, 호출될 핸들러를 명시합니다. 이벤트 디멀티플렉서에게 요청을 전달하는 것은 논 블로킹 호출이며, 제어권은 애플리케이션에게 즉시 반환되게 됩니다.
  2. 일련의 I/O 작업들이 완료되면 이벤트 디멀티플렉서는 대응하는 이벤트 작업들을 이벤트 큐에 집어 넣습니다.
  3. 이 시점에서 이벤트 루프가 이벤트 큐의 항목들을 순환합니다.
  4. 각 이벤트에 관련된 핸들러가 호출됩니다.
  5. 애플리케이션 코드의 일부인 핸들러의 실행이 완료되면 제어권을 이벤트 루프에 되돌려줍니다(5a). 핸들러 실행중에 다른 비동기 작업을 요청할 수 있으며(5b), 이는 이벤트 디멀티플렉서에 새로운 항목을 추가하는 것입니다.
  6. 이벤트큐의 모든 항목이 처리되면 이벤트 루프이벤트 디멀티플렉서에서 블로킹되며 처리 사능한 새 이벤트가 있을 경우 이 과정이 다시 트리거가 됩니다.

이제 비동기적 동작이 명확해 졌습니다. 애플리케이션은 특정 시점에 리소스로 (블로킹 없이) 접근하고 싶다는 요청과 동시에 작업이 완료 되면 호출될 핸들러를 제공합니다.

Node.js 애플리케이션은 이벤트 디멀티플렉서에 더 이상 보류중인 작업이 없고 이벤트 큐에 더이상 처리 중인 작업이 없을 경우 종료됩니다.

이제 Node.js의 핵심인 리액터 패턴에 대해 정의할 수 있습니다.

Reactor 패턴_
Reactor 패턴은 일련의 관찰 대상 리소스에서 새 이벤트를 사용할 수 있을 때까지 블로킹하여 I/O를 처리하고, 각 이벤트를 관련된 핸들러(Node.js에선 콜백함수가 이 역할을 함)에 전달함으로써 반응합니다.

Libuv, Node.js의 I/O 엔진


각 운영체제는 이벤트 디멀티플렉서를 위한 자체 인터페이스를 가지고 있습니다. 추가적으로 각 I/O 작업은 같은 OS 내에서도 리소스 유형에 따라 다르게 동작하는 경우도 있습니다. 예를 들어 유닉스에서는 일반 파일 시스템은 논블로킹 작업을 지원하지 않아 논 블로킹을 위해서는 이벤트 루프 외부에 별도의 스레드를 사용해야 합니다.

운영체제 간의 불일치성은 이벤트 디멀티플렉서를 위해 보다 높은 레벨의 추상화를 필요로 하게 되었습니다. 이러한 이유로 Node.js를 서로 다른 주요 운영체제들에서 호환되게 하고 서로 다른 유형의 리소스 유형의 논 블로킹 동작들을 표준화하기 위해 Libuv라고 불리는 C 라이브러리를 만들었습니다. Libuv는 Node.js의 low수준에 있는 I/O 엔진을 대표하며 Node.js의 구성요소중 가장 중요하다고 표현할 수 있습니다.

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

Nods.js의 구성


리액터 패턴과 libuv는 Node.js의 기본 구성 요소지만 전체 플랫폼의 구축을 위해서는 3개의 구성이 더 필요합니다.

  • libuv와 다른 저수준 기능들을 랩핑하고 표출시키기 위한 바인딩 세트
  • V8, 크롬 브라우저를 위해 구글이 개발한 JavaScript 으로 Node.js가 매우 빠르고 효율적인 이유 중 하나이기도 합니다. V8은 혁신적인 설계와 속도 그리고 효율적인 메모리 관리로 높은 평가를 받았습니다. (지금의 크롬은 메모리를 잡아먹는 괴물로 평가 받지만...)
  • 고수준 Node.js API를 구현하고 있는 코어 JavaScript 라이브러리
profile
클린 코드, 클린 아키텍처

1개의 댓글

comment-user-thumbnail
2021년 9월 2일

정리해주셔서 감사합니다 :pray:

답글 달기