본문으로 건너뛰기

패키지

이 챕터에서 다루는 것

지금까지 모든 예제는 main.go 한 파일이었다. Part 6부터는 아니다. 이 챕터는 파일 여러 개를 어떻게 묶고, 그 묶음 사이에 무엇이 보이게 할 것인가를 다룬다. package 선언과 디렉터리의 관계, 대문자 규칙, import의 네 가지 형태, init의 실행 순서, 그리고 이름 짓기다.

문제 — 파일을 나눠도 아무 일도 일어나지 않는다

다른 언어에서 온 사람이 가장 먼저 겪는 혼란이다. 파일을 user.go, order.go로 나누면 뭔가 격리될 것 같지만, Go에서는 아니다.

Go의 캡슐화 단위는 파일이 아니라 디렉터리다. 한 디렉터리 안의 .go 파일들은 전부 하나의 패키지로 컴파일되고, 서로의 비공개 식별자를 자유롭게 본다. 파일을 나누는 것은 순전히 사람이 읽기 편하자고 하는 일이다.

거꾸로, 디렉터리를 나누면 그 순간 진짜 경계가 생긴다. import를 해야 닿고, 대문자로 시작하는 것만 보이고, 순환 참조는 컴파일 자체가 거부된다.

디렉터리 하나 = 패키지 하나

규칙은 딱 세 줄이다.

  1. 한 디렉터리 안의 모든 .go 파일은 같은 package 선언을 가져야 한다.
  2. 그 디렉터리가 곧 하나의 패키지다. 하위 디렉터리는 별개의 패키지다.
  3. 패키지를 가리키는 import 경로는 모듈 경로 + 디렉터리 경로다.

한 디렉터리에 다른 패키지 선언을 섞으면 빌드가 이렇게 끊긴다.

found packages x (a.go) and y (b.go) in /tmp/pkgerr/x

이번 챕터의 예제 모듈은 이런 모양이다.

01-packages/
├── go.mod module example.com/packages
├── main.go package main
├── greet/
│ ├── greet.go package greet
│ └── formal.go package greet ← 같은 패키지의 두 번째 파일
├── registry/
│ └── registry.go package registry
└── codec/
├── upper/
│ └── upper.go package upper
└── rot13/
└── rot13.go package rot13

codec/에는 .go 파일이 없다. 패키지가 아닌 순수 디렉터리다. Go는 이것을 전혀 문제 삼지 않는다. 디렉터리는 그냥 이름 공간을 나누는 도구다.

cd examples/06-modules-and-layout/01-packages
go list ./...
example.com/packages
example.com/packages/codec/rot13
example.com/packages/codec/upper
example.com/packages/greet
example.com/packages/registry

example.com/packages/codec가 목록에 없는 것을 보라.

대문자가 곧 접근 제어자다

Go에는 public, private, protected가 없다. 식별자의 첫 글자가 대문자면 공개(exported), 소문자면 비공개다. 그게 전부다.

examples/06-modules-and-layout/01-packages/greet/greet.go
// Package greet는 인사말을 만든다.
// 패키지 주석은 패키지 선언 바로 위, 파일 하나에만 붙인다. go doc이 이것을 읽는다.
package greet

import "fmt"

// DefaultName은 대문자로 시작하므로 패키지 밖에서 보인다.
const DefaultName = "world"

// separator는 소문자라서 이 패키지 안에서만 보인다.
const separator = ", "

// Hello는 공개 함수다. greet.Hello로 부른다.
func Hello(name string) string {
if name == "" {
name = DefaultName
}
return "Hello" + separator + decorate(name)
}

// decorate는 비공개다. 같은 패키지의 다른 파일(formal.go)에서는 그대로 쓸 수 있다.
func decorate(name string) string {
return fmt.Sprintf("%s!", name)
}
examples/06-modules-and-layout/01-packages/greet/formal.go
package greet

// 같은 디렉터리의 두 번째 파일이다. package 선언이 같으므로 같은 패키지다.
// 파일 경계는 컴파일러에게 아무 의미가 없다 — decorate도 separator도 그대로 보인다.

// Formal은 격식 있는 인사말을 만든다.
func Formal(title, name string) string {
return "Good day" + separator + title + " " + decorate(name)
}

