프로젝트 레이아웃
이 챕터에서 다루는 것
6-5까지가 "경계를 어떻게 긋는가"였다면, 이 챕터는
"디렉터리를 어떻게 배치하는가" 다. cmd/, internal/, pkg/가 무엇이고 어떤
것이 논쟁 중인지, 그리고 프로젝트가 실제로 자라는 순서를 본다.
문제 — Go에는 공식 레이아웃이 없다
Rails에는 app/models가 있고 Maven에는 src/main/java가 있다. Go에는 없다.
go build는 디렉터리 이름에 아무 의미도 두지 않는다 — 6-5에서
본 internal 하나가 유일한 예외다.
그래서 GitHub에서 별을 많이 받은
golang-standards/project-layout이
사실상의 표준처럼 인용되는데, 이것은 Go 팀의 문서가 아니다. 저장소 이름이
golang-standards라 공식처럼 보일 뿐이고, Go 코어 개발자들이 공개적으로 비판해
왔다. 거기 나오는 pkg/, api/, build/, deployments/, test/를 그대로 따라
하면 파일 다섯 개짜리 프로젝트에 디렉터리가 열두 개 생긴다.
이 챕터의 입장은 이렇다: 필요해질 때 만든다.
프로젝트는 이 순서로 자란다
0단계 — 파일 하나
myapp/
├── go.mod
└── main.go
농담이 아니다. 스크립트 성격의 도구는 여기서 끝나도 된다. 표준 라이브러리의
cmd/gofmt도 오랫동안 파일 몇 개짜리 package main이었다.
1단계 — 패키지 몇 개
myapp/
├── go.mod
├── main.go
├── task/
└── report/
로직이 커져서 경계가 생겼을 때다. 아직 cmd/도 internal/도 없다.
2단계 — 바이너리가 둘 이상이거나, 라이브러리로 쓰일 때
myapp/
├── go.mod
├── cmd/
│ ├── tasks/main.go
│ └── taskstats/main.go
└── internal/
├── task/
└── report/
이번 챕터의 예제가 이 모양이다.
3단계 — 그 이상
api/, migrations/, deploy/… 는 실제로 그 파일이 생겼을 때 만든다.
cmd/ — 언제 필요한가
cmd/ 아래의 각 디렉터리가 하나의 package main, 즉 하나의 바이너리다.
06-project-layout/ module example.com/layout
├── go.mod
├── cmd/
│ ├── tasks/main.go → tasks 바이너리
│ └── taskstats/main.go → taskstats 바이너리
└── internal/
├── task/task.go
└── report/report.go
cd examples/06-modules-and-layout/06-project-layout
go list ./...
example.com/layout/cmd/tasks
example.com/layout/cmd/taskstats
example.com/layout/internal/report
example.com/layout/internal/task
cmd/가 필요한 이유는 딱 하나다: package main이 여러 개일 때 각각 자기
디렉터리를 가져야 하기 때문이다. 한 디렉터리에 두 main을 둘 수 없다
(6-1).
바이너리가 하나뿐이라면 cmd/는 그냥 디렉터리 한 겹이다. 루트에 main.go를 두면
go run ., go install .로 끝나는데, cmd/myapp/main.go로 옮기면
go run ./cmd/myapp이 된다. 얻는 것 없이 타이핑만 늘어난다면 만들지 않는다.
main 패키지는 얇게
// tasks는 할 일 목록을 출력한다.
// main 패키지에는 배선과 입출력만 둔다. 로직은 internal/task에 있다.
package main
import (
"fmt"
"os"
"example.com/layout/internal/task"
)
func main() {
store := task.DemoStore()
items := store.All()
if len(os.Args) > 1 {
items = store.ByTag(os.Args[1])
fmt.Printf("태그 #%s 필터\n", os.Args[1])
}
if len(items) == 0 {
fmt.Println("할 일이 없다")
return
}
for _, t := range items {
fmt.Println(t)
}
}
// taskstats는 같은 저장소를 다른 방식으로 요약한다.
// tasks와 완전히 별개의 바이너리이고, internal 패키지만 공유한다.
package main
import (
"fmt"
"example.com/layout/internal/report"
"example.com/layout/internal/task"
)
func main() {
store := task.DemoStore()
fmt.Println(report.Summarize(store.All()))
}
go run ./cmd/tasks
[x] 1. go.mod 읽기 #go #docs
[ ] 2. internal 경계 정리 #go
[ ] 3. 워크스페이스 실습 #go #practice
[x] 4. 장보기 #life
go run ./cmd/tasks go
태그 #go 필터
[x] 1. go.mod 읽기 #go #docs
[ ] 2. internal 경계 정리 #go
[ ] 3. 워크스페이스 실습 #go #practice
go run ./cmd/taskstats
전체 4건, 완료 2건 (50%)
#docs 1건
#go 3건
#life 1건
#practice 1건
가장 긴 제목: "워크스페이스 실습"
main에는 인자 파싱, 배선, 출력만 있다. 이유는 실용적이다 — main 패키지는
다른 곳에서 import할 수 없으므로, 거기 넣은 코드는 재사용도 테스트도 어렵다.
(테스트는 Part 8이 다룬다.)
cmd/ 끼리는 서로를 import할 수 없다는 점도 중요하다. main 패키지는 import
대상이 아니다. 두 바이너리가 무언가를 공유해야 하면 그것은 internal/로
내려가야 한다. 예제에서 DemoStore를 internal/task에 둔 이유가 그것이다.
한 번에 다 빌드하기
go build ./... # 전부 컴파일
go install ./... # $GOBIN에 모든 바이너리 설치
go build -o bin/ ./cmd/... # bin/ 아래에 모아서 출력
기능으로 나눈다, 계층으로 나누지 않는다
// Package task는 할 일 도메인이다.
// 타입, 저장소, 조회 로직이 한 패키지에 같이 있다.
// "models / services / handlers"로 쪼개지 않는다는 것이 이 예제의 요지다.
package task
import (
"fmt"
"slices"
"strings"
)
type Status string
const (
Todo Status = "todo"
Done Status = "done"
)
type Task struct {
ID int
Title string
Status Status
Tags []string
}
func (t Task) String() string {
mark := " "
if t.Status == Done {
mark = "x"
}
tags := ""
if len(t.Tags) > 0 {
tags = " #" + strings.Join(t.Tags, " #")
}
return fmt.Sprintf("[%s] %d. %s%s", mark, t.ID, t.Title, tags)
}
// Store는 인메모리 저장소다. 같은 패키지에 두는 이유는
// Task와 함께 바뀌고, 함께 바뀌는 것은 함께 두는 편이 낫기 때문이다.
type Store struct {
nextID int
items []Task
}
func NewStore() *Store { return &Store{} }
func (s *Store) Add(title string, status Status, tags ...string) Task {
s.nextID++
t := Task{ID: s.nextID, Title: title, Status: status, Tags: tags}
s.items = append(s.items, t)
return t
}
// All은 내부 슬라이스를 그대로 주지 않고 복사본을 준다.
// 그러지 않으면 호출자가 저장소 내부를 마음대로 바꿀 수 있다.
func (s *Store) All() []Task {
return slices.Clone(s.items)
}
// DemoStore는 예제용 고정 데이터다.
// 두 cmd가 공유하므로 internal에 둔다 — cmd끼리는 서로를 import할 수 없다.
func DemoStore() *Store {
s := NewStore()
s.Add("go.mod 읽기", Done, "go", "docs")
s.Add("internal 경계 정리", Todo, "go")
s.Add("워크스페이스 실습", Todo, "go", "practice")
s.Add("장보기", Done, "life")
return s
}
func (s *Store) ByTag(tag string) []Task {
var out []Task
for _, t := range s.items {
if slices.Contains(t.Tags, tag) {
out = append(out, t)
}
}
return out
}
Task(타입), Store(저장), ByTag(조회)가 한 패키지 안에 있다. 다른 언어의
습관대로라면 models/task.go, repositories/task.go, services/task.go로 나눴을
것이다.
계층별 (models/services) | 기능별 (task/report) |
|---|---|
| 필드 하나 추가에 3개 디렉터리 수정 | 1개 디렉터리 |
models가 무언가 필요해지면 순환 | 의존이 한 방향 |
task.TaskService.GetTask (말 더듬) | task.Store.All |
| 패키지 이름이 정보를 안 준다 | 호출부에서 읽힌다 |
report는 task를 import하고, task는 report를 모른다.
// Package report는 task 목록을 요약한다.
// task를 import하고, task는 report를 모른다. 의존은 한 방향이다.
package report
import (
"fmt"
"maps"
"slices"
"strings"
"example.com/layout/internal/task"
)
type Summary struct {
Total int
Done int
ByTag map[string]int
Longest string
}
func Summarize(ts []task.Task) Summary {
s := Summary{ByTag: map[string]int{}}
for _, t := range ts {
s.Total++
if t.Status == task.Done {
s.Done++
}
for _, tag := range t.Tags {
s.ByTag[tag]++
}
if len(t.Title) > len(s.Longest) {
s.Longest = t.Title
}
}
return s
}
func (s Summary) String() string {
var b strings.Builder
fmt.Fprintf(&b, "전체 %d건, 완료 %d건", s.Total, s.Done)
if s.Total > 0 {
fmt.Fprintf(&b, " (%.0f%%)", float64(s.Done)/float64(s.Total)*100)
}
// 맵 순회 순서는 무작위이므로 정렬해서 낸다.
for _, tag := range slices.Sorted(maps.Keys(s.ByTag)) {
fmt.Fprintf(&b, "\n #%s %d건", tag, s.ByTag[tag])
}
if s.Longest != "" {
fmt.Fprintf(&b, "\n 가장 긴 제목: %q", s.Longest)
}
return b.String()
}
pkg/ — 논쟁의 대상
pkg/는 "공개 라이브러리 코드를 여기 둔다"는 관행이다. 그리고 Go 코어
개발자들이 반복해서 반대해 온 관행이기도 하다.
반대 논거는 셋이다.
internal과 달리 아무것도 강제하지 않는다.internal은 컴파일러가 막지만pkg는 그냥 디렉터리 이름이다. 규칙처럼 보이는데 규칙이 아니다.- 경로가 길어지기만 한다.
github.com/user/app/pkg/task보다github.com/user/app/task가 낫다.pkg는 정보를 0비트 더한다. - 표준 라이브러리도, Go 자신의 소스도 쓰지 않는다. Go 저장소에는
src/,cmd/,internal/은 있어도pkg/는 없다.
찬성 논거는 하나뿐이고, 조건부다. 루트에 디렉터리가 너무 많을 때 Go 코드를 한
군데로 모아 준다. Dockerfile, Makefile, docs/, web/, scripts/가
섞여 있는 저장소에서는 눈에 도움이 될 수 있다.
결론: 기본은 안 쓴다. 루트가 정말 어지러우면 그때 고려한다. 이미 pkg/를 쓰는
프로젝트에 들어갔다면 그냥 따른다 — 이건 취향 싸움이지 옳고 그름이 아니다.
Go 아닌 것들을 어디에 둘까
go build는 .go가 아닌 파일을 무시하므로 어디에 두든 자유다. 널리 쓰이는 관행은
이 정도다.
| 무엇 | 어디에 | 비고 |
|---|---|---|
| 설정 예시 파일 | configs/ 또는 루트 | 실제 설정은 커밋하지 않는다 |
| DB 마이그레이션 | migrations/ 또는 internal/store/migrations/ | 아래 참고 |
| 정적 파일·템플릿 | web/, templates/, static/ | 또는 쓰는 패키지 옆 |
| 스크립트 | scripts/ | |
| Dockerfile·CI | 루트, .github/ | 도구가 위치를 정해 준다 |
| 문서 | docs/, README.md |
마이그레이션과 템플릿은 쓰는 패키지 옆에 두는 편이 낫다. embed로 바이너리에
집어넣을 때 //go:embed는 자기 패키지 디렉터리 아래만 볼 수 있기 때문이다
(상위 디렉터리를 ../로 참조할 수 없다). 그래서 internal/store/migrations/가
루트 migrations/보다 실용적이다. embed 자체는 Part 9(표준 라이브러리)에서
다룬다.
:::tip 테스트 데이터는 testdata/
testdata라는 이름의 디렉터리는 Go 도구가 자동으로 무시한다
(6-2에서 본 ignore 지시자의 원조 격이다). 골든 파일과
고정 입력을 여기 둔다. Part 8이 자세히 다룬다.
:::
흔히 하는 실수
1. 빈 프로젝트에 표준 레이아웃을 먼저 깐다
api/, build/, deployments/, test/, third_party/가 전부 비어 있는 저장소가
된다. 필요해질 때 만든다.
2. 바이너리 하나인데 cmd/를 만든다
go run .이 go run ./cmd/app이 되는 것 말고 달라지는 것이 없다.
3. main에 로직을 넣는다
main 패키지는 import 대상이 아니라 재사용도 테스트도 안 된다. 배선만 남긴다.
4. 계층으로 패키지를 나눈다
models/services/handlers는 기능 하나 고치는 데 세 곳을 건드리게 하고 순환을
부른다. 기능으로 나눈다.
5. pkg/를 반사적으로 만든다
internal과 달리 아무 효력이 없다. 경로만 길어진다.
6. 여러 cmd가 코드를 복사해서 공유한다
cmd끼리는 import할 수 없다. 공유할 것은 internal/로 내린다.
정리
- Go에 공식 프로젝트 레이아웃은 없다.
golang-standards/project-layout은 공식이 아니고 과하다. - 프로젝트는 파일 하나 → 패키지 몇 개 →
cmd/+internal/순서로 자란다. 각 단계는 필요해졌을 때 간다. cmd/는package main이 둘 이상일 때 필요하다. 하나뿐이면 루트에 둔다.main은 배선과 입출력만 담고,cmd끼리 공유할 것은internal/로 내린다.- 계층이 아니라 기능으로 나눈다. 타입·저장·조회를 한 패키지에 둔다.
models/services/handlers는 순환과 말 더듬을 부른다. pkg/는 아무것도 강제하지 않는다. 기본은 안 쓴다.- Go 아닌 파일은 어디에 둬도 되지만,
embed할 것은 쓰는 패키지 옆에 둔다.testdata/는 도구가 자동으로 무시한다.
연습문제
-
cmd/tasks와cmd/taskstats가 공통으로 쓸 "출력 형식 플래그" 처리를 추가한다고 하자. 그 코드는 어디에 두어야 하는가?cmd/tasks에 두고cmd/taskstats에서 import하면 어떤 에러가 나는가 — 예측한 뒤 확인해 보자. -
internal/task를internal/model과internal/service로 쪼개 보자.Task는model에,Store는service에 둔다. 그다음Task에Priority필드를 추가하고ByPriority조회를 넣으려면 몇 개 파일을 고쳐야 하는가? 쪼개기 전과 비교해 보자. -
이 모듈을 라이브러리로도 쓸 수 있게 만들려면 무엇을
internal/밖으로 꺼내야 하는가?task.Store를 공개했을 때 앞으로 못 바꾸게 되는 것을 목록으로 적어 보자. 힌트:All()이[]Task를 돌려준다는 사실 자체가 약속이다.