Skip to content

Widget Tree 이해

Flutter의 UI는 위젯 트리(Widget Tree)라고 불리는 계층 구조로 구성됩니다. 이 장에서는 위젯 트리의 개념, 작동 방식, 그리고 Flutter가 위젯 트리를 통해 효율적으로 UI를 렌더링하는 방법에 대해 알아보겠습니다.

위젯 트리는 Flutter 애플리케이션의 UI를 구성하는 위젯들의 계층적 구조입니다. 모든 Flutter 앱은 루트 위젯에서 시작하여 중첩된 자식 위젯들로 이루어진 트리 형태를 가집니다.

위젯 트리는 다음과 같은 이유로 Flutter에서 중요한 개념입니다:

  1. UI 구조화: 복잡한 UI를 명확하고 체계적으로 구성할 수 있습니다.
  2. 렌더링 최적화: Flutter는 위젯 트리를 사용하여 변경된 부분만 효율적으로 다시 렌더링합니다.
  3. 상태 관리: 위젯 트리는 상태 관리 및 데이터 흐름의 기반을 제공합니다.
  4. 컨텍스트 제공: 위젯 트리는 BuildContext를 통해 상위 위젯과 테마, 미디어 쿼리 등에 접근할 수 있게 해줍니다.

Flutter의 렌더링 과정은 세 가지 트리로 이루어집니다:

  1. 위젯 트리(Widget Tree): 애플리케이션의 UI를 설명하는 불변(immutable) 객체의 트리
  2. 요소 트리(Element Tree): 위젯 트리의 런타임 표현으로, 위젯과 렌더 객체를 연결하는 가변(mutable) 트리
  3. 렌더 트리(Render Tree): 실제 화면에 그리기를 담당하는 객체들의 트리

위젯 트리는 개발자가 작성한 코드로, UI를 구성하는 위젯들의 설계도입니다. 위젯은 불변 객체이므로 상태가 변경되면 새로운 위젯 트리가 생성됩니다.

MaterialApp(
home: Scaffold(
appBar: AppBar(
title: Text('위젯 트리 예제'),
),
body: Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text('Hello, Flutter!'),
ElevatedButton(
onPressed: () {},
child: Text('버튼'),
),
],
),
),
),
)

요소 트리는 위젯 트리의 인스턴스로, 위젯의 수명 주기를 관리하고 위젯과 렌더 객체 사이의 연결을 유지합니다. 요소는 위젯이 변경될 때 업데이트되거나 재사용됩니다.

요소의 주요 유형:

  • ComponentElement: 다른 위젯을 빌드하는 위젯에 대응 (예: StatelessWidget, StatefulWidget)
  • RenderObjectElement: 화면에 무언가를 그리는 위젯에 대응 (예: RenderObjectWidget)

렌더 트리는 화면에 실제로 그리기를 담당하는 객체들의 트리입니다. 레이아웃 계산, 그리기, 히트 테스트(터치 이벤트 처리) 등을 수행합니다.

렌더 객체의 주요 유형:

  • RenderBox: 사각형 영역을 차지하는 렌더 객체
  • RenderSliver: 스크롤 가능한 영역의 일부를 렌더링하는 객체
  • RenderParagraph: 텍스트를 렌더링하는 객체

Flutter가 위젯 트리를 화면에 렌더링하는 과정은 다음과 같습니다:

  1. 위젯 생성: 개발자가 작성한 코드에 따라 위젯 트리가 생성됩니다.
  2. 요소 생성/업데이트: 각 위젯에 대응하는 요소가 생성되거나 업데이트됩니다.
  3. 렌더 객체 생성/업데이트: 요소와 연결된 렌더 객체가 생성되거나 업데이트됩니다.
  4. 레이아웃 계산: 렌더 객체는 부모로부터 제약 조건을 받아 자신의 크기를 결정합니다.
  5. 페인팅: 렌더 객체가 자신의 모양을 그립니다.
  6. 화면에 표시: 최종 결과가 화면에 표시됩니다.

BuildContext는 위젯 트리에서 위젯의 위치를 나타내는 객체입니다. 실제로는 요소 트리의 요소(Element)를 참조합니다.

