티스토리 뷰

언어/Golang

[Go]Uber-fx DI Framework 1편

꼬마우뇽이(원종운) 2022. 12. 4. 16:47

Fx

Fx는 Uber에서 만든 Go 생태계에서 사용되는 Dependency Injection System(Framework) 입니다.

 

Fx를 왜 쓰는가 ?

Fx 프레임워크를 사용하지 않더라도 DI를 수행할 수 있습니다. 하지만 시스템 전반에 존재하는 의존성이 많아지게 될 경우, 각 의존성에 대한 라이프 사이클등의 관리 포인트가 많아지게 됩니다.

 

또한 동일한 의존성을 재사용하고자 하는 경우, 이미 존재하는 의존성을 사용하는 것이 아닌 무분별한 인스턴스 재생성과 같은 직접 DI를 수행하고자 할 때 발생하는 여러 불편한 점을 Fx 프레임워크는 쉽게 해결하여 줍니다.

 

Fx의 특징

Fx는 의존성을 싱글톤의 형태로 관리하며, Lazy Loading 을 기본으로 하여 런타임 시점에 DI를 진행합니다.

 

다른 DI 프레임워크 중 하나인 wire 는 컴파일 시점에 DI를 진행하여주는 특징과 대비됩니다.

 

런타임보다 컴파일타임에 이루어지는 것이 더 안정적이고, 에러가 발생하였을 때 쉽게 알 수 있다는 점에서 wire 프레임워크가 더 편하지 않을까라고 생각할 수 있습니다.

 

서로의 장단점이 있으며, wire 의 경우는 컴파일타임에 DI를 진행하기때문에 의존성간의 그래프를 분석하여 DI를 위한 코드 생성(go generate)이 필수이기에 사용 편리성은 fx 에 비해 떨어질 수 있습니다.

 

Fx의 장점

1. Eliminate Globals

Fx를 통하여 의존성을 싱글톤의 형태로 관리함으로서 어플리케이션 전반에 존재하는 Global State를 제거하는데 큰 도움을 주며, 무분별할 init() 함수의 사용을 제거할 수 있습니다.

 

아래와 같이 전역적으로 하나의 인스턴스만 존재하는 객체를 만들기 위해서 Global State(전역 변수)를 선언하고, init() 함수에서 해당 Global State를 초기화하는 등의 코드가 무분별하게 많이 사용되어집니다.

type Config struct{}

var cfg *Config // Global State

func init() { // Initialize Global State
	if cfg == nil {
		cfg = &Config{}
	}
}

2. Code resue

의존성 주입을 통하여 컴포넌트 간의 결합을 느슨하게 만들고, 재사용 가능한 컴포넌트를 구성할 수 있게됩니다. 본질적으로는 Fx의 장점이 아니라 DI 시스템을 통해서 발생하는 장점입니다.

 

3. Battle-tested

Fx는 Uber의 Go Services에서 핵심 시스템으로 사용되고 있고, 따라 안정성이 검증되어있습니다 Fx는 Uber에서 만든 시스템이며, 대규모 서비스에서 사용되고 있어 안정성이 증명되어, 믿고 사용할 수 있습니다.

 

 

Fx를 구성하는 핵심 컴포넌트

fx.App

Fx System의 실행 주체이며 시스템 내에서 크게 2가지 역할을 수행합니다.

type App struct {
	err       error
	clock     fxclock.Clock
	lifecycle *lifecycleWrapper

	container *dig.Container
	root      *module
	modules   []*module

	// Used to setup logging within fx.
	log            fxevent.Logger
	logConstructor *provide // set only if fx.WithLogger was used
	// Timeouts used
	startTimeout time.Duration
	stopTimeout  time.Duration
	// Decides how we react to errors when building the graph.
	errorHooks []ErrorHandler
	validate   bool
	// Used to signal shutdowns.
	donesMu     sync.Mutex // guards dones and shutdownSig
	dones       []chan os.Signal
	shutdownSig os.Signal

	osExit func(code int) // os.Exit override; used for testing only
}

// Example

func main() {
  app := fx.New()
}

 

의존성 컨테이너

시스템 내에서 사용될 의존성을 등록하고, 의존성이 필요한 여러 컴포넌트에게 의존성을 주입하여주는 의존성 컨테이너 역할을 수행합니다.

 

라이프사이클 관리

Fx System의 라이프 사이클(실행과 종료)을 관리하여줍니다. 따라서 예를 들어 SIGTERM 등의 시그널이 발생하여 Gradleful Shutdown 등의 부수적인 작업이 필요할 때 Fx의 종료 LifeCycle을 통하여 보다 편리하게 제어할 수 있습니다.

 

fx.Option