formal.goseparatordecorate를 아무 수식 없이 쓴다. 같은 패키지이기 때문이다. 반대로 main.go에서 greeting.decorate("x")를 쓰면 undefined: greeting.decorate로 끊긴다.

이 규칙은 함수·타입·상수·변수·구조체 필드·메서드에 전부 똑같이 적용된다. 필드 단위로 걸린다는 점이 중요하다.

type User struct {
Name string // 밖에서 보인다
email string // 안 보인다
}

:::warning 대문자 규칙에는 런타임 결과가 따라온다 5-5에서 reflect가 비공개 필드를 읽지도 쓰지도 못한다는 것을 세 번 확인했다. 그것이 JSON 필드가 대문자로 시작해야 하는 이유다. encoding/json도, 어떤 ORM도, 어떤 설정 라이브러리도 전부 reflect로 동작하고, reflect는 대문자만 볼 수 있다.

즉 대문자/소문자는 "문서화 관례"가 아니다. 런타임에 관측 가능한 성질이다. :::

import의 네 가지 형태

examples/06-modules-and-layout/01-packages/main.go
import (
"fmt"

// 별칭 import: 이 파일 안에서만 greeting이라는 이름으로 부른다.
greeting "example.com/packages/greet"

"example.com/packages/registry"

// 블랭크 import: 식별자는 하나도 안 쓰고 init만 실행시킨다.
// 이 두 줄을 지우면 registry가 텅 빈다.
_ "example.com/packages/codec/rot13"
_ "example.com/packages/codec/upper"
)
형태의미언제
import "path"기본. 패키지 이름으로 부른다거의 항상
import alias "path"이 파일 안에서만 다른 이름으로이름 충돌, 긴 이름 줄이기
import _ "path"식별자 안 씀. init만 실행드라이버·플러그인 등록
import . "path"이름 없이 직접 참조쓰지 않는다

경로와 패키지 이름은 다른 것이다

import 경로의 마지막 조각이 패키지 이름일 거라는 보장은 없다. 실제로 자주 어긋난다.

import 경로패키지 이름
math/rand/v2rand
gopkg.in/yaml.v3yaml
github.com/go-sql-driver/mysqlmysql
google.golang.org/grpcgrpc

그래서 편집기가 자동으로 붙여 주는 별칭을 지우기 전에 실제 패키지 선언을 확인해야 한다. go doc <경로>의 첫 줄이 알려 준다.

go doc ./greet
package greet // import "example.com/packages/greet"

Package greet는 인사말을 만든다. 패키지 주석은 패키지 선언 바로 위, 파일 하나에만 붙인다. go doc이 이것을 읽는다.

const DefaultName = "world"
func Formal(title, name string) string
func Hello(name string) string

go doc은 공개 식별자만 낸다. 문서가 곧 공개 API의 정의다.

안 쓰는 import는 에러다

./main.go:5:2: "os" imported and not used

경고가 아니라 컴파일 에러다. 안 쓰는 변수와 같은 취급이다. 다른 언어에서 온 사람에게 가장 거슬리는 규칙이지만, 의도는 명확하다 — 빌드 그래프에 쓰레기가 쌓이면 컴파일이 느려지고 의존성이 부풀어 오른다. Go는 빠른 컴파일을 위해 이 정도의 잔소리는 감수하기로 한 언어다.

임시로 주석 처리하고 싶을 때는 goimports가 알아서 지워 주므로 손댈 일이 거의 없다 (1-6).

점 import를 쓰지 않는 이유

import . "strings"를 쓰면 ToUpper(s)처럼 패키지 이름 없이 부를 수 있다. 그리고 그 식별자가 어디서 왔는지 아무도 알 수 없게 된다. 지역 변수와 이름이 겹치면 조용히 가려지기까지 한다. 표준 라이브러리에도 몇 군데 남아 있지만 전부 테스트 코드이고, 새로 쓸 이유는 없다.

블랭크 import와 init — 등록 패턴

블랭크 import는 "이 패키지의 식별자는 안 쓰지만 초기화는 시켜라"라는 뜻이다. database/sql 드라이버, 이미지 포맷 디코더가 전부 이 방식이다.

