Friday, October 24, 2025

Node.js 비동기 처리의 심장, 이벤트 루프의 동작 원리

Node.js는 현대 백엔드 개발에서 가장 인기 있는 기술 중 하나로 자리 잡았습니다. 뛰어난 성능과 확장성 덕분에 수많은 글로벌 기업들이 핵심 서비스에 Node.js를 채택하고 있습니다. 그런데 여기서 한 가지 근본적인 의문이 생깁니다. 자바스크립트는 본질적으로 '싱글 스레드(Single Thread)' 기반 언어입니다. 어떻게 단 하나의 실행 흐름(Thread)만으로 초당 수천, 수만 개의 동시 요청을 효율적으로 처리할 수 있을까요? 전통적인 멀티 스레드 서버 모델(예: Java의 Tomcat, Apache)이 요청마다 스레드를 할당하는 방식과 비교하면 이는 더욱 놀라운 일입니다.

이 마법과 같은 동시성 처리의 비밀은 바로 이벤트 루프(Event Loop)에 있습니다. 이벤트 루프는 Node.js 아키텍처의 심장과도 같은 존재로, '논블로킹 I/O(Non-blocking I/O)' 모델을 통해 싱글 스레드의 한계를 극복하고 높은 처리량을 달성하게 해주는 핵심 메커니즘입니다. 많은 개발자들이 Node.js를 사용하면서도 이벤트 루프의 내부 동작 방식을 정확히 이해하지 못하는 경우가 많습니다. 이는 곧 예측 불가능한 버그나 성능 저하의 원인이 되기도 합니다.

본 글에서는 Node.js의 이벤트 루프가 정확히 무엇이며, 어떤 구성 요소들과 상호작용하여 비동기 작업을 처리하는지 그 원리를 깊이 있게 파헤쳐 보고자 합니다. 콜 스택(Call Stack)과 큐(Queue)의 개념부터 시작하여, 실제 코드가 어떤 과정을 거쳐 실행되는지 단계별로 추적하고, 더 나아가 이벤트 루프를 최적으로 활용하기 위한 실용적인 전략까지 살펴보겠습니다. 이 글을 끝까지 읽고 나면, 여러분은 Node.js의 비동기 동작 방식에 대한 명확한 그림을 그리고, 더 견고하고 효율적인 애플리케이션을 구축할 수 있는 자신감을 얻게 될 것입니다.

1. 자바스크립트 엔진과 런타임: V8을 넘어서

이벤트 루프를 이해하기에 앞서, Node.js의 근간을 이루는 자바스크립트 엔진과 런타임의 관계를 명확히 해야 합니다. 많은 사람들이 Node.js를 '서버용 자바스크립트'라고만 생각하지만, 그 내부에는 정교한 아키텍처가 숨어 있습니다.

우리가 작성하는 자바스크립트 코드는 그 자체로 실행될 수 없습니다. 코드를 해석하고 실행하는 주체가 필요한데, 이를 자바스크립트 엔진(JavaScript Engine)이라고 부릅니다. 가장 유명한 엔진은 구글이 개발한 V8 엔진으로, Chrome 브라우저와 Node.js에서 모두 사용됩니다. V8 엔진의 핵심 역할은 다음과 같습니다.

  • 메모리 힙(Memory Heap): 변수나 객체 등 메모리 할당이 일어나는 영역입니다.
  • 콜 스택(Call Stack): 코드 실행을 추적하는 자료구조입니다. 함수가 호출되면 해당 함수의 실행 컨텍스트가 스택에 쌓이고(push), 함수 실행이 끝나면 스택에서 제거됩니다(pop).

하지만 V8 엔진 자체에는 `setTimeout`, 파일 시스템 접근(fs), HTTP 요청(http)과 같은 기능이 없습니다. 이러한 기능들은 자바스크립트 언어의 표준 사양(ECMAScript)이 아닌, 실행 환경, 즉 런타임(Runtime)이 제공하는 API입니다. 브라우저는 웹 API(DOM, AJAX, Geolocation 등)를 제공하고, Node.js는 백엔드에 필요한 C++ API(파일 시스템, 네트워크, OS 등)를 제공합니다. 그리고 이 런타임 환경에 바로 이벤트 루프큐(Queue)가 포함되어 있습니다.

