본문으로 건너뛰기

의존성 추가·갱신·제거

이 챕터에서 다루는 것

6-2에서 go.mod의 구조를 봤다. 이제 그 require 목록을 어떻게 바꾸는가다. 추가·갱신·다운그레이드·제거, 그리고 그 뒤에서 도는 Go 특유의 버전 선택 알고리즘인 최소 버전 선택(MVS) 을 본다.

이 챕터의 모든 명령은 네트워크를 탄다. 출력에 나오는 버전 번호는 실행 시점에 따라 달라진다 — 아래 출력은 2026-08-11에 실제로 실행한 것이다.

문제 — "최신으로 올려"가 만드는 사고

다른 언어의 패키지 매니저 대부분은 최대 버전 선택을 한다. ^1.2.0이라고 적으면 설치 시점의 가장 높은 1.x를 가져온다. 그래서 어제 되던 빌드가 오늘 깨지고, 그것을 막으려고 락 파일이 필요하다.

Go는 반대를 택했다. go.mod에 적힌 버전은 "최소 요구"이고, 실제로 선택되는 것은 그 요구들 중 가장 높은 것이다. 새 버전이 나와도 아무 일도 일어나지 않는다. 누군가 명시적으로 올려야만 올라간다.

그래서 Go에는 락 파일이 없다. go.mod 자체가 락 파일이다.

예제 모듈

examples/06-modules-and-layout/03-dependency-management/main.go
// deps는 의존성이 어떻게 딸려 오는지 보여 준다.
// 직접 import하는 것은 rsc.io/quote 하나뿐이지만,
// go.mod에는 간접 의존이 두 개 더 적힌다.
package main

import (
"fmt"

"golang.org/x/text/language"
"rsc.io/quote"
)

func main() {
// 직접 의존: import했으므로 go.mod에 // indirect 표시 없이 들어간다.
fmt.Println("quote.Hello():", quote.Hello())
fmt.Println("quote.Glass():", quote.Glass())

// golang.org/x/text는 원래 quote가 끌고 온 간접 의존이었다.
// 여기서 직접 import하는 순간 // indirect 표시가 사라진다.
tags := []language.Tag{language.Korean, language.English, language.Japanese}
for _, t := range tags {
base, conf := t.Base()
fmt.Printf(" %-8s base=%s confidence=%v\n", t, base, conf)
}
}
go.mod
module example.com/deps

go 1.26.5

require (
golang.org/x/text v0.40.0
rsc.io/quote v1.5.2
)

require rsc.io/sampler v1.3.0 // indirect
cd examples/06-modules-and-layout/03-dependency-management
go run .
quote.Hello(): Ahoy, world!
quote.Glass(): I can eat glass and it doesn't hurt me.
ko base=ko confidence=Exact
en base=en confidence=Exact
ja base=ja confidence=Exact

rsc.io/quote는 Go 모듈 문서가 예제로 쓰는 아주 작고 더 이상 바뀌지 않는 모듈이다. 버전이 고정돼 있어 실습에 안전하다.

go get — 지금은 의존성만 만진다

옛날 Go에서 go get은 "받아서 빌드하고 설치하는" 만능 명령이었다. 지금은 아니다. go help get이 직접 그렇게 말한다.

In earlier versions of Go, 'go get' was used to build and install packages.
Now, 'go get' is dedicated to adjusting dependencies in go.mod. 'go install'
may be used to build and install commands instead.
하고 싶은 것명령
이 프로젝트에 의존성 추가/변경go get 경로@버전
도구 바이너리를 내 기계에 설치go install 경로@버전
이 프로젝트 전용 도구 등록go get -tool 경로@버전

go install 경로@버전현재 디렉터리의 go.mod를 아예 무시한다. 그래서 go install golang.org/x/tools/gopls@latest는 어느 디렉터리에서 실행하든 결과가 같다. 이 분리는 "도구를 설치했더니 내 프로젝트 의존성이 바뀌어 있더라"라는 옛 문제를 없애려고 만든 것이다.

버전 지정자

지정자
@v1.2.3정확히 그 버전
@latest가장 높은 정식 릴리스 태그 (프리릴리스 제외)
@upgrade현재보다 높으면 올리고, 아니면 그대로
@patch같은 마이너 안의 최신 패치
@none제거
@브랜치이름 / @커밋해시그 커밋. pseudo-version이 된다(6-4)

추가와 갱신

go get rsc.io/sampler@v1.3.1
go: downloading rsc.io/sampler v1.3.1
go: upgraded rsc.io/sampler v1.3.0 => v1.3.1

