Go modules: 의존성 및 버전 관리
1. 개요
Go 1.11부터 Go modules가 의존성을 관리하는 공식적인 메커니즘입니다. Go 1.16부터는 기본 모드이며, 기존의 GOPATH 기반 방식을 완전히 대체합니다. 이 글에서는 모듈의 전체 수명 주기를 다룹니다: 초기화, 의존성 추가, 버전 선택 및 업그레이드, v2+ major version 처리, 그리고 로컬 코드 또는 비공개 저장소와의 작업. 이를 읽고 나면 go.mod와 go.sum이 무엇을 포함하는지 이해할 것입니다. Go가 특정 버전을 선택하는 이유와 흔한 의존성 문제를 해결하는 방법도 알게 될 것입니다.
2. 모듈 초기화
모듈은 루트에 go.mod 파일이 있는 디렉토리 트리입니다. go mod init으로 새 모듈을 만듭니다:
mkdir order-service && cd order-service
go mod init github.com/nonla/order-service
생성된 go.mod 파일:
module github.com/nonla/order-service
go 1.24
각 줄의 의미:
module은 module path입니다. 모듈 내의 모든 패키지에 대한 import 접두사입니다. 다른 사람이go get으로 이 모듈을 사용한다면, 이 경로는 저장소 URL과 일치해야 합니다.go 1.24는 모듈이 요구하는 최소 언어 버전입니다. Go 1.21부터 이 줄은 필수입니다. 도구 체인이 더 오래된 버전이면, Go는 자동으로 적절한 도구 체인을 다운로드합니다 (GOTOOLCHAIN=auto인 경우) 또는 오류를 보고합니다.
주의: 절대 외부에서 import되지 않는 내부 애플리케이션의 경우,
order-service같은 짧은 경로를 사용할 수 있습니다. 그러나 처음부터 URL 형식의 경로를 사용하면 나중에 대량의 import를 변경해야 하는 상황을 피할 수 있습니다.
3. 의존성 추가
3.1. go get 사용
주문의 ID를 UUID로 생성해야 한다고 가정합니다. github.com/google/uuid 라이브러리를 추가합니다:
go get github.com/google/uuid@v1.6.0
그런 다음 라이브러리를 사용하는 코드를 작성합니다:
// order/order.go
package order
import "github.com/google/uuid"
type Order struct {
ID string
Customer string
Total int64 // 단위: 동
}
func NewOrder(customer string, total int64) Order {
return Order{
ID: uuid.NewString(),
Customer: customer,
Total: total,
}
}
이제 go.mod에 require 섹션이 추가됩니다:
module github.com/nonla/order-service
go 1.24
require github.com/google/uuid v1.6.0
go get 단계를 건너뛸 수도 있습니다: 먼저 import를 작성한 다음 go mod tidy를 실행합니다. Go는 최신 버전을 자동으로 찾아 go.mod에 기록합니다.
3.2. go.sum 파일
동시에 Go는 go.sum 파일을 생성합니다:
github.com/google/uuid v1.6.0 h1:<모듈 내용의 해시>
github.com/google/uuid v1.6.0/go.mod h1:<go.mod 파일의 해시>
각 의존성에는 두 줄이 있습니다. 첫 번째 줄은 모듈의 전체 내용 해시이고, 두 번째 줄은 해당 go.mod 파일의 해시입니다. 모듈을 다운로드할 때 Go는 실제 해시를 go.sum과 비교합니다. 또한 공개 체크섬 데이터베이스(sum.golang.org)와도 대조합니다. 누군가 이미 릴리스된 태그의 내용을 수정하면, 알 수 없는 코드를 조용히 사용하는 대신 빌드가 실패합니다.
주의:
go.mod와go.sum모두 항상 커밋하세요.go.sum을.gitignore에 추가하지 마세요. 이 파일이 없으면 CI 머신은 의존성을 검증할 수 없습니다.
3.3. 직접 의존성과 간접 의존성
테스트를 작성하기 위해 testify를 추가합니다:
go get github.com/stretchr/testify@v1.10.0
go.mod는 두 개의 require 섹션을 갖게 됩니다:
require (
github.com/google/uuid v1.6.0
github.com/stretchr/testify v1.10.0
)
require (
github.com/davecgh/go-spew v1.1.1 // indirect
github.com/pmezard/go-difflib v1.0.0 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
)
// indirect 주석은 코드에서 직접 import하지 않지만 다른 의존성에 필요한 모듈을 표시합니다. Go가 이들을 go.mod에 기록하는 이유는 빌드를 완전히 재현할 수 있도록 하기 위함입니다. 덕분에 Go는 각 의존성의 go.mod를 다시 읽어서 의존성 그래프를 구성할 필요가 없습니다.
4. 버전 및 Go의 버전 선택 방식
4.1. Semantic versioning
Go modules는 vMAJOR.MINOR.PATCH 형식의 semantic versioning을 사용합니다:
PATCH(v1.6.0 → v1.6.1): 버그 수정, API 변경 없음.MINOR(v1.6.0 → v1.7.0): 기능 추가, 역호환성 유지.MAJOR(v1 → v2): 호환성을 깨는 변경.
버전은 v 접두사를 가진 git 태그여야 합니다. 태그가 없는 커밋을 가리키면, Go는 pseudo-version을 생성합니다:
go get github.com/google/uuid@0f3b7a2
github.com/google/uuid v1.6.1-0.20240815101530-0f3b7a2c1d4e
pseudo-version은 세 부분으로 구성됩니다: 기본 버전, 커밋의 UTC 타임스탬프, 해시의 첫 12자. 이렇게 하면 pseudo-version도 올바른 시간 순서로 정렬됩니다.
4.2. Minimal Version Selection
이것은 npm이나 Maven과 비교할 때 가장 큰 차이점입니다. Go는 Minimal Version Selection (MVS) 알고리즘을 사용합니다. 각 모듈에 대해 Go는 존재하는 최신 버전이 아니라 요구되는 버전 중 가장 높은 버전을 선택합니다.
예를 들어, 프로젝트에 두 개의 의존성이 있다면:
order-service
├── payment-sdk → require github.com/google/uuid v1.3.0
└── shipping-sdk → require github.com/google/uuid v1.5.0
uuid v1.6.0이 이미 릴리스되었더라도, Go는 v1.5.0을 선택합니다. 이유는 모든 요구사항을 만족하는 가장 낮은 버전이기 때문입니다. 결과:
- 빌드는 재현 가능합니다. 라이브러리의 새 릴리스가 빌드를 자동으로 변경하지 않습니다.
- 별도의 lock 파일이 필요 없습니다.
go.mod자체가 버전을 확인하기에 충분합니다. - 더 새로운 버전을 사용하려면 명시적으로 업그레이드해야 합니다.
실제로 선택된 버전을 다음 명령으로 확인합니다:
go list -m all
github.com/nonla/order-service
github.com/davecgh/go-spew v1.1.1
github.com/google/uuid v1.5.0
...
4.3. 업그레이드 및 다운그레이드
새로운 버전이 있는 의존성을 확인합니다:
go list -m -u all
github.com/google/uuid v1.5.0 [v1.6.0]
github.com/stretchr/testify v1.10.0
괄호 안의 버전은 업그레이드할 수 있는 최신 버전입니다. 자주 사용하는 업그레이드 명령:
go get github.com/google/uuid@latest # 최신 버전
go get github.com/google/uuid@v1.5.0 # 특정 버전으로 이동 (다운그레이드 가능)
go get -u=patch ./... # 모든 의존성에 대해 PATCH만 업그레이드
go get -u ./... # 모든 의존성에 대해 MINOR + PATCH 업그레이드
go get github.com/google/uuid@none # 의존성 완전 제거
주의:
go get -u ./...는 간접 의존성도 업그레이드합니다. diff가 매우 클 수 있습니다. 프로덕션 프로젝트에서는 각 모듈을 하나씩 업그레이드하고, 테스트를 실행한 다음, 커밋하는 것이 좋습니다.
5. Major version v2 이상
Go는 import compatibility 규칙을 적용합니다: 같은 import path를 가진 두 버전은 역호환성이 있어야 합니다. 따라서 v2부터는 major version이 모듈 경로의 일부입니다.
예를 들어 router chi의 경우:
go get github.com/go-chi/chi/v5@v5.1.0
package main
import (
"net/http"
"github.com/go-chi/chi/v5"
)
func main() {
r := chi.NewRouter()
r.Get("/orders", func(w http.ResponseWriter, _ *http.Request) {
w.Write([]byte("주문 목록"))
})
http.ListenAndServe(":8080", r)
}
/v5 접미사는 import 경로에 포함됩니다. 패키지 이름은 여전히 chi이므로 chi.NewRouter()를 평소처럼 호출합니다. chi/v4와 chi/v5는 경로가 다르므로, 두 버전이 하나의 바이너리에 동시에 존재할 수 있습니다. 이를 통해 점진적으로 한 부분씩 마이그레이션할 수 있습니다.
라이브러리 작성자로서 v2를 릴리스하려면:
module github.com/nonla/pricing/v2
go 1.24
module 줄을 /v2 접미사가 있는 경로로 변경하고, 내부 import를 업데이트한 다음, v2.0.0 태그를 붙입니다.
주의: 자주 하는 실수는
v2.0.0태그를 붙이는 것만 잊고 모듈 경로를 변경하지 않는 것입니다. 이 경우go get github.com/nonla/pricing@v2.0.0은invalid version: module contains a go.mod file, so module path must match major version같은 오류를 보고합니다.
6. 유용한 go mod 명령
go mod tidy # 누락된 모듈을 추가하고, 사용하지 않는 모듈을 제거
go mod why -m gopkg.in/yaml.v3 # 이 모듈이 왜 존재하는지 설명
go mod graph # 전체 의존성 그래프 출력
go mod verify # 로컬 캐시가 go.sum과 일치하는지 확인
go mod download # 미리 의존성을 다운로드 (Docker 레이어에 유용)
go mod why의 출력 예시:
# gopkg.in/yaml.v3
github.com/nonla/order-service/order
github.com/stretchr/testify/assert
gopkg.in/yaml.v3
위에서 아래로 읽으면: order 패키지 (테스트에서) testify/assert를 import하고, testify/assert는 다시 yaml.v3을 import합니다.
매 커밋 전에 go mod tidy를 실행하세요. CI에서는 go.mod이 정리되어 있는지 확인할 수 있습니다:
go mod tidy -diff
Go 1.23부터, -diff 플래그는 차이를 출력하고 파일이 정리되지 않았으면 0이 아닌 exit code를 반환합니다. 이 명령은 파일을 덮어쓰지 않습니다.
7. 로컬 코드로 작업: replace와 go.work
7.1. replace 지시문
pricing 라이브러리를 옆 디렉토리에서 수정 중이고, order-service가 수정 중인 버전을 즉시 사용하길 원한다고 가정합니다:
require github.com/nonla/pricing v1.2.0
replace github.com/nonla/pricing => ../pricing
Go는 네트워크에서 다운로드하는 대신 ../pricing에서 코드를 읽습니다. replace는 또한 포크를 가리킬 수 있습니다:
replace github.com/nonla/pricing => github.com/minh/pricing v1.2.1-fix
주의:
replace는 메인 모듈에서만 유효합니다. 모듈이 라이브러리인 경우, 이를 사용하는 사람들은replace를 받지 않습니다. 또한 로컬 경로를 가리키는replace를 커밋하지 마세요. CI에서의 빌드는../pricing디렉토리가 존재하지 않아 실패할 것입니다.
7.2. go.work를 사용한 워크스페이스
Go 1.18부터, 워크스페이스는 여러 모듈을 동시에 개발하는 더 깔끔한 방법입니다:
go work init ./order-service ./pricing
go.work 파일이 생성됩니다:
go 1.24
use (
./order-service
./pricing
)
워크스페이스 내에서 go build 또는 go test를 실행하면, github.com/nonla/pricing에 대한 모든 import는 로컬 코드를 사용합니다. go.mod를 수정할 필요가 없습니다. 보통 go.work는 커밋되지 않으며, 각 개발자가 디렉토리 구성에 따라 직접 생성합니다.
7.3. exclude와 retract
exclude는 메인 모듈에서 특정 버전을 제외하는 데 사용됩니다:
exclude github.com/google/uuid v1.4.0
retract는 라이브러리 작성자가 선언하여 릴리스된 버전을 사용하지 말아야 한다고 표시합니다:
retract v1.2.0 // 국제 주문에 대한 VAT 계산 오류
이를 포함하는 새 버전을 릴리스한 후, go get @latest는 v1.2.0을 건너뜁니다. go list -m -u 명령도 그 버전을 사용 중인 사람들에게 경고합니다.
8. 비공개 모듈 및 프록시
기본적으로 Go는 GOPROXY=https://proxy.golang.org,direct를 통해 모듈을 다운로드하고 sum.golang.org를 통해 체크섬을 확인합니다. 내부 저장소의 경우 공개 프록시가 접근할 수 없고, 모듈 이름을 외부에 노출하고 싶지 않습니다. 다음을 선언합니다:
go env -w GOPRIVATE=github.com/nonla/*,gitlab.nonla.internal/*
GOPRIVATE의 패턴과 일치하는 모듈은 git을 통해 직접 다운로드되며 체크섬 데이터베이스를 건너뜁니다. 여전히 git용 인증을 구성해야 합니다. 예를 들어 ~/.netrc 또는 SSH:
git config --global url."git@github.com:".insteadOf "https://github.com/"
회사에 자체 프록시 (Athens, Artifactory…)가 있으면, GOPROXY의 맨 앞에 놓으세요:
go env -w GOPROXY=https://goproxy.nonla.internal,https://proxy.golang.org,direct
9. 의존성으로 동작 테스트
섹션 3.3에서 추가한 testify를 사용하여 NewOrder에 대한 테스트를 작성합니다:
// order/order_test.go
package order
import (
"testing"
"github.com/google/uuid"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestNewOrder_GeneratesValidUniqueID(t *testing.T) {
first := NewOrder("Nguyễn Văn An", 250_000)
second := NewOrder("Trần Thị Bình", 120_000)
_, err := uuid.Parse(first.ID)
require.NoError(t, err)
assert.NotEqual(t, first.ID, second.ID)
assert.Equal(t, "Nguyễn Văn An", first.Customer)
assert.EqualValues(t, 250_000, first.Total)
}
테스트를 실행합니다:
go test ./...
ok github.com/nonla/order-service/order 0.004s
테스트는 세 가지를 확인합니다: ID는 유효한 UUID, 두 주문은 서로 다른 ID, 다른 필드들은 올바르게 할당되었습니다. require.NoError는 ID를 parse할 수 없으면 테스트를 즉시 중단합니다. assert는 오류를 기록한 후 계속 실행합니다. 새 머신에서 처음 실행할 때, Go는 go.mod의 정확한 버전에 따라 의존성을 다운로드하고 컴파일하기 전에 go.sum과 해시를 확인합니다.
10. 흔한 오류
missing go.sum entry for module providing package:go.sum에 해당하는 줄이 없습니다. 보통 누군가go.mod를 손으로 수정했을 때입니다. 해결:go mod tidy를 실행하세요.checksum mismatch: 모듈 내용이 해시와 일치하지 않습니다.go clean -modcache를 시도해보세요. 공개 모듈에서 여전히 오류가 나면, 이는 심각한 신호이므로go.sum의 줄을 무시하고 삭제하지 마세요.go: updates to go.mod needed: CI는 보통-mod=readonly로 실행되지만go.mod이 정리되지 않았습니다. 로컬에서go mod tidy를 실행한 다음 다시 커밋하세요.- major version을 잘못 import하기:
.../chi/v5대신github.com/go-chi/chi를 사용합니다. 오래된 v1이나 이상한 pseudo-version을 받게 될 것입니다. 먼저 import 경로를 확인하세요. vendor/남용하기:go mod vendor는 완전한 오프라인 빌드가 필요할 때 여전히 유용합니다. 그 외에는, 프록시와go.sum이 재현성을 보장하므로 수천 개의 vendor 파일을 커밋할 필요가 없습니다.
11. 결론
Go modules는 몇 가지 간단한 개념으로 의존성 문제를 해결합니다. go.mod은 요구사항을 선언하고, go.sum은 무결성을 보장하며, Minimal Version Selection은 lock 파일 없이 빌드를 재현 가능하게 합니다. Major version은 import 경로 자체에 포함되므로, 호환성을 깨는 변경은 항상 코드에서 명확히 드러납니다.
일상의 작업에서는 네 가지 습관만 기억하면 됩니다:
go get module@version으로 의존성을 의도적으로 추가하거나 업그레이드합니다.- 커밋 전에
go mod tidy를 실행합니다. - 여러 모듈을 동시에 개발할 때
replace대신go.work를 사용합니다. - 내부 저장소에 대해
GOPRIVATE를 구성합니다.