정리하자면, Node.js는 단순히 V8 엔진을 포장한 것이 아니라, V8 엔진 + Node.js Core Library(C++ 바인딩) + libuv(이벤트 루프 및 비동기 I/O 라이브러리)가 결합된 강력한 자바스크립트 런타임인 것입니다. 따라서 우리가 Node.js에서 비동기 코드를 작성할 때, 실제로는 V8 엔진과 Node.js 런타임 환경의 여러 구성 요소가 유기적으로 협력하여 동작하는 것입니다.

2. 비동기 처리의 기본 개념: 블로킹 vs 논블로킹

이벤트 루프의 존재 이유인 '논블로킹 I/O'를 이해하기 위해, 먼저 동기/비동기 및 블로킹/논블로킹의 개념을 명확히 짚고 넘어가겠습니다. 이 네 가지 개념은 종종 혼용되지만, 엄연히 다른 차원의 이야기입니다.

  • 동기(Synchronous): 작업이 순서대로 실행되는 방식입니다. 첫 번째 작업이 완전히 끝나야만 다음 작업이 시작될 수 있습니다. 코드의 흐름이 예측 가능하고 직관적입니다.
  • 비동기(Asynchronous): 작업이 동시에 실행될 수 있는 방식입니다. 첫 번째 작업이 끝나는 것을 기다리지 않고 즉시 다음 작업을 시작합니다. 나중에 첫 번째 작업이 완료되면, 그 결과를 콜백 함수나 프로미스(Promise)를 통해 전달받습니다.
  • 블로킹(Blocking): 호출된 함수가 자신의 작업을 마칠 때까지 제어권(Control)을 호출한 함수에게 넘겨주지 않는 것입니다. 이 시간 동안 시스템은 다른 작업을 하지 못하고 멈춰있게(blocked) 됩니다. 동기 작업은 대부분 블로킹 방식으로 동작합니다.
  • 논블로킹(Non-blocking): 호출된 함수가 자신의 작업을 즉시 완료할 수 없더라도, 제어권을 곧바로 호출한 함수에게 넘겨주는 것입니다. 시스템은 멈추지 않고 다른 작업을 계속 수행할 수 있습니다. 비동기 작업은 논블로킹 방식으로 동작합니다.

Node.js에서는 이 개념이 어떻게 적용될까요? 파일을 읽는 간단한 예시를 통해 비교해 보겠습니다.

블로킹 I/O (동기 방식)


const fs = require('fs');

console.log('파일 읽기 시작');
const data = fs.readFileSync('./example.txt', 'utf8'); // 여기서 멈춘다 (Blocking)
console.log('파일 내용:', data);
console.log('파일 읽기 완료');

위 코드에서 fs.readFileSync는 동기 함수입니다. 이 함수가 호출되면 Node.js는 파일 시스템에 접근하여 'example.txt' 파일의 내용을 전부 읽어올 때까지 다음 줄인 `console.log('파일 내용:', data);`로 넘어가지 않습니다. 만약 파일 크기가 매우 크거나 디스크 I/O가 느리다면, 프로그램 전체가 이 지점에서 수 초, 혹은 수 분간 멈춰있게 됩니다. 서버 환경이라면 이는 치명적입니다. 이 시간 동안 다른 어떤 사용자 요청도 처리할 수 없기 때문입니다.

논블로킹 I/O (비동기 방식)


const fs = require('fs');

console.log('파일 읽기 시작');
fs.readFile('./example.txt', 'utf8', (err, data) => { // 파일 읽기 요청만 보내고 바로 다음으로 넘어간다
    if (err) throw err;
    console.log('파일 내용:', data); // 파일 읽기가 완료되면 이 콜백 함수가 실행된다
});
console.log('파일 읽기 완료? (아직 모름, 일단 다음 작업 수행)');

