"Bad state: Cannot add new events after calling close"

FlutterPyo·2024년 1월 14일

개발을 하다보면 여러가지 에러를 마주하게 된다. 기껏 코드를 열심히 짯더니 에러를 뿜어내는 상황을 볼때 마다 눈앞이 깜깜해진다. 정리를 해둔다면 생각해서 하나씩 정리해 보겠다.

Bad state: Cannot add new events after calling close

우선 이런 에러 메세지가 발생하는 이유는 무엇일까??

발생하는 이유는 bloc 또는 스트림의 생명주기와 관련이 있다.

1. 스트림 닫힘(Closed Stream)

해당 오류는 주로 닫힌 스트림에 새로운 이벤트를 추가하려고 할 때 발생한다. 주로 스트림이 닫힌 후에 해당 스트림에 새로운 이벤트를 추가하려고 하는 상황에서 발생한다.

2. Bloc이 올바르게 폐기되지 않을 때

Bloc이 사용되는 위젯이 화면에서 벗어날 때, 해당 Bloc이 적절하게 폐기(close)되지 않으면 해당 Bloc과 연결된 리소스들이 해제되지 않을 수 있다.

나 같은 경우에는 2번 상황에서 문제가 발생했다. 기존 bloc을 사용하는 화면에서 벗어날때 bloc을 적절하게 폐기하지 않아서 발생했다. 사용하지 않는 bloc을 폐기 하는 이유는 메모리 누수와 같은 문제가 발생할 수 있다. Bloc을 더 이상 사용하지 않게 되면, 메모리를 효율적으로 관리하기 위해 Bloc을 닫는 것이 좋다.


🔍 에러 발생 시나리오

시나리오 1: 위젯 dispose 후 이벤트 발생

가장 흔한 경우로, 위젯이 dispose된 후에도 비동기 작업이 완료되어 Bloc에 이벤트를 추가하려고 할 때 발생합니다.

❌ 잘못된 예제

class MyScreen extends StatefulWidget {
  
  _MyScreenState createState() => _MyScreenState();
}

class _MyScreenState extends State<MyScreen> {
  late MyBloc _bloc;

  
  void initState() {
    super.initState();
    _bloc = MyBloc();
    
    // 비동기 작업 시작
    _loadData();
  }

  Future<void> _loadData() async {
    // 네트워크 요청 등 시간이 걸리는 작업
    await Future.delayed(Duration(seconds: 3));
    
    // 위젯이 이미 dispose된 후에 실행될 수 있음
    _bloc.add(LoadDataEvent()); // ❌ 에러 발생!
  }

  
  void dispose() {
    _bloc.close(); // dispose에서 close 호출
    super.dispose();
  }

  
  Widget build(BuildContext context) {
    return BlocBuilder<MyBloc, MyState>(
      bloc: _bloc,
      builder: (context, state) {
        return Scaffold(
          body: Center(child: Text('Loading...')),
        );
      },
    );
  }
}

✅ 올바른 예제

class MyScreen extends StatefulWidget {
  
  _MyScreenState createState() => _MyScreenState();
}

class _MyScreenState extends State<MyScreen> {
  late MyBloc _bloc;
  bool _isDisposed = false; // dispose 상태 추적

  
  void initState() {
    super.initState();
    _bloc = MyBloc();
    _loadData();
  }

  Future<void> _loadData() async {
    await Future.delayed(Duration(seconds: 3));
    
    // 위젯이 dispose되지 않았는지 확인
    if (!_isDisposed && !_bloc.isClosed) {
      _bloc.add(LoadDataEvent()); // ✅ 안전하게 이벤트 추가
    }
  }

  
  void dispose() {
    _isDisposed = true; // dispose 상태 표시
    _bloc.close();
    super.dispose();
  }

  
  Widget build(BuildContext context) {
    return BlocBuilder<MyBloc, MyState>(
      bloc: _bloc,
      builder: (context, state) {
        return Scaffold(
          body: Center(child: Text('Loading...')),
        );
      },
    );
  }
}

