티스토리 뷰

golang

Go 백엔드 5: 의존성 주입

주먹불끈 2025. 2. 16. 12:53

개요

현시점 코드에서 User라는 도메인을 가지고 있을 때의 의존성 주입 부분을 보자. 환경 → 설정 → 데이터베이스 연결 → 리포지토리 → 유스케이스 → 핸들러 순으로 의존성을 주입하고 있다. 단순한 서버에서는 이처럼 명시적으로 의존성을 이해하고 주입할 수 있지만 프로젝트가 커질수록 아래와 같은 문제들이 발생한다:

  • 순환 참조(Circular Dependency): 의존성이 서로 얽히며 무한 루프에 빠질 수 있다.
  • 초기화 순서 문제: 의존성이 잘못된 순서로 초기화되면 실행 중 에러가 발생할 수 있다.
  • 환경별 구성 차이: 환경(dev, qa, stg, prod)마다 필요한 구성 요소가 다를 수 있다.
	env := flag.String("env", "dev", "Environment (dev, qa, stg, prod)")
	flag.Parse()
	
	// 중략

  cfg, err := config.LoadConfig(*env)
	if err != nil {
		log.Fatalf("Config load error: %v", err)
	}

	fmt.Printf("config: %+v\\n", cfg)

	// 여기에 DB 연결 및 애플리케이션 로직 추가
	dbConn, err := db.NewDBConnection(cfg)
	if err != nil {
		log.Fatalf("DB connection error: %v", err)
	}

	// 의존성 주입
	userRepo := repository.NewUserRepository(dbConn)
	userUseCase := usecase.NewUserUseCase(userRepo)
	userHandler := handler.NewUserHandler(userUseCase)

해결책으로 fx를 사용해보자. fx는 Uber에서 만든 Go 의존성 주입 라이브러리로, Provide와 Invoke를 통해 초기화 및 주입 로직을 자동화할 수 있다. fx를 사용하면 의존성 간의 순환 참조를 방지할 수 있고, 초기화 순서를 자동으로 관리해준다. 또한, 환경별 설정을 손쉽게 적용할 수 있다. 참고로, fx를 적용하며 라우팅 부분도 별도로 분리하는 작업을 하였다.

링크

목차

  1. 사전작업
  2. route 개선
  3. 의존성 주입

사전 작업

User 도메인만으로는 의존성 주입 개선 사례로 부족하다 싶어, 4_fx-begin 브랜치에 Product, Order 라는 도메인을 추가하고 각각에 대해 handler, usecase, repository 계층 역시 추가하였다. 여기서부터 의존성 주입 개선작업을 시작하자. 의존성 주입이 완료된 브랜치는 4_fx-end 이다.

프로젝트 구조

Product, Order 도메인을 추가한 후 프로젝트의 주요 파 구조는 다음과 같다

  • domain, handler, usecase, repository/postgres 디렉토리에 Product, Order 도메인 관련 파일들이 추가되었다.
├── cmd/gocore/main.go
├── config/config.dev.yaml
└── internal
    ├── domain
    │   ├── mocks/Mock*.go
    │   ├── order.go
    │   ├── product.go
    │   └── user.go
    ├── handler
    │   ├── orderHandler.go
    │   ├── productHandler.go
    │   └── userHandler.go
    ├── repository/postgres
    │   ├── orderRepository.go
    │   ├── productRepository.go
    │   └── userRepository.go
    └── usecase
        ├── orderUsecase.go
        ├── productUsecase.go
        └── userUsecase.go

의존성 주입과 라우팅