BuildContext의 주요 용도:

  1. 상위 위젯 탐색: dependOnInheritedWidgetOfExactType()를 사용하여 상위 위젯에 접근
  2. 테마 및 미디어 쿼리 접근: Theme.of(context), MediaQuery.of(context)
  3. 네비게이션: Navigator.of(context).push()
  4. 기타 서비스 접근: ScaffoldMessenger.of(context), Form.of(context)
ElevatedButton(
onPressed: () {
// BuildContext를 사용하여 스낵바 표시
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('안녕하세요!')),
);
// BuildContext를 사용하여 다른 화면으로 이동
Navigator.of(context).push(
MaterialPageRoute(builder: (context) => SecondScreen()),
);
},
child: Text('버튼'),
)

Flutter는 위젯 트리가 변경될 때 효율적으로 UI를 업데이트하기 위해 “재조정(reconciliation)” 과정을 수행합니다:

Flutter의 재조정 알고리즘은 위젯의 “동일성”(identity)이 아닌 “동등성”(equality)에 기반합니다:

  1. 동일성(identity): 두 객체가 메모리에서 같은 인스턴스인지 (identical(a, b) 또는 a === b)
  2. 동등성(equality): 두 객체가 같은 타입과 속성을 가지는지 (a == b)

Flutter는 다음 규칙을 사용하여 위젯을 비교합니다:

  1. 다른 runtimeType: 위젯이 다른 타입이면 이전 요소를 폐기하고 새 요소를 생성합니다.
  2. 같은 runtimeType, 다른 key: 이전 요소를 폐기하고 새 요소를 생성합니다.
  3. 같은 runtimeType, 같은 key: 요소를 유지하고 속성을 업데이트합니다.

키는 Flutter가 위젯을 식별하는 데 사용되는 식별자입니다. 특히 동적 위젯(리스트, 그리드 등)에서 중요합니다.

키가 중요한 상황:

  1. 리스트 항목의 순서가 변경될 때
  2. 위젯이 추가/제거될 때
  3. 상태를 유지해야 할 때
  1. ValueKey: 단일 값을 기반으로 한 키

    ListView.builder(
    itemCount: items.length,
    itemBuilder: (context, index) {
    return ListTile(
    key: ValueKey(items[index].id),
    title: Text(items[index].title),
    );
    },
    )
  2. ObjectKey: 객체 전체를 기반으로 한 키

    ListTile(
    key: ObjectKey(item), // 'item' 객체 전체를 키로 사용
    title: Text(item.title),
    )
  3. UniqueKey: 매번 고유한 키 생성

    // 애니메이션 중에 위젯을 강제로 재생성할 때 유용
    Container(
    key: UniqueKey(),
    color: Colors.blue,
    child: Text('새로운 인스턴스'),
    )
  4. GlobalKey: 위젯의 상태에 접근하거나 위젯의 크기/위치를 파악하는 데 사용

    final formKey = GlobalKey<FormState>();
    Form(
    key: formKey,
    child: Column(
    children: [
    TextFormField(/* ... */),
    ElevatedButton(
    onPressed: () {
    if (formKey.currentState!.validate()) {
    // 폼 처리
    }
    },
    child: Text('제출'),
    ),
    ],
    ),
    )

리스트 위젯(ListView, GridView 등)은 많은 자식 위젯을 포함할 수 있습니다. 이러한 리스트에서 항목을 추가, 제거, 재정렬할 때 키를 사용하면 Flutter가 효율적으로 요소 트리를 업데이트할 수 있습니다.

키가 없으면 Flutter는 위치 기반으로 위젯을 비교합니다. 첫 번째 위젯 A는 그대로 유지되고, 두 번째 위치에 있던 B는 C로 업데이트됩니다. 이로 인해 상태가 예상치 않게 섞일 수 있습니다.

키를 사용하면 Flutter는 키를 기반으로 위젯을 식별합니다. B(key:2)가 제거되고 A와 C는 키를 통해 정확히 식별되어 상태가 올바르게 유지됩니다.

아래 예제는 복잡한 위젯 트리를 보여줍니다:

