Go에서 의존성 주입: 패턴 및 최고의 실천 방법
테스트 가능한 Go 코드를 위한 DI 패턴 정복하기
의존성 주입](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에서는 특히 인터페이스 기반의 설계 철학 덕분에 의존성 주입이 특히 강력합니다. 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)
}
이 패턴은 UserService가 UserRepository를 필요로 한다는 것을 명확하게 보여줍니다. 저장소를 제공하지 않고는 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 문법과 일반 패턴에 대한 빠른 참고 자료입니다.
유용한 링크
- Go 체크리스트
- Go의 BeautifulSoup 대안
- Go에서 PDF 생성: 라이브러리 및 예시
- Go에서 다중 테넌트 데이터베이스 패턴
- Go에서 사용할 ORM: GORM, sqlc, Ent 또는 Bun?
- Go에서 REST API 구현: 완전 가이드