examples/06-modules-and-layout/01-packages/registry/registry.go
// Package registry는 이름 붙은 변환기를 모아 둔다.
// 변환기 패키지들이 init에서 스스로 등록하고, 사용하는 쪽은 블랭크 import만 한다.
package registry

import (
"fmt"
"slices"
)

// Transform은 문자열을 문자열로 바꾸는 변환기다.
type Transform func(string) string

// codecs는 패키지 변수다. 어떤 init보다도 먼저 초기화된다.
var codecs = map[string]Transform{}

// registry는 upper·rot13·main이 모두 의존하므로 언제나 가장 먼저 초기화된다.
func init() {
fmt.Println("0. registry init — import된 패키지가 먼저 초기화된다")
}

// Register는 변환기를 등록한다. 이름이 겹치면 패닉이다.
// 등록은 프로그램 시작 시점에만 일어나므로, 실수를 즉시 드러내는 편이 낫다.
func Register(name string, t Transform) {
if _, dup := codecs[name]; dup {
panic("registry: 중복 등록 " + name)
}
codecs[name] = t
}

// Get은 등록된 변환기를 찾는다.
func Get(name string) (Transform, error) {
t, ok := codecs[name]
if !ok {
return nil, fmt.Errorf("registry: %q는 등록되지 않았다", name)
}
return t, nil
}

// Names는 등록된 이름을 정렬해서 돌려준다.
// 맵 순회 순서는 무작위라서, 정렬하지 않으면 출력이 실행할 때마다 달라진다.
func Names() []string {
out := make([]string, 0, len(codecs))
for name := range codecs {
out = append(out, name)
}
slices.Sort(out)
return out
}

등록하는 쪽은 공개 식별자가 하나도 없다.

examples/06-modules-and-layout/01-packages/codec/upper/upper.go
// Package upper는 대문자 변환기를 registry에 등록한다.
// 이 패키지는 공개 식별자를 하나도 내보내지 않는다. 존재 자체가 부수 효과다.
package upper

import (
"strings"

"example.com/packages/registry"
)

func init() {
registry.Register("upper", strings.ToUpper)
}
examples/06-modules-and-layout/01-packages/codec/rot13/rot13.go
// Package rot13은 ROT13 변환기를 registry에 등록한다.
package rot13

import (
"strings"

"example.com/packages/registry"
)

// table은 패키지 변수다. init보다 먼저 초기화된다.
var table = buildTable()

func buildTable() map[rune]rune {
m := make(map[rune]rune, 52)
for c := 'a'; c <= 'z'; c++ {
m[c] = 'a' + (c-'a'+13)%26
}
for c := 'A'; c <= 'Z'; c++ {
m[c] = 'A' + (c-'A'+13)%26
}
return m
}

func init() {
// table은 여기 도달했을 때 이미 채워져 있다.
registry.Register("rot13", encode)
}

func encode(s string) string {
return strings.Map(func(r rune) rune {
if to, ok := table[r]; ok {
return to
}
return r
}, s)
}

초기화 순서

main.go가 순서를 눈으로 보여 준다.

examples/06-modules-and-layout/01-packages/main.go
package main

import (
"fmt"

// 별칭 import: 이 파일 안에서만 greeting이라는 이름으로 부른다.
greeting "example.com/packages/greet"

"example.com/packages/registry"

// 블랭크 import: 식별자는 하나도 안 쓰고 init만 실행시킨다.
// 이 두 줄을 지우면 registry가 텅 빈다.
_ "example.com/packages/codec/rot13"
_ "example.com/packages/codec/upper"
)

// 패키지 변수 초기화 순서는 "선언 순서"가 아니라 "의존 순서"다.
// banner는 아래 line에 의존하므로, 선언은 먼저지만 초기화는 나중이다.
var banner = "[" + line + "]"

var line = makeLine()

func makeLine() string {
fmt.Println("1. makeLine() 실행 — 변수 초기화")
return "packages"
}

func init() {
fmt.Println("2. main 패키지의 첫 번째 init")
}

