Flutter Widget의 불변성과 rebuild 이해하기
Flutter Widget의 불변성과 rebuild 이해하기
Flutter의 Widget은 화면 객체 자체가 아니라 UI의 불변 설정 값이다. rebuild 때 새 Widget 인스턴스가 만들어져도 같은 위치의 Element가 runtimeType과 key를 비교해 기존 연결을 갱신할 수 있다. 따라서 Widget 생성, build 호출, layout, paint를 모두 같은 비용으로 보아서는 안 된다. build()는 빠르고 부수 효과 없이 유지하고, 실제 병목은 DevTools로 측정한다.
Flutter를 처음 사용할 때 가장 낯선 부분 중 하나는 Widget이 불변이라는 설명이다. Counter 숫자가 바뀌고 애니메이션이 움직이는데 UI의 기본 단위가 바뀌지 않는 객체라니 모순처럼 들린다.
class Greeting extends StatelessWidget {
const Greeting({
super.key,
required this.name,
});
final String name;
@override
Widget build(BuildContext context) {
return Text('Hello, $name');
}
}
name은 final이고 Widget 자체를 수정하는 메서드는 없다. 이름이 바뀌면 기존 Greeting을 수정하는 대신 새로운 설정 객체를 만든다.
Greeting(name: 'Mina')
Greeting(name: 'Jisoo')
Flutter가 매번 화면 전체를 버리고 새로 만드는 것은 아니다. 이 동작을 이해하려면 소스 코드에 보이는 Widget tree 뒤에 지속되는 Element tree와 RenderObject tree가 있다는 사실부터 봐야 한다.
목차
- #Widget은 화면이 아니라 설정이다
- #세 개의 트리를 구분하기
- #rebuild 때 실제로 일어나는 일
- #runtimeType과 key가 재사용을 결정한다
- #불변성이 주는 이점
- #build는 여러 번 호출될 수 있다
- #build 안에 부수 효과를 두면 안 되는 이유
- #const Widget이 줄이는 작업
- #rebuild와 layout·paint는 같은 말이 아니다
- #rebuild 범위를 작게 만드는 방법
- #DevTools로 실제 비용 확인하기
- #실무 체크리스트
- #마무리
- #관련 노트
- #참고 자료
Widget은 화면이 아니라 설정이다
Flutter API 문서에서 Widget은 Element의 configuration으로 설명된다. 색상, padding, 자식 관계처럼 “이 위치의 UI가 어떤 모습이어야 하는가”를 선언한다.
const Padding(
padding: EdgeInsets.all(16),
child: Text('Profile'),
)
이 객체가 직접 픽셀을 그리는 것은 아니다.
PaddingWidget은 여백 설정을 표현한다.TextWidget은 문자열과 스타일 설정을 표현한다.- Element는 해당 Widget이 트리의 어느 위치에서 사용되는지를 관리한다.
- RenderObject는 layout, paint, hit testing 같은 렌더링 작업에 참여한다.
같은 Widget 인스턴스를 여러 위치에서 사용할 수도 있다.
const spacer = SizedBox(height: 12);
Column(
children: [
const Text('이름'),
spacer,
const TextField(),
spacer,
const Text('이메일'),
],
)
spacer는 하나의 불변 설정 객체지만 트리에 삽입될 때 각 위치에 별도의 Element가 만들어진다. Widget identity만으로 화면상의 위치를 나타낼 수 없는 이유다.
“Widget이 화면에 있다”는 표현은 편리하지만 정확히는 특정 위치의 Element가 현재 Widget을 configuration으로 가지고 있다.
세 개의 트리를 구분하기
Flutter UI를 이해할 때 다음 세 트리를 나눠 보는 것이 유용하다.
flowchart LR
W["Widget tree
불변 설정"] --> E["Element tree
위치·수명·상태 연결"]
E --> R["RenderObject tree
layout·paint·hit test"]
R --> P["화면의 pixels"]Widget tree
build()가 반환하는 선언적인 UI 구성이다. state가 달라지면 새로운 Widget 객체들이 만들어질 수 있다. 비교적 짧은 수명의 값이다.
Element tree
Widget이 특정 위치에 실제로 사용된 인스턴스를 나타낸다. frame 사이에 지속되며 기존 Widget과 새 Widget을 연결한다. BuildContext도 Element가 구현하는 interface다.
RenderObject tree
크기와 위치를 계산하고 그리며 hit testing에 참여한다. 모든 Widget에 RenderObject가 하나씩 있는 것은 아니다. StatelessWidget, StatefulWidget 같은 composition Widget은 자식 Widget을 만들고, 구체적인 RenderObjectWidget 계열이 RenderObject를 구성한다.
간단한 코드도 내부 트리는 더 깊을 수 있다.
Container(
color: Colors.blue,
child: const Text('A'),
)
Container는 전달된 속성에 따라 ColoredBox, padding, constraints 같은 다른 Widget 조합을 만들 수 있다. DevTools에서 소스보다 깊은 트리가 보이는 것은 이상한 일이 아니다.
| 구분 | 주된 역할 | 수명 | 변경 방식 |
|---|---|---|---|
| Widget | UI 설정 선언 | 짧음 | 새 객체 생성 |
| Element | 트리 위치와 연결 관리 | 상대적으로 지속 | 새 Widget으로 update |
| RenderObject | layout과 paint | 상대적으로 지속 | 속성 갱신 후 필요한 phase 표시 |
rebuild 때 실제로 일어나는 일
state 변경으로 Element가 dirty가 되면 framework는 해당 부분의 build()를 다시 호출한다.
setState(() {
count += 1;
});
setState가 Widget 객체를 직접 수정하는 것은 아니다. 상태 변경을 수행하고 연결된 Element가 다시 build되어야 한다고 알린다.
개념적인 흐름은 다음과 같다.
sequenceDiagram
participant U as 사용자 입력
participant S as State
participant E as Element
participant W as 새 Widget 설정
participant R as RenderObject
U->>S: 버튼 tap
S->>S: setState로 count 변경
S->>E: dirty 표시
E->>W: build 호출
W-->>E: 새 자식 Widget 구성
E->>E: 이전·새 configuration 비교
E->>R: 변경된 속성만 갱신
R->>R: 필요할 때 layout / paint부모가 새 자식 Widget을 반환하면 Element는 기존 자식과 새 자식을 맞춰 본다. 같은 위치에서 runtimeType과 key가 같다면 기존 Element를 새 configuration으로 update할 수 있다. 다르면 기존 Element를 제거하고 새로운 Element를 inflate한다.
따라서 “Widget이 새로 생성됐다”와 “Element 및 상태가 폐기됐다”는 같은 말이 아니다.
Text('Count: 1')
Text('Count: 2')
두 Text Widget은 다른 객체지만 같은 위치에서 type과 key가 같다면 기존 Element와 RenderParagraph 계열 객체를 갱신할 수 있다.
runtimeType과 key가 재사용을 결정한다
Flutter의 Widget.canUpdate는 개념적으로 두 Widget의 runtimeType과 key가 같은지 확인한다.
old.runtimeType == new.runtimeType
old.key == new.key
key가 없다면 같은 부모 아래의 위치와 type이 중요한 기준이 된다.
Column(
children: [
UserCard(user: users[0]),
UserCard(user: users[1]),
],
)
목록 순서가 바뀌었는데 각 UserCard에 내부 state가 있다면 위치 기준 연결 때문에 state가 다른 사용자에게 붙은 것처럼 보일 수 있다.
Column(
children: [
for (final user in users)
UserCard(
key: ValueKey(user.id),
user: user,
),
],
)
ValueKey(user.id)는 이 configuration이 어떤 데이터 항목을 나타내는지 framework에 알려 준다.
key는 많을수록 좋은 것이 아니다.
- 정적인 자식 순서: 보통 key 불필요
- 삽입·삭제·재정렬되는 같은 type 목록: 안정적인 key 필요
- 위치가 바뀌어도 동일한 Element를 특별히 이동:
GlobalKey가 필요할 수 있으나 비용과 결합도 고려 - 매 build마다
UniqueKey()생성: 항상 다른 Widget으로 판단되어 state 재생성 가능
UserCard(
key: UniqueKey(),
user: user,
)
위 코드는 rebuild마다 의도적으로 identity를 바꾸므로 재사용을 막는다. state 초기화가 명확한 요구일 때만 사용한다.
StatefulWidget과 State가 분리된 이유, didUpdateWidget, dispose의 수명은 StatefulWidget의 상태가 State 객체에 있는 이유에서 이어서 다룬다.
불변성이 주는 이점
Widget이 mutable하다면 framework는 기존 객체 내부의 어떤 필드가 바뀌었는지 추적해야 한다. 자식 관계까지 객체 내부에서 직접 바뀌면 이전 상태와 새 상태를 분리해 비교하기 어려워진다.
불변 설정은 변화를 다음처럼 표현한다.
final before = StatusBadge(
status: OrderStatus.pending,
);
final after = StatusBadge(
status: OrderStatus.completed,
);
기존 설정과 새 설정이 별도 값이므로 Element는 자신의 위치를 유지하면서 최신 configuration으로 갱신할 수 있다.
불변성이 주는 실질적인 장점은 다음과 같다.
- 부모가 자식 인스턴스를 보존할 필요 없이 매번 선언할 수 있다.
- configuration의 생성 시점 이후 값이 몰래 바뀌지 않는다.
constcanonicalization과 동일 인스턴스 재사용이 가능하다.- hot reload와 reactive UI 모델이 단순해진다.
- UI를
UI = f(state)형태로 사고하기 쉽다.
이 모델에서는 개발자가 “기존 label을 찾아 문자열을 수정”하지 않는다.
setState(() {
status = OrderStatus.completed;
});
현재 state로 원하는 UI를 다시 설명하면 framework가 지속되는 객체를 효율적으로 갱신한다.
build는 여러 번 호출될 수 있다
build()는 내가 예상한 횟수만 호출되는 lifecycle callback이 아니다. framework가 필요하다고 판단하면 호출할 수 있고, animation 중에는 frame마다 호출될 수도 있다.
다음 상황에서 rebuild가 발생할 수 있다.
- 연결된 State에서
setState호출 - 부모가 rebuild하며 새 configuration 제공
- 참조하던
InheritedWidget값 변경 MediaQuery,Theme, locale 같은 ambient 값 변경- hot reload와 개발 도구 동작
따라서 build는 빠르고 반복 가능해야 한다.
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return Text(
title,
style: theme.textTheme.titleMedium,
);
}
위와 같은 가벼운 Widget 조합과 값 선택은 build의 역할이다.
반면 큰 JSON parsing, 이미지 처리, 동기 파일 I/O를 build에서 수행하면 반복 호출 때 frame budget을 소비한다.
@override
Widget build(BuildContext context) {
final report = parseLargeReport(rawJson);
return ReportView(report: report);
}
변하지 않는 계산은 사전에 수행해 state나 view model에 저장하고, 입력에 따른 파생 계산은 필요 범위와 비용을 측정한 뒤 memoization을 고려한다. 작은 계산까지 무조건 cache하면 invalidation 복잡성만 늘 수 있다.
build 안에 부수 효과를 두면 안 되는 이유
build가 여러 번 호출될 수 있으므로 네트워크 요청, analytics 전송, controller 변경 같은 부수 효과를 넣으면 중복 실행된다.
@override
Widget build(BuildContext context) {
repository.loadProfile();
analytics.logScreenView('profile');
return const ProfileSkeleton();
}
부모 rebuild나 theme 변경만으로도 요청과 로그가 반복될 수 있다.
Future를 FutureBuilder 안에서 즉석 생성하는 코드도 같은 문제를 만든다.
FutureBuilder<Profile>(
future: repository.loadProfile(),
builder: (context, snapshot) {
return ProfileContent(snapshot: snapshot);
},
)
State 수명 동안 한 번 시작해야 한다면 initState에서 Future를 보관한다.
class _ProfilePageState extends State<ProfilePage> {
late final Future<Profile> _profileFuture;
@override
void initState() {
super.initState();
_profileFuture = widget.repository.loadProfile();
}
@override
Widget build(BuildContext context) {
return FutureBuilder<Profile>(
future: _profileFuture,
builder: (context, snapshot) {
if (snapshot.hasError) {
return const ProfileErrorView();
}
if (!snapshot.hasData) {
return const ProfileSkeleton();
}
return ProfileContent(
profile: snapshot.requireData,
);
},
);
}
}
widget.repository가 부모 update로 바뀔 수 있다면 didUpdateWidget에서 기존 입력과 비교해 Future를 새로 만드는 정책이 필요하다. 무조건 late final이 정답은 아니고 요청 수명이 어떤 configuration에 묶이는지를 정해야 한다.
같은 입력으로 다시 build해도 외부 시스템 상태를 추가로 바꾸지 않아야 한다. UI 설명과 부수 효과의 수명을 분리하면 예상치 못한 rebuild가 버그가 되지 않는다.
const Widget이 줄이는 작업
컴파일 시점에 결정되는 Widget에는 const 생성자를 사용할 수 있다.
class EmptyState extends StatelessWidget {
const EmptyState({super.key});
@override
Widget build(BuildContext context) {
return const Center(
child: Text('표시할 항목이 없습니다.'),
);
}
}
같은 const 표현은 canonicalized instance로 재사용될 수 있고 framework가 동일한 Widget 인스턴스를 만났을 때 하위 update 작업을 건너뛸 기회를 준다.
Column(
children: [
Text(dynamicTitle),
const SizedBox(height: 16),
const HelpMessage(),
],
)
부모가 rebuild되어도 변하지 않는 SizedBox와 HelpMessage는 const로 분리할 수 있다.
하지만 const를 붙이는 것이 애플리케이션 성능의 전부는 아니다.
- 복잡한 동기 계산이 build에 남아 있으면 그대로 실행된다.
- 큰 state 범위가 자주 dirty가 되면 const가 아닌 자식들은 계속 갱신된다.
- layout과 paint 병목은 별도로 존재할 수 있다.
- 과도한 repaint나 shader 비용은 Widget 생성 수로 설명되지 않는다.
prefer_const_constructors 같은 lint는 좋은 기본값이지만 실제 frame 병목을 대신 측정해 주지는 않는다.
rebuild와 layout·paint는 같은 말이 아니다
Flutter frame pipeline의 주요 phase를 구분해야 한다.
flowchart LR
B["Build
Widget 구성"] --> L["Layout
크기·위치 계산"]
L --> P["Paint
그리기 명령 생성"]
P --> C["Compositing / Raster"]build가 실행됐다고 모든 하위 RenderObject가 반드시 layout과 paint를 다시 하는 것은 아니다. 새 configuration의 속성이 기존과 같다면 RenderObject 변경이 없을 수 있다.
반대로 build가 작은 범위여도 바뀐 속성이 넓은 layout이나 paint를 유발할 수 있다.
| 변경 예 | 예상되는 주된 영향 |
|---|---|
| Text 문자열 변경 | build, text layout, paint |
| 색상만 변경 | build/update, 주로 paint |
| 부모 constraints 변경 | 하위 layout 전파 가능 |
| opacity animation | 구성에 따라 compositing 또는 paint |
| 같은 const child 재사용 | 하위 build update를 건너뛸 가능성 |
“rebuild가 100번이라 느리다”는 결론을 내리기 전에 어느 phase가 frame budget을 넘는지 확인한다.
Widget rebuild, RenderObject layout, repaint, raster thread 작업을 구분해 기록한다. 같은 화면 끊김이라도 해결책이 다르다.
rebuild 범위를 작게 만드는 방법
State를 가장 가까운 곳에 두면 dirty 범위를 줄일 수 있다.
class ProductPage extends StatelessWidget {
const ProductPage({super.key});
@override
Widget build(BuildContext context) {
return const Column(
children: [
ProductHeader(),
QuantitySelector(),
ProductDescription(),
],
);
}
}
수량 state가 QuantitySelector 내부에만 필요하다면 페이지 root에 둘 이유가 없다.
class QuantitySelector extends StatefulWidget {
const QuantitySelector({super.key});
@override
State<QuantitySelector> createState() {
return _QuantitySelectorState();
}
}
class _QuantitySelectorState extends State<QuantitySelector> {
int quantity = 1;
@override
Widget build(BuildContext context) {
return Row(
children: [
IconButton(
onPressed: quantity > 1
? () => setState(() => quantity -= 1)
: null,
icon: const Icon(Icons.remove),
),
Text('$quantity'),
IconButton(
onPressed: () => setState(() => quantity += 1),
icon: const Icon(Icons.add),
),
],
);
}
}
외부 상태 관리 도구를 사용할 때도 전체 store를 watch하기보다 필요한 slice만 구독한다. 정확한 API는 사용하는 패키지에 따라 다르지만 원칙은 같다.
다음 최적화를 순서대로 고려한다.
- build 안의 I/O와 비싼 계산을 제거한다.
- state와 listen 범위를 실제 소비자 가까이 둔다.
- 변하지 않는 Widget을 const로 만든다.
- animation에서 변하지 않는 child를 builder 밖으로 분리한다.
- 긴 목록은 lazy builder를 사용한다.
- 측정으로 확인된 layout·paint 병목을 별도로 최적화한다.
Widget을 작은 클래스로 분리하면 framework가 build 경계를 건너뛸 기회가 생기고 코드 책임도 선명해질 수 있다. 단순 helper method는 호출될 때마다 부모 build의 일부로 실행된다.
DevTools로 실제 비용 확인하기
추측 대신 Flutter DevTools와 profile mode를 사용한다. debug mode의 assert, service extension, JIT 특성은 release 성능과 다르므로 성능 판단에 그대로 사용하지 않는다.
확인할 항목은 다음과 같다.
- Performance view의 UI와 raster frame 시간
- frame chart에서 긴 build, layout, paint event
- Flutter Inspector의 Widget rebuild 정보
- repaint rainbow로 불필요한 repaint 범위
- CPU profiler의 비싼 동기 계산
- memory view에서 controller와 subscription 누수
가설을 세운 뒤 같은 시나리오를 전후 비교한다.
시나리오: 상품 수량 버튼을 20회 빠르게 누른다
변경 전
- page root state 갱신
- 평균 UI frame: 가상 수치 13ms
- header와 description rebuild
변경 후
- selector 내부 state 갱신
- 평균 UI frame: 가상 수치 6ms
- selector만 rebuild
위 숫자는 측정 기록 형식을 보여 주는 예시일 뿐 실제 프로젝트 결과가 아니다.
성능 목표는 rebuild 0회가 아니다. 한 frame 안에 필요한 UI 갱신을 끝내고 사용자 입력에 안정적으로 반응하는 것이다.
실무 체크리스트
마무리
Flutter에서 Widget이 불변이라는 말은 화면이 변하지 않는다는 뜻이 아니다. 변화할 때 기존 객체를 수정하는 대신 현재 state를 반영한 새로운 UI configuration을 만든다는 뜻이다.
Widget은 비교적 가볍고 짧은 수명의 선언이다. Element는 특정 트리 위치에서 Widget을 받아 지속되는 상태와 RenderObject 연결을 관리한다. RenderObject는 layout과 paint를 담당한다. 새 Widget이 만들어져도 같은 위치에서 type과 key가 맞으면 기존 Element를 update할 수 있다.
이 구조 덕분에 개발자는 화면 객체를 직접 찾아 수정하지 않고 UI = f(state) 형태로 작성할 수 있다. 대신 build()가 여러 번 호출될 수 있다는 계약을 지켜야 한다. build는 빠르고 부수 효과 없이 유지하고, const와 작은 state 범위는 측정 결과에 따라 활용한다.
Widget 생성은 곧 화면 전체 재생성이 아니다. 불변 Widget은 새 설정을 전달하고, 지속되는 Element와 RenderObject가 필요한 부분만 갱신한다.