MYMONDAY.ai
서비스스튜디오도구데이터·포맷73CSS 생성기19색상·이미지10인코딩·보안10웹·SEO11개발·데브옵스10파일·내보내기18계산기6가이드

7분 읽기 · 마지막 업데이트

npm의 ^와 ~ 차이: semver 범위가 풀리는 방식, 0.x와 프리릴리스 예외까지

^1.2.3 같은 범위는 비교 연산자 한 쌍의 줄임 표기이고, 0.x 버전이나 프리릴리스 같은 경계 사례에서는 누구나 한 번쯤 놀랍니다. npm이 쓰는 semver 라이브러리로 직접 확인한 정확한 전개 결과를 보여 줍니다.

Semantic Versioning 2.0.0이 약속하는 것

Semantic Versioning 2.0.0(semver.org)은 버전을 MAJOR.MINOR.PATCH, 즉 앞자리 0이 없는 음이 아닌 정수 세 개로 정의합니다. 약속의 대상은 공개 API입니다. 하위 호환되는 버그 수정은 PATCH, 하위 호환되는 기능 추가(와 기능의 deprecated 처리)는 MINOR, 호환이 깨지는 변경은 MAJOR를 올립니다. MAJOR를 올리면 MINOR와 PATCH는 0으로, MINOR를 올리면 PATCH가 0으로 돌아갑니다.

이후 내용을 이해하려면 두 규칙이 필요합니다. 하나는 배포한 버전은 절대 수정하지 않는다는 것입니다. 수정은 새 버전으로 냅니다. 다른 하나는 메이저 0(0.y.z)은 초기 개발 단계이며 언제든 무엇이든 바뀔 수 있다는 규칙입니다. 뒤에서 보듯 npm 범위가 0.x를 다르게 다루는 이유가 이것입니다.

semver는 저자의 약속일 뿐 레지스트리가 강제하는 규칙이 아닙니다. 패키지가 패치 릴리스에서 API를 깨도 막을 수단이 없고, 범위는 표기를 믿는 수밖에 없습니다. lock 파일이 필요한 가장 큰 이유입니다.

프리릴리스, 빌드 메타데이터, 우선순위

프리릴리스 버전은 하이픈과 점으로 구분한 식별자를 붙입니다(1.0.0-rc.1). 같은 버전에서 태그가 없는 쪽보다 우선순위가 낮습니다. 빌드 메타데이터는 1.0.0+build.5처럼 더하기 기호 뒤에 붙으며 비교에서 무시되므로 1.0.0+build.5와 1.0.0+build.9는 우선순위가 같습니다.

우선순위(명세 11절)는 왼쪽부터 비교합니다. MAJOR, MINOR, PATCH는 숫자로 비교하므로 1.10.0이 1.9.0보다 새 버전입니다. 문자열로 비교하면 반대로 나옵니다. 세 값이 같으면 프리릴리스가 붙은 쪽이 낮습니다. 프리릴리스 식별자는 하나씩 비교하는데, 숫자 식별자는 숫자로, 나머지는 ASCII 순서로 비교하고, 숫자 식별자가 문자 식별자보다 낮으며, 앞쪽이 모두 같으면 식별자가 더 많은 쪽이 높습니다.

아래는 명세가 직접 제시한 순서(낮은 것부터)입니다. semver.compare로 정렬해도 정확히 이 순서가 나옵니다.

1.0.0-alpha
1.0.0-alpha.1
1.0.0-alpha.beta
1.0.0-beta
1.0.0-beta.2
1.0.0-beta.11
1.0.0-rc.1
1.0.0

beta.2 < beta.11     (2와 11은 숫자로 비교)
"beta.11" < "beta.2" (문자열로 비교하면 이렇게 되는데, 이것이 피해야 할 버그)

npm 범위 문법과 각 형태가 풀리는 결과

범위는 Semantic Versioning 명세에 없는 개념입니다. npm은 node-semver 방언을 쓰고, 이 방언은 줄임 표기를 하나 이상의 비교 연산자(>=, >, <=, <, =)로 풀어냅니다. 공백으로 이은 비교자는 모두 만족해야 하고(AND), ||로 나눈 묶음은 둘 중 하나만 맞으면 됩니다(OR). 아래는 라이브러리가 출력하는 정규형이며, 상한 끝의 -0은 프리릴리스 절에서 설명합니다.

