홈시리즈

© 2026 Ki Chang. All rights reserved.

본 블로그의 콘텐츠는 CC BY-NC-SA 4.0 라이선스를 따릅니다.

☕후원하기소개JSON Formatter러닝 대기질개인정보처리방침이용약관

© 2026 Ki Chang. All rights reserved.

콘텐츠: CC BY-NC-SA 4.0

☕후원하기
소개|JSON Formatter|러닝 대기질|개인정보처리방침|이용약관

Node.js 소스코드를 직접 열어봤습니다 — 런타임, V8, libuv의 실체 (1편)

정기창·2026년 2월 24일

면접 준비를 하다가 멈칫했습니다

"Node.js가 뭔가요?"

면접 예상 질문을 정리하다가 이 질문 앞에서 멈췄습니다. Chrome V8 엔진 위에서 동작하는 JavaScript 런타임이라는 건 알고 있었습니다. 그런데 막상 "런타임이 뭔데요?"라고 이어서 물으면, "그러니까... 실행 환경이요"라고밖에 답하지 못할 것 같았습니다.

NestJS로 서버를 만들고 배포까지 해봤지만, 정작 그 아래에서 무슨 일이 벌어지는지는 제대로 들여다본 적이 없었습니다. 그래서 이번에 Node.js의 기초부터 다시 짚어보기로 했습니다. 단순히 개념을 외우는 게 아니라, 실제 소스코드를 열어서 확인하는 방식으로요.

런타임이란 무엇인가

런타임(Runtime)은 코드를 실행할 수 있는 환경 그 자체를 말합니다. JavaScript라는 언어가 있고, 그 언어를 실제로 돌릴 수 있는 무대가 런타임입니다.

비유를 들어보면 이렇습니다.

  • JavaScript = 언어 (문법과 규칙)
  • V8 엔진 = 통역사 (JS 코드를 컴퓨터가 이해하는 기계어로 번역)
  • 런타임 = 무대 (번역된 코드가 실제로 실행되는 환경)

원래 JavaScript는 브라우저에서만 실행할 수 있었습니다. 브라우저 안에 V8 같은 엔진이 들어있어서, 거기서 JS를 돌렸습니다. DOM을 조작하고, fetch로 HTTP 요청을 보내고, window 객체에 접근할 수 있었던 건 브라우저라는 런타임이 그 기능을 제공했기 때문입니다.

Node.js는 여기서 발상을 전환했습니다. V8 엔진을 브라우저에서 꺼내서, 파일 시스템이나 네트워크 같은 서버 쪽 기능을 붙여 새로운 런타임을 만든 겁니다.

브라우저  = V8 + DOM, fetch, window ...  → JS 실행 환경 ①
Node.js  = V8 + fs, http, os, libuv ... → JS 실행 환경 ②

같은 JavaScript인데, 어떤 런타임에서 실행하느냐에 따라 할 수 있는 일이 달라집니다. 브라우저에서는 document.getElementById()가 되지만 Node.js에서는 안 됩니다. 반대로 Node.js에서는 fs.readFile()로 파일을 읽을 수 있지만, 브라우저에서는 보안상 불가능합니다.

V8과 libuv, 각각의 역할

Node.js 내부에는 크게 두 가지 핵심 구성 요소가 있습니다.

V8 엔진은 Google이 만든 JavaScript 엔진입니다. 역할은 단순합니다. JavaScript 코드를 받아서 기계어로 번역하고 실행하는 것. V8은 Chrome 브라우저에도 들어있고, Node.js에도 들어있습니다. 양쪽 모두에 들어있는 공통 부품입니다.

libuv(리부브)는 비동기 I/O를 실제로 처리하는 C 라이브러리입니다. 파일을 읽거나, 네트워크 요청을 보내거나, 타이머를 설정하는 것 같은 작업은 JavaScript만으로는 못합니다. 운영체제와 직접 대화해야 하는데, 그 역할을 libuv가 합니다.

V8이 JavaScript를 이해하는 두뇌라면, libuv는 실제 I/O 작업을 수행하는 손발입니다.

JS 코드: "파일 읽어줘"
      ↓
  V8: JS 코드 해석
      ↓
  libuv: OS에 파일 읽기 요청 → 완료되면 콜백 실행

개발자가 fs.readFile()을 호출하면, V8이 그 코드를 해석하고, 실제 파일 읽기는 libuv가 OS에 요청합니다. 개발자는 JavaScript만 쓰지만, 안쪽에서는 C로 작성된 libuv가 무거운 일을 처리하고 있는 겁니다.

정말 그런지 소스코드에서 확인해봤습니다

개념만 이해하고 넘어가면 면접에서 꼬리 질문에 막힐 것 같았습니다. 그래서 Node.js GitHub 저장소를 직접 열어서 JS 코드 한 줄이 OS까지 도달하는 과정을 추적해봤습니다.

