Node.js 20에서 24로 — V8 Maglev가 채우는 빈칸과 기대하면 안 되는 것
Node.js 20은 2026년 4월 30일로 지원이 끝났습니다. 이 글을 쓰는 시점에 20은 이미 보안 패치를 받지 못하는 버전입니다. 올려야 한다는 것은 분명한데, 그 대가로 정확히 무엇을 얻는지는 의외로 정리된 자료가 적다는 생각이 들었습니다.
그래서 20에서 24로 갈 때 무엇이 달라지는지를 먼저 정리했습니다. 특히 V8 엔진 쪽에서 무엇을 기대할 수 있고 무엇은 기대하면 안 되는지에 초점을 뒀습니다. 아직 올리지는 않았고, 검토 단계에서 확인하고 측정한 내용입니다.
먼저 사실관계 — 20은 이미 끝났습니다
Node.js는 짝수 메이저만 LTS가 됩니다. 홀수 버전은 실험대이고, 짝수 버전이 다음 해 10월에 LTS로 승격되어 그때부터 30개월가량 지원을 받습니다. 지금 시점의 지원 상태는 이렇습니다.
| 버전 | 코드명 | 상태 (2026년 8월 기준) | 지원 종료 |
|---|---|---|---|
| 20 | Iron | 지원 종료됨 | 2026-04-30 |
| 22 | Jod | 유지보수 (보안 패치만) | 2027-04-30 |
| 24 | Krypton | 활성 LTS | 2028-04-30 |
여기서 22가 아니라 24를 고른 이유는 단순합니다. 22는 이미 유지보수 단계라 새 기능이 들어오지 않고, 지원 종료도 2027년 4월로 24보다 1년 이릅니다. 지금 올린다면 한 번 올려서 더 오래 쓰는 쪽이 합리적입니다.
"지원 종료"가 추상적으로 들린다면 이렇게 보면 됩니다. Node 20의 마지막 릴리스는 v20.20.2(2026-03-24)였고, 그 이후 배포된 보안 릴리스들에서 20.x는 대상에서 빠졌습니다. Node 자체 코드뿐 아니라 함께 묶여 나가는 openssl, llhttp, nghttp2 같은 번들 의존성도 그 시점에 함께 멈춥니다.
조용히 동결되는 컨테이너 태그
컨테이너로 배포한다면 한 가지 함정이 더 있습니다. node:20-alpine 같은 메이저 태그는 지원 종료 이후 갱신이 멈춘 채 그 자리에 남습니다. 태그가 사라지지 않기 때문에 빌드는 계속 성공하고, 재배포해도 아무 경고 없이 같은 이미지를 받습니다. 파이프라인은 초록불인데 밑바닥은 몇 달째 고정되어 있는 상태가 됩니다.
이건 눈으로 확인할 수 있습니다. 태그가 마지막으로 갱신된 시각을 보면 됩니다.
curl -s "https://hub.docker.com/v2/repositories/library/node/tags/20-alpine" \
| python3 -c "import sys,json; d=json.load(sys.stdin); print(d['name'], d['tag_last_pushed'])"
날짜가 몇 달 전에 멈춰 있다면, 그 이미지는 앞으로도 갱신되지 않습니다. 태그의 존재는 유지보수의 증거가 아니라는 생각이 들었습니다.
V8은 자바스크립트를 한 번에 컴파일하지 않습니다
성능 이야기를 하려면 엔진이 코드를 어떻게 실행하는지부터 봐야 합니다. 여기가 이 글에서 가장 오래 붙잡고 있던 부분입니다.
자바스크립트 엔진에는 근본적인 딜레마가 있습니다. 최적화된 기계어를 만들려면 시간이 걸리는데, 정작 그 코드가 몇 번이나 실행될지는 미리 알 수 없습니다. 딱 한 번 실행되고 마는 함수를 정성껏 최적화하면 컴파일에 쓴 시간이 통째로 손해입니다. 반대로 초당 수만 번 도는 루프를 인터프리터로만 돌리면 그것대로 손해입니다.
V8의 답은 계층(tier)을 나누는 것입니다. 처음에는 대충 빠르게 시작하고, 자주 실행되는 것으로 드러난 함수만 점점 더 정성껏 다시 컴파일합니다. 같은 함수가 실행 중에 여러 번, 점점 좋은 코드로 갈아끼워지는 셈입니다.
| 계층 | 역할 | 컴파일 속도 | 생성 코드 품질 |
|---|---|---|---|
| Ignition | 인터프리터. 바이트코드를 그대로 해석 | 즉시 | 기준점 |
| Sparkplug | 베이스라인 JIT. 최적화 없이 기계어로 옮김 | 매우 빠름 | 낮음 |
| Maglev | 중간 계층 최적화 JIT (SSA 기반) | Sparkplug의 약 1/10 | 중간 |
| TurboFan | 최상위 최적화. 인라이닝·특수화를 총동원 | 가장 느림 | 가장 높음 |
Maglev가 들어오기 전에는 Sparkplug와 TurboFan 사이의 간격이 너무 넓었습니다. Sparkplug는 구조가 단순해서 빠르게 만들 수 있지만 그만큼 성능 상한이 낮고, TurboFan은 훌륭한 코드를 내놓지만 만드는 데 오래 걸립니다. 그 사이에는 "제법 자주 실행되지만 TurboFan을 부를 만큼 뜨겁지는 않은" 함수들이 잔뜩 있습니다. 실제 애플리케이션 코드의 상당 부분이 여기 속합니다.
Maglev는 정확히 그 빈칸을 겨냥해 만들어졌습니다. V8 팀의 설명에 따르면 컴파일 속도는 Sparkplug보다 약 10배 느리고 TurboFan보다 약 10배 빠릅니다. 코드 품질도 그 중간입니다. 미지근한 함수를 위해 준비된 타협인 셈입니다.
이 계층 전환이 실제로 어떤 기준으로 일어나는지는 엔진 옵션에서 엿볼 수 있습니다.
node --v8-options | grep -- "--invocation-count-for-turbofan"
--invocation-count-for-turbofan (invocation count required for optimizing with TurboFan)
type: int default: --invocation-count-for-turbofan=3000
호출 횟수라는 아주 단순한 신호로 승급을 결정합니다. 이 숫자를 직접 건드릴 일은 거의 없지만, 엔진이 "얼마나 자주 불렸는지"를 세고 있다는 사실 자체는 알아둘 만합니다. 함수가 뜨거워지는 데도 시간이 걸린다는 뜻이고, 짧게 살다 죽는 프로세스는 그 문턱에 닿기 전에 끝난다는 뜻이기도 합니다.
Maglev — 그리고 왜 하필 24에서야 켜졌는가
여기서 주의할 점이 하나 있습니다. Node 22 릴리스 공지에는 "지원 아키텍처에서 Maglev가 기본 활성화되었다"고 적혀 있습니다. 그런데 실제로 배포된 22 바이너리는 그렇지 않습니다.
사정은 이렇습니다. 공지가 틀린 것이 아니라, 공지 이후에 뒤집혔습니다. 변경 이력을 따라가면 세 지점이 나옵니다.
- 22.0.0 —
v8: enable maglev on supported architectures. 공지문은 이 시점 기준으로 맞습니다. - 22.9.0 —
Disable V8 Maglev. 위 커밋을 되돌립니다. - 24 — V8 13.6과 함께 다시 기본 활성.
되돌린 이유가 중요합니다. 성능 판단이 아니라 정확성 문제였습니다. Node 팀이 22.9.0 변경 이력에 적어둔 사유는 이렇습니다.
We have seen several crashes/unexpected JS behaviors with maglev on v22 (which ships V8 v12.4). The bugs lie in the codegen so it would be difficult for users to work around them or even figure out where the bugs are coming from.
As v22 will get stuck with V8 v12.4 as LTS, it will be increasingly difficult to backport patches for them even if the bugs are fixed. So disable it by default on v22 to reduce the churn and troubles for users.
"크래시"만이 아니라 "예기치 않은 JS 동작"이 함께 적혀 있다는 점이 눈에 들어왔습니다. 코드 생성 단계의 버그라 프로그램이 죽는 대신 조용히 틀린 결과를 내놓을 수 있었다는 뜻입니다. 사용자가 우회하기는커녕 원인이 어디인지 짚기조차 어렵다는 설명도 그래서 붙어 있습니다. 켜져 있는 최적화 계층 하나가 이런 방식으로 실패할 수 있다는 것은, 성능 기능이라도 정확성이 먼저라는 당연한 사실을 새삼 상기시킵니다.
두 번째 문단은 더 실무적입니다. 22는 LTS 기간 내내 V8 12.4에 묶여 있습니다. 그러니 22에서 Maglev를 기다리는 것은 의미가 없습니다. 나중에 켜지지 않습니다. Maglev를 원한다면 24로 가는 것 외에 선택지가 없다는 뜻입니다.
정리하면 켰다가, 껐다가, 다른 메이저에서 다시 켠 기능입니다. 공지문만 읽고 "22부터 Maglev가 켜져 있다"고 쓰면 틀린 글이 됩니다. 저도 그렇게 쓸 뻔했습니다. 손에 있는 바이너리에서 직접 보는 편이 확실합니다.
node --v8-options | grep -A1 -- "--maglev ("
# x64 기준 확인 결과
Node 20 → default: --no-maglev
Node 22 → default: --no-maglev (공지와 다릅니다)
Node 24 → default: --maglev
arm64에서도 같았습니다. 20과 22는 꺼져 있고 24에서 켜집니다. 그러니까 20에서 24로 가는 것은 JIT 계층이 하나 늘어나는 변화입니다. 20에서 22로만 갔다면 이 이득은 없고, 앞서 본 대로 22에 머무는 한 앞으로도 없습니다. 22가 아니라 24를 고를 이유가 지원 기간 말고 하나 더 있는 셈입니다.
그래서 얼마나 빨라지는가
결론부터 적자면, 워크로드가 현실적일수록 이득은 줄어듭니다. 같은 머신에서 성격이 다른 네 가지를 재봤습니다.
| 워크로드 | 20 대비 24 개선 | 성격 |
|---|---|---|
| 합성 벤치마크 (JSON 파싱 + 객체 변환) | 약 35% | 순수 CPU, JIT이 개입할 여지 최대 |
| 타입 체크 (tsc) | 약 10% | CPU 중심이지만 I/O가 섞임 |
| 유닛 테스트 (부분 집합) | 약 11% | 실행 구간이 짧음 |
| 유닛 테스트 (전체 스위트) | 약 2% | 부팅·모듈 해석·I/O가 지배 |
같은 런타임 업그레이드인데 35%와 2% 사이를 오갑니다. 이유는 어렵지 않습니다. 전체 테스트 스위트에서 시간을 잡아먹는 것은 자바스크립트 연산이 아니라 프로세스 부팅, 모듈 해석, 파일 입출력입니다. JIT이 아무리 좋아져도 개입할 자리 자체가 좁습니다.
그래서 "Node 24로 올리면 N% 빨라진다"는 문장은 어떤 워크로드인지를 같이 적지 않으면 오해를 부릅니다. 합성 벤치마크의 숫자를 그대로 자기 서비스에 대입하면 거의 반드시 실망하게 됩니다.
덧붙이자면, 요즘 빌드 파이프라인에서는 이 효과가 더 작습니다. esbuild(Go)나 SWC(Rust)처럼 무거운 일을 네이티브 바이너리가 처리하는 도구는 Node 버전과 거의 무관하게 동작합니다. "런타임을 올리면 빌드가 빨라진다"는 기대는 예전만큼 잘 맞지 않는다는 생각이 들었습니다.
측정을 통째로 뒤집는 함정 — 에뮬레이션
측정하면서 한 번 크게 속을 뻔했습니다. ARM 맥에서 x64 컨테이너를 돌려(즉 에뮬레이션 위에서) 같은 벤치마크를 쟀더니, Maglev를 켠 쪽이 2배 가까이 느리게 나왔습니다. 144ms 대 77ms였습니다. 하마터면 "Maglev는 손해"라는 결론을 낼 뻔했습니다.
원인은 에뮬레이션 자체에 있었습니다. 에뮬레이터는 명령어를 번역해서 실행하는데, JIT은 실행 중에 새 기계어를 만들어냅니다. 그러면 그 새로 만들어진 코드를 에뮬레이터가 다시 번역해야 합니다. 컴파일 계층이 늘어날수록 번역해야 할 코드도 늘어나니, 네이티브에서는 이득인 구조가 에뮬레이션 위에서는 그대로 손해로 뒤집힙니다.
네이티브 바이너리로 다시 재니 26ms 대 28ms로 정상이었습니다. JIT 성능은 에뮬레이션 위에서 재면 안 됩니다. 도커 이미지의 아키텍처가 호스트와 다른지부터 확인하는 편이 좋습니다.
node -p "process.arch" # 컨테이너 안에서 실행해 호스트 아키텍처와 비교
메모리는 오히려 늘어납니다
성능 이야기에서 잘 빠지는 쪽이 메모리입니다. 유휴 상태의 RSS를 재보니 이렇게 나왔습니다.
| 버전 | 유휴 RSS | 비고 |
|---|---|---|
| Node 20 | 약 40.9MB | 기준 |
| Node 22 | 약 47.1MB | 약 +15% |
| Node 24 | 약 47.1MB | 22와 동일 |
주목할 부분은 증가가 22에서 일어났고 24에서는 더 늘지 않았다는 점입니다. 원인이 Maglev가 아니라 V8 본체가 커진 것이기 때문입니다. 엔진이 11.3에서 12.4로 올라가면서 늘어난 몫이라 옵션 튜닝으로 되돌릴 수 있는 종류가 아닙니다.
컨테이너 메모리 상한을 빠듯하게 잡아둔 환경이라면 이 6MB가 의미 있을 수 있습니다. 특히 워커를 여러 개 띄우는 구조라면 프로세스 수만큼 곱해집니다. 힙 상한(--max-old-space-size)과 컨테이너 제한을 함께 점검해두는 편이 좋습니다.
한 가지 더, Node 22부터 스트림의 기본 highWaterMark가 16 KiB에서 64 KiB로 올랐습니다. 버퍼를 4배로 잡으니 처리량에는 유리하지만 동시에 열린 스트림 수만큼 메모리를 더 씁니다. 다만 실제로 유의미해지는 것은 스트림이 수백 개 단위일 때이고, 수십 개 규모에서는 노이즈에 묻힙니다. 정말 필요하면 되돌릴 수 있습니다.
const stream = require('node:stream');
// 첫 번째 인자는 objectMode 여부, 두 번째가 바이트 크기입니다
stream.setDefaultHighWaterMark(false, 16 * 1024);
대부분의 경우 굳이 되돌릴 필요는 없다는 생각이 듭니다. 다만 스트림을 대량으로 다루면서 메모리가 빠듯하다면, 22 이후의 이 기본값 변경이 원인 후보에 들어간다는 것 정도는 기억해둘 만합니다.
엔진 밖에서 달라지는 것
V8만 바뀐 것은 아닙니다. 실무에서 체감할 만한 것만 추리면 이렇습니다.
| 항목 | Node.js 20 | Node.js 24 |
|---|---|---|
| V8 | 11.3 | 13.6 |
| Maglev | 없음 | 기본 활성 |
| npm | 9.x / 10.x | 11 |
| undici (fetch 구현) | 5.x 계열 | 7 |
| AsyncLocalStorage | 기존 구현 | AsyncContextFrame 기본 |
| 권한 모델 | 없음 | --permission (실험 딱지 제거) |
URLPattern |
없음 | 전역 노출 |
이 중에서 조용히 영향이 큰 쪽은 AsyncLocalStorage입니다. 요청 컨텍스트나 트레이싱을 다루는 라이브러리 상당수가 여기에 의존하는데, 24에서 내부 구현이 AsyncContextFrame 기반으로 바뀌었습니다. 성능과 견고성 모두 개선되는 방향이지만 구현이 교체된 것이므로, 관련 라이브러리를 쓴다면 눈여겨볼 만합니다.
언어 차원에서는 V8 13.6이 Float16Array, RegExp.escape, Error.isError, 그리고 명시적 자원 관리(using·await using)를 들여옵니다. 이 중 실무 코드를 실제로 바꿀 만한 것은 명시적 자원 관리입니다. 파일 핸들이나 커넥션처럼 반드시 닫아야 하는 자원을 try/finally 없이 스코프에 묶을 수 있습니다.
실제로 걸리는 지점 — N-API가 바꿔놓은 풍경
메이저 버전을 올릴 때 가장 무섭던 것은 네이티브 모듈이었습니다. Node의 C++ ABI 버전(NODE_MODULE_VERSION)이 바뀌면 그 위에 빌드된 .node 바이너리는 로드되지 않습니다. 20에서 24로 가면 이 값이 115에서 137로 바뀝니다.
node -p "process.versions.modules" # 20 → 115, 24 → 137
그런데 실제로 확인해보니 재빌드가 필요 없었습니다. 요즘 주요 네이티브 패키지들이 대부분 N-API(Node-API) 위에 올라가 있기 때문입니다. N-API는 V8 API를 직접 건드리는 대신 안정적인 C 인터페이스를 거치도록 만든 계층이라, ABI가 바뀌어도 같은 바이너리가 그대로 로드됩니다. 20에서 24로 가면서 N-API 버전은 9에서 10으로 올라가지만 하위 호환입니다.
sharp, bcrypt, 그리고 napi-rs 기반 도구들(SWC, Rollup, lightningcss, Tailwind oxide 등)을 두 버전에서 실제로 로드해 확인했습니다. 전부 그대로 동작했습니다. Alpine처럼 musl 기반 환경을 위한 prebuild도 별도로 배포됩니다. "메이저를 올리면 node-gyp 재빌드 지옥이 온다"는 경험칙은 이제 잘 맞지 않는다는 생각이 들었습니다. 이번 검토에서 개인적으로 가장 크게 갱신된 인식입니다.
물론 확인은 해야 합니다. 의존성 중에 N-API를 쓰지 않고 V8 API에 직접 링크한 애드온이 있다면 그것은 여전히 재빌드 대상이고, C++20을 요구하게 되면서 소스 수정이 필요할 수도 있습니다.
대신 이런 것들을 확인하세요
- OpenSSL 3.5: 기본 보안 수준이 2로 올라가면서 2048비트 미만 RSA/DSA/DH 키, 224비트 미만 ECC 키, RC4 계열 암호 스위트가 거부됩니다. 오래된 인증서나 레거시 시스템과 TLS로 붙는 구간이 있다면 여기가 가장 먼저 깨질 자리입니다.
- 플랫폼 최소 사양: macOS는 13.5 이상, 소스 빌드 시 Xcode 16.1 또는 gcc 12.2 이상이 필요합니다. Linux의 glibc 요구사항(2.28 이상)은 그대로입니다.
- 제거·폐기된 API:
tls.createSecurePair()는 제거됐고,url.parse(),SlowBuffer등은 런타임 경고 대상입니다. - 32비트 계열: armv7 리눅스 32비트 바이너리 제공이 중단됐고, armv7 지원은 실험 단계로 내려갔습니다.
폐기 API 정리는 손으로 할 필요가 없습니다. Node 팀이 codemod를 제공합니다.
# 예: dirent.path → dirent.parentPath 일괄 치환
npx codemod run @nodejs/dirent-path-to-parent-path
# 예: tls.createSecurePair() → TLSSocket
npx codemod run @nodejs/tls-create-secure-pair-to-tls-socket
업그레이드 후 무언가 깨졌다면
마지막으로 방법론 하나를 적어두고 싶습니다. 검토 중에 빌드가 한 번 실패했습니다. 방금 런타임을 올렸으니 당연히 그 탓이라고 생각하기 쉬운 상황이었습니다.
그런데 같은 명령을 구버전에서도 돌려봤더니 에러 문구와 위치까지 똑같았습니다. 업그레이드와 무관하게 원래 있던 문제였고, 원인은 의존성 메이저 버전이 섞이면서 생긴 타입 불일치였습니다. 이 대조를 하지 않았다면 며칠을 엉뚱한 곳에서 헤맸을 겁니다.
바꾼 직후에 문제가 보이면 그 변경이 범인처럼 보이는 것은 자연스럽습니다. 다만 자연스러운 것과 맞는 것은 다릅니다. 변경 전 상태에서도 같은 증상이 나오는지를 먼저 확인하는 데는 몇 분이면 충분하고, 그 몇 분이 진단의 방향 자체를 정합니다. 이건 지표를 잘못 읽고 엉뚱한 결론에 도달할 뻔했던 경험에서도 똑같이 배웠던 것입니다.
정리하면
Node 20에서 24로 올리는 일을 성능 개선 프로젝트로 접근하면 실망하기 쉽습니다. 합성 벤치마크에서는 35%가 나오지만, 실제 테스트 스위트에서는 2%였습니다. 메모리는 오히려 15% 늘어납니다. 정직하게 말하면 이 업그레이드의 주된 이유는 성능이 아니라 지원 종료입니다.
다만 그렇다고 엔진 이야기가 무의미한 것은 아닙니다. Maglev가 채운 빈칸이 무엇인지 알고 나면, 왜 어떤 워크로드는 크게 빨라지고 어떤 워크로드는 꿈쩍도 하지 않는지가 설명됩니다. JIT 계층은 충분히 뜨거워진 코드에만 관여하고, 프로세스 부팅과 모듈 해석과 I/O에는 손댈 수 없습니다. 이 경계를 알고 있으면 다음에 "런타임을 올려서 성능을 개선하자"는 제안을 받았을 때 어디를 먼저 재봐야 할지 알 수 있습니다.
결국 남는 교훈은 조금 싱겁습니다. 공지문 대신 손에 있는 바이너리를 확인하고, 남의 벤치마크 대신 자기 워크로드를 재는 것입니다. Maglev가 22부터 켜져 있다고 믿을 뻔했고, 에뮬레이션 위에서 잰 숫자로 정반대 결론을 낼 뻔했습니다. 두 번 다 확인 한 번이 막아줬습니다.
출처에 대하여. 이 글에는 성격이 다른 두 종류의 근거가 섞여 있습니다. 버전·지원 일정·Maglev 활성 여부·폐기 API 같은 것은 Node.js의 공개 릴리스 노트와 변경 이력에서 확인한 사실이며, 누가 확인해도 같습니다.
반면 성능 수치(35% / 10% / 11% / 2%)와 메모리 수치(RSS +15%)는 한 환경의 측정치입니다. Apple Silicon(arm64)에서 공식
node:*-alpine이미지와 darwin-arm64 바이너리로, 20.20.2 / 22.23.2 / 24.19.0을 3회 교차 실행해 평균 낸 값입니다. x64 리눅스에서는 다르게 나올 수 있으니 절대치로 인용하지 마시고, 워크로드가 현실적일수록 이득이 줄어든다는 경향만 가져가시면 좋겠습니다. 숫자가 궁금하다면 결국 자기 워크로드를 직접 재는 수밖에 없습니다.
관련 글
Node.js 소스코드를 직접 열어봤습니다 — 런타임, V8, libuv의 실체 (1편)
면접 준비를 하다가 "Node.js가 뭔가요?"라는 질문에 제대로 답할 수 없다는 걸 깨달았습니다. 런타임이 뭔지, V8과 libuv가 각각 무슨 역할인지, 실제 Node.js GitHub 소스코드를 열어서 os.hostname() 한 줄이 OS까지 도달하는 과정을 추적해봤습니다.
Node.js만 있는 게 아니다 — Bun과 Deno, 같은 언어 다른 런타임
JavaScript를 실행하는 런타임은 Node.js만이 아닙니다. Bun과 Deno는 같은 언어를 다른 방식으로 실행합니다. 세 런타임의 설계 철학, 내부 구조, 실용적 차이를 정리합니다.
CommonJS와 ESM — require와 import는 어떻게 다르게 동작하는가 (8편)
Node.js의 두 가지 모듈 시스템 — CommonJS의 require와 ESM의 import가 내부에서 어떻게 다르게 동작하는지 추적합니다. 로딩 시점, 캐싱, 순환 참조 처리의 차이를 살펴봅니다.