go2026년 5월 24일13 min read

uber/fx로 의존성 주입 구현하기

uber/fx를 사용하여 Go 애플리케이션의 의존성을 자동으로 연결하고 수명주기를 관리하는 방법을 다룬다. fx.Module, fx.Decorate, fx.Annotate 등 고급 패턴과 테스트 전략까지 실전 예제로 설명한다.

FFrank Advenoh
#Go#Golang#uber/fx

1. 들어가며

Go 애플리케이션이 커지면 의존성 조립이 복잡해진다. main()에서 생성자를 하나하나 호출하고, 매개변수 순서를 맞추고, 수명주기를 직접 관리해야 한다. uber/fx는 이 문제를 해결하는 Go용 DI(Dependency Injection) 프레임워크다.

이 글에서 다루는 범위는 다음과 같다.

  • 기본 API: fx.Provide, fx.Invoke, fx.Supply, fx.New
  • 수명주기 관리: fx.Lifecycle (OnStart/OnStop)
  • 그룹화·확장 패턴: fx.Module, fx.Decorate
  • 동일 타입 다중 인스턴스: fx.Annotate + name: / group: 태그 (fx.Group)
  • Module 캡슐화: fx.Private
  • 테스트 전략: fxtest.New, fx.Replace, fx.Populate

2. fx 기초

2.1 Go에서 DI가 필요한 이유

레이어가 분리된 실전 프로젝트에서는 의존성이 많다. 예를 들어 Article API를 구성하려면 다음 의존성을 순서대로 조립해야 한다.

// 수동 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
articleUsecase := article.NewArticleUsecase(articleRepo, authorRepo, timeout)

e := NewEcho()
article.NewArticleHandler(e, articleUsecase)

e.Start(cfg.Server.Address)

의존성이 늘어날수록 이 코드는 급격히 복잡해진다. 순서를 틀리면 컴파일 에러가 나고, 새로운 서비스를 추가할 때마다 main()을 수정해야 한다.

uber/fx는 이 문제를 해결한다. 생성자 함수의 매개변수와 반환 타입을 분석하여 의존성 그래프를 자동으로 구성하고, 올바른 순서로 생성한다.

go get go.uber.org/fx

2.2 fx 기본 개념

본격적으로 들어가기 전에, 이 글에서 다루는 fx 메서드를 한눈에 정리한다. fx는 메서드가 많아 매번 헷갈리기 쉬우므로, 아래 표를 지도 삼아 읽으면 좋다. 분류 순서는 글의 진행 순서(기초 → 확장 → 테스트)와 일치한다.

메서드분류역할도입 버전
fx.New기초앱 컨테이너 생성
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.ResultTags + name:확장동일 타입을 개별 식별
group: 태그확장동일 인터페이스 구현체를 슬라이스로 모음
fx.Private확장Module 내부 의존성 캡슐화v1.20+
fxtest.New테스트테스트 전용 앱 생성
fx.Replace테스트기존 Provide를 Mock으로 교체
fx.Populate테스트컨테이너 내부 인스턴스를 외부 변수로 추출

이후 섹션에서 각 메서드를 실전 예제로 하나씩 다룬다. 우선 가장 기초가 되는 fx.Provide, fx.Invoke, fx.Supply, fx.New부터 살펴보자.

fx.Provide()에 등록된 생성자는 즉시 실행되지 않는다. 다른 곳에서 해당 타입이 필요할 때 lazy하게 생성된다.

// fx_test.go
func TestFx_Provide_Invoke(t *testing.T) {
    var svc *UserService

    app := fxtest.New(t,
        // fx.Provide: 생성자를 등록만 한다. 즉시 실행되지 않고, 의존성으로 필요해질 때 lazy하게 호출된다.
        fx.Provide(
            NewLogger,       // Logger 인터페이스 반환
            NewMysqlUserRepo, // UserRepository 인터페이스 반환 (Logger 필요)
            NewUserService,   // *UserService 반환 (UserRepository 필요)
        ),
        // fx.Invoke: 앱 시작 시 즉시 실행되는 부수 효과. 여기서는 조립된 UserService를 외부 변수로 꺼낸다.
        fx.Invoke(func(s *UserService) {
            svc = s
        }),
    )
    defer app.RequireStop()
    app.RequireStart()

    assert.Equal(t, "user-1", svc.repo.FindByID(1))
}

