본문으로 건너뛰기

go.mod와 모듈의 시작

이 챕터에서 다루는 것

6-1의 import 경로는 example.com/packages/greet였다. 이 example.com/packages라는 접두사가 어디서 오는지, 그리고 그것을 정하는 go.mod 파일이 정확히 무엇을 하는지가 이 챕터다. 지시자 하나하나, go.sum이 보장하는 것과 보장하지 않는 것, 프록시와 체크섬 데이터베이스까지 본다.

문제 — import 경로는 전역이다

Go의 import 경로에는 중앙 레지스트리가 없다. npm처럼 express라는 짧은 이름을 누가 선점하는 구조가 아니다. 대신 경로 자체가 소스의 위치다.

import "github.com/gin-gonic/gin"

이것은 "gin이라는 이름의 패키지"가 아니라 "github.com/gin-gonic/gin에서 받아온 코드"다. 이름 충돌이 원천적으로 없고, 별도의 레지스트리 서버도 필요 없다. 대신 모든 모듈이 자기 경로를 스스로 선언해야 한다. 그 선언이 go.mod의 첫 줄이다.

go mod init

mkdir myapp && cd myapp
go mod init github.com/사용자이름/myapp
go: creating new go.mod: module github.com/사용자이름/myapp

디렉터리에 이미 .go 파일이 있으면 한 줄이 더 붙는다.

go: creating new go.mod: module example.com/igtest
go: to add module requirements and sums:
go mod tidy

모듈 경로를 무엇으로 할 것인가

상황경로
공개할 예정실제 저장소 URL. github.com/user/repo
사내 저장소그 저장소 URL. git.company.com/team/svc
공개 안 함, 순수 학습example.com/무엇 또는 아무 이름

핵심은 "남이 go get으로 받을 수 있는가"다. 받게 할 생각이면 경로가 실제 저장소를 가리켜야 한다. Go 도구는 github.com/user/repo를 만나면 그 URL에 HTTPS로 붙어 소스를 가져온다. 그게 전부다.

경로를 나중에 바꾸는 것은 가능하지만 남의 코드를 깨뜨린다. 처음부터 진짜 저장소 경로로 시작하는 편이 낫다. 아직 저장소가 없다면 만들 예정인 이름을 쓴다.

:::info 왜 example.com인가 example.com, example.org, example.net은 RFC 2606이 문서용으로 예약한 도메인이다. 절대 실제 코드가 올라올 일이 없으므로, 이 강의처럼 받아 갈 필요가 없는 예제 모듈에 안전하다. 이 파트의 모든 예제가 이 접두사를 쓴다. :::

go.mod의 지시자

go mod edit이 다룰 수 있는 지시자가 곧 go.mod가 가질 수 있는 전부다.

지시자하는 일다루는 곳
module이 모듈의 경로이 챕터
go언어 버전 + 최소 툴체인 요구이 챕터
toolchain사용할 툴체인 버전이 챕터
require의존 모듈과 최소 버전6-3
exclude특정 버전을 선택에서 배제6-4
replace모듈을 다른 것으로 바꿔치기6-4
retract내가 배포한 버전을 회수6-4
ignore디렉터리를 패키지 패턴에서 제외이 챕터
tool이 모듈이 쓰는 도구 선언6-3
godebugGODEBUG 기본값 고정아래 참고

godebug는 Go가 하위 호환을 깨는 동작 변경을 넣을 때 옛 동작을 켜 두는 스위치다. 예를 들어 Go 1.26이 net/url.Parse에서 막기 시작한 맨 콜론 호스트를 다시 허용하는 urlstrictcolons=0 같은 값을 모듈 전체에 고정한다 (GODEBUG 이력). 실무에서 손댈 일은 드물다.

go.mod는 손으로 고쳐도 된다. go mod edit은 도구와 스크립트를 위한 것이고, 사람은 그냥 에디터로 연다. 다만 require를 직접 만지는 것보다는 go get이 낫다 — 의존 모듈의 요구까지 함께 맞춰 주기 때문이다.

