← Back to list

Node.js의 심장: Event Loop & Single Thread

싱글 스레드인 Node.js가 대량의 I/O(RPC 요청, DB 조회)를 처리하는 원리에 대해서 작성해보았습니다.

DevOZ · 2026-01-21 10:47 · 0 claps · 13.1 min read
#nodejs #event-loop #싱글-스레드 #libuv #non-blocking
Open on Medium ↗
Wiki topics: 🌐 · Web Development

Node.js의 심장: Event Loop & Single Thread

이번 글에서는 싱글 스레드인 Node.js가 대량의 I/O(RPC 요청, DB 조회)를 처리하는 원리에 대해서 작성해보겠습니다.

Node.js는 싱글 스레드인데 어떻게 빠를까요?

싱글 스레드라는 것은 결국에는 혼자 일한다는 것인데, 어떻게 수천 개의 요청을 처리하는 것일까요? Node.js를 레스토랑에 비유해서 한 번 정리해보겠습니다.

전통적인 서버 (Multi-Thread)

전통적인 서버는 Multi-Thread입니다. 비유하자면, 손님(요청) 한 명당 웨이터(스레드) 한 명이 붙습니다. 웨이터가 주방에 주문을 넣고 요리가 나올 때까지 주방 앞에서 멍하니 기다립니다. 따라서 웨이터가 많이 필요하고 비쌉니다.

Node.js (Single Thread)

반면에 Node.js는 Single Thread입니다. 비유하자면, 슈퍼 웨이터 한 명이 있습니다. 주문을 받고 주방(커널/OS)에 던져두고, 요리가 나올 때까지 기다리지 않고 즉시 다음 손님의 주문을 받습니다. 요리가 다 되었다는 ‘벨(이벤트)’이 울리면 그때 서빙합니다.

[ 전통적인 서버 vs Node.js 처리 방식 비교 ]

Multi-Thread (전통적 방식)
   손님 A ━━(주문)━━▶ [웨이터 1] ━━(요리 중..)━━▶ [멍하니 대기...]
   손님 B ━━(주문)━━▶ [웨이터 2] ━━(요리 중..)━━▶ [멍하니 대기...]
   (비용: 웨이터 월급 x N명)

Node.js (Single Thread)
   손님 A ━━(주문)━━▶ [슈퍼 웨이터] ━━(주방에 던짐)━━▶ 요리사들(Thread Pool)
   손님 B ━━(주문)━━▶ [슈퍼 웨이터] ━━(주방에 던짐)━━▶ 요리사들(Thread Pool)
                      │ (쉬지 않고 바로 접수!)
                      ▼
         "띵동! 요리 A 나왔습니다" (Event)

여기서 슈퍼 웨이터는 Node.js의 메인 스레드(Main Thread)인데요. 손님(Client)의 요청을 받고, 무거운 요리(DB 조회, 파일 읽기 등)는 주방에 주문표(Callback)를 붙여 넘긴 뒤, 즉시 다음 손님을 응대합니다. 절대 멈추지 않고 계속 주문을 받으러 돌아다니기 때문에 ‘이벤트 루프(Event Loop)’라고 부릅니다.

그리고 주방 & 조수들System Kernel & Libuv입니다. 실제로 무거운 작업을 처리하는 곳인데요. Node.js는 Libuv라는 라이브러리를 통해 운영체제(OS)의 커널이나 별도의 워커 스레드(Worker Thread Pool)에게 힘든 일을 위임합니다. 웨이터는 한 명이지만, 주방에는 사실 수많은 요리사(Thread)가 숨어서 일하고 있는 셈입니다.

Node.js가 빠른 이유

그러면 왜 빠를까요? 전통적인 서버(Multi-Thread) 방식은 웨이터를 수천 명 고용해야 합니다. 하지만 웨이터를 고용하고 관리하는 비용(메모리 사용, 컨텍스트 스위칭 비용)은 매우 비쌉니다.

반면, Node.js는 비싼 웨이터(Thread)를 한 명만 두기 때문에 메모리를 적게 쓰기 때문에 가볍습니다. 또한 웨이터가 멍하니 기다리는 시간(Blocking) 없이, 끊임없이 주문만 받으러 다니므로(Non-blocking) CPU 자원을 낭비하지 않아 효율적입니다.

결론적으로 Node.js는 “접수(JavaScript 실행)는 혼자 하지만, 힘든 일(I/O 작업)은 뒤에서 몰래 여러 명이 처리하는 구조”이기 때문에 빠릅니다.

Call Stack과 Event Loop

이제, 슈퍼 웨이터의 머릿속과 주방 구조를 해부해 볼 차례입니다. Node.js가 실제로 코드를 처리하는 과정은 크게 4가지 영역의 협동으로 이루어집니다.

