시맨틱 임포트 버저닝
이 챕터에서 다루는 것
6-3의 MVS는 "요구들 중 최댓값"을 고른다. 그런데 그 최댓값이 호환되지 않는 버전이면 어떻게 되는가? Go의 답은 다른 어떤 언어와도 다르다 — 호환이 깨지면 import 경로 자체를 바꾼다.
이 규칙과, 그 규칙이 만들어 낸 pseudo-version·replace·exclude·retract를 본다.
문제 — 다이아몬드 의존성
내 프로그램이 A와 B를 쓰는데, A는 lib v1을, B는 lib v2를 요구한다. lib v2는
호환을 깼다.
대부분의 언어는 두 가지 중 하나를 한다.
- 하나만 고른다 (Java의 클래스패스, Python의 site-packages). 어느 한쪽이 깨진다.
- 둘 다 설치한다 (npm의 중첩
node_modules). 대신 같은 타입의 인스턴스가 두 종류 생겨서, 라이브러리 경계를 넘는 순간 정체 불명의 타입 에러가 난다.
Go는 세 번째 답을 골랐다.
임포트 호환성 규칙(import compatibility rule) 옛 패키지와 새 패키지의 import 경로가 같다면, 새 패키지는 반드시 하위 호환되어야 한다.
대우를 취하면 이렇게 된다. 호환을 깨려면 경로를 바꿔야 한다. 그래서 v2 이상은
경로 끝에 /v2가 붙는다.
SemVer + Go의 추가 규칙
Go 모듈의 버전은 vMAJOR.MINOR.PATCH 형식이고, 앞에 v가 반드시 붙는다.
(1.5.2가 아니라 v1.5.2다.)
| 자리 | 올리는 때 |
|---|---|
| MAJOR | 하위 호환을 깼다 |
| MINOR | 기능을 추가했고 호환된다 |
| PATCH | 버그를 고쳤다 |
Go가 여기에 더한 규칙은 셋이다.
v0.x.y는 안정성 약속이 없다. 언제든 깨도 된다. 그래서 v0 안에서는 경로가 안 바뀐다.v1.x.y는 약속이다. v1을 붙이는 순간 하위 호환을 지켜야 한다.v2이상은 모듈 경로에/vN이 들어간다.v0과v1은 예외적으로 안 붙인다.
세 번째 규칙 때문에 golang.org/x/text가 아직도 v0.40.0인 것이다. Go 팀은
v1을 붙여 안정성을 약속하기보다 v0에 머무는 쪽을 택했다.
메이저 버전은 공존한다
/v3가 붙으면 Go에게는 그냥 다른 경로의 다른 패키지다. 한 바이너리 안에 같이
들어갈 수 있다.
// semver는 같은 라이브러리의 v1과 v3를 한 바이너리 안에서 동시에 쓴다.
// 경로가 다르므로 Go에게는 그냥 서로 다른 두 패키지다.
package main
import (
"fmt"
// 둘 다 패키지 이름이 quote다. 별칭이 없으면 이름이 충돌한다.
quotev1 "rsc.io/quote"
quotev3 "rsc.io/quote/v3"
)
func main() {
// v3는 호환을 깨면서 함수 이름을 전부 바꿨다. 그래서 v1과 v3의 API가 다르다.
fmt.Println("v1 Hello:", quotev1.Hello())
fmt.Println("v3 HelloV3:", quotev3.HelloV3())
fmt.Println("v1 Glass:", quotev1.Glass())
fmt.Println("v3 GlassV3:", quotev3.GlassV3())
// v3에만 있는 함수. 메이저 버전이 다르면 추가·삭제가 자유롭다.
fmt.Println("v3 Concurrency:", quotev3.Concurrency())
// 두 패키지는 완전히 별개다. 여기서는 둘 다 string이라 비교가 되지만,
// 구조체 타입이었다면 v1의 타입을 v3 함수에 넘길 수 없다.
fmt.Println("같은 문장인가:", quotev1.Glass() == quotev3.GlassV3())
}
cd examples/06-modules-and-layout/04-semantic-import-versioning
go run .
v1 Hello: Ahoy, world!
v3 HelloV3: Ahoy, world!
v1 Glass: I can eat glass and it doesn't hurt me.
v3 GlassV3: I can eat glass and it doesn't hurt me.
v3 Concurrency: Concurrency is not parallelism.
같은 문장인가: true
module example.com/semver
go 1.26.5
require (
rsc.io/quote v1.5.2
rsc.io/quote/v3 v3.1.0
)
require (
golang.org/x/text v0.0.0-20170915032832-14c0d48ead0c // indirect
rsc.io/sampler v1.3.0 // indirect
)
두 줄 다 require에 들어 있다. MVS는 이 둘을 서로 다른 모듈로 본다. 충돌이
일어날 여지 자체가 없다.
두 패키지의 패키지 이름은 둘 다 quote 라는 점을 보자. 경로만 다르고 이름은
같다. 그래서 별칭이 필요하다 — 6-1에서 별칭을 "충돌 해소에만
쓴다"고 한 그 상황이 정확히 이것이다.
:::warning 공존이 가능한 것과 바람직한 것은 다르다
v1과 v3가 같은 구조체 타입을 노출한다면, 그 둘은 이름만 같은 완전히 다른
타입이다. quotev1.Config를 quotev3.New()에 넘길 수 없고, 컴파일러 메시지도
cannot use x (variable of type quote.Config) as quote.Config value처럼 사람을
미치게 만든다.
공존은 전환기를 견디기 위한 장치다. 라이브러리 A가 v1을, 내 코드가 v3를 쓰는 상태를 잠시 허용해 준다. 영구적인 설계가 아니다. :::
v2를 만드는 쪽
내 모듈을 v2로 올리려면 go.mod의 module 줄을 고친다.
module github.com/user/mylib/v2
go 1.26.5
그리고 자기 패키지끼리의 내부 import 경로도 전부 /v2로 바꿔야 한다. 그다음
v2.0.0 태그를 단다.
디렉터리 구조는 두 가지 선택지가 있다.
| 방식 | 구조 | 특징 |
|---|---|---|
| 메이저 브랜치 | 저장소 루트의 go.mod가 /v2. v1은 다른 브랜치 | 파일 중복이 없다. v1 패치가 번거롭다 |
| 메이저 서브디렉터리 | 루트는 v1, v2/ 디렉터리에 별도 go.mod | 한 브랜치에서 둘 다 관리. 코드가 중복된다 |
대부분은 메이저 브랜치를 쓴다. 서브디렉터리 방식은 v1을 오래 유지보수해야 하는 큰 라이브러리에서나 값어치를 한다.
경로를 안 바꾸고 v2 태그만 달면 이렇게 거부당한다.
go get rsc.io/quote@v3.1.0
go: rsc.io/quote@v3.1.0: invalid version: module contains a go.mod file, so module path must match major version ("rsc.io/quote/v3")
에러 메시지가 정확히 무엇을 해야 하는지 알려 준다.
pseudo-version
태그가 없는 커밋을 가리켜야 할 때 Go가 자동으로 만들어 내는 버전이다. 위 go.mod에
이미 하나 있다.
golang.org/x/text v0.0.0-20170915032832-14c0d48ead0c
세 조각으로 읽는다.
v0.0.0 - 20170915032832 - 14c0d48ead0c
│ │ └── 커밋 해시 앞 12자리
│ └── 커밋 시각 (UTC, yyyymmddhhmmss)
└── 기준 버전
@master처럼 브랜치를 요청하면 즉석에서 만들어진다.
go get golang.org/x/text@master
go: downloading golang.org/x/text v0.40.1-0.20260805201538-02aa981a75cb
go: added golang.org/x/text v0.40.1-0.20260805201538-02aa981a75cb
(커밋 해시와 시각은 당연히 실행 시점에 따라 다르다.)
형태가 세 가지다. 기준 버전을 어떻게 잡느냐의 차이다.
| 앞선 태그가 | 형태 | 예 |
|---|---|---|
| 없다 | v0.0.0-시각-해시 | v0.0.0-20170915032832-14c0d48ead0c |
vX.Y.Z 정식 릴리스 | vX.Y.(Z+1)-0.시각-해시 | v0.40.1-0.20260805201538-02aa981a75cb |
vX.Y.Z-pre 프리릴리스 | vX.Y.Z-pre.0.시각-해시 | v1.2.0-rc.1.0.20260101…-abc123456789 |
형태가 왜 이렇게 복잡한가? SemVer 정렬 규칙에서 pseudo-version이 항상 기준
태그보다는 높고, 다음 정식 릴리스보다는 낮게 나와야 하기 때문이다.
v0.40.1-0.…은 v0.40.0보다 높고 v0.40.1보다 낮다 (프리릴리스가 정식보다
낮으므로). MVS가 제대로 돌려면 이 순서가 지켜져야 한다.
:::tip pseudo-version은 손으로 쓰지 않는다
go get 경로@커밋해시나 go get 경로@브랜치를 쓰면 도구가 정확한 형태로
만들어 준다. 손으로 적으면 십중팔구 시각이 틀리고, 그러면
invalid pseudo-version 에러가 난다.
:::
태그가 하나도 없는 저장소
go get github.com/someone/untagged를 하면 기본 브랜치의 최신 커밋으로
v0.0.0-시각-해시 pseudo-version이 만들어진다. 동작하기는 한다.
다만 이런 의존성은 go.mod만 보고 무엇이 들어 있는지 알 수 없고, go list -m -u도
쓸모가 없다. 남이 쓸 라이브러리를 만든다면 태그를 다는 것이 최소한의 예의다.
git tag v0.1.0 && git push --tags 한 줄이다.
replace
모듈을 다른 것으로 바꿔치기한다. 두 가지 형태가 있다.
// 1. 로컬 디렉터리로 (버전 없음)
replace rsc.io/sampler => ../fork
// 2. 다른 모듈/버전으로
replace rsc.io/sampler v1.3.0 => rsc.io/sampler v1.2.1
replace golang.org/x/net => github.com/myorg/net v1.0.0
로컬 치환을 실제로 돌려 보면 이렇게 된다.
module example.com/app
go 1.26.5
replace rsc.io/sampler => ../fork
require rsc.io/sampler v0.0.0-00010101000000-000000000000
go run .
포크된 sampler
go list -m all
example.com/app
rsc.io/sampler v0.0.0-00010101000000-000000000000 => ../fork
v0.0.0-00010101000000-000000000000은 "버전이 없다"는 뜻의 자리 표시자다.
로컬 디렉터리에는 태그가 없으니 Go가 영년 0시 0분의 pseudo-version을 만들어
채워 넣는다. 이것이 go.mod에 보이면 로컬 replace가 걸려 있다는 신호다.
replace의 가장 중요한 성질
replace는 메인 모듈에서만 효력이 있다. 내 라이브러리의 go.mod에 replace를
써 두어도, 그 라이브러리를 쓰는 사람에게는 아무 영향이 없다.
이 성질은 양날이다.
- 좋은 점: 라이브러리가 사용자의 의존성 그래프를 몰래 조작할 수 없다.
- 나쁜 점: 라이브러리를
replace로 로컬 개발하다 커밋해 버리면, 사용자는replace가 안 먹어서 존재하지 않는 버전을 받으려다 실패한다.
로컬 개발용 replace는 커밋하지 않는다. 여러 모듈을 동시에 개발할 때는
6-7의 go.work가 정확히 이 문제를 위해 있다.
exclude
특정 버전을 선택 대상에서 뺀다. MVS가 그 버전을 고르려 하면 그다음으로 높은 버전으로 넘어간다.
go mod edit -exclude=rsc.io/sampler@v1.3.0
go mod tidy
go: dropping requirement on excluded version rsc.io/sampler v1.3.0
go: finding module for package rsc.io/sampler
go: found rsc.io/sampler in rsc.io/sampler v1.99.99
module example.com/exctest
go 1.26.5
require rsc.io/quote v1.5.2
require (
golang.org/x/text v0.0.0-20170915032832-14c0d48ead0c // indirect
rsc.io/sampler v1.99.99 // indirect
)
exclude rsc.io/sampler v1.3.0
go run .
99 bottles of beer on the wall, 99 bottles of beer, ...
깨진 버전 하나를 피하려다 더 깨진 버전으로 올라갔다. exclude는
"그다음 것으로 넘어가라"는 뜻이지 "안전한 것으로 가라"가 아니다. 거의 언제나
go get 모듈@원하는버전으로 명시적으로 고정하는 편이 낫다.
exclude도 replace와 마찬가지로 메인 모듈에서만 효력이 있다.
retract
내가 배포한 버전을 회수한다. 태그를 지우는 것과는 다르다 — 이미 받아 간 사람의 빌드를 깨뜨리지 않으면서 "이 버전 쓰지 마세요"를 알린다.
배포하는 쪽의 go.mod에 이렇게 쓴다.
module github.com/user/mylib
go 1.26.5
retract (
v1.0.1 // API를 실수로 깨뜨렸다
[v1.2.0, v1.2.3] // 데이터 손상 버그
)
retract v1.0.0 // 잘못 태깅됨
핵심은 회수 정보가 그 모듈의 최신 버전 go.mod에 들어간다는 점이다. 그래서
v1.0.1을 회수하려면 v1.0.2를 새로 내면서 거기에 retract v1.0.1을 적어야 한다.
효과는 이렇다.
@latest가 회수된 버전을 고르지 않는다.go list -m -u가 회수 사실을 알려 준다.- 이미 그 버전을
require하고 있으면 그대로 쓴다. 강제로 못 쓰게 만들지 않는다. go list -m -retracted 모듈경로로 회수된 버전까지 볼 수 있다.
자기 자신을 회수할 수도 있다. retract v1.2.0을 v1.2.0 자신의 go.mod에 넣으면
그 버전은 사실상 없는 것이 된다 — 다만 그 정보를 읽으려면 그 버전의 go.mod를
봐야 하므로, 실무에서는 다음 버전에 적는 쪽이 확실하다.
흔히 하는 실수
1. v2 태그를 달면서 모듈 경로를 안 바꾼다
가장 흔한 사고다. 사용자가 go get하면 module path must match major version으로
거부당한다. 태그 하나 잘못 달면 되돌리기 번거로우니 경로를 먼저 바꾸고 태그를
단다.
2. /v2 붙인 뒤 내부 import를 안 고친다
module github.com/user/lib/v2로 바꿨으면, 자기 패키지끼리의 import도
github.com/user/lib/v2/internal/...이 돼야 한다. 안 고치면 자기 모듈의 v1을
의존성으로 끌어다 쓰는 기괴한 상태가 된다.
3. replace를 커밋한다
로컬 경로 replace가 들어간 채 배포된 모듈은 남이 쓸 수 없다. go.work를 쓴다.
4. exclude로 문제를 해결하려 한다
다음 버전으로 밀려 올라갈 뿐이다. go get 모듈@버전으로 고정한다.
5. v1.0.0 태그를 가볍게 단다
v1은 하위 호환 약속이다. 확신이 없으면 v0에 머무는 것이 정직하다. Go 팀 자신도
golang.org/x/*를 10년 넘게 v0로 두고 있다.
6. pseudo-version을 손으로 적는다
go get 경로@커밋으로 도구가 만들게 한다.
정리
- 임포트 호환성 규칙: 경로가 같으면 호환돼야 한다. 그래서 호환을 깨려면 경로를 바꾼다.
- v2 이상은 모듈 경로에
/vN이 붙는다. v0과 v1은 예외다. v0은 안정성 약속이 없고, v1은 약속이다. - 경로가 다르므로 메이저 버전은 한 바이너리 안에서 공존한다. 전환기를 위한 장치이지 영구 설계가 아니다. 두 버전의 타입은 완전히 별개다.
- pseudo-version은
기준버전-커밋시각-커밋해시다. 형태가 세 가지인 이유는 SemVer 정렬 순서를 지키기 위해서다. 손으로 쓰지 않는다. replace와exclude는 메인 모듈에서만 효력이 있다. 라이브러리에 써 봐야 사용자에게 전달되지 않는다. 로컬replace는 커밋하지 않는다.retract은 배포한 버전을 회수한다. 다음 버전의go.mod에 적고, 이미 쓰는 사람은 깨지지 않는다.
연습문제
-
예제 모듈에
rsc.io/quote/v4를 추가해 보자. 존재하는가? 없다면 어떤 에러가 나오는가?go list -m -versions rsc.io/quote와go list -m -versions rsc.io/quote/v3의 결과를 비교해 보고, 왜 두 명령이 서로 다른 목록을 내는지 설명해 보자. -
작은 모듈을 하나 만들어 git 저장소로 초기화하고
v1.0.0태그를 단 뒤, 호환을 깨는 변경을 하고v2.0.0으로 올려 보자.go.mod의module줄을 안 고치고 태그만 달면 다른 모듈에서go get할 때 어떻게 되는가? 고친 뒤에는? (6-8에서 이 과정을 처음부터 끝까지 한다.) -
replace로rsc.io/quote를 로컬 디렉터리로 치환하고, 그 디렉터리의go.mod에 다른 모듈 경로를 적어 보자 (예:module example.com/notquote). 어떤 에러가 나오는가? 이 검사가 없다면 무슨 일이 생기겠는가?