파일 하나로 시작하기
Go 프로그램은 main.go 하나에 전부 들어갈 수 있고, 작은 도구라면 그래야 합니다. 이 완전한 프로그램은 패키지 하나에서 입력을 파싱하고, 작업을 하고, 보고서를 출력합니다:
프로그램이 커지면 같은 디렉터리, 같은 패키지 안에서 여러 파일로 나누세요(parse.go, report.go, main.go). 한 패키지의 파일들은 모든 식별자를 공유하므로 아무것도 공개하거나 import할 필요가 없습니다. 몇천 줄짜리 프로젝트라면 이 정도 구조로 충분한 경우가 많습니다.
여러 파일로 된 package main은 패키지로 실행해야 한다는 점을 기억하세요. go run main.go가 아니라 go run .입니다. 그렇지 않으면 파일이 따로 컴파일되어 다른 파일에 정의된 이름에 대해 undefined 오류가 납니다.
강제된 구조는 없다
Go는 "디렉터리 하나에 패키지 하나" 외에는 어떤 디렉터리 구조도 요구하지 않습니다. 공식 가이드는 go.dev의 Organizing a Go module 페이지이며, 규칙이 아니라 몇 가지 모양을 설명합니다.
GitHub 저장소 golang-standards/project-layout은 널리 복사되고, 널리 표준으로 오해받습니다. 이것은 큰 프로젝트들의 관례를 모은 커뮤니티 모음이며, 당시 Go 기술 리드였던 Russ Cox가 그곳에 이슈를 열어 Go 표준이 아니라고 밝혔습니다. 대부분의 디렉터리(pkg/, api/, build/, deployments/)는 큰 코드베이스에서만 의미가 있습니다. 새 프로젝트에 골격 전체를 복사하면 아무 이득 없이 빈 폴더와 깊은 import 경로만 생깁니다.
도구가 강제하는 규칙은 짧습니다:
- 디렉터리 하나가 패키지 하나입니다. 그 안의 모든
.go파일은(_test접미사를 쓰는_test.go파일을 제외하고) 패키지 이름을 공유합니다. - import 경로는 모듈 경로에 디렉터리 경로를 더한 것입니다.
internal이라는 디렉터리는 그 안의 것을 누가 import할 수 있는지 제한합니다.testdata라는 이름의 디렉터리와.이나_로 시작하는 디렉터리는 go 도구가 무시합니다.
흔한 모양
명령 하나
todo/
├── go.mod module github.com/you/todo
├── main.go
├── parse.go
├── report.go
└── parse_test.go
평평하고, 모든 것이 package main에 있습니다. go install github.com/you/todo@latest가 동작하고 바이너리 이름은 todo입니다.
라이브러리
slug/
├── go.mod module github.com/you/slug
├── slug.go package slug
├── slug_test.go
├── example_test.go
└── internal/
└── table/ helpers the public package uses, hidden from users
패키지가 모듈 루트에 있으므로 사용자는 github.com/you/slug를 import하고 slug.Make(...)를 호출합니다. 공개 API로 지원하고 싶지 않은 것은 모두 internal/ 아래에 둡니다.
서비스 또는 여러 바이너리
shop/
├── go.mod module github.com/you/shop
├── cmd/
│ ├── shop-api/
│ │ └── main.go package main: flags, config, wiring
│ └── shop-worker/
│ └── main.go
├── internal/
│ ├── order/ package order: domain types and logic
│ │ ├── order.go
│ │ └── order_test.go
│ ├── store/ package store: database access
│ └── httpapi/ package httpapi: handlers, routing
├── migrations/
└── README.md
cmd/<name>/main.go는 바이너리마다 디렉터리 하나를 두며, 디렉터리 이름이 바이너리 이름이 됩니다(go build ./cmd/shop-api는 shop-api를 만듭니다). 각 main은 얇게 유지합니다. 설정을 읽고, 의존성을 구성하고, 서버를 시작합니다. 실제 코드는 internal/ 아래의 패키지에 있어서, 두 바이너리 모두 쓸 수 있고 다른 모듈은 쓸 수 없습니다.
대부분의 Go 서비스가 결국 도달하는 구조입니다. 첫날부터가 아니라, 두 번째 바이너리가 생기거나 패키지를 나눌 진짜 이유가 있을 때 쓰세요.
internal/은 go 명령이 강제한다
internal/ 아래의 패키지는 internal의 부모를 루트로 하는 트리 안의 코드만 import할 수 있습니다. 다른 모듈에서 import하면 빌드가 실패합니다:
package example.com/b
main.go:6:2: use of internal package example.com/a/internal/secret not allowed
그래서 internal/은 API 표면을 작게 유지하는 도구입니다. 모든 호출자가 자기 저장소 안에 있다는 것을 알기 때문에 그 안의 코드는 자유롭게 바꿀 수 있습니다. 애플리케이션이라면 거의 모든 것을 internal/에 두는 것도 합리적입니다. 라이브러리라면 안정적으로 유지하겠다고 약속하는 것과 그렇지 않은 것을 나눠 줍니다.
internal은 어느 깊이에서든 동작합니다. shop/internal/order는 shop/ 전체에서 보이고, shop/internal/order/internal/pricing은 shop/internal/order/ 안에서만 보입니다.
패키지 이름 짓기
패키지 이름은 모든 호출 지점의 일부이므로 디렉터리 트리보다 더 중요합니다.
- 짧고, 소문자이고, 한 단어로:
order,store,httpapi. 밑줄도 mixedCaps도 쓰지 않습니다. - 담고 있는 것이 아니라 제공하는 것으로 이름을 지으세요.
util,common,helpers,misc,models는 아무것도 말해 주지 않고 잡동사니 창고가 됩니다. Go 팀의 스타일 가이드도 명시적으로 권하지 않습니다. 헬퍼는 그것을 쓰는 패키지에 두거나, 목적에 따라 이름 붙인 패키지(slug,retry)에 두세요. - 말 더듬기를 피하세요. 호출자는
order.Order와order.New라고 쓰므로order.OrderService나order.NewOrder같은 이름은 붙이지 마세요. 표준 라이브러리는 일관되게 이렇게 합니다.http.HTTPServer가 아니라http.Server입니다. - 디렉터리 이름과 패키지 이름은 일치해야 합니다. 명령 디렉터리의
package main은 예외입니다. 둘이 다르면 import가 어떤 식별자를 들여오는지 알기 위해 파일을 열어 봐야 합니다.
패키지는 종류가 아니라 책임에 따라 나누세요. (다른 생태계에서 흔한) models/, controllers/, services/ 분할은 모든 기능이 패키지 세 개를 건드리게 만들고, Go가 금지하는 import 순환을 만들기 쉽습니다. 도메인 개념(order, payment, user)을 중심으로 구성한 패키지는 각자 자기 타입과 로직을 함께 담습니다.
테스트의 위치
테스트는 같은 디렉터리의 코드 옆에 *_test.go라는 이름의 파일로 둡니다. tests/ 트리는 없습니다.
order_test.go의package order: 내부 테스트이며, 공개되지 않은 식별자를 쓸 수 있습니다.- 같은 디렉터리의
package order_test: 외부 테스트이며, 호출자처럼 공개된 API만 봅니다. 예제와, 테스트에서 import 순환을 피할 때 유용합니다. - 픽스처 파일은 테스트 옆의
testdata/디렉터리에 둡니다. go 도구는 이 디렉터리를 무시하고, 테스트는 패키지 디렉터리를 작업 디렉터리로 삼아 실행되므로testdata/big.json같은 상대 경로가 동작합니다.
루트의 다른 파일들
| 파일 또는 디렉터리 | 용도 |
|---|---|
go.mod, go.sum | 모듈 정의와 의존성 체크섬, 항상 루트에 |
README.md, LICENSE | 다른 프로젝트와 같음 |
Makefile 또는 Taskfile.yml | 선택적인 빌드 단축 명령 |
Dockerfile | 서비스라면 보통 루트에 |
migrations/, web/, docs/ | Go가 아닌 자산, 담는 것에 따라 이름 붙임 |
tools.go | 도구 의존성을 고정하던 예전 방식, Go 1.24에서는 go.mod의 tool 줄(go get -tool)로 대체 |
go.work | 여러 모듈을 로컬에서 함께 개발하기 위한 워크스페이스, 모듈이 하나인 프로젝트에서는 보통 커밋하지 않음 |
흔한 실수
- 작은 프로젝트에 큰 템플릿을 복사함. 평평하게 시작하고, 두 번째 바이너리나 진짜 경계가 생기면 디렉터리를 추가하세요.
utils나common이라는 이름의 패키지. 하는 일에 따라 패키지 이름을 지으세요.- 파일 하나나 타입 하나마다 패키지 하나. Go 패키지는 Java 클래스보다 큰 단위로 설계되었습니다. 파일이 열 개인 패키지는 평범합니다.
- import 순환. 두 패키지는 서로를 import할 수 없습니다. 보통 둘이 함께 있어야 한다는 뜻이거나, 공유 타입을 더 낮은 수준의 패키지로 옮겨야 한다는 뜻이거나, 한쪽이 다른 패키지 대신 작은 인터페이스에 의존해야 한다는 뜻입니다.
- 반사적으로 쓰는
pkg/. 의미는 더하지 않고 모든 import 경로에 한 단계를 더합니다. - 별도 디렉터리의 테스트. 거기서는 공개되지 않은 코드를 볼 수 없고, 도구도 그런 배치를 예상하지 않습니다.
자주 묻는 질문
공식 Go 프로젝트 구조가 있나요?
강제되는 구조는 없습니다. Go 팀은 go.dev/doc/modules/layout에 가이드를 공개하고 있으며, 거기서 몇 가지 흔한 모양(단일 패키지, 명령 하나, internal/을 둔 여러 명령)을 설명합니다. 인기 있는 golang-standards/project-layout 저장소는 Go 표준이 아니라 커뮤니티 프로젝트이며, Go 팀도 공개적으로 그렇게 밝혔습니다.
Go의 internal 디렉터리란 무엇인가요?
import 경로에 internal 요소가 들어 있는 패키지는 그 internal 디렉터리의 부모를 루트로 하는 코드에서만 import할 수 있습니다. example.com/app/internal/store는 example.com/app/ 아래 어디서든 import할 수 있고, 다른 모듈에서 import하면 go 명령이 use of internal package ... not allowed로 거부합니다.
Go에서 pkg 디렉터리를 써야 하나요?
선택 사항이며, 의미는 더하지 않고 경로 한 단계만 늘립니다. 일부 큰 프로젝트는 공개 라이브러리 코드를 나머지와 구분하려고 pkg/를 쓰지만, Go 표준 라이브러리와 대부분의 최신 프로젝트는 쓰지 않습니다. import할 수 있는 패키지는 모듈 루트나 이름 있는 디렉터리에 두고, 비공개인 것은 internal/에 두세요.
Go 프로젝트에서 테스트 파일은 어디에 두나요?
테스트하는 코드와 같은 디렉터리에 *_test.go라는 이름으로 둡니다. Go에는 별도의 테스트 트리가 없습니다. 픽스처 파일은 테스트 옆의 testdata 디렉터리에 두며, go 도구는 패키지를 찾을 때 이 디렉터리를 무시합니다.