func init() {
// 한 패키지에 init을 여러 개 둘 수 있다. 파일 순서 → 파일 안 순서로 실행된다.
fmt.Println("3. main 패키지의 두 번째 init")
}

func main() {
fmt.Println("4. main() 시작 —", banner)

fmt.Println(greeting.Hello(""))
fmt.Println(greeting.Hello("고퍼"))
fmt.Println(greeting.Formal("Dr.", "Pike"))

// greeting.decorate("x") 는 컴파일 에러다:
// undefined: greeting.decorate

fmt.Println("등록된 변환기:", registry.Names())

for _, name := range registry.Names() {
t, err := registry.Get(name)
if err != nil {
fmt.Println("조회 실패:", err)
continue
}
fmt.Printf(" %-6s %s\n", name, t("Hello, Gopher"))
}

if _, err := registry.Get("base64"); err != nil {
fmt.Println("없는 이름:", err)
}
}
go run .
0. registry init — import된 패키지가 먼저 초기화된다
1. makeLine() 실행 — 변수 초기화
2. main 패키지의 첫 번째 init
3. main 패키지의 두 번째 init
4. main() 시작 — [packages]
Hello, world!
Hello, 고퍼!
Good day, Dr. Pike!
등록된 변환기: [rot13 upper]
rot13 Uryyb, Tbcure
upper HELLO, GOPHER
없는 이름: registry: "base64"는 등록되지 않았다

정리하면 이렇다.

  1. import된 패키지가 먼저 완전히 초기화된다. 의존성 그래프의 잎에서부터 올라온다.
  2. 한 패키지 안에서는 패키지 변수 → init 함수 순서다.
  3. 패키지 변수끼리는 선언 순서가 아니라 의존 순서다. bannerline보다 위에 선언됐지만 line이 먼저 초기화된다. 컴파일러가 의존 그래프를 보고 정한다.
  4. init은 한 패키지에 여러 개 둘 수 있고, 파일 이름 순 → 파일 안 선언 순으로 돈다. 호출할 수도, 참조할 수도 없다.
  5. 마지막으로 main.main이 불린다.

:::warning 서로 의존하지 않는 패키지 사이의 순서는 정해져 있지 않다 위 출력에서 upperrot13 중 어느 쪽 init이 먼저 도는지는 명세가 보장하지 않는다. 지금 구현이 import 경로 순으로 도는 것뿐이다.

그래서 등록 패턴에서 두 패키지가 같은 이름을 등록하려 들면 어느 쪽이 이길지 알 수 없다. Register가 중복에 패닉을 내도록 만든 이유다 — 비결정적으로 조용히 지는 것보다 항상 시끄럽게 죽는 편이 낫다. :::

init을 언제 쓰는가

거의 안 쓴다. init은 다음 성질을 전부 가진다.

  • 호출자를 찾을 수 없다. 왜 실행됐는지 추적하기 어렵다.
  • 에러를 돌려줄 방법이 없다. 실패하면 패닉밖에 없다.
  • 테스트에서 끄거나 갈아 끼울 수 없다.
  • 순서에 의존하기 시작하면 손쓸 수 없어진다.

그래서 정당한 용도는 사실상 자기 등록(self-registration) 하나다. 그 외에는 명시적인 New.../Init... 함수를 만들고 main에서 호출한다.

// 이렇게 하지 말고
func init() { db = mustConnect(os.Getenv("DSN")) }

// 이렇게 한다
func main() {
db, err := connect(os.Getenv("DSN"))
if err != nil {
log.Fatal(err)
}
// ...
}

패키지 이름 짓기

패키지 이름은 호출 지점에서 읽힌다. greet.Hello(...)처럼 항상 식별자 앞에 붙으므로, 이름 두 개를 한 문장으로 읽어서 자연스러운지가 유일한 기준이다.

규칙

  • 짧고, 전부 소문자, 밑줄과 대소문자 섞기 없음. httputil이지 http_util이나 httpUtil이 아니다.
  • 단수형. user이지 users가 아니다.
  • 말 더듬지 않기(stutter). http.HTTPServer가 아니라 http.Server, bytes.NewBytesBuffer가 아니라 bytes.NewBuffer. 패키지 이름은 이미 접두사다.
  • 디렉터리 이름과 패키지 이름을 맞춘다. 어긋나도 컴파일은 되지만 읽는 사람이 헷갈린다.

