avatar
Published on

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

Author
  • avatar
    Name
    yceffort

I/O는 느리다

IO (Input / Output)은 컴퓨터의 기본 작업 중에 제일 느리다. RAM에 비해서 당연히 느리고, 디스크의 처리속도, CPU 등등과 비교해서도 제일 느린 것이 IO다. 과거 플로피 디스크를 생각해보면, 디스크에서 긁히는 (읽는) 소리가 나서야 프로그램이 동작했다. 따라서 I/O는 웹 서비스의 성능에 가장 많은 영향을 미치는, 모니터링 해야 하는 요소 중에 하나다.

블로킹 I/O

전통적인 블로킹 IO 프로그래밍 에서는, IO 요청에 해당하는 함수의 작업이 완료 될 때까지 스레드의 실행이 차단된다.

// 데이터가 사용가능해질 때까지 블로킹됨
data = socket.read()
// 데이터가 사용가능해져야 비로소 print가 된다.
print(data)

따라서 블로킹 IO를 사용하여 구현된 웹서버에서는, 한개의 스레드에서 여러개의 연결을 처리할 수 없다. 소켓의 IO 동작이 다른 연결의 처리를 차단해 버리기 때문이다. 이 문제를 해결하기 위한 전통적인 접근 방식은, 각각의 연결을 동시에 처리하기 위해 별도의 스레드 (프로세스)를 사용하는 것이다.

데이터 베이스 또는 파일 시스템과 상호작용하기 위하여 IO를 차단해야 한다면, IO 작업 결과를 대기 하기 위해 얼마나 많은 스레드가 차단되어야 하는지는 쉽게 상상할 수 있다. 당연히도, 스레드는 시스템 리소스 측면에서 저렴하지 않으므로, 각 연결에 대해 장기간 실행되는 스레드를 가지고 대부분의 시간 동안 사용하지 않는 것은 소중한 메모리와 CPU 사이클을 낭비하게 되는 결과를 초래한다.

논블로킹 I/O

대부분의 최신 OS는 블로킹 IO 외에도 논블로킹 IO라고 하는 또다른 메커니즘을 지원한다. 이 모드에서는, 데이터가 읽히거나 쓰일 때 까지 기다리지 않고 즉시 시스템 호출을 반환한다. 이 시점에 결과를 리턴할 준비가 안되어 있을 경우, 미리 정해진 상수를 반환하여 그 순간에 아직 반환할 수 있는 데이터가 없음을 나타낸다.

이러한 논블로킹 IO 처리를 위한 기본적인 패턴은, 실제 데이터가 반환될 때까지 루프 내에서 리소스를 계속해서 폴링하는 것이다. 이를 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)
    }
  }
}

보시다시피 이는 간단하게 동일 스레드에서 서로 다른 자원을 처리할 수 있지만, 여전히 효율적이 지 않다. 위 예제의 루프는 대부분의 시간을 사용할 수 없는 리소스를 반복하기 위해 귀중한 CPU 자원만 소비한다. 폴링 알고리즘은, CPU 를 엄청나게 낭비한다.

이벤트 디멀티플렉싱

멀티플렉싱이란, 하나의 통신 채널을 통해서 둘 이상의 데이터를 전송하는데 사용하는 기술로, 여러개의 신호를 하나로 결합해 용량이 제한된 매체를 통해 전송하는 방식이다.

디멀티플렉싱은, 신호가 원래 구성요소로 다시 분할되는, 멀티플렉싱과 반대되는 동작이다. 여러 리소스를 감시하고, 이중 하나의 실행된 읽기 또는 쓰기 작업이 완료되면 새로운 이벤트를 반환한다. 여기서 장점은 동기식으로 작동하기 때문에 새로운 이벤트가 있을 때까지 차단한다는 것이다. 즉 이 매커니즘은, 일련의 리소스들로부터 오는 IO 이벤트를 모아서 큐에 넣고 처리할 수 있는 새 이벤트가 있을 때까지 차단한다.

