Go 의존성 주입 접근 방식 - Wire vs. fx, 그리고 수동 모범 사례
Emily Parker
Product Engineer · Leapcell

소개
Go로 견고하고 유지보수 가능한 애플리케이션을 구축하는 것, 특히 복잡성이 증가함에 따라 효과적인 의존성 관리가 필요하게 됩니다. 소프트웨어 시스템이 발전함에 따라 컴포넌트는 종종 기능을 수행하기 위해 다른 컴포넌트에 의존하게 됩니다. 구조화된 접근 방식 없이는 이러한 상호 의존성을 관리하는 것이 테스트, 수정 및 이해하기 어려운 단단하게 결합된 코드베이스로 빠르게 이어질 수 있습니다. 이것이 의존성 주입(DI)이 빛나는 곳입니다. DI는 객체가 자체적으로 의존성을 생성하는 대신 외부 소스에서 의존성을 제공함으로써 느슨한 결합을 촉진하는 소프트웨어 디자인 패턴입니다. 모듈성, 테스트 용이성 및 확장성을 달성하기 위한 기본 원칙입니다. Go 생태계에서 개발자들은 종종 "올바른" DI 전략을 선택하는 데 어려움을 겪습니다. 이 글에서는 Google Wire, Uber Fx, 그리고 종종 저평가되는 일반 수동 주입의 힘이라는 주요 솔루션을 탐구하고, 해당 구조, 실제 사용 사례 및 모범 사례를 살펴봅니다.
핵심 개념 이해
각 DI 접근 방식에 대해 자세히 알아보기 전에 관련 핵심 용어를 간략하게 정의해 보겠습니다.
- 의존성 주입 (DI): 객체가 의존성을 스스로 생성하는 대신 외부 소스에서 의존성을 받는 디자인 패턴입니다. 느슨한 결합과 쉬운 테스트를 촉진합니다.
- 의존성: 객체가 기능을 수행하기 위해 필요한 객체 또는 서비스입니다. 예를 들어,
UserService
는UserRepository
에 의존할 수 있습니다. - Provider 함수 (또는 생성자): 의존성 인스턴스를 생성하는 함수입니다. 이러한 함수는 종종 다른 의존성을 인수로 받습니다.
- 의존성 그래프: 애플리케이션의 의존성 간의 관계를 나타내는 방향성 그래프입니다. 노드는 컴포넌트이고, 간선은 "의존한다"는 관계를 나타냅니다.
- 제어의 역전 (IoC): DI의 기본 원칙으로, 프레임워크 또는 주입기가 객체 자체를 제어하는 대신 객체의 인스턴스화 및 수명 주기를 제어합니다.
Go의 의존성 주입 접근 방식
수동 의존성 주입
수동 의존성 주입은 생성자 주입 또는 함수 옵션으로도 알려져 있으며, Go에서 의존성을 관리하는 가장 간단하고 종종 관용적인 방법입니다. 생성자 또는 함수에 의존성을 명시적으로 전달하는 것을 포함합니다.
작동 방식:
구조체와 생성자 유사 함수(종종 X
구조체에 대해 NewX
라고 명명됨)를 정의하고 필요한 모든 의존성을 인수로 전달합니다.
예시:
package main import ( "fmt" "log" "os" ) // Logger는 간단한 의존성입니다 type Logger struct { prefix string } func NewLogger(prefix string) *Logger { return &Logger{prefix: prefix} } func (l *Logger) Log(message string) { log.Printf("[%s] %s\n", l.prefix, message) } // UserRepository는 또 다른 의존성입니다 type UserRepository struct { dbName string logger *Logger } func NewUserRepository(dbName string, logger *Logger) *UserRepository { return &UserRepository{dbName: dbName, logger: logger} } func (r *UserRepository) SaveUser(user string) { r.logger.Log(fmt.Sprintf("'%s' 사용자를 데이터베이스 '%s'에 저장", user, r.dbName)) } // UserService는 UserRepository와 Logger에 의존합니다 type UserService struct { repo *UserRepository logger *Logger } func NewUserService(repo *UserRepository, logger *Logger) *UserService { return &UserService{repo: repo, logger: logger} } func (s *UserService) RegisterUser(username string) { s.logger.Log(fmt.Sprintf("사용자 등록 시도: %s", username)) s.repo.SaveUser(username) } func main() { // 수동 배선 logger := NewLogger("APP") repo := NewUserRepository("users_db", logger) userService := NewUserService(repo, logger) userService.RegisterUser("Alice") }
장점:
- 간단함 및 가독성: 이해하고 의존성 흐름을 따르기 쉽습니다. 숨겨진 마법이 없습니다.
- 외부 의존성 없음: 서드파티 라이브러리가 필요 없으므로
go.mod
를 깔끔하게 유지합니다. - Go 관용구: 명시적인 코드와 단순함이라는 Go 철학에 잘 부합합니다.
- 컴파일 타임 안전성: 모든 의존성이 명시적으로 전달되므로 누락된 의존성은 컴파일 타임 오류를 발생시킵니다.
- 쉬운 테스트: 테스트 중에 다른 구현을 전달하여 의존성을 쉽게 모의하거나 스텁할 수 있습니다.
단점:
- 상당한 양의 코드 (매우 큰 애플리케이션의 경우): 애플리케이션이 성장하고 의존성 그래프가 깊어짐에 따라
main
함수 (또는 전용 "와이어링" 함수)는 많은 인스턴스화 코드가 될 수 있습니다. - 리팩터링 오버헤드: 그래프 깊숙이 새로운 의존성이 도입되면 상위의 많은 생성자 시그니처를 업데이트해야 할 수 있습니다.
수동 DI를 위한 모범 사례:
- 의존성 그래프를 얕게 유지하십시오: 서비스가 직접적인 의존성을 적게 갖도록 설계하십시오.
- 관련 의존성을 그룹화하십시오: 생성자 인수 수를 줄이기 위해 구조체에 관련 의존성을 래핑하십시오 (예:
PersistenceDependencies
구조체). - 함수형 옵션 사용: 선택적 의존성 또는 구성을 위해 함수형 옵션은 생성자 폭발 없이 구성을 위한 깔끔한 방법을 제공합니다.
- 와이어링 중앙 집중화: 모든 최상위 컴포넌트가 인스턴스화되고 함께 와이어링되는 단일 전용 패키지 또는 파일 (예:
pkg/app/wire.go
또는main.go
)을 만듭니다.
Google Wire
Google Wire는 의존성 주입을 위한 코드 생성 도구입니다. 런타임 DI 컨테이너와 달리 Wire는 Go의 강력한 유형 시스템을 활용하여 컴파일 타임에 의존성 주입 컨테이너를 생성합니다.
작동 방식:
특정 유형을 생성하는 방법을 아는 함수 (프로바이더)를 포함하는 프로바이더 세트(provider set)를 정의합니다. 또한 주입기(injector) 인터페이스를 정의합니다. Wire는 이러한 정의를 읽고 모든 의존성을 인스턴스화하고 함께 와이어링하는 Go 코드를 생성합니다.
예시:
먼저 프로바이더와 주입기를 정의하는 wire.go
파일 (또는 유사한 파일)을 만듭니다.
//go:build wireinject //go:build !wireinject // 빌드 태그는 스텁이 최종 출력에 빌드되지 않도록 합니다. package main import ( "github.com/google/wire" ) // 이전 수동 예제의 프로바이더 함수 func NewLogger(prefix string) *Logger { return &Logger{prefix: prefix} } func NewUserRepository(dbName string, logger *Logger) *UserRepository { return &UserRepository{dbName: dbName, logger: logger} } func NewUserService(repo *UserRepository, logger *Logger) *UserService { return &UserService{repo: repo, logger: logger} } // 프로바이더 세트 정의 var appProviderSet = wire.NewSet( wire.Value("APP"), // 로거 접두사에 대한 문자열 "APP" 제공 wire.Value("users_db"), // 리포지토리 dbName에 대한 문자열 "users_db" 제공 NewLogger, NewUserRepository, NewUserService, ) // 주입기 함수 선언 func InitializeUserService() *UserService { wire.Build(appProviderSet) return &UserService{} // Wire는 이 반환 값을 실제 인스턴스로 대체합니다 }
그런 다음 해당 패키지 디렉토리에서 터미널에서 wire
를 실행합니다.
go get github.com/google/wire/cmd/wire wire
그러면 wire_gen.go
파일이 생성됩니다.
// Wire에 의해 생성되었습니다. 편집하지 마십시오. //go:build !wireinject // +build !wireinject package main import ( "github.com/google/wire" ) // wire.go의 주입기: func InitializeUserService() *UserService { logger := NewLogger("APP") userRepository := NewUserRepository("users_db", logger) userService := NewUserService(userRepository, logger) return userService } // wire.go: // 로거 접두사에 대한 문자열 "APP" 제공 var appProviderSet = wire.NewSet(wire.Value("APP"), wire.Value("users_db"), NewLogger, NewUserRepository, NewUserService)
이제 main.go
는 생성된 함수를 사용할 수 있습니다.
package main func main() { // 생성된 주입기 사용 userService := InitializeUserService() userService.RegisterUser("Bob") }
장점:
- 컴파일 타임 안전성: 모든 의존성 해결은 컴파일 타임에 이루어지므로 오류를 조기에 발견합니다.
- 런타임 오버헤드 없음: 생성된 코드는 일반 Go 코드이므로 반사 또는 런타임 성능 페널티가 없습니다.
- 명시적 와이어링: 코드 생성 도구이지만, 입력
wire.go
파일은 관계를 명시적으로 정의하므로 검사 가능합니다. - 보일러플레이트 감소 (복잡한 그래프의 경우): 깊은 의존성 그래프의 경우
main
에서 작성해야 하는 수동 와이어링 코드를 크게 줄입니다. - Go 관용구 출력: 생성된 코드는 직접 작성한 Go 코드처럼 보이므로 디버깅 및 이해가 용이합니다.
단점:
- 코드 생성 단계: 빌드 프로세스에 추가 단계가 필요합니다.
- 학습 곡선:
wire.ProviderSet
및wire.Build
와 같은 개념은 사전에 약간의 이해가 필요합니다. - 동적 시나리오에 대한 유연성 부족: 외부 요인에 따라 런타임에 의존성이 변경될 수 있는 시나리오에는 적합하지 않습니다.
Wire에 대한 모범 사례:
- 프로바이더 구성: 가독성과 재사용성을 위해
wire.NewSet
를 사용하여 프로바이더를 논리적 그룹으로 묶습니다. wire.Value
의 현명한 사용: 간단한 기본 값의 경우 괜찮지만, 복잡한 구성의 경우 전용 구성 구조체를 고려하십시오.wire.go
파일 깔끔하게 유지: 여기에 와이어링만 집중하십시오.- 빌드 파이프라인에 통합:
wire_gen.go
를 최신 상태로 유지하기 위해make
파일 또는 CI/CD에서wire
를 자동으로 실행해야 합니다.
Uber Fx
Uber Fx는 모듈성과 생성자 개념을 기반으로 하는 수명 주기가 인식되는 관습적인 애플리케이션 프레임워크로, 런타임 의존성 주입 컨테이너를 포함합니다. 모듈성, 테스트 용이성 및 정상 종료에 중점을 둡니다.
작동 방식:
Fx 애플리케이션은 fx.Module
컬렉션으로 구축됩니다. 각 모듈은 객체를 제공 (fx.Provide
사용)할 수 있으며, 이 객체는 fx.Invoke
하는 다른 모듈 또는 컴포넌트에서 사용할 수 있습니다. Fx는 런타임에 반사를 사용하여 의존성을 해결합니다.
예시:
package main import ( "context" "fmt" "log" "os" "time" "go.uber.org/fx" ) // Logger Fx 프로바이더 func NewFxLogger() *Logger { return NewLogger("FX-APP") // 이전의 NewLogger 재사용 } // UserRepository에 대한 Fx 프로바이더 func NewFxUserRepository(logger *Logger) *UserRepository { return NewUserRepository("fx_users_db", logger) } // UserService에 대한 Fx 프로바이더 func NewFxUserService(repo *UserRepository, logger *Logger) *UserService { return NewUserService(repo, logger) } // 애플리케이션 로직을 시작하는 fx.Invoke 함수 func RunApplication(lifecycle fx.Lifecycle, userService *UserService) { lifecycle.Append(fx.Hook{ OnStart: func(ctx context.Context) error { go func() { userService.RegisterUser("Charlie via Fx") fmt.Println("Fx 애플리케이션이 시작되고 사용자가 등록되었습니다.") }() return nil }, OnStop: func(ctx context.Context) error { fmt.Println("Fx 애플리케이션이 중지됩니다.") return nil }, }) } func main() { fx.New( fx.Provide( NewFxLogger, NewFxUserRepository, NewFxUserService, ), fx.Invoke(RunApplication), ).Run() }
장점:
- 런타임 유연성: 의존성을 동적으로 해결할 수 있어 더 복잡한 시나리오 (예: 플러그인 아키텍처)에 적합합니다.
- 수명 주기 관리: 애플리케이션 시작 및 종료, 리소스 정리 (예: 데이터베이스 연결, HTTP 서버)를 관리하기 위한 내장 구조체 (
fx.Lifecycle
)를 제공합니다. - 모듈성: 애플리케이션을 독립적이고 조합 가능한 모듈로 구축하도록 장려하여 더 나은 구성을 제공합니다.
- 관찰 가능성: Fx는 애플리케이션 수명 주기 및 의존성 그래프를 관찰하기 위한 훅 및 도구를 제공합니다.
- 코드 생성 축소 (수명 주기 관리의 경우): 서비스 시작 및 중지와 관련된 보일러플레이트를 처리합니다.
단점:
- 런타임 오버헤드: 컴파일 타임 솔루션 또는 수동 주입에 비해 시작 시 약간의 성능 저하를 초래할 수 있는 반사를 사용합니다.
- 암시적 의존성 해결: 의존성은 유형별로 해결되므로 때로는 Wire 또는 수동 주입보다 덜 명시적일 수 있습니다. 모호성은 태그를 필요로 할 수 있습니다.
- 더 큰 공간: 상당한 프레임워크 의존성을 도입합니다.
- 학습 곡선: 시간 내에 파악해야 하는 자체 패러다임 및 관례 (
fx.Provide
,fx.Invoke
,fx.Options
,fx.Module
)가 있습니다. - 디버깅: 런타임 반사 오류는 컴파일 타임 오류보다 진단하기 어려울 수 있습니다.
Fx에 대한 모범 사례:
- 모듈로 구조화: 애플리케이션을
fx.Module
로 나눕니다. 각 모듈은 특정 도메인 또는 서비스 세트에 대한 책임이 있습니다. fx.Lifecycle
활용: 올바른 리소스 초기화 및 종료를 위해 수명 주기 훅을 사용합니다.fx.Annotate
로 명시하십시오 (필요한 경우): 여러 프로바이더가 동일한 유형을 제공하는 경우fx.Annotate
를 사용하여 이름으로 구별합니다.fx.Out
및fx.In
사용: 더 복잡한 생성자 시그니처의 경우, 특히 하나의 프로바이더에서 여러 항목을 제공하는 경우 필요한 의존성 및 제공된 의존성을 명시적으로 나타냅니다.
결론
Go의 의존성 주입 전략 선택은 프로젝트의 규모, 복잡성 및 특정 요구 사항에 크게 좌우됩니다.
수동 의존성 주입은 단순성, Go 관용구 및 컴파일 타임 보증을 무엇보다 중요하게 생각하는 중소 규모 애플리케이션에 여전히 선택 사항입니다. 종종 가장 가독성이 높고 유지보수 가능한 초기 접근 방식입니다.
Google Wire는 복잡하지만 정적인 의존성 그래프를 가진 더 큰 애플리케이션을 위한 훌륭한 중간 지점으로 등장합니다. 컴파일 타임 안전성과 제로 런타임 오버헤드를 유지하면서 자동화된 와이어링의 이점을 제공하여 그렇지 않으면 작성해야 하는 수동 코드를 효과적으로 생성합니다.
Uber Fx는 강력한 수명 주기 관리, 잠재적으로 동적 의존성 해결 및 관찰 가능성에 대한 강한 강조가 필요한 매우 크고 모듈화된 애플리케이션을 위한 강력한 프레임워크입니다. 배터리 포함 접근 방식에는 학습 곡선과 런타임 반사가 따르지만 복잡하고 장기 실행 서비스에서 보상을 얻습니다.
궁극적으로 대부분의 Go 프로젝트에서는 수동 주입으로 시작하십시오. 보일러플레이트가 성장하는 정적인 의존성 그래프의 경우 관리하기 어렵게 되면 Wire를 고려하십시오. 수명 주기 관리, 모듈성이 통합된 포괄적인 애플리케이션 프레임워크가 필요하고 동적 또는 복잡한 서비스 구성의 경우 Uber Fx는 매력적인 선택입니다. 현명하게 선택하면 유지보수 가능하고 테스트 가능하며 확장 가능한 Go 애플리케이션을 보장할 수 있습니다.