Go의 의존성 주입: 패턴과 모범 사례

테스트 가능한 Go 코드를 위한 DI 패턴 마스터하기

Page content

의존성 주입 (DI)은 Go 애플리케이션에서 깨끗하고, 테스트 가능하며, 유지보수하기 쉬운 코드를 촉진하는 근본적인 설계 패턴입니다.

REST API를 구축하거나, 멀티 테넌트 데이터베이스 패턴을 구현하거나, ORM 라이브러리를 사용하는 경우, 의존성 주입을 이해하면 코드 품질을 크게 향상시킬 수 있습니다.

go-dependency-injection

의존성 주입(Dependency Injection)이란?

의존성 주입은 컴포넌트가 내부에서 의존성을 생성하는 대신 외부 소스로부터 의존성을 받는 설계 패턴입니다. 이 접근 방식은 컴포넌트를 분리하여 코드를 더 모듈화하고, 테스트하며, 유지보수하기 쉽게 만듭니다.

Go에서는 언어의 인터페이스 기반 설계 철학 때문에 의존성 주입이 특히 강력합니다. Go의 암묵적 인터페이스 만족(Implicit interface satisfaction)은 기존 코드를 수정하지 않고도 구현을 쉽게 교체할 수 있음을 의미합니다.

Go에서 의존성 주입을 사용하는 이유?

테스트 가능성 향상: 의존성을 주입함으로써 실제 구현을 모크(Mock)나 테스트 더블(Test Double)로 쉽게 교체할 수 있습니다. 이를 통해 데이터베이스나 API와 같은 외부 서비스가 필요하지 않고 빠르고 격리된 단위 테스트를 작성할 수 있습니다.

유지보수성 개선: 코드에서 의존성이 명시적으로 드러납니다. 생성자 함수를 볼 때 컴포넌트가 무엇을 필요로 하는지 즉시 알 수 있습니다. 이는 코드베이스를 이해하고 수정하기 쉽게 만듭니다.

느슨한 결합(Loose Coupling): 컴포넌트는 구체적인 구현이 아닌 추상화(인터페이스)에 의존합니다. 이는 의존성 코드를 영향주지 않고 구현을 변경할 수 있음을 의미합니다.

유연성: 비즈니스 로직을 변경하지 않고도 다른 환경(개발, 테스트, 프로덕션)에 대해 다른 구현을 구성할 수 있습니다.

생성자 주입: Go 스타일

Go에서 의존성 주입을 구현하는 가장 일반적이고 관례적인 방법은 생성자 함수를 통한 것입니다. 이들은 일반적으로 NewXxx로 이름 지어지며 의존성을 매개변수로 받습니다.

기본 예제

다음은 생성자 주입을 보여주는 간단한 예제입니다:

// 리포지토리용 인터페이스 정의
type UserRepository interface {
    FindByID(id int) (*User, error)
    Save(user *User) error
}

// 서비스는 리포지토리 인터페이스에 의존
type UserService struct {
    repo UserRepository
}

// 생성자가 의존성을 주입
func NewUserService(repo UserRepository) *UserService {
    return &UserService{repo: repo}
}

// 메서드는 주입된 의존성을 사용
func (s *UserService) GetUser(id int) (*User, error) {
    return s.repo.FindByID(id)
}

이 패턴은 UserServiceUserRepository를 필요로 한다는 것을 명확히 합니다. 리포지토리를 제공하지 않고서는 UserService를 생성할 수 없으므로, 누락된 의존성으로 인한 런타임 오류를 방지합니다.

다중 의존성

컴포넌트에 여러 의존성이 있는 경우, 단순히 생성자 매개변수로 추가합니다:

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, Dependency Inversion Principle)**입니다: 고수준 모듈은 저수준 모듈에 의존해서는 안 되며, 둘 다 추상화에 의존해야 합니다.

Go에서는 컴포넌트가 필요로 하는 것만 나타내는 작고 집중된 인터페이스를 정의하는 것을 의미합니다:

// 좋음: 작고 집중된 인터페이스
type PaymentProcessor interface {
    ProcessPayment(amount float64) error
}

// 나쁨: 불필요한 메서드가 포함된 큰 인터페이스
type PaymentService interface {
    ProcessPayment(amount float64) error
    RefundPayment(id string) error
    GetPaymentHistory(userID int) ([]Payment, error)
    UpdatePaymentMethod(userID int, method PaymentMethod) error
    // ... 더 많은 메서드들
}

더 작은 인터페이스는 **인터페이스 분리 원칙(ISP, Interface Segregation Principle)**을 따릅니다—클라이언트는 사용하지 않는 메서드에 의존해서는 안 됩니다. 이는 코드를 더 유연하고 테스트하기 쉽게 만듭니다.

실습 예제: 데이터베이스 추상화

Go 애플리케이션에서 데이터베이스를 다룰 때, 종종 데이터베이스 작업을 추상화해야 합니다. 의존성 주입이 어떻게 도움이 되는지 살펴보겠습니다:

// 데이터베이스 인터페이스 - 고수준 추상화
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)
}

// 리포지토리는 추상화에 의존
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()
    
    // ... rows 파싱
}