go get은 무엇이 어떻게 바뀌었는지 항상 한 줄로 알려 준다. 이 출력을 읽는 습관이 중요하다 — 하나를 올리면 다른 것이 따라 움직이는 일이 흔하다.

다운그레이드는 전파된다

여기가 Go 모듈에서 가장 놀라운 지점이다.

go get rsc.io/sampler@v1.2.0
go: downloading rsc.io/sampler v1.2.0
go: downloading rsc.io/quote v1.4.0
go: downgraded rsc.io/quote v1.5.2 => v1.4.0
go: downgraded rsc.io/sampler v1.3.1 => v1.2.0

sampler만 내렸는데 quote까지 내려갔다. 이유는 단순하다. quote v1.5.2go.modsampler v1.3.0 이상을 요구한다. sampler를 v1.2.0으로 고정하려면 그 요구를 하지 않는 quote 버전으로 내려가는 수밖에 없다.

Go는 요구 사항을 어기면서까지 내려 주지 않는다. 버전을 내리려면 그것을 요구하는 쪽도 함께 내려간다. 이것이 MVS의 다운그레이드 알고리즘이다.

최소 버전 선택 (MVS)

go mod graph가 그래프 전체를 낸다.

go mod graph
example.com/deps go@1.26.5
example.com/deps golang.org/x/text@v0.40.0
example.com/deps rsc.io/quote@v1.5.2
example.com/deps rsc.io/sampler@v1.3.0
go@1.26.5 toolchain@go1.26.5
golang.org/x/text@v0.40.0 golang.org/x/tools@v0.47.0
golang.org/x/text@v0.40.0 golang.org/x/mod@v0.37.0
golang.org/x/text@v0.40.0 golang.org/x/sync@v0.22.0
golang.org/x/text@v0.40.0 go@1.25.0
rsc.io/quote@v1.5.2 rsc.io/sampler@v1.3.0
rsc.io/sampler@v1.3.0 golang.org/x/text@v0.0.0-20170915032832-14c0d48ead0c

왼쪽 모듈 → 오른쪽 모듈을 요구함 형식이다. 마지막 줄을 보자. golang.org/x/text를 요구하는 곳이 둘이다.

  • 내 모듈이 v0.40.0을 요구한다
  • rsc.io/sampler v1.3.0v0.0.0-20170915032832-14c0d48ead0c를 요구한다
go list -m all
example.com/deps
golang.org/x/mod v0.37.0
golang.org/x/sync v0.22.0
golang.org/x/text v0.40.0
golang.org/x/tools v0.47.0
rsc.io/quote v1.5.2
rsc.io/sampler v1.3.0

선택된 것은 v0.40.0요구들 중 가장 높은 것이다.

MVS는 이 한 문장으로 끝난다.

각 모듈에 대해, 그 모듈을 요구하는 모든 곳이 적어 낸 버전 중 최댓값을 고른다.

"최소 버전 선택"이라는 이름은 각자가 최소한만 요구한다는 뜻이지 낮은 것을 고른다는 뜻이 아니다. 헷갈리기 쉬운 이름이다.

이 알고리즘의 성질이 중요하다.

  • 결정적이다. 같은 go.mod 집합이면 언제 어디서 돌려도 같은 답이 나온다. 락 파일이 필요 없는 이유다.
  • 최소한만 움직인다. 새 버전이 나와도 아무도 요구하지 않으면 선택되지 않는다.
  • SAT 솔버가 없다. 그래프를 한 번 훑으면 끝난다. npm이나 Cargo의 해결기가 지수 시간 최악을 갖는 것과 대비된다.

:::info golang.org/x/mod, x/sync, x/tools는 왜 목록에 있는가 go list -m all모듈 그래프에 들어 있는 것 전부를 낸다. 실제로 빌드에 쓰이는 패키지가 하나도 없어도 나온다. x/textgo.mod가 그것들을 요구하기 때문이다.

빌드에 실제로 필요한 것만 보려면 go list -deps ./...처럼 패키지 단위로 물어야 한다. go.sum에 안 쓰는 모듈의 go.mod 해시가 들어가는 것도 같은 이유다. :::

go mod tidy

가장 자주 쓰는 명령이다. 하는 일은 두 방향이다.

  1. 소스에서 import하는데 go.mod에 없는 것을 추가한다.
  2. go.mod에 있는데 아무도 import하지 않는 것을 뺀다.

예제에서 golang.org/x/text/language import를 지우고 tidy를 돌리면 이렇게 된다.

module example.com/deps

go 1.26.5

require rsc.io/quote v1.5.2

require (
golang.org/x/text v0.40.0 // indirect
rsc.io/sampler v1.3.0 // indirect
)