시나리오 2: BlocProvider 없이 직접 Bloc 생성

BlocProvider를 사용하지 않고 직접 Bloc을 생성할 때 생명주기 관리가 어려워집니다.

❌ 잘못된 예제

class ProductListScreen extends StatelessWidget {
  
  Widget build(BuildContext context) {
    final bloc = ProductBloc(); // 매번 새로운 Bloc 생성
    
    return BlocBuilder<ProductBloc, ProductState>(
      bloc: bloc,
      builder: (context, state) {
        return ListView.builder(
          itemCount: state.products.length,
          itemBuilder: (context, index) {
            return ListTile(
              title: Text(state.products[index].name),
              onTap: () {
                bloc.add(SelectProductEvent(state.products[index]));
                // 위젯이 rebuild되면 새로운 Bloc이 생성되어 이전 Bloc은 닫히지 않음
              },
            );
          },
        );
      },
    );
  }
}

✅ 올바른 예제: BlocProvider 사용

class ProductListScreen extends StatelessWidget {
  
  Widget build(BuildContext context) {
    return BlocProvider(
      create: (context) => ProductBloc()..add(LoadProductsEvent()),
      child: _ProductListView(),
    );
  }
}

class _ProductListView extends StatelessWidget {
  
  Widget build(BuildContext context) {
    return BlocBuilder<ProductBloc, ProductState>(
      builder: (context, state) {
        final bloc = context.read<ProductBloc>(); // Provider에서 가져오기
        
        return ListView.builder(
          itemCount: state.products.length,
          itemBuilder: (context, index) {
            return ListTile(
              title: Text(state.products[index].name),
              onTap: () {
                bloc.add(SelectProductEvent(state.products[index])); // ✅ 안전
              },
            );
          },
        );
      },
    );
  }
}

시나리오 3: 여러 화면에서 동일한 Bloc 공유

여러 화면에서 동일한 Bloc 인스턴스를 공유할 때 생명주기 관리가 복잡해집니다.

❌ 잘못된 예제

class App extends StatelessWidget {
  final authBloc = AuthBloc(); // 전역으로 생성

  
  Widget build(BuildContext context) {
    return MaterialApp(
      home: BlocProvider.value(
        value: authBloc,
        child: HomeScreen(),
      ),
    );
  }
}

class HomeScreen extends StatelessWidget {
  
  Widget build(BuildContext context) {
    final authBloc = context.read<AuthBloc>();
    
    return Scaffold(
      body: ElevatedButton(
        onPressed: () {
          Navigator.push(
            context,
            MaterialPageRoute(
              builder: (context) => ProfileScreen(),
            ),
          );
          // HomeScreen이 dispose되면 authBloc도 닫힐 수 있음
        },
        child: Text('Go to Profile'),
      ),
    );
  }
}

class ProfileScreen extends StatelessWidget {
  
  Widget build(BuildContext context) {
    final authBloc = context.read<AuthBloc>();
    
    return Scaffold(
      body: ElevatedButton(
        onPressed: () {
          authBloc.add(LogoutEvent()); // ❌ 이미 닫힌 Bloc에 이벤트 추가 시도
        },
        child: Text('Logout'),
      ),
    );
  }
}

✅ 올바른 예제: MultiBlocProvider 사용

class App extends StatelessWidget {
  
  Widget build(BuildContext context) {
    return MultiBlocProvider(
      providers: [
        BlocProvider<AuthBloc>(
          create: (context) => AuthBloc()..add(CheckAuthStatusEvent()),
        ),
        BlocProvider<ProductBloc>(
          create: (context) => ProductBloc(),
        ),
      ],
      child: MaterialApp(
        home: HomeScreen(),
      ),
    );
  }
}

class HomeScreen extends StatelessWidget {
  
  Widget build(BuildContext context) {
    return Scaffold(
      body: ElevatedButton(
        onPressed: () {
          Navigator.push(
            context,
            MaterialPageRoute(
              builder: (context) => ProfileScreen(),
            ),
          );
        },
        child: Text('Go to Profile'),
      ),
    );
  }
}