go 지시자 — 두 가지 일을 한다

가장 오해가 많은 줄이다. go 1.26.5는 이 두 가지를 동시에 뜻한다.

  1. 언어 버전. 이 모듈의 코드는 Go 1.26.5의 언어 규칙으로 컴파일된다. go 1.21이라고 적혀 있으면 Go 1.22의 루프 변수 변경이 적용되지 않는다.
  2. 최소 툴체인 요구. 이보다 낮은 툴체인은 이 모듈을 빌드하기를 거부한다.

두 번째를 확인해 보자. go.mod에 존재하지 않는 버전을 적고 툴체인 자동 전환을 끈다.

go mod edit -go=1.99.0
GOTOOLCHAIN=local go build .
go: go.mod requires go >= 1.99.0 (running go 1.26.5; GOTOOLCHAIN=local)

왜 언어 버전이 모듈에 박히는가

Go 1.21부터 이 규칙이 생겼다. 모듈마다 다른 언어 버전으로 컴파일된다. go 1.21인 라이브러리와 go 1.26.5인 내 프로그램이 한 빌드 안에 공존한다.

이것이 Go의 하위 호환 전략이다. 언어에 비호환 변경을 넣어야 할 때, 옛 모듈은 옛 규칙으로 계속 컴파일된다. 대표적인 예가 Go 1.22의 루프 변수 변경이다. go 1.21 이하로 선언한 모듈에서는 여전히 루프 변수가 반복마다 공유된다.

그래서 go 지시자를 올리는 것은 의미 있는 행위다. 아무 생각 없이 올리면 동작이 바뀔 수 있고, 반대로 계속 낮게 두면 새 문법을 못 쓴다.

go get go@1.25.0
go: downgraded go 1.26.5 => 1.25.0

go mod edit -go=1.25.0도 같은 결과를 내지만, go get go@...가 공식 권장 방식이다 (go help mod edit이 그렇게 적어 두었다).

:::note Go 1.26이 go 지시자를 한 단계 낮게 쓰려던 이야기 Go 1.26 릴리스 노트에는 go mod init이 현재 툴체인보다 한 단계 낮은 버전 (go 1.25.0)을 쓴다고 적혀 있다. 갓 나온 Go로 만든 모듈을 조금 이전 버전 사용자도 빌드할 수 있게 하자는 의도였다.