이 패턴은 서로 다른 데이터베이스 구현이나 연결 전략 사이를 전환해야 할 때 특히 유용하며, 멀티 테넌트 데이터베이스 패턴 구현 시에 유용합니다.

컴포지션 루트(Composition Root) 패턴

컴포지션 루트는 애플리케이션의 진입점(일반적으로 main)에서 모든 의존성을 조립하는 곳입니다. 이는 의존성 구성을 중앙화하고 의존성 그래프를 명시적으로 만듭니다.

func main() {
    // 인프라 의존성 초기화
    db := initDatabase()
    logger := initLogger()
    
    // 리포지토리 초기화
    userRepo := NewUserRepository(db)
    orderRepo := NewOrderRepository(db)
    
    // 의존성과 함께 서비스 초기화
    emailSvc := NewEmailService(logger)
    paymentSvc := NewPaymentService(logger)
    userSvc := NewUserService(userRepo, logger)
    orderSvc := NewOrderService(orderRepo, emailSvc, logger, paymentSvc)
    
    // HTTP 핸들러 초기화
    userHandler := NewUserHandler(userSvc)
    orderHandler := NewOrderHandler(orderSvc)
    
    // 라우트 연결
    router := setupRouter(userHandler, orderHandler)
    
    // 서버 시작
    log.Fatal(http.ListenAndServe(":8080", router))
}

이 접근 방식은 애플리케이션이 어떻게 구조화되어 있고 의존성이 어디서 오는지 명확히 보여줍니다. 여러 계층의 의존성을 조정해야 하는 Go의 REST API 구축 시 특히 가치 있습니다.

의존성 주입 프레임워크

복잡한 의존성 그래프가 있는 대규모 애플리케이션의 경우, 의존성을 수동으로 관리하는 것은 번거로울 수 있습니다. Go에는 도움이 될 수 있는 몇 가지 DI 프레임워크가 있습니다:

Google Wire (컴파일 타임 DI)

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
}

Wire는 컴파일 타임에 의존성 주입 코드를 생성하여 타입 안전성을 보장하고 런타임 리플렉션 오버헤드를 제거합니다.

Uber Dig (런타임 DI)

Dig는 리플렉션을 사용하는 런타임 의존성 주입 프레임워크입니다. 더 유연하지만 일부 런타임 비용이 발생합니다.

예제:

import "go.uber.org/dig"

func main() {
    container := dig.New()
    
    // 프로바이더 등록
    container.Provide(NewDB)
    container.Provide(NewUserRepository)
    container.Provide(NewUserService)
    container.Provide(NewUserHandler)
    
    // 의존성이 필요한 함수 호출
    err := container.Invoke(func(handler *UserHandler) {
        // 핸들러 사용
    })
    if err != nil {
        log.Fatal(err)
    }
}

프레임워크를 언제 사용해야 할까요?

프레임워크를 사용하는 경우:

  • 서로 의존하는 많은 컴포넌트가 있는 복잡한 의존성 그래프가 있을 때
  • 구성에 따라 선택해야 하는 동일한 인터페이스의 여러 구현이 있을 때
  • 자동 의존성 분해가 필요할 때
  • 수동 와이링이 오류가 발생하기 쉬운 대규모 애플리케이션을 구축할 때

수동 DI를 유지하는 경우:

  • 애플리케이션이 소규모 또는 중규모일 때
  • 의존성 그래프가 단순하고 이해하기 쉬울 때
  • 의존성을 최소하고 명시적으로 유지하고 싶을 때
  • 생성된 코드보다 명시적인 코드를 선호할 때

의존성 주입을 통한 테스트

의존성 주입의 주요 이점 중 하나는 테스트 가능성의 향상입니다. DI가 테스트를 어떻게 쉽게 만드는지 살펴보겠습니다:

단위 테스트 예제

// 테스트용 모크 구현
type mockUserRepository struct {
    users map[int]*User
    err   error
}

func (m *mockUserRepository) FindByID(id int) (*User, error) {
    if m.err != nil {
        return nil, m.err
    }
    return m.users[id], nil
}

func (m *mockUserRepository) Save(user *User) error {
    if m.err != nil {
        return m.err
    }
    m.users[user.ID] = user
    return nil
}

// 모크를 사용한 테스트
func TestUserService_GetUser(t *testing.T) {
    mockRepo := &mockUserRepository{
        users: map[int]*User{
            1: {ID: 1, Name: "John", Email: "john@example.com"},
        },
    }
    
    service := NewUserService(mockRepo)
    
    user, err := service.GetUser(1)
    assert.NoError(t, err)
    assert.Equal(t, "John", user.Name)
}

이 테스트는 빠르게 실행되며 데이터베이스가 필요하지 않고 비즈니스 로직을 격리하여 테스트합니다. Go의 ORM 라이브러리와 작업할 때, 데이터베이스 설정 없이 서비스 로직을 테스트하기 위해 모크 리포지토리를 주입할 수 있습니다.

일반적인 패턴 및 모범 사례

1. 인터페이스 분리 사용

클라이언트가 실제로 필요로 하는 것에 집중하여 인터페이스를 작고 집중되게 유지하세요:

