프롤로그 - "프로덕션이 터진 금요일 밤"
2023년 11월, 금요일 오후 4시 30분.
배포 30분 전, 저는 자신만만했습니다.
"이번 주 마지막 배포인데, 라이브러리 업데이트나 하고 가자."
npm update
터미널에 초록색 체크마크들이 쭉 뜨고, 모든 게 순조로워 보였습니다.
로컬에서 테스트 몇 개 돌려보고 "문제없네!" 하고는 배포 버튼을 눌렀습니다.
오후 5시 10분, 슬랙에 빨간 알림이 폭포처럼 쏟아지기 시작했습니다.
ERROR: Cannot find module 'createUser'
ERROR: login is not a function
ERROR: Uncaught TypeError at UserService.js:45
프로덕션이 완전히 멈췄습니다.
CEO가 직접 전화를 걸어왔고, 저는 식은땀을 흘리며 롤백을 시작했습니다. 문제는 "어느 버전으로 롤백해야 하는가?"였습니다.
package-lock.json을 열어보니:
{
"dependencies": {
"user-auth-lib": {
"version": "3.0.0" // ← 원래 2.8.1이었음
}
}
}
시니어 개발자가 제 화면을 보더니 한숨을 쉬었습니다.
"야, 이거 Major 버전 업데이트잖아. 2.x → 3.x는 Breaking Change야. 당연히 터지지."
저: "근데 npm update는 안전한 업데이트만 하는 거 아니에요?"
시니어: "package.json에 뭐라고 써있어?"
{
"dependencies": {
"user-auth-lib": "^2.8.1"
}
}
시니어: "이거 봐, 캐럿(^) 붙어있잖아. 이건 Minor 업데이트도 허용하는 거야. 근데 너 이 라이브러리 개발자가 SemVer 안 지킨 거 같은데? 3.0.0으로 올리면서 Breaking Change 안 써놨네."
그날 밤 11시까지 긴급 수정을 했고, 월요일에 CEO한테 1시간 동안 혼났습니다.
그때 깨달았습니다: "버전 숫자는 그냥 숫자가 아니구나. 이건 계약이고 약속이다."
왜 공부하게 되었나
그날의 사고 이후, 저는 다음 질문들에 답을 찾아야 했습니다:
- 왜 npm update가 3.0.0까지 설치했을까?
- 캐럿(^)과 틸드(~)는 뭐가 다른가?
- "Breaking Change"는 정확히 뭘 의미하는가?
- lockfile은 왜 필요한가?
- 개발자들이 SemVer를 안 지키면 어떻게 대응해야 하나?
무엇보다, 제가 라이브러리를 만들게 되면 "어떻게 버전을 올려야 사용자들이 안심하고 쓸 수 있을까?"가 궁금했습니다.
처음엔 뭐가 이해가 안 갔나
1. 버전 숫자가 임의적이라고 생각했다
"1.0.0에서 1.0.1로 올리든, 2.0.0으로 올리든 그냥 개발자 맘 아닌가?"
2. npm update가 항상 안전하다고 믿었다
"npm이 알아서 안전한 버전만 설치하겠지?"
3. lockfile의 필요성을 몰랐다
"package.json만 있으면 되는 거 아닌가?"
4. Pre-release 태그가 뭔지 몰랐다
"v1.0.0-alpha.1이 뭐야? 알파? 이게 정식 버전인가?"
5. 캐럿(^)이 "거의 모든 업데이트"를 허용한다고 착각했다
"^1.0.0이면 1.x.x는 다 안전하겠지?"
가장 큰 착각: "버전은 그냥 개발자가 적당히 올리는 숫자"
깨달음의 순간 - "버전은 API 계약서다"
롤백 작업을 하면서 시니어가 칠판에 그림을 그려줬습니다.
┌─────────────────────────────────────────────────────┐
│ MAJOR . MINOR . PATCH - PreRelease + Build │
│ 2 . 8 . 1 - beta.3 + 20231115 │
│ │ │ │ │ │ │
│ │ │ │ │ └─ 빌드 메타데이터
│ │ │ │ └─ 출시 전 테스트 버전
│ │ │ └─ 버그 수정 (하위 호환 O)
│ │ └─ 기능 추가 (하위 호환 O)
│ └─ API 파괴적 변경 (하위 호환 X)
└─────────────────────────────────────────────────────┘
시니어: "이게 Semantic Versioning의 공식이야. 각 숫자에는 명확한 의미가 있어."
"MAJOR 버전을 올린다 = 기존 코드가 작동 안 할 수 있다는 경고"
"MINOR 버전을 올린다 = 새 기능 추가, 기존 코드는 안전"
"PATCH 버전을 올린다 = 버그만 고침, 완전 안전"
저: "그럼 user-auth-lib가 2.8.1 → 3.0.0으로 올라간 건..."
시니어: "createUser() 함수 이름을 registerUser()로 바꿨거나, 파라미터 순서를 바꿨거나, 리턴 타입을 바꿨거나... 뭔가 API를 바꾼 거지. 그래서 MAJOR를 올린 거고."
그 순간 이해했습니다: "버전은 숫자가 아니라 약속이다. 계약서다."
1. MAJOR.MINOR.PATCH의 의미
형식
vMAJOR.MINOR.PATCH
─┬─── ─┬─── ─┬───
│ │ └─ PATCH: 버그 수정 (기존 API 변경 없음)
│ └────── MINOR: 기능 추가 (기존 API는 유지)
└─────────── MAJOR: API 변경 (기존 코드가 깨질 수 있음)
실제 예시 - 가상 라이브러리 UserAPI
v1.0.0 (처음 출시)
// UserAPI v1.0.0
export function getUser(id) {
return fetch(`/api/users/${id}`)
.then(res => res.json());
}
export function createUser(data) {
return fetch('/api/users', {
method: 'POST',
body: JSON.stringify(data)
}).then(res => res.json());
}
사용자 코드:
import { getUser, createUser } from 'user-api';
getUser(123).then(user => console.log(user));
createUser({ name: 'John' }).then(user => console.log(user));
v1.0.1 (PATCH: 버그 수정)
// UserAPI v1.0.1
// 버그 수정: ID가 null일 때 크래시 발생 → 에러 처리 추가
export function getUser(id) {
if (!id) {
return Promise.reject(new Error('ID is required')); // ← 수정
}
return fetch(`/api/users/${id}`)
.then(res => res.json());
}
export function createUser(data) {
if (!data || !data.name) {
return Promise.reject(new Error('Name is required')); // ← 수정
}
return fetch('/api/users', {
method: 'POST',
body: JSON.stringify(data)
}).then(res => res.json());
}
호환성: 기존 코드 그대로 작동 ✅
사용자는 아무것도 수정할 필요 없음.
v1.1.0 (MINOR: 기능 추가)
// UserAPI v1.1.0
// 새 기능: 여러 사용자 조회 함수 추가
export function getUser(id) {
if (!id) {
return Promise.reject(new Error('ID is required'));
}
return fetch(`/api/users/${id}`)
.then(res => res.json());
}
export function createUser(data) {
if (!data || !data.name) {
return Promise.reject(new Error('Name is required'));
}
return fetch('/api/users', {
method: 'POST',
body: JSON.stringify(data)
}).then(res => res.json());
}
// ← 새로운 함수 추가! (기존 함수는 그대로)
export function getUsers(ids) {
return Promise.all(ids.map(id => getUser(id)));
}
export function deleteUser(id) {
return fetch(`/api/users/${id}`, { method: 'DELETE' })
.then(res => res.json());
}
호환성: 기존 코드 그대로 작동 ✅
사용자는 원한다면 새 함수를 쓸 수 있음. 안 써도 문제 없음.
v2.0.0 (MAJOR: 파괴적 변경)
// UserAPI v2.0.0
// Breaking Change: 함수 이름 변경, async/await로 전환
// getUser → fetchUser (이름 변경!)
export async function fetchUser(id) { // ← 이름 바뀜!
if (!id) throw new Error('ID is required');
const res = await fetch(`/api/users/${id}`);
return res.json();
}
// createUser → registerUser (이름 변경!)
export async function registerUser(data) { // ← 이름 바뀜!
if (!data || !data.name) throw new Error('Name is required');
const res = await fetch('/api/users', {
method: 'POST',
body: JSON.stringify(data)
});
return res.json();
}
export async function fetchUsers(ids) {
return Promise.all(ids.map(id => fetchUser(id)));
}
export async function removeUser(id) { // ← deleteUser → removeUser
const res = await fetch(`/api/users/${id}`, { method: 'DELETE' });
return res.json();
}
호환성: 기존 코드 작동 안 함 ❌
// 기존 코드 (v1.x)
import { getUser, createUser } from 'user-api'; // ← 에러! 함수 없음
getUser(123).then(user => console.log(user)); // ← getUser is not defined
사용자는 코드를 수정해야 함:
// 수정된 코드 (v2.x)
import { fetchUser, registerUser } from 'user-api'; // ← 이름 변경
const user = await fetchUser(123); // ← async/await로 변경
console.log(user);
2. 실제 - 의존성 지옥과 Breaking Change Hell
제가 겪은 3가지 사건
사건 1 - 조용한 MAJOR 업데이트
# package.json (배포 전날)
{
"dependencies": {
"axios": "^0.21.0" // ← 캐럿 붙음
}
}
# npm install (배포 당일 새 서버에서)
npm install
# 결과: package-lock.json
{
"axios": "1.0.0" // ← 0.x → 1.x로 MAJOR 점프!
}
문제: axios 0.x → 1.x는 Breaking Change였습니다.
axios.get()응답 구조가 바뀜- 에러 핸들링 방식이 바뀜
교훈: v0.x.y는 unstable로 간주되어 ^0.21.0이 1.0.0을 허용합니다.
사건 2 - left-pad 사태 (2016년 실화)
2016년 3월, left-pad라는 11줄짜리 npm 패키지가 삭제되었습니다.
// left-pad 패키지 전체 코드 (11줄)
module.exports = leftpad;
function leftpad(str, len, ch) {
str = String(str);
ch = ch || ' ';
var i = -1;
len = len - str.length;
while (++i < len) {
str = ch + str;
}
return str;
}
하지만 이 패키지를 의존성으로 쓰는 패키지가 수천 개였고, 그 중 하나가 Babel이었습니다.
npm install
# 에러
npm ERR! 404 'left-pad' is not in the npm registry.
결과: React, Babel을 쓰는 전 세계 수만 개 프로젝트가 동시에 빌드 실패.
원인:
- 의존성 버전 관리 부실
- lockfile 미사용 (당시 package-lock.json 없었음)
- 중요한 패키지에 대한 버전 고정 없음
교훈: "작은 의존성 하나가 전체를 무너뜨릴 수 있다."
사건 3 - 의도하지 않은 PATCH 변경
# package.json
{
"dependencies": {
"some-logging-lib": "~2.3.4" // ← 틸드: PATCH만 허용
}
}
# npm update
npm update
# 결과
{
"some-logging-lib": "2.3.5" // ← PATCH 업데이트
}
업데이트 후 프로덕션에서 로그가 폭증:
[INFO] User logged in
[DEBUG] Database query: SELECT * FROM users WHERE id = 123
[DEBUG] Query time: 45ms
[DEBUG] Result: {...}
[INFO] Session created
...
문제: v2.3.5에서 개발자가 "디버그 로그 추가"를 PATCH로 분류했지만, 실제로는 로그 양이 100배 증가해서 디스크가 꽉 참.
교훈: "PATCH도 side effect를 일으킬 수 있다. 완전히 안전한 건 아니다."
3. 버전 범위 표기법 완벽 정리
3.1. 캐럿 ^ (Caret) - 가장 많이 씀
{
"dependencies": {
"react": "^18.2.0"
}
}
규칙: "가장 왼쪽의 0이 아닌 숫자는 고정"
^18.2.0 허용 범위:
✅ 18.2.0
✅ 18.2.1 (PATCH)
✅ 18.3.0 (MINOR)
✅ 18.99.99 (MINOR)
❌ 19.0.0 (MAJOR)
^0.5.2 허용 범위 (주의!):
✅ 0.5.2
✅ 0.5.3 (PATCH)
❌ 0.6.0 (MINOR도 막음!)
❌ 1.0.0 (MAJOR)
^0.0.3 허용 범위 (더 엄격!):
✅ 0.0.3
❌ 0.0.4 (PATCH도 막음!)
왜 이렇게 설계됐나?
- v1.0.0 이상: MAJOR만 막으면 안전
- v0.x.y: unstable이라 MINOR도 Breaking일 수 있음
- v0.0.x: 극도로 unstable이라 PATCH도 막음
3.2. 틸드 ~ (Tilde) - 보수적
{
"dependencies": {
"lodash": "~4.17.21"
}
}
규칙: "PATCH 버전만 업데이트"
~4.17.21 허용 범위:
✅ 4.17.21
✅ 4.17.22 (PATCH)
✅ 4.17.99 (PATCH)
❌ 4.18.0 (MINOR)
❌ 5.0.0 (MAJOR)
~1.2 허용 범위 (PATCH 생략):
✅ 1.2.0
✅ 1.2.1
❌ 1.3.0
~1 허용 범위 (MINOR, PATCH 생략):
✅ 1.0.0
✅ 1.1.0 (MINOR)
✅ 1.99.99 (MINOR)
❌ 2.0.0 (MAJOR)
3.3. 고정 버전 (No Symbol)
{
"dependencies": {
"critical-lib": "3.5.2" // ← 정확히 3.5.2만
}
}
허용: 3.5.2만
사용 시기:
- 레거시 프로젝트
- 버전 변경이 심각한 버그를 일으키는 경우
- 은행, 의료 등 안정성이 최우선인 프로젝트
3.4. 비교 연산자
{
"dependencies": {
"pkg1": ">=1.2.0", // 1.2.0 이상
"pkg2": ">1.2.0", // 1.2.0 초과
"pkg3": "<=2.0.0", // 2.0.0 이하
"pkg4": "<2.0.0", // 2.0.0 미만
"pkg5": ">=1.0.0 <2.0.0" // 1.x 버전만
}
}
3.5. OR 연산자 ||
{
"dependencies": {
"pkg": "^1.0.0 || ^2.0.0" // 1.x 또는 2.x
}
}
사용 예: 플러그인이 두 버전의 호스트를 지원할 때
{
"peerDependencies": {
"react": "^17.0.0 || ^18.0.0" // React 17 또는 18 둘 다 OK
}
}
3.6. 하이픈 범위 -
{
"dependencies": {
"pkg": "1.2.0 - 1.5.0" // 1.2.0 ≤ version ≤ 1.5.0
}
}
동일 표현:
{
"dependencies": {
"pkg": ">=1.2.0 <=1.5.0"
}
}
3.7. x 범위
{
"dependencies": {
"pkg1": "1.x", // >=1.0.0 <2.0.0 (1.x 버전)
"pkg2": "1.2.x", // >=1.2.0 <1.3.0 (1.2.x 버전)
"pkg3": "*" // >=0.0.0 (모든 버전) ← 위험!
}
}
가이드
| 표기법 | 안전성 | 업데이트 범위 | 추천 상황 |
|---|---|---|---|
^1.2.3 | 중간 | MINOR + PATCH | 대부분의 경우 (npm 기본) |
~1.2.3 | 높음 | PATCH만 | 안정성이 중요한 프로덕션 |
1.2.3 | 최고 | 업데이트 없음 | 레거시, 은행/의료 시스템 |
>=1.2.3 | 낮음 | 모든 상위 버전 | 개발 도구 |
* | 위험 | 모든 버전 | 절대 사용 금지 |
^0.x.y | 주의 | PATCH만 (0.x는 특수) | Unstable 패키지 |
4. Pre-release 버전과 Build Metadata
4.1. Pre-release 태그
v1.0.0-alpha.1
v1.0.0-alpha.2
v1.0.0-beta.1
v1.0.0-beta.2
v1.0.0-rc.1 (Release Candidate)
v1.0.0 (정식 출시)
의미:
alpha: 초기 개발 버전, 기능 불완전, 버그 많음beta: 기능 완성, 테스트 중, 버그 있을 수 있음rc(Release Candidate): 출시 후보, 거의 완성, 최종 테스트 중
버전 우선순위:
1.0.0-alpha.1 < 1.0.0-alpha.2 < 1.0.0-beta.1 < 1.0.0-rc.1 < 1.0.0
4.2. Build Metadata
v1.0.0+20231115
v1.0.0+build.123
v1.0.0-beta.1+exp.sha.5114f85
의미: 빌드 시간, 커밋 해시 등 메타데이터 (버전 비교에는 영향 없음)
1.0.0+build.1 == 1.0.0+build.2 (버전은 동일)
실제 예시 - React의 버전 히스토리
# 실제 React 버전 예시
18.0.0-alpha-e6be2d531-20211019
18.0.0-beta-24dd07bd2-20211208
18.0.0-rc.0
18.0.0-rc.1
18.0.0 # 정식 출시
18.0.1 # Patch
18.1.0 # Minor
18.2.0
npm install과 Pre-release
# 정식 버전만 설치
npm install react # → 18.2.0
# Pre-release 포함
npm install react@next # → 19.0.0-rc.1 (최신 베타)
# 특정 Pre-release
npm install react@18.0.0-rc.1
# package.json에서 Pre-release 고정
{
"dependencies": {
"react": "18.0.0-rc.1" // ← 정확히 이 버전만
}
}
주의: Pre-release는 ^나 ~ 범위에 포함되지 않음!
{
"dependencies": {
"react": "^18.0.0" // ← 18.0.0-rc.1은 설치 안 됨!
}
}
5. lockfile: package-lock.json, yarn.lock, pnpm-lock.yaml
5.1. 왜 lockfile이 필요한가?
문제 상황
팀원 A의 컴퓨터 (2023년 11월 1일):
npm install
# package.json
{
"dependencies": {
"axios": "^1.5.0"
}
}
# 설치된 버전
axios 1.5.0
팀원 B의 컴퓨터 (2023년 11월 15일):
npm install # 똑같은 package.json
# 설치된 버전
axios 1.6.0 # ← Minor 업데이트가 나왔음!
결과: A의 컴퓨터에서는 작동하는데 B의 컴퓨터에서는 버그 발생.
"내 컴퓨터에서는 되는데?" 사태.
해결책: lockfile
# package-lock.json (팀 공유)
{
"name": "my-project",
"version": "1.0.0",
"lockfileVersion": 3,
"dependencies": {
"axios": {
"version": "1.5.0", # ← 정확한 버전 고정
"resolved": "https://registry.npmjs.org/axios/-/axios-1.5.0.tgz",
"integrity": "sha512-..." # ← 체크섬으로 변조 검증
}
}
}
이제 팀원 B도:
npm install # ← package-lock.json을 읽음
# 설치된 버전
axios 1.5.0 # ← A와 동일한 버전!
5.2. lockfile의 3가지 보증
- 결정적 설치 (Deterministic Install): 같은 lockfile → 항상 같은 버전
- 무결성 검증 (Integrity Check): 체크섬으로 패키지 변조 감지
- 의존성 트리 고정: 하위 의존성까지 완전히 고정
5.3. lockfile 비교
| 도구 | lockfile | 특징 |
|---|---|---|
| npm | package-lock.json | JSON 형식, 가독성 낮음 |
| yarn | yarn.lock | YAML 형식, 가독성 높음 |
| pnpm | pnpm-lock.yaml | YAML, 심볼릭 링크 사용 |
5.4. 가이드
DO:
# lockfile을 git에 커밋
git add package-lock.json
git commit -m "Lock dependencies"
# lockfile이 있으면 npm ci 사용 (CI/CD에서)
npm ci # ← package-lock.json 기준으로 정확히 설치
DON'T:
# lockfile을 .gitignore에 추가 (절대 금지!)
echo "package-lock.json" >> .gitignore # ❌
# package-lock.json 무시하고 설치
npm install --no-package-lock # ❌
# lockfile 수동 수정
vim package-lock.json # ❌ (npm이 관리함)
6. 버전 올리기 - npm version 명령어
6.1. 수동 버전 관리
# 현재 버전: 1.2.3
# PATCH 올리기 (1.2.3 → 1.2.4)
npm version patch
# MINOR 올리기 (1.2.3 → 1.3.0)
npm version minor
# MAJOR 올리기 (1.2.3 → 2.0.0)
npm version major
동작:
- package.json의
version필드 수정 - git commit 자동 생성 (
v1.2.4메시지) - git tag 자동 생성 (
v1.2.4)
6.2. Pre-release 버전
# 현재 버전: 1.2.3
# Pre-release로 올리기 (1.2.3 → 1.2.4-0)
npm version prerelease
# Pre-release 계속 올리기 (1.2.4-0 → 1.2.4-1)
npm version prerelease
# 정식 버전으로 (1.2.4-1 → 1.2.4)
npm version patch
6.3. Pre-release 태그 지정
# 현재 버전: 1.2.3
# Alpha 버전 (1.2.3 → 1.2.4-alpha.0)
npm version preminor --preid=alpha
# Beta 버전 (1.2.4-alpha.0 → 1.3.0-beta.0)
npm version preminor --preid=beta
# RC 버전 (1.3.0-beta.0 → 1.3.0-rc.0)
npm version prerelease --preid=rc
6.4. 자동 커밋 비활성화
# git commit/tag 생성 안 함
npm version patch --no-git-tag-version
6.5. 실제 워크플로우
# 1. 기능 개발
git checkout -b feature/new-feature
# 2. 개발 완료, 커밋
git add .
git commit -m "feat: Add new feature"
# 3. main 브랜치로 돌아가기
git checkout main
git merge feature/new-feature
# 4. 버전 올리기 (자동 커밋/태그)
npm version minor # → v1.3.0
# 5. npm에 배포
npm publish
# 6. git에 푸시 (태그 포함)
git push origin main --tags
7. Conventional Commits와 자동 버전 관리
7.1. Conventional Commits 규칙
# 형식
<type>(<scope>): <subject>
# 예시
feat(auth): Add login function # → MINOR 버전 올림
fix(api): Fix null pointer bug # → PATCH 버전 올림
feat(api)!: Change API signature # → MAJOR 버전 올림
# 또는
feat(api): Change API signature
BREAKING CHANGE: API signature changed # → MAJOR 버전 올림
Type 종류:
feat: 새 기능 → MINORfix: 버그 수정 → PATCHdocs: 문서 변경 → 버전 안 올림style: 코드 스타일 변경 → 버전 안 올림refactor: 리팩토링 → PATCHperf: 성능 개선 → PATCHtest: 테스트 추가 → 버전 안 올림chore: 빌드 설정 등 → 버전 안 올림
7.2. standard-version으로 자동화
# 설치
npm install --save-dev standard-version
# package.json에 스크립트 추가
{
"scripts": {
"release": "standard-version"
}
}
# 사용
npm run release
동작:
- 마지막 릴리스 이후 커밋 분석
feat있으면 MINOR 올림,fix만 있으면 PATCH 올림BREAKING CHANGE있으면 MAJOR 올림- CHANGELOG.md 자동 생성
- package.json 버전 업데이트
- git commit & tag
예시 CHANGELOG.md:
# Changelog
## [2.1.0] (2023-11-15)
### Features
- **auth**: Add OAuth login ([a3f2c1b](link-to-commit))
- **api**: Support pagination ([8d9e4f7](link-to-commit))
### Bug Fixes
- **api**: Fix null pointer in getUser ([5c6d8a2](link-to-commit))
## [2.0.0] (2023-11-01)
### BREAKING CHANGES
- **api**: Remove deprecated createUser function
Migration: Use registerUser instead
### Features
- **api**: Add registerUser function
7.3. semantic-release로 완전 자동화
# 설치
npm install --save-dev semantic-release
# .releaserc.json 설정
{
"branches": ["main"],
"plugins": [
"@semantic-release/commit-analyzer", // 커밋 분석
"@semantic-release/release-notes-generator", // 릴리스 노트 생성
"@semantic-release/changelog", // CHANGELOG 생성
"@semantic-release/npm", // npm 배포
"@semantic-release/git", // git commit/tag
"@semantic-release/github" // GitHub 릴리스 생성
]
}
# CI/CD에서 실행
npx semantic-release
CI/CD 워크플로우 (GitHub Actions):
# .github/workflows/release.yml
name: Release
on:
push:
branches: [main]
jobs:
release:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- uses: actions/setup-node@v3
with:
node-version: 18
- run: npm ci
- run: npm test
# 자동 버전 관리 & 배포
- run: npx semantic-release
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
NPM_TOKEN: ${{ secrets.NPM_TOKEN }}
결과:
feat:커밋 푸시 → CI가 자동으로 MINOR 버전 올리고 npm 배포- CHANGELOG.md 자동 업데이트
- GitHub Release 자동 생성
- git tag 자동 생성
8. SemVer vs CalVer vs 기타 버저닝 방식
8.1. Semantic Versioning (SemVer)
v2.3.4 = MAJOR.MINOR.PATCH
장점:
- API 호환성을 명확히 표현
- 자동화하기 쉬움
- 업계 표준
단점:
- 마케팅에 불리 (v1.0.0에서 오래 머무름)
- MAJOR 버전을 올리기 부담스러움
사용 예: npm, pip, gems, 대부분의 라이브러리
8.2. Calendar Versioning (CalVer)
Ubuntu: YY.MM (예: 22.04, 23.10)
PyPI: YYYY.MM.MICRO (예: 2023.11.1)
장점:
- 릴리스 시기를 바로 알 수 있음
- 정기 릴리스에 적합
- 마케팅에 유리
단점:
- API 호환성 정보 없음
- 자동화 어려움
사용 예: Ubuntu, Windows (20H2, 21H1), pip 자체
8.3. 기타 버저닝 방식
Windows
Windows 10 21H2 (2021년 하반기)
Windows 11 22H2 (2022년 하반기)
Chrome
Chrome 119.0.6045.123
│ │ │ └─ Patch (버그 수정)
│ │ └─────── Build (자동 빌드 번호)
│ └────────── Branch (개발 브랜치)
└────────────── Major (기능 변경)
4~6주마다 MAJOR 버전 올림 (API 변경 없어도).
iOS
iOS 17.1.1
│ │ └─ Patch (긴급 버그 수정)
│ └─── Minor (기능 추가)
└────── Major (메이저 업데이트, 연례 출시)
8.4. 비교표
| 방식 | 형식 | 장점 | 단점 | 사용 예 |
|---|---|---|---|---|
| SemVer | MAJOR.MINOR.PATCH | API 호환성 명확 | 마케팅 불리 | npm, pip, gems |
| CalVer | YY.MM.MICRO | 시기 파악 쉬움 | 호환성 정보 없음 | Ubuntu, pip |
| Chrome식 | MAJOR.BRANCH.BUILD.PATCH | 빠른 출시 | 혼란스러움 | Chrome, Edge |
| iOS식 | MAJOR.MINOR.PATCH | 직관적 | SemVer와 다름 | iOS, macOS |
9. 실제 - 라이브러리 개발자의 책임
9.1. 잘못된 예 (신뢰 파괴)
실수 1 - PATCH인데 Breaking Change
v1.5.3 → v1.5.4 (PATCH)
// v1.5.3
function login(username, password) {
return fetch('/api/login', {
method: 'POST',
body: JSON.stringify({ username, password })
});
}
// v1.5.4
function login(email, password) { // ← username → email 변경!
return fetch('/api/login', {
method: 'POST',
body: JSON.stringify({ email, password })
});
}
결과:
- 사용자가
~1.5.3(PATCH만 허용)으로 설정 - v1.5.4로 자동 업데이트
- 모든 로그인 실패
- "이 라이브러리는 SemVer를 안 지킨다" → 신뢰 상실
실수 2 - MINOR인데 Breaking Change
v2.3.0 → v2.4.0 (MINOR)
// v2.3.0
export function getUser(id) {
return fetch(`/api/users/${id}`)
.then(res => res.json());
}
// v2.4.0
export async function getUser(id) { // ← 동기 → 비동기 변경!
const res = await fetch(`/api/users/${id}`);
return res.json();
}
문제:
// 기존 사용자 코드 (v2.3.0)
getUser(123).then(user => console.log(user)); // ← 작동
// v2.4.0으로 업데이트 후
getUser(123).then(user => console.log(user)); // ← 여전히 작동하지만...
// 만약 이렇게 쓴 사용자가 있다면?
const user = getUser(123); // ← Promise 대신 undefined 반환
console.log(user.name); // ← TypeError!
올바른 방법: MAJOR 버전을 올려야 함 (2.3.0 → 3.0.0).
9.2. 올바른 예
방법 1 - Deprecation 경고
// v2.3.0
export function getUser(id) {
console.warn('getUser() is deprecated. Use fetchUser() instead.');
return fetchUser(id);
}
export function fetchUser(id) { // ← 새 함수 추가 (MINOR)
return fetch(`/api/users/${id}`)
.then(res => res.json());
}
타임라인:
- v2.3.0:
fetchUser()추가,getUser()는 유지 (MINOR) - v2.4.0:
getUser()deprecated 경고 (MINOR) - v3.0.0:
getUser()제거 (MAJOR)
방법 2: Migration Guide
# v3.0.0 Migration Guide
## Breaking Changes
### 1. `getUser()` → `fetchUser()`
**Before (v2.x)**:
\`\`\`javascript
import { getUser } from 'my-lib';
getUser(123).then(user => console.log(user));
\`\`\`
**After (v3.x)**:
\`\`\`javascript
import { fetchUser } from 'my-lib';
const user = await fetchUser(123);
console.log(user);
\`\`\`
**Codemod (자동 마이그레이션)**:
\`\`\`bash
npx @my-lib/codemod v2-to-v3
\`\`\`
### 2. Login API signature change
**Before**:
\`\`\`javascript
login(username, password)
\`\`\`
**After**:
\`\`\`javascript
login(email, password)
\`\`\`
10. 실수 사례와 해결 방법
실수 1 - * 또는 latest 사용
{
"dependencies": {
"some-lib": "*" // ❌ 위험!
}
}
문제: MAJOR 업데이트가 자동으로 설치됨.
해결:
{
"dependencies": {
"some-lib": "^2.3.0" // ✅ MAJOR는 막음
}
}
실수 2 - devDependencies를 너무 엄격하게
{
"devDependencies": {
"eslint": "8.56.0", // ← 고정 버전
"prettier": "3.1.0"
}
}
문제: 개발 도구는 자주 업데이트되는데 수동으로 관리해야 함.
해결:
{
"devDependencies": {
"eslint": "^8.56.0", // ✅ Minor 업데이트 허용
"prettier": "^3.1.0"
}
}
실수 3 - peerDependencies 범위를 너무 좁게
{
"peerDependencies": {
"react": "18.2.0" // ❌ 정확히 18.2.0만 허용
}
}
문제: 사용자가 React 18.3.0을 쓰면 경고 발생.
해결:
{
"peerDependencies": {
"react": "^18.0.0" // ✅ React 18.x 모두 허용
// 또는
"react": "^17.0.0 || ^18.0.0" // ✅ 17, 18 둘 다 지원
}
}
실수 4 - lockfile을 .gitignore에 추가
# .gitignore
node_modules/
package-lock.json # ❌ 절대 금지!
문제: 팀원마다 다른 버전 설치 → "내 컴퓨터에서는 되는데?" 사태.
해결:
# .gitignore
node_modules/
# package-lock.json은 절대 추가하지 말 것!
11. 도구와 자동화
11.1. npm-check-updates
# 설치
npm install -g npm-check-updates
# 업데이트 가능 버전 확인
ncu
Checking package.json
[====================] 12/12 100%
axios ^1.5.0 → ^1.6.5 (Minor)
react ^18.2.0 → ^18.2.0 (Up to date)
lodash ^4.17.21 → ^4.17.21 (Up to date)
# MAJOR 업데이트 포함
ncu --target latest
react ^18.2.0 → ^19.0.0 (MAJOR! 주의!)
# package.json 자동 업데이트
ncu -u
# 특정 패키지만
ncu axios
11.2. npm outdated
npm outdated
Package Current Wanted Latest Location
axios 1.5.0 1.6.5 1.6.5 my-project
react 18.2.0 18.2.0 19.0.0 my-project
- Current: 현재 설치된 버전
- Wanted: package.json 범위 내 최신 버전
- Latest: npm에 출시된 최신 버전
11.3. Dependabot (GitHub)
# .github/dependabot.yml
version: 2
updates:
- package-ecosystem: "npm"
directory: "/"
schedule:
interval: "weekly"
open-pull-requests-limit: 10
reviewers:
- "my-team"
labels:
- "dependencies"
동작: 매주 자동으로 PR 생성, 업데이트 가능한 의존성 표시.
11.4. Renovate Bot
Dependabot보다 강력한 대안:
// renovate.json
{
"extends": ["config:base"],
"packageRules": [
{
"matchUpdateTypes": ["minor", "patch"],
"automerge": true // Minor/Patch는 자동 머지
},
{
"matchUpdateTypes": ["major"],
"labels": ["major-update"],
"reviewers": ["team-lead"] // Major는 리뷰 필요
}
]
}
12. v0.x.y의 위험성
규칙
v0.x.y = "Initial development. Anything MAY change at any time."
의미: MAJOR가 0이면 unstable로 간주됨.
실제 예시
React v0.14.0 → v0.15.0 (MINOR 업데이트)
// v0.14.0
React.render(<App />, document.body);
// v0.15.0
ReactDOM.render(<App />, document.body); // ← Breaking Change!
문제: MINOR 업데이트인데 API가 완전히 바뀜!
캐럿(^) 동작 차이
{
"dependencies": {
"stable-lib": "^1.5.0", // 1.x 모두 허용
"unstable-lib": "^0.5.0" // 0.5.x만 허용 (PATCH만!)
}
}
이유: v0.x.y는 MINOR도 Breaking일 수 있어서 PATCH만 허용.
대응 방법
{
"dependencies": {
"unstable-lib": "0.5.2" // ← 고정 버전
// 또는
"unstable-lib": "~0.5.2" // ← PATCH만 (명시적)
}
}
13. 정리 - SemVer 체크리스트
버전 올리기 결정 트리
변경사항이 있는가?
├─ YES
│ ├─ 기존 코드가 깨지는가? (Breaking Change)
│ │ ├─ YES → MAJOR 버전 올림 (1.0.0 → 2.0.0)
│ │ └─ NO
│ │ ├─ 새 기능 추가?
│ │ │ ├─ YES → MINOR 버전 올림 (1.0.0 → 1.1.0)
│ │ │ └─ NO
│ │ │ ├─ 버그 수정?
│ │ │ │ ├─ YES → PATCH 버전 올림 (1.0.0 → 1.0.1)
│ │ │ │ └─ NO → 버전 안 올림 (문서, 테스트만 변경)
└─ NO → 버전 안 올림
Breaking Change 체크리스트
다음 중 하나라도 해당되면 MAJOR:
- 함수/클래스 이름 변경
- 함수 파라미터 추가/제거/순서 변경
- 함수 리턴 타입 변경
- 필수 파라미터 추가
- 기본값 변경 (동작이 바뀌는 경우)
- 에러 타입 변경
- 최소 지원 버전 변경 (Node.js, Python 등)
- 의존성 MAJOR 업데이트 (Breaking 전파)
MINOR 체크리스트
- 새 함수/클래스 추가
- 옵션 파라미터 추가 (기본값 있음)
- 기존 기능 확장 (하위 호환)
- Deprecation 경고 추가
PATCH 체크리스트
- 버그 수정
- 성능 개선 (동작 변화 없음)
- 내부 리팩토링 (API 변화 없음)
- 문서 오타 수정
- 타입 정의 수정 (타입스크립트)
package.json 전략
{
"dependencies": {
// 프레임워크 (중요): 고정 또는 틸드
"react": "18.2.0",
"vue": "~3.3.0",
// 유틸 라이브러리: 캐럿
"lodash": "^4.17.21",
"axios": "^1.6.0",
// v0.x.y (unstable): 고정
"new-experimental-lib": "0.5.2",
// 플러그인: OR 범위 (여러 호스트 버전 지원)
"eslint-plugin-react": "^7.0.0"
},
"devDependencies": {
// 개발 도구: 캐럿 (자유롭게 업데이트)
"eslint": "^8.56.0",
"prettier": "^3.1.0",
"jest": "^29.7.0"
},
"peerDependencies": {
// 호스트 라이브러리: 넓은 범위
"react": "^17.0.0 || ^18.0.0"
}
}
마치며 - "숫자 뒤에 숨은 약속"
처음 버전 관리를 공부하기 시작했을 때, 저는 "그냥 숫자 아냐?"라고 생각했습니다.
프로덕션을 날려먹고 나서야 깨달았습니다.
"버전은 숫자가 아니라 개발자 간의 신뢰 프로토콜이다."
v2.3.4에서 v2.3.5로 올라가는 건 단순한 증가가 아닙니다. "이 업데이트는 안전합니다. 당신의 코드는 그대로 작동할 겁니다"라는 약속입니다.
v2.3.4에서 v3.0.0으로 올라가는 건 "주의하세요. 코드를 수정해야 할 수 있습니다"라는 경고입니다.
제가 배운 교훈:
- MAJOR 버전: API가 바뀔 수 있다 → 신중하게 업데이트
- MINOR 버전: 새 기능이 추가됐다 → 비교적 안전
- PATCH 버전: 버그만 고쳤다 → 안전 (하지만 100%는 아님)
- 캐럿(^): MINOR + PATCH 허용 → 대부분의 경우 OK
- 틸드(~): PATCH만 허용 → 안정성이 중요할 때
- 고정 버전: 업데이트 없음 → 레거시 프로젝트
- lockfile: 팀원 간 버전 통일 → 반드시 커밋
제가 이제 라이브러리를 만든다면, SemVer를 철저히 지킬 것입니다.
왜냐하면 제가 사용자의 금요일 밤을 망치고 싶지 않기 때문입니다.
"신뢰는 천천히 쌓이지만, 한 번의 Breaking Change로 무너진다."
여러분도 라이브러리를 만든다면, 버전 번호를 신중하게 올려주세요.
숫자 하나하나에 책임감을 담아주세요.
그것이 개발자로서 지켜야 할 최소한의 예의입니다.