반면 fs.readFile은 비동기 함수입니다. 이 함수가 호출되면, Node.js는 파일 읽기 작업을 운영체제(커널)에게 위임하고, 즉시 다음 코드인 `console.log('파일 읽기 완료? ...');`를 실행합니다. 파일 읽기라는 시간이 걸리는 작업은 백그라운드에서 진행되며, 메인 스레드는 다른 일을 계속할 수 있습니다. 나중에 파일 읽기가 완료되면, 운영체제가 Node.js에게 알려주고, 미리 등록해 둔 콜백 함수 `(err, data) => { ... }`가 실행되는 구조입니다. 이처럼 작업 완료를 기다리지 않고 다른 일을 할 수 있게 해주는 것이 바로 논블로킹의 핵심이며, 이를 가능하게 하는 것이 이벤트 루프입니다.

3. 이벤트 루프의 핵심 구성 요소들

이제 이벤트 루프가 어떻게 동작하는지 구체적으로 살펴볼 시간입니다. 이벤트 루프는 혼자 일하지 않습니다. 여러 구성 요소들과 긴밀하게 협력하며 비동기 작업을 조율합니다. 가장 중요한 구성 요소는 콜 스택, 백그라운드(Node.js API), 그리고 두 종류의 큐(태스크 큐, 마이크로태스크 큐)입니다.

3.1. 콜 스택 (Call Stack)

앞서 언급했듯이, 콜 스택은 자바스크립트 코드의 실행을 추적하는 기본적인 자료구조입니다. LIFO(Last-In, First-Out) 원칙에 따라 작동합니다. 함수가 호출되면 스택의 맨 위에 쌓이고(push), 함수의 실행이 끝나 `return`을 만나면 스택에서 빠져나옵니다(pop). 자바스크립트는 싱글 스레드이므로, 콜 스택도 단 하나만 존재합니다. 이는 곧 한 번에 하나의 작업만 수행할 수 있음을 의미합니다.

예를 들어, 다음 코드를 보겠습니다.


function first() {
  console.log('첫 번째 함수');
  second();
  console.log('첫 번째 함수 끝');
}

function second() {
  console.log('두 번째 함수');
  third();
  console.log('두 번째 함수 끝');
}

function third() {
  console.log('세 번째 함수');
}

first();

이 코드의 콜 스택 변화를 텍스트로 그려보면 다음과 같습니다.

1. first() 호출
   +--------------+
   |    first     |
   +--------------+
   | (anonymous)  | <-- 전역 컨텍스트
   +--------------+

2. second() 호출
   +--------------+
   |    second    |
   +--------------+
   |    first     |
   +--------------+
   | (anonymous)  |
   +--------------+

3. third() 호출
   +--------------+
   |    third     |
   +--------------+
   |    second    |
   +--------------+
   |    first     |
   +--------------+
   | (anonymous)  |
   +--------------+

4. third() 종료
   +--------------+
   |    second    |
   +--------------+
   |    first     |
   +--------------+
   | (anonymous)  |
   +--------------+

5. second() 종료
   +--------------+
   |    first     |
   +--------------+
   | (anonymous)  |
   +--------------+

6. first() 종료
   +--------------+
   | (anonymous)  |
   +--------------+

7. 모든 코드 실행 완료. 스택이 비워짐.

이벤트 루프의 가장 중요한 규칙 중 하나는 "콜 스택이 비어있을 때만 큐에서 작업을 가져온다"는 것입니다. 즉, 현재 실행 중인 동기 코드가 모두 완료되어 콜 스택이 깨끗하게 비워지기 전까지는, 어떤 비동기 작업의 콜백도 실행될 수 없습니다.

3.2. 백그라운드 (Node.js APIs & libuv)

setTimeout, fs.readFile, http.get과 같은 비동기 함수가 호출되면, 이 작업은 V8 엔진의 콜 스택에서 바로 처리되지 않습니다. 대신 Node.js가 제공하는 C++ API로 전달되어 백그라운드에서 처리됩니다. 이 백그라운드 작업의 실체는 대부분 libuv라는 라이브러리입니다.

