LODY/정리

Flutter / 위젯 트리와 Scaffold

위젯 트리와 Scaffold

🟣 가벼운 설정이 어떻게 픽셀이 되는가

앞 장에서 build가 만드는 위젯은 실제 화면이 아니라 가벼운 설정 객체라고 했다. 그러면 의문이 남는다. 이 설정은 언제 진짜 화면이 되고, 상태가 바뀔 때마다 전체를 다시 선언하는데도 왜 느리지 않은가. 이 질문에 답하려면 Flutter가 위젯 하나 뒤에 몰래 두 개의 트리를 더 관리한다는 사실을 알아야 한다.

UIKit에서는 UIView가 설정이자 실제 그려지는 객체였다. 하나의 뷰가 자기 상태도 들고, 화면에 자기를 그리기도 했다. Flutter는 이 역할을 셋으로 쪼갠다. 무엇을 그릴지 기술하는 층, 그 기술을 실제 인스턴스로 붙들고 있는 층, 픽셀을 측정하고 그리는 층이 각각 분리돼 있다. 이 분리가 선언형이 빠를 수 있는 이유다.


🟣 세 개의 트리: Widget, Element, RenderObject

Flutter 내부에는 트리가 세 벌 있다. 우리가 코드로 직접 다루는 건 그중 위젯 트리 하나뿐이고, 나머지 둘은 프레임워크가 알아서 관리한다.

위젯 트리는 화면을 어떻게 구성할지를 담은 불변의 청사진이다. build가 매번 새로 만드는 게 이것이다. 가볍고 자주 버려진다. 엘리먼트 트리는 각 위젯에 대응하는 실제 인스턴스로, 화면에 붙어 있는 동안 살아 있으면서 위젯과 렌더 객체를 연결한다. 상태를 들고 있는 것도 이 층이다. 렌더 객체 트리는 실제로 크기를 재고 배치하고 픽셀을 그리는 무거운 객체들이다.

위젯 트리(불변 청사진)엘리먼트 트리(수명·상태 보관)렌더 객체 트리(측정·배치·그리기)설정을 인스턴스화그리기 담당 생성

이 구조가 앞 장의 성능 걱정을 풀어 준다. 상태가 바뀌어 build가 다시 돌면 위젯 트리는 통째로 새로 만들어지지만, 프레임워크는 이 새 위젯을 기존 엘리먼트 트리와 같은 자리끼리 비교한다. 위젯의 타입과 키가 그대로면 엘리먼트와 렌더 객체는 재사용하고 바뀐 속성만 갱신한다. 무거운 렌더 객체는 그대로 두고 가벼운 위젯만 새로 만들어 대조하기 때문에, 매 프레임 전체를 선언해도 실제 다시 그리는 비용은 달라진 곳에만 든다.

iOS의 자동 레이아웃과 비교하면 렌더 객체 층의 일이 선명해진다. 렌더 객체가 크기를 정하는 과정은 부모가 자식에게 허용 범위(제약)를 내려보내고 자식이 그 안에서 자기 크기를 정해 돌려주는 방식이다. 제약은 위에서 아래로 흐르고 크기는 아래에서 위로 올라온다. 오토레이아웃이 제약을 한데 모아 풀던 것과 달리, Flutter는 트리를 한 번 내려갔다 올라오는 단일 패스로 배치를 끝낸다.


🟣 StatelessWidget과 StatefulWidget

위젯은 크게 두 종류다. 상태가 없는 위젯과 있는 위젯이다. 이 구분이 앞의 엘리먼트 트리와 직접 연결된다.

StatelessWidget은 입력만으로 화면이 정해지는 위젯이다. 생성자로 받은 값으로 build를 하고 나면 스스로 바뀔 일이 없다. 버튼 라벨이나 정적인 카드처럼, 부모가 주는 값이 곧 전부인 경우에 쓴다.

StatefulWidget은 시간에 따라 바뀌는 상태를 들고 있어야 하는 위젯이다. 여기서 Flutter의 설계가 한 번 더 갈라진다. 위젯 자체는 여전히 불변이라 상태를 담을 수 없어서, 상태는 짝을 이루는 별도의 State 객체에 둔다. 위젯은 매번 새로 버려지지만 이 State 객체는 엘리먼트 트리에 붙어 화면에 머무는 동안 살아남는다. 그래서 위젯이 아무리 다시 만들어져도 카운터 값 같은 상태는 유지된다.