class ProfileScreen extends StatelessWidget {
final User user;
const ProfileScreen({
Key? key,
required this.user,
}) : super(key: key);
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: Text('프로필'),
actions: [
IconButton(
icon: Icon(Icons.settings),
onPressed: () { /* ... */ },
),
],
),
body: SingleChildScrollView(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// 사용자 헤더 섹션
UserHeaderWidget(user: user),
// 카운터 섹션
StatsSection(
followers: user.followers,
following: user.following,
posts: user.posts,
),
// 포스트 그리드
PostGridWidget(
posts: user.recentPosts,
onPostTap: (post) { /* ... */ },
),
],
),
),
bottomNavigationBar: BottomNavigationBar(
currentIndex: 0,
items: [
BottomNavigationBarItem(
icon: Icon(Icons.home),
label: '홈',
),
BottomNavigationBarItem(
icon: Icon(Icons.search),
label: '검색',
),
BottomNavigationBarItem(
icon: Icon(Icons.person),
label: '프로필',
),
],
onTap: (index) { /* ... */ },
),
);
}
}
// 중첩된 자식 위젯의 예
class UserHeaderWidget extends StatelessWidget {
final User user;
const UserHeaderWidget({
Key? key,
required this.user,
}) : super(key: key);
@override
Widget build(BuildContext context) {
return Container(
padding: EdgeInsets.all(16),
child: Row(
children: [
CircleAvatar(
radius: 40,
backgroundImage: NetworkImage(user.avatarUrl),
),
SizedBox(width: 16),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
user.name,
style: TextStyle(
fontSize: 20,
fontWeight: FontWeight.bold,
),
),
SizedBox(height: 4),
Text(user.bio),
],
),
),
],
),
);
}
}

위 코드의 위젯 트리 구조:

Flutter는 위젯 트리를 디버깅하기 위한 다양한 도구를 제공합니다:

Flutter DevTools의 위젯 인스펙터를 사용하면 위젯 트리를 시각적으로 탐색하고 속성을 검사할 수 있습니다.

// 위젯 트리를 콘솔에 출력
void _printWidgetTree() {
debugDumpApp();
}
// 사용 예
ElevatedButton(
onPressed: _printWidgetTree,
child: Text('위젯 트리 출력'),
)
// 위젯 인스펙터 활성화
void main() {
WidgetsFlutterBinding.ensureInitialized();
if (kDebugMode) {
WidgetInspectorService.instance.selection.addListener(() {
// 선택한 위젯이 변경될 때 호출
print('선택된 위젯: ${WidgetInspectorService.instance.selection.current}');
});
}
runApp(MyApp());
}

위젯 트리를 효율적으로 구성하면 앱의 성능을 향상시킬 수 있습니다:

과도하게 깊은 위젯 트리는 빌드 시간을 늘리고 메모리를 더 많이 사용합니다.

// 좋지 않은 예: 불필요하게 깊은 트리
Container(
child: Container(
child: Container(
child: Text('깊은 트리'),
),
),
)
// 좋은 예: 간결한 트리
Container(
padding: EdgeInsets.all(16),
margin: EdgeInsets.all(8),
decoration: BoxDecoration(/* ... */),
child: Text('간결한 트리'),
)

const 생성자로 만든 위젯은 빌드 시간에 한 번만 생성되어 메모리와 성능을 개선합니다.

// 좋지 않은 예: 매번 새로운 위젯 인스턴스 생성
Container(
padding: EdgeInsets.all(16),
child: Text('Hello'),
)
// 좋은 예: 불변 위젯 재사용
const SizedBox(height: 16)

자주 변경되지 않는 위젯을 분리하여 불필요한 재빌드를 방지합니다.

// 좋지 않은 예: 전체 화면이 다시 빌드됨
class MyScreen extends StatefulWidget {
@override
_MyScreenState createState() => _MyScreenState();
}
class _MyScreenState extends State<MyScreen> {
int _count = 0;
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: Text('앱')), // 매번 재빌드됨
body: Center(
child: Column(
children: [
ComplexWidget(), // 매번 재빌드됨
Text('카운트: $_count'),
ElevatedButton(
onPressed: () => setState(() => _count++),
child: Text('증가'),
),
],
),
),
);
}
}
// 좋은 예: 변경되지 않는 위젯 분리
class MyScreen extends StatefulWidget {
@override
_MyScreenState createState() => _MyScreenState();
}
class _MyScreenState extends State<MyScreen> {
int _count = 0;
// 클래스 필드로 선언하여 재사용
final _appBar = AppBar(title: Text('앱'));
final _complexWidget = ComplexWidget();
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: _appBar, // 재사용됨
body: Center(
child: Column(
children: [
_complexWidget, // 재사용됨
Text('카운트: $_count'), // 변경됨
ElevatedButton(
onPressed: () => setState(() => _count++),
child: const Text('증가'),
),
],
),
),
);
}
}

