본문으로 건너뛰기

시맨틱 임포트 버저닝

이 챕터에서 다루는 것

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가 여기에 더한 규칙은 셋이다.

  1. v0.x.y는 안정성 약속이 없다. 언제든 깨도 된다. 그래서 v0 안에서는 경로가 안 바뀐다.
  2. v1.x.y는 약속이다. v1을 붙이는 순간 하위 호환을 지켜야 한다.
  3. v2 이상은 모듈 경로에 /vN이 들어간다. v0v1은 예외적으로 안 붙인다.

세 번째 규칙 때문에 golang.org/x/text가 아직도 v0.40.0인 것이다. Go 팀은 v1을 붙여 안정성을 약속하기보다 v0에 머무는 쪽을 택했다.

메이저 버전은 공존한다

/v3가 붙으면 Go에게는 그냥 다른 경로의 다른 패키지다. 한 바이너리 안에 같이 들어갈 수 있다.

examples/06-modules-and-layout/04-semantic-import-versioning/main.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
go.mod
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.Configquotev3.New()에 넘길 수 없고, 컴파일러 메시지도 cannot use x (variable of type quote.Config) as quote.Config value처럼 사람을 미치게 만든다.

공존은 전환기를 견디기 위한 장치다. 라이브러리 A가 v1을, 내 코드가 v3를 쓰는 상태를 잠시 허용해 준다. 영구적인 설계가 아니다. :::

v2를 만드는 쪽

내 모듈을 v2로 올리려면 go.modmodule 줄을 고친다.

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

로컬 치환을 실제로 돌려 보면 이렇게 된다.

app/go.mod
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.modreplace를 써 두어도, 그 라이브러리를 쓰는 사람에게는 아무 영향이 없다.

이 성질은 양날이다.

  • 좋은 점: 라이브러리가 사용자의 의존성 그래프를 몰래 조작할 수 없다.
  • 나쁜 점: 라이브러리를 replace로 로컬 개발하다 커밋해 버리면, 사용자는 replace가 안 먹어서 존재하지 않는 버전을 받으려다 실패한다.

로컬 개발용 replace는 커밋하지 않는다. 여러 모듈을 동시에 개발할 때는 6-7go.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 모듈@원하는버전으로 명시적으로 고정하는 편이 낫다.

excludereplace와 마찬가지로 메인 모듈에서만 효력이 있다.

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 정렬 순서를 지키기 위해서다. 손으로 쓰지 않는다.
  • replaceexclude는 메인 모듈에서만 효력이 있다. 라이브러리에 써 봐야 사용자에게 전달되지 않는다. 로컬 replace는 커밋하지 않는다.
  • retract은 배포한 버전을 회수한다. 다음 버전의 go.mod에 적고, 이미 쓰는 사람은 깨지지 않는다.

연습문제

  1. 예제 모듈에 rsc.io/quote/v4를 추가해 보자. 존재하는가? 없다면 어떤 에러가 나오는가? go list -m -versions rsc.io/quotego list -m -versions rsc.io/quote/v3의 결과를 비교해 보고, 왜 두 명령이 서로 다른 목록을 내는지 설명해 보자.

  2. 작은 모듈을 하나 만들어 git 저장소로 초기화하고 v1.0.0 태그를 단 뒤, 호환을 깨는 변경을 하고 v2.0.0으로 올려 보자. go.modmodule 줄을 안 고치고 태그만 달면 다른 모듈에서 go get할 때 어떻게 되는가? 고친 뒤에는? (6-8에서 이 과정을 처음부터 끝까지 한다.)

  3. replacersc.io/quote를 로컬 디렉터리로 치환하고, 그 디렉터리의 go.mod다른 모듈 경로를 적어 보자 (예: module example.com/notquote). 어떤 에러가 나오는가? 이 검사가 없다면 무슨 일이 생기겠는가?