생성자를 등록하면 조립은 fx가 한다
— 타입으로 그래프를 만들고, 수명주기까지 관리하기
main()이 조립 설명서가 되어간다main()을 고친다// 수동 DI: main()에서 직접 조립 cfg, _ := config.New() db, _ := database.New(cfg) authorRepo := author.NewMysqlAuthorRepository(db) articleRepo := article.NewMysqlArticleRepository(db) timeout := time.Duration(cfg.Context.Timeout) * time.Second usecase := article.NewArticleUsecase(articleRepo, authorRepo, timeout) e := NewEcho() article.NewArticleHandler(e, usecase) e.Start(cfg.Server.Address)
생성자 함수를 등록만 한다. 순서는 신경 쓰지 않는다.
fx.Provide(
config.New,
database.New,
NewArticleUsecase,
)
각 생성자의 매개변수 타입과 반환 타입을 읽어 그래프를 구성하고, 올바른 순서로 호출한다.
New(cfg *config.Config) (*sql.DB, error) ↑ 필요 ↑ 제공
database.New가 *config.Config를 요구하므로, 그 타입을 반환하는 config.New가 먼저 호출된다. 순환 의존성은 앱 시작 시점에 에러로 알려준다.
go get go.uber.org/fx
| 장 | 내용 | 핵심 메서드 |
|---|---|---|
| 01 | fx 기초 — 등록·실행·수명주기 | Provide Invoke Supply Lifecycle |
| 02 | 확장 패턴 — 커진 앱을 정리하는 도구들 | Module Decorate Annotate Private |
| 03 | 테스트 전략 | fxtest Replace Populate |
| 04 | 정리 — 언제 쓰고 언제 안 쓰나 | 선택 가이드 |
fx는 메서드가 많아 헷갈리기 쉽다. 다음 슬라이드의 지도를 먼저 펼쳐두고, 각 장에서 하나씩 실전 예제로 채워 나간다.
등록하고, 실행하고, 수명주기를 맡긴다.
이 네 가지 — Provide · Invoke · Supply · Lifecycle — 만 알아도 실전 앱 하나는 굴러간다.
| 메서드 | 분류 | 역할 |
|---|---|---|
fx.New | 기초 | 앱 컨테이너 생성 — 모든 Option을 모아 그래프를 만든다 |
fx.Provide | 기초 | lazy 등록. 반환 타입 기준으로 그래프에 올린다 |
fx.Invoke | 기초 | eager 실행. 부수 효과의 시작점 |
fx.Supply | 기초 | 생성자 없이 이미 만든 값을 등록 |
fx.Lifecycle | 수명주기 | OnStart / OnStop 훅 |
| 메서드 | 분류 | 역할 | 도입 |
|---|---|---|---|
fx.Module | 확장 | 도메인별 그룹화 | v1.17+ |
fx.Decorate | 확장 | 기존 의존성 래핑 (로깅·캐싱·메트릭) | v1.18+ |
fx.Annotate | 확장 | 생성자에 name / group / As 부여 | — |
fx.In / fx.Out | 확장 | 파라미터·반환값을 구조체로 묶어 태그 매칭 | — |
fx.Private | 확장 | Module 내부 의존성 캡슐화 | v1.20+ |
fxtest.New | 테스트 | 테스트 전용 앱 | — |
fx.Replace | 테스트 | 기존 Provide를 Mock으로 교체 | — |
fx.Populate | 테스트 | 내부 인스턴스를 외부 변수로 추출 | — |
fx.Option을 반환한다app := fx.New( // ← Option 들을 모아 앱을 만든다 fx.Provide(...), // fx.Option fx.Supply(...), // fx.Option UserModule, // fx.Option (fx.Module 이 반환) fx.Decorate(...), // fx.Option fx.Invoke(...), // fx.Option )
Provide · Invoke · Supply · Module · Decorate · Replace · Populate
→ fx.New의 인자로 들어간다
Annotate · ResultTags · ParamTags
→ Provide · Supply 안에서 쓰인다
Provide는 등록만 한다 — 실행은 미룬다app := fxtest.New(t, // Provide: 생성자를 등록만 한다. 즉시 실행되지 않는다. fx.Provide( NewLogger, // Logger 반환 NewMysqlUserRepo, // UserRepository 반환 (Logger 필요) NewUserService, // *UserService 반환 (UserRepository 필요) ), // Invoke: 여기서 비로소 그래프가 조립된다 fx.Invoke(func(s *UserService) { svc = s }), )
NewUserService를 먼저 써도 결과는 같다. fx가 타입으로 정렬한다.
Invoke가 없으면 아무 일도 일어나지 않는다fx.Provide — lazy그래프에 올려만 둔다. 부수 효과 없음.
대부분의 NewXxx 생성자
fx.Invoke — eager앱 시작 시 반드시 실행된다. 여기서 요구한 타입이 그래프 조립의 출발점이 된다.
서버 기동 · 라우터 등록 · 훅 등록
// Provide만 있고 Invoke가 없는 앱 → 생성자는 단 하나도 호출되지 않는다. 앱은 조용히 뜨고 조용히 끝난다.
Supply로type Config struct { DBHost string DBPort int } cfg := &Config{DBHost: "localhost", DBPort: 3306} app := fxtest.New(t, fx.Supply(cfg), // 생성자 없이 값 그대로 등록 fx.Invoke(func(c *Config) { // c.DBHost == "localhost" }), )
Provide로 감싸면 func() *Config { return cfg } 같은 군더더기가 생긴다.
Provide는 생성자 함수를, Supply는 이미 만든 값을 등록한다.
// cmd/main.go app := fx.New( fx.Provide( config.New, // *config.Config database.New, // *sql.DB (*config.Config 필요) NewEcho, // *echo.Echo // 익명 함수도 그대로 생성자가 된다 — 타입만 맞으면 된다 func(cfg *config.Config) time.Duration { return time.Duration(cfg.Context.Timeout) * time.Second }, article.NewArticleHandler, // Echo, UseCase 필요 article.NewArticleUsecase, // Repo, AuthorRepo, Duration 필요 article.NewMysqlArticleRepository, // DB 필요 author.NewMysqlAuthorRepository, // DB 필요 ), fx.Invoke(registerHooks), // 서버 시작 )
화살표는 의존 방향이다. fx는 이 그래프를 선언 없이 매개변수·반환 타입만으로 만든다. 순환이 생기면 앱 시작 시점에 어디가 물렸는지 알려준다.
func registerHooks(lifecycle fx.Lifecycle, e *echo.Echo, cfg *config.Config) { lifecycle.Append(fx.Hook{ OnStart: func(context.Context) error { fmt.Println("Starting server") go e.Start(cfg.Server.Address) return nil }, OnStop: func(context.Context) error { fmt.Println("Stopping server") return nil }, }) }
fx.Invoke(registerHooks)로 등록한다.
fx.Invoke 함수 본문이 즉시 실행된다. 여기서 하는 일은 Lifecycle에 훅을 등록하는 것뿐. (fx.Populate도 이 시점에 채워진다)OnStart 훅이 실행된다 — 실제 서버 기동은 여기서 일어난다OnStop 훅이 실행된다 — Graceful ShutdownStart까지 미루기 위해서다.
앱이 커지면 Provide 목록이 수십 줄이 된다.
묶고(Module), 덧씌우고(Decorate), 구분하고(name/group), 감추는(Private) 도구들.
var UserModule = fx.Module("user", fx.Provide( NewMysqlUserRepo, NewUserService, ), ) var OrderModule = fx.Module("order", fx.Provide( NewMysqlOrderRepo, NewOrderService, ), )
// 실전: 각 도메인 패키지가 Module 을 노출하고 // main 에서 조합한다 app := fx.New( fx.Provide(config.New, database.New, NewEcho), article.Module, // article 도메인 author.Module, // author 도메인 payment.Module, // payment 도메인 fx.Invoke(registerHooks), )
Module은 이름을 붙여 묶어줄 뿐이다. 안의 Provide는 여전히 전역 그래프에 노출된다. 감추려면 22번의 fx.Private이 필요하다.
// 로깅 데코레이터: 기존 UserRepository 를 래핑 type loggingUserRepo struct { inner UserRepository logger Logger } func (r *loggingUserRepo) FindByID(id int) string { r.calls = append(r.calls, fmt.Sprintf("FindByID(%d)", id)) return r.inner.FindByID(id) // 원본 호출 } app := fxtest.New(t, fx.Provide(NewLogger, NewMysqlUserRepo, NewUserService), fx.Decorate(func(repo UserRepository, logger Logger) UserRepository { return &loggingUserRepo{inner: repo, logger: logger} }), )
UserService는 코드 한 줄 안 고치고 래퍼를 주입받는다.
fx.Provide( fx.Annotate(NewReadDB, fx.ResultTags(`name:"readDB"`)), fx.Annotate(NewWriteDB, fx.ResultTags(`name:"writeDB"`)), NewDBService, )
fx.In 구조체로 매칭type DBParams struct { fx.In ReadDB *DBConnection `name:"readDB"` WriteDB *DBConnection `name:"writeDB"` } func NewDBService(p DBParams) *DBService { return &DBService{ readDB: p.ReadDB, writeDB: p.WriteDB, } }
fx.Out)
fx.Provide( fx.Annotate(func() Notifier { return &EmailNotifier{} }, fx.ResultTags(`group:"notifiers"`)), fx.Annotate(func() Notifier { return &SlackNotifier{} }, fx.ResultTags(`group:"notifiers"`)), fx.Annotate(func() Notifier { return &SMSNotifier{} }, fx.ResultTags(`group:"notifiers"`)), NewNotifierService, )
type NotifierParams struct { fx.In Notifiers []Notifier `group:"notifiers"` } func NewNotifierService(p NotifierParams) *NotifierService { return &NotifierService{notifiers: p.Notifiers} }
| 패턴 | 용도 | 수신 측 | 구현체가 늘면 |
|---|---|---|---|
name:"X" | 동일 타입을 개별 식별 | 단일 필드 | 수신 코드도 고쳐야 한다 |
group:"Y" | 동일 타입·인터페이스를 모음 | 슬라이스 필드 | 수신 코드는 그대로 |
PrivateModule := fx.Module("private", fx.Provide( newInternalDB, fx.Private, // ← 이 Provide 그룹 전체를 // Module 내부 전용으로 ), fx.Provide(newModuleService), // 이건 외부 노출 ) // 외부에서 *internalDB 를 요청하면? leakApp := fx.New( PrivateModule, fx.Populate(&leaked), fx.NopLogger, ) // leakApp.Err() != nil → 그래프 구성 단계에서 에러
*internalDB는 같은 Module 안의 newModuleService만 주입받을 수 있다.
fx로 조립한 앱을 테스트에서 어떻게 띄우고, 어떻게 Mock을 끼워 넣고, 조립된 인스턴스를 어떻게 꺼내는가.
app := fxtest.New(t, fx.Provide(NewLogger, NewMysqlUserRepo, NewUserService), fx.Invoke(func(svc *UserService) { /* ... */ }), ) defer app.RequireStop() app.RequireStart()
t로 리포트하고, 정리를 도와주며, fx 로그가 테스트 출력에 포함된다.
RequireStart/RequireStop은 실패하면 테스트를 즉시 중단시킨다.
app := fxtest.New(t, fx.Provide(NewLogger, NewMysqlUserRepo, NewUserService), // 실제 UserRepository 를 Mock 으로 교체 fx.Replace(fx.Annotate(&mockUserRepo{}, fx.As(new(UserRepository)))), fx.Invoke(func(svc *UserService) { result := svc.repo.FindByID(1) // result == "mock-user-1" }), )
fx.As가 왜 필요한가
fx는 타입으로 매칭한다. &mockUserRepo{}의 타입은 *mockUserRepo이지 UserRepository가 아니다.
fx.As(new(UserRepository))로 인터페이스 타입으로 등록해야 기존 Provide를 교체한다.
// 방식 1: Invoke 클로저로 캡처 var svc1 *UserService app1 := fxtest.New(t, fx.Provide(NewLogger, NewMysqlUserRepo, NewUserService), fx.Invoke(func(s *UserService) { svc1 = s }), )
// 방식 2: Populate 로 직접 추출 var svc2 *UserService app2 := fxtest.New(t, fx.Provide(NewLogger, NewMysqlUserRepo, NewUserService), fx.Populate(&svc2), ) // 여러 개도 한 번에 fx.Populate(&svc, &logger)
| 구분 | fx.Invoke | fx.Populate |
|---|---|---|
| 본질 | 함수를 실행한다 | 변수에 값을 채운다 |
| 넘기는 것 | 함수(클로저) | 포인터 |
| 고르는 기준 | 꺼낸 뒤 호출·검증까지 할 때 | 꺼내는 것 자체가 목적일 때 |
무엇을 언제 고를지, 그리고 — 어디까지 fx에 맡기지 않을 것인지.
| 하고 싶은 일 | 메서드 | 기억법 |
|---|---|---|
| 의존성을 그래프에 등록 (lazy) | fx.Provide | 즉시 실행 아님 |
| 앱 시작 시 즉시 실행 (서버 기동) | fx.Invoke | 부수 효과면 Invoke |
| 이미 만든 값·설정을 주입 | fx.Supply | 상수·구성값 |
| 시작/종료 훅 (Graceful Shutdown) | fx.Lifecycle | Invoke 안에서 Append |
| 도메인별로 의존성 묶기 | fx.Module | 커진 Provide 분리 |
| 기존 의존성에 로깅·캐싱 덧입히기 | fx.Decorate | 원본 수정 없이 래핑 |
| 동일 타입을 개별 식별 (read/write) | Annotate + name: | 수신 측 단일 필드 |
| 동일 인터페이스를 모아서 주입 | group: | 수신 측 슬라이스 |
| Module 내부 의존성 숨기기 | fx.Private | 인프라 핸들 격리 |
| 테스트에서 Mock 주입 | fx.Replace | fx.As로 인터페이스 매칭 |
| 테스트에서 인스턴스 꺼내기 | fx.Populate | 검증까지면 Invoke |
수명주기 관리가 필요한 컴포넌트
DB 연결 · HTTP 서버 · 외부 클라이언트 — 시작과 정리가 짝을 이루고, 여러 곳에서 공유되는 것들
단순한 값 객체와 유틸리티
생성 비용이 없고 수명주기도 없는 것을 그래프에 올리면 얻는 것 없이 간접성만 늘어난다
Provide는 등록만 하고 실행은 미루는 lazy 등록이기 때문이다. 그래프가 조립되려면 그 타입을 요구하는 쪽이 있어야 하고, 그 시작점이 fx.Invoke다. → 슬라이드 09 · 10Provide는 생성자 함수를 등록하고 fx가 반환 타입으로 그래프에 연결한다. Supply는 이미 만들어진 값을 그대로 등록한다. 설정 구조체·상수처럼 생성 로직이 없는 값에 맞다. → 슬라이드 11Invoke 본문은 fx.New() 때 즉시 실행되지만 거기서 하는 일은 훅 등록뿐이고, OnStart 본문은 app.Start(ctx)에서 실행된다. → 슬라이드 15fx.Private이다. Module은 이름을 붙여 묶어줄 뿐, 안의 Provide는 전역 그래프에 노출된다. 같은 Provide 그룹에 fx.Private을 넣어야 감춰진다. → 슬라이드 17 · 22fx.Decorate다. 원본을 매개변수로 받아 래핑한 같은 타입을 반환하면 fx가 그래프의 해당 노드를 교체한다. 주입받는 쪽은 코드 변경 없이 래퍼를 받는다. → 슬라이드 18fx.Annotate + fx.ResultTags로 생성자마다 name:"readDB" 같은 이름을 붙이고, 수신 측은 fx.In을 임베드한 구조체 필드에 같은 태그를 단다. → 슬라이드 19group:"notifiers"로 등록하고 []Notifier 슬라이스로 받으면 수신 코드는 그대로다. → 슬라이드 20 · 21&mockUserRepo{}의 타입은 *mockUserRepo이지 인터페이스가 아니기 때문이다. fx.Annotate(..., fx.As(new(UserRepository)))로 감싸야 교체된다. → 슬라이드 25Populate는 내부적으로 Invoke로 구현된 편의 함수다. 차이는 목적이다. 담는 것만이 목적이면 Populate, 꺼낸 뒤 호출·검증까지 하면 Invoke. → 슬라이드 26Provide는 lazy 등록, Invoke는 eager 실행. Invoke가 없으면 아무 일도 일어나지 않는다Lifecycle은 2단계다 — New에서 훅을 등록하고, Start에서 본문을 실행한다Module로 묶고, Decorate로 덧씌우고, name/group으로 구분하고, Private으로 감춘다fxtest + Replace(fx.As 잊지 말 것) + Populate