libuv는 비동기 I/O에 초점을 맞춘 C 언어 기반의 다중 플랫폼 라이브러리입니다. libuv는 내부적으로 운영체제의 고성능 이벤트 알림 기능(Linux의 epoll, macOS/BSD의 kqueue, Windows의 IOCP 등)을 활용하여 여러 I/O 작업을 효율적으로 관리합니다. 또한, 파일 시스템 접근이나 DNS 조회처럼 일부 비동기 API가 운영체제 수준에서 지원되지 않는 경우를 대비해, 자체적으로 스레드 풀(Thread Pool)을 운영하여 작업을 병렬로 처리하기도 합니다.

중요한 점은, 이 모든 복잡한 작업이 자바스크립트의 메인 스레드와는 별개로 일어난다는 사실입니다. 메인 스레드는 단지 "이 작업 좀 해줘. 끝나면 이 함수 실행해줘"라고 요청만 보낼 뿐입니다.

3.3. 큐 (Queues)

백그라운드에서 비동기 작업이 완료되면, 그 결과와 함께 실행되어야 할 콜백 함수는 즉시 콜 스택으로 가지 않습니다. 대신, 잠시 대기하는 공간인 큐(Queue)로 들어갑니다. 큐는 FIFO(First-In, First-Out) 원칙에 따라 작동하며, 먼저 들어온 작업이 먼저 처리됩니다. Node.js에는 목적에 따라 여러 종류의 큐가 있지만, 가장 중요한 것은 태스크 큐와 마이크로태스크 큐입니다.

태스크 큐 (Task Queue, 또는 Callback Queue, Macrotask Queue)

가장 일반적인 큐입니다. 다음과 같은 비동기 API의 콜백 함수들이 이곳으로 들어옵니다.

  • setTimeout, setInterval
  • setImmediate
  • 파일 I/O, 네트워크 I/O 등 대부분의 비동기 작업 콜백

이벤트 루프는 콜 스택이 비워질 때마다 이 태스크 큐를 확인하여, 가장 오래된(가장 먼저 들어온) 작업을 꺼내 콜 스택으로 옮겨 실행합니다.

마이크로태스크 큐 (Microtask Queue, 또는 Job Queue)

태스크 큐보다 더 높은 우선순위를 갖는 특별한 큐입니다. 이곳에는 다음과 같은 작업의 콜백이 들어갑니다.

  • Promise.then(), .catch(), .finally()
  • process.nextTick() (Node.js 고유의 API로, 마이크로태스크 큐 중에서도 가장 높은 우선순위를 가집니다)
  • queueMicrotask()

이벤트 루프의 동작 규칙에서 마이크로태스크 큐는 매우 중요합니다. 이벤트 루프는 하나의 태스크(매크로태스크)를 처리한 직후, 또는 콜 스택이 비워진 직후에, 마이크로태스크 큐를 확인합니다. 그리고 마이크로태스크 큐에 작업이 있다면, 큐가 완전히 빌 때까지 모든 마이크로태스크를 연속적으로 실행합니다. 그 후에야 다음 태스크를 처리하거나 다른 단계로 넘어갑니다. 이 우선순위의 차이가 코드의 실행 순서를 예측하는 데 핵심적인 역할을 합니다.

4. 이벤트 루프의 전체 동작 흐름 시뮬레이션

이제 각 구성 요소들이 어떻게 상호작용하는지 실제 코드를 통해 단계별로 따라가 보겠습니다. 다음 코드는 이벤트 루프의 동작을 이해하는 데 가장 고전적인 예제입니다.


console.log('Script Start');

setTimeout(() => {
  console.log('setTimeout Callback');
}, 0);

Promise.resolve().then(() => {
  console.log('Promise Resolved');
});

console.log('Script End');

