본문으로 건너뛰기

internal 패키지와 API 경계

이 챕터에서 다루는 것

6-1의 대문자 규칙은 "패키지 안이냐 밖이냐" 두 단계뿐이다. 현실에는 그 중간이 필요하다 — 내 모듈 안에서는 공유하되 밖으로는 안 내보내는 것. internal/이 그 자리를 채운다.

그리고 패키지를 나누기 시작하면 반드시 만나는 벽이 있다. Go는 순환 import를 컴파일 단계에서 거부한다. 그것을 어떻게 푸는지가 이 챕터의 나머지다.

internal/ — 컴파일러가 강제하는 경계

규칙은 한 문장이다.

경로에 internal이라는 디렉터리가 있으면, 그 internal의 부모 디렉터리를 루트로 하는 서브트리 안에서만 import할 수 있다.

internal어디에 있느냐가 범위를 정한다. 이번 예제 모듈이 두 단계를 보여 준다.

05-internal-and-boundaries/ module example.com/boundaries
├── go.mod
├── main.go
├── internal/
│ └── idgen/ ← 모듈 전체에서 보임, 모듈 밖에서는 안 보임
├── user/
│ ├── user.go
│ └── internal/
│ └── valid/ ← user/ 서브트리에서만 보임
└── order/
└── order.go
import하는 곳internal/idgenuser/internal/valid
main.go가능불가
user/user.go가능가능
order/order.go가능불가
이 모듈을 쓰는 다른 모듈불가불가

order에서 user/internal/valid를 import하면 이렇게 끊긴다.

package example.com/boundaries/order
order/order.go:9:2: use of internal package example.com/boundaries/user/internal/valid not allowed

이것은 관례가 아니라 컴파일러 규칙이다. 대문자/소문자와 달리 internal은 패키지 경계를 넘어 작동한다. Go에서 유일한 "패키지보다 큰 단위의 접근 제어"다.

:::info 표준 라이브러리도 이걸 쓴다 net/http의 구현을 뜯어보면 net/http/internal/ascii 같은 것이 잔뜩 나온다. 표준 라이브러리는 하위 호환을 영원히 지켜야 하므로, 공개하지 않아도 되는 것은 전부 internal로 밀어 넣는다. 그래야 나중에 마음대로 고칠 수 있다.

같은 논리가 내 코드에도 적용된다. 공개 API는 되돌릴 수 없는 약속이다. :::

실무 결론

라이브러리를 만든다면 이렇게 시작한다.

  1. 일단 전부 internal/ 아래에 만든다.
  2. 밖에서 정말 필요한 것만 밖으로 꺼낸다.

반대 방향(공개했다가 감추기)은 남의 코드를 깨뜨린다. internal결정을 미루는 비용을 0으로 만들어 주는 장치다.

순환 import는 왜 금지인가

package example.com/cyctest/x
imports example.com/cyctest/y from x.go
imports example.com/cyctest/x from y.go: import cycle not allowed

Java도 Python도 순환 참조를 (고통스럽게나마) 허용한다. Go는 아예 막는다. 이유는 셋이다.

  1. 초기화 순서를 정할 수 없다. 6-1에서 본 "의존 그래프의 잎에서부터 초기화"가 순환이 있으면 성립하지 않는다.
  2. 컴파일 단위를 정할 수 없다. Go는 패키지 단위로 컴파일하고 결과를 캐시한다. 순환이 있으면 무엇을 먼저 컴파일할지 정할 수 없다.
  3. 설계 신호다. 순환은 거의 언제나 "경계를 잘못 그었다"는 뜻이다. 금지가 그것을 즉시 알려 준다.

Go에서 순환 import 에러는 버그 리포트가 아니라 설계 리뷰라고 생각하는 편이 맞다.

순환을 끊는 네 가지 방법

userorder를 예로 든다. orderuser.User가 필요하고, user는 그 사용자의 주문 수를 알고 싶다. 순진하게 쓰면 서로를 import하게 된다.

1. 소비자 쪽에 인터페이스를 둔다 (가장 자주 쓰는 답)

4-3소비자 쪽 정의 원칙이 여기서 순환 해소 도구가 된다.

examples/06-modules-and-layout/05-internal-and-boundaries/user/user.go
// Package user는 사용자를 다룬다.
// order 패키지를 import하지 않는다는 점이 이 예제의 핵심이다.
package user

import (
"fmt"

"example.com/boundaries/internal/idgen"
"example.com/boundaries/user/internal/valid"
)

type User struct {
ID string
Name string
}