// 리소스가 하나씩 추가된다
watchedList.add(socketA, FOR_READ)
watchedList.add(fileB, FOR_READ)
// demultiplexer.watch는 동기식으로 작동되며
// 감시 대상 중 하나라도 데이터를 리턴하기 전까지 차단된다.
// 자원이 읽어드릴 준비가 되면, 호출로 부터 복귀해서 새로운 이벤트를 처리할 수 있게 된다. (비동기)
while ((events = demultiplexer.watch(watchedList))) {
  // 디멀티플렉서가 감시할 자원의 그룹을 설정해둔다.
  // 이벤트 루프
  for (event of events) {
    // (3)
    // 디멀티플렉서가 반환한 이벤트가 처리된다.
    // 이곳에 도달했다는 것은, 읽기 작업이 완료되었다는 것이므로 차단되지 않고 데이터를 반환한다.
    data = event.resource.read()
    if (data === RESOURCE_CLOSED) {
      // 리소스가 닫히면, 더 이상 감시하지 않는다.
      demultiplexer.unwatch(event.resource)
    } else {
      // 데이터를 받으면, 그냥 처리한다.
      consumeData(data)
    }
  }
}

정리하자면, 바로 값을 가져올 수 없는 형태의 함수를 만날 경우, 일단 약속된 상수값을 리턴하고, 해당 함수를 디멀티플렉서에 추가한다. 추가된 내용에는 완료 후 호출된 콜백과 이벤트가 들어 있다. 이벤트가 완료되면 디멀티플렉서가 이벤트를 반환한다. 반환된 이벤트는 이벤트 큐에 푸시되고, 이벤트 루프는 이 큐를 순환하며 각각 이벤트에 대한 핸들러를 실행한다.

이를 활용하면, 하나의 스레드로 여러 IO 작업을 동시에 실행할 수 있다. 이 외에도 하나의 스레드에서 처리한다는 것은, 프로그래머들이 동시성에 접근하는 방식에도 이점을 얻을 수 있다.

반응자 패턴 (Reactor Pattern)

반응자 패턴의 핵심 개념은 각 IO 동작과 관련된 핸들러를 갖는 것이다. Nodejs에서 핸들러는 콜백 함수로 표현된다. 핸들러는 이벤트 루프에 의해 이벤트가 생성되고, 처리되는 즉시 호출된다.

https://miro.medium.com/max/1200/1*X0m82lpBhRONFvRGCRu84w.jpeg

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

비동기 동작이 이제 보다 분명해졌다. 애플리케이션은 특정 시점에 (블로킹 없이) 자원에 접근하는 것에 대한 의사를 표시하고 이에 따른 핸들러를 제공하며, 다음 연산이 완료되면 다른 시점에 호출된다.

우리는 Node.js의 핵심 패턴을 다음과 같이 정의할 수 있다.

반응자 패턴: 관찰 대상 리소스가 반응하면 (콜백) 해당 이벤트 핸들러를 추적해 실행하는 디자인 패턴.

Libuv, Node.js의 IO 엔진

각 운영체제는 이벤트 디멀티플렉서에 대한 자체 인터페이스가 있다. (리눅스의 epoll, macos의 kqueue, 윈도우의 IOCP API) 이러한 불일치로 인해 node.js 팀은 모든 주요 운영 체제와 호환되고 서로 다른 유형의 논블로킹 동작을 정상적으로 처리하기 위한 목적으로 libuv라고 하는 네이티브 라이브러리를 만들었다. libuv는 기본적인 시스템 호출을 추상화 하는 것 외에도, 반응자 패턴을 구현하여 이벤트 루프 생성, 이벤트 큐 곤리, 비동기 IO 작업 실행 및, 기타 유형의 작업 큐를 위한 API를 제공한다.

Node.js

반응자패턴과 libuv는 Node.js를 구성하는 핵심이며, 여기에 추가적으로 3개의 구성요소를 더하면 node.js가 완성된다.

  • Binding: libuv 과 기타 저수준 기능을 자바스크립트에 래핑하고 사용가능하게 만들어 준다.
  • V8: 구글에서 만든 크롬용 자바스크립트 엔진. Node.js를 빠르고 효율적으로 만들어 준다. 혁신적인 디자인과 속도, 효율적인 메모리 관리로 호평받고 있다.
  • Javascript Core API: Node.js API를 구현하는 코어

https://t1.daumcdn.net/cfile/tistory/992DF44A5AD96F4E0B