이 코드가 실행되면 콘솔에는 어떤 순서로 로그가 찍힐까요? 정답은 `Script Start`, `Script End`, `Promise Resolved`, `setTimeout Callback` 순입니다. 왜 그런지 이벤트 루프의 관점에서 자세히 분석해 보겠습니다.

1단계: `console.log('Script Start')` 실행

  • `console.log('Script Start')`가 콜 스택에 push 됩니다.
  • 'Script Start'가 콘솔에 출력됩니다.
  • 함수 실행이 완료되고 콜 스택에서 pop 됩니다.
   Call Stack: [ ]
   Microtask Queue: [ ]
   Task Queue: [ ]
   Console: Script Start

2단계: `setTimeout(() => {...}, 0)` 실행

  • `setTimeout` 함수가 콜 스택에 push 됩니다.
  • Node.js API를 통해 타이머 설정을 요청합니다. 지연 시간은 0ms이지만, 즉시 실행된다는 의미는 아닙니다. 최소 0ms를 기다린 후 콜백을 큐에 넣으라는 의미입니다.
  • 타이머가 백그라운드에서 시작되고, `setTimeout` 함수 자체의 실행은 끝났으므로 콜 스택에서 pop 됩니다.
  • 약 0ms (실제로는 시스템에 따라 1ms 이상 걸릴 수 있음) 후에, 타이머가 만료되고 등록된 콜백 함수 `() => { console.log('setTimeout Callback'); }`가 태스크 큐로 이동합니다.
   Call Stack: [ ]
   Microtask Queue: [ ]
   Task Queue: [ setTimeout Callback ]
   Console: Script Start

3단계: `Promise.resolve().then(() => {...})` 실행

  • `Promise.resolve()`가 즉시 이행(resolved) 상태의 프로미스를 반환하고, `.then()` 메소드가 콜 스택에 push 됩니다.
  • `.then()`에 등록된 콜백 함수 `() => { console.log('Promise Resolved'); }`는 마이크로태스크 큐로 이동합니다.
  • `.then()` 메소드의 실행이 완료되고 콜 스택에서 pop 됩니다.
   Call Stack: [ ]
   Microtask Queue: [ Promise Callback ]
   Task Queue: [ setTimeout Callback ]
   Console: Script Start

4단계: `console.log('Script End')` 실행

  • `console.log('Script End')`가 콜 스택에 push 됩니다.
  • 'Script End'가 콘솔에 출력됩니다.
  • 함수 실행이 완료되고 콜 스택에서 pop 됩니다.
   Call Stack: [ ]
   Microtask Queue: [ Promise Callback ]
   Task Queue: [ setTimeout Callback ]
   Console: Script Start, Script End

5단계: 이벤트 루프의 확인 작업 (1) - 마이크로태스크

  • 전역 스크립트 코드 실행이 모두 끝났습니다. 이제 콜 스택은 완전히 비어있습니다.
  • 이벤트 루프는 제어권을 얻고, 가장 먼저 마이크로태스크 큐를 확인합니다.
  • 마이크로태스크 큐에 'Promise Callback'이 있는 것을 발견합니다.
  • 'Promise Callback'을 마이크로태스크 큐에서 꺼내 콜 스택으로 push 합니다.
   Call Stack: [ Promise Callback ]
   Microtask Queue: [ ]
   Task Queue: [ setTimeout Callback ]
   Console: Script Start, Script End

6단계: 'Promise Callback' 실행

  • 콜 스택에 있는 'Promise Callback'이 실행됩니다. 내부의 `console.log('Promise Resolved')`가 실행됩니다.
  • 'Promise Resolved'가 콘솔에 출력됩니다.
  • 콜백 함수 실행이 완료되고 콜 스택에서 pop 됩니다.
   Call Stack: [ ]
   Microtask Queue: [ ]
   Task Queue: [ setTimeout Callback ]
   Console: Script Start, Script End, Promise Resolved

