서론

Node.js 는 V8과 libuv 위에 얹힌 C++ 런타임이지만 레포를 보면 JS 파일(lib/*.js, lib/internal/*)도 가지고 있다.
이 JS 코드들은 Node의 모듈- JS 코드 간 인터페이스 역할을 하는데, 유저 코드와 동일한 환경에서 함께 실행되기 때문에 악의 또는 실수에 의해 오염될 가능성도 있다! Node.js가 JS 객체를 보호하기 위한 방법을 알아보았다.

worker_threads.js. process._eval을 덮어쓴다면..?


1. Symbol로 내부 상태 은닉

  • Node 내부 객체는 Symbol을 이용해 비공개 상태를 저장한다.
  • Symbol 키는 열거 불가능하고 충돌 위험이 없다.

_http_client.js

2. 클로저로 모듈 스코프 격리

  • Node.js는 각 모듈을 로드할 때, 해당 코드를 자동으로 함수 스코프로 감싸 실행한다.
  • 이 함수는 즉시 실행되어 모듈의 내용을 독립된 클로저(closure) 로 감싼다.덕분에 파일 단위로 독립된 렉시컬 환경(lexical environment) 이 형성되며,각 모듈의 내부 변수나 헬퍼 함수는 외부에서 직접 접근할 수 없다.

출처: https://nodejs.org/api/modules.html#the-module-wrapper

It keeps top-level variables (defined with var, const, or let) scoped to the module rather than the global object.

 

require('XXX')
 └─ Module.require()
      └─ Module._load()
           └─ Module._extensions['.js'](module, filename)

                 └─Module._compile

                        └─ wrapSafe

                             └─Module.wrap

loader.js

3. Primordials로 원본 내장 객체 보존

  • Node는 부팅 시 Array.prototype, Object, Promise 등
    원본 내장 객체의 복사본을 따로 저장한다.(lib/internal/per_context/primordials.js)
  • 이후 core 모듈들은 이 복사본을 직접 사용한다.
  • 덕분에 유저가 Array.prototype.push를 덮어써도Node 내부는 영향을 받지 않는다.

primordials.js 정의들
this 없이 함수를 바인딩

서론

Node.js 는 여러 요청을 순차적으로 처리하지 않는다. 작업이 완료되면 등록해둔 콜백을 실행하는 방식이기 때문에, 기본적으로는 각 단위의 작업들 간 컨텍스트가 유지되지 않는다!

그런데 웹 서버를 구현할 때, 만든 값(요청 ID, 사용자 정보 등)을 그 뒤에 이어지는 모든 비동기 작업에서 꺼내 쓰려면 컨텍스트가 필요하다. 모니터링 툴은 트레이스에서 DB요청, 서비스 함수 실행등을 요청별로 묶어 보여준다. 이러한 기능은 어떻게 구현될 수 있는 것인지 알아보았다.


AsyncHooks 와 AsyncLocalStorage 

개요

  • AsyncLocalStorage는 AsyncHooks 을 이용하여 작업 간 공유되는 데이터를 관리한다
  • AsyncHooks는 비동기 리소스의 생명주기를 추적하여 사용자가 각 주기에 커스텀 처리를 가능하게 함.
    • (hook: 기존 실행 루틴에 내 코드를 걸어서 끼워 넣는다는 의미)

AsyncHooks

1. 훅 등록하기

async_hooks.js의 createHook()과 enable()을 호출하면, 아래 흐름을 거쳐 C++ 런타임(Environment)에 훅 실행용 트램펄린 함수가 등록된다. 훅에 등록한 함수들이 트램펄린 함수를 거치도록 해서 컨텍스트 복원·에러 격리·성능 최적화를 중앙집중식으로 처리하는 목적이다. (트램펄린에 한번 튕겨서 호출한다는 의미)

 

2. 훅 실행

예를 들어 net.js의 서버 객체를 생성하여 사용하면 TCPWrap 객체가 생성되는데 (이 과정은 포스트에 정리해두었다), 이 때 부모 클래스인 AsyncWrap 가 TCPWrap 객체의 동작에 따라 해당하는 주기에 등록해둔 함수를 호출한다.

 

new TCPWrap()

      AsyncWrap::AsyncWrap

           └─ AsyncWrap::AsyncReset

                  └─ AsyncWrap::EmitAsyncInit

async_wrap.cc. 비동기 작업이 시작되면(생성자) init에 등록한 훅을 호출한다.

 

TCPWrap::Listen

         └─ OnConnection

                 └─ MakeCallback

tcp_wrap.cc. libuv의 uv_listen함수에 OnConnection 함수를 콜백으로 넘긴다.
connection_wrap.cc
async_wrap.cc

AsyncLocalStorage

AsyncLocalStorage 생성자에서 _enable()을 거쳐 storageHook가 실행된다. 여기서 새로 생성된 비동기 리소스에 부모 리소스가 가진 store를 복사하는 함수(_propage)를 init 에 등록한다. store는 심볼로 정의된, 리소스에 종속된 프로퍼티로 관리한다.

async_hook.js
async_hooks.js


활용 사례

한계

  • context가 동기 경계에서만 유지된다는 점 (예: 다른 프로세스로의 IPC, Worker Threads 간에는 단절)
  • 내부적으로 Map을 기반으로 store를 관리하므로 메모리 누수 가능성

 


출처

Node.js Docs 와 소스코드

V8의 기능을 활용하여 Node.js 위에서 실행되는 애플리케이션의 프로파일을 얻을 수 있다.

 

구조

[DevTools / node inspect CLI]
                 │  (WebSocket, JSON-RPC)
                ▼
 ┌───────────────┐
  │ node::inspector::Agent                    │  ← 디버거 서버 (포트 9229)
  │   └─ NodeInspectorClient             │  ← V8 브리지
  │        └─ v8_inspector API              │  ← V8 디버거 엔진
 └───────────────┘
                │
               ▼
    [JS 코드 실행부]

동작

타겟 애플리케이션을 --inspect 옵션으로 실행하거나 실행 중 USR1 신호를 보내면

inspect agent 가 실행되어 9229 포트로 디버거 서버를 띄운다.

 

테스트 방법

  1. 타겟 애플리케이션 실행: node를 --inspect 옵션으로 실행하거나 실행 중 USR1 신호를 보냄
    • kill -USR1 {pid}
  2. inspector 프로세스 실행: pid 나 host:port 를 명시하여 타겟에 attach
    • node inspect [--pid {process id} | 192.168.0.10:9229 ]
  3. inspector 프로세스에서 profile, profileEnd 커맨드를 순차 실행 후 profiles 로 결과 파일 확인 및 저장
    • profiles[{인덱스}].save('{경로}') 

 

결과 파일 읽기

  • 전체 구조
{
  "nodes": [ ... ],      // 함수 호출 트리 (각 노드: 함수 하나)
  "samples": [ ... ],    // 특정 시점에 실행 중이던 node.id
  "startTime": 1093425539501,
  "endTime": 1093450780167
}
  • nodes 구조 (예시)
{
  "id": 34,
  "callFrame": {
    "functionName": "(익명함수)",
    "scriptId": "95",
    "url": "file:///Users/jihyechoi/Documents/testcode/my-server.js",
    "lineNumber": 13,
    "columnNumber": 33
  },
  "hitCount": 697,
  "children": [35, 38],
  "positionTicks": [
    {"line": 27, "ticks": 2},
    {"line": 26, "ticks": 695}
  ]
}
  • functionName 종류
(idle) 이벤트 루프가 놀고 있을 때 (대기 시간)
(program) 초기 런타임 세팅
(garbage collector) GC 중 사용된 CPU
processTicksAndRejections 이벤트 루프의 마이크로태스크 처리
parserOnHeadersComplete HTTP 요청 파싱 완료 콜백
onconnection / Socket TCP 연결 수락 및 스트림 설정
emit Node.js EventEmitter
end, _send, _writeRaw 응답을 네트워크로 전송 중
Readable.read, Writable.write 스트림 I/O 동작
  • samples: 샘플링 타이머가 매번 실행 중인 함수의 id를 기록한 것. 많이 등장한 id 의 함수가 CPU를 많이 점유했다는 뜻이다

마스터스 과정 시작 이후 도전했던 커밋과 과정을 정리해보려고 한다.

 

test_runner의 mock timer 버그

Node.js에 내장된 테스트 프레임워크인 test_runner가 있다. test_runner 의 이슈도 찾아보고, 스터디하며 여러 기능을 써보다가, 타이머 기능을 목킹하는 mock timer 가 setInterval 함수의 콜백 내부에서 timer를 clear 한 경우에 제대로 동작하지 않는 점을 확인했다. clearTimer 함수 내부에 interval 을 undefined로 세팅하는 코드와 테스트 코드를 추가하였고, 별다른 논의 없이 머지가 되었다. 그리고 그동안 별 생각 없이 다른 사람들의 PR을 보고 lib: XXX 으로 커밋 메시지를 작성했는데, 특정 모듈에 관련된 경우에는 모듈명을 작성해야 한다고 콜라보레이터가 리뷰를 달아주어서 알게 되었다!

 

TransfromStream의 tranfer 시 멤버(Readable/Writable Stream) 중복 체크

  • 상태: Change requested 후 방향 고민 (링크)

Node.js는 서버 뿐 아니라 웹 호환성 지원 범위를 늘려가고 있다. test/wpt(Web Platform Test)에 Whatwg 명세에 따른 테스트파일과 그 중 expected fail인 목록을 정리한 json파일이 있다. 이 중 TransformStream을 다른 프로세스 또는 스레드 간 메시징을 통해 전송할때, 그 멤버인 Readable 또는 Writable stream을 함께 전송하려고 시도하면 (Transform을 전송할때 이미 그 멤버는 전송이 되기 때문에, 중복으로 인하여) DataCloneError을 던져야 한다는 테스트가 있었고, 이 테스트를 Pass하도록 수정하는 것에 도전해보았다. 처음에는 테스트 코드를 pass하는 것을 목적으로 structuredClone 함수에 TranformStream 여부와 멤버 여부를 확인하는 로직을 추가했다. 그런데 Web Platform 쪽에서 활발히 활동중인 콜라보레이터가 Change Request를 하며 이유를 자세히 설명해주었다. 요약하자면 스펙을 따르지 않는다는 이유였다. 스펙에 따르면 structuredClone 함수는 edge case를 처리하는 로직을 갖지 않는다는 것이다.

 

리뷰에 달린 WebStreams와 sturcuredClone 관련 스펙 문서를 여러 번 읽고, 기존 코드와 스펙을 비교하고, 코드를 이렇게 저렇게 고치고 테스트하며 일주일 정도를 투자했는데, 이 테스트가 통과되면 다른 테스트가 실패하는 등 난관에 봉착하여, 아직 갈피를 못잡은 상태이다. 리뷰에는 현재 Node.js의 TransformStream의 전송 구현이 불완전하다고 하기도 했고, 스펙에 멤버 스트림이 전송되었다는 여부를 처리하는 부분은 structuredSerializeWithTransfer인데, 이부분을 Node.js에서 찾을 수가 없어서, 혼란스러웠고 다소 복잡한 주제라고 느껴졌다. 그래서 우선 다른 주제를 찾아보기로 하였다.

 

 

Single Executable Application 모드의 argv 설정파일 지원

안정된 기능을 스터디하는 것 이외에, 활발히 개발 중인 기능에도 참여해보고 싶어서 Single Executable Application(SEA)을 살펴보았다. SEA는 js파일과 Node.js를 바이너리파일로 배포함으로써 Node.js 를 설치하지 않고도 실행가능하고, 보안 측면에서 유리하다. 현재 지원되는 옵션 중 snapshot이 있다. snapshot은 애플리케이션을 실행하기 전에 미리 Node.js 런타임 상태(메모리, 모듈 로딩 상태, 초기화 결과 등)를 저장해 둔 이미지로서, 이걸 포함해서 빌드하면 초기 실행속도가 빠르고, 고정된 동작을 보장할 수 있다. 코드를 살펴보다 argv를 런타임에 제공되는 것 이외에 JSON 또는 프로그래마틱한 방식으로 configurable 하게 해보자는 TODO 코멘트를 발견하여, 기존의 execArgv (Node.js-specific 아규먼트)를 참고하여 구현해보았다. 테스트 코드를 작성하여 PR을 올렸는데 컨플릭이 나서 확인해보니 아규먼트 전달 경로(env, runtime cli 등) 관련한 커밋이 추가로 머지되어, 내 작업에 영향이 있을 수 있을 것 같다.

 

test.py결과 all tests passed의 모호함 개선

PR을 올리기 전에 lint 와 함께 테스트를 돌려보는데, 너무 오래걸려서 ctrl + c 로 실행취소를 했는데도 all tests passed가 출력되어 test.py 를 살펴보니 fail 이 없으면 해당 문자열이 출력되는 것을 확인했다. 출력 조건에 keyboard interrupted 되지 않음을 조건에 추가하였고, 많은 어프루브와 함께 빠르게 머지되었다.

배경

우리 회사에서는 AWS를 사용하여 서비스를 운영하고 있다. API 서버는 Node.js를 플랫폼으로 하여 ElasticBeanstalk 을 사용하고 있는데, 2025년 7월에 현재 사용중인 Node.js 18 버전이 지원 종료되어, 22 버전으로 업그레이드하였다.

요약

운영 중인 서비스에 영향 없이 버전을 업그레이드해야 했다. Route53을 활용하여 RDS 의 블루/그린 배포와 유사한 방법으로 진행할 수 있었다.

작업 순서

1. .nvmrc와 관련 의존성 업그레이드를 위한 package.json과 package-lock.json 수정

2. Elastic Beanstalk - 해당 애플리케이션에서 Node.js v22 플랫폼의 환경 신규 생성

3. 서버와 연결된 데이터베이스 보안그룹의 인바운드 규칙에 신규 환경의 EC2 보안그룹 추가

4. 변경사항 배포

   4-1. 이 단계 이전에 CodePipeline 배포 단계의 타겟 환경을 신규 환경으로 변경하였다.

5. 신규 환경의 헬스체크, API 호출 테스트

6. Route53 에서 서버 도메인의 환경을 신규 환경으로 변경

7. 모니터링

   7-1. 서비스 접속하여 동작을 확인 및 기존 인스턴스 액세스 로그가 더 이상 없는 것을 확인했다.

8. 기존 환경 제거(Terminate)

기타 사항, 회고

환경의 구성(Configuration) 중 인스턴스 타입(예: t3micro) 을 실수로 기존 환경과 다르게 생성하여, 6번 작업 이후에 추가로 수정 하는 과정에서 또한 서버 다운타임이 발생하지 않도록 구성 Update, Monitoring and logging 하위의 배포 전략을 RollingWithAdditionalBatch로 설정하였다.(이 때, Batch 크기는 Instance Traffic and Scaling 하위의 Capacity 의 최소 인스턴스 보다 작거나 같아야 한다.) 이렇게 설정하면 인스턴스가 추가로 생성된 후 업데이트되고, 기존 인스턴스가 제거되어 다운타임 없이 인스턴스 타입 업데이트가 가능하며, 이 과정은 Elastic Beankstalk 콘솔의 Event 탭에서 확인할 수 있다.

 

간단히 요약하면..

구성 요소

V8은 ECMAScript 명세를 구현한 JavaScript 실행 엔진으로, 자체적으로 힙(heap), 스택(stack), 실행 컨텍스트(Execution Context) 구조를 가진다. 즉, V8만으로도 JavaScript 언어 자체 기능(변수, 함수, 클래스, 프로미스 등) 은 실행할 수 있다.

Node.js는 V8을 임베드(embed) 하면서 파일 시스템, 타이머, 네트워크 요청 등 V8에는 없는 기능을 C++로 구현한 API를 JS 환경에 노출한다. 또한 이러한 API들은 비동기 처리를 위해 libuv를 사용하며, libuv는 OS의 비동기 I/O 를 다룬다.

주요 기능

  1. Node.js가 초기화될 때, C++ 계층에서 V8에 JS 함수 이름과 C++ 함수 포인터를 바인딩한다. 예를 들어 fs.readFile() JS 함수는 실제로 C++의 FSReqWrap::ReadFile()을 가리킨다.
  2. JS 코드에서 함수를 호출하면, Node는 이 객체를 감싸는(wrap) C++ 객체(*)(예: TCPWrap) 를 생성하여 이 내부에 libuv가 제공하는 구조체(uv_req_t 또는 uv_handle_t) 포함시킨다(이 구조체의 내부 data 필드에 다시 Wrap을 할당하여 서로 참조 가능하다). 이 객체(*)는 JS 콜백에 대한 포인터도 가지고 있어, libuv가 I/O 완료 시 C++ 계층에서 V8에 해당 콜백을 실행할 수있도록 요청을 보낸다.

흐름 제어 

V8 바인딩을 설정하는 등의 초기화 이후, 노드의 메인 스레드는 이벤트루프 로직을 반복한다(libuv의 uv_run (...) { while(..) {...} })

uv_run은 while문을 반복하며 poll. check, timer 등의 phase를 실행하고, 각 phase는 해당하는 큐가 비어있지 않는동안 그 안의 로직을 반복한다.

종류

노드 인스턴스의 종류를 메인 인스턴스와 워커 인스턴스(스레드풀과는 다르다)로 나눌 수 있는데, 각각의 V8 isolate와 이벤트루프를 갖는다.


코드로 살펴보기!

node app.js 와 같은 명령어를 통해 Node 로 JS 파일을 실행했을 때 Node.js 내부에서는 어떤 것들이 실행되는지, main함수부터 따라가 보았다. SEA(단독으로 실행가능한 애플리케이션) 모드가 아닌 표준 모드로, unix 기준으로 살펴보았다.

src/node_main.cc

1. V8 바인딩 설정

main()

   └─ node::Start()

       └─ StartInternal()

           └─ InitializeOncePerProcessInternal()

               └─ InitializeNodeWithArgsInternal()

                   └─ binding::RegisterBuiltinBindings()

           └─ ...

 

main 함수에서 시작해서 위와 같은 과정을 통해 ResiterBuiltinBindings 함수가 실행되는데, 이 함수는 X-Macro 패턴을 사용하여 그 다음의 코드를 자동생성한다.

node_binding.cc

void RegisterBuiltinBindings() {
  _register_fs();
  _register_tcp_wrap();
  _register_timer_wrap();
  // ... 기타
}

_register_XXX 함수들은 node.js 소스코드가 컴파일 되는 시점에 NODE_BINDING_CONTEXT_AWARE_INTERNAL 매크로가 호출한 NODE_BINDING_CONTEXT_AWARE_CPP 매크로에 의해 생성되어 있는 상태이다. 예를 들어, src에 tcp_wrap.cc가 포함된 상태로 node.js를 컴파일하면 TCP::Initialize 함수의 구현부로 _register_tcp_wrap 함수가 생성되고, 노드가 초기화될 때 이 함수가 실행되게 된다.

tcp_wrap.cc
node_binding.h

TCPWrap::Initialize함수는 JS에서 호출 가능한 TCP 관련 메서드들을 Node.js의 C++ 구현과 연결한다. 

이를 위해 V8의 FunctionTemplate을 사용하여 JS 함수의 설계도(블루프린트)를 만들고, 각 메서드 이름에 대응하는 C++ 콜백 함수를 등록한다. 이렇게 등록된 함수는 JS 코드에서 해당 메서드가 호출될 때 V8이 내부적으로 C++ 콜백을 실행하도록 동작한다.

tcp_wrap.cc

아래의 TCPWrap::Listen 함수는 TCPWrap 객체를 만든다음 libuv의 uv_listen() 을 호출해서 uv_tcp_t 핸들을 OS 이벤트루프에 “리스닝 소켓”으로 등록하고, 새 연결이 들어오면 OnConnection 콜백을 호출하도록 설정한 다음, 그 결과 코드를 JS로 돌려준다.

2. uv loop 설정 및 생성, V8 Isolate 생성

main()

   └─ node::Start()

       └─ StartInternal()

          └─ ... (1번)

           └─ NodeMainInstance()

node.cc
node_main_instance.cc

3. Environment (노드의 메인 객체) 생성

main()

   └─ node::Start()

       └─ StartInternal()

           └─ ... (1번)

           └─ NodeMainInstance()

                └─ ... (2번)

           └─ NodeMainInstance::Run()

               └─ CreateMainEnvironment()

                    └─ CreateEnvironment()

 

4. JS 엔트리 파일을 실행

main()

   └─ node::Start()

       └─ StartInternal()

           └─ ... (1번)

           └─ NodeMainInstance()

                └─ ... (2번)

           └─ NodeMainInstance::Run()

               └─ CreateMainEnvironment()

                    └─ ... (3번)

                   └─ LoadEnvironment()

                       └─ StartExecution()

                           └─ Realm::ExecuteBootstrapper("internal/main/run_main_module")

                               └─ BuiltinLoader::CompileAndCall()

                                    └─ Function::Call()

                                        └─ [lib/internal/main/run_main_module.js 실행]

                                            └─ Module.runMain(mainEntry)

                                                └─ executeUserEntryPoint()

main 함수에서 시작해서 위와 같은 과정을 통해 executeUserEntryPoint 함수가 실행된다. 이 때 아래 코드가 실행된다고 가정해보자.

// app.js
const net = require('net');

const server = net.createServer((socket) => {
  console.log('client connected');
  socket.write('hello from server!\n');
  socket.end();
});

server.listen(8080, () => {
  console.log('server listening on port 8080');
});

server.listen(...)이 실행되면 net.js의 해당 부분을 통해

src/lib/net.js

JS: server.listen()
 └─ Server.prototype.listen()
     └─ listenInCluster()                           
         └─ server._listen2() (= setupListenHandle)  
             └─ createServerHandle()                          
             └─ this._handle.bind(address, port)
             └─ this._handle.listen(backlog)

 

이러한 함수들을 지나서. 위에서 나온 TCPWrap::Listen 함수가 실행되게 된다.

5. libuv 이벤트루프로 들어간다. 

다음으로 아래의 함수를 따라 libuv의 uv_run이 node.js 프로세스에서 실행되어, I/O 작업과 그 콜백 작업의 실행 흐름을 제어한다. 

main()

   └─ node::Start()

       └─ StartInternal()

           └─ NodeMainInstance::Run()

               └─ ... (3번)

               └─ NodeMainInstance::Run(ExitCode*, Environment*)

                   └─ SpinEventLoopInternal()

                       └─ uv_run()

 

이 내용은 별도의 포스팅으로 다루겠다.

 

Node.js 딥다이브 (2) - libuv 의 I/O 멀티플렉싱 살펴보기

서론ibuv는 Node.js와 독립적인 비동기 I/O 추상화 계층이지만, Node.js의 이벤트 루프와 비동기 작업 흐름을 담당하므로 함께 정리하였다.I/O 멀티플렉싱이란..I/O 작업은 유저 스페이스에서 할 수 없

9436188.tistory.com

 


메모

V8 핸들

TCPWrap.cc 코드에 Local<XXX> 타입이 있다. 이것은 V8 핸들이다.

JS 객체(obj)는 V8 이 관리하는 힙에 존재하며 GC에 의해 이동하거나 해제할 수 있기 때문에 직접 포인터로 잡아두면 위험하다. 대신 V8이 “핸들 테이블(handle table)”에 객체를 등록해주고 그 인덱스를 통해 안전하게 접근하도록 하는 목적이다.

빌트인 스크립트

네이티브 빌트인 스크립트는 노드에 내장된 파일로, 노드 사용 시 접근할 수는 없으며 미리 컴파일 되어있어 내장로더로 실행시 초기화 성능을 최적화할 수 있다.

가장 처음에 app.js를 찾고 읽는 것을 C++레벨에서 직접 구현할 줄 알았는데, 이 스크립트를 실행하도록 하여 일관성과 유연성 측면에서 강점을 가진다고 한다.

 

+ Recent posts