네이티브 모듈과 Node 버전: 최신을 깔면 안 되는 이유
새 맥에 개발 환경을 세우면서 brew install node 를 쳤습니다. 그때 깔린 것이 26 이었습니다. Next 앱 두 개는 잘 떴고, 세 번째 프로젝트에서 npm install 이 이렇게 멈췄습니다.
prebuild-install warn install No prebuilt binaries found
(target=26.7.0 runtime=node arch=arm64 libc= platform=darwin)
./src/util/binder.lzz:40:37: error: no member named 'GetPrototype' in 'v8::Object'
./src/better_sqlite3.lzz:68:34: error: no member named 'GetIsolate' in 'v8::Context'
./src/objects/database.lzz:416:89: error: no member named 'This' in 'v8::PropertyCallbackInfo<v8::Value>'better-sqlite3 였습니다.
자바스크립트 패키지인데 컴파일이 필요한 이유
npm 패키지 대부분은 자바스크립트라 어느 Node 에서든 그대로 돕니다. 네이티브 모듈은 다릅니다. C++ 로 짜여 있고, Node 안에 들어 있는 V8 엔진의 C++ API 를 직접 부릅니다. 그래서 설치할 때 그 기계에서 컴파일되거나, 미리 빌드된 바이너리를 받아옵니다.
V8 의 C++ API 는 메이저 버전마다 바뀝니다. 위 오류 셋이 정확히 그것입니다. GetPrototype, GetIsolate, PropertyCallbackInfo::This 가 Node 26 의 V8 에서 사라졌거나 자리를 옮겼습니다. 패키지 코드는 그대로인데 그 코드가 부르던 함수가 없어진 것입니다.
미리 빌드된 바이너리를 받는 길도 막힙니다. 배포자가 Node 26 이 나오기 전에 올려 둔 것이라 26 용은 없습니다. 첫 줄의 No prebuilt binaries found 가 그 뜻이고, 그래서 소스 컴파일로 넘어갔다가 위 오류를 만납니다.
지원이 없는 상태로 시작하는 최신 Node
여기서 순서를 뒤집어 생각하게 됐습니다. Node 26 이 나온 시점에 그 버전을 지원하는 네이티브 모듈은 하나도 없습니다. 패키지 관리자들이 새 V8 에 맞춰 고치고 바이너리를 올려야 지원이 생깁니다.
즉 brew install node 로 최신을 까는 것은 지원이 가장 적은 버전을 고르는 일입니다. 저는 그걸 "일단 최신"이라고 생각하고 골랐습니다.
이미 답을 갖고 있던 프로젝트
막히고 나서야 리포들을 봤습니다. 네 개 전부 Dockerfile 첫 줄이 같았습니다.
FROM node:22-alpine로컬만 26 이었습니다. 배포되는 환경과 개발 기계의 Node 메이저 버전이 다른 상태로 며칠을 보낸 셈이고, 그동안 앱이 잘 떴다는 것이 오히려 운이었습니다. 22 로 내리자 npm install 이 그대로 통과했습니다.
brew install node@22
brew unlink node
brew link --overwrite --force node@22같은 함정이 한 번 더, 훨씬 고약하게
리포의 Dockerfile 맨 위에 주석 스물몇 줄이 붙어 있었습니다. 두 달 전 같은 종류의 사고를 적어 둔 것입니다.
⚠️ Node 를 24 로 올리지 말 것. better-sqlite3 11.x 와 맞지 않는다.
node:24-alpine 에서 API 요청 약 30회마다 프로세스가 네이티브 어서션으로 죽었다:
node::RemoveEnvironmentCleanupHook … Assertion failed: (env) != nullptr
이번 제 실패보다 나쁜 형태입니다. 빌드는 성공했습니다. 컴파일도 통과했습니다. 돌다가 30번에 한 번씩 프로세스가 죽었습니다.
증상은 더 멀리 떨어져 나타났습니다. 화면에서는 "값을 바꿔도 새로고침해야 보인다" 로 보였습니다. 그 앱의 상태 저장소가 새로고침할 때 11개 API 를 Promise.all 로 부르는데, 하나가 502 면 전체가 reject 되어 아무 상태도 안 바뀝니다. 서버가 죽었다는 사실이 화면 어디에도 나타나지 않고, 대신 "가끔 안 바뀐다" 로 보입니다.
코드가 아니었던 그날의 원인
가장 배울 점은 그다음 문장입니다.
코드도 버전도 안 바뀌었는데 그날 터진 이유는 빌드 캐시를 비웠기 때문이다. 그전까지는 8/21 에 만든 deps 레이어가 재사용되어 그때 컴파일한 바이너리를 계속 쓰고 있었다.
Node 버전을 올린 날과 장애가 난 날이 달랐던 것입니다. 도커가 npm ci 레이어를 캐시하고 있었으므로, 옛 Node 로 컴파일한 바이너리가 계속 이미지에 실려 나갔습니다. 캐시가 사라지자 그제야 새 Node 로 다시 컴파일했고, 잠복해 있던 불일치가 드러났습니다.
원인과 증상 사이에 닷새가 있었습니다. 그 사이에 한 일들은 전부 무관했고, 그래서 그날의 변경만 들여다보면 아무것도 못 찾습니다.
왜 그냥 최신 버전으로 못 올리나
주석은 왜 22 에 머물러야 하는지도 적어 두었습니다.
@prisma/adapter-better-sqlite3 6.19.3 이 better-sqlite3: ^11.9.0 에 고정돼 있는데, Node 24 를 명시적으로 지원하는 것은 12.x 부터입니다. 즉 Node 를 올리려면 Prisma 를 7 로 올려야 하고, 그건 메이저 업그레이드라 별개의 작업입니다.
의존성 사슬이 이렇게 걸려 있으면 "Node 만 올리자" 가 성립하지 않습니다. Node 버전은 혼자 정할 수 있는 값이 아니라 사슬 전체가 함께 정하는 값입니다.
배포 환경이 정하고 개발 기계가 맞추는 순서
Node 버전은 배포 환경이 정하고, 개발 기계가 거기 맞춥니다. 반대 방향은 성립하지 않습니다. Dockerfile 이 node:22-alpine 이면 로컬도 22 입니다.
네이티브 모듈이 있는 프로젝트에서 Node 메이저 버전은 마음대로 바꿀 수 있는 값이 아닙니다. 어떤 패키지가 네이티브인지 모른다면 npm ls 로 better-sqlite3, sharp, canvas, node-sass 같은 이름이 있는지 보면 됩니다. 없다면 Node 버전에 훨씬 자유롭습니다.
빌드 캐시는 문제를 미룹니다. 캐시된 레이어가 옛 컴파일 결과를 들고 있는 동안은 아무 일도 안 일어나고, 캐시가 사라진 날 터집니다. 그날 바뀐 것을 아무리 봐도 원인이 없는 이유입니다.
아직 확신이 없는 부분도 있습니다. 이번에 22 로 내리면서 이미 26 으로 설치해 둔 다른 프로젝트들의 node_modules 를 다시 만들지는 않았습니다. 그쪽 네이티브 모듈은 ABI 가 안정적인 방식(N-API)을 쓰는 것으로 보여 그대로 뒀는데, 언젠가 같은 방식으로 드러날 수 있다고 생각하고 있습니다.
함께 읽기
- docker builder prune 상한: 왜 남의 캐시까지 깎일까?한 서비스의 개발 배포를 다른 호스트로 옮기는 중이었습니다. 러너를 새로 붙이고, 환경변수 출처를 바꾸고, 이제 워크플로를 켜기만 하면 되는 참이었습니다. 그런데 배포 스크립트 마지막에 있는 한 줄에서 멈췄습니다.
- Node 24 + better-sqlite3: 빌드 캐시가 가린 크래시값을 바꿔도 화면에 반영되지 않았습니다. 새로고침하면 보였습니다.
- Cloudflare 존 이전과 R2: 커스텀 도메인이 따라오지 않는 이유이미지가 하나도 안 나온다는 말을 듣고 열어 봤습니다. 이미지 원본을 서빙하는 호스트 세 개가 DNS 에서 통째로 사라져 있었습니다. dig 가 아무것도 돌려주지 않았습니다.
- Vercel DB 통합: 비밀번호를 몰라도 되는 이유하루에 관리형 Postgres 두 개를 배포에 붙였습니다. 하나는 비밀번호를 재설정하고 연결 문자열을 손으로 옮겨 적었고, 다른 하나는 비밀번호를 끝내 보지 않았습니다. 후자가 더 빨랐고, 무엇보다 틀릴 자리가 없었습니다.
- ERR_REQUIRE_ESM: 같은 커밋인데 새로 만든 배포만 500이 난 이유블로그 목록에는 글 297편이 그대로 나왔습니다. 그중 하나를 누르면 500이었습니다. 문서 사이트도 똑같이 목록은 정상이고 문서 본문만 죽었습니다. 두 서비스 모두 마지막 커밋이 일주일 전이었고, 그 사이 코드를 건드린 사람은 없었습니다.