의존성 주입 코드는 다음과 같다.

  • env를 생성하여 config.LoadConfig에 주입해주고, 그렇게 생성한 cfg를 db.NewDBConnection에 주입해준다.
  • 다시 생성된 dbConn 을 repository.NewUserRepository, repository.NewProductRepository, repository.NewOrderRepository에 주입하는 것을 볼 수 있다. 일일이 의존성을 주입하여야 한다.
  // cmd/gocore/main.go
  env := flag.String("env", "dev", "Environment (dev, qa, stg, prod)")
	flag.Parse()

	validEnvs := map[string]bool{"dev": true, "qa": true, "stg": true, "prod": true}
	if !validEnvs[*env] {
		log.Fatalf("Invalid environment: %s. Valid environments are: dev, qa, stg, prod", *env)
	}

	cfg, err := config.LoadConfig(*env)
	if err != nil {
		log.Fatalf("Config load error: %v", err)
	}

	fmt.Printf("config: %+v\\n", cfg)

	// 여기에 DB 연결 및 애플리케이션 로직 추가
	dbConn, err := db.NewDBConnection(cfg)
	if err != nil {
		log.Fatalf("DB connection error: %v", err)
	}

	// 의존성 주입
	userRepo := repository.NewUserRepository(dbConn)
	userUseCase := usecase.NewUserUseCase(userRepo)
	userHandler := handler.NewUserHandler(userUseCase)

	productRepo := repository.NewProductRepository(dbConn)
	productUseCase := usecase.NewProductUseCase(productRepo)
	productHandler := handler.NewProductHandler(productUseCase)

	orderRepo := repository.NewOrderRepository(dbConn)
	orderUseCase := usecase.NewOrderUseCase(orderRepo)
	orderHandler := handler.NewOrderHandler(orderUseCase)

라우팅 처리 역시 main 함수내에 있다. users, products, orders 등으로 묶어두면 여러모로 관리하기 편할 것이다.

// cmd/gocore/main.go
	e := echo.New()

	e.POST("/users", userHandler.CreateUser)
	e.GET("/users/:id", userHandler.GetByID)
	e.GET("/users", userHandler.GetAll)

	e.POST("/products", productHandler.CreateProduct)
	e.GET("/products/:id", productHandler.GetByID)
	e.GET("/products", productHandler.GetAll)

	e.POST("/orders", orderHandler.CreateOrder)
	e.GET("/orders/:id", orderHandler.GetByID)
	e.GET("/orders", orderHandler.GetAll)

개선점 메모

구현하며 좀더 완성도 있게 구현했으면 하는 부분이 보이지만 우선은 넘어가도록 한다. 개선할 점은 메모로 남겨둔다.

  • order를 생성할 때에 product 재고여부를 확인하고 주문한 만큼 재고 갯수를 줄여주는 비즈니스 로직 필요
  • user, product, order에 대한 soft delete 구현도 필요
  • 존재하지 않는 user, product 에 대한 order 생성을 시도하면 4xx 에러여야 한다.

route 개선

*echo.Echo를 핸들러 생성자에 함께 주입하여 생성자 내부에서 라우팅을 정의하도록 개선하였다.

NewProductHandler 생성자 함수를 비교해보자.

  • 기존에는 domain.ProductUseCase 인터페이스만을 주입받아서 ProductHandler 구현체를 생성하여 리턴하였다.
  • 개선 코드에는 *echo.Echo를 주입받아 product 도메인의 모든 라우팅을 정의하였다. userHandler.go, orderHandler.go 의 생성자 역시 마찬가지로 작업하였다.
  • 참고로 핸들러 구현체의 리턴은 사용하는 곳은 없다. 추후 테스트시에 사용할 것을 염두에 두고 리턴해둔다.
// internal/handler/productHandler.go
// AS-IS:
func NewProductHandler(productUseCase domain.ProductUseCase) *ProductHandler {
	return &ProductHandler{productUseCase: productUseCase}
}

// TO-BE:
func NewProductHandler(e *echo.Echo, productUseCase domain.ProductUseCase) *ProductHandler {
	handler := &ProductHandler{productUseCase: productUseCase}

	group := e.Group("/products")
	group.POST("", handler.CreateProduct)
	group.GET("", handler.GetAll)
	group.GET("/:id", handler.GetByID)

	return handler
}

Go로 만드는 백엔드 서버 프로젝트에서 구조나 구현에 정답은 없다. 이러한 구현도 하나의 제안이며 다음과 같은 장점이 있다.

  • 도메인(ex. user, product, order) 별로 라우팅을 모아둘 수 있다. 이렇게 모아둔 라우팅 그룹별로 인가(authorization) 작업을 할 수도 있겠다.
  • 라우팅을 정의한 코드 아래에 실제 핸들러 코드가 있어서 확인이 쉽다.

의존성 주입

Go 의존성 주입 라이브러리

대표적인 두 라이브러리인 fx와 wire를 비교해보자. 결론부터 말하자면 대규모 프로젝트, 모듈화 및 라이프사이클 관리가 필요하다면 fx, 컴파일 타임 안정성 및 성능을 중시한다면 wire를 선택할 수 있겠다. 여기서는 fx를 사용한다.

Fx(uber-go/fx)