추적 대상으로 os.hostname()을 골랐습니다. 이 함수는 컴퓨터의 호스트 이름을 반환하는 단순한 함수인데, 인자도 없고, 콜백도 없고, JS → C++ → C → OS 시스템 콜이 1:1:1:1로 깔끔하게 매핑되기 때문입니다.

Layer 1: JavaScript (lib/os.js)

Node.js에서 os.hostname()을 호출하면, 가장 먼저 실행되는 건 lib/os.js 파일입니다.

// lib/os.js (Node.js GitHub 소스)
const { getHostname: _getHostname } = internalBinding('os');

const getHostname = getCheckedFunction(_getHostname);

module.exports = {
  hostname: getHostname,
};

📎 lib/os.js#L51 — internalBinding('os')에서 getHostname을 가져오는 부분
📎 lib/os.js#L319 — module.exports에서 hostname을 내보내는 부분

여기서 핵심은 internalBinding('os')입니다. 이건 Node.js 내부에서 C++로 작성된 네이티브 모듈을 가져오는 함수입니다. JavaScript 세계와 C++ 세계를 연결하는 다리 역할을 합니다.

_getHostname은 JavaScript 함수처럼 보이지만, 실제로는 C++ 함수에 대한 참조입니다.

Layer 2: C++ 바인딩 (src/node_os.cc)

internalBinding('os')가 연결하는 C++ 코드는 src/node_os.cc에 있습니다.

// src/node_os.cc (Node.js GitHub 소스)
static void GetHostname(const FunctionCallbackInfo<Value>& args) {
  Environment* env = Environment::GetCurrent(args);
  char buf[UV_MAXHOSTNAMESIZE];
  size_t size = sizeof(buf);
  int r = uv_os_gethostname(buf, &size);  // ← libuv 호출!

  if (r != 0) {
    // 에러 정보를 JS 쪽 ctx 객체에 채우고 return
    USE(env->CollectUVExceptionInfo(args[args.Length() - 1], r, "uv_os_gethostname"));
    return;
  }

  // C 문자열을 V8 String 객체로 변환하여 JS에 반환
  args.GetReturnValue().Set(String::NewFromUtf8(env->isolate(), buf).ToLocalChecked());
}

📎 src/node_os.cc#L63 — GetHostname 함수 정의

C++을 몰라도 흐름은 읽을 수 있습니다. 버퍼를 하나 만들고, uv_os_gethostname()이라는 libuv 함수를 호출하고, 그 결과를 V8 문자열로 변환해서 JavaScript에 돌려줍니다.

여기서 uv_로 시작하는 함수가 libuv의 함수입니다. C++ 바인딩 레이어는 JavaScript와 libuv 사이에서 데이터를 변환해주는 중간 다리 역할을 합니다.

Layer 3: libuv (deps/uv/src/unix/core.c)

마지막으로 libuv 내부입니다. Node.js 저장소의 deps/uv/ 디렉토리에 libuv 소스가 포함되어 있습니다.

// deps/uv/src/unix/core.c (libuv 소스)
int uv_os_gethostname(char* buffer, size_t* size) {
  char buf[UV_MAXHOSTNAMESIZE];

  if (buffer == NULL || size == NULL || *size == 0)
    return UV_EINVAL;

  if (gethostname(buf, sizeof(buf)) != 0)  // ← POSIX 시스템 콜!
    return UV__ERR(errno);

  buf[sizeof(buf) - 1] = '\0';
  size_t len = strlen(buf);

  if (len >= *size) {
    *size = len + 1;
    return UV_ENOBUFS;
  }

  memcpy(buffer, buf, len + 1);
  *size = len;
  return 0;
}

📎 deps/uv/src/unix/core.c#L1558 — uv_os_gethostname 함수 정의

최종적으로 gethostname()이라는 POSIX 시스템 콜을 호출합니다. 이건 운영체제가 제공하는 함수로, macOS든 Linux든 호스트 이름을 돌려줍니다. libuv가 크로스 플랫폼을 지원한다는 건, 이런 OS별 차이를 libuv가 내부적으로 처리해준다는 뜻입니다.

전체 흐름 정리

os.hostname()                          JS (lib/os.js)
    ↓
GetHostname(args)                      C++ 바인딩 (src/node_os.cc)
    ↓
uv_os_gethostname(buf, &size)         libuv - C (deps/uv/src/unix/core.c)
    ↓
gethostname(buf, size)                 POSIX 시스템 콜
    ↓
"my-macbook.local"                     OS가 반환