이 변경은 커뮤니티 반발 후 되돌려졌다 (golang/go#77653). 이 기계의 go1.26.5에서 go mod init을 실행하면 go 1.26.5가 쓰인다. 릴리스 노트 쪽이 낡았다.

교훈은 값을 외우지 말라는 것이다. cat go.mod로 직접 확인하고, 바꾸고 싶으면 go get go@1.25.0을 쓴다. :::

toolchain 지시자와 GOTOOLCHAIN

go 지시자가 "최소 요구"라면, toolchain은 "이 버전으로 빌드하라"는 지정이다.

module example.com/tcdemo

go 1.26.5

toolchain go1.26.5

로컬 툴체인이 요구를 만족하지 못하면 GOTOOLCHAIN 설정에 따라 갈린다.

go env GOTOOLCHAIN
auto
GOTOOLCHAIN동작
auto (기본)필요하면 맞는 툴체인을 자동으로 내려받아 그걸로 실행한다
local절대 전환하지 않는다. 요구를 못 맞추면 에러
go1.25.0항상 그 버전으로 실행한다
pathPATH에서 찾은 것으로 전환한다. 다운로드는 안 한다

auto가 기본이라, 팀원 중 누가 옛 Go를 깔고 있어도 프로젝트가 그냥 빌드된다. Go 도구가 필요한 툴체인을 알아서 받아 온다. Node의 .nvmrc나 Python의 pyenv가 하던 일을 언어 자체가 한다.

전환 시도는 빌드뿐 아니라 거의 모든 go 명령에서 일어난다. 위 실험에서 go.mod가 go 1.99.0인 채로 go mod edit을 부르자 이렇게 나왔다.

go: downloading go1.99.0 (darwin/arm64)
go: download go1.99.0 for darwin/arm64: toolchain not available

존재하지 않는 버전을 적어 두면 go.mod를 고치는 명령조차 안 돈다. 이때 GOTOOLCHAIN=local을 앞에 붙여 탈출한다.

toolchain 줄이 go 줄과 같은 값이면 의미가 없다. go mod tidy가 알아서 지운다. 실제로 위 실험에서 toolchain go1.26.5를 넣고 go mod tidy를 돌리자 사라졌다.

go.sum이 보장하는 것

의존성을 하나 추가해 보자.

mkdir sumdemo && cd sumdemo
go mod init example.com/sumdemo
# main.go에서 rsc.io/quote를 import한 뒤
go mod tidy
go: finding module for package rsc.io/quote
go: found rsc.io/quote in rsc.io/quote v1.5.2
go.mod
module example.com/sumdemo

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.3.0 // indirect
)
go.sum
golang.org/x/text v0.0.0-20170915032832-14c0d48ead0c h1:qgOY6WgZOaTkIIMiVjBQcw93ERBE4m30iBm00nkL0i8=
golang.org/x/text v0.0.0-20170915032832-14c0d48ead0c/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
rsc.io/quote v1.5.2 h1:w5fcysjrx7yqtD/aO+QwRjYZOKnaM9Uh2b40tElTs3Y=
rsc.io/quote v1.5.2/go.mod h1:LzX7hefJvL54yjefDEDHNONDjII0t9xZLPXsUe+TKr0=
rsc.io/sampler v1.3.0 h1:7uVkIFmeBqHfdjD+gZwtXXI+RODJ2Wc4O7MPEh/QiW4=
rsc.io/sampler v1.3.0/go.mod h1:T1hPZKmBbMNahiBKFy5HrXp6adAjACjK9JXDnKaTXpA=

모듈마다 줄이 두 개다.

  • 모듈 버전 h1:...소스 트리 전체의 해시
  • 모듈 버전/go.mod h1:... — 그 모듈의 go.mod 파일만의 해시

go.mod 해시가 따로 있는 이유는, 버전 선택(6-3의 MVS)을 하려면 실제로 쓰지 않는 모듈의 go.mod까지 읽어야 하기 때문이다. 소스는 안 받고 go.mod만 받는 경우가 흔하다.

보장하는 것과 보장하지 않는 것

go.sum이 보장하는 것: 내가 받은 rsc.io/quote v1.5.2의 바이트가, 다른 누가 어디서 받은 것과 똑같다. 태그가 몰래 옮겨졌거나 프록시가 내용을 바꿨으면 빌드가 실패한다.

보장하지 않는 것: 그 코드가 안전한지, 악의가 없는지. go.sum은 무결성 검증이지 보안 감사가 아니다. 악성 코드가 v1.5.2로 태깅됐다면 그 악성 코드의 해시가 충실히 기록될 뿐이다.

:::warning go.sum은 반드시 커밋한다 package-lock.json이나 Cargo.lock과 비슷하지만 역할은 다르다. 버전을 고정하는 것은 go.mod이고, go.sum은 무결성 검증용이다. .gitignore에 넣으면 재현 가능한 빌드도, 공급망 검증도 사라진다. :::

GOPROXY, GOSUMDB, GOPRIVATE

go env GOPROXY GOSUMDB GOPRIVATE
https://proxy.golang.org,direct
sum.golang.org

(마지막 빈 줄이 GOPRIVATE다 — 기본값이 비어 있다.)

GOPROXY — 모듈을 어디서 받을지. 기본값 https://proxy.golang.org,direct는 "먼저 공개 프록시에 물어보고, 없으면 저장소에 직접 붙어라"는 뜻이다. 프록시가 있어 GitHub이 죽어도 빌드가 살고, 저장소에서 태그가 지워져도 이미 캐시된 버전은 계속 받을 수 있다. GOPROXY=off면 모듈 캐시에 있는 것만 쓴다.