// OrderCounter는 "이 사용자의 주문 수를 세는 무언가"다.
// order 패키지가 아니라 여기, 쓰는 쪽에 선언한다.
// 이렇게 하면 user는 order를 import하지 않아도 되고, 순환이 생기지 않는다.
type OrderCounter interface {
CountFor(userID string) int
}

type Service struct {
ids *idgen.Gen
byID map[string]User
orders OrderCounter // nil이어도 동작해야 한다
}

func NewService(orders OrderCounter) *Service {
return &Service{
ids: idgen.New("U"),
byID: make(map[string]User),
orders: orders,
}
}

func (s *Service) Create(name string) (User, error) {
if err := valid.Name(name); err != nil {
return User{}, fmt.Errorf("사용자 생성: %w", err)
}
u := User{ID: s.ids.Next(), Name: name}
s.byID[u.ID] = u
return u, nil
}

func (s *Service) Get(id string) (User, bool) {
u, ok := s.byID[id]
return u, ok
}

// Summary는 order 패키지를 전혀 모른 채 주문 수를 붙여 준다.
func (s *Service) Summary(id string) string {
u, ok := s.byID[id]
if !ok {
return fmt.Sprintf("%s: 없는 사용자", id)
}
if s.orders == nil {
return fmt.Sprintf("%s(%s): 주문 정보 없음", u.Name, u.ID)
}
return fmt.Sprintf("%s(%s): 주문 %d건", u.Name, u.ID, s.orders.CountFor(u.ID))
}

orderuser를 import한다. 한 방향뿐이다.

examples/06-modules-and-layout/05-internal-and-boundaries/order/order.go
// Package order는 주문을 다룬다. user를 import한다 — 방향은 한쪽뿐이다.
package order

import (
"fmt"

"example.com/boundaries/internal/idgen"
"example.com/boundaries/user"
)

// "example.com/boundaries/user/internal/valid"를 import하면 컴파일 에러다:
// use of internal package example.com/boundaries/user/internal/valid not allowed

type Order struct {
ID string
UserID string
Item string
}

type Service struct {
ids *idgen.Gen
orders []Order
}

func NewService() *Service {
return &Service{ids: idgen.New("O")}
}

func (s *Service) Place(u user.User, item string) (Order, error) {
if u.ID == "" {
return Order{}, fmt.Errorf("주문 생성: 사용자 ID가 비어 있다")
}
o := Order{ID: s.ids.Next(), UserID: u.ID, Item: item}
s.orders = append(s.orders, o)
return o, nil
}

// CountFor는 user.OrderCounter를 만족한다.
// order는 그 인터페이스의 존재를 몰라도 된다 — 암묵적 구현이기 때문이다.
func (s *Service) CountFor(userID string) int {
n := 0
for _, o := range s.orders {
if o.UserID == userID {
n++
}
}
return n
}

orderuser.OrderCounter라는 이름을 한 번도 쓰지 않는다. 암묵적 구현이기 때문이다. 다른 언어라면 implements OrderCounter라고 적어야 하고, 그 순간 order → user가 아니라 order → interface 정의 패키지 의존이 생겨 다시 순환을 걱정해야 한다. Go의 암묵적 구현이 순환 해소를 구조적으로 쉽게 만든다.

배선은 main이 한다.

examples/06-modules-and-layout/05-internal-and-boundaries/main.go
package main

import (
"fmt"

"example.com/boundaries/internal/idgen"
"example.com/boundaries/order"
"example.com/boundaries/user"
)

func main() {
// main이 두 서비스를 만들고 연결한다.
// user는 order를 모르고, order는 user.OrderCounter를 모른다.
// 배선은 전부 여기서 일어난다.
orders := order.NewService()
users := user.NewService(orders)

alice, err := users.Create("Alice")
if err != nil {
fmt.Println("에러:", err)
return
}
bob, err := users.Create("Bob")
if err != nil {
fmt.Println("에러:", err)
return
}

for _, item := range []string{"키보드", "모니터", "책상"} {
if _, err := orders.Place(alice, item); err != nil {
fmt.Println("에러:", err)
return
}
}
if _, err := orders.Place(bob, "의자"); err != nil {
fmt.Println("에러:", err)
return
}

fmt.Println(users.Summary(alice.ID))
fmt.Println(users.Summary(bob.ID))
fmt.Println(users.Summary("U-999"))

// 검증 실패 경로. valid 패키지는 user 서브트리 안에 숨어 있다.
if _, err := users.Create(" "); err != nil {
fmt.Println("검증 실패:", err)
}

// orders 없이도 user는 동작한다. 인터페이스를 쓰는 쪽에 두면
// 구현을 안 넘겨도 되는 설계가 자연스럽게 나온다.
standalone := user.NewService(nil)
carol, err := standalone.Create("Carol")
if err != nil {
fmt.Println("에러:", err)
return
}
fmt.Println(standalone.Summary(carol.ID))

// internal/idgen은 이 모듈 안이므로 main에서도 쓸 수 있다.
g := idgen.New("X")
fmt.Println("직접 만든 ID:", g.Next(), g.Next())
}
cd examples/06-modules-and-layout/05-internal-and-boundaries
go run .
Alice(U-001): 주문 3건
Bob(U-002): 주문 1건
U-999: 없는 사용자
검증 실패: 사용자 생성: 이름이 비어 있다
Carol(U-001): 주문 정보 없음
직접 만든 ID: X-001 X-002

