블로그

  • Dependency Injection with “GetIt” .aka DI

    Dependency Injection

    목차

    1. DI란 무엇인가?
    2. DI의 장단점
    3. DI 구현 방법
    4. 순수 Dart로 DI 구현하기
    5. GetIt 사용하기
    6. Injectable로 자동화하기
    7. IoC와 DI의 차이
    8. 인터페이스를 활용한 고급 패턴

    DI란 무엇인가?

    **Dependency Injection(의존성 주입, 이하 DI)**은 객체 간의 결합도를 낮추고 유연성을 높이는 디자인 패턴입니다.

    핵심 개념

    DI는 외부에서 객체 간의 의존성을 결정해줍니다. 즉, 객체를 직접 생성하는 것이 아닌 외부에서 생성한 후 주입시켜주는 방식입니다.

    // ❌ DI 없이: 직접 생성 (강한 결합)
    class Member {
      AuthService service = AuthService(); // 직접 생성
    }
    
    // ✅ DI 사용: 외부에서 주입 (느슨한 결합)
    class Member {
      AuthService service;
      Member(this.service); // 생성자를 통해 주입받음
    }
    

    왜 중요한가?

    위의 첫 번째 예시에서 AuthService의 생성자가 변경되면 어떻게 될까요?

    // AuthService가 변경됨
    class AuthService {
      final ApiClient client;
      AuthService(this.client); // 생성자에 파라미터 추가!
    }
    
    // ❌ DI 없는 코드: 컴파일 에러 발생!
    class Member {
      AuthService service = AuthService(); // 에러: AuthService() requires 1 argument
    }
    
    // ✅ DI 사용: Member 코드는 변경 불필요
    class Member {
      AuthService service;
      Member(this.service); // 그대로 사용 가능
    }
    

    DI를 사용하면 Member 클래스는 AuthService의 구현 세부사항을 몰라도 되며, AuthService의 변경이 Member에 직접적인 영향을 주지 않습니다.


    DI의 장단점

    장점

    1. Unit Test가 용이해짐

    의존성을 Mock 객체로 쉽게 대체할 수 있습니다.

    // 테스트에서 Mock 객체 주입
    class MockAuthService extends AuthService {
      @override
      Future<User> login(String email, String password) async {
        return User(id: '1', name: 'Test User');
      }
    }
    
    // 테스트
    void main() {
      test('Member uses auth service', () {
        final mockService = MockAuthService();
        final member = Member(mockService); // Mock 주입
        // 실제 네트워크 호출 없이 테스트 가능
      });
    }
    

    2. 코드의 재사용성 증가

    같은 클래스를 다른 구현체와 함께 사용할 수 있습니다.

    // 프로덕션: 실제 API 사용
    final prodMember = Member(RealAuthService());
    
    // 개발: Mock API 사용
    final devMember = Member(MockAuthService());
    
    // 테스트: Test Double 사용
    final testMember = Member(TestAuthService());
    

    3. 객체 간 의존성 감소

    인터페이스를 통해 구현체에 대한 의존성을 제거할 수 있습니다.

    // 추상화된 인터페이스에 의존
    abstract class AuthService {
      Future<User> login(String email, String password);
    }
    
    class Member {
      final AuthService service; // 구체적 구현이 아닌 인터페이스에 의존
      Member(this.service);
    }
    
    // 어떤 구현체든 사용 가능
    class RealAuthService implements AuthService { /* ... */ }
    class MockAuthService implements AuthService { /* ... */ }
    

    단점

    1. 초기 러닝 커브

    DI 개념과 사용법을 학습하는 데 시간이 필요합니다.

    2. 주입된 객체들의 코드 추적이 어려움

    의존성이 외부에서 주입되므로 객체 생성 흐름을 파악하기 어려울 수 있습니다.

    // 이 Member는 어떤 AuthService를 사용하는지 코드만으로는 알기 어려움
    final member = getIt<Member>();
    

    3. 복잡성 증가

    단일 책임 원칙을 따르다 보면 클래스 수가 증가하고 초기 설정이 복잡해질 수 있습니다.


    DI 구현 방법

    1. Constructor Injection (생성자 주입) ⭐ 권장

    가장 많이 사용되고 권장되는 방법입니다.

    class Member {
      final AuthService service;
      
      // 생성자를 통해 의존성 주입
      Member(this.service);
      
      void doSomething() {
        service.login('user@example.com', 'password');
      }
    }
    
    // 사용
    final authService = AuthService();
    final member = Member(authService);
    

    장점:

    • 의존성이 명확하게 드러남
    • 불변성(immutability) 보장 가능 (final 사용)
    • 필수 의존성 강제 (생성자 파라미터로 명시)
    • 테스트가 쉬움

    2. Setter Injection (세터 주입)

    class Member {
      late AuthService service;
      
      void setService(AuthService service) {
        this.service = service;
      }
    }
    
    // 사용
    final member = Member();
    member.setService(AuthService());
    

    단점:

    • 객체가 불완전한 상태로 존재할 수 있음
    • service를 설정하지 않으면 런타임 에러 발생 가능
    • 의존성이 숨겨져 있음

    3. Field Injection (필드 주입)

    class Member {
      @inject // 프레임워크가 자동으로 주입
      late AuthService service;
    }
    

    단점:

    • 의존성이 더욱 숨겨져 있어 파악하기 어려움
    • 테스트 작성이 복잡함
    • 불변성 보장 불가능

    순수 Dart로 DI 구현하기

    GetIt과 같은 라이브러리 없이 순수 Dart로 DI를 구현해봅시다.

    DI 사용 전

    class Member {
      AuthService service = AuthService();
      
      void login() {
        service.login('user@example.com', 'password');
      }
    }
    

    문제점:

    • AuthService의 생성자가 변경되면 Member도 수정해야 함
    • AuthService의 의존성이 추가되면 Member에서도 처리해야 함
    • 테스트 시 Mock 객체로 대체 불가능
    // AuthService에 의존성이 추가되면
    class AuthService {
      final ApiClient client;
      AuthService(this.client); // 파라미터 추가!
    }
    
    // Member도 수정해야 함
    class Member {
      AuthService service = AuthService(ApiClient()); // 수정 필요
    }
    

    DI 사용 후

    class Member {
      final AuthService service;
      Member(this.service); // 외부에서 주입받음
      
      void login() {
        service.login('user@example.com', 'password');
      }
    }
    
    // 사용
    final apiClient = ApiClient();
    final authService = AuthService(apiClient);
    final member = Member(authService);
    

    개선점:

    • AuthService의 변경이 Member에 영향을 주지 않음
    • 테스트 시 Mock 객체 주입 가능
    • 의존성이 명확하게 드러남

    수동 DI 컨테이너 만들기

    여러 곳에서 같은 인스턴스를 사용하려면 DI 컨테이너를 만들 수 있습니다.

    class ServiceLocator {
      // 싱글톤 패턴
      static final ServiceLocator _instance = ServiceLocator._internal();
      factory ServiceLocator() => _instance;
      ServiceLocator._internal();
      
      // 의존성 저장
      late final ApiClient apiClient;
      late final AuthService authService;
      
      // 초기화
      void setup() {
        apiClient = ApiClient();
        authService = AuthService(apiClient);
      }
    }
    
    // main.dart에서 초기화
    void main() {
      ServiceLocator().setup();
      runApp(MyApp());
    }
    
    // 사용
    class Member {
      final AuthService service = ServiceLocator().authService;
      
      void login() {
        service.login('user@example.com', 'password');
      }
    }
    

    문제점:

    • 모든 의존성을 수동으로 등록해야 함
    • 복잡한 의존성 그래프는 관리하기 어려움
    • 보일러플레이트 코드가 많음

    이러한 문제를 해결하기 위해 GetIt을 사용합니다.


    GetIt 사용하기

    GetIt이란?

    GetIt은 Flutter에서 가장 널리 사용되는 서비스 로케이터(Service Locator) 라이브러리입니다.

    특징

    • O(1) 검색 속도: 매우 빠른 성능
    • Context 독립적: UI 트리에 의존하지 않음
    • 간편한 사용법: 다른 DI 라이브러리 대비 낮은 러닝 커브
    • 유연성: 다양한 등록 방식 지원

    설치

    # pubspec.yaml
    dependencies:
      get_it: ^7.6.0
    

    기본 사용법

    import 'package:get_it/get_it.dart';
    
    final getIt = GetIt.instance;
    
    void main() {
      // 1. 의존성 등록
      setupDependencies();
      
      runApp(MyApp());
    }
    
    void setupDependencies() {
      // 싱글톤 등록
      getIt.registerSingleton<ApiClient>(ApiClient());
      
      // 다른 의존성을 주입받는 서비스 등록
      getIt.registerSingleton<AuthService>(
        AuthService(getIt<ApiClient>()),
      );
    }
    
    // 사용
    class Member {
      final AuthService service = getIt<AuthService>();
      
      void login() {
        service.login('user@example.com', 'password');
      }
    }
    

    다양한 등록 방식

    1. Singleton (싱글톤)

    앱 전체에서 하나의 인스턴스만 사용합니다.

    getIt.registerSingleton<ApiClient>(ApiClient());
    
    // 어디서든 같은 인스턴스
    final client1 = getIt<ApiClient>();
    final client2 = getIt<ApiClient>();
    print(client1 == client2); // true
    

    사용 시기:

    • 네트워크 클라이언트 (Dio, HttpClient)
    • 데이터베이스 인스턴스
    • 로거, 설정 관리자

    2. LazySingleton (지연 싱글톤)

    처음 요청될 때 인스턴스를 생성하고, 이후 재사용합니다.

    getIt.registerLazySingleton<DatabaseHelper>(
      () => DatabaseHelper(),
    );
    
    // 처음 호출 시 생성
    final db1 = getIt<DatabaseHelper>(); // DatabaseHelper 생성
    final db2 = getIt<DatabaseHelper>(); // 같은 인스턴스 재사용
    

    사용 시기:

    • 초기화 비용이 크지만 항상 필요하지 않은 서비스
    • 조건부로 사용되는 서비스

    3. Factory (팩토리)

    매번 새로운 인스턴스를 생성합니다.

    getIt.registerFactory<Member>(
      () => Member(getIt<AuthService>()),
    );
    
    // 매번 새로운 인스턴스
    final member1 = getIt<Member>();
    final member2 = getIt<Member>();
    print(member1 == member2); // false
    

    사용 시기:

    • 상태를 가지지 않는 서비스
    • 매번 다른 초기 상태가 필요한 객체
    • ViewModel, UseCase 등

    GetIt 사용 전후 비교

    사용 전

    class InjectionCore {
      Member createMember() {
        final apiClient = ApiClient();
        final authService = AuthService(apiClient);
        return Member(authService);
      }
    }
    
    // 사용
    final injectionCore = InjectionCore();
    final member = injectionCore.createMember();
    

    문제점:

    • InjectionCore에 모든 생성 로직이 집중됨
    • AuthService의 의존성이 변경되면 InjectionCore도 수정해야 함
    • 코드 추적이 어려워짐

    GetIt 사용 후

    // 등록
    void setupDependencies() {
      getIt.registerSingleton<ApiClient>(ApiClient());
      getIt.registerSingleton<AuthService>(
        AuthService(getIt<ApiClient>()),
      );
      getIt.registerFactory<Member>(
        () => Member(getIt<AuthService>()),
      );
    }
    
    // 소비
    class DIConsumer {
      final Member member = getIt<Member>(); // 의존성을 몰라도 됨
      
      void useMemebr() {
        member.consumeB();
      }
    }
    

    개선점:

    • 각 클래스의 의존성이 명확히 분리됨
    • Member를 사용하는 쪽은 AuthService의 존재를 몰라도 됨
    • GetIt이 의존성 그래프를 자동으로 해결함

    Injectable로 자동화하기

    Injectable은 GetIt 등록을 자동화하는 코드 생성 라이브러리입니다.

    설치

    # pubspec.yaml
    dependencies:
      get_it: ^7.6.0
      injectable: ^2.3.0
    
    dev_dependencies:
      build_runner: ^2.4.0
      injectable_generator: ^2.4.0
    

    기본 사용법

    1. 클래스에 어노테이션 추가

    // auth_service.dart
    import 'package:injectable/injectable.dart';
    
    @injectable // GetIt에 자동 등록
    class AuthService {
      final ApiClient client;
      
      AuthService(this.client); // Injectable이 자동으로 주입
      
      void printText() {
        print("hello");
      }
    }
    

    2. Module 정의 (선택사항)

    복잡한 생성 로직이나 외부 라이브러리를 등록할 때 사용합니다.

    // app_module.dart
    import 'package:injectable/injectable.dart';
    
    @module
    abstract class AppModule {
      // Dio 인스턴스 설정
      @singleton
      Dio get dio {
        final dio = Dio();
        dio.options.baseUrl = 'https://api.example.com';
        return dio;
      }
      
      // SharedPreferences (비동기)
      @preResolve // 비동기 팩토리 메서드 표시
      Future<SharedPreferences> get prefs => SharedPreferences.getInstance();
    }
    

    3. 설정 파일 생성

    // injection.dart
    import 'package:get_it/get_it.dart';
    import 'package:injectable/injectable.dart';
    import 'injection.config.dart';
    
    final getIt = GetIt.instance;
    
    @InjectableInit()
    void configureDependencies() => getIt.init();
    

    4. 코드 생성

    flutter pub run build_runner build --delete-conflicting-outputs
    

    이 명령어는 injection.config.dart 파일을 생성합니다.

    5. main.dart에서 초기화

    void main() {
      configureDependencies(); // 의존성 초기화
      runApp(MyApp());
    }
    

    6. 사용

    class DIConsumer {
      final Member member = getIt<Member>();
      
      void consumeA() {
        member.consumeB();
      }
    }
    

    Injectable vs 수동 등록 비교

    수동 등록

    void setupDependencies() {
      getIt.registerSingleton<ApiClient>(ApiClient());
      getIt.registerSingleton<AuthService>(AuthService(getIt<ApiClient>()));
      getIt.registerFactory<Member>(() => Member(getIt<AuthService>()));
    }
    

    단점:

    • 의존성이 많아지면 코드가 길어짐
    • 등록 순서를 직접 관리해야 함
    • 실수하기 쉬움

    Injectable 사용

    @injectable
    class ApiClient { }
    
    @injectable
    class AuthService {
      AuthService(ApiClient client);
    }
    
    @injectable
    class Member {
      Member(AuthService service);
    }
    
    // 자동 생성된 코드가 모든 등록을 처리
    @InjectableInit()
    void configureDependencies() => getIt.init();
    

    장점:

    • 어노테이션만 추가하면 자동 등록
    • 의존성 순서 자동 해결
    • 실수 가능성 감소

    다양한 Injectable 어노테이션

    // 싱글톤
    @singleton
    class ApiClient { }
    
    // 지연 싱글톤 (기본값)
    @lazySingleton
    class DatabaseHelper { }
    
    // 팩토리 (기본값)
    @injectable
    class Member { }
    
    // 환경별 등록
    @dev
    @injectable
    class MockAuthService implements AuthService { }
    
    @prod
    @injectable
    class RealAuthService implements AuthService { }
    

    IoC와 DI의 차이

    많은 사람들이 IoC(Inversion of Control)와 DI를 동일하게 생각하지만, 이 둘은 분명히 다릅니다.

    IoC (제어의 역전)

    IoC는 제어 흐름의 주체가 외부로 바뀌는 것을 의미하는 설계 원칙입니다.

    일반적인 제어 흐름

    class OrderProcessor {
      void process() {
        // 개발자가 직접 제어
        final validator = OrderValidator(); // 내가 생성
        final repository = OrderRepository(); // 내가 생성
        
        validator.validate(); // 내가 호출
        repository.save(); // 내가 호출
      }
    }
    

    프로그래머가 객체 생성과 메서드 호출을 직접 제어합니다.

    IoC 적용 후

    class OrderProcessor {
      final OrderValidator validator;
      final OrderRepository repository;
      
      // 외부(프레임워크/컨테이너)가 생성해서 주입
      OrderProcessor(this.validator, this.repository);
      
      void process() {
        validator.validate();
        repository.save();
      }
    }
    
    // 설정 (IoC 컨테이너가 제어)
    void setup() {
      getIt.registerFactory(() => OrderValidator());
      getIt.registerFactory(() => OrderRepository());
      getIt.registerFactory(() => OrderProcessor(
        getIt<OrderValidator>(),
        getIt<OrderRepository>(),
      ));
    }
    

    객체 생성과 생명주기 관리를 **외부(GetIt, 프레임워크)**가 제어합니다.

    제어역전 적용 전후 비교

    // 제어역전 적용 전
    1. 객체 생성 (개발자가 직접)
    2. 의존성 객체 생성 (개발자가 직접)
    3. 의존성 객체 메서드 호출
    
    // 제어역전 적용 후
    1. 객체 생성 (외부 컨테이너)
    2. 의존성 객체 주입 (외부 컨테이너)
    3. 의존성 객체 메서드 호출
    

    DI는 IoC의 구현 방법

    • IoC: “무엇을 할 것인가” (설계 원칙)
      • 제어권을 외부로 위임한다
    • DI: “어떻게 할 것인가” (디자인 패턴)
      • 의존성을 외부에서 주입한다

    Martin Fowler의 말:

    “Inversion of Control은 너무 일반적인 용어이기 때문에 사람들은 그것을 혼동한다. 그 결과 다양한 IoC 옹호자들과 많은 논의를 거쳐 Dependency Injection이라는 이름을 정했다.”

    IoC의 다른 구현 방법들

    DI 외에도 IoC를 구현하는 방법은 다양합니다:

    1. Service Locator Pattern

    final service = ServiceLocator.get<UserService>();
    

    2. Factory Pattern

    final service = ServiceFactory.createUserService();
    

    3. Template Method Pattern

    abstract class BaseService {
      void process() {
        load();
        doProcess(); // 서브클래스에서 구현 (제어가 역전됨)
        save();
      }
      
      void doProcess(); // 추상 메서드
    }
    

    4. Event-Driven Architecture

    // 이벤트 발생
    eventBus.fire(UserLoggedInEvent());
    
    // 프레임워크가 리스너 호출 (제어 역전)
    eventBus.on<UserLoggedInEvent>().listen((event) {
      // 처리
    });
    

    인터페이스를 활용한 고급 패턴

    추상화의 중요성

    구체적인 구현이 아닌 인터페이스에 의존하면 다형성을 활용할 수 있습니다.

    인터페이스 없이

    @injectable
    class Member {
      final AuthService service; // 구체 클래스에 직접 의존
      
      Member(this.service);
      
      void consumeB() {
        service.printText();
      }
    }
    

    문제점:

    • AuthService의 구체적 구현에 의존
    • 다른 구현체로 교체하기 어려움
    • 테스트 시 실제 AuthService가 필요함

    인터페이스 사용

    // 1. 인터페이스 정의
    abstract class MemberInterface {
      void consumeB();
    }
    
    abstract class AuthServiceInterface {
      void printText();
    }
    
    // 2. 구현체
    @Injectable(as: AuthServiceInterface) // 인터페이스로 등록
    class AuthService implements AuthServiceInterface {
      @override
      void printText() {
        print("hello");
      }
    }
    
    @Injectable(as: MemberInterface)
    class Member implements MemberInterface {
      final AuthServiceInterface service; // 인터페이스에 의존
      
      Member(this.service);
      
      @override
      void consumeB() {
        service.printText();
      }
    }
    
    // 3. 사용
    class DIConsumer {
      final MemberInterface member = getIt<MemberInterface>();
      
      void consumeA() {
        member.consumeB();
      }
    }
    

    인터페이스의 장점

    1. 캡슐화 & 은닉화

    abstract class UserRepository {
      Future<User> getUser(String id);
    }
    
    // 구현 세부사항 숨김
    @Injectable(as: UserRepository)
    class UserRepositoryImpl implements UserRepository {
      final ApiClient _client; // private
      final DatabaseHelper _db; // private
      
      UserRepositoryImpl(this._client, this._db);
      
      @override
      Future<User> getUser(String id) async {
        // 복잡한 내부 로직
        try {
          return await _client.get('/users/$id');
        } catch (e) {
          return await _db.getUser(id);
        }
      }
    }
    
    // 사용하는 쪽은 인터페이스만 알면 됨
    class UserService {
      final UserRepository repository;
      
      UserService(this.repository); // 구현 세부사항 몰라도 됨
    }
    

    2. 다형성

    환경에 따라 다른 구현체를 사용할 수 있습니다.

    abstract class AuthServiceInterface {
      Future<User> login(String email, String password);
    }
    
    // 개발 환경: Mock 데이터
    @dev
    @Injectable(as: AuthServiceInterface)
    class MockAuthService implements AuthServiceInterface {
      @override
      Future<User> login(String email, String password) async {
        return User(id: '1', email: email, name: 'Test User');
      }
    }
    
    // 프로덕션: 실제 API
    @prod
    @Injectable(as: AuthServiceInterface)
    class RealAuthService implements AuthServiceInterface {
      final ApiClient client;
      
      RealAuthService(this.client);
      
      @override
      Future<User> login(String email, String password) async {
        final response = await client.post('/auth/login', {
          'email': email,
          'password': password,
        });
        return User.fromJson(response.data);
      }
    }
    
    // 사용하는 쪽은 동일
    class LoginUseCase {
      final AuthServiceInterface service; // 어떤 구현체든 OK
      
      LoginUseCase(this.service);
    }
    

    3. 테스트 용이성

    // 테스트용 Mock 구현
    class TestAuthService implements AuthServiceInterface {
      @override
      Future<User> login(String email, String password) async {
        if (email == 'test@test.com') {
          return User(id: '1', email: email, name: 'Test');
        }
        throw Exception('Invalid credentials');
      }
    }
    
    // 테스트
    void main() {
      test('LoginUseCase test', () async {
        final testService = TestAuthService();
        final useCase = LoginUseCase(testService); // Mock 주입
        
        final user = await useCase.execute('test@test.com', 'password');
        expect(user.id, '1');
      });
    }
    

    4. 유지보수성

    한 구현체의 변경이 다른 코드에 영향을 주지 않습니다.

    // AuthService 구현 변경
    @Injectable(as: AuthServiceInterface)
    class AuthService implements AuthServiceInterface {
      final ApiClient client;
      final TokenStorage storage; // 새로운 의존성 추가
      
      AuthService(this.client, this.storage); // 생성자 변경
      
      @override
      Future<User> login(String email, String password) async {
        final user = await client.post('/auth/login', {
          'email': email,
          'password': password,
        });
        await storage.saveToken(user.token); // 새로운 로직
        return user;
      }
    }
    
    // Member는 전혀 영향 받지 않음!
    class Member implements MemberInterface {
      final AuthServiceInterface service; // 그대로
      
      Member(this.service); // 그대로
      
      @override
      void consumeB() {
        service.printText(); // 그대로
      }
    }
    

    Module과 함께 사용하기

    @module
    abstract class ViewModelModule {
      // 인터페이스로 등록
      @injectable
      MemberInterface member(AuthServiceInterface service) => Member(service);
    }
    

    실전 예제

    전체 흐름

    // 1. 인터페이스 정의
    abstract class AuthServiceInterface {
      Future<User> login(String email, String password);
      void logout();
    }
    
    abstract class MemberInterface {
      void performAction();
    }
    
    // 2. 구현체
    @Injectable(as: AuthServiceInterface)
    class AuthService implements AuthServiceInterface {
      final ApiClient client;
      
      AuthService(this.client);
      
      @override
      Future<User> login(String email, String password) async {
        final response = await client.post('/auth/login', {
          'email': email,
          'password': password,
        });
        return User.fromJson(response.data);
      }
      
      @override
      void logout() {
        print("Logging out...");
      }
    }
    
    @Injectable(as: MemberInterface)
    class Member implements MemberInterface {
      final AuthServiceInterface service;
      
      Member(this.service);
      
      @override
      void performAction() {
        service.logout();
      }
    }
    
    // 3. DI 설정
    @module
    abstract class AppModule {
      @singleton
      ApiClient get client => ApiClient();
    }
    
    // injection.dart
    @InjectableInit()
    void configureDependencies() => getIt.init();
    
    // 4. main.dart
    void main() {
      configureDependencies();
      runApp(MyApp());
    }
    
    // 5. 사용
    class MyWidget extends StatelessWidget {
      final MemberInterface member = getIt<MemberInterface>();
      
      @override
      Widget build(BuildContext context) {
        return ElevatedButton(
          onPressed: () => member.performAction(),
          child: Text('Perform Action'),
        );
      }
    }
    

    베스트 프랙티스

    1. 인터페이스 사용

    // ✅ 좋은 예: 인터페이스에 의존
    abstract class Repository { }
    @Injectable(as: Repository)
    class RepositoryImpl implements Repository { }
    
    // ❌ 나쁜 예: 구체 클래스에 의존
    @injectable
    class RepositoryImpl { }
    

    2. 생성자 주입 사용

    // ✅ 좋은 예
    @injectable
    class Service {
      final Repository repository;
      Service(this.repository);
    }
    
    // ❌ 나쁜 예
    @injectable
    class Service {
      late Repository repository;
      void setRepository(Repository repo) {
        repository = repo;
      }
    }
    

    3. 적절한 생명주기 선택

    // ✅ 싱글톤: 상태를 공유하는 서비스
    @singleton
    class ApiClient { }
    
    // ✅ 팩토리: 상태를 가지지 않는 UseCase
    @injectable
    class LoginUseCase { }
    

    4. Module 활용

    // ✅ 외부 라이브러리나 복잡한 설정은 Module로
    @module
    abstract class NetworkModule {
      @singleton
      Dio get dio => Dio(BaseOptions(
        baseUrl: 'https://api.example.com',
      ));
    }
    

    참고 자료