GOSUMDB — 체크섬 데이터베이스. sum.golang.org는 전 세계가 관측한 모듈 해시를 담은 추가만 가능한(append-only) 투명성 로그다. 처음 보는 모듈을 받을 때 여기에 물어봐서 해시를 대조한 뒤 go.sum에 적는다. 그래서 go.sum에 없는 모듈을 처음 받을 때도 검증이 된다.

GOPRIVATE — 사내 모듈처럼 공개 프록시와 체크섬 DB에 보내면 안 되는 경로. 쉼표로 구분된 glob 패턴이다.

go env -w GOPRIVATE='git.company.com/*,github.com/mycorp/*'

이 한 줄이 GONOPROXYGONOSUMDB를 동시에 설정한다. 모듈 경로 자체가 정보 누출이라는 점이 중요하다 — 설정하지 않으면 사내 저장소 이름이 공개 프록시 로그에 남는다. 사내 저장소를 쓰기 시작하는 첫날 해야 하는 설정이다.

ignore 지시자

Go 1.25에서 추가됐다. 특정 디렉터리를 ./... 같은 패키지 패턴에서 제외한다.

examples/06-modules-and-layout/02-go-mod-basics/go.mod
module example.com/modtour

go 1.26.5

// scratch 디렉터리는 ./... 같은 패키지 패턴에서 제외된다.
ignore scratch
examples/06-modules-and-layout/02-go-mod-basics/scratch/experiment.go
// Package scratch는 굴러다니는 실험 코드다.
// go.mod의 ignore 지시자에 걸려 있어서 ./... 패턴에 잡히지 않는다.
package scratch

// TODO: 이 아이디어가 쓸 만해지면 정식 패키지로 옮긴다.
func Idea(n int) int { return n*n + 1 }
go list ./...
example.com/modtour

ignore 줄을 주석 처리하면 이렇게 바뀐다.

example.com/modtour
example.com/modtour/scratch

원래 Go에는 이런 디렉터리가 세 종류 있었다. 이름이 _.로 시작하는 디렉터리, 그리고 testdata. 전부 이름으로만 제외되므로 이름을 마음대로 못 지었다. ignore는 그 제약을 없앤다.

쓸모 있는 곳은 이런 자리다.

  • 아직 컴파일 안 되는 실험 코드
  • 다른 언어의 소스나 생성 전 템플릿이 .go 확장자로 들어 있는 디렉터리
  • 문서용 예제 스니펫

컴파일되지 않는 파일을 넣어 두면 차이가 극적이다. 임시 모듈에서 확인한 결과다.

# example.com/igtest/scratch
scratch/broken.go:3:1: syntax error: non-declaration statement outside function body

ignore scratch를 넣으면 go build ./...가 그냥 통과한다.

go.mod는 바이너리 안에도 들어간다

runtime/debug.ReadBuildInfo가 그것을 꺼내 준다.

examples/06-modules-and-layout/02-go-mod-basics/main.go
// modtour는 go.mod의 내용이 빌드된 바이너리 안에 어떻게 남는지 보여 준다.
package main

import (
"fmt"
"runtime/debug"
)

func main() {
info, ok := debug.ReadBuildInfo()
if !ok {
fmt.Println("빌드 정보를 읽을 수 없다 (go build로 만든 바이너리가 아니다)")
return
}

fmt.Println("모듈 경로:", info.Main.Path)
fmt.Println("go 지시자:", info.GoVersion)
fmt.Println("의존 모듈 수:", len(info.Deps))

// Settings에는 빌드에 쓰인 플래그와 VCS 정보가 들어 있다.
// 값은 실행 환경마다 다르므로 몇 개만 골라서 본다.
want := map[string]bool{"GOARCH": true, "GOOS": true, "-compiler": true}
for _, s := range info.Settings {
if want[s.Key] {
fmt.Printf(" %-10s %s\n", s.Key, s.Value)
}
}
}
cd examples/06-modules-and-layout/02-go-mod-basics
go run .
모듈 경로: example.com/modtour
go 지시자: go1.26.5
의존 모듈 수: 0
-compiler gc
GOARCH arm64
GOOS darwin

