본문으로 건너뛰기

종합 실습 — 다중 모듈 워크스페이스

이 실습에서 만드는 것

Part 6에서 다룬 것을 하나로 합친다.

  • 라이브러리 모듈 example.com/notelib와 CLI 모듈 example.com/notecli
  • go.work로 로컬 연결
  • internal/로 라이브러리 내부 감추기
  • git 태그로 v1.0.0 배포
  • 호환을 깨면서 /v2로 전환하고, v1과 v2를 한 바이너리에서 동시에 쓰기
08-practice-workspace/
├── go.work
├── notelib/ module example.com/notelib
│ ├── go.mod
│ ├── note.go
│ ├── internal/format/
│ └── v2/ module example.com/notelib/v2
│ ├── go.mod
│ ├── note.go
│ └── internal/format/
└── notecli/ module example.com/notecli
├── go.mod
└── main.go

v2를 메이저 서브디렉터리 방식으로 둔 이유는, 한 트리 안에서 v1과 v2를 동시에 보여 주기 위해서다(6-4). 실제 프로젝트에서는 메이저 브랜치 방식이 더 흔하다.

1단계 — 라이브러리 모듈 (v1)

mkdir -p notelib/internal/format && cd notelib
go mod init example.com/notelib
examples/06-modules-and-layout/08-practice-workspace/notelib/note.go
// Package notelib은 메모를 담는다. v1 API다.
package notelib

import (
"strings"

"example.com/notelib/internal/format"
)

type Note struct {
ID int
Text string
}

type Book struct {
notes []Note
}

func New() *Book { return &Book{} }

// Add는 메모를 추가하고 결과를 돌려준다.
// v1에서는 빈 문자열도 그대로 받는다 — 이것이 v2에서 바뀐다.
func (b *Book) Add(text string) Note {
n := Note{ID: len(b.notes) + 1, Text: text}
b.notes = append(b.notes, n)
return n
}

// All은 모든 메모를 돌려준다. v2에서 이름이 바뀐다.
func (b *Book) All() []Note {
out := make([]Note, len(b.notes))
copy(out, b.notes)
return out
}

func (b *Book) Render() string {
var sb strings.Builder
for i, n := range b.notes {
if i > 0 {
sb.WriteByte('\n')
}
sb.WriteString(format.Line(n.ID, n.Text))
}
return sb.String()
}
examples/06-modules-and-layout/08-practice-workspace/notelib/internal/format/format.go
// Package format은 notelib 전용 출력 헬퍼다.
// example.com/notelib 모듈 밖에서는 import할 수 없다.
package format

import "fmt"

// Line은 노트 한 줄을 만든다.
func Line(id int, text string) string {
return fmt.Sprintf("%3d | %s", id, text)
}

출력 형식을 internal/에 넣은 것이 설계 결정이다. 나중에 형식을 바꿔도 사용자 코드가 안 깨진다. 만약 format.Line을 공개했다면 그것도 하위 호환 약속의 일부가 됐을 것이다(6-5).

2단계 — 태그를 달아 v1을 "배포"한다

git init -b main .
git add -A
git commit -m "notelib v1"
git tag v1.0.0
git tag -l
v1.0.0

이것으로 끝이다. Go 모듈 배포는 태그를 다는 것이 전부다. 업로드할 레지스트리도, publish 명령도 없다. 저장소를 공개하고 태그를 밀면 go get example.com/notelib@v1.0.0이 동작한다.

:::info 이 실습의 모듈 경로는 진짜 저장소가 아니다 example.com/notelib은 문서용 예약 도메인이라(6-2) 실제로 go get할 수 없다. 그래서 아래에서는 go.work로 로컬 연결한다. 진짜로 배포하려면 모듈 경로를 github.com/사용자/notelib 같은 실제 저장소 URL로 바꾸고 태그를 push하면 된다. :::

3단계 — CLI 모듈과 워크스페이스

cd .. && mkdir notecli && cd notecli
go mod init example.com/notecli
cd ..
go work init ./notelib ./notecli
go.work
go 1.26.5

use (
./notecli
./notelib
./notelib/v2
)

(위는 v2까지 추가한 최종 상태다. 3단계 시점에는 ./notelib./notecli 두 줄이다.)

notecli/go.mod에는 require가 없다. 워크스페이스가 옆 디렉터리에서 해결해 주기 때문이다(6-7).