7단계: 이벤트 루프의 확인 작업 (2) - 태스크

  • 이벤트 루프는 마이크로태스크 큐를 다시 확인합니다. 비어있습니다.
  • 이제 이벤트 루프는 태스크 큐를 확인합니다.
  • 태스크 큐에 'setTimeout Callback'이 있는 것을 발견합니다.
  • 'setTimeout Callback'을 태스크 큐에서 꺼내 콜 스택으로 push 합니다.
   Call Stack: [ setTimeout Callback ]
   Microtask Queue: [ ]
   Task Queue: [ ]
   Console: Script Start, Script End, Promise Resolved

8단계: 'setTimeout Callback' 실행

  • 콜 스택에 있는 'setTimeout Callback'이 실행됩니다. 내부의 `console.log('setTimeout Callback')`이 실행됩니다.
  • 'setTimeout Callback'이 콘솔에 출력됩니다.
  • 콜백 함수 실행이 완료되고 콜 스택에서 pop 됩니다.
   Call Stack: [ ]
   Microtask Queue: [ ]
   Task Queue: [ ]
   Console: Script Start, Script End, Promise Resolved, setTimeout Callback

모든 작업이 완료되었습니다. 이처럼 이벤트 루프는 '콜 스택 비우기 -> 마이크로태스크 큐 비우기 -> 태스크 큐에서 하나 꺼내 실행'이라는 사이클을 끊임없이 반복합니다. 이 우선순위 규칙을 이해하는 것이 Node.js 비동기 코드의 동작을 정확히 예측하는 열쇠입니다.

5. 더 깊게: libuv와 이벤트 루프의 6단계 (Phases)

지금까지의 설명은 이벤트 루프를 개념적으로 이해하기 위한 고수준 모델입니다. 실제 Node.js의 이벤트 루프는 libuv에 의해 구현되어 있으며, 단순히 하나의 큐만 확인하는 것이 아니라 정해진 순서에 따라 여러 단계를 순회하는 형태로 동작합니다. 각 순회(tick)는 여러 개의 단계(Phase)로 구성됩니다.

이벤트 루프의 각 단계는 자신만의 FIFO 큐를 가지고 있으며, 해당 단계에 진입하면 자신에게 할당된 작업들을 처리합니다. 주요 6단계는 다음과 같습니다.

   ┌───────────────────────────┐
┌─>│           timers          │
│  └─────────────┬─────────────┘
│  ┌─────────────┴─────────────┐
│  │     pending callbacks     │
│  └─────────────┬─────────────┘
│  ┌─────────────┴─────────────┐
│  │       idle, prepare       │
│  └─────────────┬─────────────┘      ┌───────────────┐
│  ┌─────────────┴─────────────┐      │   incoming:   │
│  │           poll            │<─────┤  connections, │
│  └─────────────┬─────────────┘      │   data, etc.  │
│  ┌─────────────┴─────────────┐      └───────────────┘
│  │           check           │
│  └─────────────┬─────────────┘
│  ┌─────────────┴─────────────┐
└──┤      close callbacks      │
   └───────────────────────────┘
  1. Timers (타이머): 이 단계에서는 setTimeout()setInterval()에 의해 스케줄된 콜백들이 실행됩니다.
  2. Pending Callbacks (대기 콜백): 이전 루프에서 지연된 I/O 콜백들을 실행합니다. 예를 들어, TCP 소켓에서 `EAGAIN`과 같은 오류가 발생했을 때 재시도를 위해 대기하는 콜백들이 여기서 처리됩니다. (일반적인 애플리케이션 개발에서는 거의 마주치지 않습니다.)
  3. Idle, Prepare (휴지, 준비): 내부적으로만 사용되는 단계입니다.
  4. Poll (폴링): 가장 중요한 단계 중 하나입니다. 새로운 I/O 이벤트를 가져오고, I/O와 관련된 콜백(파일 읽기, 네트워크 요청 등)을 실행합니다. 대부분의 비동기 작업 콜백은 여기서 실행됩니다. Poll 단계의 동작은 조금 복잡합니다.
    • Poll 큐에 처리할 콜백이 있으면, 큐가 빌 때까지 또는 시스템 종속적인 한계에 도달할 때까지 동기적으로 실행합니다.
    • Poll 큐가 비어있으면, 새로운 I/O 이벤트를 기다립니다. 이 때 setImmediate()로 스케줄된 콜백이 있는지 확인하고, 있다면 Check 단계로 넘어갑니다. 또한, 만료된 타이머가 있는지 확인하고, 있다면 Timers 단계로 넘어갑니다. 이 과정에서 이벤트 루프는 필요하다면 블로킹될 수 있습니다.
  5. Check (체크): setImmediate()로 스케줄된 콜백들이 이 단계에서 실행됩니다. Poll 단계가 완료된 직후에 실행됩니다.
  6. Close Callbacks (종료 콜백): 소켓이나 핸들이 닫힐 때 발생하는 이벤트(예: socket.on('close', ...))의 콜백들이 실행됩니다.