GOARCHGOOS는 당연히 기계마다 다르다. go build로 만든 바이너리라면 vcs.revision, vcs.time, vcs.modified도 들어간다 — git 커밋 해시가 바이너리에 자동으로 박힌다는 뜻이다. go version -m ./바이너리로 밖에서도 볼 수 있다.

흔히 하는 실수

1. go.sum.gitignore에 넣는다

무결성 검증이 통째로 사라진다. 커밋한다.

2. go 지시자를 최신으로 올리는 것이 무해하다고 생각한다

언어 버전이 함께 바뀐다. go 1.21go 1.22는 루프 변수의 의미를 바꾼다. 올릴 때는 테스트를 돌린다.

3. go mod initmyapp 같은 이름으로 한다

혼자 쓸 때는 문제없지만, 나중에 남이 import할 수 있게 하려면 경로 전체를 바꿔야 하고 그러면 이미 쓰던 사람이 깨진다. 처음부터 저장소 경로로 시작한다.

4. 사내 모듈을 GOPRIVATE 없이 쓴다

공개 프록시가 사내 저장소를 뒤지려 하고(실패하고), 경로 이름이 로그에 남는다.

5. vendor/go.sum을 손으로 고친다

go.sumgo mod tidy가 관리한다. 해시가 안 맞으면 이유를 찾아야지 지워서는 안 된다. 정말 필요하면 go clean -modcache 후 다시 받는다.

정리

  • 모듈 경로는 곧 소스의 위치다. 중앙 레지스트리가 없는 대신 경로가 전역 식별자 역할을 한다. 공개할 거면 실제 저장소 URL로 시작한다.
  • go.mod의 지시자는 module / go / toolchain / require / exclude / replace / retract / ignore / tool / godebug가 전부다.
  • go 지시자는 언어 버전과 최소 툴체인 요구를 동시에 뜻한다. 이것이 Go의 하위 호환 전략이고, 올릴 때는 동작 변화를 확인해야 한다.
  • GOTOOLCHAIN=auto가 기본이라 필요한 툴체인을 자동으로 받아 쓴다. 막으려면 GOTOOLCHAIN=local.
  • go.sum은 무결성이지 보안이 아니다. 받은 바이트가 남과 같다는 것만 보장한다. 반드시 커밋한다.
  • GOPROXY는 어디서 받을지, GOSUMDB는 해시를 어디에 대조할지, GOPRIVATE는 둘 다 건너뛸 경로다. 사내 저장소를 쓰면 첫날 설정한다.
  • ignore 지시자로 이름과 무관하게 디렉터리를 패키지 패턴에서 뺀다.
  • go.mod의 내용과 VCS 정보는 바이너리 안에 남는다. debug.ReadBuildInfogo version -m으로 읽는다.

연습문제

  1. 빈 디렉터리에 go mod init example.com/probe를 하고 cat go.modgo 줄을 확인한 뒤, go get go@1.21.0으로 낮춰 보자. 그다음 for i := range 10 {} (Go 1.22의 range over int)를 쓴 파일을 만들고 빌드하면 어떤 메시지가 나오는가? 이 실험이 "go 지시자는 언어 버전"이라는 말을 어떻게 증명하는가?

  2. go.sumrsc.io/quote v1.5.2 h1:... 줄에서 해시 문자 하나를 바꾸고 go build를 해 보자. 어떤 에러가 나는가? 그 줄을 통째로 지우면 어떻게 되는가? 두 결과가 다른 이유를 설명해 보자.

  3. ignore 지시자를 testdata처럼 이미 자동 제외되는 이름에 걸면 어떻게 되는가? 그리고 ignore된 디렉터리 안의 패키지를 다른 패키지가 명시적으로 import하면 빌드는 되는가? 되기 전에 먼저 답을 예측하고 확인해 보자.