sayu.day

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)은 절대 옵션으로 내리지 않기
  • 옵션 함수에서 유효성 검증 수행
  • 기본값은 생성자 내부에서 한 번만 정의

자주 하는 실수

  • 옵션끼리 충돌 규칙이 없음(예: timeout 0 허용)
  • 생성자 외부에서 기본값을 중복 관리
  • 모든 값을 옵션으로 처리해 필수값 계약이 붕괴

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: 최신 버전만 캐시

이중 구조를 쓰면 감사 추적과 조회 성능을 동시에 맞출 수 있습니다.

쓰기 흐름

  1. 최신 버전 조회
  2. 변경 반영한 새 버전 생성 (version + 1)
  3. documents에 insert
  4. current_documents upsert
  5. 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의 핵심은 "무조건 저장"이 아니라 "일관된 버전 생성 규칙"입니다. 쓰기 경로를 단일화하고 트랜잭션으로 묶으면 감사성과 복구성이 크게 좋아집니다.

정리

네 패턴은 서로 다른 층위에 있지만 하나의 원칙으로 연결됩니다. 계약(인터페이스)과 구현(구조체)을 분리하고, 생성 시점의 필수/선택값을 분리하고, 조립 시점의 의존성 그래프를 명시적으로 관리하고, 데이터 변경 이력을 단일 쓰기 경로로 통제하는 것입니다. 각 단계에서 "경계를 흐리지 않는 것"이 공통된 실무 원칙이며, 이를 지키면 서비스 규모가 커져도 설계가 오래 버팁니다.