CarolU-001인 것을 보라. standalone은 별도의 idgen.Gen을 가진 별개 서비스다. 패키지 수준 전역 상태를 쓰지 않았기 때문에 두 서비스가 서로를 간섭하지 않는다.

2. 공통 타입을 아래로 내린다

두 패키지가 같은 타입을 주고받아야 해서 순환이 난다면, 그 타입만 둘 다 의존하는 제3의 패키지로 뺀다.

before: user ⇄ order
after: user → model ← order

주의할 점은 이 패키지가 model이나 types라는 이름의 쓰레기통이 되기 쉽다는 것이다. 아래 "util 안티패턴"에서 다시 본다. 정말 도메인 개념이면 도메인 이름을 붙인다 — money, event, catalog.

3. 콜백 함수를 받는다

인터페이스가 메서드 하나뿐이면 함수 타입이 더 가볍다.

type Service struct {
countOrders func(userID string) int
}

호출부가 user.NewService(orders.CountFor)로 메서드 값을 그대로 넘긴다. 구현이 하나뿐일 때 4-5의 "인터페이스 오염" 경고를 피하는 방법이다.

4. 합친다

userorder가 정말 서로를 계속 필요로 한다면, 애초에 한 패키지여야 했던 것일 수 있다. 순환을 억지로 끊으려고 인터페이스를 세 개 만들 바에는 한 패키지로 합치는 편이 낫다. Go의 패키지는 큰 것이 정상이다 — net/http는 파일이 수십 개다.

언제 패키지를 쪼개는가

나누는 것 자체에는 아무 가치가 없다. 기준은 하나다.

이 코드를 따로 놓았을 때 경계가 생기는가?

쪼갤 이유가 되는 것.

  • 공개 API 표면을 줄인다. 헬퍼를 internal/로 밀어 넣는다.
  • 의존성을 격리한다. DB 드라이버를 쓰는 코드가 한 패키지에만 있으면, 나머지는 그 무거운 의존성 없이 컴파일된다.
  • 재사용 단위가 다르다. 실제로 다른 곳에서도 쓰는 것.
  • 순환을 끊어야 한다.

쪼갤 이유가 안 되는 것.

  • 파일이 길어서. → 같은 패키지 안에서 파일을 나눈다. 비용이 0이다.
  • "계층별로 나누는 게 깔끔해서". → 아래 참고.
  • 타입과 함수를 분리하고 싶어서.

:::tip 파일을 나누는 것은 공짜, 패키지를 나누는 것은 아니다 6-1에서 본 대로 같은 디렉터리의 파일은 서로를 전부 본다. user.go가 800줄이 됐다면 user_create.go, user_query.go로 나누면 그만이다. 패키지를 나누는 순간 공개 API를 설계해야 하고, 그것은 되돌리기 어렵다. :::

util 안티패턴

util/
├── string.go
├── time.go
├── db.go
└── http.go

이 디렉터리는 반드시 다음 세 가지로 끝난다.

  1. 아무거나 들어간다. util이라는 이름은 아무것도 배제하지 않는다. 무엇을 넣을지 고민할 필요가 없으니 전부 여기로 온다.
  2. 모두가 import한다. 그래서 util은 아무것도 import할 수 없게 된다 — 무엇을 import하는 순간 순환이 나기 때문이다.
  3. util.Process(x)가 무슨 뜻인지 아무도 모른다. 호출 지점에서 패키지 이름이 정보를 전혀 주지 않는다.

models, types, common, base, helpers, shared도 전부 같은 병이다. 역할이 아니라 문법 범주나 추상적 형용사로 이름을 지으면 경계가 생기지 않는다.

고치는 법

함수를 쓰는 곳으로 옮긴다. 하나만 쓴다면 그 패키지 안에 소문자로 둔다.

정말 공통이면 무엇에 대한 것인지로 이름을 짓는다.