golang.org/x/text사라진 것이 아니라 // indirect로 옮겨졌다. 내 코드는 더 이상 안 쓰지만 sampler가 여전히 쓰기 때문이다.

// indirect의 정확한 뜻

"내 모듈의 어떤 패키지도 이 모듈의 패키지를 직접 import하지 않는다"이다. go mod tidy가 자동으로 붙이고 뗀다. 손으로 관리할 것이 아니다.

require 블록이 둘로 나뉜 것도 tidy가 한 일이다. 직접 의존과 간접 의존을 갈라 놓는다.

:::tip CI에서 tidy 확인하기 go mod tidy를 돌린 뒤 git diff --exit-code go.mod go.sum으로 변화가 있는지 본다. 변화가 있으면 누군가 tidy를 안 하고 커밋한 것이다. 이 두 줄이 의존성 드리프트를 막는 가장 값싼 장치다. :::

제거

go get rsc.io/quote@none
go: removed rsc.io/quote v1.4.0

하지만 소스에서 import를 안 지웠다면 다음 빌드에서 이렇게 끊긴다.

main.go:10:2: no required module provides package rsc.io/quote; to add it:
go get rsc.io/quote

그래서 실제 순서는 소스에서 import를 지우고 → go mod tidy 다. @none은 간접 의존을 억지로 떼어 낼 때나 쓴다.

업데이트 확인 — 그리고 -u의 위험

go list -m -u all
example.com/deps
golang.org/x/mod v0.37.0 [v0.39.0]
golang.org/x/sync v0.22.0
golang.org/x/text v0.40.0
golang.org/x/tools v0.47.0 [v0.48.0]
rsc.io/quote v1.5.2
rsc.io/sampler v1.3.0 [v1.99.99]

대괄호 안이 올릴 수 있는 버전이다. (이 목록은 실행 시점에 따라 달라진다.)

rsc.io/sampler[v1.99.99]가 눈에 띈다. 이제 -u로 전부 올려 보자.

go get -u ./...
go: upgraded rsc.io/sampler v1.3.0 => v1.99.99

go.mod가 바뀌었고, 빌드도 통과하고, 테스트도 없다. 그런데 실행하면.

quote.Hello(): 99 bottles of beer on the wall, 99 bottles of beer, ...
quote.Glass(): I can eat glass and it doesn't hurt me.
ko base=ko confidence=Exact
en base=en confidence=Exact
ja base=ja confidence=Exact

Ahoy, world!가 맥주 노래로 바뀌었다. rsc.io/sampler v1.99.99는 이 상황을 가르치려고 일부러 만들어 둔 버전이다. 컴파일은 되고 동작만 달라진다.

:::danger go get -u ./...를 습관적으로 치지 않는다 -u간접 의존까지 전부 마이너 버전을 올린다. 컴파일 에러는 눈에 보이지만 동작 변화는 안 보인다. 실무에서 쓸 만한 형태는 이 정도다.

  • go get -u=patch ./... — 패치 버전만 올린다. 훨씬 안전하다
  • go get 모듈경로@latest — 하나씩, 이유를 알고 올린다
  • 보안 패치는 govulncheck가 지목한 것만 올린다

그리고 무엇을 하든 올린 뒤에 테스트를 돌린다. 이 강의에 아직 테스트가 없다는 것이 지금 이 사고를 못 잡은 이유다. Part 8이 그 자리를 채운다. :::

go mod why — 왜 이게 들어와 있는가

go mod why -m golang.org/x/text
# golang.org/x/text
example.com/deps
golang.org/x/text/language

내 모듈이 golang.org/x/text/language를 직접 import해서다.

go mod why rsc.io/sampler
# rsc.io/sampler
example.com/deps
rsc.io/quote
rsc.io/sampler

내 모듈 → quote → sampler 경로로 딸려 왔다.

:::warning go mod why의 인자는 기본이 패키지 경로다 go mod why golang.org/x/text를 그냥 치면 이렇게 나온다.

# golang.org/x/text
(main module does not need package golang.org/x/text)

golang.org/x/text는 모듈 경로이지 패키지가 아니다(그 디렉터리에 .go 파일이 없다). 모듈을 묻고 싶으면 -m을 붙인다. 이 메시지를 "안 쓰는 의존성이네"로 읽고 지우려 들면 빌드가 깨진다. :::

버전 목록 보기

go list -m -versions rsc.io/sampler
rsc.io/sampler v1.0.0 v1.2.0 v1.2.1 v1.3.0 v1.3.1 v1.99.99

