프로그램이나 앱이 한번 가동되면 종료될 때까지 한 개의 인스턴스만을 가져야 하는 경우가 있습니다. 예를 들면 소켓, I/O 처리, 옵션 설정 등이 있을 겁니다.

지금까지 싱글톤 패턴을 이용해 어디서든 instance를 가져와 사용했었는데, 아주 큰 단점이 하나 있습니다. 사용하는 순간 종속되어 단위 테스트와 다른 프로젝트에 재활용할 수 없다는 것이죠. 😨

그래서 최대한 싱글톤 사용을 지양하고자 다른 방법을 찾게 되었습니다. 바로 의존성 주입(DI)죠.

🔎 의존성 주입 (DI, Dependency Injection)

💡 Quotation

의존성 주입은 프로그램 디자인이 결합도를 느슨하게 되도록 하고 의존관계 역전 원칙과 단일 책임 원칙을 따르도록 클라이언트의 생성에 대한 의존성을 클라이언트의 행위로부터 분리하는 것이다. - Wikipedia

말이 거창한데, 단순하게 A 객체에서 B 객체를 생성해서 사용하느냐, 아니면 외부에서 B 객체를 받아서 사용하느냐의 차이입니다.

그리고 결합도를 느슨하게 하기위해 보통은 인터페이스를 받아서 사용합니다. 아래는 C++ 코드 예시입니다.

 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
// 주입받을 인터페이스 (추상화)
interface SocketManager {
    void connect();
}

interface FileManager {
    void readFile();
}

// A 객체 (클라이언트)
class A {
    private final SocketManager socketManager;
    private final FileManager fileManager;

    // 생성자를 통해 필요한 모든 의존성을 주입받음
    public A(SocketManager socketManager, FileManager fileManager) {
        this.socketManager = socketManager;
        this.fileManager = fileManager;
    }

    // A 객체의 로직
    public void execute() {
        socketManager.connect();
        fileManager.readFile();
        // ...
    }
}

🚀 플러터에서의 DI

가장 많이 사용하는 방법은 Provider 라는 패키지를 사용하여 의존성 주입을 한다고 합니다. (아니 Flutter 공식도 Provider 패키지 사용을 권장한다고 하네요.) 의존성 주입을 위해 패키지를 설치해야 한다는 것이 이해가 되지 않아 이번 글에서는 패키지 사용 없이 해볼겁니다. 🤔

🔬 InheritedWidget

데이터를 위젯 트리를 따라 효율적으로 아래로 전달하고 공유할 수 있게 해주는 특별한 종류의 위젯입니다. 이는 Flutter의 기본 메커니즘 중 하나이며, 복잡한 상태 관리 솔루션이 나오기 전까지 의존성 주입이나 간단한 상태 관리를 위해 널리 사용되었습니다.

의존성 주입을 위한 클래스에 InheritedWidget을 상속받아 최상단에 위치하게 하고 그 밑에 MaterialApp 등 여러 위젯을 놓으면 모든 위젯들은 의존성 주입 클래스에서 기능 또는 변수들을 받아 사용할 수 있을겁니다! 😎

 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
import 'package:flutter/material.dart';

class ServiceInjector extends InheritedWidget {
  // 주입할 두 객체를 final 멤버 변수로 선언합니다.
  final Logger logger;
  final DataService dataService;

  const ServiceInjector({
    super.key,
    required this.logger,
    required this.dataService,
    required super.child,
  });

  // 하위 위젯에서 객체에 접근할 수 있도록 헬퍼 메서드를 정의합니다.
  static ServiceInjector? of(BuildContext context) {
    return context.dependOnInheritedWidgetOfExactType<ServiceInjector>();
  }

  // InheritedWidget의 핵심!
  // 객체가 변경될 일이 거의 없으므로, 일반적으로 false를 반환하여 불필요한 리빌드를 막습니다.
  @override
  bool updateShouldNotify(ServiceInjector oldWidget) {
    return false; 
  }
}
 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
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
class ServiceConsumerScreen extends StatefulWidget {
  const ServiceConsumerScreen({super.key});

  @override
  State<ServiceConsumerScreen> createState() => _ServiceConsumerScreenState();
}

class _ServiceConsumerScreenState extends State<ServiceConsumerScreen> {
  String _data = 'Loading...';
  late Logger _logger;
  late DataService _dataService;

  // initState에서는 context.dependOnInheritedWidgetOfExactType 호출이 불가능합니다.
  // DidChangeDependencies에서 객체를 가져옵니다.
  @override
  void didChangeDependencies() {
    super.didChangeDependencies();
    
    // 3. 주입된 객체를 가져옵니다. (DI의 '주입' 및 '사용' 단계)
    final injector = ServiceInjector.of(context)!;
    _logger = injector.logger;
    _dataService = injector.dataService;

    // 로거를 사용하여 로그 남기기
    _logger.log('Screen initialized and services injected.');
  }

  void _loadData() async {
    _logger.log('Starting data fetch...');
    final result = await _dataService.fetchData();
    setState(() {
      _data = result;
    });
    _logger.log('Data fetch complete: $_data');
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: const Text('InheritedWidget DI')),
      body: Center(
        child: Column(
          mainAxisAlignment: MainAxisAlignment.center,
          children: <Widget>[
            Text(_data),
            const SizedBox(height: 20),
            ElevatedButton(
              onPressed: _loadData,
              child: const Text('Load Data'),
            ),
          ],
        ),
      ),
    );
  }
}
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
class MyApp extends StatelessWidget {
  const MyApp({super.key});

  @override
  Widget build(BuildContext context) {
    // 1. 서비스 객체들을 인스턴스화합니다. (DI의 '생성' 단계)
    final logger = ConsoleLogger();
    final dataService = MockDataService();

    // 2. ServiceInjector를 사용하여 하위 위젯에 주입합니다.
    return ServiceInjector(
      logger: logger,
      dataService: dataService,
      child: MaterialApp(
        title: 'InheritedWidget DI Example',
        home: ServiceConsumerScreen(),
      ),
    );
  }
}

✅ 정리

  1. 싱글톤은 될 수 있으면 지양하자.
  2. InheritedWidget 을 최상위에 두면 모든 위젯은 of 메서드를 이용하여 불러올 수 있다.
  3. FlutterProvider라는 패키지 사용을 권장한다. 🤔