fx.Supply()는 생성자 없이 이미 만들어진 값을 직접 제공한다.

// fx_test.go
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"
    }),
)

2.3 실전 프로젝트에 fx 적용

수동 DI 코드를 fx로 변환하면 다음과 같다.

// cmd/main.go
app := fx.New(
    fx.Provide(
        config.New,              // *config.Config
        database.New,            // *sql.DB (*config.Config 필요)
        NewEcho,                 // *echo.Echo
        ProvideBasicConfig,      // time.Duration

        article.NewArticleHandler,         // Handler (Echo, UseCase 필요)
        article.NewArticleUsecase,         // UseCase (Repo, AuthorRepo, Duration 필요)
        article.NewMysqlArticleRepository, // Repository (DB 필요)

        author.NewMysqlAuthorRepository,   // AuthorRepo (DB 필요)
    ),
    fx.Invoke(registerHooks),  // 서버 시작
)

fx는 각 생성자의 매개변수 타입을 보고 의존성 순서를 자동으로 결정한다. 예를 들어 database.New(cfg *config.Config)*config.Config가 필요하므로 config.New()가 먼저 호출된다.

각 생성자의 시그니처를 보면 의존성 관계가 명확하다.

// pkg/config/config.go
func New() (*config.Config, error) { ... }

// pkg/database/db.go
func New(cfg *config.Config) (*sql.DB, error) { ... }

// article/usecase.go
func NewArticleUsecase(
    a domain.ArticleRepository,
    ar domain.AuthorRepository,
    timeout time.Duration,
) domain.ArticleUsecase { ... }

// article/handler.go
func NewArticleHandler(e *echo.Echo, us domain.ArticleUsecase) *ArticleHandler { ... }

fx.Provide()에는 위처럼 미리 정의한 생성자뿐 아니라 익명 함수도 그대로 등록할 수 있다. 간단한 변환 로직은 별도 생성자 파일을 만들 필요 없이 인라인으로 처리하면 편하다. 위의 ProvideBasicConfig를 익명 함수로 풀어 쓰면 다음과 같다.

// cmd/main.go
app := fx.New(
    fx.Provide(
        config.New,
        database.New,
        NewEcho,

        // 익명 함수도 생성자로 등록 가능 — 매개변수·반환 타입만 맞으면 된다
        func(cfg *config.Config) time.Duration {
            return time.Duration(cfg.Context.Timeout) * time.Second
        },

        article.NewArticleHandler,
        article.NewArticleUsecase,
        article.NewMysqlArticleRepository,
        author.NewMysqlAuthorRepository,
    ),
    fx.Invoke(registerHooks),
)

fx는 named 생성자와 똑같이 익명 함수의 매개변수(*config.Config)와 반환 타입(time.Duration)을 분석해 의존성 그래프에 연결한다. 함수 형태만 다를 뿐, fx 입장에서는 동일한 생성자다.

이렇게 등록한 생성자들로 fx가 구성하는 의존성 그래프를 시각화하면 다음과 같다.

graph TD
    Config["config.New()"] --> Database["database.New()"]
    Database --> ArticleRepo["NewMysqlArticleRepository()"]
    Database --> AuthorRepo["NewMysqlAuthorRepository()"]
    ArticleRepo --> ArticleUsecase["NewArticleUsecase()"]
    AuthorRepo --> ArticleUsecase
    ArticleUsecase --> ArticleHandler["NewArticleHandler()"]
    Config --> Echo["NewEcho()"]
    Config --> RegisterHooks["registerHooks()"]
    Echo --> RegisterHooks
    ArticleHandler --> RegisterHooks

