프로그래밍/Node.js

【Node.js에 대한 간단한 고찰】 부제: Node의 메모리 관리 방법

라즐리 2026. 3. 15. 22:16

개요

JAVA, Python, Rust 모두 각각 메모리를 관리하는 방식이 참 다양하고 다릅니다.

이중에서 FrontEnd의 표준이 된 코드 발사대인 Node.js의 메모리 관리 방식에 대해서 알아볼까 합니다.

 

Node.js의 관리 Layer

Node.js는 근본적으로 Chrome을 동작시키는 V8 Engine을 기반으로 동작합니다.

V8 Engine이 브라우저 프로그램에서 동작된다면 여기서 JS를 동작시키는 엔진을 똑 떼서 가져와 CLI기반의 JS코드를 동작시킬 수 있도록 만든 것이 바로 Node.js입니다.

https://nodejs.org/en/learn/getting-started/the-v8-javascript-engine

 

Node.js — The V8 JavaScript Engine

Node.js® is a free, open-source, cross-platform JavaScript runtime environment that lets developers create servers, web apps, command line tools and scripts.

nodejs.org

즉, Node.js는 근본적으로 V8엔진을 이용해 JS코드의 파싱을 하고 동작을 시키는 일종의 Wrapper역할을 한다고도 볼 수 있습니다.

Node.js는 JS를 실행하고 메모리를 관리하는 V8과, 이벤트 루프·I/O·OS 정보 조회를 담당하는 libuv를 묶은 런타임인 겁니다.

 

A라는 동작을 했을 때, JS 코드 실행은 V8이, 파일·네트워크 같은 시스템 작업은 libuv가 맡아서 처리하는 형태인 겁니다.

메모리는 어디서 관리할까?

그렇다면 메모리는 어느 Layer에서 관리를 할까요?

V8? Node?

정답은 V8 Engine과 Node 모두 합니다.

엥? 그럼 둘이 뭘 나눠서 하는데?

 

역할을 나눠보면 V8이 관리하고, Node는 한도를 정해줍니다.

 

힙 할당과 GC는 모두 V8 안에서 일어납니다. V8의 GC(Orinoco)는 새로 만든 객체가 모이는 Young 영역은 Scavenger로, 오래 살아남은 객체가 있는 Old 영역은 Mark-Compact로 정리합니다.

 

그렇다면 Node는 뭘 할까요? "이 프로세스가 쓸 수 있는 메모리가 얼마인가"를 V8에게 알려줍니다.

Node는 V8 Isolate를 만들 때 libuv의 uv_get_constrained_memory()로 cgroup 메모리 한도를 읽고, 이를 기준으로 V8의 기본 힙 상한을 설정합니다.

 

V8은 그 상한에 가까워질수록 GC를 더 적극적으로 돌립니다.

 

자 그러면 이제 이 v8 레포에 있는 파일을 한번 뜯어봅시다.

https://github.com/v8/v8/blob/14.8.30/src/base/platform/platform-linux.cc

 

v8/src/base/platform/platform-linux.cc at 14.8.30 · v8/v8

The official mirror of the V8 Git repository. Contribute to v8/v8 development by creating an account on GitHub.

github.com

 

// 55ac243aa000-55ac243ac000 r--p 00000000 fe:01 31594735 /usr/bin/foo
if (!ReadHex(&entry.start, &delim)) return std::nullopt;
if (!ReadHex(&entry.end, &delim)) return std::nullopt;
for (int i = 0; i < 4; ++i) {
    if (!ReadChar(&entry.raw_permissions[i])) return std::nullopt;
}

 

int fd = open(enclosing_region.pathname, O_RDONLY);
struct stat stat_buf;
if (fstat(fd, &stat_buf)) { /* 에러 처리 */ }

if (stat_buf.st_dev != enclosing_region.dev ||
    stat_buf.st_ino != enclosing_region.inode) {
    close(fd);
    return false;
}

void* mapped_address = mmap(new_address, size, protection,
                            MAP_FIXED | MAP_PRIVATE, fd, offset_in_file);

 

보이는 바와 같이 공통적으로 실제 메모리에 접근해서 동작을 하는 것을 볼 수 있습니다.

즉 System과 강결합된 코드들이 많은 모습이죠

 

그에 반면에 이번엔 해당 파일을 한번 훑어보죠

https://github.com/nodejs/node/blob/2ea7a7b67c3d698a0e80d2591365e2dc5c5f7a81/deps/uv/src/unix/linux.c#L2130

 

node/deps/uv/src/unix/linux.c at 2ea7a7b67c3d698a0e80d2591365e2dc5c5f7a81 · nodejs/node

Node.js JavaScript runtime ✨🐢🚀✨. Contribute to nodejs/node development by creating an account on GitHub.

github.com