fx에는 의존성 등록을 위한 Option부터 다양한 Option 들을 제공하고 있습니다. 그 중 대표적으로 fx.Provide, fx.Module, fx.Invokefx.LifeCycle을 알아보도록 하겠습니다.

 

fx.Provide

fx.Provide Option을 통하여 의존성을 등록할 수 있습니다. Provide의 인자로는 등록하고자 하는 의존성을 반환하는 생성자(constructor)을 전달되고, 한번에 여러개도 전달할 수 있습니다.

 

등록된 의존성은 재사용을 위해 캐싱되어 싱글톤 형태로 제공되며 사용하는 시점에 Lazy 하게 생성되고 주입됩니다.

 

주의점

함수를 전달하는 것이지, 함수의 결과값을 전달하는 것이 아닙니다.

 

생성자는 오로지 인스턴스의 생성 목적만을 수행하는 것이 좋으며, Server Listen Loop , Background Timer Loops, Background processing goroutines 과 같은 작업은 생성자내에서 수행하는 것이 아니라 Lifecycle 을 사용하는 것이 좋습니다.

 

또한 Lazy 하게 초기화되기때문에, 실제로 사용되는 곳(노출되는 곳)이 없다면 초기화(생성) 되지 않습니다.

// Signature
func Provide(constructors ...interface{}) Option

// Example
package main

import (
	"go.uber.org/fx"
)

type Config struct{}

// 사용되는 곳(노출 되는 곳)이 없기때문에 초기화되지 않으므로 호출되지 않습니다.
func NewConfig() *Config { 
	return &Config{}
}

func main() {
	app := fx.New(
		fx.Provide(NewConfig),
	)
	app.Run()
}

 

fx.Invoke

fx.Invoke Option은 다른 fx.Option 들이 적용이 다된 이후에 최종적으로 적용되는 Option이며, 같은 레벨의 fx.Invoke Option등록된 순서에 의존하여 실행(적용)됩니다.

 

등록된 순서대로 실행되는 이유는 내부적으로 Invoke를 등록할 경우 Invoke를 저장해두는 자료구조가 Slice 이며, Slice를 순회하면서 Invoke에 등록된 함수를 실행하기때문입니다.

 

package main

import (
	"fmt"
	"go.uber.org/fx"
)

type LoggerConfig struct{}
type DBConfig struct{}
type TestConfig struct{}

func NewTestConfig() *TestConfig {
	fmt.Println("new test config")
	return &TestConfig{}
}

func NewLoggerConfig() *LoggerConfig {
	fmt.Println("new logger config")
	return &LoggerConfig{}
}

func NewDBConfig(lc fx.Lifecycle) *DBConfig {
	fmt.Println("new db config")
	return &DBConfig{}
}

func main() {
	app := fx.New(
		fx.Provide(NewTestConfig, NewDBConfig, NewLoggerConfig),
		fx.Invoke(func(testConfig *TestConfig) {

		}),
		fx.Invoke(func(testConfig *TestConfig, loggerConfig *LoggerConfig, dbConfig *DBConfig) {
			fmt.Println("call")
		}),
	)
	app.Run()
}

// "new test config" -> "new logger config" -> "new db config" -> "call"

 

Invoke에 전달된 함수의 파라미터에  노출되는 순서대로 필요한 의존성을 Lazy 하게 생성하고 주입받습니다. 주입 받을 시점에 이미 생성되어있는 의존성이라면 그것을 사용합니다. (싱글톤)

 

2번째의 Invoke 의 TestConfig 인스턴스는 1번째 Invoke에서 이미 초기화된 상태이므로 2번째 Invoke에서는 새로 생성되지 않습니다

 

또한 2번째의 Invoke의 함수의 파라미터의 순서에 따라 LoggerConfig 인스턴스가 먼저 초기화되고 DBConfig 인스턴스가 이후 초기화됩니다.

 

주의점

등록되는 순서에 의존하며, 다른 fx.Option 들이 적용이 다 된 이후에 적용이 되기때문에 만약 fx.Invoke Option이 fx.Module Option에 의하여 래핑되었다면 그렇지 않은 fx.Invoke Option은 차후 실행된다는 의미입니다.

 

func (m *module) executeInvokes() error {
	for _, m := range m.modules {
		if err := m.executeInvokes(); err != nil {
			return err
		}
	}

	for _, invoke := range m.invokes {
		if err := m.executeInvoke(invoke); err != nil {
			return err
		}
	}

	return nil
}

그 이유는 위와 같이 내부적으로 자신의 하위 모듈(fx.Module)에 등록된 Invoke 들을 먼저 수행한 후, 자신에게 등록된 Invoke를 수행하기때문입니다. 자세한 부분은 구현을 참고하여주세요.

 

package main

import (
	"fmt"
	"go.uber.org/fx"
)