[ Node.js의 비동기 처리 순환도 ]

   ┌─────────────────┐          ┌───────────────────┐
   │ 1. Call Stack   │          │ 2. Web APIs       │
   │ (웨이터 작업대)   │ ──────▶ │ (주방 / 조수)       │
   │ [ console.log ] │          │ [ Timer, DB, Net ]│
   └────────▲────────┘          └─────────┬─────────┘
            │                             │
      4. Event Loop                       │ (완료되면 이동)
     (스택 비었나 감시 중)                   ▼
            │                   ┌───────────────────┐
            └───────(Tick)──────┤ 3. Callback Queue │
                                │ (대기실)           │
                                │ [ cb, cb, ... ]   │
                                └───────────────────┘

1. Call Stack

첫 번째 영역Call Stack(호출 스택)입니다. 웨이터의 작업대라고 보시면 됩니다. 우리가 작성한 자바스크립트 코드가 실제로 올라가서 실행되는 곳입니다. LIFO(Last In, First Out) 구조로 나중에 들어온 작업이 먼저 나가는 구조입니다.

싱글 스레드이기 때문에 이 작업대는 딱 하나뿐입니다. 여기서 어떤 함수가 실행 중이라면(스택에 쌓여 있다면), 웨이터는 다른 일을 할 수 없습니다. 즉, 여기가 막히면(Blocking) 전체 서버가 멈춥니다.

2. Web APIs / Background

두 번째 영역Web APIs / Background(백그라운드)입니다. 주방과 조수들이라고 생각하시면 됩니다. setTimeout, fetch, DB 조회 같은 비동기 작업들이 실행되는 공간입니다.

Call Stack에서 무거운 작업이 들어오면, Node.js는 즉시 이쪽으로 작업을 던져버리고 다음 코드를 실행합니다. 여기서 작업이 완료되면, 결과물(콜백 함수)을 들고 대기실(Queue)로 보냅니다.

3. Callback Queue

세 번째 영역Callback Queue(콜백 큐)입니다. 대기실이라고 생각하시면 됩니다. 백그라운드에서 작업이 끝난 콜백 함수들이 실행되기 위해 줄을 서서 기다리는 곳입니다. 먼저 끝난 작업이 먼저 대기열에 서는 FIFO 구조입니다.

4. Event Loop

네 번째 영역Event Loop(이벤트 루프)입니다. 성실한 관리자라고 생각하시면 됩니다. 이 친구가 바로 Node.js가 멈추지 않고 돌아가게 하는 심장입니다. 역할은 아주 단순하지만 강력합니다. Call Stack이 비었는지, Queue에 대기 중인 손님이 있는지를 계속해서 두리번거리며 감시합니다. 만약 Call Stack이 텅 비어 있다면, Queue에서 대기 중인 콜백을 하나 낚아채서 Call Stack으로 쑥 밀어 넣습니다. (이것을 ‘Tick’이라고 합니다.)

코드로 살펴보기

이제 간단한 코드로 한 번 살펴보겠습니다.

console.log('1. 주문 받음');

setTimeout(() => {
  console.log('2. 요리 완료 (2초 후)');
}, 2000);

console.log('3. 다음 손님');

별 생각 없이 코드를 보면 1 -> 2 -> 3이어야겠지만, Node.js에서는 1 -> 3 -> 2 순서로 출력됩니다. 왜 그럴까요?

  1. ‘1. 주문 받음’Call Stack에 들어가서 바로 실행됩니다. (출력: 1)
  2. setTimeout을 만납니다. 비동기 작업이기에 Call Stack은 이걸 보자마자 Web APIs(타이머 조수)에게 던져버리고 바로 다음 줄로 넘어갑니다.
  3. ‘3. 다음 손님’Call Stack에 들어가서 실행됩니다. (출력: 3)
  4. 이제 Call Stack은 텅 비었습니다. 이때 Event Loop는 빈 스택을 확인하고 대기합니다.
  5. 2초 뒤, 타이머 조수(Web APIs)가 일을 마칩니다. ‘2. 요리 완료’를 들고 Callback Queue에 줄을 섭니다.
  6. Event Loop가 봅니다. “어? 스택이 비었고 큐에 뭐가 있네?”하고 큐에 있던 ‘2. 요리 완료’Call Stack으로 옮깁니다.
  7. 이때가 되어서야 ‘2. 요리 완료’가 실행됩니다. (출력: 2)

이것이 바로 Node.js가 멈추지 않고 수많은 요청을 처리하는 비결입니다.

Task Queue vs Microtask Queue

