
Node.js는 싱글 스레드 아키텍처를 통해 서버 자원을 효율적으로 활용하며 빠른 속도를 자랑한다. 하지만 정말 단일 스레드만으로 이러한 성능을 낼 수 있을까?
Process vs. Thread
Node.js를 이해하려면 먼저 프로세스와 스레드가 무엇인지 이해해야 한다.
- 프로세스: 실행 중인 프로그램의 한 인스턴스. 각 프로세스는 독립적으로 실행되며 주요 자원은 다음과 같다:
- Execution Code; - 프로그램의 실제 코드
- Data Segment - 전역 변수 및 정적 변수 저장
- Heap - 동적 메모리 저장 공간
- Stack - 지역 변수, 함수 호출 정보
- Registers - CPU의 임시 데이터 저장소(e.g., 프로그램 포인터, 스택 포인터)
- 스레드: 프로세스 내에서 독립적으로 실행되는 작업 단위
- 여러 스레드가 하나의 프로세스 안에서 실행되며 실행 코드, 데이터, 힙을 공유
- 단, 스택과 레지스터는 각각 독립적

JavaScript는 스레드와 무관하다
JavaScript 자체는 스레드에 의존하지 않는다. 단순히 명령어 집합일 뿐이며, 이를 실행하는 플랫폼이 단일 스레드인지, 멀티 스레드인지에 따라 달라진다.
I/O 작업
I/O 작업은 일반적인 연산보다 느리다.
예:
- 디스크에 데이터 쓰기
- 디스크로부터 데이터 읽기
- 사용자의 입력(마우스 클릭과 같은)을 기다리기
- HTTP 요청 보내기
- 데이터베이스 쿼리 실행하기
I/O 작업은 느리다
I/O 작업은 다른 연산보다 느리다. 이는 물리적인 제약 때문이다.
- RAM 접근은 나노초 단위로 빠르지만, 디스크나 네트워크는 밀리초 단위이다.
- RAM은 GB/s 속도로 데이터를 처리하지만, 디스크와 네트워크는 MB/s ~ GB/s 수준에 머문다.
- 사용자 입력은 사람의 속도에 따라 제한되기 때문이다.
I/O가 문제를 일으키는 이유
입출력 작업이 실행되면 프로그램의 스레드가 이를 기다리며 블로킹(정지) 상태가 된다. 즉, I/O가 완료될 때까지 해당 스레드는 다른 작업을 수행하지 못 한다.

스레드를 많이 만들어 해결할 수 없을까?
그렇다면 왜 프로그램 안에서 더 많은 스레드를 생성하고 각 요청을 개별적으로 처리하지 않는걸까? 사실, 이건 꽤 좋은 아이디어처럼 보인다. 이제 각 클라이언트 요청이 자체적인 스레드를 가지게 되고, 서버는 여러 요청을 동시에 처리할 수 있게 된다.

프로그램은 각 스레드에 대해 추가적인 메모리와 CPU 자원을 할당해야 한다. 이건 합리적으로 보일 수 있다.
그러나 스레드가 I/O 작업을 수행할 때 심각한 문제가 발생한다. 스레드가 대부분의 시간을 유휴 상태로 보내며, 자원을 0%만 사용한 채 작업 완료를 기다리게 되는 것이다. 스레드 수가 많아질수록 자원 활용의 비효율성이 증가한다.
게다가 스레드를 관리하는 일은 매우 까다롭다. 경쟁 상태(race condition), 데드락(deadlock), 라이블락(livelock) 같은 잠재적인 문제가 발생할 수 있다. 또한, 운영 체제는 스레드 간 전환을 수행해야 하며, 이로 인해 오버헤드가 추가되어 멀티스레드의 효율성이 감소할 수 있다.
해결책
다행히도, 인류는 이미 이러한 작업을 효율적으로 처리할 수 있는 똑똑한 메커니즘을 발명했다.
바로 이벤트 디멀티플레서(Event Demultiplexer)이다. 이 기술은 멀티플렉싱(Multiplexing)이라는 과정을 포함하는데, 이는 여러 신호를 하나의 공유 자원을 통해 결합하여 처리하는 방법이다. 그 목적은 제한된 자원(우리의 경우 CPU와 RAM)을 효과적으로 공유하는 데 있다.
예를 들어, 통신 분야에서는 여러 전화 통화가 하나의 선을 통해 동시에 처리될 수 있다.

