Go에서 의존성 주입: 패턴 및 모범 사례
Source: Dev.to
소개
Dependency Injection(DI)은 Go 애플리케이션에서 깨끗하고 테스트 가능하며 유지보수가 쉬운 코드를 촉진하는 기본 디자인 패턴입니다. 내부에서 직접 생성하지 않고 외부 소스로부터 의존성을 받아들임으로써 컴포넌트는 결합도가 낮아지고 모듈화되며 다루기 쉬워집니다.
Dependency Injection의 장점
- 테스트 용이성 향상 – 실제 구현을 목(mock)이나 테스트 더블로 교체하여 외부 서비스 없이 빠르고 격리된 단위 테스트를 수행할 수 있습니다.
- 유지보수성 향상 – 의존성이 생성자 함수에 명시적으로 드러나므로 컴포넌트가 무엇을 필요로 하는지 명확합니다.
- 느슨한 결합 – 컴포넌트는 구체 타입이 아닌 추상화(인터페이스)에 의존하므로 구현을 바꾸어도 의존자에 영향을 주지 않습니다.
- 유연성 – 비즈니스 로직을 변경하지 않고도 개발, 테스트, 프로덕션 환경에 맞는 서로 다른 구현을 구성할 수 있습니다.
생성자 주입 (Idiomatic Go)
Go에서 DI를 구현하는 가장 일반적인 방법은 NewXxx와 같이 명명된 생성자 함수를 통해 의존성을 매개변수로 받는 것입니다.
// Define an interface for the repository
type UserRepository interface {
FindByID(id int) (*User, error)
Save(user *User) error
}
// Service depends on the repository interface
type UserService struct {
repo UserRepository
}
// Constructor injects the dependency
func NewUserService(repo UserRepository) *UserService {
return &UserService{repo: repo}
}
// Methods use the injected dependency
func (s *UserService) GetUser(id int) (*User, error) {
return s.repo.FindByID(id)
}
다중 의존성
컴포넌트가 여러 협력자를 필요로 할 때는 모든 협력자를 생성자 매개변수로 나열합니다:
type EmailService interface {
Send(to, subject, body string) error
}
type Logger interface {
Info(msg string)
Error(msg string, err error)
}
type OrderService struct {
repo OrderRepository
emailSvc EmailService
logger Logger
paymentSvc PaymentService
}
func NewOrderService(
repo OrderRepository,
emailSvc EmailService,
logger Logger,
paymentSvc PaymentService,
) *OrderService {
return &OrderService{
repo: repo,
emailSvc: emailSvc,
logger: logger,
paymentSvc: paymentSvc,
}
}
의존성 역전 원칙(DIP) 및 인터페이스 분리 원칙
고수준 모듈은 구체 구현이 아니라 추상화에 의존해야 합니다. Go에서는 작고 집중된 인터페이스를 정의하는 것이 이에 해당합니다.
// Good: Small, focused interface
type PaymentProcessor interface {
ProcessPayment(amount float64) error
}
// Bad: Large interface with unnecessary methods
type PaymentService interface {
ProcessPayment(amount float64) error
RefundPayment(id string) error
GetPaymentHistory(userID int) ([]Payment, error)
UpdatePaymentMethod(userID int, method PaymentMethod) error
// ... many more methods
}
좁은 인터페이스는 인터페이스 분리 원칙을 따르며, 클라이언트가 사용하지 않는 메서드에 의존하지 않게 합니다.
데이터베이스 추상화 예시
// Database interface – high‑level abstraction
type DB interface {
Query(ctx context.Context, query string, args ...interface{}) (Rows, error)
Exec(ctx context.Context, query string, args ...interface{}) (Result, error)
BeginTx(ctx context.Context) (Tx, error)
}
// Repository depends on the abstraction
type UserRepository struct {
db DB
}
func NewUserRepository(db DB) *UserRepository {
return &UserRepository{db: db}
}
func (r *UserRepository) FindByID(ctx context.Context, id int) (*User, error) {
query := "SELECT id, name, email FROM users WHERE id = $1"
rows, err := r.db.Query(ctx, query, id)
if err != nil {
return nil, err
}
defer rows.Close()
// ... parse rows
return nil, nil
}
이 패턴은 멀티 테넌트 데이터베이스이거나 서로 다른 드라이버 간 전환이 필요할 때 특히 유용합니다.
Composition Root
Composition Root는 전체 객체 그래프가 조립되는 단일 지점(보통 main)을 의미합니다:
func main() {
// Initialize infrastructure dependencies
db := initDatabase()
logger := initLogger()
// Initialize repositories
userRepo := NewUserRepository(db)
orderRepo := NewOrderRepository(db)
// Initialize services with dependencies
emailSvc := NewEmailService(logger)
paymentSvc := NewPaymentService(logger)
userSvc := NewUserService(userRepo, logger)
orderSvc := NewOrderService(orderRepo, emailSvc, logger, paymentSvc)
// Initialize HTTP handlers
userHandler := NewUserHandler(userSvc)
orderHandler := NewOrderHandler(orderSvc)
// Wire up routes
router := setupRouter(userHandler, orderHandler)
// Start server
log.Fatal(http.ListenAndServe(":8080", router))
}
이렇게 하면 의존성 그래프가 명시적으로 드러나며, 애플리케이션 구조를 이해하기 쉬워집니다. 이는 REST API 및 기타 계층형 시스템에 특히 가치가 있습니다.
대규모 프로젝트를 위한 DI 프레임워크
Wire (컴파일 타임)
Wire는 컴파일 시 타입‑안전 코드를 생성해 런타임 리플렉션 오버헤드를 없앱니다.
go install github.com/google/wire/cmd/wire@latest
// wire.go
//go:build wireinject
// +build wireinject
package main
import "github.com/google/wire"
func InitializeApp() (*App, error) {
wire.Build(
NewDB,
NewUserRepository,
NewUserService,
NewUserHandler,
NewApp,
)
return &App{}, nil
}
Dig (런타임)
Dig는 리플렉션을 사용해 런타임에 의존성을 해결하므로 유연성이 높지만 약간의 오버헤드가 발생합니다.
import "go.uber.org/dig"
func main() {
container := dig.New()
// Register providers
container.Provide(NewDB)
container.Provide(NewUserRepository)
container.Provide(NewUserService)
container.Provide(NewUserHandler)
// Invoke a function that needs dependencies
err := container.Invoke(func(handler *UserHandler) {
// Use handler
})
if err != nil {
log.Fatal(err)
}
}
프레임워크 사용 vs. 수동 DI 선택 시점
-
프레임워크를 사용할 때
- 의존성 그래프가 복잡하고 상호 의존 컴포넌트가 많을 경우.
- 동일 인터페이스의 여러 구현을 설정에 따라 선택해야 할 경우.
- 자동 해석이 보일러플레이트와 오류 가능성을 크게 줄여줄 때.
- 수동 와이어링이 부담스러워지는 대규모 애플리케이션을 구축할 때.
-
수동 DI를 고수할 때
- 애플리케이션 규모가 작거나 중간 정도이며 그래프가 단순할 때.
- 추가 의존성 없이 명시적인 와이어링을 선호할 때.