// 좋음: 클라이언트는 사용자 읽기만 필요
type UserReader interface {
    FindByID(id int) (*User, error)
    FindByEmail(email string) (*User, error)
}

// 쓰기를 위한 별도의 인터페이스
type UserWriter interface {
    Save(user *User) error
    Delete(id int) error
}

2. 생성자에서 에러 반환

생성자는 의존성을 검증하고 초기화 실패 시 에러를 반환해야 합니다:

func NewUserService(repo UserRepository) (*UserService, error) {
    if repo == nil {
        return nil, errors.New("user repository cannot be nil")
    }
    return &UserService{repo: repo}, nil
}

3. 요청 범위 의존성을 위해 Context 사용

데이터베이스 트랜잭션과 같이 요청 특정한 의존성의 경우, 컨텍스트를 통해 전달하세요:

type ctxKey string

const dbKey ctxKey = "db"

func WithDB(ctx context.Context, db DB) context.Context {
    return context.WithValue(ctx, dbKey, db)
}

func DBFromContext(ctx context.Context) (DB, bool) {
    db, ok := ctx.Value(dbKey).(DB)
    return db, ok
}

4. 과도한 주입 피하기

진정으로 내부 구현 세부 사항인 의존성은 주입하지 마세요. 컴포넌트가 자체 헬퍼 객체를 생성하고 관리한다면 괜찮습니다:

// 좋음: 내부 헬퍼는 주입이 필요 없음
type UserService struct {
    repo UserRepository
    // 내부 캐시 - 주입 불필요
    cache map[int]*User
}

func NewUserService(repo UserRepository) *UserService {
    return &UserService{
        repo:  repo,
        cache: make(map[int]*User),
    }
}

5. 의존성 문서화

주석을 사용하여 의존성이 필요한 이유와 모든 제약 사항을 문서화하세요:

// UserService는 사용자 관련 비즈니스 로직을 처리합니다.
// 데이터 접근을 위해 UserRepository와 에러 추적을 위해 Logger가 필요합니다.
// 리포지토리는 동시 컨텍스트에서 사용되는 경우 스레드 안전해야 합니다.
func NewUserService(repo UserRepository, logger Logger) *UserService {
    // ...
}

의존성 주입을 사용하지 말아야 할 때

의존성 주입은 강력한 도구이지만, 항상 필요한 것은 아닙니다:

DI를 건너뛰는 경우:

  • 단순한 값 객체 또는 데이터 구조
  • 내부 헬퍼 함수 또는 유틸리티
  • 일회성 스크립트 또는 소형 유틸리티
  • 직접 인스턴스화가 더 명확하고 단순할 때

DI를 사용하지 않아야 할 예제:

// 단순 구조체 - DI 불필요
type Point struct {
    X, Y float64
}

func NewPoint(x, y float64) Point {
    return Point{X: x, Y: y}
}

// 단순 유틸리티 - DI 불필요
func FormatCurrency(amount float64) string {
    return fmt.Sprintf("$%.2f", amount)
}

Go 생태계와의 통합

의존성 주입은 다른 Go 패턴 및 도구와 원활하게 작동합니다. Go의 표준 라이브러리를 사용한 웹 스크레이핑 또는 PDF 보고서 생성을 사용하는 애플리케이션을 구축할 때, 이러한 서비스를 비즈니스 로직에 주입할 수 있습니다:

type PDFGenerator interface {
    GenerateReport(data ReportData) ([]byte, error)
}

type ReportService struct {
    pdfGen PDFGenerator
    repo   ReportRepository
}

func NewReportService(pdfGen PDFGenerator, repo ReportRepository) *ReportService {
    return &ReportService{
        pdfGen: pdfGen,
        repo:   repo,
    }
}

이를 통해 PDF 생성 구현을 교체하거나 테스트 중에 모크를 사용할 수 있습니다.

결론

의존성 주입은 유지보수 가능하고 테스트 가능한 Go 코드를 작성하는 핵심 요소입니다. 이 기사에서 설명한 패턴—생성자 주입, 인터페이스 기반 설계, 컴포지션 루트 패턴—을 따르면 이해하고, 테스트하고, 수정하기 쉬운 애플리케이션을 만들 수 있습니다.

소규모 및 중규모 애플리케이션에는 수동 생성자 주입으로 시작하고, 의존성 그래프가 성장함에 따라 Wire 또는 Dig와 같은 프레임워크를 고려하십시오. 목표는 복잡성 그 자체가 아니라 명확성과 테스트 가능성이라는 점을 기억하십시오. 애플리케이션 구조를 명령 및 쿼리 핸들러 중심으로 구축하는 경우, Go에서 CQRS 구현은 동일한 생성자 주입 접근 방식이 Application, Commands, Queries 구조체를 어떻게 깔끔하고 번거로움 없이 연결하는지 보여줍니다.

더 많은 Go 개발 리소스는 빠른 Go 구문 및 일반적인 패턴 참조를 위한 Go 치트시트를 확인하십시오.

유용한 링크

외부 리소스

구독하기

시스템, 인프라, AI 엔지니어링에 관한 새 글을 받아보세요.