fx는 이 그래프를 생성자의 매개변수와 반환 타입만으로 자동 구성한다. 순환 의존성이 있으면 앱 시작 시 명확한 에러 메시지를 출력한다.

2.4 Lifecycle 관리

fx.Lifecycle은 앱의 시작과 종료를 관리한다. OnStart에서 서버를 시작하고, OnStop에서 Graceful Shutdown을 처리한다.

// cmd/main.go
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
            },
        },
    )
}

registerHooksfx.Invoke()로 등록한다. 한 가지 주의할 점은 실행 시점이다. fx.Invoke(그리고 이를 내부적으로 사용하는 fx.Populate)는 app.Start()가 아니라 fx.New() 호출 시점에 즉시 실행된다. 따라서 위 registerHooksfx.New() 단계에서 호출되어 Lifecycle에 OnStart/OnStop 훅을 등록만 하고, 실제 서버 기동(OnStart 본문)은 이후 app.Start(ctx) 시점에 트리거된다. fx.Lifecycle이 이런 2단계 구조를 가지는 이유다 — Invoke로 일찍 훅을 등록해두고, 훅 본문 실행은 Start까지 미루는 것.

단계시점실행되는 것
1fx.New() / fxtest.New()fx.Invoke 함수 본문 (= fx.Populate도 여기서 채워짐), Lifecycle 훅 등록
2app.Start(ctx)등록된 OnStart 훅 실행 (서버 기동 등)
3app.Stop(ctx)등록된 OnStop 훅 실행 (Graceful Shutdown)
// fx_test.go
func TestFx_Lifecycle(t *testing.T) {
    var startCalled, stopCalled bool

    app := fxtest.New(t,
        fx.Invoke(func(lc fx.Lifecycle) {
            lc.Append(fx.Hook{
                OnStart: func(context.Context) error {
                    startCalled = true
                    return nil
                },
                OnStop: func(context.Context) error {
                    stopCalled = true
                    return nil
                },
            })
        }),
    )

    app.RequireStart()
    assert.True(t, startCalled)

    app.RequireStop()
    assert.True(t, stopCalled)
}

3. 확장 패턴

fx.Module부터 fx.Private까지, fx 기초 위에 쌓이는 확장 도구들을 살펴본다.

3.1 fx.Module 패턴

fx.Module()은 관련 의존성을 도메인별로 그룹화한다. 앱이 커질수록 fx.Provide()에 생성자가 한꺼번에 나열되면 가독성이 떨어진다. Module로 분리하면 관심사가 명확해진다.

// fx_test.go
var UserModule = fx.Module("user",
    fx.Provide(
        NewMysqlUserRepo,
        NewUserService,
    ),
)

var OrderModule = fx.Module("order",
    fx.Provide(
        NewMysqlOrderRepo,
        NewOrderService,
    ),
)

app := fxtest.New(t,
    fx.Provide(NewLogger), // 공통 의존성
    UserModule,
    OrderModule,
    fx.Invoke(func(u *UserService, o *OrderService) {
        // 모든 의존성이 자동으로 연결됨
    }),
)

실제 프로젝트에서는 각 도메인 패키지에 Module 변수를 정의하고, main()에서 조합하는 패턴이 일반적이다.

// 실전 적용 예시
app := fx.New(
    fx.Provide(config.New, database.New, NewEcho),
    article.Module,   // article 도메인 모듈
    author.Module,    // author 도메인 모듈
    payment.Module,   // payment 도메인 모듈
    fx.Invoke(registerHooks),
)

fx.Module은 v1.17.0+부터 사용 가능하다. 이전 버전에서는 fx.Options()로 유사한 그룹화가 가능하지만, 모듈 이름과 스코프 격리는 지원하지 않는다.

3.2 fx.Decorate 패턴

