Skip to content
khiopost — 개발·AI 트러블슈팅
Go back

Mac Node.js 버전 충돌 — 빌드가 styleText에서 죽었고, 지우는 대신 공존을 택했다

Updated:

이 블로그의 Astro 빌드는 이 명령으로만 돈다.

PATH="/opt/homebrew/opt/node@25/bin:$PATH" npm run build

그냥 npm run build를 치면 죽는다. Mac에서 Node.js 버전 충돌을 겪으면 다들 "하나만 남기고 다 지워라"부터 찾는데, 나는 반대로 갔다. 결론부터 말하면 — 전역은 버전 관리자가 잡고, 예외 버전이 필요한 명령에만 PATH를 한 줄 얹어 공존시킨다. 이 글은 그 셋업이 왜 나왔는지를 내 머신(MacBook M1 32GB)에서 실제로 확인한 값들로 기록한 것이다.

왜 멀쩡하던 빌드가 죽었나 — 범인은 styleText

한 줄로 답하면, 셸 기본 Node.js가 v20.10.0인데 빌드 체인이 v20.12.0부터 생긴 util.styleText를 부르기 때문이다.

직접 확인해보면 이렇다. 같은 머신에서 node 버전만 바꿔 같은 코드를 돌렸다.

$ node -v
v20.10.0
$ node -e "const u=require('node:util'); console.log(typeof u.styleText)"
undefined

$ /opt/homebrew/opt/node@25/bin/node -v
v25.9.0
$ /opt/homebrew/opt/node@25/bin/node -e "const u=require('node:util'); console.log(typeof u.styleText)"
function

Node.js 공식 문서 기준으로 styleText는 v21.7.0과 v20.12.0에서 추가됐다. 내 기본 버전은 v20.10.0 — 마이너 딱 두 개 차이로 이 API가 없다. 터미널 출력에 색 입히는 함수 하나 때문에 빌드 전체가 주저앉는 셈이다.

이 사이트의 빌드 체인에서 실제로 styleText를 부르는 놈은 번들러인 rolldown이었다. node_modules를 grep해서 찾았다. 이런 API 구멍은 package.json의 engines 필드로 걸러지지 않는 한 설치 때는 조용히 넘어가고, 빌드를 돌리는 순간에야 터진다. "어제까지 되던 게 왜"의 답이 의존성 업데이트 + 오래된 로컬 node 조합인 경우가 생각보다 많다.

내 머신의 node 지층 — which node부터 친다

지금 무슨 node가 실행될지는 PATH 순서가 정한다. 그래서 버전이 꼬였다 싶으면 나는 무조건 which node부터 친다.

$ which node
/Users/kyunghunkim/Library/Caches/fnm_multishells/86822_1783604965891/bin/node
$ node -v
v20.10.0

경로가 말해주듯 내 셸의 node는 fnm이라는 버전 관리자가 쥐고 있다. fnm에는 세 버전이 깔려 있고 기본이 v20.10.0이다.

$ fnm ls
* v20.10.0 default
* v22.13.1
* v24.18.0
* system

여기에 Homebrew 지층이 하나 더 있다.

$ ls /opt/homebrew/Cellar | grep node
node
node@16
$ ls -la /opt/homebrew/bin/node
lrwxr-xr-x@ 1 kyunghunkim admin 30 Apr 2 22:09 /opt/homebrew/bin/node -> ../Cellar/node/25.9.0/bin/node

brew에도 node 25.9.0이 깔려 있고 /opt/homebrew/bin/node로 링크까지 되어 있다. 심지어 2021년 12월에 깔았던 node@16 잔해도 그대로 남아 있다. 그런데도 셸에서 node -v를 치면 v20.10.0이 나온다. 이유는 PATH 순서다.

$ echo $PATH | tr ':' '\n' | head -3
/Users/kyunghunkim/Library/Caches/fnm_multishells/86822_1783604965891/bin
/Users/kyunghunkim/Library/pnpm
/opt/homebrew/bin

fnm의 shim 디렉터리가 /opt/homebrew/bin보다 앞이라, brew node 25.9.0은 깔려만 있고 셸에서는 실행되지 않는다. "분명 새 버전을 깔았는데 node -v가 그대로다"라는 흔한 증상의 정체가 대부분 이거다. Mac에서 Node.js 버전 충돌이라고 검색하게 되는 상황의 절반은 지우고 다시 깔 문제가 아니라 이 순서 문제다.

Homebrew만으로 여러 버전을 오가려면 brew link/unlink를 프로젝트 바꿀 때마다 반복해야 하고, 그때마다 전역이 통째로 바뀐다. 버전 관리자가 필요한 이유다.

Node.js 버전 충돌, 지우지 않고 공존시키는 원칙

