플러터(Flutter) 개발 현장에서 빈번하게 발생하는 퍼포먼스 저하와 간헐적인 크래시는 대부분 위젯의 생명주기(Lifecycle)에 대한 이해 부족에서 기인합니다. 선언형 UI(Declarative UI) 패러다임에서 개발자는 UI의 '상태'를 정의하지만, 실제 프레임워크가 이를 렌더링하고 메모리에서 해제하는 타이밍은 정해진 규칙을 따릅니다. 이 규칙을 무시하고 비동기 작업을 처리하거나 리소스를 점유할 경우, 메모리 누수(Memory Leak)와 'setState() called after dispose()' 같은 치명적인 런타임 에러를 마주하게 됩니다. 본 아티클에서는 플러터의 렌더링 파이프라인 관점에서 생명주기를 분석하고, 안정적인 애플리케이션 설계를 위한 엔지니어링 원칙을 제시합니다.
1. 아키텍처 관점: 세 개의 트리(Three Trees)
위젯의 생명주기를 논하기 전, 플러터가 UI를 렌더링하는 메커니즘인 '세 개의 트리' 구조를 이해해야 합니다. 단순히 build() 메서드가 호출된다고 해서 실제 화면이 다시 그려지는 것은 아닙니다.
- Widget Tree: 개발자가 작성하는 코드 그 자체입니다. 불변(Immutable) 객체이며, UI의 설정값(Configuration)만을 담고 있습니다. 매우 가볍기 때문에 빈번하게 생성되고 파괴되어도 성능에 큰 영향을 주지 않습니다.
- Element Tree: Widget과 RenderObject를 연결하는 중간 관리자입니다. 위젯의 생명주기 상태(State)를 관리하며, Widget이 교체될 때 기존 Element를 재사용할지 판단(Diffing)합니다. 실질적인 생명주기의 주체입니다.
- RenderObject Tree: 실제 레이아웃을 계산하고 페인팅을 수행하는 무거운 객체들입니다. Element Tree의 변경 사항에 따라 필요한 부분만 갱신됩니다.
setState()를 호출하면 해당 Widget에 연결된 Element가 'Dirty' 상태로 마킹됩니다. 다음 프레임에서 플러터 엔진은 Dirty Element의 build()를 재호출하여 Widget Tree를 재구성하고, 변경 사항만을 RenderObject에 반영하여 렌더링 비용을 최소화합니다.
2. StatelessWidget: 정적 라이프사이클
StatelessWidget은 상태를 가지지 않으므로 생명주기가 단순합니다. 생성자가 호출되고 build()가 실행되면 역할이 끝납니다. 하지만 '비용' 측면에서 고려할 점이 있습니다.
class StaticView extends StatelessWidget {
// 생성자: 불변 변수 초기화
const StaticView({Key? key}) : super(key: key);
@override
Widget build(BuildContext context) {
// 렌더링 로직
return Container();
}
}
이 위젯은 데이터가 변경되면 위젯 인스턴스 자체가 통째로 교체됩니다. 따라서 내부적으로 무거운 연산을 수행하거나 API를 호출하는 로직을 build() 내부에 포함해서는 안 됩니다.
3. StatefulWidget: 상태 관리와 생명주기 상세 분석
StatefulWidget은 위젯 인스턴스와 State 객체가 분리되어 있습니다. 위젯은 불변이지만, State 객체는 Element Tree에 유지되며 생명주기 동안 데이터를 보존합니다.
3-1. 초기화 단계 (Initialization)
| Method | 호출 횟수 | 주요 역할 |
|---|---|---|
createState() |
1회 | State 객체 생성. 생성자 직후 호출됨. |
initState() |
1회 | 변수 초기화, Stream 구독, Controller 생성. |
didChangeDependencies() |
1회 이상 | InheritedWidget(Provider, Theme 등) 의존성 변경 감지. |
Anti-Pattern 주의: initState() 내부에서 BuildContext에 의존하는 로직(예: Provider.of(context), MediaQuery.of(context))을 직접 호출하면 안 됩니다. 이 시점에는 아직 위젯이 트리에 완전히 마운트되지 않았기 때문입니다. 컨텍스트 의존 로직은 didChangeDependencies()에서 처리해야 안전합니다.
3-2. 업데이트 단계 (Updating)
위젯이 다시 그려지는(Rebuild) 상황은 크게 세 가지입니다.
setState()호출: 내부 상태 변경을 알림.- 부모 위젯의 Rebuild: 부모로부터 전달받는 파라미터가 변경될 때.
- InheritedWidget 변경: 참조하고 있는 상위 데이터(Theme, Locale 등)가 변경될 때.
@override
void didUpdateWidget(covariant MyWidget oldWidget) {
super.didUpdateWidget(oldWidget);
// 부모로부터 받은 설정(configuration)이 변경되었을 때 로직 수행
// 예: 사용자 ID가 바뀌었을 때 채팅방 소켓 재연결
if (widget.userId != oldWidget.userId) {
_reconnectSocket(widget.userId);
}
}
3-3. 소멸 단계 (Destruction) 및 리소스 해제
가장 중요한 단계입니다. dispose() 메서드에서 리소스를 제대로 해제하지 않으면 앱이 백그라운드로 넘어가거나 화면이 닫혀도 메모리를 계속 점유하게 됩니다.
dispose() 내에서 명시적으로 해제해야 합니다.
class _LifecycleDemoState extends State<LifecycleDemo> {
late final StreamSubscription _sub;
final TextEditingController _textCtrl = TextEditingController();
@override
void initState() {
super.initState();
_sub = someStream.listen((data) => updateData(data));
}
@override
void dispose() {
// 순서 중요: 리소스 해제 후 super.dispose 호출
_sub.cancel(); // 1. 스트림 구독 취소
_textCtrl.dispose(); // 2. 컨트롤러 해제
super.dispose(); // 3. 상위 클래스 소멸자 호출
}
// ... build method
}
4. 실무 이슈: 비동기 작업과 Mounted 속성
비동기 작업(API 호출) 후 setState()를 호출할 때 앱이 크래시되는 경우가 많습니다. 이는 비동기 작업이 완료되기 전에 사용자가 화면을 뒤로 가기(Pop)하여 위젯이 소멸(Unmount)되었는데, 소멸된 위젯에 대해 리빌드를 요청하기 때문입니다.
mounted 속성을 확인하여 위젯이 현재 트리에 존재하는지 검증해야 합니다.
Future<void> fetchData() async {
try {
final data = await apiService.getData();
// 비동기 대기(await) 이후에는 위젯이 살아있는지 반드시 확인
if (!mounted) return;
setState(() {
_data = data;
});
} catch (e) {
if (!mounted) return; // 에러 처리 시에도 확인 필요
showErrorDialog(context, e);
}
}
5. 성능 최적화 요약
생명주기를 올바르게 이해하면 다음과 같은 최적화가 가능합니다.
- 불필요한 리빌드 방지:
const생성자를 사용하여 상위 위젯이 리빌드될 때 하위 위젯의 인스턴스화를 방지합니다. Element Tree는 이를 감지하고 업데이트를 스킵합니다. - 무거운 작업 분리:
build()메서드는 프레임마다(초당 60~120회) 호출될 수 있습니다. JSON 파싱이나 데이터 변환 로직은initState혹은 별도 비즈니스 로직(BLoC, Provider)으로 위임해야 합니다. - Keys 활용: 컬렉션(List) 내의 아이템 순서가 바뀔 때,
Key를 부여하면 Element Tree가 기존 State를 보존하면서 효율적으로 위치만 변경합니다.
결론: 프레임워크의 의도를 파악하라
플러터의 생명주기는 개발자를 귀찮게 하기 위한 제약이 아니라, 고성능 UI 렌더링을 보장하기 위한 안전장치입니다. StatelessWidget을 기본으로 사용하되, 상태 변화가 필수적인 곳에만 StatefulWidget을 배치하여 복잡도를 관리하십시오. 특히 dispose()와 mounted 체크 패턴을 팀 내 코딩 컨벤션으로 정착시키는 것은 앱의 안정성을 높이는 가장 비용 효율적인 방법입니다.
Post a Comment