의존 라이브러리를 업데이트했더니 빌드가 깨집니다. "버전을 1.4에서 2.0으로 올렸어요." 다른 개발자가 한숨 쉽니다. "MAJOR가 올랐으면 호환이 깨진 거예요, 마이그레이션 가이드 봤어야죠." 한편 인프라 담당은 "어제 분명 됐는데 오늘 같은 코드가 CI에서 깨져요"로 막막합니다 — 잠금 파일이 없어 그사이 패치 버전이 바뀐 것입니다. 버전 숫자는 장식이 아니라 '호환성에 대한 약속'입니다. 이 약속을 읽고 활용하면, 업데이트가 도박이 아니라 판단이 됩니다.
- 1MAJOR.MINOR.PATCH가 각각 무엇을 약속하는지 설명할 수 있다
- 2breaking change가 왜 MAJOR를 올리는지 설명할 수 있다
- 3버전 범위(^, ~)와 잠금 파일의 역할을 구분할 수 있다
- 4버전 정보로 업데이트 위험과 마이그레이션 필요성을 판단할 수 있다
SemVer — 숫자에 담긴 약속
MAJOR.MINOR.PATCH: 무엇이 바뀌었는지를 숫자로
시맨틱 버저닝(SemVer)은 버전을 MAJOR.MINOR.PATCH로 매기고, 각 자리가 무엇을 약속하는지 정합니다.
확대
핵심: 버전 숫자만 봐도 "내 코드를 고쳐야 하는지" 가늠할 수 있게 한 것이 SemVer의 가치입니다. MAJOR가 오르면 멈추고 변경 로그·마이그레이션 가이드를 봅니다. MINOR·PATCH면 릴리스 전략의 점진 배포로 안전하게 올립니다.
확대
위 그림처럼 MAJOR는 빨간색(호환 파괴), MINOR는 파란색(기능 추가), PATCH는 초록색(버그 수정)으로 서로 다른 약속을 담고 있습니다.
breaking change와 버전 범위
무엇이 '깨는' 변경이고, 어떻게 자동 업데이트를 통제하나
breaking change는 기존 사용자의 코드가 그대로면 깨지는 변경입니다.
breaking (MAJOR↑):
- API 응답 필드 제거 / 이름 변경
- 필수 파라미터 추가
- 함수 시그니처·동작 의미 변경
- 기본값 변경(동작이 달라짐)
비-breaking (MINOR/PATCH):
- 새 선택적 필드·엔드포인트 추가(기존은 그대로)
- 내부 리팩터링(외부 영향 없음)
- 버그 수정(의도된 동작 복구)
버전 범위 지정으로 자동 업데이트를 통제합니다(package.json 등):
"^1.2.3" → MAJOR 고정(1.x), MINOR·PATCH 자동 (1.5.0 OK, 2.0.0 ✗) ← 가장 흔함
"~1.2.3" → MINOR도 고정, PATCH만 자동 (1.2.9 OK, 1.3.0 ✗) ← 보수적
"1.2.3" → 정확히 그 버전만 ← 완전 고정
캐럿(^)은 "SemVer 약속을 신뢰해 MAJOR만 막고 나머지는 받겠다"는 뜻입니다. 하지만 범위만으론 설치 시점마다 패치가 달라질 수 있어, 잠금 파일로 정확한 버전을 박제합니다(아래).
확대
위 그림처럼 캐럿(^)은 MAJOR를 고정해 가장 넓은 범위를, 틸드(~)는 PATCH만, 정확히 고정하면 해당 버전만 허용합니다.
확대
위 그림처럼 잠금 파일 없이 범위만 지정하면 로컬·CI·Prod가 서로 다른 버전을 설치해 "로컬은 되는데 CI가 깨진다"가 발생하고, 잠금 파일은 이를 방지합니다.
버전 안정성 점검 — 직접 확인
업데이트 전, 어떤 의존성이 MAJOR를 올리는지(위험)와 잠금 파일이 버전을 고정하는지 확인합니다.
# 업데이트 가능한 의존성과 현재/최신 버전 비교
npm outdated
# 잠금 파일이 커밋돼 있는지(재현성의 핵심)
git ls-files | grep -E "package-lock.json|yarn.lock|go.sum|poetry.lock"
# 컨테이너 베이스 이미지가 가변 태그(latest)인지 불변인지
grep -E "^FROM" Dockerfile
$ npm outdated
Package Current Wanted Latest
axios 1.6.2 1.6.8 1.7.0 ← Wanted(1.6.8)는 범위 내 안전, Latest(1.7.0)는 MINOR↑
left-pad 1.3.0 1.3.0 2.0.0 ← Latest가 MAJOR(2.0.0)! 올리면 breaking 가능
$ git ls-files | grep lock
package-lock.json ← 잠금 파일 커밋됨(재현성 OK)
$ grep FROM Dockerfile
FROM node:20.11.1-slim ← 불변 태그(OK). node:latest였다면 위험
npm outdated- npm outdated에서 Latest가 Current보다 MAJOR가 높으면(예: 1.x→2.x) → breaking 가능. 그 패키지는 자동 업데이트 말고 변경로그 확인 후 별도 마이그레이션
- Wanted == Current인데 Latest가 더 높으면 → 범위 지정(^)이 MAJOR를 막고 있는 것(정상). 의도적으로 올리려면 범위를 수정
- 잠금 파일(package-lock 등)이 git에 없으면 → 환경마다 다른 버전 설치 위험("로컬은 되는데 CI는 깨진다"의 주원인). 커밋하도록 요청
- Dockerfile FROM이 :latest나 태그 없음이면 → 빌드 시점마다 베이스가 달라져 재현 불가·롤백 무의미. 불변 태그(node:20.11.1)로 고정
상황: CI가 의존성을 자동 갱신하거나, 잠금 파일 없이 재설치하는 과정에서 한 라이브러리의 MAJOR 버전이 올라가(1.x→2.x) 빌드가 깨지거나, 더 나쁘게는 빌드는 되지만 동작이 미묘하게 달라져 운영에서 버그가 터집니다.
원인: SemVer 약속과 버전 고정의 부재입니다. 범위가 너무 느슨하거나(>=1.0.0) 잠금 파일이 없어, 통제되지 않은 MAJOR 업데이트가 들어왔습니다. MAJOR는 정의상 호환이 깨질 수 있는 변경입니다.
진단:
# 잠금 파일 변경으로 어떤 버전이 점프했는지(PR diff에서)
git diff package-lock.json | grep -E '"version"' | head
# MAJOR가 바뀐 항목이 있으면 그게 범인
해결: (1) 잠금 파일을 커밋해 모든 환경의 버전을 고정. (2) 범위는 ^(MAJOR 고정)을 기본으로, 의존성 업데이트는 자동 PR(Dependabot/Renovate)로 받되 MAJOR는 자동 머지 금지하고 사람이 변경로그를 검토. (3) 업데이트 후 CI/CD 파이프라인의 자동 테스트가 회귀를 잡도록. 버전 숫자를 '읽을 줄 아는' 것만으로 이런 사고의 상당수를 예방합니다.
심화 — 약속의 한계와 잠그기의 청구서
심화: SemVer는 검증되는 규칙이 아니라 자기 신고다
버전 숫자를 읽는 법을 익혔다면, 다음 단계는 그 숫자를 어디까지 믿을지 아는 것입니다. SemVer는 도구가 검증해 주는 규격이 아니라 메인테이너가 스스로 매기는 신고이고, 규모가 커질수록 그 틈이 드러납니다.
- PATCH도 깨질 수 있습니다: 메인테이너가 버그 수정이라 판단한 변경이, 그 버그 동작에 기대던 코드에겐 breaking입니다. 사용자가 충분히 많으면 관찰 가능한 모든 동작에 누군가는 의존하게 된다(하이럼의 법칙)는 말처럼, breaking 여부는 배포자의 의도가 아니라 소비자의 사용 방식이 결정합니다. 그래서 MINOR·PATCH 자동 업데이트도 결국 테스트가 받쳐줘야 안전합니다.
- 잠금 파일이 잠그는 건 트리 전체입니다: package.json에 30개를 적어도 잠금 파일엔 수백~수천 개가 기록됩니다 — 직접 의존성이 끌고 오는 의존성(전이 의존성)까지 전부. 빌드를 깨는 범인은 대개 내가 이름도 모르는 전이 의존성입니다. 그리고 잠금 파일이 있어도
npm install은 package.json과 어긋나면 잠금을 고쳐 쓰므로, CI에선 잠금 그대로만 설치하고 어긋나면 실패하는npm ci를 써야 재현성이 완성됩니다. - 잠그기에도 비용이 있습니다: 완전 고정은 재현성을 주는 대신 보안 패치의 유입도 끊습니다. 잠근 채 방치된 의존성은 조용히 낡아가고(업그레이드 부채), 몇 년 뒤 취약점 대응 때 한꺼번에 청구됩니다. 잠금은 갱신 파이프라인(자동 PR + 테스트)과 짝일 때만 안전장치입니다.
- SemVer가 유일한 체계도 아닙니다: Ubuntu 22.04처럼 날짜를 버전으로 쓰는 CalVer는 호환성 대신 시점을 약속합니다. 버전 문자열을 읽기 전에 그 프로젝트가 어떤 체계를 쓰는지부터 확인해야, 22.04를 MAJOR 22로 오독하지 않습니다.
그래서 성숙한 팀은 버전 숫자를 신뢰의 근거가 아니라 위험 분류의 힌트로 씁니다 — 최종 판정은 언제나 테스트가 합니다.
상황: 운영 서비스의 핵심 의존성에서 심각(Critical) 취약점이 보고됩니다. 대응은 간단해 보였습니다 — 패치된 버전으로 올리면 끝. 그런데 현재 버전은 2.3인데 패치는 5.x에만 반영돼 있습니다. 2년 전 '안정성을 위해' 전 의존성을 정확 버전으로 고정한 뒤 한 번도 올리지 않았던 것입니다.
원인: 완전 고정 전략의 숨은 비용입니다. 고정은 그 시점의 재현성을 주지만, MINOR·PATCH로 흘러들어왔을 보안 수정까지 함께 차단합니다. 업그레이드를 미룬 기간만큼 breaking change가 누적되고(2.x→3.0→4.0→5.0), 취약점이 터진 날 그 부채가 한꺼번에 만기됩니다. 보안 대응은 일정을 기다려 주지 않아, 최악의 타이밍에 최대 규모의 마이그레이션을 강요당합니다.
진단: npm outdated(또는 각 생태계의 동등 도구)로 Current와 Latest의 MAJOR 격차를 정기적으로 봅니다 — 격차가 2 이상 벌어진 핵심 의존성이 곧 잠재 부채 목록입니다. 취약점이 이미 터진 상황이라면, 패치가 구버전 라인에 백포트됐는지(예: 2.3.9) 릴리스 노트부터 확인합니다.
해결: 급한 불은 백포트 패치나 임시 완화(취약 기능 비활성화, 경계에서의 요청 차단)로 끄고, MAJOR 마이그레이션은 별도 작업으로 계획합니다. 재발 방지는 고정을 푸는 게 아니라 갱신을 상시화하는 것입니다 — 자동 PR(Renovate 등)로 MINOR·PATCH를 작게 자주 올리고(CI/CD 파이프라인의 테스트가 게이트), MAJOR는 분기마다 하나씩 계획적으로 소화합니다. 업그레이드는 미룰수록 싸지는 게 아니라 비싸지는 작업입니다.
인프라/SRE로서 버저닝은 재현성과 롤백의 토대입니다 — 컨테이너 이미지를 불변 태그(v1.2.0)로 빌드해 릴리스 전략의 롤백 기준점을 만들고, 의존성 잠금 파일을 강제해 "어느 환경에서나 같은 빌드"를 보장합니다. 의존성 자동 업데이트(Renovate 등)를 도입하되 MAJOR는 게이트로 막아, 통제되지 않은 breaking change가 파이프라인에 흘러들지 않게 합니다. PM은 자사 API의 버전을 SemVer로 관리해 외부 연동사에 "이번 변경이 깨지는가(MAJOR)"를 명확히 약속하고, breaking 변경 시 마이그레이션 기간·deprecated 공지를 일정에 넣습니다. 버전 숫자는 팀과 외부가 호환성을 두고 나누는 공용 언어입니다.
이것으로 Phase 4(빌드·배포·릴리스)를 마칩니다. 다음 Phase에서는 이렇게 배포되는 시스템의 구조 자체 — 소프트웨어 아키텍처를 다룹니다.
실전 랩으로 손에 익히기: 시맨틱 버저닝 실습 — 변경을 major/minor/patch로 분류하고 버전·호환성을 관리합니다.