npm 버전 표기법 캐럿(^), 0.x 버전 주의점

npm 캐럿(^) 버전 범위, 0.x 버전 주의점

3줄 요약

  • 캐럿(^)은 버전의 왼쪽에서 처음 만나는 0이 아닌 자리를 고정합니다. 그래서 ^7.0.0은 7.x 전체를, ^0.124.0은 0.124.x만 허용합니다.
  • 2026-09-12 기준 실측으로 ^7.0.0은 45일 동안 openai 15개 버전을 지나 5.08MB와 export 431개가 늘었고, 같은 기간 ^0.124.0은 만족 버전이 1개라 그대로 멈춰 있었습니다.
  • 지금 쓰는 AI SDK의 버전이 0.x인지 1.0 이상인지부터 확인하시고, 아래 조회 스크립트로 자기 package.json의 실제 드리프트 폭을 재 보시면 됩니다.

node_modules 용량이 286MB로 찍혀 있었습니다. 설치한 것은 AI SDK 세 개뿐이었고 package.json의 dependencies는 세 줄이었습니다. 파일 수는 9,545개였습니다.

세 줄 중 어느 것도 정확한 버전을 적어 두지 않았습니다. ^7.0.0, ^0.124.0, ^0.3.260. 전부 앞에 캐럿이 붙어 있었고, 저는 세 줄이 같은 방식으로 느슨하다고 생각하고 있었습니다. 실제로 설치된 것은 7.15.0, 0.124.0, 0.3.269였습니다. 하나는 열다섯 칸을 갔고 하나는 아홉 칸을 갔고 하나는 제자리였습니다.

1. 세 가지 선언 방식이 45일 뒤 어디에 가 있는가

먼저 결과 표입니다. 이후 섹션이 각 행의 근거를 채웁니다. 대상은 2026-07-28에 openai 7.0.0이 npm에 올라온 날부터 2026-09-12 08시(KST)까지이며, 숫자는 전부 registry.npmjs.org가 반환한 값입니다.

선언 실제 해석 범위 만족 버전 수 오늘 설치되는 값 45일 증가분
openai@^7.0.0 >=7.0.0 <8.0.0 15개 7.15.0 +5.08MB, export +431
openai@~7.14.0 >=7.14.0 <7.15.0 1개 7.14.0 0
@anthropic-ai/sdk@^0.124.0 >=0.124.0 <0.125.0 1개 0.124.0 0
@anthropic-ai/claude-agent-sdk@^0.3.260 >=0.3.260 <0.4.0 8개 0.3.269 +0.10MB, export +8
ai@^7.0.0 >=7.0.0 <8.0.0 93개 7.0.98 (라인 3종 동시 유지)

한 줄로 정리하면 이렇습니다. 캐럿을 붙였다는 사실만으로는 아무것도 알 수 없고, 그 패키지가 0.x인지 1.0 이상인지를 봐야 합니다. 같은 기호가 한쪽에서는 마이너 열다섯 개를 통과시키고 다른 쪽에서는 한 칸도 움직이지 않습니다.

2. 캐럿이 0.x에서 다르게 동작하는 이유

규칙은 애매하지 않습니다

npm이 쓰는 범위 해석기는 semver 패키지입니다. npm 10.9.8이 번들한 버전은 7.7.4였고, 별도로 받은 최신 배포본은 7.8.5였습니다. 그 패키지에 들어 있는 README.md가 규칙 원문입니다.

캐럿은 "[major, minor, patch] 세 자리 중 왼쪽에서 처음 만나는 0이 아닌 자리를 바꾸지 않는 변경"을 허용합니다. 1.0.0 이상에서는 major가 0이 아니므로 minor와 patch가 열립니다. 0.1.0 이상 0.x에서는 minor가 그 자리이므로 patch만 열립니다. 0.0.x에서는 아예 아무것도 열리지 않습니다.

문서는 여기에 판단 근거까지 적어 두었습니다. 많은 저자가 0.x의 x를 사실상 major처럼 쓰고, 0.2.4와 0.3.0 사이에 호환성을 깨는 변경을 넣는 것이 흔한 관행이라는 것입니다. 캐럿은 그 관행을 그대로 따릅니다.

선언과 설치 결과를 같은 자리에서 봅니다

말로 읽으면 헷갈리므로 직접 설치해 봤습니다. 락파일 없이 npm install 한 번입니다.

{
  "name": "drift-test",
  "private": true,
  "dependencies": {
    "openai": "^7.0.0",
    "@anthropic-ai/sdk": "^0.124.0",
    "@anthropic-ai/claude-agent-sdk": "^0.3.260"
  }
}

결과는 openai 7.15.0, @anthropic-ai/sdk 0.124.0, @anthropic-ai/claude-agent-sdk 0.3.269였습니다. 세 줄이 똑같이 생겼는데 한 줄만 선언값 그대로 멈췄습니다.