피할 이름

안 좋은 이름
util, common, helpers, misc무엇이 들어갈지 아무 제약이 없다. 반드시 쓰레기통이 된다
base, core같은 이유
models, types, interfaces역할이 아니라 문법 범주로 나눈 것이다. 순환 import를 부른다
myapp모듈 경로가 이미 그 일을 한다

util 안티패턴은 6-5에서 순환 import와 함께 다시 다룬다. 표준 라이브러리의 이름들을 보면 기준이 분명하다 — bytes, time, sort, errors, context. 전부 무엇을 다루는지를 말하지 어떻게 생겼는지를 말하지 않는다.

흔히 하는 실수

1. 파일을 나누면 캡슐화된다고 생각한다

같은 디렉터리면 전부 한 패키지다. 격리하려면 디렉터리를 나눠야 한다.

2. 기능을 문법 범주로 쪼갠다

models/ user.go, order.go
services/ user.go, order.go
handlers/ user.go, order.go

이 구조는 "사용자 기능 하나 고치기"가 항상 세 디렉터리를 건드리게 만들고, modelsservices를 필요로 하는 순간 순환 import에 부딪힌다. Go의 관례는 기능 단위로 묶는 것이다 — user/ 하나에 타입·저장소·핸들러를 같이 둔다. 6-6에서 자세히 본다.

3. 전부 대문자로 만든다

일단 공개하면 되돌릴 때 남의 코드가 깨진다. 소문자로 시작하고, 밖에서 필요해질 때 대문자로 올린다. 반대 방향은 비용이 크다.

4. init에 로직을 넣는다

설정을 읽고 DB에 붙는 init은 테스트를 불가능하게 만든다. init은 등록만 한다.

5. 별칭으로 이름을 예쁘게 바꾼다

import str "strings" // 읽는 사람이 매번 위를 확인해야 한다

별칭은 충돌을 해소할 때만 쓴다.

정리

  • 캡슐화 단위는 파일이 아니라 디렉터리다. 한 디렉터리 = 한 패키지, 하위 디렉터리는 별개 패키지, .go가 없는 디렉터리는 패키지가 아니다.
  • 대문자로 시작하면 공개, 소문자면 비공개. 필드·메서드에도 똑같이 적용되고, reflect가 볼 수 있는 것도 대문자뿐이다.
  • import 경로의 마지막 조각과 패키지 이름은 다를 수 있다. go doc으로 확인한다.
  • 안 쓰는 import는 컴파일 에러이고, 점 import는 쓰지 않는다.
  • 블랭크 import _init만 실행시킨다. 드라이버·플러그인 등록의 표준 방식이다.
  • 초기화 순서: import된 패키지 → 패키지 변수(의존 순서) → init(선언 순서) → main. 서로 의존하지 않는 패키지 사이의 순서는 보장되지 않는다.
  • 패키지 이름은 짧은 소문자 단수형, 말 더듬지 않기. util·common·models는 이름이 아니라 미룬 결정이다.

연습문제

  1. codec/ 아래에 reverse 패키지를 추가하고 main.go에 블랭크 import를 한 줄 더한다. 그다음 블랭크 import를 지우고 실행해 보자. 컴파일은 되는가? registry.Names()에는 무엇이 나오는가? 이 동작이 "쓰지 않는 import는 에러"라는 규칙과 어떻게 공존하는지 설명해 보자.

  2. upper와 이름이 같은 "upper"를 등록하는 패키지를 하나 더 만들어 블랭크 import 해 보자. 어떤 메시지로 죽는가? 만약 Register가 패닉 대신 조용히 덮어썼다면 어떤 버그가 생기겠는가?

  3. 5-2Set[T comparable]main에서 꺼내 set 패키지로 옮겨 보자. SortedItemsMapSet은 어디에 두어야 하는가? Setm 필드를 비공개로 유지하면 set 패키지 밖에서 새 연산을 추가할 수 있는가? 없다면, 무엇을 공개해야 가능해지는가 — 그리고 그것을 공개하는 대가는 무엇인가?