func main() {
	app := fx.New(
		fx.Invoke(func() {
			fmt.Println("func3")
		}),
		fx.Module("someModule",
			fx.Invoke(func() {
				fmt.Println("func1")
			}),
			fx.Invoke(func() {
				fmt.Println("func2")
			}),
		),
		fx.Invoke(func() {
			fmt.Println("func4")
		}),
	)
	app.Run()
}

// "func1" -> "func2" -> "func3" -> "func4"

 

fx.Invoke(func3) Option은 가장 먼저 등록되었지만, fx.Module Option이 적용된 이후에 순차적으로 등록된 순서에 맞게 실행됩니다.

 

따라서 최종적으로 실행되는 순서는 func1  func2  func3  func4 입니다.

 

func1, func2 는 같은 레벨의 Invoke Option이므로 적용된 순서가 빠른 func1 이 먼저 실행된 것입니다. 

그 이후 같은 레벨의 Invoke Option인 func3  func4 는 적용된 순서가 빠른 func3 이 먼저 실행된 것입니다.

 

fx.Module

여러 fx.Option 들을 모아서 하나의 Module을 구성할 수 있도록 도와줍니다. 앞서 살펴본 App 컴포넌트가 최상위 모듈이며, 모듈은 등록되는 모듈을 부모 모듈로 삼으며, 따라서 계층적인 구조를 형성합니다.

 

Module 또한 fx.Option 이며, 아래와 같이 2개의 fx.Provide Option을 패키징하여 하나의 fx.Module Option으로 만들어 모듈화 할 수 있습니다.

 

package main

import (
	"fmt"
	"go.uber.org/fx"
)

type LoggerConfig struct{}
type DBConfig struct{}

func NewLoggerConfig() *LoggerConfig {
	fmt.Println("new test call")
	return &LoggerConfig{}
}

func NewDBConfig(lc fx.Lifecycle) *DBConfig {
	return &DBConfig{}
}

func main() {
	app := fx.New(
		fx.Module("config module",
			fx.Provide(NewLoggerConfig),
			fx.Provide(NewDBConfig),
		),
	)
	app.Run()
}

 

fx.LifeCycle

fx.LifeCycle 인터페이스는 Fx System이 실행될 때, 종료될 때 실행될 수 있는 Hook을 등록하여 라이플 사이클에 맞게 특정 행위를 수행할 수 있도록 도와줍니다.

// Lifecycle allows constructors to register callbacks that are executed on
// application start and stop. See the documentation for App for details on Fx
// applications' initialization, startup, and shutdown logic.
type Lifecycle interface {
	Append(Hook)
}

// A Hook is a pair of start and stop callbacks, either of which can be nil.
// If a Hook's OnStart callback isn't executed (because a previous OnStart
// failure short-circuited application startup), its OnStop callback won't be
// executed.
type Hook struct {
	OnStart func(context.Context) error
	OnStop  func(context.Context) error
}

 

콜백에 전달되는 Context는 뒤에서 살펴볼 fx.App의 startTimeoutstopTimeout이 Timeout이 설정된 `context.WithTimeOut` 과 같은 Context 입니다.

startCtx, cancel := app.clock.WithTimeout(context.Background(), app.StartTimeout())
stopCtx, cancel := app.clock.WithTimeout(context.Background(), app.StopTimeout())

 

Hook은 OnStart 콜백과 OnStop 콜백으로 구성되어져있으며, 시스템이 실행될 때 OnStart 콜백이 실행되고, 시스템이 종료될 때 OnStop 이 실행되어집니다.

위 LifeCycle Hook의 OnStart 콜백은 시스템이 실행될 때 Hook이 등록된 순서대로 실행되며 OnStart 콜백은 시스템이 종료될 때 등록된 Hook 중 OnStart 콜백에서 에러가 발생하지 않은 Hook에 대해서 역순으로 실행됩니다.

 

OnStart 콜백은 이전의 OnStart 콜백 실행에 에러가 발생한 경우, 등록된 Hook의 OnStart 콜백 함수의 실행을 즉시 멈추고, 시스템은 비정상적(exitCode = 1)으로 종료됩니다.

// Start runs all OnStart hooks, returning immediately if it encounters an
// error.
func (l *Lifecycle) Start(ctx context.Context) error {
	if ctx == nil {
		return errors.New("called OnStart with nil context")
	}

	l.mu.Lock()
	l.startRecords = make(HookRecords, 0, len(l.hooks))
	l.mu.Unlock()

	for _, hook := range l.hooks {
		// if ctx has cancelled, bail out of the loop.
		if err := ctx.Err(); err != nil {
			return err
		}

		if hook.OnStart != nil {
			l.mu.Lock()
			l.runningHook = hook
			l.mu.Unlock()

			runtime, err := l.runStartHook(ctx, hook)
			if err != nil {
                		/* 에러가 발생할 경우, 즉시 에러를 반환하고 콜백 실행을 중단합니다. */
				return err
			}

			l.mu.Lock()
			l.startRecords = append(l.startRecords, HookRecord{
				CallerFrame: hook.callerFrame,
				Func:        hook.OnStart,
				Runtime:     runtime,
			})
			l.mu.Unlock()
		}
		l.numStarted++
	}

	return nil
}

 