여기서 오해하기 쉬운 지점이 하나 있습니다. ^0.124.0이 움직이지 않은 것은 그 SDK가 조용해서가 아닙니다. 같은 기간에 0.125.0이 나왔고, 그 안에는 뒤에서 볼 새 기능이 들어 있었습니다. 캐럿이 그 경계를 넘지 않기로 되어 있을 뿐입니다.

0.x 패키지에 캐럿을 붙이면 사실상 고정입니다

이것은 장점일 수도 단점일 수도 있습니다. 보안 패치를 자동으로 받고 싶었다면 ^0.124.0은 기대를 배신합니다. 0.124.x에 패치가 하나도 올라오지 않으면 영원히 0.124.0에 머뭅니다. 반대로 예측 가능성을 원했다면 이미 원하는 상태입니다.

문제는 둘 중 어느 쪽을 원했는지 모르고 붙였을 때입니다. 저는 몰랐습니다.

3. 45일 동안 실제로 얼마나 늘었나

openai 7.0.0에서 7.15.0까지

각 버전의 tarball을 받아 풀고 직접 셌습니다. .d.ts 파일에서 export 선언 이름을 뽑아 집합으로 비교하는 방식입니다.

버전 npm 게시(KST) export 선언 파일 압축 해제 크기 직전 대비 추가/제거
7.0.0 2026-07-28 06:56 1,486 2,701 11.89MB —
7.5.0 2026-08-18 04:18 1,534 2,747 13.07MB +48 / 0
7.10.0 2026-09-04 04:56 1,629 2,953 14.85MB +95 / 0
7.14.0 2026-09-11 02:40 1,747 3,242 15.98MB +118 / 0
7.15.0 2026-09-11 04:46 1,917 3,512 16.97MB +170 / 0

45일 동안 export 선언이 1,486개에서 1,917개로 29.0% 늘었고 크기는 42.7% 늘었습니다. 실제 npm install 기준으로는 node_modules가 20MB에서 28MB가 됐습니다.

마이너 하나가 API 하나를 통째로 들고 옵니다

7.15.0이 무엇을 더했는지는 패키지에 동봉된 CHANGELOG.md에 한 줄로 적혀 있습니다. Agents API 추가입니다. 그 한 줄의 실물은 resources/beta/agents와 lib/agents 아래 파일 270개, 합계 788,792바이트였습니다. 직전 7.14.0은 Live API를 같은 방식으로 들고 왔고, 두 버전의 간격은 2시간 6분이었습니다.

이 패턴은 예외가 아니라 기본값에 가깝습니다. openai 7.x의 npm 게시본 15개 중 패치 릴리스는 7.12.1 하나뿐이고 나머지 14개가 전부 마이너입니다. 즉 ^7.0.0을 쓰는 동안 들어오는 변경은 대부분 "기능 한 덩어리"입니다.

릴리스 속도는 패키지마다 80배 차이납니다

같은 기준으로 AI 관련 패키지 여덟 개의 최근 30일 릴리스 수를 셌습니다.

패키지 30일 90일 90일 중앙 간격 최신 버전과 게시(KST)
ai 80 241 1.3시간 7.0.98 · 09-12 03:56
@anthropic-ai/claude-agent-sdk 32 78 23.4시간 0.3.269 · 09-12 03:15
openai 10 22 54.3시간 7.15.0 · 09-11 04:46
@anthropic-ai/sdk 9 28 27.1시간 0.125.0 · 09-11 02:55
langchain 7 17 77.7시간 1.5.11 · 09-10 05:43
@langchain/core 5 12 135.8시간 1.2.10 · 09-10 05:44
gpt-tokenizer 1 1 — 4.0.0 · 08-16 11:34
@modelcontextprotocol/sdk 0 1 — 1.30.0 · 07-28 02:56

위아래가 80배입니다. 같은 사내 규칙을 이 여덟 개에 똑같이 적용하는 것이 왜 무리인지는 이 표 하나로 충분합니다. @modelcontextprotocol/sdk에 캐럿을 붙이는 것과 ai에 캐럿을 붙이는 것은 서로 다른 일입니다.

4. 파일 diff는 변화를 과장합니다

1,735개 중 813개가 달라졌다는 말

@anthropic-ai/sdk 0.124.0과 0.125.0을 파일 단위로 비교하면 겁이 납니다. 1,735개 중 동일한 것이 922개뿐이고 813개가 달랐습니다. 버전 문자열만 다른 파일은 6개였으니 나머지 807개는 "내용이 다른" 파일입니다.