올리기 전에 무엇이 있는지 확인하는 용도다.

tool 지시자 — 프로젝트 전용 도구

예전에는 tools.go라는 파일에 블랭크 import를 몰아넣는 편법을 썼다. 지금은 go.mod가 직접 지원한다.

go get -tool golang.org/x/tools/cmd/stringer@v0.47.0
go: downloading golang.org/x/tools v0.47.0
go: added golang.org/x/mod v0.37.0
go: added golang.org/x/sync v0.21.0
go: added golang.org/x/tools v0.47.0
go.mod
module example.com/toolscratch

go 1.26.5

tool golang.org/x/tools/cmd/stringer

require (
golang.org/x/mod v0.37.0 // indirect
golang.org/x/sync v0.21.0 // indirect
golang.org/x/tools v0.47.0 // indirect
)
go tool
fix
link
preprofile
vet
golang.org/x/tools/cmd/stringer

(위는 목록의 마지막 다섯 줄이다.) 이제 go tool stringer로 실행할 수 있고, 버전이 go.mod에 고정되므로 팀원 전원이 같은 도구 버전을 쓴다. 코드 생성기, 목 생성기, 마이그레이션 도구를 여기에 넣는다.

go install과의 차이는 명확하다. go install내 기계 전역에 ($GOBIN) 설치하고, tool 지시자는 이 프로젝트에 묶인다.

흔히 하는 실수

1. go.modrequire를 손으로 고쳐서 버전을 올린다

의존 모듈이 요구하는 것과 충돌해도 알 수 없다. go get은 그래프를 다시 풀어 준다.

2. // indirect 주석을 지운다

go mod tidy가 다시 붙인다. 사람이 관리하는 표시가 아니다.

3. go mod tidy를 안 하고 커밋한다

go.mod에 쓰레기가 남거나, 반대로 CI에서 "missing go.sum entry"로 깨진다.

4. 다운그레이드가 왜 다른 것까지 내렸는지 보지 않는다

go get의 출력 줄을 전부 읽는다. go mod graph | grep 모듈이름으로 누가 요구하는지 확인한다.

5. go get -u ./... 후 커밋

위에서 본 그대로다. 최소한 -u=patch를 쓰고, 테스트를 돌린다.

6. go get으로 도구를 설치하려 한다

이제 go get은 바이너리를 설치하지 않는다. go install 경로@버전이다.

정리

  • go getgo.mod를 고치고, go install은 바이너리를 설치한다. go install 경로@버전은 현재 go.mod를 무시한다.
  • go.mod의 버전은 최소 요구이고, 실제 선택은 요구들의 최댓값이다. 이것이 최소 버전 선택(MVS)이고, Go에 락 파일이 없는 이유다.
  • 다운그레이드는 전파된다. 낮추려는 버전을 요구하는 상위 모듈도 함께 내려간다.
  • go mod tidy가 의존성 관리의 기본 동작이다. 없는 것을 넣고, 안 쓰는 것을 빼고, // indirect를 다시 계산한다.
  • @none은 제거, @latest는 최신 정식 태그, @patch는 같은 마이너의 최신 패치.
  • go get -u ./...는 위험하다. 컴파일이 되는 동작 변화는 잡히지 않는다. -u=patch를 쓰고 테스트를 돌린다.
  • go mod why -m 으로 왜 들어와 있는지, go mod graph 로 누가 요구하는지 본다. -m 없이 쓰면 패키지를 묻는 것이다.
  • tool 지시자로 프로젝트 전용 도구의 버전을 팀 전체에 고정한다.

연습문제

  1. 예제 모듈에서 go get rsc.io/quote@v1.3.0을 해 보자. sampler는 어떻게 되는가? 그리고 go build는 통과하는가? quote v1.3.0Glass()가 있는지 go doc rsc.io/quote로 확인해 보고, 의존성을 내릴 때 왜 컴파일 에러가 가장 반가운 결과인지 생각해 보자.

  2. go mod graph의 출력을 grep으로 걸러 golang.org/x/text를 요구하는 모든 모듈을 찾아보자. 그중 가장 높은 버전을 요구하는 곳은 어디인가? 내 go.mod에서 golang.org/x/text를 지우면 어떤 버전이 선택되는가 — 예측한 뒤 확인하자.

  3. go get -tool로 도구를 하나 더 등록하고, 그 도구가 끌고 온 의존성이 내 프로그램의 빌드에 영향을 주는지 확인해 보자. 힌트: go list -deps .의 결과와 go list -m all의 결과를 비교한다. 도구 의존성이 // indirect로 들어오는 것이 왜 문제가 될 수 있는가?