하지만 OnStop 콜백은 이전의 OnStop 콜백 실행에 에러가 발생하여도, OnStart 콜백이 성공한 Hook에 대한 OnStop 콜백을 모두 실행한 후, 시스템은 비정상적(exitCode = 1)으로 종료됩니다.

// Stop runs any OnStop hooks whose OnStart counterpart succeeded. OnStop
// hooks run in reverse order.
func (l *Lifecycle) Stop(ctx context.Context) error {
	if ctx == nil {
		return errors.New("called OnStop with nil context")
	}

	l.mu.Lock()
	l.stopRecords = make(HookRecords, 0, l.numStarted)
	l.mu.Unlock()

	// Run backward from last successful OnStart.
	var errs []error
	for ; l.numStarted > 0; l.numStarted-- {
		if err := ctx.Err(); err != nil {
			return err
		}
		hook := l.hooks[l.numStarted-1]
		if hook.OnStop == nil {
			continue
		}

		l.mu.Lock()
		l.runningHook = hook
		l.mu.Unlock()

		runtime, err := l.runStopHook(ctx, hook)
		if err != nil {
            		/* 에러가 발생하여도, 멈추지 않고 남은 OnStop 콜백을 모두 실행합니다. */
			// For best-effort cleanup, keep going after errors.
			errs = append(errs, err)
		}

		l.mu.Lock()
		l.stopRecords = append(l.stopRecords, HookRecord{
			CallerFrame: hook.callerFrame,
			Func:        hook.OnStop,
			Runtime:     runtime,
		})
		l.mu.Unlock()
	}

	return multierr.Combine(errs...)
}

 

주의점

LifeCycle에 등록된 Hook에는 Timeout 설정이 되어있습니다. 등록된 Hook의 모든 OnStart 콜백의 실행의 Timeout은 startTimeout 을 따르며, 등록된 모든 OnStop 콜백의 실행의 Timeout은 stopTimeout 을 따릅니다. 각각 기본값은 15초입니다.

// DefaultTimeout is the default timeout for starting or stopping an
// application. It can be configured with the StartTimeout and StopTimeout
// options.
const DefaultTimeout = 15 * time.Second

 

만약 startTimeout 시간내에 등록된 Hook들의 OnStart 콜백이 실행되지 못한다면, 시스템은 비정상적(exitCode = 1)으로 종료됩니다.

 

마찬가지로 stopTimeout 시간내에 등록된 Hook들의 OnStop 콜백이 실행되지 못한다면, 시스템은 비정상적(exitCode = 1)으로 종료됩니다.

 

만약 시간이 오래 소요되는 콜백이 포함되어있다면 startTimeoutstopTimeout 을 설정할 수 있습니다.

 

Advanced Fx - 2편

위의 옵션들만을 사용하면 아래와 같이 의존성을 주입받는 쪽에서 인터페이스로 선언하게 될 경우, 의존성이 해당 인터페이스를 구현하고 있어도 주입되지 않습니다.

 

이유는 당연합니다. 특정 인터페이스를 구현하는 구조체(구현체)는 많이 존재할 수 있고, 의존성 컨테이너에서는 어떠한 구현체를 주입해줘야하는지 모릅니다.

 

package main

import (
	"fmt"
	"go.uber.org/fx"
)

type Service interface {
	Do() error
}

type ServiceImpl struct{}

func (s *ServiceImpl) Do() error {
	return nil
}

func NewTestInvoke(s Service) {
	fmt.Println(s.Do())
}

func main() {
	app := fx.New(
		fx.Invoke(NewTestInvoke),
	)
	app.Run()
}

 

이러한 문제를 다른 fx의 Option들을 이용하여 해결할 수 있으며, 다음과 같은 의존성 주입도 가능합니다.

 

특정 인터페이스를 구현하는 모든 구조체(구현체) 주입

파라미터 순서에 맞게 원하는 의존성 주입 등

 

다음 시간에는 다양한 주입방식을 위한 fx.Option을 알아보도록 하겠습니다.

'언어 > Golang' 카테고리의 다른 글

[Go]Uber-fx DI Framework 2편  (0) 2022.12.11
공지사항
최근에 올라온 글
최근에 달린 댓글
Total
Today
Yesterday
링크
«   2024/09   »
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
29 30
글 보관함