그런데 변경된 파일을 유형별로 세면 소스맵이 444개, .js와 .mjs와 .ts가 각각 112개씩이었습니다. 그 세 묶음 336개를 한 줄씩 대조하니 214개는 생성 도구가 파일 맨 위에 넣던 주석 한 줄이 빠진 것뿐이었고, 나머지 119개만 다른 변경을 함께 담고 있었습니다. 체인지로그에 그 작업이 적혀 있습니다. 생성 파일에 패키지 버전을 찍어 넣는 일을 그만뒀다는 내용입니다.

export 이름으로 다시 세면

같은 두 버전을 export 선언 이름으로 비교하면 추가 11개, 제거 0개입니다. 추가된 것은 Managed Agents의 도구 권한 관련 타입과 사용자 프로필 관련 타입이었습니다.

비교 단위 @anthropic-ai/sdk 0.124.0→0.125.0 openai 7.14.0→7.15.0
파일 diff 813개 변경 63개 변경
그중 소스맵 444개 (신규 파일 270개 별도)
export 선언 추가 11개 170개
export 선언 제거 0개 0개

파일 diff만 보면 앞쪽이 13배 큰 변경으로 보이지만, 실제로 코드가 닿는 표면은 뒤쪽이 15배 큽니다. 업그레이드 판단을 파일 개수로 하면 정확히 거꾸로 갑니다.

제거는 어디서 일어났나

궁금한 것은 추가가 아니라 제거입니다. 지우는 쪽이 코드를 깨기 때문입니다. 45일 구간에서 openai 7.x는 제거가 한 건도 없었습니다. @anthropic-ai/sdk는 달랐습니다.

0.115.0에서 0.120.0으로 갈 때 4개가 사라졌고 그중에 addRequestID가 있었습니다. 0.120.0에서 0.124.0으로 갈 때는 12개가 사라졌는데 SkillCreateResponse, SkillListResponse, resolveSkillVersion 같은 Skill 관련 이름이 통째로 묶여 있었습니다.

여기서 앞의 규칙과 맞물립니다. 제거가 일어난 자리는 전부 0.x의 마이너 경계이고, 캐럿은 0.x에서 그 경계를 넘지 않습니다. 캐럿이 보수적으로 동작하는 지점과 실제로 위험한 지점이 우연히 겹쳐 있는 셈입니다. 다만 이 측정은 export 이름 기준이라, 인터페이스 안쪽 필드가 조용히 빠진 경우는 잡지 못합니다.

5. 체인지로그의 버전과 설치 가능한 버전은 다릅니다

npm에 없는 7.11.0과 7.12.0

openai 7.x의 체인지로그에는 7.12.0이 나옵니다. 그런데 registry.npmjs.org의 time 필드에는 7.11.0도 7.12.0도 없습니다. 7.12.1 항목에 이유가 적혀 있습니다. 7.12.0의 내용을 포함하며 7.12.0은 npm에 게시되지 않았다는 설명입니다.

이런 구멍은 검색으로는 잘 안 보입니다. 릴리스 요약 사이트나 주간 정리 글은 체인지로그를 그대로 옮기기 때문에 없는 버전을 있는 것처럼 적습니다. 패키지 레지스트리의 time 필드가 게시 여부를 판정하는 유일한 1차 근거입니다. 같은 이유로 저는 MCP 서버가 한 줄짜리 오류만 남기고 죽던 문제를 추적할 때도 버전 번호가 아니라 게시 타임스탬프로 순서를 다시 세웠고, 그때 번호 순서와 시간 순서가 어긋나는 사례를 실제로 만났습니다.

메이저 라인 세 개를 동시에 유지하는 경우

ai 패키지는 더 극단적입니다. dist-tags에 latest(7.0.98) 말고도 ai-v6(6.0.281), ai-v5(5.0.256)가 따로 걸려 있고, 최근 30일 릴리스 80개가 7.x 32개, 6.x 27개, 5.x 21개로 갈려 있습니다. 2026-09-12 03시 55분부터 03시 56분 사이에 세 라인이 94초 안에 차례로 올라갔습니다.

이 구조에서 latest만 보고 "지금 버전"을 말하면 절반은 틀립니다. 어떤 라인을 쓰고 있는지를 먼저 말해야 합니다.

6. 어디까지 하고 멈출 것인가

팀 규모가 작으면 의존성 정책을 촘촘하게 만들수록 아무도 지키지 않습니다. 저는 세 가지만 남기는 쪽을 택했습니다.

판단 항목 캐럿 그대로 락파일 커밋 + 캐럿 정확한 버전 고정
도입 난이도 없음 파일 한 개 커밋 선언 전부 수정
유지보수 비용 낮음(대신 재현 불가) 중간(갱신 PR 검토) 높음(수동 추적)
팀 학습곡선 낮음(대신 오해 그대로) 낮음 중간(0.x 규칙 숙지 필요)
재현성 설치 시점에 따라 다름 보장됨 보장됨
권장 대상 로컬 실험 대부분의 저장소 릴리스 산출물·재현 실험