class ProfileScreen extends StatelessWidget {
  
  Widget build(BuildContext context) {
    final authBloc = context.read<AuthBloc>(); // ✅ 안전하게 접근
    
    return Scaffold(
      body: ElevatedButton(
        onPressed: () {
          if (!authBloc.isClosed) {
            authBloc.add(LogoutEvent()); // ✅ 상태 확인 후 이벤트 추가
          }
        },
        child: Text('Logout'),
      ),
    );
  }
}

시나리오 4: StreamSubscription 관리

Bloc 내부에서 StreamSubscription을 사용할 때 적절히 취소하지 않으면 문제가 발생할 수 있습니다.

❌ 잘못된 예제

class TimerBloc extends Bloc<TimerEvent, TimerState> {
  StreamSubscription<int>? _timerSubscription;

  TimerBloc() : super(TimerInitial()) {
    on<StartTimerEvent>(_onStartTimer);
  }

  Future<void> _onStartTimer(
    StartTimerEvent event,
    Emitter<TimerState> emit,
  ) async {
    _timerSubscription = Stream.periodic(
      Duration(seconds: 1),
      (count) => count,
    ).listen((count) {
      add(TickEvent(count)); // ❌ Bloc이 닫힌 후에도 실행될 수 있음
    });
  }

  
  Future<void> close() {
    // _timerSubscription?.cancel(); // ❌ 취소하지 않음
    return super.close();
  }
}

✅ 올바른 예제

class TimerBloc extends Bloc<TimerEvent, TimerState> {
  StreamSubscription<int>? _timerSubscription;

  TimerBloc() : super(TimerInitial()) {
    on<StartTimerEvent>(_onStartTimer);
    on<TickEvent>(_onTick);
  }

  Future<void> _onStartTimer(
    StartTimerEvent event,
    Emitter<TimerState> emit,
  ) async {
    await _timerSubscription?.cancel(); // 기존 subscription 취소
    
    _timerSubscription = Stream.periodic(
      Duration(seconds: 1),
      (count) => count,
    ).listen(
      (count) {
        if (!isClosed) { // ✅ Bloc이 닫히지 않았는지 확인
          add(TickEvent(count));
        }
      },
      onError: (error) {
        if (!isClosed) {
          add(TimerErrorEvent(error));
        }
      },
    );
  }

  Future<void> _onTick(
    TickEvent event,
    Emitter<TimerState> emit,
  ) async {
    emit(TimerTick(event.count));
  }

  
  Future<void> close() {
    _timerSubscription?.cancel(); // ✅ subscription 취소
    return super.close();
  }
}

🛠️ 해결 방법

방법 1: BlocProvider 사용 (권장)

BlocProvider를 사용하면 Bloc의 생명주기가 자동으로 관리됩니다.

class MyScreen extends StatelessWidget {
  
  Widget build(BuildContext context) {
    return BlocProvider(
      create: (context) => MyBloc()..add(InitialEvent()),
      child: _MyScreenContent(),
    );
  }
}

class _MyScreenContent extends StatelessWidget {
  
  Widget build(BuildContext context) {
    final bloc = context.read<MyBloc>();
    
    return BlocBuilder<MyBloc, MyState>(
      builder: (context, state) {
        return Scaffold(
          appBar: AppBar(title: Text('My Screen')),
          body: Center(
            child: ElevatedButton(
              onPressed: () {
                if (!bloc.isClosed) {
                  bloc.add(SomeEvent());
                }
              },
              child: Text('Click Me'),
            ),
          ),
        );
      },
    );
  }
}

방법 2: 수동으로 Bloc 관리

BlocProvider를 사용할 수 없는 경우, 수동으로 생명주기를 관리해야 합니다.

class MyScreen extends StatefulWidget {
  
  _MyScreenState createState() => _MyScreenState();
}

class _MyScreenState extends State<MyScreen> {
  late MyBloc _bloc;
  bool _mounted = true;

  
  void initState() {
    super.initState();
    _bloc = MyBloc();
    _initializeBloc();
  }

