네이티브 모듈과 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)을 쓰는 것으로 보여 그대로 뒀는데, 언젠가 같은 방식으로 드러날 수 있다고 생각하고 있습니다.
함께 읽기
- Node 24 + better-sqlite3: 빌드 캐시가 가린 크래시값을 바꿔도 화면에 반영되지 않았습니다. 새로고침하면 보였습니다.
- 로컬에서는 통과한 Next.js 타입 검사가 Docker 배포에서만 실패하는 이유Next.js 앱에서 로컬 타입 검사는 통과하는데 Docker 배포에서만 같은 오류가 다시 나오면 코드보다 빌드 이미지에 무엇이 들어갔는지 봐야 합니다. 이번 작업에서는 린트, 타입 검사, 프로덕션 빌드를 모두 통과시킨 뒤 배포했는데 원격 빌드만 실패했습니다. 같은 커밋과 같은 잠금 파일을 썼으니 처음에는 환경 차이가 …
- Docker 빌드 컨텍스트가 1.06GB까지 커진 이유Docker 빌드가 느리면 패키지 설치나 애플리케이션 컴파일부터 보게 됩니다. 저도 타입 검사 실패를 고치는 데 집중하다가 그보다 앞선 load build context 단계에서 40초가 넘게 쓰이고 있다는 사실을 뒤늦게 봤습니다. 실패 원인과는 별개였지만, 매 배포마다 반복되는 낭비였습니다.
- Cloudflare Tunnel로 Vercel을 연결하면 안 되는 이유Vercel 배포는 성공했습니다. 프로젝트명.vercel.app 으로 접속하면 잘 뜹니다.
- 오래 걸리는 작업을 API에서 바로 처리하면 안 되는 이유보고서 생성, 대량 메일, 이미지 변환, 외부 API 수집을 일반 API 요청 안에서 끝내려 하면 처음에는 단순해 보인다. 하지만 실행 시간이 길어질수록 브라우저, 프록시, 서버리스 함수 중 하나가 먼저 연결을 끊을 가능성이 커진다. 사용자가 다시 누르면 같은 작업이 중복 실행되기도 한다.