바로 이전에 우리는 콜백 함수들이 ‘대기실(Queue)’에서 기다린다고 배웠습니다. 그런데 사실 Node.js에는 신분 차별이 존재합니다. 모든 손님이 공평하게 기다리는 것이 아니라는 뜻이죠…

이 대기실은 크게 두 종류로 나뉩니다. 일반 대기실(Task Queue)VIP 대기실(Microtask Queue)입니다.

이벤트 루프는 Call Stack이 비면 대기실을 확인하는데, 이때 무조건 VIP 대기실을 먼저 확인합니다.

Task Queue

먼저 Task Queue를 살펴보겠습니다. Task Queue는 Macro Queue라고도 불리며, 일반 대기실이라고 비유할 수 있습니다. VIP 대기실(Microtask Queue)의 업무가 하나도 없을 때만 비로소 실행 기회를 얻습니다. setTimeout, setInterval, setImmediate 등을 대상으로 합니다.

Microtask Queue

다음은 Microtask Queue를 살펴보겠습니다. Microtask Queue는 VIP 대기실에 비유할 수 있습니다. 최우선 순위로 Call Stack이 비는 순간, 이벤트 루프는 이곳에 있는 작업들을 쳐다봅니다. 무서운 점은 이곳이 완전히 텅 빌 때까지 이벤트 루프는 절대 다른 대기실(Task Queue)로 이동하지 않는다는 것입니다. VIP들의 요구사항을 다 들어줄 때까지 일반 손님은 계속 기다려야 합니다. Promise.then, .catch, .finally, process.nextTick 등을 대상으로 합니다.

       Event Loop (문지기)
             │
             │ "Call Stack 비었나요?"
             ▼
    ┌────────────────────┐
    │   Microtask Queue  │ ◀── (VIP: Promise, nextTick)
    │ [ 작업 A, 작업 B ]  │       "얘네 다 나갈 때까지..."
    └────────────────────┘
             │
             │ "...일반 손님은 기다리세요"
             ▼
    ┌────────────────────┐
    │     Task Queue     │ ◀── (일반: setTimeout, setInterval)
    │     [ 작업 C ]      │
    └────────────────────┘

코드로 살펴보기

이론은 여기까지 하고 실제로 그런지 코드로 살펴보겠습니다.

console.log('1. Start');

// 0초 뒤에 실행하라고 했으니 바로 실행될까요?
setTimeout(() => {
  console.log('2. setTimeout (Task Queue)');
}, 0);

// Promise는 VIP입니다.
Promise.resolve().then(() => {
  console.log('3. Promise (Microtask Queue)');
});

console.log('4. End');

그냥 코드 순서대로라면 1 -> 2 -> 3 -> 4여야 할 것 같고, setTimeout이0초니까 Promise보다 빠를 것 같지만, 실제 결과는 다릅니다.

[실행 결과]
1. Start 
4. End 
3. Promise (Microtask Queue) 
2. setTimeout (Task Queue)

왜 이런 결과가 나왔을까요? 이 순서가 Node.js의 비동기 처리의 핵심입니다. 한 줄씩 따라가 보겠습니다.

  1. 1. Start (Call Stack): 동기 코드이므로 스택에 들어가자마자 바로 실행됩니다.
  2. setTimeout: “0초 뒤 실행”이지만, 이 친구는 일반 대기실(Task Queue)로 보내집니다. (아직 대기중)
  3. Promise: 비동기 작업이지만, 이 친구는 VIP 대기실(Microtask Queue)로 보내집니다. (아직 대기중)
  4. 4. End (Call Stack): 동기 코드이므로 스택에 들어가서 바로 실행됩니다.
  5. 메인 작업 종료: 이제 Call Stack이 텅 비었습니다.
  6. 이벤트 루프의 선택:
  • 이벤트 루프가 스택이 비었음을 확인하고 대기실을 둘러봅니다.
  • 일반 대기실엔 setTimeout이 있고, VIP 대기실엔 Promise가 있습니다.
  • 당연히 VIP인 Promise를 먼저 꺼내서 실행합니다. (3. Promise 출력)
  1. VIP 처리 완료: VIP 대기실이 텅 비었습니다. 그제야 일반 대기실에 있던 setTimeout을 가져와 실행합니다. (2. setTimeout 출력)

정리하자면 코드 상에서 setTimeout이 더 위에 있고 시간이 0초라 하더라도, Promise(Microtask)가 큐에 있다면 무조건 먼저 실행됩니다. 이것이 Node.js의 실행 순서 비밀입니다.

블로킹(Blocking)

