RSS듀오랩스
웹 인프라

네이티브 모듈과 Node 버전: 최신을 깔면 안 되는 이유

작성자
듀오랩스 대표·7분 읽기

새 맥에 개발 환경을 세우면서 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 lsbetter-sqlite3, sharp, canvas, node-sass 같은 이름이 있는지 보면 됩니다. 없다면 Node 버전에 훨씬 자유롭습니다.

빌드 캐시는 문제를 미룹니다. 캐시된 레이어가 옛 컴파일 결과를 들고 있는 동안은 아무 일도 안 일어나고, 캐시가 사라진 날 터집니다. 그날 바뀐 것을 아무리 봐도 원인이 없는 이유입니다.

아직 확신이 없는 부분도 있습니다. 이번에 22 로 내리면서 이미 26 으로 설치해 둔 다른 프로젝트들의 node_modules 를 다시 만들지는 않았습니다. 그쪽 네이티브 모듈은 ABI 가 안정적인 방식(N-API)을 쓰는 것으로 보여 그대로 뒀는데, 언젠가 같은 방식으로 드러날 수 있다고 생각하고 있습니다.

마지막 수정:

공유하실 때는 출처(Duolabs)와 원문 주소를 표시해 주세요.