fx.Decorate()는 기존 의존성을 래핑하여 동작을 추가한다. 데코레이터 패턴과 동일한 개념으로, 로깅, 캐싱, 메트릭 수집 등에 활용된다.

// fx_test.go
// 로깅 데코레이터: 기존 UserRepository를 래핑
type loggingUserRepo struct {
    inner  UserRepository
    logger Logger
    calls  []string
}

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),
    // 기존 UserRepository를 로깅 래퍼로 교체
    fx.Decorate(func(repo UserRepository, logger Logger) UserRepository {
        return &loggingUserRepo{inner: repo, logger: logger}
    }),
    fx.Invoke(func(svc *UserService) {
        svc.repo.FindByID(1) // 로깅 래퍼를 통해 호출됨
    }),
)

fx.Decorate()는 원본 의존성을 매개변수로 받아 래핑된 새 인스턴스를 반환한다. UserService는 변경 없이 자동으로 래핑된 Repository를 주입받는다.

fx.Decorate는 v1.18.0+부터 사용 가능하다.

3.3 fx.Annotate + Named 의존성

동일 타입의 여러 인스턴스를 구분해야 할 때 fx.Annotate()name 태그를 사용한다. 예를 들어 Read/Write DB를 분리하는 경우다.

// fx_test.go
type DBConnection struct {
    Name string
    DSN  string
}

func NewReadDB() *DBConnection {
    return &DBConnection{Name: "read", DSN: "read-replica:3306"}
}

func NewWriteDB() *DBConnection {
    return &DBConnection{Name: "write", DSN: "primary:3306"}
}

fx.Annotate()로 각 생성자에 이름을 부여한다.

// fx_test.go
fx.Provide(
    fx.Annotate(NewReadDB, fx.ResultTags(`name:"readDB"`)),
    fx.Annotate(NewWriteDB, fx.ResultTags(`name:"writeDB"`)),
    NewDBService,
)

수신 측에서는 fx.In 구조체에 name 태그로 매칭한다.

// fx_test.go
type DBParams struct {
    fx.In
    ReadDB  *DBConnection `name:"readDB"`
    WriteDB *DBConnection `name:"writeDB"`
}

func NewDBService(params DBParams) *DBService {
    return &DBService{
        readDB:  params.ReadDB,  // read-replica:3306
        writeDB: params.WriteDB, // primary:3306
    }
}

3.4 fx.Group으로 동일 인터페이스 여러 구현체 모으기

name: 태그는 동일 타입을 개별 식별할 때 쓴다. 하지만 동일 인터페이스의 여러 구현체를 한꺼번에 주입받고 싶다면 — 예를 들어 모든 Notifier에게 알림을 발송하는 경우 — name:으로는 부족하다. 각 구현체에 다른 이름을 붙이고 수신 측에서 일일이 받아야 하기 때문이다.

group: 태그는 이 문제를 해결한다. 같은 그룹에 등록된 구현체들이 슬라이스로 한꺼번에 주입된다.

// fx_test.go
type Notifier interface {
    Send(msg string) string
}

type EmailNotifier struct{}
func (e *EmailNotifier) Send(msg string) string { return "email:" + msg }

type SlackNotifier struct{}
func (s *SlackNotifier) Send(msg string) string { return "slack:" + msg }

type SMSNotifier struct{}
func (s *SMSNotifier) Send(msg string) string { return "sms:" + msg }

fx.Annotate()fx.ResultTags()로 각 생성자를 같은 그룹에 등록한다.

// fx_test.go
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,
)

수신 측은 fx.In 구조체에 group: 태그가 붙은 슬라이스 필드로 받는다.

// fx_test.go
type NotifierParams struct {
    fx.In
    Notifiers []Notifier `group:"notifiers"`
}

type NotifierService struct {
    notifiers []Notifier
}

func NewNotifierService(p NotifierParams) *NotifierService {
    return &NotifierService{notifiers: p.Notifiers}
}

