벤더링과 워크스페이스
이 챕터에서 다루는 것
서로 다른 두 문제를 푸는 두 도구다.
- 벤더링 — 의존성 소스를 저장소 안으로 복사해 넣는다. 네트워크와 프록시에서 독립한다.
- 워크스페이스 — 아직 배포되지 않은 여러 모듈을 로컬에서 함께 개발한다.
6-4에서 "커밋하면 안 된다"고 한 로컬
replace의 대체재다.
벤더링 — 의존성을 저장소에 넣는다
cd examples/06-modules-and-layout/03-dependency-management
go mod vendor
vendor/ 디렉터리가 생긴다.
vendor
vendor/golang.org
vendor/golang.org/x
vendor/golang.org/x/text
vendor/rsc.io
vendor/rsc.io/quote
vendor/rsc.io/sampler
# golang.org/x/text v0.40.0
## explicit; go 1.25.0
golang.org/x/text/internal/language
golang.org/x/text/internal/language/compact
golang.org/x/text/internal/tag
golang.org/x/text/language
# rsc.io/quote v1.5.2
## explicit
rsc.io/quote
# rsc.io/sampler v1.3.0
## explicit
rsc.io/sampler
modules.txt를 읽는 법.
# 모듈 버전— 어떤 모듈의 어떤 버전인지## explicit— 메인 모듈의go.mod에 직접 적혀 있다는 표시- 그 아래 줄들 — 실제로 복사된 패키지 목록
golang.org/x/text가 통째로 들어오지 않았다. 빌드에 실제로 필요한 네 패키지만
복사된다. 그래서 벤더 디렉터리는 생각보다 작다.
vendor/가 있으면 자동으로 쓴다
vendor/가 존재하고 go 지시자가 1.14 이상이면, go build는 아무 플래그 없이도
-mod=vendor로 동작한다. 모듈 캐시도 네트워크도 보지 않는다.
그래서 이런 일이 벌어진다.
go list -m all
go: can't compute 'all' using the vendor directory
(Use -mod=mod or -mod=readonly to bypass.)
벤더 모드에서는 모듈 그래프가 없다. vendor/에는 복사된 패키지만 있고 각
모듈의 go.mod가 없기 때문이다. go mod graph, go list -m -u all, go mod why가
전부 이 상태에서는 반쪽이 된다.
언제 벤더링하는가
| 상황 | 벤더링이 답인가 |
|---|---|
| 인터넷이 없는 빌드 환경 (에어갭) | 그렇다 |
| 규제상 모든 의존 소스를 저장소에 보관해야 함 | 그렇다 |
| 의존성 코드를 코드 리뷰에 포함시켜야 함 | 그렇다 |
| CI 빌드가 느려서 | 아니다 — 모듈 캐시를 캐싱한다 |
| 의존성이 사라질까 봐 | 아니다 — proxy.golang.org가 영구 보관한다 |
| 재현 가능한 빌드 | 아니다 — go.mod + go.sum이 이미 보장한다 |
기본값은 벤더링하지 않는 것이다. 대가가 명확하다.
- 저장소가 커진다. 의존성 몇 개면 수만 줄이 들어온다.
- 모든 의존성 변경이 거대한 diff가 된다. 리뷰가 무의미해진다.
go mod tidy후go mod vendor를 다시 돌리는 것을 잊으면 빌드가 어긋난다.- 위에서 본 대로 모듈 도구 절반이 안 돈다.
:::tip 벤더링한다면 CI에서 검증한다
go mod vendor 후 git diff --exit-code vendor/로 벤더 디렉터리가 go.mod와
동기화돼 있는지 확인한다. go mod verify는 모듈 캐시의 내용이 go.sum과 맞는지
검사한다.
:::
워크스페이스 — 여러 모듈 동시 개발
이번 예제는 모듈이 둘이다.
07-vendor-and-workspaces/
├── go.work
├── greeter/ module example.com/wsgreeter (아직 배포 안 됨)
│ ├── go.mod
│ └── greeter.go
└── app/ module example.com/wsapp
├── go.mod
└── main.go
// Package wsgreeter는 워크스페이스 예제의 라이브러리 모듈이다.
// 아직 어디에도 배포되지 않았고 태그도 없다.
package wsgreeter
import "strings"
// Greet은 인사말을 만든다.
func Greet(name string) string {
name = strings.TrimSpace(name)
if name == "" {
name = "익명"
}
return "안녕하세요, " + name + "님"
}
// Shout은 인사말을 크게 외친다. app이 배포된 버전에는 없는 새 함수라고 가정한다.
func Shout(name string) string {
return strings.ToUpper(Greet(name)) + "!!!"
}
// app은 아직 배포되지 않은 wsgreeter 모듈을 쓴다.
// go.work가 없으면 빌드되지 않는다.
package main
import (
"fmt"
"example.com/wsgreeter"
)
func main() {
fmt.Println(wsgreeter.Greet("고퍼"))
fmt.Println(wsgreeter.Greet(""))
fmt.Println(wsgreeter.Shout("고퍼"))
}
app/go.mod에는 require가 한 줄도 없다.
module example.com/wsapp
go 1.26.5
이 상태로 app 안에서 워크스페이스를 끄고 빌드하면 당연히 실패한다.
cd app
GOWORK=off go build .
main.go:8:2: no required module provides package example.com/wsgreeter; to add it:
go get example.com/wsgreeter
시키는 대로 해도 안 된다. 존재하지 않는 모듈이기 때문이다.
GOWORK=off go mod tidy
go: finding module for package example.com/wsgreeter
go: example.com/wsapp imports
example.com/wsgreeter: cannot find module providing package example.com/wsgreeter: unrecognized import path "example.com/wsgreeter": reading https://example.com/wsgreeter?go-get=1: 404 Not Found
go work init
cd examples/06-modules-and-layout/07-vendor-and-workspaces
go work init ./greeter ./app
go 1.26.5
use (
./app
./greeter
)
이제 그냥 된다.
go run ./app
안녕하세요, 고퍼님
안녕하세요, 익명님
안녕하세요, 고퍼님!!!
go.work의 use에 나열된 모듈은 전부 "메인 모듈"이 된다. 그중 하나가 다른
하나의 패키지를 import하면, 프록시에 물어보지 않고 옆 디렉터리의 소스를 그대로
쓴다. greeter.go를 고치면 다음 go run에 바로 반영된다.
go list -m all
example.com/wsapp
example.com/wsgreeter
go work 하위 명령
| 명령 | 하는 일 |
|---|---|
go work init [디렉터리…] | go.work 생성 |
go work use ./새모듈 | 모듈 추가 |
go work use -r . | 하위 디렉터리를 재귀적으로 찾아 전부 추가 |
go work edit -dropuse ./모듈 | 모듈 제거 |
go work sync | 워크스페이스가 고른 버전을 각 모듈의 go.mod에 써넣는다 |
go work vendor | 워크스페이스 전체를 하나의 vendor/로 |
go.work에는 replace 지시자도 쓸 수 있다. 워크스페이스의 replace가 개별
모듈의 replace보다 우선한다.
워크스페이스를 끄기
cd app
GOWORK=off go build ./...
go.work가 실제로 필요한지, 배포 후에도 빌드되는지 확인할 때 쓴다. 워크스페이스
루트는 그 자체로 모듈이 아니므로 모듈 디렉터리 안에서 실행해야 한다.
go env GOWORK로 지금 어떤 go.work가 적용 중인지 볼 수 있다.
go env GOWORK
/Users/sgn04088/golang-lecture/examples/06-modules-and-layout/07-vendor-and-workspaces/go.work
(경로는 당연히 기계마다 다르다.) go.work는 현재 디렉터리에서 위로 올라가며
찾는다. go.mod와 같은 방식이다.
replace 대신 go.work를 쓰는 이유
워크스페이스가 나오기 전에는 이렇게 했다.
require example.com/wsgreeter v0.1.0
replace example.com/wsgreeter => ../greeter
동작은 같다. 문제는 그것이 커밋되는 파일이라는 점이다.
로컬 replace | go.work | |
|---|---|---|
| 어디에 적히나 | go.mod — 커밋 대상 | go.work — 보통 커밋 안 함 |
| 실수로 배포하면 | 사용자가 ../greeter를 못 찾아 실패 | 애초에 배포되지 않음 |
| 경로 | 내 기계의 디렉터리 구조에 종속 | 마찬가지지만 개인 파일 |
| 모듈 3개 이상 | replace를 모든 조합에 추가 | use 한 줄씩 |
| 끄기 | go.mod를 편집 | GOWORK=off |
로컬 개발용 replace는 이제 쓸 이유가 없다. replace는 포크를 쓰거나 특정
버전을 강제로 바꿔치기할 때만 남는다.
go.work를 커밋할까
기본은 커밋하지 않는다. .gitignore에 넣는다.
use경로가 개발자의 디렉터리 구조에 묶여 있다.- 커밋하면 CI도 워크스페이스 모드로 빌드하게 되는데, 그러면 배포된 버전이 아니라 로컬 소스로 빌드된다. "내 기계에서는 되는데" 문제가 CI로 옮겨간다.
- 각 모듈이 독립적으로도 빌드되는지 검증할 기회를 잃는다.
예외는 하나의 저장소 안에 여러 모듈이 들어 있는 모노레포다. 이때는 디렉터리
구조가 저장소에 고정돼 있으므로 go.work를 커밋하는 것이 합리적이다. 이 강의의
예제 저장소도 그런 경우라 go.work를 함께 둔다.
:::warning go.work가 켜져 있으면 CI가 거짓말을 한다
로컬에서 go.work로 개발하다 보면 app/go.mod에 require를 넣는 것을 잊기
쉽다. 워크스페이스가 다 해결해 주기 때문이다. 그 상태로 배포하면 사용자는
빌드할 수 없다.
배포 전에 반드시 GOWORK=off go build ./...를 돌린다. 위에서 본
no required module provides package가 정확히 그 사고의 모습이다.
:::
언제 워크스페이스를 쓰는가
- 라이브러리와 그 라이브러리를 쓰는 앱을 동시에 고칠 때. 가장 흔한 경우다.
- 의존 라이브러리의 버그를 재현·수정할 때. 포크를 클론해서
go work use로 붙이면, 고치는 즉시 내 앱에 반영된다. - 모노레포에서 모듈이 여러 개일 때.
반대로, 모듈 하나짜리 프로젝트에는 필요 없다. 그리고 "패키지를 나누고 싶다"는 이유로 모듈을 나누면 안 된다. 모듈은 버전이 따로 붙는 배포 단위이고, 패키지는 그 안의 구획이다. 하나의 저장소에 모듈 하나가 기본이다.
흔히 하는 실수
1. 패키지를 나누려고 모듈을 나눈다
모듈은 배포 단위다. 한 저장소 = 한 모듈이 기본이고, 디렉터리를 나누면 패키지가 된다.
2. go.work를 커밋하고 CI에서 그대로 쓴다
CI가 로컬 소스로 빌드해 통과하고, 실제 배포는 깨진다. GOWORK=off를 CI에 넣는다.
3. go.work에 기대어 go.mod를 방치한다
배포 전에 각 모듈이 홀로 빌드되는지 확인한다.
4. CI를 빠르게 하려고 벤더링한다
모듈 캐시($GOMODCACHE)를 캐싱하는 편이 훨씬 낫다. 벤더링은 저장소를 부풀리고
diff를 망친다.
5. go mod tidy 후 go mod vendor를 잊는다
vendor/가 go.mod와 어긋나면 inconsistent vendoring 에러가 난다. CI에서
git diff --exit-code vendor/로 잡는다.
6. 벤더 모드에서 go list -m all이 안 된다고 당황한다
정상이다. -mod=mod를 붙이거나 vendor/를 잠시 치운다.
정리
go mod vendor는 빌드에 필요한 패키지만vendor/로 복사한다.vendor/modules.txt가 무엇이 왜 들어왔는지 기록한다.vendor/가 있으면 아무 플래그 없이 벤더 모드로 빌드되고, 그 상태에서는 모듈 그래프 관련 명령이 동작하지 않는다.- 벤더링의 정당한 이유는 에어갭·규제·의존성 코드 리뷰뿐이다. 속도나 재현성은 이유가 안 된다.
go.work는 여러 모듈을 로컬에서 함께 개발하는 장치다.use에 적힌 모듈이 전부 메인 모듈이 되고, 서로를 옆 디렉터리 소스로 참조한다.- 로컬 개발용
replace는go.work로 대체한다.replace는 포크·강제 치환에만 남긴다. go.work는 기본적으로 커밋하지 않는다. 모노레포는 예외다. 배포 전에GOWORK=off로 반드시 검증한다.- 모듈은 배포 단위, 패키지는 구획이다. 패키지를 나누려고 모듈을 만들지 않는다.
연습문제
-
app/go.mod에require example.com/wsgreeter v0.0.0을 손으로 넣고go work sync를 돌려 보자. 어떤 에러가 나는가? 그 이유를 "워크스페이스가 go.mod에 쓸 버전을 어디서 알아내는가"로 설명해 보자. -
greeter에Shout을 하나 더 추가하고app에서 즉시 써 보자. 다시 빌드하기 전에go get이나 태그가 필요한가? 그다음GOWORK=off로 빌드하면 어떻게 되는가? 이 두 결과의 차이가 워크스페이스의 존재 이유다. -
03-dependency-management모듈을 복사해서go mod vendor를 돌린 뒤,vendor/rsc.io/quote/quote.go의 문자열을 고치고go run .을 해 보자. 반영되는가?go mod verify는 뭐라고 하는가? 이것이 벤더링의 어떤 위험을 보여 주는지 생각해 보자.