class Counter extends StatefulWidget {
  @override
  State<Counter> createState() => _CounterState();
}
 
class _CounterState extends State<Counter> {
  int count = 0;
 
  void increment() {
    setState(() => count++);
  }
}

setState가 하는 일은 값을 바꾸는 게 아니라, "이 상태가 바뀌었으니 build를 다시 불러 달라"고 프레임워크에 표시하는 것이다. 값 자체는 그 안에서 우리가 직접 바꾸고, 재빌드 예약만 setState가 맡는다. SwiftUI의 @State가 값 변경을 감지해 body를 다시 부르던 것과 목적은 같지만, Flutter는 재빌드 시점을 이렇게 명시적으로 찍어 준다는 점이 달랐다.


🟣 Scaffold: 화면 한 장의 뼈대

이론을 실제 화면에 얹으려면 위젯을 어떻게 쌓는지 봐야 한다. Flutter에서 앱은 MaterialApp이라는 최상위 위젯에서 시작하고, 화면 한 장의 표준 뼈대는 Scaffold가 잡는다. 상단 바, 본문, 하단 버튼처럼 흔한 자리를 미리 슬롯으로 마련해 둔 위젯이다.

MaterialAppScaffoldAppBarbody: ColumnFloatingActionButtonhomeappBarbodyfloatingActionButton

본문 자리에는 보통 배치 위젯이 들어간다. Column은 자식을 세로로, Row는 가로로 쌓고, Container는 여백과 배경, 크기를 한데 묶는다. UIKit이라면 스택 뷰와 제약으로 짜던 레이아웃을, Flutter에서는 이 배치 위젯을 중첩하는 것으로 대신한다.

Scaffold(
  appBar: AppBar(title: Text("카운터")),
  body: Column(
    mainAxisAlignment: MainAxisAlignment.center,
    children: [
      Text("$count"),
      ElevatedButton(onPressed: increment, child: Text("+1")),
    ],
  ),
  floatingActionButton: FloatingActionButton(
    onPressed: increment,
    child: Icon(Icons.add),
  ),
)

여기까지 오면 앞 장의 "모든 것이 위젯"이 실감난다. 화면의 뼈대(Scaffold), 배치 규칙(Column), 내용(Text), 상호작용(FloatingActionButton)이 전부 같은 위젯이라는 단위로 트리에 꽂힌다. UIKit이라면 뷰컨트롤러, 스택 뷰, 레이블, 버튼이라는 서로 다른 종류의 개념이었을 것들이다.


🟣 정리

  • Flutter는 위젯·엘리먼트·렌더 객체 세 트리를 관리한다. 우리가 다루는 건 위젯 트리뿐이고, 나머지는 프레임워크가 잇는다.
  • 재빌드 때 새 위젯을 기존 엘리먼트와 같은 자리끼리 비교해 무거운 렌더 객체를 재사용한다. 전체를 다시 선언해도 갱신 비용이 바뀐 곳에만 드는 이유다.
  • 배치는 제약이 위에서 내려가고 크기가 아래에서 올라오는 단일 패스로 정해진다. 오토레이아웃과 다른 방식이다.
  • 위젯은 불변이라, 살아 있어야 하는 상태는 엘리먼트에 붙는 State 객체에 둔다. setState는 값이 아니라 재빌드를 예약한다.
  • Scaffold는 화면 한 장의 표준 뼈대를 슬롯으로 제공하고, 그 안을 Column·Row 같은 배치 위젯으로 채운다.

면접에서 "선언형인데 매번 다시 그리면 느리지 않냐"는 질문을 받으면, 이 장의 세 트리와 엘리먼트 재사용으로 답할 수 있었다. 라이브러리를 쓸 줄 안다가 아니라 왜 빠른지를 설명할 수 있는 것이 깊이의 차이였다. 다음 장에서는 setState 하나로는 감당이 안 되는 상황, 즉 상태가 위젯 트리 깊은 곳까지 전달돼야 할 때의 문제를 다룬다.