그리고 가장 중요한 규칙이 있습니다. 이벤트 루프는 한 단계에서 다음 단계로 넘어가기 전에, 항상 마이크로태스크 큐(process.nextTick 포함)를 확인하고, 그 안에 있는 모든 작업을 비웁니다.

이러한 단계 때문에 setTimeout(..., 0)setImmediate()의 실행 순서는 예측이 불가능할 때가 있습니다.


setTimeout(() => console.log('Timeout'), 0);
setImmediate(() => console.log('Immediate'));

만약 이 코드가 메인 모듈에서 직접 실행된다면, 어느 것이 먼저 실행될지 보장할 수 없습니다. 이벤트 루프가 Timers 단계에 진입하는 시점의 성능(예: 다른 프로세스의 부하)에 따라 0ms 타이머가 준비될 수도, 안 될 수도 있기 때문입니다. 하지만 I/O 콜백 안에서는 순서가 항상 보장됩니다.


const fs = require('fs');

fs.readFile(__filename, () => {
  setTimeout(() => console.log('Timeout'), 0);
  setImmediate(() => console.log('Immediate'));
});

이 코드는 항상 'Immediate'가 먼저 출력되고 'Timeout'이 나중에 출력됩니다. 왜냐하면 파일 읽기 콜백은 Poll 단계에서 실행되는데, 그 다음 단계가 Check(setImmediate)이기 때문입니다. Check 단계가 실행된 후, 루프는 다시 Timers 단계로 돌아가므로 `setTimeout` 콜백은 다음 루프에서 실행됩니다.

6. 이벤트 루프를 막지 마세요: 성능 최적화 전략

Node.js의 성능은 이벤트 루프가 얼마나 빠르고 효율적으로 순회하는지에 달려있습니다. 만약 콜 스택에서 오랜 시간이 걸리는 동기적인 코드가 실행된다면, 그 시간 동안 이벤트 루프는 완전히 멈춰버립니다. 이를 '이벤트 루프를 블로킹한다(Blocking the Event Loop)'고 표현합니다.

이벤트 루프가 블로킹되면 어떤 일이 벌어질까요? 서버는 새로운 요청을 받지 못하고, 진행 중이던 I/O 작업의 콜백도 처리하지 못하며, 타이머도 동작하지 않습니다. 즉, 애플리케이션 전체가 먹통이 되는 것입니다. 따라서 고성능 Node.js 애플리케이션을 작성하기 위해서는 이벤트 루프 블로킹을 반드시 피해야 합니다.

이벤트 루프를 블로킹하는 주범들

  • 복잡한 계산: 정규표현식, 암호화, 압축, 복잡한 반복문 등 CPU를 많이 사용하는 작업.
  • 동기 I/O: fs.readFileSync, fs.writeFileSync 등. (디버깅이나 간단한 스크립트 외에는 절대 사용하지 말아야 합니다.)
  • 거대한 JSON 처리: JSON.parse()JSON.stringify()는 동기적으로 작동합니다. 매우 큰 JSON 객체를 다룰 경우 상당한 블로킹을 유발할 수 있습니다.

블로킹을 피하는 방법

