🟣 명령형에 익숙한 손이 처음 막히는 곳
UIKit으로 화면을 만들 때는 뷰를 하나씩 손으로 다뤘다. 레이블을 만들고, 상위 뷰에 붙이고, 값이 바뀌면 그 레이블을 찾아 text를 다시 넣었다. 화면은 내가 내린 명령들이 쌓여서 만들어지는 결과였다. 상태가 열 군데로 흩어지면 "지금 이 버튼을 누르면 어느 뷰를 갱신해야 하지"를 매번 손으로 추적해야 했다.
Flutter를 처음 켰을 때 가장 낯설었던 지점이 여기였다. Flutter에는 "이 레이블의 글자를 바꿔라" 같은 명령이 없다. 대신 현재 상태로부터 화면 전체를 어떻게 그릴지를 함수 하나로 기술하고, 상태가 바뀌면 그 함수를 다시 부른다. 뷰를 찾아가 고치는 게 아니라, 새 상태로 화면을 다시 선언한다. 이 관점 전환을 이해하려면 먼저 그 함수를 쓰는 언어인 Dart부터 봐야 한다.
🟣 Dart: 익숙한데 살짝 다른 언어
Dart는 처음 보면 Swift와 Java를 섞어 놓은 인상이다. 클래스 기반이고, 정적 타입이며, async/await를 언어 차원에서 지원한다. iOS를 하던 사람이라면 대부분의 문법이 낯설지 않다. 그래도 몇 군데는 Swift와 감각이 달라서 짚어 둘 만하다.
첫째는 널 안정성이다. Dart도 Swift의 옵셔널처럼 널 허용 여부를 타입에 새긴다. String은 널이 될 수 없고 String?는 될 수 있다. Swift의 String/String?와 사실상 같은 모델이라 이 부분은 그대로 옮겨 왔다. 다만 Dart는 이 규칙을 언어 역사 중간에 도입해서, 널 안정성 이전 코드와 이후 코드가 섞여 있는 시기가 있었다. 예제를 볼 때 이 둘을 구분하는 눈이 필요했다.
둘째는 비동기 모델이다. 하나의 값을 나중에 돌려주는 Future는 Swift의 async 함수 반환과 비슷하고, 여러 값을 시간에 걸쳐 흘려보내는 Stream은 Combine의 퍼블리셔에 가깝다. 이 Stream이 뒤에서 블루투스 스캔 결과처럼 계속 들어오는 데이터를 다룰 때 그대로 쓰인다.
Future<String> fetchName() async {
await Future.delayed(Duration(seconds: 1));
return "Lody";
}
Stream<int> counter() async* {
for (var i = 0; i < 3; i++) {
await Future.delayed(Duration(seconds: 1));
yield i;
}
}셋째가 가장 흥미로웠다. Dart는 기본적으로 단일 스레드 이벤트 루프로 돈다. iOS에서 GCD로 여러 스레드에 작업을 흩뿌리던 것과 달리, Dart의 async/await는 스레드를 새로 만드는 게 아니라 한 스레드 위에서 실행 순서를 양보하는 방식이다. 진짜 병렬이 필요하면 Isolate라는 별도 실행 단위를 띄우는데, 이 Isolate들은 메모리를 공유하지 않고 메시지로만 통신한다. 공유 메모리에서 락으로 경쟁 상태를 막던 방식과 정반대로, 아예 공유를 없애서 동기화 문제를 피하는 설계다. 시스템 프로그래밍에서 봤던 IPC 고민이 언어 런타임 층에서 이렇게 다르게 풀리는 게 인상적이었다.
🟣 선언형 UI: 화면은 상태의 함수다
언어를 훑고 나면 본론인 선언형 UI로 들어간다. 핵심은 한 문장이다. 화면은 상태를 입력으로 받아 UI를 출력하는 함수의 결과다. Flutter에서 이 함수가 위젯의 build 메서드다.
카운터를 예로 들면, 명령형에서는 숫자가 오를 때마다 레이블을 찾아 텍스트를 갈아 끼웠다. 선언형에서는 그런 코드가 없다. build가 "현재 count 값을 텍스트로 보여준다"고 한 번 기술해 두고, count가 바뀌면 build를 통째로 다시 부른다. 새로 만들어진 화면 기술에는 바뀐 숫자가 이미 반영돼 있다.
여기서 자연스럽게 드는 걱정이 하나 있다. 상태가 바뀔 때마다 화면 전체를 다시 그리면 느리지 않을까. UIKit이었다면 레이블 하나만 고치면 될 일을, 전부 새로 만드는 셈이니 낭비처럼 보인다. Flutter는 이 문제를 다음 장의 트리 구조로 푼다. build가 만드는 것은 실제 화면 픽셀이 아니라 가벼운 설정값 객체이고, 프레임워크가 이전 설정과 새 설정을 비교해 진짜로 달라진 부분만 다시 그린다. 그래서 매번 전체를 선언해도 실제 갱신 비용은 바뀐 곳에만 든다.
SwiftUI를 아는 사람이라면 이 그림이 거의 겹친다. var body: some View가 상태로부터 뷰를 기술하고, @State가 바뀌면 body가 다시 평가된다. Flutter의 build와 setState가 정확히 그 역할이다. 두 프레임워크가 등장 시기는 비슷했는데 같은 결론에 도달했다는 사실이, 선언형이 시대의 방향이라는 걸 실감하게 했다.
🟣 위젯: 모든 것이 위젯이다
Flutter를 처음 배우면 반드시 듣는 말이 "everything is a widget"이다. 버튼도 위젯, 텍스트도 위젯, 여백도 위젯, 화면 전체의 뼈대도 위젯이다. 심지어 정렬이나 패딩 같은 배치 규칙도 위젯으로 표현된다. UIKit에서 UIView, 오토레이아웃 제약, 뷰컨트롤러가 각각 다른 종류의 개념이었던 것과 대조적으로, Flutter는 이것들을 하나의 위젯이라는 단위로 통일한다.
이 통일이 주는 이점은 조합이다. 위젯은 다른 위젯을 자식으로 품어 트리를 이룬다. 텍스트를 패딩 위젯으로 감싸고, 그걸 다시 정렬 위젯으로 감싸는 식으로, 작은 위젯을 중첩해 복잡한 화면을 만든다. 상속으로 거대한 뷰 클래스를 만드는 대신, 단순한 위젯을 겹겹이 구성(composition) 하는 쪽이 Flutter의 기본 문법이다.
Widget build(BuildContext context) {
return Center(
child: Padding(
padding: EdgeInsets.all(16),
child: Text("count: $count"),
),
);
}이 코드가 앞서 말한 "가벼운 설정값 객체"의 정체다. Center, Padding, Text는 각각 화면에 무엇을 어떻게 배치할지를 담은 불변 설정일 뿐, 실제로 화면을 그리는 무거운 객체가 아니다. 그래서 build가 매 프레임 이것들을 새로 만들어도 부담이 적다. 이 설정 객체가 어떻게 실제 픽셀이 되는지가 다음 장의 주제다.
🟣 정리
- Flutter는 명령형으로 뷰를 고치는 대신, 상태로부터 화면을 기술하는
build함수를 다시 부르는 선언형 모델을 쓴다. SwiftUI의body재평가와 같은 발상이다. - Dart는 Swift와 감각이 비슷하되, 널 안정성 모델이 옵셔널과 닮았고
Future/Stream이 async·Combine에 대응한다. - Dart는 단일 스레드 이벤트 루프로 돌고, 진짜 병렬은 메모리를 공유하지 않는
Isolate로 처리한다. 공유 대신 메시지로 동기화 문제를 피하는 설계다. build가 만드는 위젯은 실제 화면이 아니라 가벼운 설정 객체다. 그래서 전체를 다시 선언해도 갱신 비용은 바뀐 곳에만 든다.- 모든 것이 위젯이라는 통일 덕분에, 작은 위젯을 중첩해 조합하는 방식으로 화면을 만든다.
면접에서 "SwiftUI와 Flutter를 둘 다 써 봤는데 어떤 공통점을 느꼈나"를 받으면, 이 장의 선언형 모델과 상태 기반 재평가를 근거로 답할 수 있었다. 다음 장에서는 이 가벼운 위젯 설정이 실제 화면 픽셀로 바뀌는 과정, 즉 위젯 트리 뒤에 숨은 두 개의 트리를 들여다본다.