범위풀린 결과매칭 범위
^1.2.3>=1.2.3 <2.0.0-01.2.3부터 마지막 1.x.x까지
^1.2>=1.2.0 <2.0.0-01.2.0부터 마지막 1.x.x까지
^1>=1.0.0 <2.0.0-0모든 1.x.x
~1.2.3>=1.2.3 <1.3.0-01.2.3부터 마지막 1.2.x까지
~1.2>=1.2.0 <1.3.0-0모든 1.2.x
~1>=1.0.0 <2.0.0-0모든 1.x.x
1.x>=1.0.0 <2.0.0-0모든 1.x.x
1.2.x>=1.2.0 <1.3.0-0모든 1.2.x
**프리릴리스가 아닌 모든 버전
1.2.3 - 2.3.4>=1.2.3 <=2.3.4양 끝 포함
1.2.3 - 2.3>=1.2.3 <2.4.0-0상한이 일부만 적히면 다음 마이너로 올림
1.2.3 - 2>=1.2.3 <3.0.0-0상한이 일부만 적히면 다음 메이저로 올림
>1.2.3>1.2.31.2.4 이상(가장 낮은 매칭은 1.2.4)
18 || 20>=18.0.0 <19.0.0-0||>=20.0.0 <21.0.0-0모든 18.x.x 또는 20.x.x

캐럿과 틸드, 실무에서의 차이

캐럿(^)은 가장 왼쪽의 0이 아닌 자리를 바꾸지 않는 변경을 허용합니다. 보통의 1.x 이상 패키지에서는 마이너와 패치 업데이트를 받고 메이저 업데이트는 받지 않는다는 뜻입니다. 틸드(~)는 더 엄격해서, 마이너까지 적으면 패치 업데이트만 받습니다. 메이저만 적은 ~1은 모든 1.x.x를 허용합니다. node-semver는 ~=1.2도 ~1.2의 별칭(>=1.2.0 <1.3.0-0)으로 받지만, 이 표기에 PEP 440의 의미를 적용하면 안 됩니다.

npm install 패키지는 기본으로 ^를 기록합니다. 대부분의 저자가 마이너 릴리스에서도 semver를 지킨다고 보기 때문입니다. 버그 수정만 받고 싶다면, 예컨대 마이너 릴리스에서 이미 문제가 된 적 있는 도구라면 ~를 쓰세요. 깨지기 쉬운 의존성이라 업데이트보다 재현성이 중요하다면 정확한 버전으로 고정합니다.

특정 릴리스가 문제라는 것을 알고 있다면 경계를 직접 지정하는 비교자가 맞는 도구입니다. >=1.2.0 <1.5.0은 1.2.0부터 1.4.x까지 받고 1.5.0은 거부합니다. ^1.2.0 <1.5.0도 유효합니다. 한 묶음 안의 비교자는 AND로 합쳐져 >=1.2.0 <2.0.0-0 <1.5.0으로 풀립니다. 두 메이저를 동시에 지원한다면 ^17.0.0 || ^18.0.0처럼 ||를 씁니다.

후보: 1.2.2  1.2.3  1.2.9  1.3.0  1.9.0  2.0.0

^1.2.3   ->  1.2.3  1.2.9  1.3.0  1.9.0
~1.2.3   ->  1.2.3  1.2.9
1.2.3    ->  1.2.3

0.x 예외

1.0.0 미만에서는 명세가 무엇이든 바뀔 수 있다고 하므로 캐럿이 한 자리 오른쪽으로 옮겨 갑니다. 앞자리가 0이면 가장 왼쪽의 0이 아닌 자리가 고정됩니다. 마이너가 메이저 역할을 하고, 0.0.x에서는 패치가 메이저 역할을 합니다.

