Flutter는 선언적(declarative) UI 프레임워크로, 개발자가 UI의 현재 상태를 단순히 그리는 틀이라 볼 수 있는 코드만 작성하면 된다. 이 과정에서 Flutter는 위젯, 엘리먼트, 렌더 오브젝트라는 세 가지 핵심 계층을 활용하여 실제 화면에 변경된 부분만 효율적으로 페인팅한다. 이번 글에서는 Flutter가 어떻게 변경된 위젯만 정확히 찾아내어 다시 렌더링하는지, 그 내부 동작 원리를 자세히 설명하고자 한다.
Flutter 아키텍처
Flutter의 UI 아키텍처는 크게 세 계층으로 구성된다.
- Widgets
위젯은 UI의 “청사진(blueprint)”이다. 위젯은 불변(immutable) 객체이며, 개발자가 UI의 상태를 기술하는 선언적 도구이다. 위젯은 단순히 화면에 무엇을 보여줄지 기술할 뿐, 직접적으로 화면을 그리는 역할은 하지 않는다. - Elements
엘리먼트는 위젯과 렌더 오브젝트 사이의 다리 역할을 한다. 엘리먼트는 위젯 인스턴스를 실제 트리 상의 “노드”로 변환하며, 해당 위치에 어떤 렌더 오브젝트가 연결되어 있는지를 관리한다. 이 과정에서 엘리먼트는 위젯 트리와 렌더 트리 간의 비교(diffing) 작업을 수행한다. - Render Objects
렌더 오브젝트는 실제 화면에 그려지는 “실체”이다. 이들은 레이아웃, 페인팅, 히트 테스트 등 UI의 물리적 특성을 관리하며, 상대적으로 무거운 객체이다. 성능 최적화를 위해 렌더 오브젝트는 가능한 재사용된다.
Linear Build
선언형 UI프레임워크 특성상 UI변화가 있으면 그부분만 콕 집어서 UI를 다시그려내는 것이 성능상 굉장히 중요하다. Flutter는 이부분을 Elements가 수행하는 Diffing작업이 담당하고 있다.
Inside Flutter 공식문서에서는 Diffing 메커니즘이 왜 효율적인지 설명해주고 있다. 이 문서에 근거하여 설명해보자면
먼저 State의 생명주기는 위와 같다. 무언가 위젯에 변경사항이 있는 해당 위젯은 dirty flag가 꽂히고 이를 다시 build 해서 clean 상태로 바꾼다. 대부분 블로그가 여기까지 이야기하고있는데 이를 좀더 자세히 들여다보면...
플러터는 기본적으로 트리구조이다. 아래위젯은 부모위젯의 상태를 상속(via inheritedWidget)받아 자신의 상태를 반영한다. 만약 여기서 Theme 위젯의 텍스트 컬러에 변화가 생겼다고 해보자. 그렇다면 해당 Theme을 상속받는 모든 하위위젯들은 모두 변경사항이 생길 것이다. 따라서 Theme Widget 뿐만 아니라 영향받는 모든 하위위젯들도 Dirty flag가 꽂히게 된다. 이렇게 최상단부터 최하단 위젯까지 도달해서 dirty가 꽂혔다면 이제 다시 최하단부터 변경사항을 반영해야한다.
앞선 예시를 계속 이어가자면 가장 하위 텍스트 위젯은 가장 최상단 TextTheme을 알아야 자기자신을 변경할 수 있기 때문에 상위 위젯을 탐색하게 된다. 자 그러면 이때 시간복잡도는 어떻게 될까? 트리의 높이가 N이고 트리의 너비도 N이기 때문의 시간복잡도는 O(n^2)이 되버린다. 플러터팀은 이러한 작업을 최적화해서 시간복잡도를 O(n)으로 줄였다. 공식문서에서는 이를 Linear Building으로 소개하고 있다.
앞선 방법은 변경점이 있는 위젯을 dirty로 바꾸고 영향받는 하위위젯까지 dirty로 바꾸었다가 영향받은 최하단 위젯에서 다시 최상단 위젯을 찾았다면 이번에는..
1. dirty가 된 위젯들을 모두 List에 저장해둔다.
2. dirty list에 있는 위젯은 다시 새로운 위젯으로 만들어내고 기존 Element와 비교한다.
3. 만약 동일(타입과 key가 같다면)하면 Element.update()를 호출하여 속성만 업데이트하고, 하위 clean 영역은 조기 종료(Early Exit)로 처리한다.
4. 동일하지 않으면 기존 Element를 폐기하고 새 Element를 생성한다.
이때 하위위젯은 상위위젯 변경사항을 알기위해 InheritedWidet의 해쉬테이블 자료구조를 이용하게 된다.
정리하자면, 상위 위젯에서의 변화 영향을 받은 위젯(상위 -> 하위)을 탐색하는데 DirtyList를 탐색하기 때문에 O(n), 이제 하위 위젯이 상위위젯 변경점을 파악할때 InheritedWidget 내부에 해쉬테이블 자료구조를 이용해서 시간복잡도 O(1), 변경사항을 반영하는데 총 O(n) 이 되는 것이다. 이러한 일련의 과정을 Linear build라고한다.
Reconciliation
Linear Build 내부를 좀더 자세히 살펴보자. 기존 element tree와 dirty위젯을 새로만든 위젯을 비교하는 구간이 있는데 이를 공식문서에서는 Reconciliation(재조정)이라고 소개하고 있다.
Flutter는 새로운 위젯 트리와 기존 엘리먼트 트리의 각 노드를 재귀적으로 순회하며 비교한다. 이때 각 엘리먼트는 자신의 자식 위젯 리스트와 새로 생성된 위젯 리스트를 비교하는데, 주로 위젯의 타입(widget.runtimeType)과 선택적으로 지정된 key를 기준으로 동일성을 판단한다.
만약 기존 엘리먼트의 위젯과 새 위젯이 동일한 타입과 key를 공유한다면, 해당 엘리먼트는 재사용되며 Element.update() 메서드를 통해 새로운 속성 값들이 렌더 오브젝트에 반영된다. 반면, 타입이나 key가 일치하지 않는 경우에는 기존 엘리먼트가 제거되고 새롭게 Element가 생성되어 트리에 삽입된다. (좀더 자세한 key 비교방식 정리 링크)
이렇게 reconciliation 과정이 이루어진 이후 결과를 토대로 렌더 오브젝트는 레이아웃과 페인팅의 무거운 작업을 담당한다. 이들은 생성 비용이 크므로 diffing 결과를 토대로 다음과 같이 최적화를 수행한다.
- 유형 일치 검사
동일한 위젯 타입이 존재할 경우, 기존 렌더 오브젝트를 그대로 재사용하고 변경된 속성만 업데이트한다. 이 과정은 렌더 트리의 불필요한 재구성을 방지한다. - 상태 보존
StatefulWidget의 경우, 위젯이 다시 생성되더라도 내부의 State 객체는 엘리먼트에 의해 유지된다. 따라서, 사용자가 입력한 값이나 애니메이션 상태 등은 계속 보존되며, 변경된 부분만 다시 반영된다.
즉 플러터의 3계층 Widgets, Element, RenderObject 에서 각각 UI 최적화 작업이 이루어지고 있으며 위젯의 생명주기를 담당하는 Element가 특히 더 중추적인 역할을 수행한다.
자 이제 어디가서 플러터는 어떻게 바뀐 위젯만 다시그리나요? 라고 물으면 이렇게 답하면 된다.
위젯은 그려질때 Linear Building 방식을 따르고 변화점을 Reconciliation 방식을 통해 최적화 작업을 수행합니다 이러한 최적화 과정 속에서 플러터는 변경된 위젯만 콕 찝어 다시 그려내게 됩니다 :)
'Flutter' 카테고리의 다른 글
Flutter에서 Stomp로 소켓통신하기 (1) | 2025.02.05 |
---|---|
Stateless Widget 과 Stateful Widget 의 선택 기준 (0) | 2025.02.02 |
Package 버전관리 전략 (0) | 2025.01.27 |
Expanded 과 Flexible 의 차이 (0) | 2025.01.25 |
Segment 버튼에 애니메이션 적용하기 (0) | 2025.01.25 |