  Future<void> _initializeBloc() async {
    // 비동기 초기화 작업
    await Future.delayed(Duration(seconds: 1));
    
    if (_mounted && !_bloc.isClosed) {
      _bloc.add(InitialEvent());
    }
  }

  void _safeAddEvent(MyEvent event) {
    if (_mounted && !_bloc.isClosed) {
      _bloc.add(event);
    }
  }

  
  void dispose() {
    _mounted = false;
    _bloc.close();
    super.dispose();
  }

  
  Widget build(BuildContext context) {
    return BlocBuilder<MyBloc, MyState>(
      bloc: _bloc,
      builder: (context, state) {
        return Scaffold(
          body: ElevatedButton(
            onPressed: () => _safeAddEvent(SomeEvent()),
            child: Text('Click'),
          ),
        );
      },
    );
  }
}

방법 3: Bloc 상태 확인 헬퍼 함수

반복적으로 사용되는 패턴을 헬퍼 함수로 추출합니다.

extension BlocExtension on Bloc {
  void safeAdd(Event event) {
    if (!isClosed) {
      add(event);
    }
  }
}

// 사용 예제
class MyWidget extends StatelessWidget {
  
  Widget build(BuildContext context) {
    final bloc = context.read<MyBloc>();
    
    return ElevatedButton(
      onPressed: () {
        bloc.safeAdd(SomeEvent()); // ✅ 안전하게 이벤트 추가
      },
      child: Text('Click'),
    );
  }
}

방법 4: BlocListener와 함께 사용

BlocListener를 사용하여 상태 변화를 안전하게 처리합니다.

class MyScreen extends StatelessWidget {
  
  Widget build(BuildContext context) {
    return BlocProvider(
      create: (context) => MyBloc()..add(LoadDataEvent()),
      child: BlocListener<MyBloc, MyState>(
        listener: (context, state) {
          if (state is MyErrorState) {
            ScaffoldMessenger.of(context).showSnackBar(
              SnackBar(content: Text(state.message)),
            );
          }
        },
        child: _MyScreenContent(),
      ),
    );
  }
}

class _MyScreenContent extends StatelessWidget {
  
  Widget build(BuildContext context) {
    return BlocBuilder<MyBloc, MyState>(
      builder: (context, state) {
        if (state is MyLoadingState) {
          return Center(child: CircularProgressIndicator());
        }
        
        if (state is MyLoadedState) {
          return ListView.builder(
            itemCount: state.items.length,
            itemBuilder: (context, index) {
              return ListTile(
                title: Text(state.items[index].name),
              );
            },
          );
        }
        
        return Center(child: Text('No data'));
      },
    );
  }
}

🔧 Bloc 내부에서의 리소스 관리

Bloc 내부에서 사용하는 리소스들도 적절히 관리해야 합니다.

완전한 예제: 리소스 관리가 잘 된 Bloc

class DataBloc extends Bloc<DataEvent, DataState> {
  final DataRepository _repository;
  StreamSubscription<Data>? _dataSubscription;
  Timer? _pollingTimer;

  DataBloc({required DataRepository repository})
      : _repository = repository,
        super(DataInitial()) {
    on<LoadDataEvent>(_onLoadData);
    on<StartPollingEvent>(_onStartPolling);
    on<StopPollingEvent>(_onStopPolling);
    on<DataUpdatedEvent>(_onDataUpdated);
  }

  Future<void> _onLoadData(
    LoadDataEvent event,
    Emitter<DataState> emit,
  ) async {
    emit(DataLoading());
    
    try {
      final data = await _repository.fetchData();
      emit(DataLoaded(data));
    } catch (e) {
      if (!isClosed) {
        emit(DataError(e.toString()));
      }
    }
  }

  Future<void> _onStartPolling(
    StartPollingEvent event,
    Emitter<DataState> emit,
  ) async {
    _pollingTimer?.cancel();
    
    _pollingTimer = Timer.periodic(
      Duration(seconds: 5),
      (timer) {
        if (!isClosed) {
          add(LoadDataEvent());
        } else {
          timer.cancel();
        }
      },
    );
  }