멀티플렉싱 다이어그램
이벤트 디멀티플렉서의 역할은 다음과 같은 단계로 나눌 수 있다.
- 이벤트 소스 식별: 각 소스는 이벤트를 생성할 수 있다.
- 이벤트 소스 등록: 각 소스에서 어떤 이벤트를 모니터링할 것인지 지정하여 등록한다.
- 이벤트 대기: 등록된 이벤트가 발생할 때까지 대기한다.
- 이벤트 알림 전송: 이벤트가 발생하면 이를 알린다.
중요!
이벤트 디멀티플렉서는 현실 세계에 존재하는 실제 컴포넌트나 장치가 아니다. 이는 수많은 동시 이벤트를 효율적으로 처리하는 방법을 설명하기 위한 이론적 모델이다.
이 복잡한 과정을 이해하기 위해 과거로 돌아가보자.
옛날 전화 교환기를 떠올려보면,
- 교환기는 이벤트 소스(전화기)를 식별하고 등록한 뒤 새로운 이벤트(전화)가 발생하기를 기다린다.
- 새로운 이벤트(전화)가 발생하면 알림(램프를 점등)을 전달한다.
- 교환원은 이를 확인한 뒤 대상 전화 번호를 확인하고, 통화를 적절한 목적지로 연결한다.

컴퓨터에서도 원리는 동일하다.
다만, 여기서 이벤트 소스 역할을 하는 것은 파일 디스크립터, 네트워크 소켓, 타이머, 사용자 입력 장치 등이다.
각 소스는 데이터를 읽을 준비가 됨, 데이터를 쓸 공간이 마련됨, 연결 요청 등과 같은 이벤트를 생성할 수 있다.
운영 체제별로 이벤트 디멀티플렉서 메커니즘이 이미 구현되어 있다: epoll(Linux), kqueue(macOS), event ports(Solaris), IOCP(Windows).
하지만 Node.js는 크로스플랫폼(cross-platform)이다. 이 전체 프로세스를 관리하면서도 다양한 플랫폼의 I/O를 지원하려면, Node.js는 추상화 계층(abstraction layer)을 사용해야 한다. 이 계층은 플랫폼 간 복잡성을 캡슐화하여, Node.js 상위 계층에서 일반화된 API를 사용할 수 있도록 한다.
Libuv

Libuv는 C언어로 작성된 크로스플랫폼 라이브러리로, Node.js를 위해 개발되었다. 이 라이브러리는 다양한 운영 체제에서 비동기 I/O를 일관되게 처리할 수 있는 인터페이스를 제공한다.
Libuv는 운영 체제의 이벤트 디멀티플렉서와 상호작용할 뿐만 아니라, 다음의 두 가지 중요한 구성 요소를 포함하고 있다.
- 이벤트 큐(Event Queue)
- 이벤트 루프(Event Loop)
이 두 구성 요소는 협력하여 동시 비동기 작업을 효율적으로 처리한다.
이벤트 큐(Event Queue)
이벤트 큐는 데이터 구조로, 이벤트 디멀티플렉서가 생성한 모든 이벤트를 저장한다. 큐에 저장된 이벤트는 이벤트 루프에 의해 순차적으로 처리된다. 큐가 비워질 때까지 계속해서 작업이 진행된다.
이벤트 루프(Event Loop)
이벤트 루프는 끊임없이 실행되는 프로세스이다. 이벤트 큐에서 메시지를 기다렸다가, 이를 적절한 핸들러로 전달하여 실행한다. 이벤트 루프는 Node.js 비동기 처리를 가능케하는 핵심 메커니즘이다.
문제 해결?
Node.js에서 I/O 작업이 호출될 때 어떤 일이 일어나는지 각 단계별로 살펴보면,
- Libuv가 운영 체제에 따른 적절한 이벤트 디멀티플렉서를 초기화한다.
- Node.js 인터프리터가 코드를 스캔하고 각 작업을 호출 스택(Call Stack)에 추가한다.
- 모든 작업은 순차적으로 실행되지만, I/O 작업의 경우 Node.js는 이를 비동기 방식으로 이벤트 디멀티플렉서로 전송한다. 이렇게 하면 I/O 작업이 실행 중이어도 스레드가 블로킹되지 않아 다른 작업을 동시에 처리할 수 있다.
- 이벤트 디멀티플렉서가 I/O 작업의 소스를 식별하고 이를 OS의 기능을 사용해 등록한다.
- 이벤트 디멀티플렉서가 소스를 지속적으로 모니터링한다.
- 이벤트가 발생하면 디멀티플렉서가 신호를 보내고 해당 이벤트와 콜백을 이벤트 큐에 추가한다.
- 이벤트 루프가 이벤트 큐를 지속적으로 확인하고 이벤트 콜백을 처리한다.
하나의 요청이 I/O 작업을 기다리는 동안, 다른 요청을 처리할 수 있다. Node.js는 특정 요청이 완료되기를 기다리지 않고, 모든 요청을 동시에 처리한다.

