유의적 버전(Semantic Versioning, SemVer) 완벽 정리
package.json을 열어 보면 "react": "^18.2.0" 같은 줄이 잔뜩 보이는데요. 여기서 18.2.0이라는 숫자 세 개가 정확히 무엇을 뜻하는지, 앞에 붙은 ^ 기호는 또 무슨 의미인지 자신 있게 설명할 수 있으신가요? 🤔
저도 한동안은 “그냥 버전 번호겠지” 하고 넘겼는데요. 막상 npm install 한 번에 의존성이 깨지거나, 내가 만든 라이브러리의 버전을 올려야 하는 입장이 되면 이 숫자들의 규칙을 모르고는 한 발짝도 나아가기 어렵습니다.
이 숫자 세 개에는 유의적 버전(Semantic Versioning, 줄여서 SemVer)이라는 엄연한 약속이 담겨 있습니다. 이 글에서는 SemVer가 무엇이고 각 자리가 어떤 의미인지, 그리고 ^나 ~ 같은 범위 표기를 어떻게 읽어야 하는지까지 차근차근 정리해 보겠습니다.
버전 번호는 왜 필요할까요?
소프트웨어는 한 번 만들고 끝나지 않습니다. 버그를 고치고, 기능을 추가하고, 때로는 기존 동작을 갈아엎기도 하죠. 문제는 내가 만든 패키지를 다른 사람이 가져다 쓸 때 발생합니다.
예를 들어 제가 awesome-lib라는 라이브러리를 배포했고, 여러분이 이걸 프로젝트에 설치해 잘 쓰고 있다고 해 볼게요. 그런데 어느 날 제가 함수 이름을 getData에서 fetchData로 바꿔 버린 새 버전을 올렸습니다. 아무것도 모른 채 최신 버전을 받으면 멀쩡하던 코드가 그대로 멈춰 버립니다. 💥
이런 혼란을 막으려면 “이번 업데이트가 안전한지, 위험한지”를 버전 번호만 보고도 판단할 수 있어야 합니다. SemVer는 바로 이 판단을 가능하게 해 주는 규칙입니다. 버전 번호를 단순한 일련번호가 아니라 의미를 담은 신호로 쓰자는 것이죠. 이름에 “유의적(semantic)”, 즉 “의미가 있는”이 붙은 이유입니다.
MAJOR.MINOR.PATCH 세 자리의 의미
SemVer의 핵심은 버전을 점(.)으로 구분된 세 개의 숫자로 표현한다는 점입니다.
MAJOR . MINOR . PATCH
1 . 4 . 2
각 자리는 다음과 같은 의미를 가집니다.
우선 맨 앞의 MAJOR(주 버전)는 기존과 호환되지 않는 변경, 즉 하위 호환성이 깨지는 변경(breaking change)이 있을 때 올립니다. 앞서 예로 든 getData → fetchData처럼 사용자의 코드를 고쳐야만 하는 변경이 여기에 해당합니다. 1.x.x에서 2.0.0으로 올라갔다면 “그냥 업데이트하면 깨질 수 있으니 변경 사항을 꼭 확인하라”는 경고로 읽어야 합니다.
다음으로 가운데의 MINOR(부 버전)는 하위 호환성을 유지하면서 기능을 추가할 때 올립니다. 새 함수나 옵션이 생겼지만 기존 코드는 그대로 동작하는 경우죠. 1.4.0에서 1.5.0이 되었다면 “새 기능이 생겼지만 기존 코드는 안전하다”는 뜻입니다.
마지막으로 맨 뒤의 PATCH(수 버전)는 하위 호환되는 버그 수정일 때 올립니다. 동작은 그대로인데 잘못된 부분만 바로잡은 경우입니다. 1.4.1에서 1.4.2가 되었다면 마음 놓고 올려도 되는, 가장 안전한 업데이트입니다.
정리하면 이렇게 한 줄로 외울 수 있습니다.
호환성을 깨면 → MAJOR (1.4.2 → 2.0.0)
기능을 더하면 → MINOR (1.4.2 → 1.5.0)
버그만 고치면 → PATCH (1.4.2 → 1.4.3)
한 가지 중요한 규칙은 윗자리를 올리면 아랫자리는 0으로 초기화된다는 점입니다. MINOR를 올리면 PATCH가 0이 되고(1.4.2 → 1.5.0), MAJOR를 올리면 MINOR와 PATCH가 모두 0이 됩니다(1.5.3 → 2.0.0). 자릿수가 올라가는 자동차 주행거리계를 떠올리면 직관적입니다.
직접 버전을 올려 보기
말로만 들으면 추상적이니 실제로 손을 움직여 보겠습니다. npm에는 npm version 명령어가 있어서 SemVer 규칙에 맞게 버전을 올리고 package.json까지 자동으로 고쳐 줍니다.
먼저 간단한 패키지를 하나 만들어 볼게요.
mkdir semver-demo && cd semver-demo
bun init -y # 또는 npm init -y
package.json에는 보통 "version": "1.0.0"이 기본값으로 들어갑니다. 이제 버그를 하나 고쳤다고 가정하고 PATCH를 올려 보겠습니다.
npm version patch
v1.0.1
1.0.0이 1.0.1로 올라갔습니다. 이번엔 새 기능을 추가했다고 가정하고 MINOR를 올려 보죠.
npm version minor
v1.1.0
PATCH 자리가 0으로 초기화되면서 1.1.0이 되었습니다. 마지막으로 호환성을 깨는 변경을 했다고 가정하고 MAJOR를 올리면 어떻게 될까요?
npm version major
v2.0.0
MINOR와 PATCH가 모두 0으로 돌아가며 2.0.0이 되었습니다. 이렇게 npm version 명령어는 자리만 알려 주면 초기화 규칙까지 알아서 처리해 줍니다. 참고로 이 명령어는 Git 저장소 안에서 실행하면 버전 커밋과 git tag까지 자동으로 만들어 주는데요. 실제로 패키지를 배포하는 전체 흐름은 npm publish로 패키지 발행하기에서 더 자세히 다룹니다.
캐럿(^)과 틸드(~), 버전 범위 읽기
여기까지 왔다면 package.json의 ^18.2.0에서 숫자 부분은 읽을 수 있게 되었는데요. 이제 앞에 붙은 기호를 해석할 차례입니다. 이 기호들은 “정확히 이 버전”이 아니라 “이 범위 안의 버전이면 받겠다”는 허용 범위를 뜻합니다.
가장 자주 보이는 것이 캐럿(^)입니다. 캐럿은 “MAJOR가 같으면 모두 허용”한다는 의미입니다.
^1.4.2 → 1.4.2 이상 2.0.0 미만 (1.5.0, 1.9.3 모두 OK)
즉 ^1.4.2는 MINOR나 PATCH가 올라간 1.5.0, 1.9.3은 받아들이지만 MAJOR가 바뀐 2.0.0은 거부합니다. SemVer에서 MAJOR가 같으면 호환된다고 약속했으니, 캐럿은 “안전한 범위 안에서 최신을 따라가겠다”는 합리적인 기본값인 셈입니다. 그래서 npm이 패키지를 설치할 때 기본으로 붙여 주는 기호도 캐럿입니다.
다음으로 틸드(~)는 캐럿보다 보수적입니다. 틸드는 보통 “MINOR까지 같으면 허용”, 즉 PATCH만 올라가는 것을 허용합니다.
~1.4.2 → 1.4.2 이상 1.5.0 미만 (1.4.9는 OK, 1.5.0은 거부)
~1.4.2는 버그 수정인 1.4.3, 1.4.9는 받지만 새 기능이 들어온 1.5.0은 거부합니다. 기능 추가조차 부담스럽고 오직 버그 수정만 받고 싶을 때 쓰는 표기입니다.
이 밖에도 몇 가지 표기를 더 만날 수 있습니다.
1.4.2— 기호 없이 숫자만 쓰면 정확히 그 버전만 허용합니다. 가장 엄격합니다.>=1.4.2— 해당 버전 이상이면 무엇이든 허용합니다(MAJOR가 바뀌어도 받으므로 주의).1.4.x또는1.4.*—x나*자리는 아무 값이나 허용합니다.1.4.x는~1.4.0과 사실상 같습니다.*— 모든 버전을 허용합니다. 거의 쓰지 않는 게 좋습니다.
이런 범위 표기가 실제 설치 결과를 어떻게 좌우하는지, 그리고 왜 정확한 버전을 못 박아 두는 잠금 파일이 필요한지는 패키지 잠금 파일에서 이어서 살펴보면 좋습니다.
0.x.y 버전의 함정
SemVer를 처음 배울 때 가장 많이 놓치는 부분이 바로 MAJOR가 0인 버전입니다. SemVer 명세는 0.x.y 버전을 “아직 정식 출시 전, 무엇이든 바뀔 수 있는 개발 단계”로 규정합니다. 즉 0.x.y에서는 호환성 약속이 적용되지 않습니다.
이게 왜 함정이냐면, 캐럿의 동작이 달라지기 때문입니다. 1.x 이상에서는 캐럿이 MINOR 업데이트를 허용하지만, 0.x에서는 npm이 MINOR 변경마저 호환성을 깨는 변경으로 간주해 막아 버립니다.
^0.4.2 → 0.4.2 이상 0.5.0 미만 (0.5.0은 거부! 마치 ~처럼 동작)
^0.0.3 → 0.0.3 이상 0.0.4 미만 (PATCH 변경조차 막음)
^0.4.2는 이름은 캐럿이지만 실제로는 틸드처럼 0.5.0을 거부합니다. 0.x대 라이브러리에서는 MINOR가 한 칸 올라가는 것만으로도 코드가 깨질 수 있다는 뜻이죠. 그래서 아직 1.0.0에 도달하지 않은 라이브러리를 의존성에 넣을 때는 한층 더 조심해서 변경 로그를 확인하는 습관이 필요합니다.
반대로 내가 라이브러리를 만드는 입장이라면, API가 어느 정도 안정되었다고 판단되는 순간 과감하게 1.0.0을 선언하는 것이 사용자에 대한 예의입니다. 1.0.0은 “이제부터 호환성을 책임지고 지키겠다”는 공개적인 약속이니까요.
사전 출시 버전과 빌드 메타데이터
정식 버전을 내보내기 전에 미리 테스트 버전을 공개하고 싶을 때가 있는데요. SemVer는 이를 위해 사전 출시(pre-release) 표기를 제공합니다. 버전 뒤에 하이픈(-)을 붙이고 식별자를 적습니다.
1.0.0-alpha
1.0.0-alpha.1
1.0.0-beta.2
1.0.0-rc.1 (rc = release candidate, 출시 후보)
여기서 중요한 규칙은 사전 출시 버전이 정식 버전보다 낮은 우선순위를 가진다는 점입니다. 즉 1.0.0-alpha는 1.0.0보다 이전 버전으로 취급됩니다. 직관적으로도 알파 → 베타 → 정식 순서가 맞죠. 그래서 1.0.0-alpha < 1.0.0-alpha.1 < 1.0.0-beta < 1.0.0-rc.1 < 1.0.0 순으로 정렬됩니다.
이런 사전 출시 버전은 기본적으로 캐럿이나 틸드 범위에 포함되지 않습니다. ^1.0.0으로 설치해도 1.0.0-beta.2는 받지 않으므로, 테스트 버전을 받으려면 보통 npm install awesome-lib@beta처럼 명시적으로 태그를 지정해야 합니다.
한편 버전 뒤에 더하기(+)를 붙이는 빌드 메타데이터도 있습니다.
1.0.0+20260623
1.0.0+exp.sha.5114f85
빌드 메타데이터는 빌드 시각이나 커밋 해시처럼 부가 정보를 담는 용도인데요. 버전 우선순위를 따질 때는 완전히 무시됩니다. 즉 1.0.0+build1과 1.0.0+build2는 SemVer 관점에서 같은 버전으로 취급됩니다. 실무에서 자주 쓸 일은 많지 않지만, 가끔 마주칠 때 당황하지 않도록 알아 두면 좋습니다.
흔히 저지르는 실수들
SemVer를 알고 나서도 실무에서 자주 어긋나는 부분이 몇 가지 있는데요. 미리 짚어 두면 도움이 됩니다.
가장 흔한 실수는 호환성을 깨면서 MINOR나 PATCH만 올리는 것입니다. 함수 시그니처를 바꾸거나 기본 동작을 바꿔 놓고도 “기능은 그대로니까”라며 PATCH만 올리는 경우인데요. 사용자 입장에서는 안전한 줄 알고 받았다가 코드가 깨지므로, SemVer가 주는 신뢰를 정면으로 배신하는 셈입니다. 호환성이 깨지는지 판단이 서지 않을 때는 MAJOR를 올리는 쪽이 안전합니다.
반대로 사소한 변경에 MAJOR를 남발하는 것도 문제입니다. 오타 수정이나 내부 리팩터링처럼 사용자에게 아무 영향이 없는 변경인데도 MAJOR를 올리면, 사용자는 매번 “뭐가 깨졌나” 하고 변경 로그를 뒤져야 합니다. 버전 번호는 과하지도 부족하지도 않게, 실제 영향에 맞춰 올려야 신호로서 가치가 있습니다.
또 하나는 문서에 없는 동작에 의존하는 것입니다. SemVer의 호환성 약속은 어디까지나 공개된 API(public API)를 기준으로 합니다. 라이브러리가 내부적으로만 쓰던 함수나 우연히 노출된 동작에 기대고 있었다면, MINOR 업데이트만으로도 깨질 수 있습니다. 그래서 라이브러리를 만드는 쪽은 “무엇이 공개 API인지”를 명확히 문서로 밝혀 두는 것이 중요합니다.
마지막으로 버전 범위를 너무 느슨하게 잡는 것도 주의해야 합니다. *이나 >=로 열어 두면 의도치 않게 MAJOR 업데이트까지 받아 버려 빌드가 깨질 수 있습니다. 특별한 이유가 없다면 npm 기본값인 캐럿(^)을 그대로 쓰는 편이 안전합니다.
마치며
지금까지 유의적 버전(SemVer)의 규칙을 살펴봤는데요. 핵심만 다시 짚어 보면, 버전은 MAJOR.MINOR.PATCH 세 자리로 이루어지며 각각 호환성 깨짐, 기능 추가, 버그 수정을 뜻합니다. package.json의 캐럿(^)은 MAJOR가 같은 범위를, 틸드(~)는 PATCH만 올라가는 범위를 허용하고, MAJOR가 0인 버전에서는 이 규칙이 더 엄격하게 적용된다는 점까지 기억하면 충분합니다.
이제 ^18.2.0 같은 표기를 봐도 “MAJOR 18을 유지하면서 최신 마이너·패치를 따라가는구나” 하고 자신 있게 읽을 수 있을 텐데요. 버전 번호를 읽을 줄 알게 되면 의존성 업데이트가 더 이상 도박처럼 느껴지지 않습니다. 더 나아가 내가 만든 패키지에 책임감 있게 버전을 매기는 것은, 그 패키지를 쓰는 모든 사람과 맺는 신뢰의 약속이기도 합니다.
규칙 하나하나를 더 깊이 파고들고 싶다면 Semantic Versioning 공식 명세를 참고하세요. 한국어 번역도 잘 정리되어 있어 곁에 두고 보기 좋습니다.
This work is licensed under
CC BY 4.0