Node.js는 빠르고 효율적이지만, 치명적인 약점이 하나 있습니다. 바로 “웨이터가 한 명뿐”이라는 사실입니다. 이 점을 망각하고 코드를 짜면 서버 전체가 마비되는 대참사가 일어납니다.

전통적인 서버(Multi-Thread)를 보면 웨이터 한 명이 계산하느라 멈춰 있어도, 다른 웨이터들이 다른 손님을 받으면 됩니다.

반면 Node.js(Single Thread)를 보면 유일한 웨이터가 계산기(CPU 작업)을 두드리느라 멈춰버립니다. 식당 문 앞에서 손님들이 줄을 서도, 주방에서 요리가 다 됐다고 벨을 울려도, 웨이터는 계산이 끝날 때까지 꼼짝도 안 합니다.

이것을 블로킹(Blocking)이라고 합니다.

코드로 살펴보기

실제로 어떤 일이 벌어지는지 코드로 확인해보겠습니다.

app.get('/danger', (req, res) => {
  // ! 절대 하지 말아야 할 짓 (CPU Intensive Task)
  console.log('어려운 계산 시작…');

  let count = 0;
  for (let i = 0; i < 5000000000; i++) { // 50억 번 반복
    count++;
  }

  console.log('계산 끝!');
  res.send(`결과: ${count}`);
});

app.get('/hello', (req, res) => {
  res.send('안녕하세요!');
});
  1. 누군가 /danger 요청을 보냅니다.
  2. 웨이터(Call Stack)는 50억 번의 for문을 돌기 시작합니다.
  3. 이때, 다른 사용자가 가벼운 /hello 요청을 보냅니다.
  4. 결과적으로 /hello 요청은 처리되지 않습니다. 왜냐하면 웨이터가 for문이라는 감옥(Call Stack)에 갇혀 있기 때문입니다. 50억 번의 반복이 끝날 때까지 서버는 사실상 ‘멈춤(Hang)’상태가 됩니다.

기술적으로 보면, for문이나 JSON.stringify(객체가 엄청 클 때), 암호화 연산 같은 CPU 집약적인 작업(CPU-bound)은 Call Stack을 오랫동안 점유합니다.

Event Loop의 역할은 “Call Stack이 비었는지” 확인하고 큐에서 작업을 가져오는 것인데, Call Stack이 비워지지 않으니 Event Loop도 멈춰버립니다. 즉, DB에서 데이터를 아무리 빨리 가져와도, 타이머가 끝나도, 웨이터가 그것을 처리해주지 못하는 것입니다.

[ 블로킹(Blocking) 상황 발생 시 ]

   ┌───────────────────────────┐
   │     Call Stack (감옥)      │
   │ [ for (i=0; i<50억).. ]   │ ◀── 혼자서 계산기 두드리는 중
   └───────────────────────────┘
               │
      Event Loop (작동 불가)
               │
   ┌───────────────────────────┐
   │       요청 대기열           │
   │ [ /hello 요청 ] (대기...)  │ ◀── 가벼운 요청도 처리 불가!
   │ [ /login 요청 ] (대기...)  │
   └───────────────────────────┘
     Result: 서버 응답 없음 (Hang)

이러한 이유에서대표적인 Node.js를 사용할 때의 철칙으로 “메인 스레드(Event Loop)를 막지 말라(Don’t block the event loop)”가 있습니다.

무거운 작업을 처리하는 방법

그럼 무거운 작업은 어떻게 처리해야 할까요?

먼저 Node.js로만 처리하는 방식으로는 Worker Threads를 이용하는 방식이 있습니다. Node.js가 제공하는 별도의 스레드를 생성해서 계산을 맡깁니다. (알바생 추가 고용)

다른 대안으로는 서비스 분리 방식이 있습니다. 이미지 처리나 복잡한 연산은 Python이나 Go로 된 별도의 서버로 분리하는 방식입니다.

결론적으로 Node.js는 ‘입이 짧고 자주 먹는 손님(I/O 작업)’에게는 최고의 서비스를 제공하지만, ‘하루 종일 앉아서 뷔페를 먹는 손님(CPU 작업)’에게는 최악의 선택이 될 수 있습니다.


메타데이터
post_id
08c84aee3de8
slug
node-js의-심장-event-loop-single-thread-08c84aee3de8
url
https://medium.com/@DevOZ/node-js%EC%9D%98-%EC%8B%AC%EC%9E%A5-event-loop-single-thread-08c84aee3de8
canonical_url
https://medium.com/@DevOZ/node-js%EC%9D%98-%EC%8B%AC%EC%9E%A5-event-loop-single-thread-08c84aee3de8
author_url
https://medium.com/@DevOZ
status
ok
fetched_at
2026-06-24 04:09:36