여러 외부 서비스 클라이언트를 단일 인터페이스 슬라이스로 모으는 패턴이 대표적인 실전 활용 예다. 새 구현체가 추가되어도 수신 측 코드는 변경되지 않는다.

name: vs group: 차이를 정리하면:

패턴용도수신 측
name:"X"동일 타입을 개별 식별단일 필드
group:"Y"동일 타입(또는 인터페이스)을 모음슬라이스 필드

3.5 fx.Private로 Module 캡슐화

fx.Module()로 도메인을 분리해도 모든 fx.Provide()는 기본적으로 전역에 노출된다. Module 내부 전용으로만 쓰고 싶은 의존성은 fx.Private으로 막을 수 있다. 데이터베이스 핸들이나 외부 API 클라이언트 같은 인프라 의존성을 다른 Module이 우연히 같은 인스턴스를 공유하는 걸 막을 때 유용하다.

fx.Private은 같은 fx.Provide() 호출 안에 다른 생성자와 함께 넣으면 그 그룹 전체를 Module-private으로 만든다.

// fx_test.go
type internalDB struct {
    name string
}

func newInternalDB() *internalDB {
    return &internalDB{name: "private-db"}
}

type ModuleService struct {
    db *internalDB
}

func newModuleService(db *internalDB) *ModuleService {
    return &ModuleService{db: db}
}

PrivateModule := fx.Module("private",
    fx.Provide(
        newInternalDB,
        fx.Private,        // 같은 fx.Provide() 그룹 전체를 Module 내부 전용으로
    ),
    fx.Provide(newModuleService), // ModuleService는 외부 노출
)

*internalDBPrivateModule 안의 newModuleService만 주입받을 수 있다. Module 외부에서 *internalDB를 직접 요청하면 fx는 의존성 그래프 구성 시점에 에러를 반환한다(fx.Populate는 §2.8.3에서 다룬다).

// fx_test.go
// 외부에서 *internalDB 직접 추출 시도 → fx.New가 에러 반환
var leaked *internalDB
leakApp := fx.New(
    PrivateModule,
    fx.Populate(&leaked),
    fx.NopLogger,
)
// leakApp.Err() != nil

fx.Private은 v1.20.0+부터 사용 가능하다. 이전 버전에서는 fx.Module로 격리하더라도 모든 Provide가 전역 그래프에 등록된다.

4. 테스트 전략

fx로 구성한 앱은 fxtest 패키지로 테스트한다. mock 주입과 인스턴스 추출까지 살펴본다.

4.1 fxtest.New

fxtest.New()는 테스트 전용 앱을 생성한다. 테스트 실패 시 자동으로 정리되고, fx 로그가 테스트 출력에 포함된다.

// fx_test.go
app := fxtest.New(t,
    fx.Provide(NewLogger, NewMysqlUserRepo, NewUserService),
    fx.Invoke(func(svc *UserService) { ... }),
)
defer app.RequireStop()
app.RequireStart()

4.2 fx.Replace로 Mock 주입

fx.Replace()는 기존 Provide를 완전히 교체한다. 테스트에서 실제 구현 대신 Mock을 주입할 때 유용하다.

// fx_test.go
type mockUserRepo struct{}

func (r *mockUserRepo) FindByID(id int) string {
    return fmt.Sprintf("mock-user-%d", id) // Mock 응답
}

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(new(UserRepository))*mockUserRepoUserRepository 인터페이스로 타입 변환하여 등록한다.

4.3 fx.Populate로 인스턴스 추출

지금까지는 fx.Invoke(func(s *Svc) { svc = s }) 형태로 외부 변수에 인스턴스를 캡처했다. fx.Populate는 같은 일을 더 간결하게 한다.

