Flutter 튜토리얼 78편: Flutter 내부 구조 이해
요약
핵심 요지
- Flutter는 세 가지 트리(Widget, Element, RenderObject)를 사용하여 UI를 관리합니다.
- 적극적인 합성(Aggressive Composability)으로 작은 위젯들을 조합해 복잡한 UI를 구성합니다.
- 선형 이하(Sublinear) 알고리즘으로 레이아웃과 빌드 성능을 최적화합니다.
- 무한 스크롤 리스트는 뷰포트 인식 레이아웃과 주문형 위젯 빌드로 구현됩니다.
문서가 설명하는 범위
Flutter 공식 문서의 Architectural overview와 Inside Flutter를 기반으로 Flutter의 계층 구조, 세 가지 트리 시스템, 성능 최적화 기법을 설명합니다.
참고 자료
문제 상황
Flutter 앱을 개발하다 보면 여러 의문이 생깁니다.
Widget을 아무리 많이 만들어도 왜 성능이 괜찮을까요?setState()를 호출하면 내부에서 무슨 일이 일어날까요?- Hot Reload는 어떻게 상태를 유지하면서 UI를 업데이트할까요?
- 무한 스크롤 리스트는 어떻게 수천 개의 아이템을 부드럽게 처리할까요?
이러한 질문에 답하려면 Flutter의 내부 구조를 이해해야 합니다.
해결 방법
챕터 1: Flutter 아키텍처 계층
Why: 왜 계층 구조가 중요한가요?
Flutter는 계층화된 아키텍처1로 설계되어 있습니다. 각 계층이 독립적이므로 필요한 부분만 교체하거나 확장할 수 있습니다.
What: Flutter의 계층 구조
Flutter는 크게 세 가지 계층으로 나뉩니다.
1. Framework (Dart)
개발자가 주로 사용하는 계층입니다.
┌─────────────────────────────────────────┐│ Material / Cupertino │ ← 디자인 시스템├─────────────────────────────────────────┤│ Widgets │ ← UI 구성 요소├─────────────────────────────────────────┤│ Rendering │ ← 레이아웃, 페인팅├─────────────────────────────────────────┤│ Foundation │ ← 기본 유틸리티└─────────────────────────────────────────┘2. Engine (C++)
Flutter의 핵심 런타임입니다.
- Skia: 2D 그래픽 엔진
- Dart VM: Dart 코드 실행
- Text rendering: 텍스트 렌더링
3. Embedder (Platform-specific)
각 플랫폼(iOS, Android, Web 등)에 Flutter를 연결합니다.
How: 계층 구조 활용하기
일반적인 앱 개발에서는 Widget 계층만 사용해도 충분합니다.
// Widget 계층에서 작업class MyApp extends StatelessWidget { @override Widget build(BuildContext context) { return MaterialApp( home: Scaffold( body: Center( child: Text('Hello, Flutter!'), ), ), ); }}커스텀 렌더링이 필요하면 Rendering 계층에 접근합니다.
// CustomPainter로 직접 그리기class MyPainter extends CustomPainter { @override void paint(Canvas canvas, Size size) { final paint = Paint()..color = Colors.blue; canvas.drawCircle( Offset(size.width / 2, size.height / 2), 50, paint, ); }
@override bool shouldRepaint(covariant CustomPainter oldDelegate) => false;}Watch out: 주의할 점
- 대부분의 경우 Widget 계층만으로 충분합니다.
- Rendering 계층을 직접 다루면 복잡도가 급격히 증가합니다.
- 플랫폼별 기능이 필요하면 Platform Channel을 사용하세요.
챕터 2: 세 가지 트리 시스템
Why: 왜 트리가 세 개나 필요한가요?
Flutter는 Widget, Element, RenderObject라는 세 가지 트리를 사용합니다. 각 트리가 서로 다른 역할을 담당하여 성능과 유연성을 모두 확보합니다.
What: 각 트리의 역할
Widget Tree - 설계도
위젯은 불변(immutable)2 객체입니다. UI가 어떻게 보여야 하는지 설명하는 청사진 역할을 합니다.
// Widget은 불변 - 매번 새로 생성Container( color: Colors.blue, child: Text('Hello'),)Element Tree - 관리자
Element는 Widget과 RenderObject 사이를 연결합니다. Widget의 생명주기를 관리하고 상태(State)를 보관합니다.
Widget (설계도) → Element (관리자) → RenderObject (실제 작업자)RenderObject Tree - 실제 작업자
실제 레이아웃 계산과 화면 그리기를 담당합니다.
// RenderObject가 하는 일// 1. 레이아웃: 크기와 위치 계산// 2. 페인팅: 화면에 그리기// 3. 히트 테스트: 터치 이벤트 처리How: 세 트리의 상호작용
setState() 호출 시 일어나는 일을 살펴봅시다.
class Counter extends StatefulWidget { @override State<Counter> createState() => _CounterState();}
class _CounterState extends State<Counter> { int count = 0;
void increment() { setState(() { // 1. Element를 dirty로 표시 count++; }); }
@override Widget build(BuildContext context) { // 2. 새 Widget 생성 return Text('Count: $count'); }}실행 순서는 다음과 같습니다.
setState()가 Element를 dirty로 표시합니다.- 다음 프레임에서 dirty Element의
build()를 호출합니다. - 새 Widget과 기존 Widget을 비교합니다.
- 변경된 부분만 RenderObject를 업데이트합니다.
Watch out: 주의할 점
- Widget은 매 빌드마다 새로 생성되지만, Element와 RenderObject는 재사용됩니다.
const생성자를 사용하면 동일한 Widget 인스턴스를 재사용할 수 있습니다.- Key를 적절히 사용하면 Element 재사용을 제어할 수 있습니다.
챕터 3: 적극적인 합성(Aggressive Composability)
Why: 왜 작은 위젯으로 나누나요?
Flutter는 모든 것을 위젯으로 만듭니다.
Padding도 위젯이고, Center도 위젯입니다.
// 다른 프레임워크에서는 속성<div style="padding: 16px">...</div>
// Flutter에서는 위젯Padding( padding: EdgeInsets.all(16), child: ...,)이렇게 하면 조합의 자유도가 높아지고 재사용성이 증가합니다.
What: 합성의 장점
1. 단일 책임
각 위젯이 하나의 역할만 수행합니다.
// 각 위젯은 하나의 책임만Center( // 중앙 정렬 child: Padding( // 여백 추가 padding: EdgeInsets.all(16), child: Container( // 배경색, 크기 color: Colors.blue, width: 100, height: 100, ), ),)2. 유연한 조합
필요한 기능만 선택적으로 조합할 수 있습니다.
// Padding 없이 Center만Center(child: Text('Hello'))
// Center 없이 Padding만Padding( padding: EdgeInsets.all(16), child: Text('Hello'),)3. 테스트 용이성
작은 단위로 나뉘어 있어 테스트하기 쉽습니다.
How: 효율적인 합성 패턴
재사용 가능한 위젯을 만드세요.
// 재사용 가능한 카드 위젯class InfoCard extends StatelessWidget { final String title; final String subtitle; final VoidCallback? onTap;
const InfoCard({ super.key, required this.title, required this.subtitle, this.onTap, });
@override Widget build(BuildContext context) { return Card( child: InkWell( onTap: onTap, child: Padding( padding: const EdgeInsets.all(16), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text(title, style: Theme.of(context).textTheme.titleMedium), const SizedBox(height: 4), Text(subtitle, style: Theme.of(context).textTheme.bodySmall), ], ), ), ), ); }}Watch out: 주의할 점
- 위젯이 많아도 성능 문제는 거의 없습니다(최적화된 알고리즘 덕분).
- 하지만 불필요한 중첩은 코드 가독성을 해칩니다.
- 3단계 이상 중첩되면 별도 위젯으로 추출하는 것을 고려하세요.
챕터 4: 선형 이하(Sublinear) 알고리즘
Why: 왜 성능이 좋은가요?
Flutter는 O(N) 미만의 알고리즘을 사용합니다. 위젯 수가 늘어나도 성능이 선형적으로 증가하지 않습니다.
What: 최적화 기법들
1. 레이아웃 최적화
Flutter의 레이아웃은 한 패스(single pass)로 완료됩니다.
부모 → 자식: 제약 조건 전달자식 → 부모: 크기 반환
┌─────────────────────┐│ Constraints ↓ ││ ┌───────────────┐ ││ │ Child │ ││ │ Widget │ ││ └───────────────┘ ││ Size ↑ │└─────────────────────┘2. 빌드 최적화
dirty로 표시된 Element만 다시 빌드합니다.
class Parent extends StatelessWidget { @override Widget build(BuildContext context) { return Column( children: [ const Header(), // 변경 안 됨 - 스킵 DynamicContent(), // dirty - 리빌드 const Footer(), // 변경 안 됨 - 스킵 ], ); }}3. 빠른 비교
Widget의 동일성은 참조 비교로 확인합니다.
// const를 사용하면 같은 인스턴스 재사용const Icon(Icons.star) // 항상 같은 인스턴스How: 성능 최적화 적용
const 생성자 활용
// 좋은 예const Text('Hello')const SizedBox(height: 16)const Icon(Icons.home)
// 나쁜 예 - 매번 새 인스턴스 생성Text('Hello')SizedBox(height: 16)불필요한 리빌드 방지
class OptimizedList extends StatelessWidget { final List<String> items;
const OptimizedList({super.key, required this.items});
@override Widget build(BuildContext context) { return ListView.builder( itemCount: items.length, itemBuilder: (context, index) { // 각 아이템만 필요할 때 빌드 return ListTile(title: Text(items[index])); }, ); }}Watch out: 주의할 점
const를 사용할 수 있는 곳에서는 항상 사용하세요.ListView.builder는 보이는 항목만 빌드합니다.- 불필요한 상위 위젯의
setState()는 피하세요.
챕터 5: 무한 스크롤 구현 원리
Why: 어떻게 무한 리스트가 가능한가요?
ListView에 10만 개의 아이템이 있어도 Flutter는 부드럽게 동작합니다. 이는 뷰포트 인식 레이아웃과 주문형 빌드 덕분입니다.
What: Sliver 시스템
Sliver3는 스크롤 가능한 영역의 일부를 나타냅니다.
// Sliver를 사용한 커스텀 스크롤 뷰CustomScrollView( slivers: [ SliverAppBar( // 접히는 앱바 expandedHeight: 200, flexibleSpace: FlexibleSpaceBar( title: Text('My App'), ), ), SliverList( // 리스트 delegate: SliverChildBuilderDelegate( (context, index) => ListTile(title: Text('Item $index')), childCount: 100000, // 10만 개도 문제없음 ), ), ],)How: 주문형 빌드 활용
ListView.builder 사용
ListView.builder( itemCount: items.length, // 또는 null로 무한 itemBuilder: (context, index) { // 화면에 보일 때만 호출됨 return buildItem(index); },)GridView.builder 사용
GridView.builder( gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount( crossAxisCount: 2, ), itemCount: items.length, itemBuilder: (context, index) { return buildGridItem(index); },)무한 스크롤 구현
class InfiniteList extends StatefulWidget { @override State<InfiniteList> createState() => _InfiniteListState();}
class _InfiniteListState extends State<InfiniteList> { final List<String> items = []; bool isLoading = false;
@override void initState() { super.initState(); loadMore(); }
Future<void> loadMore() async { if (isLoading) return; setState(() => isLoading = true);
// API 호출 시뮬레이션 await Future.delayed(const Duration(seconds: 1));
setState(() { items.addAll(List.generate(20, (i) => 'Item ${items.length + i}')); isLoading = false; }); }
@override Widget build(BuildContext context) { return NotificationListener<ScrollNotification>( onNotification: (notification) { if (notification is ScrollEndNotification) { if (notification.metrics.pixels >= notification.metrics.maxScrollExtent - 200) { loadMore(); // 끝에 가까워지면 더 로드 } } return false; }, child: ListView.builder( itemCount: items.length + (isLoading ? 1 : 0), itemBuilder: (context, index) { if (index >= items.length) { return const Center(child: CircularProgressIndicator()); } return ListTile(title: Text(items[index])); }, ), ); }}Watch out: 주의할 점
ListView(children: [...])대신ListView.builder를 사용하세요.children방식은 모든 위젯을 한 번에 생성합니다.- 아이템이 20개 이상이면 반드시 builder 패턴을 사용하세요.
챕터 6: Element 재사용과 Key
Why: 왜 Key가 필요한가요?
Flutter는 Element를 재사용하여 성능을 최적화합니다. 하지만 리스트 순서가 바뀌면 잘못된 Element가 재사용될 수 있습니다.
What: Key의 종류
ValueKey
고유한 값으로 위젯을 식별합니다.
ListView.builder( itemCount: items.length, itemBuilder: (context, index) { final item = items[index]; return ListTile( key: ValueKey(item.id), // 고유 ID 사용 title: Text(item.name), ); },)ObjectKey
객체 참조로 위젯을 식별합니다.
ListTile( key: ObjectKey(item), // 객체 자체를 키로 사용 title: Text(item.name),)GlobalKey
앱 전체에서 고유한 키입니다.
final formKey = GlobalKey<FormState>();
Form( key: formKey, child: TextFormField(...),)
// 어디서든 접근 가능formKey.currentState?.validate();How: Key 올바르게 사용하기
순서가 바뀔 수 있는 리스트
// 드래그 앤 드롭으로 순서 변경ReorderableListView( onReorder: (oldIndex, newIndex) { setState(() { final item = items.removeAt(oldIndex); items.insert(newIndex, item); }); }, children: items.map((item) { return ListTile( key: ValueKey(item.id), // Key 필수! title: Text(item.name), ); }).toList(),)StatefulWidget 리스트
// 각 아이템이 상태를 가질 때ListView.builder( itemCount: todos.length, itemBuilder: (context, index) { return TodoItem( key: ValueKey(todos[index].id), // Key로 상태 유지 todo: todos[index], ); },)Watch out: 주의할 점
- 인덱스를 Key로 사용하지 마세요. 순서가 바뀌면 문제가 생깁니다.
- GlobalKey는 꼭 필요할 때만 사용하세요. 비용이 큽니다.
- StatelessWidget만 있는 리스트에서는 Key가 필수는 아닙니다.
한계
- 이 문서는 Flutter의 개념적인 구조를 설명합니다. 실제 소스 코드는 더 복잡합니다.
- Engine(C++) 계층의 상세 동작은 다루지 않습니다.
- 플랫폼별 Embedder 구현은 별도 학습이 필요합니다.
Footnotes
공유
이 글이 도움이 되었다면 다른 사람과 공유해주세요!