4단계 — 호환을 깨는 변경이 필요해졌다

메모에 태그를 붙이고 싶고, 빈 본문은 거부하고 싶다. 그러면 이렇게 된다.

변경호환을 깨는가
NoteTags []string 필드 추가깬다 — 구조체 리터럴 Note{1, "x"}가 안 됨
Add(text)Add(text, tags...)가변 인자라 호출은 호환되지만…
Add(Note, error) 반환깬다 — 반환값 개수가 바뀜
All()Notes() 이름 변경깬다
ByTag 추가안 깬다

깨는 변경이 하나라도 있으면 메이저 버전을 올려야 한다.

:::tip 필드 추가도 호환을 깰 수 있다 Note{1, "x"}처럼 필드 이름 없는 구조체 리터럴을 쓰는 코드가 있으면 필드 추가가 컴파일 에러를 낸다. 그래서 공개 구조체에는 관례적으로 이름 있는 리터럴만 쓰도록 문서에 적거나, 아예 생성자만 노출한다. 표준 라이브러리가 종종 쓰는 방법은 구조체에 비공개 필드를 하나 넣어 두어 위치 리터럴 자체를 막는 것이다. :::

5단계 — v2로 전환

전환은 기계적이다. 실제 저장소에서 해 본 결과다.

go mod edit -module=example.com/notelib/v2
cat go.mod
module example.com/notelib/v2

go 1.26.5

여기서 반드시 해야 하는 두 번째 일이 있다. 자기 모듈 안의 import 경로를 전부 /v2로 바꾸는 것이다. 안 바꾸면 이렇게 된다.

note.go:7:2: no required module provides package example.com/notelib/internal/format; to add it:
go get example.com/notelib/internal/format

internal이라서 에러가 났지만, 공개 패키지였다면 에러 없이 v1의 코드를 끌어다 쓰는 더 나쁜 상태가 됐을 것이다. v2 모듈이 자기 자신의 v1을 의존성으로 갖게 된다.

고친 뒤 태그를 단다.

git add -A && git commit -m "v2: module path"
git tag v2.0.0
git tag -l
v1.0.0
v2.0.0

v2의 코드

examples/06-modules-and-layout/08-practice-workspace/notelib/v2/note.go
// Package notelib은 메모를 담는다. v2 API다.
// v1과 달리 태그를 지원하고, 잘못된 입력을 에러로 돌려준다.
package notelib

import (
"errors"
"fmt"
"slices"
"strings"

// v2 모듈 안의 internal 패키지도 경로에 /v2가 들어간다.
// 이것을 안 고치면 v1의 internal을 끌어다 쓰게 된다.
"example.com/notelib/v2/internal/format"
)

// ErrEmptyText는 센티널 에러다. 호출자가 errors.Is로 판별한다.
var ErrEmptyText = errors.New("notelib: 본문이 비어 있다")

type Note struct {
ID int
Text string
Tags []string // v2에서 추가됐다
}

type Book struct {
notes []Note
}

func New() *Book { return &Book{} }

// Add는 v1과 시그니처가 다르다. 가변 태그를 받고 에러를 돌려준다.
// 이 변경 하나만으로도 메이저 버전을 올려야 한다.
func (b *Book) Add(text string, tags ...string) (Note, error) {
if strings.TrimSpace(text) == "" {
return Note{}, fmt.Errorf("메모 추가: %w", ErrEmptyText)
}
n := Note{ID: len(b.notes) + 1, Text: text, Tags: slices.Clone(tags)}
b.notes = append(b.notes, n)
return n, nil
}

// Notes는 v1의 All을 대신한다. 이름 변경도 호환을 깨는 변경이다.
func (b *Book) Notes() []Note {
return slices.Clone(b.notes)
}

// ByTag는 v2에서 새로 생겼다.
func (b *Book) ByTag(tag string) []Note {
var out []Note
for _, n := range b.notes {
if slices.Contains(n.Tags, tag) {
out = append(out, n)
}
}
return out
}

func (b *Book) Render() string {
var sb strings.Builder
for i, n := range b.notes {
if i > 0 {
sb.WriteByte('\n')
}
sb.WriteString(format.Line(n.ID, n.Text, n.Tags))
}
return sb.String()
}

패키지 이름은 여전히 notelib이다. /v2는 모듈 경로에만 붙는다 (6-1에서 본 "경로와 패키지 이름은 다른 것"이 여기서 확인된다).