이 방식 덕분에 Node.js는 단일 스레드로도 효율적으로 실행될 수 있다. 운영 체제 개발자들이 블로킹 I/O 작업의 복잡성을 해결했기에 가능한 일이다.
문제가 완전히 해결된 것은 아니다
Libuv의 구조를 자세히 살펴보면 흥미로운 부분이 있다.

Libuv 구조
스레드 풀(Thread Pool)? 그렇다. 이제 우리는 깊숙이 들어와서 핵심 질문에 대한 답을 찾았다. 왜 Node.js는 완전히 단일 스레드가 아닌가?
Node.js는 비동기 코드를 단일 스레드로 실행할 수 있도록 강력한 도구와 OS 유틸리티를 활용한다. 하지만 이벤트 디멀티플렉서에는 한계가 있다.
운영 체제별로 이벤트 디멀티플렉서 구현 방식이 다르기 때문에, 일부 I/O 작업은 완전한 비동기 처리가 어렵다. 특히 파일 I/O 구현에서 이러한 문제가 두드러지며, 이는 일부 Node.js의 DNS 함수에도 영향을 미친다.
뿐만 아니라 비동기 처리로 완전히 해결되지 않는 작업들은 다음과 같다.
- DNS 작업:
dns.lookup같은 작업은 원격 서버를 쿼리해야 하므로 블로킹될 수 있다. - CPU 집중 작업: 암호화(cryptography) 등 연산 집약적인 작업
- 압축 작업: ZIP 파일 생성 등
이러한 경우, Node.js는 스레드 풀(Thread Pool)을 사용해 별도의 스레드에서 I/O 작업을 처리한다. 기본적으로 4개의 스레드를 사용한다. 따라서, Node.js의 전체 아키텍처 다이어그램은 다음과 같은 모습을 갖게 된다.

Node.js 자체는 단일 스레드 기반이지만, 내부적으로 사용하는 라이브러리(libuv와 그 안의 스레드 풀)는 그렇지 않다.
스레드 풀은 작업 큐(Tasks Queue)와 함께 블로킹 I/O 작업을 처리하는 데 사용된다.
기본적으로 스레드 풀은 4개의 스레드를 포함하며, 이 동작은 환경 변수를 통해 수정할 수 있다.
UV_THREADPOOL_SIZE=8 node my_script.js비동기로 처리할 수 없는 I/O 작업이 발생할 때 Node.js는 다음과 같은 방식으로 처리한다. 하지만 일반적인 비동기 작업과는 몇 가지 차이가 있다.
- 이벤트 디멀티플렉서는 I/O 작업의 소스를 확인한 뒤, 해당 작업을 작업 큐에 등록한다.
- 스레드 풀은 작업 큐를 지속적으로 확인하며, 새로운 작업이 들어오면 이를 감지한다.
- 작업 큐에 새로운 작업이 추가되면, 스레드 풀은 준비된 스레드 중 하나를 사용해 비동기적으로 작업을 처리한다.
- 작업이 완료되면 스레드 풀은 작업 결과를 이벤트 큐에 추가해 이벤트 루프가 처리할 수 있도록 한다.
I/O 작업은 실제로 완전한 비동기로 처리될 수 없다. 이는 물리적인 제약 때문인데, 데이터 전송 속도는 하드웨어 성능에 따라 정해질 뿐 더 빠르게 처리할 방법은 없다.
세상에 완벽한 것은 없기 때문에, 하드웨어가 발전하기 전까지 우리가 가진 자원을 활용해 최적화된 알고리즘으로 최대한 효율적으로 처리하는 수밖에 없다.
이 글은 Tkachenko Evgeny의 Node.js is Not Single-Threaded를 한글로 번역한 글입니다.