  Future<void> _onStopPolling(
    StopPollingEvent event,
    Emitter<DataState> emit,
  ) async {
    _pollingTimer?.cancel();
    _pollingTimer = null;
  }

  Future<void> _onDataUpdated(
    DataUpdatedEvent event,
    Emitter<DataState> emit,
  ) async {
    if (state is DataLoaded) {
      emit(DataLoaded(event.data));
    }
  }

  // StreamSubscription 예제
  void _subscribeToDataStream() {
    _dataSubscription?.cancel();
    
    _dataSubscription = _repository.dataStream.listen(
      (data) {
        if (!isClosed) {
          add(DataUpdatedEvent(data));
        }
      },
      onError: (error) {
        if (!isClosed) {
          add(DataErrorEvent(error.toString()));
        }
      },
    );
  }

  
  Future<void> close() {
    // 모든 리소스 정리
    _dataSubscription?.cancel();
    _pollingTimer?.cancel();
    return super.close();
  }
}

🐛 디버깅 팁

1. Bloc 상태 확인

void _checkBlocState(Bloc bloc) {
  print('Bloc is closed: ${bloc.isClosed}');
  print('Bloc state: ${bloc.state}');
}

2. 에러 발생 시 스택 트레이스 확인

try {
  bloc.add(SomeEvent());
} catch (e, stackTrace) {
  print('Error: $e');
  print('Stack trace: $stackTrace');
}

3. BlocObserver 사용

class MyBlocObserver extends BlocObserver {
  
  void onCreate(BlocBase bloc) {
    super.onCreate(bloc);
    print('Bloc created: ${bloc.runtimeType}');
  }

  
  void onClose(BlocBase bloc) {
    super.onClose(bloc);
    print('Bloc closed: ${bloc.runtimeType}');
  }

  
  void onError(BlocBase bloc, Object error, StackTrace stackTrace) {
    super.onError(bloc, error, stackTrace);
    print('Bloc error: ${bloc.runtimeType}, Error: $error');
  }

  
  void onEvent(BlocBase bloc, Object? event) {
    super.onEvent(bloc, event);
    print('Event: ${bloc.runtimeType}, Event: $event');
  }

  
  void onTransition(BlocBase bloc, Transition transition) {
    super.onTransition(bloc, transition);
    print('Transition: ${bloc.runtimeType}, $transition');
  }
}

// main.dart에서 사용
void main() {
  Bloc.observer = MyBlocObserver();
  runApp(MyApp());
}

📋 체크리스트

에러를 방지하기 위한 체크리스트:

  • BlocProvider를 사용하여 Bloc 생명주기 자동 관리
  • dispose()에서 Bloc.close() 호출 (수동 관리 시)
  • 이벤트 추가 전 isClosed 확인
  • 비동기 작업 완료 후 위젯이 dispose되었는지 확인
  • StreamSubscription 사용 시 close()에서 cancel() 호출
  • Timer 사용 시 close()에서 cancel() 호출
  • 여러 화면에서 Bloc 공유 시 MultiBlocProvider 사용
  • BlocObserver를 사용하여 디버깅 정보 수집

정리

"Bad state: Cannot add new events after calling close" 오류는 주로 스트림 관리와 Bloc 생명주기 관리와 관련이 있다. 올바른 Bloc 사용 패턴과 생명주기 관리를 통해 이러한 문제를 효과적으로 해결할 수 있다.

핵심 포인트

BlocProvider 사용을 통한 자동 생명주기 관리
Bloc 사용 후 반드시 close() 호출
닫힌 Bloc에 이벤트를 추가하지 않기
isClosed 속성을 확인하여 Bloc 상태 체크
비동기 작업 완료 후 위젯 상태 확인
StreamSubscription, Timer 등 리소스 적절히 정리
메모리 누수 방지를 위한 적절한 리소스 관리

추가 학습 자료


0개의 댓글