핵심 요약
Flutter에서 setState() called after dispose 오류가 발생할 때 Future 완료, Timer, Stream, AnimationController와 화면 전환을 구분하고 mounted 검사와 작업 취소를 올바른 순서로 적용합니다.
검수 범위
StatefulWidget에서 비동기 요청이나 반복 작업이 끝난 뒤 UI 상태를 갱신하는 경우를 대상으로 합니다. Riverpod·Bloc 등 외부 상태 관리 도구의 수명주기는 원칙이 겹치는 부분만 다룹니다.
setState() called after dispose()는 화면이 사라졌는데도 늦게 끝난 작업이 그 화면의 State를 다시 그리려 할 때 발생합니다. 네트워크 요청, Future.delayed, Timer, Stream, animation callback처럼 시작 시점과 완료 시점이 다른 코드에서 자주 나타납니다. 오류 직전에 실행된 setState만 지우면 화면 갱신이 사라질 뿐 작업과 참조는 계속 남을 수 있습니다.
해결은 두 단계입니다. 먼저 비동기 간격 뒤 State나 BuildContext를 사용하기 전에 아직 화면이 유효한지 확인합니다. 그다음 화면이 사라졌을 때 중단할 수 있는 작업은 dispose()에서 실제로 취소하고 listener를 제거합니다.
오류 상황으로 남아 있는 작업 찾기
| 오류가 나는 순간 | 가능성이 높은 원인 | 정리 위치 |
|---|---|---|
| API 응답 직후 | await 중 화면 이탈 |
완료 뒤 mounted와 최신 요청 여부 검사 |
| 몇 초 뒤 항상 발생 | Timer 또는 Future.delayed |
Timer는 dispose에서 cancel |
| 화면을 나간 뒤 데이터 이벤트마다 발생 | Stream 구독이 유지됨 | subscription cancel |
| 전환·스크롤·애니메이션 중 발생 | controller 또는 listener 잔존 | listener 제거와 controller dispose |
| 같은 화면의 ID가 바뀐 뒤 잘못된 데이터 표시 | 이전 요청 결과가 나중에 도착 | didUpdateWidget 재구독과 요청 세대 검사 |
| Navigator·SnackBar 호출에서 오류 | 비동기 간격 뒤 만료된 context 사용 | context.mounted 검사 |
1단계: State 수명주기에서 dispose의 의미 확인
Flutter는 State 객체를 widget tree에 연결하면 mounted를 true로 유지합니다. 부모가 해당 widget을 영구적으로 제거하면 dispose()가 호출되고 이후 mounted는 false가 됩니다. 이 단계는 끝난 상태이므로 같은 State가 다시 mount되지 않습니다.
deactivate()는 tree에서 잠시 빠졌지만 같은 frame 안에 다시 들어갈 가능성이 있는 단계입니다. Timer나 controller 같은 일반 자원 정리를 deactivate()에서 확정하면 GlobalKey 이동처럼 State가 재사용되는 상황을 방해할 수 있습니다. 영구 해제는 dispose()를 기준으로 합니다.
2단계: await 다음 줄을 수명주기 경계로 보기
비동기 요청이 시작될 때는 화면이 존재해도 응답이 돌아올 때는 사용자가 뒤로 이동했을 수 있습니다. await 뒤에서 State 필드를 바꾸거나 setState를 호출하기 전에 mounted를 확인합니다.
Future<void> loadProfile() async {
setState(() => _loading = true);
try {
final profile = await repository.fetchProfile();
if (!mounted) return;
setState(() {
_profile = profile;
_loading = false;
});
} catch (error) {
if (!mounted) return;
setState(() {
_error = error;
_loading = false;
});
}
}
성공 경로에만 검사하고 catch나 finally에서 다시 setState를 호출하면 같은 오류가 남습니다. 비동기 간격 뒤 State를 사용하는 모든 분기를 확인합니다. Flutter 공식 문서는 단순 검사보다 가능한 작업을 미리 취소하는 방식을 더 권장합니다. 이미 필요 없어진 요청이 CPU와 네트워크를 계속 쓰는 문제까지 줄일 수 있기 때문입니다.
3단계: setState 콜백을 async로 만들지 않기
setState에 전달하는 콜백은 즉시 동기적으로 실행돼야 하며 Future를 반환하면 안 됩니다. 비동기 작업은 밖에서 끝내고, 실제 State 필드 변경만 짧게 감쌉니다.
// 잘못된 형태
setState(() async {
_profile = await repository.fetchProfile();
});
// 올바른 형태
final profile = await repository.fetchProfile();
if (!mounted) return;
setState(() {
_profile = profile;
});
파일 쓰기, JSON 변환이나 긴 계산도 콜백 밖에서 수행합니다. setState는 UI에 영향을 주는 값의 변경만 감싸야 어떤 이벤트가 rebuild를 만들었는지 추적하기 쉽습니다.
4단계: State와 BuildContext의 mounted를 구분하기
State 메서드 안에서 State 필드나 setState를 사용할 때는 if (!mounted) return;을 확인합니다. 비동기 간격 뒤 지역 변수로 받은 BuildContext를 사용해 Navigator, Dialog, Theme 등을 호출한다면 그 context 자체의 mounted를 확인합니다.
Future<void> submit(BuildContext dialogContext) async {
await repository.save();
if (!dialogContext.mounted) return;
Navigator.of(dialogContext).pop();
}
서로 다른 Navigator나 dialog의 context를 넘겼다면 State의 mounted가 true여도 그 지역 context는 이미 제거됐을 수 있습니다. 반대로 context를 사용하지 않는 repository 계층에 widget 수명주기 검사를 넣으면 UI와 데이터 계층이 불필요하게 결합됩니다. 검사는 UI 경계에 둡니다.
5단계: Timer는 참조를 저장하고 dispose에서 취소
Timer.periodic을 지역 변수로 만들면 화면이 사라질 때 취소할 참조가 없습니다. State 필드에 저장하고 callback에서도 mounted를 확인하며, dispose()에서 cancel합니다.
Timer? _timer;
@override
void initState() {
super.initState();
_timer = Timer.periodic(const Duration(seconds: 10), (_) {
if (!mounted) return;
setState(() => _now = DateTime.now());
});
}
@override
void dispose() {
_timer?.cancel();
super.dispose();
}
cancel된 Timer의 callback은 다시 호출되지 않으며 여러 번 cancel해도 추가 효과가 없습니다. 단순 지연 실행이 화면 수명과 연결돼 있다면 Future.delayed보다 취소 가능한 Timer가 더 명확할 수 있습니다.
6단계: Stream 구독과 listener를 대칭으로 정리
listen()으로 만든 StreamSubscription은 필드에 보관하고 해제합니다. 공식 State 문서의 원칙처럼 initState()에서 구독하고, widget 설정이 바뀌어 다른 stream을 받아야 하면 didUpdateWidget()에서 이전 구독을 취소한 뒤 새로 구독하며, 마지막에 dispose()에서 취소합니다.
StreamSubscription<SensorValue>? _subscription;
void _subscribe() {
_subscription = widget.values.listen((value) {
if (!mounted) return;
setState(() => _latest = value);
});
}
@override
void initState() {
super.initState();
_subscribe();
}
@override
void didUpdateWidget(covariant SensorPage oldWidget) {
super.didUpdateWidget(oldWidget);
if (oldWidget.values != widget.values) {
_subscription?.cancel();
_subscribe();
}
}
@override
void dispose() {
_subscription?.cancel();
super.dispose();
}
StreamSubscription.cancel() 뒤에는 새 이벤트가 전달되지 않으며 정리 완료를 나타내는 Future를 반환합니다. dispose() 자체는 async로 만들 수 없으므로, 파일이나 소켓을 닫은 다음 후속 작업이 반드시 필요하다면 그 소유권과 비동기 정리 위치를 상위 서비스로 옮겨 설계합니다.
7단계: AnimationController와 addListener를 함께 추적
State가 만든 AnimationController, ScrollController, TextEditingController, FocusNode는 State가 소유권을 가진 경우 dispose에서 정리합니다. 외부에서 받은 controller라면 임의로 dispose하지 말고, State가 추가한 listener만 제거합니다.
late final AnimationController _controller;
@override
void initState() {
super.initState();
_controller = AnimationController(
vsync: this,
duration: const Duration(milliseconds: 300),
)..addListener(_onTick);
}
void _onTick() {
if (!mounted) return;
setState(() { /* animation value changed */ });
}
@override
void dispose() {
_controller.removeListener(_onTick);
_controller.dispose();
super.dispose();
}
AnimationController는 dispose 뒤 사용할 수 없고 가장 최근 TickerFuture도 취소 상태가 됩니다. controller를 두 번 dispose하거나 외부 소유 controller까지 정리하지 않도록 “만든 곳이 정리한다”는 소유권 원칙을 코드 리뷰 기준으로 둡니다.
8단계: Future는 mounted 검사만으로 오래된 결과를 막지 못함
mounted가 true라는 것은 화면이 존재한다는 뜻일 뿐, 지금 도착한 결과가 최신 요청이라는 뜻은 아닙니다. 검색어 A 요청 뒤 검색어 B 요청을 보냈는데 A가 늦게 도착하면 화면은 살아 있어도 오래된 결과로 덮일 수 있습니다. 요청 세대 번호나 현재 query를 비교합니다.
int _requestVersion = 0;
Future<void> search(String query) async {
final version = ++_requestVersion;
final result = await repository.search(query);
if (!mounted || version != _requestVersion) return;
setState(() => _result = result);
}
@override
void dispose() {
_requestVersion++;
super.dispose();
}
사용하는 HTTP 라이브러리가 취소 기능을 제공하면 화면 이탈이나 새 검색 시작 시 이전 요청도 취소합니다. 일반 Future 자체가 모든 작업을 자동으로 중단시키는 것은 아니므로, 취소 지원 여부를 확인하지 않고 “Future를 cancel했다”고 가정하지 않습니다.
9단계: dispose 안에서 setState하거나 async 작업을 시작하지 않기
dispose()는 UI를 마지막으로 갱신하는 장소가 아니라 자원 연결을 끊는 장소입니다. 여기서 setState를 호출하면 이미 끝나는 State를 다시 그리려는 모순이 생깁니다. 저장이 필요한 사용자 데이터는 변경 순간에 저장하거나 widget 수명과 독립된 repository로 넘깁니다.
Flutter 문서에 따르면 앱 프로세스가 갑자기 종료되는 모든 경우에 dispose가 보장되는 것도 아닙니다. 따라서 중요한 데이터 저장이나 서버 반영을 dispose 하나에만 의존하면 안 됩니다. dispose에서는 Timer, subscription, listener와 controller를 빠르게 정리하고 마지막에 super.dispose()를 호출합니다.
10단계: 오류 스택에서 최초 callback과 보유자를 찾기
스택의 setState 프레임만 보지 말고 그 위에서 callback을 시작한 Timer, stream listener, animation 또는 Future 완료 지점을 찾습니다. Flutter의 오류 설명은 dispose 뒤 State를 다른 객체가 계속 참조하면 메모리 누수 가능성도 있다고 안내합니다.
- 어떤 화면 클래스가 dispose됐는지
- callback을 등록한 함수와 등록 시각
- 화면이 닫힌 경로: back, route 교체, 조건부 widget 제거
- callback을 보관한 객체와 listener 제거 여부
- 작업 ID·요청 세대·완료 시각
운영 오류 수집에는 사용자 데이터나 응답 본문 대신 widget 종류, 작업 종류, route, 익명 작업 ID와 수명주기 상태만 남깁니다. 같은 State 인스턴스에서 반복되는지 확인하면 단순 경합과 장기 listener 누수를 구분하는 데 도움이 됩니다.
mounted 검사와 실제 취소의 선택 기준
| 작업 | mounted 검사 | dispose 정리 |
|---|---|---|
| 한 번 끝나는 Future | 완료 뒤 필요 | 라이브러리가 지원하면 취소 |
| Timer | callback 방어로 사용 | 반드시 cancel |
| StreamSubscription | 이벤트 callback 방어로 사용 | cancel |
| AnimationController | listener 방어로 사용 | 소유했다면 dispose |
| BuildContext 사용 | context.mounted 확인 |
context를 장기 보관하지 않음 |
| 연속 검색 요청 | mounted와 최신 요청을 모두 검사 | 이전 요청 취소 또는 결과 폐기 |
수정 후 재현 테스트
| 테스트 | 기대 결과 |
|---|---|
| 느린 API 요청 직후 뒤로 이동 | 응답 뒤 setState·Navigator 호출 없음 |
| Timer 실행 직전 화면 닫기 | dispose에서 취소되고 callback 없음 |
| Stream 이벤트 중 route 교체 | 구독 취소 뒤 추가 이벤트 없음 |
| widget의 stream 입력 변경 | 이전 구독 해제 후 새 stream만 반영 |
| 검색 A 뒤 B를 빠르게 입력 | A가 늦게 와도 B 결과를 덮지 않음 |
| 애니메이션 중 화면 닫기 | controller와 listener가 한 번만 정리됨 |
| dialog 대기 중 route 닫기 | context.mounted 검사로 안전하게 종료 |
| 같은 화면 20회 진입·이탈 | callback·구독 수와 메모리가 계속 증가하지 않음 |
가장 짧은 수정 순서
- 오류 스택에서 dispose된 State와 callback 시작 지점을 찾습니다.
- 모든
await뒤 State·context 사용 분기를 확인합니다. - State에는
mounted, 지역 context에는context.mounted를 적용합니다. - Timer, Stream, listener와 controller의 참조를 필드로 보관합니다.
dispose()에서 소유한 작업을 취소하고 마지막에super.dispose()를 호출합니다.- 연속 요청에는 최신 요청 판별을 추가합니다.
- 느린 요청·빠른 화면 이탈·반복 진입 테스트로 누수를 다시 확인합니다.
이 순서를 따르면 오류 메시지만 숨기는 데서 끝나지 않고, 화면 수명보다 오래 살아 있는 비동기 작업과 참조를 함께 정리할 수 있습니다.
검증 기준과 참고 자료
이 글은 2026-09-15에 Flutter와 Dart 공식 API 문서를 기준으로 State 수명주기, setState, mounted, dispose, 구독·Timer·AnimationController 해제를 검수했습니다. 사용하는 상태 관리 도구와 네트워크 라이브러리에 따라 취소 API와 소유권 위치는 달라질 수 있습니다.
- Flutter: State 수명주기
- Flutter: State.setState()
- Flutter: State.dispose()
- Flutter: State.mounted
- Flutter: BuildContext.mounted
- Flutter: State.didUpdateWidget()
- Dart: Timer.cancel()
- Dart: StreamSubscription.cancel()
- Flutter: AnimationController.dispose()