1. 모든 I/O에 비동기 API 사용

가장 기본적이고 중요한 원칙입니다. 데이터베이스 쿼리, 파일 시스템 접근, 외부 API 호출 등 모든 I/O 작업은 반드시 비동기 버전의 API(콜백, 프로미스, async/await)를 사용해야 합니다.

2. CPU 집약적 작업을 외부로 이전하기

피할 수 없는 CPU 집약적 작업이 있다면, 이를 메인 스레드가 아닌 다른 곳에서 처리해야 합니다. Node.js는 이를 위한 몇 가지 방법을 제공합니다.

워커 스레드 (Worker Threads): Node.js v12부터 정식으로 지원되는 기능으로, 진정한 멀티 스레딩을 가능하게 합니다. CPU 집약적인 작업을 별도의 스레드에 위임하고, 작업이 끝나면 결과만 메인 스레드로 받아볼 수 있습니다. 메인 스레드의 이벤트 루프는 전혀 방해받지 않습니다.

예시: 복잡한 계산을 워커 스레드로 옮기기

main.js


const { Worker } = require('worker_threads');

console.log('메인 스레드 시작');

// 워커 생성
const worker = new Worker('./worker.js', { workerData: { num: 45 } });

// 워커로부터 메시지 수신
worker.on('message', (result) => {
  console.log(`피보나치 결과: ${result}`);
});

worker.on('error', (err) => {
    console.error(err);
});

console.log('메인 스레드는 다른 작업을 계속합니다...');

worker.js


const { parentPort, workerData } = require('worker_threads');

function fibonacci(n) {
  if (n < 2) return n;
  return fibonacci(n - 2) + fibonacci(n - 1);
}

const result = fibonacci(workerData.num);

// 결과를 메인 스레드로 전송
parentPort.postMessage(result);

위 예제에서 시간이 오래 걸리는 `fibonacci` 함수는 워커 스레드에서 실행됩니다. 그동안 메인 스레드는 `console.log('메인 스레드는 다른 작업을 계속합니다...');`를 즉시 실행하며 이벤트 루프를 계속 돌릴 수 있습니다.

3. 자식 프로세스 (Child Process)

외부 커맨드나 스크립트를 실행해야 할 경우 `child_process` 모듈을 사용할 수 있습니다. `exec`, `spawn` 등의 함수를 통해 별도의 프로세스를 생성하여 작업을 위임할 수 있습니다.

결론: 이벤트 기반 아키텍처의 이해

Node.js의 이벤트 루프는 단순히 기술적인 구현을 넘어, '작업이 완료될 때까지 기다리지 말고, 일단 요청만 해놓고 다른 일을 하다가, 작업이 끝나면 알려달라'는 철학을 담고 있는 이벤트 기반 아키텍처의 핵심입니다. 이 모델을 통해 Node.js는 싱글 스레드라는 제약에도 불구하고 뛰어난 동시성과 처리량을 확보할 수 있었습니다.

오늘 우리는 이벤트 루프를 구성하는 콜 스택, 큐, 그리고 libuv의 상세한 단계들을 살펴보았습니다. 또한, 마이크로태스크와 매크로태스크의 우선순위 차이가 어떻게 코드의 실행 순서에 영향을 미치는지, 그리고 이벤트 루프 블로킹이 왜 위험하며 어떻게 피할 수 있는지에 대해서도 알아보았습니다.

Node.js 개발자에게 이벤트 루프에 대한 깊이 있는 이해는 선택이 아닌 필수입니다. 비동기 코드의 흐름을 명확하게 예측하고, 잠재적인 성능 병목을 찾아내며, 궁극적으로는 더 빠르고 안정적인 애플리케이션을 만드는 가장 강력한 무기가 될 것입니다. 이제 여러분은 Node.js의 심장이 어떻게 뛰는지 알게 되었습니다. 이 지식을 바탕으로 더욱 성숙한 Node.js 개발자로 나아가시길 바랍니다.


0 개의 댓글:

Post a Comment