본문으로 건너뛰기

벤더링과 워크스페이스

이 챕터에서 다루는 것

서로 다른 두 문제를 푸는 두 도구다.

  • 벤더링 — 의존성 소스를 저장소 안으로 복사해 넣는다. 네트워크와 프록시에서 독립한다.
  • 워크스페이스 — 아직 배포되지 않은 여러 모듈을 로컬에서 함께 개발한다. 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
vendor/modules.txt
# 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 tidygo mod vendor를 다시 돌리는 것을 잊으면 빌드가 어긋난다.
  • 위에서 본 대로 모듈 도구 절반이 안 돈다.

:::tip 벤더링한다면 CI에서 검증한다 go mod vendorgit 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
examples/06-modules-and-layout/07-vendor-and-workspaces/greeter/greeter.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)) + "!!!"
}
examples/06-modules-and-layout/07-vendor-and-workspaces/app/main.go
// 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가 한 줄도 없다.

examples/06-modules-and-layout/07-vendor-and-workspaces/app/go.mod
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.work
go 1.26.5

use (
./app
./greeter
)

이제 그냥 된다.

go run ./app
안녕하세요, 고퍼님
안녕하세요, 익명님
안녕하세요, 고퍼님!!!

go.workuse에 나열된 모듈은 전부 "메인 모듈"이 된다. 그중 하나가 다른 하나의 패키지를 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를 쓰는 이유

워크스페이스가 나오기 전에는 이렇게 했다.

app/go.mod
require example.com/wsgreeter v0.1.0

replace example.com/wsgreeter => ../greeter

동작은 같다. 문제는 그것이 커밋되는 파일이라는 점이다.

로컬 replacego.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.modrequire를 넣는 것을 잊기 쉽다. 워크스페이스가 다 해결해 주기 때문이다. 그 상태로 배포하면 사용자는 빌드할 수 없다.

배포 전에 반드시 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 tidygo 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에 적힌 모듈이 전부 메인 모듈이 되고, 서로를 옆 디렉터리 소스로 참조한다.
  • 로컬 개발용 replacego.work로 대체한다. replace는 포크·강제 치환에만 남긴다.
  • go.work는 기본적으로 커밋하지 않는다. 모노레포는 예외다. 배포 전에 GOWORK=off로 반드시 검증한다.
  • 모듈은 배포 단위, 패키지는 구획이다. 패키지를 나누려고 모듈을 만들지 않는다.

연습문제

  1. app/go.modrequire example.com/wsgreeter v0.0.0을 손으로 넣고 go work sync를 돌려 보자. 어떤 에러가 나는가? 그 이유를 "워크스페이스가 go.mod에 쓸 버전을 어디서 알아내는가"로 설명해 보자.

  2. greeterShout을 하나 더 추가하고 app에서 즉시 써 보자. 다시 빌드하기 전에 go get이나 태그가 필요한가? 그다음 GOWORK=off로 빌드하면 어떻게 되는가? 이 두 결과의 차이가 워크스페이스의 존재 이유다.

  3. 03-dependency-management 모듈을 복사해서 go mod vendor를 돌린 뒤, vendor/rsc.io/quote/quote.go의 문자열을 고치고 go run .을 해 보자. 반영되는가? go mod verify는 뭐라고 하는가? 이것이 벤더링의 어떤 위험을 보여 주는지 생각해 보자.