v2의 internal/format도 별개 패키지다.

examples/06-modules-and-layout/08-practice-workspace/notelib/v2/internal/format/format.go
// Package format은 notelib/v2 전용 출력 헬퍼다.
// v1의 같은 이름 패키지와는 완전히 별개다 — 경로가 다르기 때문이다.
package format

import (
"fmt"
"strings"
)

// Line은 태그까지 포함한 노트 한 줄을 만든다.
func Line(id int, text string, tags []string) string {
if len(tags) == 0 {
return fmt.Sprintf("%3d | %s", id, text)
}
return fmt.Sprintf("%3d | %s [%s]", id, text, strings.Join(tags, ", "))
}

6단계 — v1과 v2를 동시에 쓴다

go work use ./notelib/v2
examples/06-modules-and-layout/08-practice-workspace/notecli/main.go
// notecli는 notelib v1과 v2를 한 바이너리에서 같이 쓴다.
// 마이그레이션 중간 상태를 재현한 것이다.
package main

import (
"errors"
"fmt"

notev1 "example.com/notelib"
notev2 "example.com/notelib/v2"
)

func main() {
fmt.Println("== v1 ==")
old := notev1.New()
old.Add("모듈 경로 정하기")
old.Add("internal 경계 긋기")
old.Add("") // v1은 빈 메모도 받는다
fmt.Println(old.Render())
fmt.Println("개수:", len(old.All()))

fmt.Println("\n== v2 ==")
book := notev2.New()
if _, err := book.Add("모듈 경로 정하기", "setup"); err != nil {
fmt.Println("에러:", err)
return
}
if _, err := book.Add("internal 경계 긋기", "design", "go"); err != nil {
fmt.Println("에러:", err)
return
}

// v2는 빈 본문을 거부한다.
if _, err := book.Add(" "); err != nil {
fmt.Println("거부됨:", err)
fmt.Println("센티널 판별:", errors.Is(err, notev2.ErrEmptyText))
}

fmt.Println(book.Render())
fmt.Println("개수:", len(book.Notes()))
fmt.Println("#go 태그:", book.ByTag("go"))

// 두 버전의 Note는 이름만 같은 별개 타입이다.
// var n notev1.Note = book.Notes()[0] 는 컴파일 에러다:
// cannot use book.Notes()[0] (value of struct type notelib.Note) as notelib.Note value
fmt.Println("\nv1 Note 타입:", fmt.Sprintf("%T", notev1.Note{}))
fmt.Println("v2 Note 타입:", fmt.Sprintf("%T", notev2.Note{}))
}
cd examples/06-modules-and-layout/08-practice-workspace
go run ./notecli
== v1 ==
1 | 모듈 경로 정하기
2 | internal 경계 긋기
3 |
개수: 3

== v2 ==
거부됨: 메모 추가: notelib: 본문이 비어 있다
센티널 판별: true
1 | 모듈 경로 정하기 [setup]
2 | internal 경계 긋기 [design, go]
개수: 2
#go 태그: [{2 internal 경계 긋기 [design go]}]

v1 Note 타입: notelib.Note
v2 Note 타입: notelib.Note

마지막 두 줄을 보라

v1 Note 타입: notelib.Note
v2 Note 타입: notelib.Note

%T가 완전히 같은 문자열을 낸다. 그런데 이 둘은 서로 대입할 수 없는 다른 타입이다. 컴파일러 메시지도 이렇게 나온다.

cannot use book.Notes()[0] (value of struct type notelib.Note) as notelib.Note value

6-4에서 "공존은 전환기를 위한 장치"라고 한 이유가 이것이다. 두 버전이 타입을 주고받아야 하는 자리가 생기면 지옥이 시작된다. 경계에서 변환하고, 전환을 빨리 끝낸다.

7단계 — 배포 검증

각 모듈 디렉터리로 들어가서 워크스페이스를 끄고 빌드해 본다. 워크스페이스 루트에서 GOWORK=off를 쓰면 메인 모듈이 없어서 이렇게 거부당하므로, 반드시 모듈 안에서 실행한다.

pattern ./notelib/...: directory prefix notelib does not contain main module or its selected dependencies
cd notelib && GOWORK=off go build ./... # 통과
cd ../notelib/v2 && GOWORK=off go build ./... # 통과
cd ../../notecli && GOWORK=off go build ./...
main.go:9:2: no required module provides package example.com/notelib; to add it:
go get example.com/notelib
main.go:10:2: no required module provides package example.com/notelib/v2; to add it:
go get example.com/notelib/v2