원칙은 하나다 — 전역은 버전 관리자 하나가 관리하고, 예외 버전이 필요한 명령에는 그 한 줄에만 PATH 접두를 붙인다.

# 이 명령 한 줄에서만 node 25가 우선된다 — 셸 기본 버전은 그대로
PATH="/opt/homebrew/opt/node@25/bin:$PATH" npm run build

PATH 접두는 그 프로세스에만 적용된다. 명령이 끝나면 셸은 여전히 fnm의 v20.10.0이고, 다른 프로젝트는 아무 영향도 받지 않는다.

fnm에 v24도 깔려 있으니 기본 버전을 올려버리는 게 더 간단해 보일 수 있다. 근데 기본을 올리면 이 머신에서 도는 모든 프로젝트가 같이 따라간다. 어느 프로젝트가 어느 API에 묶여 있는지 전수조사할 자신이 없으면, 전역을 움직이는 건 또 다른 충돌의 씨앗이다. 반면 빌드 명령에 버전이 박혀 있으면 명령 자체가 문서가 된다. 몇 달 뒤에 다시 봐도 "이 빌드는 node 25가 필요하구나"가 명령에서 바로 읽힌다. 배포 자동화를 붙이다 11번 연속으로 실패했을 때 뼈저리게 배운 게 "로컬과 CI의 실행 환경이 다르다"는 사실이라, 실행 환경을 암묵에 맡기는 걸 그 뒤로는 안 믿는다.

"brew node는 다 지워라"는 흔한 조언도 안 따랐다. 내 예외 버전인 node 25의 공급처가 brew라서다. Cursor도 dmg 대신 brew로 깐 것과 같은 이유로, brew가 최신 node를 들고, fnm이 전역을 쥐고, 필요한 명령만 PATH로 예외 처리한다. 셋이 안 싸운다.

nvm으로 시작한다면 — 설치는 최소한만

새로 시작한다면 버전 관리자 하나를 깔고 프로젝트에 버전 파일을 두는 것으로 충분하다. 어느 걸 골라도 충돌을 피하는 원칙은 위와 같다.

사실상 표준은 여전히 nvm이다. 설치는 한 줄이고(이 글을 개고한 2026년 7월 기준 최신 v0.40.5), 스크립트가 ~/.zshrc에 초기화 코드까지 넣어준다.

curl -o- https://raw.githubusercontent.com/nvm-sh/nvm/v0.40.5/install.sh | bash

설치 뒤에 챙길 건 두 가지뿐이다.

나는 nvm 대신 fnm을 쓴다. Rust로 만들어져 셸 시작이 가볍고, 디렉터리 자동 전환이 옵션 하나로 내장돼 있어서다. 내 ~/.zshrc의 관련 설정은 이 한 줄이 전부다.

eval "$(fnm env --use-on-cd)"

fnm은 .nvmrc도 그대로 읽으니 nvm 쓰는 팀에 섞여도 문제없다. npm 버전까지 package.json에 고정하고 싶으면 Volta라는 선택지도 있는데, 개인 머신에는 오버킬이라고 본다.

자주 묻는 질문

nvm use를 했는데 새 터미널에서는 버전이 되돌아가 있다?

정상 동작이다. nvm use는 현재 세션에만 적용되고 새 탭은 default 별칭을 따른다. 늘 쓸 버전이면 nvm alias default 20으로 기본값을 바꾸거나, .nvmrc + 자동 전환 훅을 걸어둔다.

npm -g로 깔아둔 패키지는 버전을 바꾸면 사라지나?

사라지는 게 아니라 버전별로 분리 보관된다. 새 버전에는 아직 없는 상태일 뿐이다. nvm install 22 --reinstall-packages-from=20처럼 설치 시점에 이전 버전의 글로벌 패키지를 함께 옮길 수 있다.

brew로 깐 node는 꼭 지워야 하나?

아니다. PATH 순서만 이해하면 버전 관리자와 얼마든지 공존한다. 실제로 내 머신에는 brew node 25.9.0과 fnm의 세 버전이 같이 살고 있다. 헷갈릴 땐 which node 한 번이면 어느 쪽이 실행되는지 바로 보인다. 로컬 버전 관리 자체가 지겨우면 devcontainer가 버전을 고정해주는 Codespaces로 넘어가는 길도 있다 — 그쪽은 이 글의 문제 자체가 사라진다.


글쓴이 — khio · 10년차 백엔드 개발자. 직접 겪은 문제와 해결 과정을 기록한다.
Share this post on:

Previous Post
JetBrains 환불 받은 후기 — All Products Pack 부분환불 5단계 정리
Next Post
Cloudflare Workers Cron Trigger가 안 돌 때 — 은퇴시킨 워커에서 배운 것들