· 9분 읽기
Go 실전 설계 패턴: 인터페이스, Functional Options, Wire DI, Append-Only 버저닝
Go로 서비스를 설계하다 보면 반복적으로 마주치는 네 가지 결정 지점이 있습니다. 함수의 입출력 타입을 인터페이스로 할지 구조체로 할지, 생성자에 옵션이 늘어날 때 시그니처를 어떻게 지킬지, 의존성 그래프를 런타임에 조립할지 컴파일 타임에 조립할지, 수정 이력이 중요한 데이터를 어떻게 저장할지입니다. 이 글은 API 계약 설계 → 객체 생성 → 의존성 조립 → 데이터 계층이라는 흐름으로 네 가지 패턴을 묶어 정리합니다.
API 계약: Accept Interfaces, Return Structs
Go 인터페이스 설계의 핵심 규칙은 세 가지입니다.
- Accept interfaces: 입력은 추상화에 의존합니다.
- Return structs: 반환은 구체 타입으로 명확히 전달합니다.
- 인터페이스는 구현자가 아니라 소비자 패키지에서 정의합니다.
입력은 인터페이스로
type UserRepo interface {
FindByID(ctx context.Context, id string) (*User, error)
}
type Service struct {
repo UserRepo
}
func NewService(repo UserRepo) *Service {
return &Service{repo: repo}
}
이 구조면 테스트에서 mock 주입이 쉽고 구현체 교체 비용이 작습니다.
반환은 구조체로
func NewPostgresRepo(db *sql.DB) *PostgresRepo {
return &PostgresRepo{db: db}
}
반환 타입을 인터페이스로 감추면 호출자가 실제 기능을 알기 어렵고 타입 추론도 약해집니다.
인터페이스는 작게
type UserFinder interface {
FindByID(ctx context.Context, id string) (*User, error)
}
type UserSaver interface {
Save(ctx context.Context, u *User) error
}
큰 인터페이스는 구현 부담과 결합도를 동시에 키웁니다.
포인터 vs 값 수신자
선택 기준은 다음과 같습니다.
- 상태 변경 필요: 포인터 수신자
- 큰 구조체: 포인터 수신자
- 작은 불변 값: 값 수신자 가능
중요한 점은 혼용 최소화입니다. 타입 하나에서는 가능하면 한 스타일로 통일하세요. 또한 포인터 수신자 메서드만 있으면 값 타입은 인터페이스를 만족하지 못한다는 점도 주의해야 합니다.
type S interface{ String() string }
type T struct{ v string }
func (t *T) String() string { return t.v }
var s S
// s = T{v:"x"} // 불가
s = &T{v: "x"} // 가능
자주 하는 실수
- 인터페이스가 3개 메서드를 넘어가는데 분리 검토를 안 하는 것
- 생성자 반환 타입을 이유 없이 인터페이스로 감추는 것
- 테스트 더블(mock/fake) 구현 난이도가 과도해질 때까지 방치하는 것
인터페이스 설계의 핵심은 "추상화의 위치"입니다. 소비자 기준의 작은 인터페이스와 명확한 구체 반환을 지키면 테스트성과 확장성이 동시에 확보됩니다.
생성: Functional Options로 생성자 안정화
인터페이스 계약이 정해졌다면, 다음 문제는 구현체를 어떻게 생성하느냐입니다. 설정 필드가 늘어날수록 생성자 시그니처는 빠르게 깨집니다. Functional Options는 다음 두 가지를 분리해 이 문제를 줄입니다.
- 필수값: 생성자 인자로 강제
- 선택값:
With...옵션으로 오버라이드
type Config struct {
batchSize int
timeout time.Duration
}
type Option func(*Config)
func WithBatchSize(v int) Option {
return func(c *Config) {
if v > 0 {
c.batchSize = v
}
}
}
func WithTimeout(v time.Duration) Option {
return func(c *Config) {
if v > 0 {
c.timeout = v
}
}
}
func NewWorker(client Client, handler Handler, opts ...Option) *Worker {
cfg := Config{batchSize: 10, timeout: 5 * time.Second}
for _, opt := range opts {
opt(&cfg)
}
return &Worker{client: client, handler: handler, cfg: cfg}
}
실무 규칙
- 필수 의존성(
client,handler)은 절대 옵션으로 내리지 않기 - 옵션 함수에서 유효성 검증 수행
- 기본값은 생성자 내부에서 한 번만 정의
자주 하는 실수
- 옵션끼리 충돌 규칙이 없음(예:
timeout0 허용) - 생성자 외부에서 기본값을 중복 관리
- 모든 값을 옵션으로 처리해 필수값 계약이 붕괴
Functional Options는 "유연한 설정" 패턴이 아니라 "생성자 안정성" 패턴입니다. 필수/선택 경계를 명확히 유지하면 API가 오래 버팁니다.
조립: Wire를 활용한 컴파일 타임 DI
인터페이스와 생성자가 늘어나면 이번엔 이들을 어떻게 조립하느냐가 문제가 됩니다. Wire는 리플렉션 기반 컨테이너가 아니라 코드 생성 기반 DI입니다.
장점
- 컴파일 타임 타입 검증
- 런타임 오버헤드 없음
- 생성 코드가 명시적이라 디버깅이 쉬움
단점
- 초기 설정과 학습 비용
- 그래프가 크면 생성 코드 추적이 번거로울 수 있음
기본 구조
func NewConfig() *Config { ... }
func NewDB(cfg *Config) (*sql.DB, error) { ... }
func NewRepo(db *sql.DB) *Repo { ... }
func NewService(r *Repo) *Service { ... }
//go:build wireinject
func InitializeService() (*Service, error) {
wire.Build(
NewConfig,
NewDB,
NewRepo,
NewService,
)
return nil, nil
}
wire 실행 후 wire_gen.go가 생성됩니다.
Provider Set으로 모듈화
var InfraSet = wire.NewSet(NewConfig, NewDB, NewLogger)
var RepoSet = wire.NewSet(NewRepo)
var ServiceSet = wire.NewSet(NewService)
조립 루트는 다음처럼 구성합니다.
func InitializeApp() (*App, error) {
wire.Build(InfraSet, RepoSet, ServiceSet, NewApp)
return nil, nil
}
인터페이스 바인딩
앞서 정의한 "소비자 기준 인터페이스"를 Wire에 연결하려면 wire.Bind로 구체 타입을 매핑합니다.
type UserRepo interface {
FindByID(ctx context.Context, id string) (*User, error)
}
type userRepo struct { ... }
var RepoSet = wire.NewSet(
NewUserRepo,
wire.Bind(new(UserRepo), new(*userRepo)),
)
운영에서 자주 겪는 문제
- 빌드 태그(
wireinject) 누락으로 생성 파일 충돌 - Provider 함수가 숨은 사이드이펙트를 가져 테스트 격리가 깨짐
- 생성 코드 커밋 정책이 팀마다 달라 CI 불일치
추천 규칙
- DI 조립 코드는
internal/app또는cmd/*/wire.go에 집중 - Provider는 생성 책임만, 환경 검증/실행 로직은 분리
- CI에서
wire재실행 후 diff 검사
Wire의 가치는 "자동 주입"이 아니라 명시적인 의존성 그래프를 안전하게 유지하는 데 있습니다. 규칙만 잘 잡으면 규모가 커질수록 수동 DI보다 유지보수가 쉬워집니다.
데이터: Append-Only 문서 버저닝
앞의 세 패턴이 코드 구조에 관한 것이라면, 마지막은 데이터 계층 사례입니다. 수정 이력을 보존해야 하는 도메인에서는 "update overwrite" 방식이 감사 추적을 깨뜨립니다. Append-Only는 기존 문서를 덮어쓰지 않고 새 버전을 추가합니다.
권장 저장 구조
documents: 모든 버전 스냅샷current_documents: 최신 버전만 캐시
이중 구조를 쓰면 감사 추적과 조회 성능을 동시에 맞출 수 있습니다.
쓰기 흐름
- 최신 버전 조회
- 변경 반영한 새 버전 생성 (
version + 1) documents에 insertcurrent_documentsupsert- audit log 기록
트랜잭션으로 묶어 원자성을 보장합니다.
최소 모델
type Document struct {
URI string
Version int32
Status string // ACTIVE, DELETED
Fields map[string]any
CreatedAt time.Time
UpdatedAt time.Time
}
운영에서 중요한 점
(uri, version)유니크 인덱스 필수- 최신 조회용
(uri, updated_at)인덱스 최적화 - 소프트 삭제도 새 버전으로 표현
- 히스토리 압축/아카이빙 정책 미리 정의
자주 하는 실수
- 최신 컬렉션만 유지하고 히스토리 정합성 검증이 없음
- 버전 생성 로직을 여러 서비스가 중복 구현
- 업데이트 중간에 audit 기록 누락
Append-Only의 핵심은 "무조건 저장"이 아니라 "일관된 버전 생성 규칙"입니다. 쓰기 경로를 단일화하고 트랜잭션으로 묶으면 감사성과 복구성이 크게 좋아집니다.
정리
네 패턴은 서로 다른 층위에 있지만 하나의 원칙으로 연결됩니다. 계약(인터페이스)과 구현(구조체)을 분리하고, 생성 시점의 필수/선택값을 분리하고, 조립 시점의 의존성 그래프를 명시적으로 관리하고, 데이터 변경 이력을 단일 쓰기 경로로 통제하는 것입니다. 각 단계에서 "경계를 흐리지 않는 것"이 공통된 실무 원칙이며, 이를 지키면 서비스 규모가 커져도 설계가 오래 버팁니다.