라이브러리 두 모듈은 외부 의존이 없으므로 워크스페이스 없이도 빌드된다. notecli는 안 된다 — require가 없기 때문이다. 실제로 배포한다면 이 순서다.

  1. notelib을 진짜 저장소 경로로 바꾸고 v1.0.0 태그를 push
  2. notecli에서 go get github.com/사용자/notelib@v1.0.0
  3. go.work를 지우거나 GOWORK=off로 빌드가 되는지 확인
  4. go mod tidy 후 커밋

3번을 건너뛰면 "내 기계에서는 되는데"가 된다.

직접 해 볼 것

여기까지는 완성본을 읽은 것이다. 아래는 직접 손을 대야 하는 부분이다.

과제 1 — v1을 유지보수한다

notelib v1에 호환되는 변경을 하나 넣고 v1.1.0 태그를 달아 보자. 예를 들어 func (b *Book) Len() int를 추가한다.

  • 왜 이것은 마이너 버전으로 충분한가?
  • Book에 공개 필드를 하나 추가하는 것은 어떤가?
  • v1과 v2 양쪽에 같은 수정을 해야 한다면, 코드 중복을 어떻게 관리하겠는가? (힌트: 메이저 서브디렉터리 방식의 대가가 이것이다.)

과제 2 — 경계를 옮겨 본다

notelib/internal/formatnotelib/format으로 (공개로) 옮겨 보자.

  • notecli에서 format.Line을 직접 부를 수 있는가?
  • 이제 Line의 시그니처를 바꾸면 무엇이 깨지는가?
  • v2에서 Line(id, text, tags)로 인자가 하나 늘었다. 만약 v1에서 공개돼 있었다면 이 변경만으로도 메이저 버전을 올려야 했는가?

과제 3 — 세 번째 모듈

notestats라는 CLI 모듈을 하나 더 만들어 v2만 쓰게 하자.

  • go work use ./notestats 후 바로 빌드되는가?
  • 세 모듈을 replace만으로 연결하려면 go.mod를 몇 줄 고쳐야 하는가? go.work와 비교해 보자.
  • notestatsnotelib/v2/internal/format을 쓰려 하면 어떻게 되는가?

과제 4 — 진짜로 배포해 본다

빈 GitHub 저장소를 하나 만들고 notelib을 실제 경로로 옮겨 v1.0.0을 push한 뒤, 전혀 다른 디렉터리에서 go get으로 받아 써 보자.

  • 태그를 push한 직후 go get이 실패하면 왜인가? (힌트: GOPROXY와 캐시)
  • GOFLAGS=-mod=mod GOPROXY=direct로 하면 달라지는가?
  • 태그를 지우고 다시 밀면 어떻게 되는가? go.sum은 뭐라고 하는가? 이 실험은 go.sum이 무엇을 지키는지 몸으로 알려 준다.

Part 6 정리

  • 패키지는 디렉터리, 캡슐화는 대문자. reflect가 볼 수 있는 것도 대문자뿐이다.
  • go.mod가 모듈 경로·언어 버전·의존성을 선언한다. go.sum은 무결성만 보장하고, 둘 다 커밋한다.
  • MVS는 요구들의 최댓값을 고른다. 그래서 락 파일이 없고, go get -u는 위험하다.
  • 호환을 깨면 경로를 바꾼다. v2 이상은 /vN, 그래서 메이저 버전이 공존한다.
  • internal/은 컴파일러가 강제하는 유일한 패키지 밖 경계다. 라이브러리는 전부 여기서 시작한다.
  • 순환 import는 설계 신호다. 소비자 쪽 인터페이스로 끊는다.
  • 레이아웃은 자라는 대로 만든다. cmd/는 바이너리가 둘 이상일 때, pkg/는 웬만하면 쓰지 않는다.
  • go.work는 로컬 다중 모듈 개발용이고, 배포 전 GOWORK=off 검증이 필수다.

다음 파트는 동시성이다. 고루틴과 채널은 패키지 경계와 무관하게 동작하지만, "누가 이 채널을 닫는가", "이 상태를 누가 소유하는가" 같은 질문은 결국 이 파트에서 배운 경계 감각 위에서 답하게 된다.