Go에서 의존성 주입: 패턴 및 최고의 실천 방법

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

Page content

의존성 주입](https://www.glukhov.org/ko/post/2025/12/dependency-injection-in-go/ “Go에서의 의존성 주입”) (DI)는 Go 애플리케이션에서 깔끔하고 테스트 가능하며 유지보수가 쉬운 코드를 촉진하는 근본적인 설계 패턴입니다.

REST API](https://www.glukhov.org/ko/post/2025/11/implementing-api-in-go/ “Go에서의 REST API 구현: 완전 가이드”), 다중 테넌트 데이터베이스 패턴, 또는 ORM 라이브러리와 함께 작업하든 간에, 의존성 주입을 이해하면 코드 품질을 크게 향상시킬 수 있습니다.

go-dependency-injection

의존성 주입이란?

의존성 주입은 구성 요소가 외부 소스에서 의존성을 받는 설계 패턴입니다. 내부적으로 의존성을 생성하는 대신 외부에서 의존성을 주입합니다. 이 접근 방식은 구성 요소를 분리하여 코드를 더 모듈화하고 테스트 가능하며 유지보수가 쉬워집니다.

Go에서는 특히 인터페이스 기반의 설계 철학 덕분에 의존성 주입이 특히 강력합니다. Go의 암시적 인터페이스 충족은 기존 코드를 수정하지 않고 구현을 쉽게 교체할 수 있도록 합니다.

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

테스트 가능성 향상: 의존성을 주입하면 실제 구현을 모크 또는 테스트 더블로 쉽게 교체할 수 있습니다. 이는 데이터베이스 또는 API와 같은 외부 서비스를 사용하지 않고도 빠르고 고립된 단위 테스트를 작성할 수 있게 해줍니다.

유지보수성 향상: 의존성은 코드에서 명확해집니다. 생성자 함수를 보면 구성 요소가 무엇을 필요로 하는지 즉시 알 수 있습니다. 이는 코드베이스를 더 쉽게 이해하고 수정할 수 있게 합니다.

느슨한 결합: 구성 요소는 구체적인 구현이 아닌 추상화(인터페이스)에 의존합니다. 이는 구현을 변경하더라도 의존 코드에 영향을 주지 않습니다.

유연성: 개발, 테스트, 프로덕션 환경에 따라 다른 구현을 구성할 수 있습니다. 이는 비즈니스 로직을 변경하지 않고도 가능합니다.

생성자 주입: 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,
    }
}

의존성 주입을 위한 인터페이스 설계

의존성 주입을 구현할 때의 핵심 원칙 중 하나는 **의존성 역전 원칙(Dependency Inversion Principle, DIP)**입니다: 고위 모듈은 저위 모듈에 의존해서는 안 되며, 모두 추상화(인터페이스)에 의존해야 합니다.

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
    // ... 많은 다른 메서드
}

작은 인터페이스는 인터페이스 분리 원칙을 따릅니다: 클라이언트는 사용하지 않는 메서드에 의존해서는 안 됩니다. 이는 코드를 더 유연하고 테스트하기 쉬워지게 합니다.

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

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()
    
    // ... 행 파싱
}

이 패턴은 다중 테넌트 데이터베이스 패턴을 구현할 때 특히 유용합니다. 이때 다른 데이터베이스 구현 또는 연결 전략으로 전환해야 할 수도 있습니다.

구성 루트 패턴

구성 루트는 애플리케이션의 진입점(일반적으로 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에는 이러한 문제를 해결해주는 여러 의존성 주입 프레임워크가 있습니다:

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("사용자 저장소는 nil일 수 없습니다")
    }
    return &UserService{repo: repo}, nil
}

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

데이터베이스 트랜잭션과 같이 요청에 따라 달라지는 의존성은 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를 사용하여
// 오류 추적을 수행합니다. 병렬 처리 환경에서 사용될 경우
// UserRepository는 스레드 안전해야 합니다.
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 표준 라이브러리의 웹 크롤링 또는 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 개발 자원이 필요하다면, Go 체크리스트를 확인하세요. Go 문법과 일반 패턴에 대한 빠른 참고 자료입니다.

유용한 링크

외부 자원