사실 fx.Populate는 내부적으로 fx.Invoke로 구현된 편의 함수다. fx.Populate(&svc)는 "주입받은 값을 svc에 대입하는 fx.Invoke"를 자동으로 생성하는 것과 같다. 즉 둘의 본질은 동일하다. 차이는 목적에 있다. fx.Invoke는 꺼낸 의존성으로 무언가를 실행하는 게 목적이라 클로저 본문에서 호출·검증 등 무엇이든 할 수 있고(추출은 그중 하나일 뿐), fx.Populate추출 자체가 목적이라 클로저가 군더더기일 때 이를 생략한 형태다.

두 메서드를 비교하면 다음과 같다.

구분fx.Invokefx.Populate
본질함수를 실행한다변수에 값을 채운다
넘기는 것함수(클로저)포인터
본문있음 — 대입·호출·검증 등 무엇이든없음 — 추출만
추출클로저 안에서 svc = s로 부수적으로 가능그 자체가 유일한 목적
// fx_test.go
// 방식 1: fx.Invoke 클로저로 캡처 (앞서 사용한 방식)
var svc1 *UserService
app1 := fxtest.New(t,
    fx.Provide(NewLogger, NewMysqlUserRepo, NewUserService),
    fx.Invoke(func(s *UserService) {
        svc1 = s
    }),
)

// 방식 2: fx.Populate로 직접 추출
var svc2 *UserService
app2 := fxtest.New(t,
    fx.Provide(NewLogger, NewMysqlUserRepo, NewUserService),
    fx.Populate(&svc2),
)

여러 인스턴스를 한꺼번에 추출할 때 차이가 더 두드러진다.

// fx_test.go
var (
    svc    *UserService
    logger Logger
)
app := fxtest.New(t,
    fx.Provide(NewLogger, NewMysqlUserRepo, NewUserService),
    fx.Populate(&svc, &logger),
)

선택 가이드는 단순하다.

상황권장
인스턴스를 외부 변수로 꺼내는 게 목적fx.Populate
추출 후 함수 호출이나 추가 검증을 같은 시점에 수행fx.Invoke

5. 마무리

fx는 메서드가 많아 헷갈리기 쉽다. 상황별로 무엇을 고르면 되는지 정리하면 다음과 같다.

하고 싶은 일메서드선택 팁
의존성을 그래프에 등록 (나중에 lazy 생성)fx.Provide즉시 실행 아님 — 필요할 때 호출
앱 시작 시 즉시 실행 (서버 기동·라우터 등록)fx.InvokeProvide와 헷갈리면 "부수 효과면 Invoke"
이미 만든 값·설정을 생성자 없이 주입fx.Supply상수·구성값에 적합
시작/종료 훅 관리 (Graceful Shutdown)fx.LifecycleInvoke 안에서 Append로 등록
도메인별로 의존성 묶기fx.Module커진 Provide 목록을 분리
기존 의존성에 로깅·캐싱 덧입히기fx.Decorate원본 코드 수정 없이 래핑
동일 타입을 개별 식별 (read/write DB)fx.Annotate + name:수신 측은 단일 필드
동일 인터페이스 구현체를 모아서 주입group: 태그수신 측은 슬라이스 필드
Module 내부 의존성을 외부에 숨기기fx.Private인프라 핸들 격리
테스트에서 실제 구현 대신 Mockfx.Replacefx.As로 인터페이스 매칭
테스트에서 컨테이너 내부 인스턴스 꺼내기fx.Populate검증까지 같이 하면 fx.Invoke

한 가지만 기억하자. 모든 의존성을 fx로 관리할 필요는 없다. 단순한 값 객체나 유틸리티는 직접 생성하는 편이 명확하고, fx는 수명주기 관리가 필요한 컴포넌트(DB 연결·HTTP 서버·외부 클라이언트)에 집중할 때 가장 빛난다. 리플렉션 기반이라 컴파일 타임 타입 안전성은 일부 포기하지만, 상세한 런타임 에러 메시지와 실전 생산성이 이를 충분히 상쇄한다.

전체 소스는 다음 두 곳에서 확인할 수 있다.

6. 참고

관련 글