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) 이 형성되며,각 모듈의 내부 변수나 헬퍼 함수는 외부에서 직접 접근할 수 없다.
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.ccasync_wrap.cc
AsyncLocalStorage
AsyncLocalStorage 생성자에서 _enable()을 거쳐 storageHook가 실행된다. 여기서 새로 생성된 비동기 리소스에 부모 리소스가 가진 store를 복사하는 함수(_propage)를 init 에 등록한다. store는 심볼로 정의된, 리소스에 종속된 프로퍼티로 관리한다.
Node.js에 내장된 테스트 프레임워크인 test_runner가 있다. test_runner 의 이슈도 찾아보고, 스터디하며 여러 기능을 써보다가, 타이머 기능을 목킹하는 mock timer 가 setInterval 함수의 콜백 내부에서 timer를 clear 한 경우에 제대로 동작하지 않는 점을 확인했다. clearTimer 함수 내부에 interval 을 undefined로 세팅하는 코드와 테스트 코드를 추가하였고, 별다른 논의 없이 머지가 되었다. 그리고 그동안 별 생각 없이 다른 사람들의 PR을 보고 lib: XXX 으로 커밋 메시지를 작성했는데, 특정 모듈에 관련된 경우에는 모듈명을 작성해야 한다고 콜라보레이터가 리뷰를 달아주어서 알게 되었다!
TransfromStream의 tranfer 시 멤버(Readable/Writable Stream) 중복 체크
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(SEA)을 살펴보았다. SEA는 js파일과 Node.js를 바이너리파일로 배포함으로써 Node.js 를 설치하지 않고도 실행가능하고, 보안 측면에서 유리하다. 현재 지원되는 옵션 중 snapshot이 있다. snapshot은 애플리케이션을 실행하기 전에 미리 Node.js 런타임 상태(메모리, 모듈 로딩 상태, 초기화 결과 등)를 저장해 둔 이미지로서, 이걸 포함해서 빌드하면 초기 실행속도가 빠르고, 고정된 동작을 보장할 수 있다. 코드를 살펴보다 argv를 런타임에 제공되는 것 이외에 JSON 또는 프로그래마틱한 방식으로 configurable 하게 해보자는 TODO 코멘트를 발견하여, 기존의 execArgv (Node.js-specific 아규먼트)를 참고하여 구현해보았다. 테스트 코드를 작성하여 PR을 올렸는데 컨플릭이 나서 확인해보니 아규먼트 전달 경로(env, runtime cli 등) 관련한 커밋이 추가로 머지되어, 내 작업에 영향이 있을 수 있을 것 같다.
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 를 다룬다.
주요 기능
Node.js가 초기화될 때, C++ 계층에서 V8에 JS 함수 이름과 C++ 함수 포인터를 바인딩한다. 예를 들어 fs.readFile() JS 함수는 실제로 C++의 FSReqWrap::ReadFile()을 가리킨다.
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.ccnode_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로 돌려준다.