JavaScript 함수 한 줄이 실행되면, Node.js 내부에서는 JS → C++ 바인딩 → libuv(C) → OS 시스템 콜이라는 네 단계를 거칩니다. 평소에 os.hostname()을 호출할 때 이 과정을 의식하지는 않지만, Node.js가 "JavaScript 런타임"이라는 말의 의미가 여기서 드러납니다. V8이 JS를 해석하고, libuv가 OS와 대화하고, 그 사이를 C++ 바인딩이 연결합니다.

논블로킹 I/O가 중요한 이유

Node.js의 또 다른 핵심 특성은 논블로킹 I/O입니다. 이건 위에서 본 libuv의 역할과 직결됩니다.

// 블로킹 — 파일을 다 읽을 때까지 멈춤
const data = fs.readFileSync('/file.txt');
console.log(data);
console.log('이건 파일 읽기가 끝나야 실행됩니다');
// 논블로킹 — 파일 읽기를 libuv에 맡기고 바로 다음 줄 실행
fs.readFile('/file.txt', (err, data) => {
  console.log(data);
});
console.log('이게 먼저 실행됩니다');

블로킹 방식은 파일을 읽는 동안 아무것도 할 수 없습니다. 반면 논블로킹 방식은 파일 읽기를 libuv에게 넘기고, 메인 스레드는 바로 다음 코드를 실행합니다. 파일 읽기가 끝나면 libuv가 "다 됐어"라고 알려주고, 그때 콜백이 실행됩니다.

이게 왜 중요하냐면, Node.js는 싱글 스레드이기 때문입니다. 메인 스레드가 하나뿐인데, 파일 하나 읽겠다고 그 스레드를 붙잡아 두면 그동안 다른 요청을 전혀 처리하지 못합니다. 논블로킹 I/O 덕분에 하나의 스레드가 여러 I/O 작업을 동시에 처리하는 것처럼 동작할 수 있습니다.

싱글 스레드에서 이벤트 루프가 어떻게 동시성을 만들어내는지는 다음 편에서 자세히 다루겠습니다.

정리하면

면접 준비를 하면서 "Node.js가 뭔가요?"라는 질문을 다시 마주했고, 이번에는 소스코드까지 열어서 확인해봤습니다.

  • 런타임은 코드를 실행할 수 있는 환경입니다. Node.js는 브라우저 밖에서 JavaScript를 실행할 수 있게 만든 런타임입니다.
  • V8 엔진은 JavaScript를 기계어로 번역하는 엔진이고, 브라우저와 Node.js 양쪽에 들어있는 공통 부품입니다.
  • libuv는 파일, 네트워크, OS 관련 작업을 실제로 처리하는 C 라이브러리입니다.
  • os.hostname() 한 줄이 실행될 때, JS → C++ 바인딩 → libuv → OS 시스템 콜이라는 네 단계를 거칩니다.
  • 논블로킹 I/O는 I/O 작업을 libuv에 맡기고 메인 스레드가 멈추지 않는 방식으로, 싱글 스레드인 Node.js가 높은 동시성을 가질 수 있는 기반입니다.

돌이켜보면, NestJS로 서버를 만들면서 fs나 os 모듈을 당연하게 써왔는데, 그 한 줄이 내부에서 네 개의 레이어를 거친다는 걸 이번에 처음 제대로 인식했습니다. 다음 편에서는 Node.js가 싱글 스레드임에도 동시 요청을 처리할 수 있는 핵심 메커니즘, 이벤트 루프를 다루겠습니다.

Node.jsV8libuv런타임JavaScript면접 준비

관련 글

Node.js는 싱글 스레드인데 어떻게 동시에 처리할까 — 콜 스택과 이벤트 루프 (3편)

1편에서 Node.js의 내부 구조를, 2편에서 프로세스와 스레드의 기본 개념을 확인했습니다. 이번에는 "싱글 스레드인데 어떻게 동시 처리가 가능한가"라는 질문에 답하기 위해, 콜 스택과 이벤트 루프의 관계, libuv가 작업을 처리하는 두 가지 방식, 그리고 이벤트 루프 6개 페이즈의 실체를 소스코드로 확인해봤습니다.

관련도 93%

프로세스, 스레드, 메모리 — Node.js를 이해하기 위한 OS 기초 (2편)

Node.js의 libuv 스레드 풀을 이해하려면 OS 기초가 필요합니다. 프로세스와 스레드의 차이, CPU 코어와 소프트웨어 스레드의 관계, 메모리 동적 할당까지 — Node.js 동시성의 전제 지식을 정리합니다.

관련도 92%

Node.js만 있는 게 아니다 — Bun과 Deno, 같은 언어 다른 런타임

JavaScript를 실행하는 런타임은 Node.js만이 아닙니다. Bun과 Deno는 같은 언어를 다른 방식으로 실행합니다. 세 런타임의 설계 철학, 내부 구조, 실용적 차이를 정리합니다.

관련도 91%