첫째, 락파일을 커밋합니다. 캐럿의 해석 규칙을 팀 전원이 외우게 만드는 것보다 이쪽이 훨씬 쌉니다. 캐럿을 남겨 두어도 npm ci가 락파일의 값으로 설치하므로 설치 시점이 결과를 바꾸지 않습니다.

둘째, 0.x 패키지에 붙은 캐럿은 "자동 업데이트"로 읽지 않습니다. @anthropic-ai/sdk 0.125.0이 나왔어도 ^0.124.0은 그대로 있으므로, 올릴지 말지를 사람이 결정해야 합니다. 결정하지 않으면 올라가지 않습니다.

셋째, 업그레이드 검토는 파일 diff가 아니라 export 표면과 체인지로그로 합니다. 807개 파일이 달라졌다는 숫자와 export 11개가 늘었다는 숫자 중 후자가 판단 재료입니다.

자기 package.json을 재 보는 스크립트

아래를 저장소 루트에서 돌리면 선언한 범위가 실제로 몇 개 버전을 통과시키는지 나옵니다. 네트워크는 registry.npmjs.org만 씁니다.

# drift.py — package.json의 각 의존성이 몇 개 버전을 통과시키는지 셉니다
import json, re, urllib.request

def parse(v):
    m = re.match(r"^(\d+)\.(\d+)\.(\d+)(?:-(.+))?$", v)
    return None if not m else (int(m[1]), int(m[2]), int(m[3]), m[4])

def upper(base, op):
    M, m, p, _ = base
    if op == "~":                      # 틸드: 마이너 고정
        return (M, m + 1, 0)
    if M > 0:                          # 캐럿, 1.0.0 이상: 메이저 고정
        return (M + 1, 0, 0)
    if m > 0:                          # 캐럿, 0.x: 마이너 고정
        return (0, m + 1, 0)
    return (0, 0, p + 1)               # 캐럿, 0.0.x: 패치까지 고정

deps = json.load(open("package.json")).get("dependencies", {})
for name, rng in deps.items():
    op = rng[0] if rng[0] in "^~" else ""
    base = parse(rng.lstrip("^~"))
    if not base:                       # 별칭·git·태그 선언은 건너뜁니다
        print(f"{name:<34}{rng:<12}건너뜀"); continue
    url = "https://registry.npmjs.org/" + name
    vs = json.load(urllib.request.urlopen(url, timeout=60))["versions"]
    lo, hi = base[:3], upper(base, op)
    ok = sorted((parse(v)[:3] for v in vs
                 if parse(v) and not parse(v)[3] and lo <= parse(v)[:3] < hi))
    top = ".".join(map(str, ok[-1])) if ok else "-"
    print(f"{name:<34}{rng:<12}{len(ok):>4}개 통과, 오늘 설치 {top}")

게시 시각까지 같이 보고 싶으면 아래 한 줄이면 됩니다. 릴리스 요약 페이지 대신 레지스트리를 직접 읽는 습관이 결국 시간을 아껴 줍니다. 한글 원고의 토큰 수를 셀 때 npm 패키지의 게시 타임스탬프부터 확인했던 것과 같은 이유입니다.

# 특정 패키지의 최근 게시 이력을 KST로 출력합니다
python3 - <<'PY'
import json, urllib.request, datetime
KST = datetime.timezone(datetime.timedelta(hours=9))
d = json.load(urllib.request.urlopen("https://registry.npmjs.org/openai", timeout=60))
rows = sorted((datetime.datetime.fromisoformat(t.replace("Z", "+00:00")).astimezone(KST), v)
              for v, t in d["time"].items() if v not in ("created", "modified"))
for dt, v in rows[-10:]:
    print(f"{v:<12}{dt:%Y-%m-%d %H:%M KST}")
PY

마지막으로 점검 목록입니다.

□ 락파일이 저장소에 커밋돼 있는가 (CI는 npm ci 를 쓰는가)
□ dependencies 중 0.x 패키지가 몇 개인가 — 그 캐럿은 사실상 고정이다
□ 릴리스 속도가 주 5회 이상인 패키지가 있는가 (있으면 별도 갱신 주기)
□ 업그레이드 검토 때 체인지로그와 export 변화를 봤는가
□ 체인지로그에만 있고 레지스트리에 없는 버전을 근거로 쓰지 않았는가

댓글

이 블로그의 인기 게시물

옵시디언 3기기 동기화, 유료 결제 없이 구글 드라이브로 끝내기

최저가 주유소, 얼마나 돌아가면 손해일까?

회의시간 줄이는 법, 개수보다 배치 먼저

유류세 인하 2개월 연장, 주유소 가격 실제 인하폭 계산

국민연금 추납 조회, 미루면 얼마나 더 낼까?