대신이렇게
util.FormatDurationhumanize.Duration
util.RetryHTTPretry.Do 또는 httpx.Retry
models.Useruser.User
common.Configconfig.Config

표준 라이브러리에 util이 없다는 것이 답이다. strings, strconv, path/filepath, net/url — 전부 무엇을 다루는지가 이름이다.

:::note xinternal/xxx 접미사 httpx, sqlx처럼 표준 패키지 이름에 x를 붙이는 관례가 있다. 표준 라이브러리를 감싸는 확장이라는 뜻이고, 이름 충돌도 피한다. util보다는 낫지만 이것도 커지면 같은 병에 걸린다. 정기적으로 들여다보고 쪼갠다. :::

공개 API를 최소로 유지하는 실전 규칙

  1. 소문자로 시작하고, 필요할 때 올린다.
  2. 구조체 필드를 함부로 공개하지 않는다. 공개하면 불변식을 강제할 수 없다. Set/Get이 필요 없으면 그냥 공개해도 되지만, 생성자에서 검증한 값이라면 닫는다.
  3. 생성자는 구체 타입을 반환한다. 4-5의 "구조체를 반환하고 인터페이스를 수용한다" 그대로다.
  4. internal/을 기본값으로 쓴다.
  5. go doc ./...으로 공개 표면을 정기적으로 읽는다. 의도하지 않은 것이 새어 나가 있는지 눈으로 확인하는 가장 싼 방법이다.

흔히 하는 실수

1. internal을 관례로 안다

컴파일러가 강제한다. 실수로 import할 수가 없다.

2. 모든 것을 루트 internal/ 하나에 넣는다

internal/이 20개 패키지를 담고 있으면 그 안에서는 다시 경계가 없다. 경계가 필요한 곳마다 internal을 둔다. user/internal/valid처럼.

3. 순환을 interface{}나 리플렉션으로 피한다

타입 안전성을 버려 가며 잘못된 설계를 유지하는 것이다. 순환은 끊어야 할 신호다.

4. 인터페이스를 구현 쪽 패키지에 둔다

order 패키지에 OrderCounter를 두면 user → order 의존이 생겨 순환이 돌아온다. 쓰는 쪽에 둔다.

5. 계층별로 패키지를 나눈다

models/ services/ handlers/는 기능 하나를 고칠 때마다 세 디렉터리를 건드리게 하고, models가 무언가를 필요로 하는 순간 순환에 부딪힌다. 6-6에서 대안을 본다.

6. util을 만든다

이름을 지을 수 없다면 아직 그 패키지가 무엇인지 모른다는 뜻이다. 일단 쓰는 곳에 둔다.

정리

  • internal/은 컴파일러가 강제하는 경계다. internal부모 디렉터리 서브트리 안에서만 import할 수 있고, 어디에 두느냐로 범위를 조절한다.
  • 라이브러리는 전부 internal/에서 시작해서 필요한 것만 꺼낸다. 공개는 되돌릴 수 없는 약속이다.
  • 순환 import는 컴파일 에러다. 초기화 순서와 컴파일 단위를 정할 수 없기 때문이고, 동시에 설계가 틀렸다는 신호다.
  • 끊는 방법은 소비자 쪽 인터페이스(기본), 공통 타입을 아래로, 콜백 함수, 그리고 합치기. 암묵적 구현 덕에 Go에서는 첫 번째가 특히 깔끔하다.
  • 파일을 나누는 것은 공짜, 패키지를 나누는 것은 API 설계다. 경계가 생기지 않으면 나누지 않는다.
  • util·common·models는 이름이 아니라 미룬 결정이다. 모두가 import하고 아무것도 import하지 못하는 패키지가 된다.

연습문제

  1. order 패키지에서 example.com/boundaries/user/internal/valid를 import해 보고 에러를 확인하자. 그다음 validuser/internal/에서 루트 internal/로 옮기면 어떻게 되는가? 그 이동이 무엇을 잃게 하는지 한 문장으로 설명해 보자.

  2. user.OrderCounterorder 패키지로 옮겨서 순환을 만들어 보자. 에러 메시지에 어떤 경로가 찍히는가? 그다음 콜백 함수 방식(func(string) int)으로 다시 끊어 보고, 인터페이스 방식과 비교해 각각 어떤 상황에서 나은지 정리해 보자.

  3. 5-2Set[T comparable]을 이 모듈에 넣는다면 어디에 두겠는가 — 루트의 set/인가, internal/set/인가? userorder가 둘 다 쓴다면? 그리고 나중에 이 모듈을 라이브러리로 배포하게 됐을 때 두 선택의 차이는 무엇인가?