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-0 | 1.2.3부터 마지막 1.x.x까지 |
| ^1.2 | >=1.2.0 <2.0.0-0 | 1.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-0 | 1.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.3 | 1.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.30.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-0 | 0.2.3, 0.2.9 | 0.3.0, 0.2.2 |
| ^0.0.3 | >=0.0.3 <0.0.4-0 | 0.0.3만 | 0.0.4 |
| ^0.0 | <0.1.0-0 | 0.0.0부터 0.0.x 전부 | 0.1.0 |
| ^0 | <1.0.0-0 | 모든 0.x.x | 1.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 nolock 파일이 있어도 범위가 중요한 이유
범위는 받아들일 수 있는 버전을 말하고, 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 변화 없는 크래시 수정 | PATCH | 1.4.2 → 1.4.3 |
| 선택 매개변수나 새 함수 추가 | MINOR | 1.4.2 → 1.5.0 |
| 함수를 deprecated로 표시하되 계속 동작 | MINOR | 1.4.2 → 1.5.0 |
| 내보낸 함수 제거나 이름 변경 | MAJOR | 1.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같은 상한을 함께 적으세요.