결과는 놓치기 쉽습니다. ^0.2.3은 0.2.9는 받지만 0.3.0은 받지 않습니다. ^0.0.3은 0.0.3만 받고 0.0.4는 거부합니다. 의존하는 라이브러리가 아직 0.x라면 캐럿 범위로는 다음 마이너 릴리스가 들어오지 않는데, 의도된 동작입니다.

범위풀린 결과매칭매칭 안 됨
^0.2.3>=0.2.3 <0.3.0-00.2.3, 0.2.90.3.0, 0.2.2
^0.0.3>=0.0.3 <0.0.4-00.0.3만0.0.4
^0.0<0.1.0-00.0.0부터 0.0.x 전부0.1.0
^0<1.0.0-0모든 0.x.x1.0.0

프리릴리스는 범위와 어떻게 만나는가

기본적으로 프리릴리스 버전은, 같은 묶음 안의 어떤 비교자가 같은 [major, minor, patch] 튜플에 프리릴리스 태그를 달고 있을 때만 범위를 만족합니다. 일반 범위가 불안정한 빌드를 실수로 고르는 일은 없습니다. 앞 표의 상한이 -0으로 끝나는 이유가 이것입니다. 2.0.0-0은 2.0.0 프리릴리스 중 가장 낮은 값이므로, <2.0.0-0은 2.0.0뿐 아니라 2.0.0-rc.1도 제외합니다.

프리릴리스를 직접 적으면 허용됩니다. ^2.0.0-alpha.0은 튜플 2.0.0이 같으므로 2.0.0-beta.1을 받지만, 튜플이 다른 2.0.1-beta.1, 2.1.0-beta.1은 받지 않습니다. 마찬가지로 ^1.2.3-beta.2는 1.2.3-beta.4와 1.2.3을 받고 1.2.4-beta.1은 거부합니다.

한 가지 결과가 자주 오해를 부릅니다. 2.0.0의 프리릴리스는 2.0.0보다 낮게 정렬되므로, 라이브러리의 includePrerelease 옵션을 켜도 ^2.0.0은 2.0.0-beta.1을 절대 받지 못합니다. >=2.0.0-alpha.0이나 ^2.0.0-0처럼 프리릴리스 이하에서 시작하는 범위여야 포함됩니다.

버전범위만족 여부
2.0.0-beta.1^2.0.0아니오
2.0.0-beta.1>=2.0.0-alpha.0예
2.0.0-beta.1^2.0.0-alpha.0예
2.0.1-beta.1^2.0.0-alpha.0아니오
2.1.0-beta.1^2.0.0아니오(includePrerelease를 켜면 예)
1.2.3-beta.4^1.2.3-beta.2예
1.2.4-beta.1^1.2.3-beta.2아니오
3.0.0-rc.1>=1.0.0아니오(includePrerelease를 켜면 예)
2.0.0-rc.1<2.0.0아니오

실제 버전 목록으로 범위 확인하기

범위가 무엇을 하는지 확인하는 가장 빠른 방법은 버전 목록을 넣고 어느 것이 통과하는지 보는 것입니다. 아래 결과는 semver 계산기의 샘플 목록에 ^1.2.3을 적용해 얻은 것으로, npm과 같은 semver 패키지를 씁니다. 가장 높은 매칭이 1.9.0이 아니라 1.10.0이고, 프리릴리스 2.0.0-beta.1도 2.0.0과 함께 제외된다는 점을 보세요.

range    ^1.2.3   ->   >=1.2.3 <2.0.0-0

3.0.0         no
2.1.0         no
2.0.0         no
2.0.0-beta.1  no
1.10.0        yes   <- 가장 높은 매칭
1.9.0         yes
1.2.4         yes
1.2.3         yes   <- 가장 낮은 매칭
1.2.0         no
1.1.0         no
1.0.0         no
0.9.0         no

lock 파일이 있어도 범위가 중요한 이유