런타임 의존성 주입 라이브러리로 Uber에서 개발하였다. 저수준에선 역시 Uber에서 개발한 dig 기반으로 동작한다.

  • 장점:
    • 런타임 기반으로 유연하며 모듈 간의 의존성 주입이 간단함
    • 애플리케이션 시작 및 종료 관리 (OnStart, OnStop 등)
    • 모듈화 지원 (fx.Module)로 대규모 프로젝트에 유리
    • 자동 의존성 탐색 및 DI 컨테이너 기능 내장
  • 단점:
    • 런타임 오버헤드가 있으며 컴파일 타임 검증 부족
    • 구조가 복잡해 학습 곡선이 가파름
    • 의존성 주입 실패는 런타임에만 발견됨

Wire(google/wire)

컴파일 타임 의존성 주입 라이브러리로 Google에서 개발하였다.

  • 장점:
    • 컴파일 타임에 의존성 문제 발견 (안전성 높음)
    • 런타임 오버헤드 없음 (성능 우수)
    • 코드가 명시적이고 단순, 추적 가능성 높음
    • 작은 프로젝트 및 유틸리티성 모듈에 적합
  • 단점:
    • 동적 주입 불가 (런타임에 의존성 변경 불가능)
    • 매번 wire gen 실행 필요 (생산성 저하)
    • 라이프사이클 관리 기능 없음
    • 대규모 프로젝트에서 모듈 관리 불편

fx를 이용한 의존성 주입

main 함수는 이처럼 간결해졌다. fx의 세부적인 기능에 대한 설명보다는 개념에 대한 소개를 하겠다.

  • fx.New 로 *fx.App을 생성한다. fx.New 안에는 크게 fx.Provide, fx.Invoke로 나눌 수 있다.
  • fx.Provide 안에는 생성자 함수들을 나열한다. 보통은 무언가를 주입 받아 무언가를 생성, 리턴하는 함수들이다.
    • fx.Provide 안의 생성자 순서와 fx.Provide들간의 순서는 의미가 없다.
    • fx.Provide는 요리를 할 때에 필요한 재료를 나열한 것으로 볼 수 있겠다. 재료들은 서로의 의존성과 필요를 알아서 찾아 주입되고 생성된다. 고추가루와 설탕의 생성자가 있다면, 떡볶이소스 생성자에 알아서 주입되고 떡볶이 소스가 나오는 셈이다.
  • fx.Invoke 는 app.Run 시에 실행할 작업의 순서이다. 요리 비유를 이어가면 실제 요리를 하는 순서가 된다.
    • 요리의 순서라는 비유처럼 fx.Provide와는 달리 fx.Invoke는 순서대로 실행이 된다.
    • 여기서는 각 핸들러 생성자를 실행하고 마지막으로 StartServer 함수가 실행된다.
  • lazy evaluation 개념도 이해해두자. fx.Invoke의 함수들은 무조건 실행되지만, fx.Provide의 함수들은 필요한 경우에만 실행이 된다. fx.Invoke에서 떡볶이를 만들라는 함수가 실행이 되었다면, fx.Provide에 제공된 떡볶이소스 생성자, 고추가루와 설탕 생성자는 그때에야 뒤늦게(lazy) 실행된다. 하지만 만약에 fx.Provide에 떡볶이에는 사용하지 않는 된장 생성자가 있었다면 이는 실행되지 않는다.
  • 가독성을 위해 handler, usecase, repository 별로 나누어 놓았다. fx.Provide의 모든 생성자는 순서에 상관이 없고 심지어 하나의 fx.Provide 안에 넣어두어도 된다. handler의 경우에는 이들 생성자를 모두 호출하는 임의의 함수를 만들어 실행해도 되지만 클린 아키텍처의 세 계층을 한 눈에 볼 수 있도록 fx.Invoke에 바로 넣어주었다.
func main() {

	app := fx.New(
		fx.Provide(
			NewConfig,
			NewDB,
			echo.New,
		),
		fx.Provide(
			repository.NewUserRepository,
			repository.NewProductRepository,
			repository.NewOrderRepository,
		),
		fx.Provide(
			usecase.NewUserUseCase,
			usecase.NewProductUseCase,
			usecase.NewOrderUseCase,
		),
		fx.Invoke(
			handler.NewUserHandler,
			handler.NewProductHandler,
			handler.NewOrderHandler,
		),
		fx.Invoke(StartServer),
	)

	app.Run()
}

