Singleton Design Parttern

🤔 Flutter에서 정말 Thread Safety가 필요할까?

많은 개발자들이 "Flutter는 Single Thread 아닌가?"라고 생각하지만, 실제로는 여러 상황에서 동시성 문제가 발생할 수 있습니다.

📍 동시성 문제가 발생하는 실제 상황들

1. async/await를 사용한 비동기 초기화

가장 흔한 상황입니다. 데이터베이스나 API 클라이언트를 초기화할 때:

dart

class ApiClient {
  static ApiClient? _instance;
  
  static Future<ApiClient> getInstance() async {
    if (_instance == null) {
      // 네트워크 호출이나 파일 I/O 등 시간이 걸리는 작업
      await Future.delayed(Duration(seconds: 1));
      _instance = ApiClient._internal();
    }
    return _instance!;
  }
}

// 문제 상황: 앱 시작 시 여러 화면에서 동시 호출
void main() async {
  runApp(MyApp());
}

class MyApp extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      home: Scaffold(
        body: Column(
          children: [
            UserProfile(),  // ApiClient.getInstance() 호출
            PostList(),     // ApiClient.getInstance() 호출 (동시에!)
            CommentList(),  // ApiClient.getInstance() 호출 (동시에!)
          ],
        ),
      ),
    );
  }
}

결과: 3개의 서로 다른 ApiClient 인스턴스가 생성될 수 있음!

2. Isolate를 사용하는 경우

dart

// Background에서 데이터 처리
void backgroundTask(String data) {
  Database.getInstance(); // Background Isolate에서 호출
}

void main() async {
  Database.getInstance();           // Main Isolate에서 호출
  compute(backgroundTask, "data");  // Background Isolate 시작
}

3. 앱 라이프사이클 이벤트 처리

dart

class MyApp extends StatefulWidget {
  @override
  void initState() {
    super.initState();
    
    // 여러 라이프사이클 이벤트에서 동시 호출 가능
    WidgetsBinding.instance.addPostFrameCallback((_) {
      Database.getInstance();
    });
    
    AppStateManager.getInstance();
  }
}

⚡ 실제 테스트로 문제 확인하기

dart

void demonstrateRaceCondition() async {
  print('=== Race Condition 테스트 ===');
  
  // 동시에 10개의 Future 실행
  List<Future<UnsafeDatabase>> futures = List.generate(
    10, 
    (index) => UnsafeDatabase.getInstance()
  );
  
  List<UnsafeDatabase> instances = await Future.wait(futures);
  
  // 결과 확인
  Set<int> hashCodes = instances.map((e) => e.hashCode).toSet();
  print('생성된 인스턴스 수: ${hashCodes.length}'); // 1개여야 하지만 여러 개 나올 수 있음
  print('해시 코드들: $hashCodes');
}

🎯 상황별 해결책

1. Completer를 사용한 해결 (권장)

dart

class SafeDatabase {
  static SafeDatabase? _instance;
  static Completer<SafeDatabase>? _completer;
  
  SafeDatabase._internal();
  
  static Future<SafeDatabase> getInstance() async {
    // 이미 생성된 경우
    if (_instance != null) {
      return _instance!;
    }
    
    // 이미 생성 중인 경우 - 기다림
    if (_completer != null) {
      return _completer!.future;
    }
    
    // 새로 생성 시작
    _completer = Completer<SafeDatabase>();
    
    try {
      // 실제 초기화 작업 (DB 연결, API 설정 등)
      await _performAsyncInitialization();
      _instance = SafeDatabase._internal();
      _completer!.complete(_instance!);
    } catch (error) {
      _completer!.completeError(error);
      _completer = null; // 실패 시 재시도 가능하도록
      rethrow;
    }
    
    return _instance!;
  }
  
  static Future<void> _performAsyncInitialization() async {
    await Future.delayed(Duration(milliseconds: 500)); // 초기화 시뮬레이션
  }
}

2. 동기적 생성 + 비동기 초기화 (심플)

dart

class SimpleDatabase {
  static final SimpleDatabase _instance = SimpleDatabase._internal();
  bool _isInitialized = false;
  
  SimpleDatabase._internal();
  
  static SimpleDatabase get instance => _instance;
  
  Future<void> initialize() async {
    if (_isInitialized) return;
    
    await Future.delayed(Duration(milliseconds: 500));
    _isInitialized = true;
  }
}

// 사용법
void main() async {
  WidgetsFlutterBinding.ensureInitialized();
  await SimpleDatabase.instance.initialize(); // 앱 시작 시 미리 초기화
  runApp(MyApp());
}

3. GetIt 패키지 사용 (최고 권장)

yaml

dependencies:
  get_it: ^7.6.4

dart

import 'package:get_it/get_it.dart';

final GetIt locator = GetIt.instance;

void setupServiceLocator() {
  // 비동기 싱글톤 등록
  locator.registerSingletonAsync<Database>(
    () async {
      final db = Database._internal();
      await db.initialize();
      return db;
    },
  );
  
  // 동기적 싱글톤 등록
  locator.registerSingleton<ApiClient>(ApiClient._internal());
}

void main() async {
  WidgetsFlutterBinding.ensureInitialized();
  setupServiceLocator();
  
  // 비동기 서비스들이 준비될 때까지 대기
  await locator.allReady();
  
  runApp(MyApp());
}

// 사용법
class MyWidget extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    final database = locator<Database>();
    final apiClient = locator<ApiClient>();
    
    return Container(
      // UI 구현
    );
  }
}

4. Provider와 함께 사용

dart

void main() async {
  WidgetsFlutterBinding.ensureInitialized();
  
  final database = await SafeDatabase.getInstance();
  
  runApp(
    Provider<SafeDatabase>.value(
      value: database,
      child: MyApp(),
    ),
  );
}

class MyWidget extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    final database = Provider.of<SafeDatabase>(context, listen: false);
    return Container(
      // database 사용
    );
  }
}

📊 성능 비교와 권장사항

🎯 결론 및 Best Practice

권장사항

  1. 간단한 싱글톤: 동기적 생성 + 별도 초기화 메서드

  2. 복잡한 의존성: GetIt 패키지 사용

  3. 앱 시작 시: 모든 싱글톤을 미리 초기화

  4. 테스트: 항상 동시성 테스트 코드 작성

피해야 할 것

  1. async/await 없이 비동기 싱글톤 구현

  2. 앱 실행 중 동적 싱글톤 생성

  3. Isolate간 싱글톤 공유 시도

💡 핵심 포인트

Flutter는 Single Thread이지만, async/await와 Future를 사용하면 동시성 문제가 발생할 수 있습니다. 특히 앱 시작 시 여러 위젯에서 동시에 싱글톤을 요청할 때 Race Condition이 발생하기 쉽습니다.

Last updated