코드가 길어서 특정 줄만 링크를 가져왔긴합니다.

 

보이는 것과 동일하게 여기서는 Cgroup을 v1과 v2로 분기처리해서 호출하는 모습이 포착됩니다.

static void uv__get_cgroup2_memory_limits(char buf[static 1024], uint64_t* high,
                                          uint64_t* max) {
  char filename[4097];
  char* p;
  int n;

  /* Find out where the controller is mounted. */
  p = buf + strlen("0::/");
  n = (int) strcspn(p, "\n");

  /* Read the memory limits of the controller. */
  snprintf(filename, sizeof(filename), "/sys/fs/cgroup/%.*s/memory.max", n, p);
  *max = uv__read_uint64(filename);
  snprintf(filename, sizeof(filename), "/sys/fs/cgroup/%.*s/memory.high", n, p);
  *high = uv__read_uint64(filename);
}
static void uv__get_cgroup1_memory_limits(char buf[static 1024], uint64_t* high,
                                          uint64_t* max) {
  char filename[4097];
  char* p;
  int n;
  uint64_t cgroup1_max;

  /* Find out where the controller is mounted. */
  p = uv__cgroup1_find_memory_controller(buf, &n);
  if (p != NULL) {
    snprintf(filename, sizeof(filename),
             "/sys/fs/cgroup/memory/%.*s/memory.soft_limit_in_bytes", n, p);
    *high = uv__read_uint64(filename);

    snprintf(filename, sizeof(filename),
             "/sys/fs/cgroup/memory/%.*s/memory.limit_in_bytes", n, p);
    *max = uv__read_uint64(filename);

    /* If the controller wasn't mounted, the reads above will have failed,
     * as indicated by uv__read_uint64 returning 0.
     */
     if (*high != 0 && *max != 0)
       goto update_limits;
  }

 

결론

다음과 같은 결론을 낼 수 있겠네요.

V8

메모리의 할당과 해제, 그리고 GC까지 V8 Engine에서 동작을 합니다.

왜 이걸 V8에서 할까?

V8엔진은 앞서 설명한 바와 같이 브라우저의 JS파싱을 하는 엔진이기 때문이죠.

결국 브라우저 레벨에서도 누군가는 메모리 관리를 해주어야 합니다.

Node

V8이 쓸 힙 상한을 정하기 위해 컨테이너 메모리 한도를 감지하는 것은 Node에서 동작을 합니다.

이건 왜 Node에서 할까?

Node는 V8 Engine을 래핑하고 그 위에서 JS가 동작가능하도록 만든 프로그램입니다.

즉 V8에서 메모리를 할당/파기/호출하는 것과 다르게 Node는 V8을 감싸서 실행 환경(프로세스, 컨테이너)과 연결해주는 레이어입니다. V8은 자기가 어떤 컨테이너 안에서 돌고 있는지 알지 못하기 때문에, cgroup 한도처럼 OS 환경에 대한 정보는 Node(libuv)가 읽어서 V8에게 넘겨주는 구조입니다.

 

이 글에서 DevOps가 얻을 수 있는 인사이트

K8S 1.25버전 부터는 공식적으로 cgroup v2를 지원합니다.

이에 대한 내용은 해당 사이트에서 더 깊게 볼 수 있으니 참고해보시면 좋습니다.

 

cgroups(7) - Linux manual page

cgroups(7) — Linux manual page cgroups(7) Miscellaneous Information Manual cgroups(7) NAME         top cgroups - Linux control groups DESCRIPTION         top Control groups, usually referred to as cgroups, are a Linux kernel feature which allow p

man7.org

근데 여기서 문제가 발생합니다.

대부분 AWS의 EKS를 사용하는 사람들은 공감하겠지만 AWS에서는 Amazon Linux라는 것에 종속되기 참 쉽습니다.

 

이미 1.33 Cluster Upgrade를 해본 사람은 공감하겠지만 옛날부터 Amazon Linux 2를 쓰던 사람들은 필연적으로 EKS Cluster 1.33 업그레이드에서 Amazon Linux 2023으로 넘어가야하는 피눈물 나는 작업을 해보았을 거라 예상됩니다...ㅜ

 

기존 클러스터 업그레이드 작업도 기본적으로 무중단 Multi Cluster Blue/Green Update 환경을 만들어 두지 않는 이상 힘든 작업이었겠지만 이번 1.33은 더욱 크게 느껴질 것 같네요.

 

이유는?

기존에는 Cluster 업그레이드 딸깍 → Node AMI 교체 후 Rolling 형태로만 해도 충분히 동작 했을 겁니다.

하지만 이번엔 근간이 되는 AMI 자체를 엎어야하는 상황이 생겨버렸죠.

 

이유는? 아래 사진과 같습니다.

https://repost.aws/questions/QUNMXffcGsRbyyNef9DyEQKQ/amazon-linux-2-%EC%84%9C%ED%8F%AC%ED%8A%B8-%EC%A2%85%EB%A3%8C%EC%97%90-%EA%B4%80%ED%95%B4%EC%84%9C

 

AL 2가 불안불안하더니 결국 종료해버렸습니다 ㅋㅋ ㅜㅜㅜ

https://docs.aws.amazon.com/ko_kr/eks/latest/userguide/kubernetes-versions-standard.html#kubernetes-1-33

이젠 EKS에서도 Amazon Linux 2의 지원을 종료해버렸기에 이젠 AMI 교체가 필연적으로 이루어져야겠죠...

 

AL 2023에선 어떤 문제가 있을까요?

컨테이너의 자원을 관리하는 cgroup이 바로 위에서 언급한 cgroup v2로 업데이트 되었습니다.

결국 cgroup v1만 지원하던 구버전의 언어/프레임워크는 모두 업데이트를 강행해야만 한다는 의미 입니다.

 

여기선 Node.js의 메모리 관리만을 다루기 때문에 Node.js의 지원 버전을 말해볼까 합니다.

바로 20.3.0버전 부터 지원합니다.

 

https://www.reddit.com/r/node/comments/1c29sge/cgroups_v2_support_in_nodejs/?tl=ko

이러한 이슈가 있고, 

 

20.3.0 이전 버전의 코드를 보면 cgroup v1, v2에 대한 분기처리(함수)가 없었습니다.

https://github.com/nodejs/node/blob/e7618fb5a5fc25d76b6474e2a6607f04fd6f10e0/deps/uv/src/unix/linux-core.c#L796

 

20.3.0은?

https://github.com/nodejs/node/blob/57679e1c97a90057d5ca9e1f0d1f3c6255d20200/deps/uv/src/unix/linux.c#L1874

 

node/deps/uv/src/unix/linux.c at 57679e1c97a90057d5ca9e1f0d1f3c6255d20200 · nodejs/node

Node.js JavaScript runtime ✨🐢🚀✨. Contribute to nodejs/node development by creating an account on GitHub.

github.com

cgroup v1, v2에 대한 분기처리도 생기고, 기존에 linux-<역할>.c 형태의 파일들이 linux.c로 통합된 모습을 볼 수 있습니다.

 

즉, Amazon Linux 2023 AMI에서 20.3.0버전 이전 버전을 사용할 경우 cgroup v2를 지원하는 Amazon Linux 2023에서는 Node.js가 자신에게 할당된 Memory를 읽지 못하고 적재하다가 OOM이 펑펑 터질 수 있습니다 왜 그럴까요?

 

cgroup v2 한도를 못 읽는 Node는 컨테이너 limit이 아닌 호스트(노드) 전체 메모리를 기준으로 힙 상한을 잡습니다.

예를 들어 Pod limit은 1Gi인데 노드 메모리가 16GB라면, V8은 힙이 1Gi를 한참 넘어도 아직 여유가 있다고 판단해 GC를 서두르지 않습니다.

그 사이 컨테이너가 cgroup limit에 먼저 도달하고, 커널이 프로세스를 죽입니다...OTL....

JS 레벨의 heap out of memory 에러가 아니라 그냥 Pod가 죽는 형태로 나타나기 때문에 원인 찾기가 더 어렵습니다.

 

그럼 어떻게 해야할까요?

개발자와 같이 열심히 버전업 해야죠...ㅎㅎ

 

당장 버전업이 어렵다면 NODE_OPTIONS=--max-old-space-size=<MiB>로 힙 상한을 직접 지정하는 방법이 있긴 합니다.

보통 컨테이너 limit의 70~75% 정도로 잡는데, 힙 외에 버퍼나 네이티브 메모리도 쓰기 때문입니다.

 

하지만 근본원인을 제거하는게 맞겠죠 ㅎㅎ

마무리

Python은 결론만 이야기하면 CPython은 cgroup 한도를 보고 힙 상한을 정하는 구조가 아니라서 같은 유형의 문제는 상대적으로 덜 합니다 (와!)

 

그럼 JAVA는...? 있네요~!

하지만 따로 다루지는 않겠습니다. (넘 길어요...이해도도 떨어지고 말이죠)

https://younsl.github.io/blog/cgroup-v2/

 

cgroup v2

back2024-09-30 cgroup v2개요cgroup v1에서 cgroup v2로 전환할 경우 발생할 수 있는 문제를 정리한 페이지입니다.Amazon Linux 2023은 기본적으로 cgroup v2를 사용합니다. 따라서 Amazon Linux 2에서 Amazon Linux 2023으

younsl.github.io

대신 처음보는 블로그이지만 잘 정리해둔 블로그가 있어서 링크를 가져왔으니 참고해보면 좋을 것 같습니다~