fx를 사용하기 위해 별도의 생성자로 NewConfig, NewDB 생성자를 만들었다.NewConfig는 env 플래그를 확인하여 그에 맞는 config.Config를 생성해주고, NewDB는 config.Config를 주입받아서 설정에 따라 데이터베이스 연결을 만든다.

NewDB에서 fx.Lifecycle을 주입받는 것에 주목하자. fx 라이브러리는 별도의 생성이나 언급없이 fx.Lifecycle 파라미터를 알아서 주입해준다. 여기서는 OnStop 이벤트가 발생하는 경우에 데이터베이스 연결을 끊어주도록 하고 있다.

func NewConfig() *config.Config {
	env := flag.String("env", "dev", "Environment (dev, qa, stg, prod)")
	flag.Parse()

	validEnvs := map[string]bool{"dev": true, "qa": true, "stg": true, "prod": true}
	if !validEnvs[*env] {
		log.Fatalf("Invalid environment: %s. Valid environments are: dev, qa, stg, prod", *env)
	}

	cfg, err := config.LoadConfig(*env)
	if err != nil {
		log.Fatalf("Config load error: %v", err)
	}

	fmt.Printf("config: %+v\\n", cfg)
	return cfg
}

func NewDB(lc fx.Lifecycle, cfg *config.Config) *sql.DB {
	dbConn, err := db.NewDBConnection(cfg)
	if err != nil {
		log.Fatalf("DB connection error: %v", err)
	}

	lc.Append(fx.Hook{
		OnStop: func(ctx context.Context) error {
			return dbConn.Close()
		},
	})

	return dbConn
}

마지막으로 echo 서버를 실행하는 함수를 보자.

  • fx.Lifecycle, echo.Echo, config.Config 셋을 주입받고 있다. fx.Lifecycle은 앞서 언급한대로 fx 라이브러리가 알아서 주입해주고 있으며, echo.Echo는 fx.Provide에서 제공되고, fx.Invoke에서 핸들러 생성자들이 먼저 사용하여 라우팅 정보가 이미 적용되어 있다. config.Config는 앞서 데이터베이스 연결을 생성할 때에도 주입이 되지만 여기서는 서버의 포트정보를 제공하기 위해서 주입되었다.
  • fx.Lifecycle 은 OnStart 이벤트에서 서버를 실행하고, OnStop에서 서버를 종료하는 fx.Hook를 추가한다. 기억할 것은 앞서 NewDB에서 추가한 fx.Hook와의 관계이다. NewDB에서 먼저 추가되었으므로 OnStop 이벤트 발생시에는 추가의 역순으로 실행이 되어 서버의 종료가 먼저되고, 이후 데이터베이스 연결을 닫는다.
func StartServer(lc fx.Lifecycle, e *echo.Echo, cfg *config.Config) {
	lc.Append(fx.Hook{
		OnStart: func(ctx context.Context) error {
			go func() {
				if err := e.Start(fmt.Sprintf(":%d", cfg.App.Port)); err != nil {
					log.Fatal("Shutting down the server due to:", err)
				}
			}()
			return nil
		},
		OnStop: func(ctx context.Context) error {
			return e.Shutdown(ctx)
		},
	})
}

마무리

클린 아키텍처부터 설정, 데이터베이스 연결, 유닛테스트, 그리고 이번 의존성 주입까지 두서없이 생각나는 주제를 하나씩 풀어내고 있다. 코드나 순서를 좀더 최적화하고 싶은 욕심은 있으나 그러다 압도되어 지칠까 싶어 이런 식으로 계속 풀어가고 있다.

다음 주제는 logging을 메인으로 하여 미들웨어 전반에 대한 작업을 하겠다.

반응형

'golang' 카테고리의 다른 글

Go 백엔드 6: 미들웨어  (0) 2025.02.20
Go 백엔드 4: 유닛 테스트  (0) 2025.02.12
Go 백엔드 3: 데이터베이스 연결  (0) 2025.02.09
Go 백엔드 2: 설정  (0) 2025.02.09
Go 백엔드 1: 클린 아키텍처 기본  (0) 2025.02.06
반응형
잡학툰 뱃지
최근에 올라온 글
최근에 달린 댓글
Total
Today
Yesterday
«   2025/02   »
1
2 3 4 5 6 7 8
9 10 11 12 13 14 15
16 17 18 19 20 21 22
23 24 25 26 27 28
글 보관함