RepaintBoundary는 자식 위젯이 다시 그려질 때 부모 위젯까지 다시 그려지는 것을 방지합니다.

class MyAnimatedWidget extends StatefulWidget {
@override
_MyAnimatedWidgetState createState() => _MyAnimatedWidgetState();
}
class _MyAnimatedWidgetState extends State<MyAnimatedWidget>
with SingleTickerProviderStateMixin {
late AnimationController _controller;
@override
void initState() {
super.initState();
_controller = AnimationController(
vsync: this,
duration: Duration(seconds: 1),
)..repeat();
}
@override
void dispose() {
_controller.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return Column(
children: [
Text('이 텍스트는 다시 그려지지 않습니다'),
// RepaintBoundary로 애니메이션 위젯 격리
RepaintBoundary(
child: AnimatedBuilder(
animation: _controller,
builder: (context, child) {
return Transform.rotate(
angle: _controller.value * 2 * pi,
child: Container(
width: 100,
height: 100,
color: Colors.blue,
),
);
},
),
),
Text('이 텍스트도 다시 그려지지 않습니다'),
],
);
}
}

상속된 위젯(InheritedWidget)과 위젯 트리

Section titled “상속된 위젯(InheritedWidget)과 위젯 트리”

InheritedWidget은 위젯 트리를 통해 데이터를 효율적으로 전달하는 방법을 제공합니다. 이는 테마, 사용자 데이터 등을 하위 위젯에 전달하는 데 유용합니다.

// 데이터 모델
class UserData {
final String name;
final String email;
UserData({required this.name, required this.email});
}
// InheritedWidget 정의
class UserProvider extends InheritedWidget {
final UserData userData;
const UserProvider({
Key? key,
required this.userData,
required Widget child,
}) : super(key: key, child: child);
// of 메서드로 위젯 트리에서 UserProvider 인스턴스 찾기
static UserProvider of(BuildContext context) {
final provider = context.dependOnInheritedWidgetOfExactType<UserProvider>();
assert(provider != null, 'UserProvider가 위젯 트리에 없습니다');
return provider!;
}
@override
bool updateShouldNotify(UserProvider oldWidget) {
return userData.name != oldWidget.userData.name ||
userData.email != oldWidget.userData.email;
}
}
// 사용 예시
class MyApp extends StatelessWidget {
@override
Widget build(BuildContext context) {
return MaterialApp(
home: UserProvider(
userData: UserData(
name: '홍길동',
email: 'hong@example.com',
),
child: HomeScreen(),
),
);
}
}
// 하위 위젯에서 데이터 접근
class ProfileSection extends StatelessWidget {
@override
Widget build(BuildContext context) {
// UserProvider.of(context)로 데이터 접근
final userData = UserProvider.of(context).userData;
return Card(
child: Column(
children: [
Text('이름: ${userData.name}'),
Text('이메일: ${userData.email}'),
],
),
);
}
}

위젯 트리는 Flutter UI의 핵심 구성 요소입니다. 위젯 트리, 요소 트리, 렌더 트리의 개념을 이해하면 Flutter가 어떻게 효율적으로 UI를 구성하고 업데이트하는지 파악할 수 있습니다.

효율적인 위젯 트리 구성은 Flutter 앱의 성능과 유지 관리성에 큰 영향을 미칩니다. 적절한 위젯 키 사용, 위젯 구조 최적화, const 생성자 활용 등의 기법으로 더 효율적인 UI를 구축할 수 있습니다.

InheritedWidget과 같은 상속 메커니즘을 활용하면 위젯 트리를 통해 데이터를 효율적으로 공유하여 앱 아키텍처를 개선할 수 있습니다. 이러한 개념들은 Provider, Riverpod 등 Flutter의 상태 관리 솔루션의 기반이 됩니다.

다음 장에서는 Flutter의 기본 위젯들에 대해 더 자세히 알아보겠습니다.