범위는 받아들일 수 있는 버전을 말하고, lock 파일(package-lock.json)은 실제로 설치된 정확한 버전을 기록합니다. lock 파일이 있으면 npm ci는 그 버전 그대로 설치하고, npm install도 package.json을 계속 만족하는 한 기록된 버전을 유지합니다. 범위가 다시 작동하는 때는 패키지를 추가하거나 범위를 바꾸거나 npm update를 실행할 때입니다. npm update는 각 의존성을 범위가 허용하는 최신 버전으로 올립니다. npm outdated는 현재 버전, 범위 안의 최신(wanted), 전체 최신(latest)을 나란히 보여 줍니다.

정리하면 범위는 호환성을, lock 파일은 재현성을 담당합니다. 애플리케이션은 lock 파일을 커밋하고 범위는 업데이트가 쉽도록 적당히 느슨하게 둡니다. 라이브러리는 다릅니다. 사용자는 여러분의 lock 파일이 아니라 범위를 설치하므로, 실제로 테스트한 가장 넓은 범위를 선언해야 합니다. 호스트 프레임워크는 peerDependencies에 ^17.0.0 || ^18.0.0처럼 넓게 적습니다.

  • 너무 좁은 범위(1.2.3): package.json을 직접 고치지 않으면 보안 패치가 들어오지 않습니다.
  • 너무 느슨한 범위(*, >=1): lock 파일을 다시 만들 때 새 메이저가 조용히 들어옵니다.
  • lock 파일을 커밋하지 않음: 날짜가 다른 두 설치가 서로 다른 버전으로 풀릴 수 있습니다.

어느 숫자를 올릴지 정하는 기준

릴리스할 때는 공개 API를 호출하는 사람의 입장에서 판단합니다. 새 기능 없이 버그만 고치면 PATCH입니다. 기존 호출자를 깨지 않고 옵션, 함수, 필드를 더하면 MINOR입니다. 무언가를 제거하거나 이름을 바꾸고, 받아들이는 입력을 좁히고, 반환 타입이나 문서화된 동작을 바꾸면 코드 변경이 한 줄이어도 MAJOR입니다.

범위가 넘지 않는 경계가 MAJOR이므로, 잘못 붙인 표기는 사용자의 다음 설치에서 자동으로 장애를 만듭니다. 판단이 서지 않으면 더 큰 숫자를 올리세요. 메이저를 올리는 비용은 마이그레이션 안내문 하나이지만, 잘못 붙인 패치의 비용은 캐럿 범위를 쓰는 모든 사용자의 장애입니다.

변경올리는 숫자예
API 변화 없는 크래시 수정PATCH1.4.2 → 1.4.3
선택 매개변수나 새 함수 추가MINOR1.4.2 → 1.5.0
함수를 deprecated로 표시하되 계속 동작MINOR1.4.2 → 1.5.0
내보낸 함수 제거나 이름 변경MAJOR1.4.2 → 2.0.0
다음 메이저의 첫 릴리스 후보프리릴리스2.0.0-rc.1

범위를 쓸 때 자주 하는 실수

범위 버그는 대부분 몇 가지 습관에서 나옵니다. 위의 전개 결과와 대조해 보면 코드 리뷰에서 바로 눈에 띕니다.

  • ^가 어디서나 같은 뜻이라고 가정하기: ^1.2.3은 모든 1.x 릴리스를 포함하지만 ^0.2.3은 0.3.0 앞에서 멈춥니다.
  • ^2.0.0이 베타를 받아 줄 것이라고 기대하기: 같은 major.minor.patch의 프리릴리스를 범위에 적어야 합니다. 예를 들어 ^2.0.0-beta.1입니다.
  • 하이픈 범위에서 공백 빼먹기: 1.2.3 - 2.3.4는 유효하지만 1.2.3 -2.3.4는 잘못된 범위로 거부됩니다.
  • 완전한 버전이 필요한 곳에 일부만 적기: 범위에는 1.2를 쓸 수 있지만, 검사할 버전은 1.2.0이어야 합니다.
  • 버전을 문자열로 비교하기: 1.10.0은 사전순으로 1.9.0보다 앞서지만 더 새로운 릴리스입니다.
  • >1.2.3을 안전한 하한으로 믿기: 1.2.4부터 시작해 이후의 모든 메이저까지 허용하므로 <2.0.0 같은 상한을 함께 적으세요.

직접 해보기