목차
들어가며
어느 평범한 아침, 코드를 하나도 안 바꿨는데 production deploy가 실패했다는 알림이 왔습니다. CI 로그를 열어 보니 빌드 도중 yarn install 단계에서 다음과 같은 에러가 떨어져 있었습니다.
error [email protected]: The engine "node" is incompatible with this module.
Expected version ">=20". Got "18.17.0"
error Found incompatible module.
js-cookie 라는 라이브러리는 우리 package.json에 직접 적은 적이 없습니다. 그리고 어제까지는 동일한 코드로 무사히 배포가 되고 있었습니다.
이 글은 “코드 변경이 없는데 어느 날 갑자기 빌드가 깨졌다” 류의 사고가 어떻게 일어나는지를, 위 사례를 따라가며 분석합니다. 결론부터 말하면 범인은 package.json의 caret(^) 범위와, 빌드 컨텍스트에서 빠져 있던 yarn.lock의 조합이었습니다.
어느 날 갑자기 production 빌드가 깨졌다
상황을 단순화하면 다음과 같습니다.
- Nuxt 2 기반의 프런트엔드 프로젝트
- Dockerfile은
node:18.17.0-alpine을 베이스로 빌드 - CI에서
yarn install --frozen-lockfile && yarn build실행
어제까지 잘 돌던 빌드가 오늘 깨졌고, 우리 저장소에는 푸시된 새 커밋이 없습니다. 즉 우리가 바꾼 것이 없는데 결과가 달라졌습니다. 이런 류의 사고는 정의상 “외부 환경의 어떤 것이 우리 모르게 바뀌었다”는 뜻입니다.
가장 흔한 후보는 두 가지입니다.
- 베이스 이미지의 패키지가 업데이트됨 — 이번엔 Node 버전을 명시적으로
18.17.0으로 핀했으므로 후보 제외. - npm registry의 의존성이 업데이트됨 — 의심해 볼 만함.
1단계 — 어떤 패키지가 들어왔는가
먼저 js-cookie가 어디서 들어오는지 추적합니다. 우리 package.json에는 직접 의존성으로 적혀 있지 않고, yarn.lock을 grep해도 js-cookie 항목이 보이지 않습니다. 즉 정상 lockfile 기반 의존성 트리에는 존재하지 않는 패키지입니다. 그런데 빌드 중에는 분명히 설치를 시도하고 있습니다.
이를 추적하기 위해 임시 디렉터리에서 lockfile 없이 yarn install을 다시 실행한 뒤, yarn why로 origin을 캡니다.
mkdir /tmp/trace && cd /tmp/trace
cp ~/project/package.json .
# private git dep는 인증이 필요하므로 한 줄 제외
grep -v "private-internal-lib" package.json > package.json.tmp && mv package.json.tmp package.json
yarn install --ignore-engines --ignore-scripts
yarn why js-cookie
결과:
info Found "[email protected]"
- "vue-jest#js-beautify" depends on it
- Hoisted from "vue-jest#js-beautify#js-cookie"
추적된 의존성 체인은 다음과 같습니다.
vue-jest (devDependency)
└─ js-beautify (^1.6.14)
└─ js-cookie (^3.0.5)
vue-jest는 Vue 컴포넌트를 Jest로 테스트하기 위한 devDependency입니다. 우리가 직접 import해서 쓰는 일은 없지만, 빌드 단계에서 yarn install이 devDep까지 모두 설치하기 때문에 (Docker builder stage에서는 --production이 아님) 그 transitive까지 함께 따라들어옵니다.
2단계 — 왜 어제는 됐고 오늘은 안 되는가
js-cookie의 npm registry 히스토리를 들여다보면 답이 명확합니다. 다음은 semver 검색기와 registry API로 확인한 3.x 라인의 publish 이력입니다.
| 버전 | publish 일자 | engines |
|---|---|---|
| 3.0.5 | 2023-04-24 | node: '>=14' |
| 3.0.6 | 2026-05-15 | node: '>=20' |
| 3.0.7 | 2026-05-16 | node: '>=20' |
3.0.5 → 3.0.6으로 가는 사이에 engines 요구가 >=14에서 >=20으로 올라갔습니다. semver 규칙상 이는 메이저를 올려야 할 만큼의 변경(major bump)으로 보는 게 일반적이지만, 메인테이너는 patch 번호(3.0.6)로 release했습니다. 우리 Node 18 환경에서는 이 patch가 들어오는 순간 engine 체크에서 실패합니다.
핵심 질문은 이것입니다: 우리는 js-cookie 어느 버전도 직접 지정한 적이 없는데, 왜 새로 published된 3.0.6/3.0.7이 우리 빌드에 들어오는가?
답은 의존성 체인의 가장 안쪽 link에 박혀 있는 caret 범위입니다.
// [email protected] 의 package.json 안:
"dependencies": {
"js-cookie": "^3.0.5"
}
^3.0.5라는 표기는 “semver의 caret range”이며, “3.x.x 범위에서 3.0.5 이상의 모든 버전”을 의미합니다. 즉 3.0.6도, 3.0.7도, 3.99.99도 매치됩니다. yarn은 매번 resolve할 때 이 범위에 매치되는 registry의 최신 버전을 선택합니다. 2026-05-15 이후로는 그 최신이 3.0.6/3.0.7이 되었기 때문에, 어제는 3.0.5가, 오늘은 3.0.7이 우리 컨테이너 안으로 들어온 것입니다.
3단계 — 그렇다면 왜 lockfile이 막아주지 못했는가
여기서 의문이 듭니다. 우리 저장소에는 yarn.lock이 멀쩡히 있고, 그 안에는 js-cookie가 아예 없다고 위에서 확인했습니다. lockfile만 제대로 쓰였다면 js-cookie는 트리에 등장하지도 않았을 것입니다. 그런데 빌드는 그것을 가져와 설치하려 했습니다.
Dockerfile을 다시 봅니다.
COPY package.json ./
RUN yarn install --frozen-lockfile
문제가 보입니다. yarn.lock을 빌드 컨텍스트로 복사하지 않고 있습니다. 빌더 스테이지에 yarn.lock이 없으면 yarn 1.x는 --frozen-lockfile 플래그가 지정되어 있어도 lockfile이 존재하지 않으니 그냥 새로 resolve해서 install합니다. 즉 매번 등록된 registry의 현재 시점 latest를 가져오게 되고, 그러다 보니 외부에서 누가 새 patch를 publish하면 우리 빌드가 그것을 따라갑니다.
이 사실은 사고를 풀어가며 깨달은 가장 중요한 부분입니다. 락파일이 빌드 컨텍스트에 없는 상태는 “우리 의존성 트리가 npm registry에 새로 publish된 패키지 버전에 따라 매번 달라질 수 있다”는 말과 같습니다.
수정은 단순합니다.
COPY package.json yarn.lock ./
RUN yarn install --frozen-lockfile
이렇게 두 줄이 되면 yarn은 lockfile에 적힌 정확한 버전(우리 case에서는 js-cookie가 아예 없는 상태)을 그대로 재현합니다. 외부 registry에서 무엇이 publish되든 우리 트리는 변하지 않습니다.
package.json의 caret(^)은 무엇을 의미하는가
여기서 한 걸음 떨어져, semver와 caret을 정리하고 갑니다. 이 사고의 핵심 메커니즘이 caret이기 때문입니다.
npm의 Semantic Versioning 문서에 따르면 버전은 MAJOR.MINOR.PATCH로 구성되며, 각각 다음과 같은 의미를 갖습니다.
| 자리 | 의미 |
|---|---|
| MAJOR | 호환되지 않는 API 변경 (breaking change) |
| MINOR | 하위 호환을 지키면서 기능 추가 |
| PATCH | 하위 호환을 지키는 버그 수정 |
package.json의 dependency 범위 표기는 이 셋 중 어디까지 자동 업데이트를 허용할지를 결정합니다. 그중 가장 흔히 쓰이는 게 caret(^)입니다.
npm CLI 문서의 caret-range 정의에 따르면 caret은 “가장 왼쪽 0이 아닌 자리를 고정하고, 그 아래로는 모든 업데이트를 허용한다” 입니다.
| 표기 | 매치 범위 | 메모 |
|---|---|---|
^1.2.3 | >=1.2.3 <2.0.0 | MAJOR 자리(1)가 고정 |
^0.2.5 | >=0.2.5 <0.3.0 | 가장 왼쪽 nonzero가 MINOR(2)이므로 MINOR가 고정 |
^0.0.4 | >=0.0.4 <0.0.5 | 가장 왼쪽 nonzero가 PATCH(4)이므로 PATCH가 고정 |
마지막 두 case가 중요합니다. 0.x 라인은 semver상 “아직 불안정한 단계”로 취급되기 때문에 caret이 보수적으로 동작합니다. 어떤 라이브러리를 도입할 때 0.x 라인의 caret과 1.x 라인의 caret이 같은 의미가 아니라는 점을 기억해 둘 만합니다.
특정 caret 표기가 정확히 어떤 버전 집합을 매치하는지는 npm의 semver 계산기에서 즉시 확인할 수 있습니다. registry의 실제 publish된 버전 목록과 함께 보여 주기 때문에 디버깅에 유용합니다.
이 사건에서 문제가 된 caret은 우리가 적은 게 아니었습니다. vue-jest 메인테이너가 자신의 package.json에 적은 “js-beautify”: “^1.6.14”, 그리고 js-beautify 메인테이너가 적은 “js-cookie”: “^3.0.5”입니다. 둘 다 caret이기 때문에 같은 major 안에서 새로 publish되는 모든 버전이 자동으로 우리 트리에 흘러들어옵니다.
caret을 떼면 해결되는가 — 흔한 오해
“그럼 우리 package.json의 모든 caret을 떼서 exact pin하면 되지 않나?”
이번 사고를 추적하다 자연스럽게 떠오르는 대응책이고, 같이 일하던 동료도 같은 질문을 했습니다. 답은 “부분적으로만, 그리고 우리 사고는 해결하지 못함” 입니다.
핵심은 caret이 누구의 package.json에 있느냐입니다.
[ 우리 package.json ] ← 여기 caret을 떼면 우리 직접 deps만 핀됨
└─ [email protected]
[ vue-jest 의 package.json ] ← 여기 caret은 우리 통제 밖
└─ js-beautify ^1.6.14
[ js-beautify 의 package.json ] ← 여기도 우리 통제 밖
└─ js-cookie ^3.0.5 ← 사고의 진원지
우리 package.json에서 "vue-jest": "^3.0.4"를 "vue-jest": "3.0.4"로 고정한다 해도, vue-jest 자신이 가지고 있는 "js-beautify": "^1.6.14"의 caret은 그대로 살아 있습니다. 그 결과 fresh resolve할 때 여전히 latest js-beautify가, 그리고 그것이 끌어오는 latest js-cookie가 트리에 들어옵니다.
다시 말해 transitive drift는 우리 package.json의 표기 방식이 아니라 lockfile의 사용 여부로만 막을 수 있는 것입니다. caret을 떼는 작업은 직접 의존성의 minor 자동 업데이트를 제한할 뿐, transitive까지 통제하려면 lockfile 또는 yarn의 resolutions 필드와 같이 트리 전체를 제어하는 메커니즘이 필요합니다.
이는 평소에는 잘 의식되지 않다가 사고가 났을 때만 떠오르는 부분입니다. 평소 caret이 “범위로 잡힌 채” 있어도 lockfile이 잘 동작하고 있으면 trees는 그대로 재현되므로, 표면적으로는 caret이 무해해 보입니다.
정리
이 사고는 단 한 줄짜리 미흡한 Dockerfile에서 시작해, 외부 registry의 새 patch publish가 트리거가 되어 빌드를 무너뜨렸습니다. 다시 한번 시간순으로 정리합니다.
- Dockerfile이 빌드 컨텍스트에
yarn.lock을 복사하지 않고 있었음. yarn install --frozen-lockfile은 lockfile이 존재하지 않으면 사실상 fresh resolve로 동작.- 평소에는 운 좋게 의존성 트리가 “어제와 같음”이었으나, registry 쪽이 변하면 트리도 변함.
js-cookie메인테이너가 patch 버전(3.0.6)에서 engines를>=14→>=20으로 변경.- 우리 caret(정확히는 transitive의 caret)이 그 patch까지 매치 → fresh resolve가 그것을 끌어와 설치 시도.
- Node 18 환경에서 engine 체크 실패 → production deploy 멈춤.
수정 본질은 두 줄입니다. lockfile을 빌드 컨텍스트에 포함시키고, --frozen-lockfile로 그것을 강제로 사용한다. 이 한 가지만 지키면 외부의 변덕이 우리 빌드를 흔드는 통로가 닫힙니다. caret을 어떻게 쓰느냐와는 별도의 layer 문제입니다.
semver는 메인테이너의 약속이고, caret은 그 약속에 대한 우리의 신뢰 표현입니다. 둘 다 사람의 판단에 기반하기 때문에 본질적으로 깨질 수 있고, 깨졌을 때 우리 빌드를 보호해 주는 마지막 안전망이 lockfile입니다.
참고 자료
- npm Semantic Versioning 개요 (About semantic versioning)
- npm CLI 문서 — Caret ranges
- npm semver 계산기
- Yarn 1 — Selective version resolutions
제 블로그가 도움이 되셨나요? 하단의 댓글을 달아주시면 저에게 큰 힘이 됩니다!
앱 홍보
Deku가 개발한 앱을 한번 사용해보세요.Deku가 개발한 앱은 Flutter로 개발되었습니다.관심있으신 분들은 앱을 다운로드하여 사용해 주시면 정말 감사하겠습니다.