BuildContext 지하 탐사 — Element, 트리, 그리고 단일 스레드
2025-12-28
Theme.of(context), Navigator.of(context), context.read()… 저도 하루에 수십 번 씁니다. 그런데 "이게 정확히 뭐냐"고 스스로에게 물으면 대답이 궁색해집니다. 문서엔 "위젯 트리에서의 위치를 다루는 핸들"이라고만 적혀 있죠. 이번엔 그 핸들의 정체를 지하까지 파보려 합니다 — 스레드 영역까지 내려가서요.
말로만 하면 미덥지 않으니, 이번엔 통과하는 테스트로 못박았습니다. 새 학습 리포에 아홉 가지 명제를 담아 전부 초록으로 확인했고, 그 위에 이 글을 씁니다.
💻 이 글의 증명 테스트는 github.com/kahnco/flutter-study 의
test/buildcontext_deep_test.dart에 있습니다.fvm flutter test로 직접 돌려볼 수 있습니다. 전부 Flutter 3.44.8 에서 실측하고, 그 버전의framework.dart소스로 교차검증했습니다.
먼저, Element 가 뭔가요 — 설계도와 건물
BuildContext 로 들어가기 전에, 그 정체인 Element 부터 짚어야 합니다. 여기서 막히면 뒤가 다 막히거든요.
Text('안녕') 이라고 쓰면, 그건 화면에 있는 "무엇"이 아니라 "이렇게 그려 달라"는 설명서 입니다. Widget 은 불변(immutable)이라, 값 하나만 바뀌어도 통째로 새로 만들어져 버려집니다 — 매 프레임 수백 개가 생겼다 사라지죠. 그런데 화면에 실제로 자리를 지키고 서 있는 무언가, "나는 트리의 이 위치야", "내 부모는 누구·자식은 누구", "내 상태(State)는 이거야" 를 기억하는 무언가는 따로 있어야 합니다. 그게 Element 입니다.
비유하면 이렇습니다.
- Widget = 설계도(주문서). 매번 새로 그려서 던지는 종이 한 장. 그 자체론 화면에 아무것도 아닙니다.
- Element = 그 설계도로 실제 지어져 자리를 지키는 건물(현장 담당자). 오래 삽니다. 새 설계도가 와도 건물을 부수고 다시 짓는 게 아니라 바뀐 부분만 반영(리모델링) 합니다.
- RenderObject = 실제로 크기를 재고 픽셀을 칠하는 실무자.
Flutter 는 여러분이 만든 Widget 마다 createElement() 로 Element 를 하나씩 부풀려(inflate) 트리를 세웁니다. 이 Element 트리 가 진짜로 살아 돌아가는 런타임 트리입니다. Widget 트리는 매 빌드 갈아엎어지지만, Element 트리는 같은 자리에 호환되는 위젯이 오는 한 그대로 유지 됩니다.
왜 이런 게 필요할까요? Widget 이 불변이고 매번 버려지기 때문입니다. 버려지는 것에는 상태를 담을 수도, "내가 트리 어디에 있는지"를 기억할 수도 없습니다. 그래서 Flutter 는 그 역할을 할 오래 사는 객체 를 따로 뒀고, 그게 Element 입니다.
비유가 아니라 실제 소스로 보면 이 역할 분담이 대놓고 드러납니다.
// 실제 framework.dart (Flutter 3.44.8) — 주석·assert 는 생략
// Widget: 불변 설정. 하는 일은 "내 Element 를 만들어라" 하나뿐이다.
abstract class Widget extends DiagnosticableTree {
const Widget({this.key});
final Key? key;
@factory
Element createElement(); // ← 위젯이 자기 Element 를 부풀린다(inflate)
}
// Element: 살아있는 노드. "내 위치·부모·수명·의존"을 전부 기억한다.
abstract class Element extends DiagnosticableTree implements BuildContext {
Element? _parent; // 부모(위로 걷는 사슬)
Object? _slot; // 부모 안에서 내 자리
Widget? _widget; // 지금 이 자리의 위젯(매번 갈아끼워짐)
_ElementLifecycle _lifecycleState = _ElementLifecycle.initial; // 수명
Set<InheritedElement>? _dependencies; // 내가 구독한 것들
PersistentHashMap<Type, InheritedElement>? _inheritedElements; // 조상 색인
}
Widget 은 createElement() 하나가 전부 입니다 — 그림을 어떻게 그릴지는 알아도, "내가 어디 있는지"는 모릅니다. 그 기억은 전부 Element 가 들고 있죠. 그리고 눈여겨볼 곳 — Element 가 implements BuildContext. 이 Element 가, 다음 장의 주인공인 BuildContext 입니다.
BuildContext 는 사실 그 Element 다
방금 Element implements BuildContext 를 봤습니다. 그런데 "그럼 내 build(context) 로 넘어오는 그 context 가 정말 이 Element 냐?" — 소스가 직접 답합니다.
// 실제 framework.dart (3.44.8) — StatelessElement
class StatelessElement extends ComponentElement {
StatelessElement(StatelessWidget super.widget);
@override
Widget build() => (widget as StatelessWidget).build(this); // ← this(=Element)를 context 로 넘긴다
}
build(this) — 여러분의 Widget.build(BuildContext context) 에 넘어오는 context 는 바로 이 Element(this) 입니다. 별도로 만들어 주는 객체가 아니라, 그 자리의 Element 를 그대로 건네는 거죠. 테스트로도 확인됩니다.
late BuildContext captured;
await tester.pumpWidget(Builder(builder: (context) {
captured = context;
return const SizedBox();
}));
expect(captured, isA<Element>()); // ✓ Element 다
expect(captured.runtimeType.toString(), 'StatelessElement'); // Builder 는 Stateless
StatefulWidget 이면 그 자리 Element 는 StatefulElement 이고, State.context 와 build 의 context 는 같은 인스턴스 입니다(identical). 결국 context 는 살아있는 Element 트리에서 "내 위치" 였던 겁니다.
Element 는 오래 살고, Widget 은 잠깐 산다 — 증명
앞서 설계도(Widget)와 건물(Element)로 나눴습니다. 이 "수명 차이"를 표로 정리하고, 코드로 증명해 봅니다.
| 트리 | 정체 | 수명 |
|---|---|---|
| Widget | 불변 청사진(@immutable) |
짧음 — 매 빌드마다 새로 생성·폐기 |
| Element | 살아있는 런타임 노드 = BuildContext | 긺 — 같은 자리면 유지 |
| RenderObject | 레이아웃·페인트·히트테스트 | 긺 |
그런데 StatefulWidget 의 State 는 어디에 사는 걸까요? 리빌드해도 안 날아가는 그 상태 말입니다. 소스를 보면 State 를 붙들고 있는 건 Element 입니다.
// 실제 framework.dart (3.44.8)
abstract class StatefulWidget extends Widget {
@override
StatefulElement createElement() => StatefulElement(this);
@protected @factory
State createState();
}
class StatefulElement extends ComponentElement {
StatefulElement(StatefulWidget widget)
: _state = widget.createState(), // ← Element 가 State 를 만들어 들고 있는다
super(widget) {
state._element = this; // ← State ↔ Element 서로를 가리킨다(양방향)
state._widget = widget;
}
final State _state;
// build() 는 state.build(this) 를 부른다 — State 의 build 에도 this(=Element)가 context 로 간다
}
여기서 놓치기 쉬운 게 있습니다 — setState 는 위젯을 바꾸지 않습니다. State 는 Element(StatefulElement)가 붙들고 있으니, 위젯이 새로 와도 State 와 Element 는 그대로 유지 됩니다. 새 위젯을 만들어 같은 Element 에 갈아끼울 뿐이죠. 같은 자리에 새 위젯을 두 번 주입해 보면 드러납니다.
expect(identical(elements[0], elements[1]), isTrue); // Element 는 그대로
expect(identical(widgets[0], widgets[1]), isFalse); // Widget 은 교체됨
_state 가 Element 안에 있다는 이 한 줄이, "리빌드해도 State 가 안 날아가는" 그 흔한 사실의 진짜 근거입니다. 그리고 "context 를 필드에 들고 재사용"이 (자리가 그대로인 한) 성립하는 이유이기도 하죠.
context 는 "전역"이 아니라 "트리 위치"다
Theme.of(context) 가 위치마다 다른 값을 주는 이유가 여기 있습니다. context 는 전역 변수가 아니라 트리에서의 내 주소 라서, 어디서 얻었느냐에 따라 조상이 달라집니다. 그래서 context 로 조상을 걸어 올라갈 수 있습니다.
leaf.visitAncestorElements((e) { chain.add(e.widget.runtimeType); return true; });
// → 바로 위 부모부터 루트 방향으로 조상 위젯 타입이 순서대로 쌓인다
findAncestorStateOfType<T>() 는 이 부모 사슬을 O(깊이) 로 훑는 1회성 조회입니다(구독이 아닙니다). 값이 바뀌어도 다시 안 불러 주니, build 에서 반복하면 비쌉니다.
setState 는 마법이 아니다 — BuildOwner 와 더티 목록
setState 가 화면을 어떻게 갱신하는지, 흐름은 전부 동기·단일 스레드 입니다.
setState()
→ Element.markNeedsBuild() // _dirty = true
→ owner.scheduleBuildFor(this) // BuildOwner._dirtyElements 에 추가
→ 다음 프레임 요청
프레임(VSync):
WidgetsBinding.drawFrame
→ buildOwner.buildScope(root)
→ _dirtyElements 를 depth 오름차순 정렬(얕은 것 먼저)
→ 각 element.rebuild → build() → updateChild()
→ flushLayout → flushPaint → 레이어 합성
앞 두 홉의 실제 소스는 이렇습니다.
// 실제 framework.dart (3.44.8) — State.setState 의 끝은 결국 한 줄이다
void setState(VoidCallback fn) {
// … fn() 이 Future 를 반환하면 에러(비동기 금지) 등 검사 …
_element!.markNeedsBuild();
}
void markNeedsBuild() { // Element
if (_lifecycleState != _ElementLifecycle.active) {
return; // 죽은(비활성) Element 면 무시 — mounted 와 직결된다
}
if (_dirty) {
return;
}
_dirty = true;
owner!.scheduleBuildFor(this); // BuildOwner 의 더티 목록에 넣고 프레임을 예약
}
markNeedsBuild 의 첫 줄 — 비활성 Element 면 그냥 return — 이 뒤에서 볼 mounted 이야기의 씨앗입니다. 그리고 더티 목록을 깊이 순 으로 도는 이유는, 부모를 먼저 리빌드하면 자식은 그 과정에서 갱신되니 중복을 피하려는 겁니다. 그리고 재조정(reconciliation)의 심장인 updateChild 에는 이런 단축이 있습니다.
if (hasSameSuperclass && child.widget == newWidget) {
// 새 위젯이 옛 위젯과 identical → 이 자식 서브트리는 리빌드하지 않는다
}
이 "identical 이면 건너뛴다"가 다음 이야기의 실험을 가능하게 합니다.
InheritedWidget — context 가 상태를 "구독"하는 진짜 자리
Provider, Theme.of, MediaQuery.of 가 전부 이 하나 위에 서 있습니다. 두 부분으로 나뉩니다.
먼저 O(1) 조회. InheritedElement 는 mount 될 때 부모의 색인 맵을 물려받아 자기를 얹어 자손에게 물려줍니다. 그래서 조회는 트리를 걸어 올라가는 게 아니라 맵 룩업 한 번입니다.
// 실제 framework.dart (3.44.8) — InheritedElement
void _updateInheritance() {
final incoming = _parent?._inheritedElements
?? const PersistentHashMap<Type, InheritedElement>.empty();
_inheritedElements = incoming.put(widget.runtimeType, this); // 부모 맵 + 나
}
// dependOnInheritedWidgetOfExactType 의 조회부 — 그냥 맵에서 꺼낸다
final InheritedElement? ancestor = _inheritedElements?[T]; // O(1)
그래서 50겹 아래에서도 dependOnInheritedWidgetOfExactType<T>() 는 O(1)입니다. 테스트로도 확인했습니다.
두 번째가 진짜 핵심 — 구독. dependOn… 은 조회만 하지 않습니다. 소스를 그대로 따라가면 등록·통지·리빌드가 한 줄씩 이어집니다.
// 실제 framework.dart (3.44.8) — 주석·assert 생략
// (1) 구독 등록: 서로를 가리킨다
InheritedWidget dependOnInheritedElement(InheritedElement ancestor, {Object? aspect}) {
(_dependencies ??= HashSet<InheritedElement>()).add(ancestor); // 내 의존 목록에 조상 추가
ancestor.updateDependencies(this, aspect); // 조상의 _dependents 에 나 등록
return ancestor.widget as InheritedWidget;
}
// (2) InheritedElement 는 자기를 구독한 Element 들을 들고 있다
class InheritedElement extends ProxyElement {
final Map<Element, Object?> _dependents = HashMap<Element, Object?>();
void updated(InheritedWidget oldWidget) {
if ((widget as InheritedWidget).updateShouldNotify(oldWidget)) {
super.updated(oldWidget); // updateShouldNotify 가 true 일 때만 통지로 넘어간다
}
}
void notifyClients(InheritedWidget oldWidget) {
for (final Element dependent in _dependents.keys) {
notifyDependent(oldWidget, dependent); // 등록된 각 dependent 에게
}
}
void notifyDependent(InheritedWidget oldWidget, Element dependent) {
dependent.didChangeDependencies(); // → 결국 markNeedsBuild()
}
}
// (3) Element.didChangeDependencies 는 그냥 리빌드다
void didChangeDependencies() {
markNeedsBuild();
}
이 세 조각을 이으면 끝입니다 — of(context) 로 구독을 걸어 두면, 값이 바뀔 때(updateShouldNotify) _dependents 에 등록된 그 context 들만 markNeedsBuild 된다. 이걸 깔끔히 증명하려고, 자손 서브트리를 identical 로 고정(앞의 단축을 이용)해 구조적 리빌드를 배제한 뒤 값만 바꿨습니다.
key.currentState!.bump(); // InheritedWidget 값만 변경
await tester.pump();
expect(depBuilds, 2); // 의존한 자손 → 리빌드됨
expect(nonDepBuilds, 1); // 의존 안 한 자손 → 리빌드 안 됨 ← 이게 핵심
Provider 가 "watch 한 위젯만 리빌드"되는 것도, Theme 를 바꾸면 그걸 쓴 위젯만 갱신되는 것도, 전부 이 _dependents 통지 한 줄에서 나옵니다.
구독 없는 조회도 있습니다.
findAncestorWidgetOfExactType/getElementForInheritedWidgetOfExactType는 등록을 안 합니다(1회성, 값이 바뀌어도 통지 없음). "of() 는 build 에서만 부르라"는 규칙이 여기서 나옵니다 — build 밖에서 부르면 구독이 안 걸려 갱신을 놓칩니다.
지하 바닥 — 스레드와 아이솔레이트
이제 정말 밑바닥입니다. 위젯·엘리먼트·렌더 트리와 빌드/레이아웃/페인트 파이프라인은 전부 UI(root) 아이솔레이트의 단일 스레드 에서 돕니다. 위젯 트리에 멀티스레드는 없습니다.
엔진(C++)에는 platform·raster(GPU)·IO 스레드가 따로 있지만, 우리의 Dart 위젯 코드는 거기서 절대 실행되지 않습니다. raster 스레드는 페인트가 만든 레이어 트리를 소비할 뿐, Element 를 만지지 않습니다.
그리고 결정적으로 — 아이솔레이트는 메모리를 공유하지 않습니다.
globalCounter = 41;
final inIsolate = await Isolate.run(() => ++globalCounter);
expect(inIsolate, 1); // 새 아이솔레이트는 메인의 41 을 못 본다 — 힙이 분리됨
expect(globalCounter, 41); // 메인 힙도 그대로 — 공유되지 않음
그래서 compute() 나 Isolate.run 에 BuildContext 를 넘길 수 없습니다. 참조로도 안 됩니다 — 객체가 아이솔레이트 경계를 못 넘으니까요. 무거운 일은 순수 데이터만 아이솔레이트로 보내고, 결과를 받아 UI 아이솔레이트에서 context 를 만져야 합니다.
한 가지 더. Future/await 는 다른 스레드가 아닙니다. 같은 아이솔레이트의 이벤트 루프 가 나중에 이어서 실행하는 것뿐입니다. 그래서 await 뒤에도 스레드는 그대로지만, 그 사이 프레임이 지나 Element 가 죽었을 수 있습니다.
await tester.pumpWidget(const SizedBox()); // 위젯 제거 → Element unmount(defunct)
expect(ctx.mounted, isFalse);
"async gap 뒤 context 를 쓰지 말라" 는 그 유명한 규칙이 정확히 여기서 나옵니다. 스레드가 바뀌어서가 아니라, 이벤트 루프가 되돌아왔을 땐 이미 그 Element 가 defunct 일 수 있어서 입니다.
정직하게 — 이건 버전에 묶인 이야기다
여기서 짚고 갈 게 있습니다. _inheritedElements·_dependents 같은 필드는 전부 private 입니다. 공개 API 가 아니라 프레임워크 내부라, 버전에 따라 얼마든지 바뀔 수 있습니다. 그래서 이 글은 "3.44.8 에서는 이렇게 생겼다"는 한 시점의 해부 이지, 영원한 계약이 아닙니다. 다음 메이저에서 자료구조가 바뀌어도 놀랄 일이 아닙니다.
바뀌지 않는 건 공개 계약 입니다 — dependOnInheritedWidgetOfExactType 가 "구독을 건다"는 것, context 가 트리 위치라는 것, 트리가 단일 아이솔레이트에 산다는 것. 내부 구현은 이 계약을 지키는 한 방식 일 뿐입니다. 그래서 이 글을 읽는 법은 "이 필드 이름을 외우자"가 아니라, "공개 규칙이 왜 그렇게 생겼는지 그 근거를 한 번 보자" 입니다.
정리 — 규칙의 근거는 전부 지하에 있다
- BuildContext = Element. context 는 살아있는 Element 트리에서 내 위치입니다.
- Element 는 오래, Widget 은 잠깐. setState 는 위젯을 갈아끼울 뿐, Element 는 유지됩니다.
- InheritedWidget 이 구독의 뿌리. 색인 맵으로 O(1) 조회,
_dependents통지로 의존한 자손만 리빌드 — Provider·Theme 가 다 이 위에 있습니다. - 단일 UI 아이솔레이트. 트리는 한 스레드에 갇혀 있고, 아이솔레이트는 메모리를 공유하지 않아 context 를 넘길 수 없으며, await 는 스레드가 아니라 같은 루프의 재개라
mounted확인이 필요합니다.
핵심 한 줄 — 외우던 규칙들은 암기할 게 아니라, 이 지하 구조에서 자연히 따라 나오는 결론 이었습니다. "async 뒤 context 금지", "of() 는 build 에서만", "context 마다 결과가 다름" — 한 번 밑바닥을 보고 나면, 규칙이 규칙이 아니라 당연한 얘기가 됩니다.
이 글의 아홉 가지 증명 테스트는 리포의
test/buildcontext_deep_test.dart, 정리는docs/buildcontext.md에 있습니다.