본문으로 건너뛰기

프로젝트 레이아웃

이 챕터에서 다루는 것

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 패키지는 얇게

examples/06-modules-and-layout/06-project-layout/cmd/tasks/main.go
// 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)
}
}
examples/06-modules-and-layout/06-project-layout/cmd/taskstats/main.go
// 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/로 내려가야 한다. 예제에서 DemoStoreinternal/task에 둔 이유가 그것이다.

한 번에 다 빌드하기

go build ./... # 전부 컴파일
go install ./... # $GOBIN에 모든 바이너리 설치
go build -o bin/ ./cmd/... # bin/ 아래에 모아서 출력

기능으로 나눈다, 계층으로 나누지 않는다

examples/06-modules-and-layout/06-project-layout/internal/task/task.go
// 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
패키지 이름이 정보를 안 준다호출부에서 읽힌다

reporttask를 import하고, taskreport를 모른다.

examples/06-modules-and-layout/06-project-layout/internal/report/report.go
// 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 코어 개발자들이 반복해서 반대해 온 관행이기도 하다.

반대 논거는 셋이다.

  1. internal과 달리 아무것도 강제하지 않는다. internal은 컴파일러가 막지만 pkg는 그냥 디렉터리 이름이다. 규칙처럼 보이는데 규칙이 아니다.
  2. 경로가 길어지기만 한다. github.com/user/app/pkg/task보다 github.com/user/app/task가 낫다. pkg는 정보를 0비트 더한다.
  3. 표준 라이브러리도, 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/는 도구가 자동으로 무시한다.

연습문제

  1. cmd/taskscmd/taskstats가 공통으로 쓸 "출력 형식 플래그" 처리를 추가한다고 하자. 그 코드는 어디에 두어야 하는가? cmd/tasks에 두고 cmd/taskstats에서 import하면 어떤 에러가 나는가 — 예측한 뒤 확인해 보자.

  2. internal/taskinternal/modelinternal/service로 쪼개 보자. Taskmodel에, Storeservice에 둔다. 그다음 TaskPriority 필드를 추가하고 ByPriority 조회를 넣으려면 몇 개 파일을 고쳐야 하는가? 쪼개기 전과 비교해 보자.

  3. 이 모듈을 라이브러리로도 쓸 수 있게 만들려면 무엇을 internal/ 밖으로 꺼내야 하는가? task.Store를 공개했을 때 앞으로 못 바꾸게 되는 것을 목록으로 적어 보자. 힌트: All()[]Task를 돌려준다는 사실 자체가 약속이다.