Flutter 앱 개발과 내부 구조: 위젯·엘리먼트·렌더 트리, 플랫폼 채널, 상태 관리
이 글의 핵심
Flutter 설치와 기본 위젯 사용법에 이어, 위젯 트리가 엘리먼트·렌더 트리로 바뀌는 과정, 레이아웃과 페인트 파이프라인, 네이티브 코드와 통신하는 플랫폼 채널, 상태 관리의 내부 동작을 정리합니다.
Flutter란?
Flutter는 Google이 만든 크로스플랫폼 UI 프레임워크로, Dart 언어 하나로 iOS·Android·웹·데스크톱 앱을 만듭니다. React Native가 각 플랫폼의 네이티브 UI 컴포넌트를 조합하는 것과 달리, Flutter는 버튼 하나까지 자체 렌더링 엔진(iOS와 최신 Android에서는 Impeller, 그 외 일부 환경에서는 Skia)으로 직접 그립니다. 그래서 플랫폼이 달라도 화면이 픽셀 단위로 같게 나오는 대신, 플랫폼 고유의 룩앤필을 원하면 Cupertino 위젯 등으로 따로 맞춰야 합니다.
개발 중에는 Dart VM이 JIT로 코드를 실행하므로 Hot Reload로 수정 사항을 상태를 유지한 채 바로 반영할 수 있고, 릴리스 빌드에서는 AOT 컴파일로 네이티브 기계어가 만들어집니다. 이 글은 기본 위젯과 상태, 내비게이션, API 호출 같은 사용법과 함께, 위젯·엘리먼트·렌더 트리와 프레임 파이프라인, 플랫폼 채널처럼 프레임워크 내부가 어떻게 동작하는지를 다룹니다.
설치 및 프로젝트 생성
설치
# macOS (Homebrew cask)
brew install --cask flutter
# Windows/Linux: 공식 사이트에서 SDK를 받아 PATH에 추가하거나 VS Code Flutter 확장으로 설치
# 설치 후 의존 도구(Xcode, Android SDK 등) 점검
flutter doctor
프로젝트 생성
flutter create my_app
cd my_app
flutter run
기본 Widgets
import 'package:flutter/material.dart';
void main() {
runApp(MyApp());
}
class MyApp extends StatelessWidget {
@override
Widget build(BuildContext context) {
return MaterialApp(
title: 'Flutter Demo',
// Material 3(Flutter 3.16부터 기본)에서는 primarySwatch 대신 seed 색으로 색 구성을 만든다
theme: ThemeData(
colorScheme: ColorScheme.fromSeed(seedColor: Colors.blue),
),
home: HomeScreen(),
);
}
}
class HomeScreen extends StatelessWidget {
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: Text('Home'),
),
body: Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text('Hello Flutter!', style: TextStyle(fontSize: 24)),
SizedBox(height: 16),
ElevatedButton(
onPressed: () {
debugPrint('Button pressed');
},
child: Text('Click me'),
),
],
),
),
);
}
}
Stateful Widget
class CounterScreen extends StatefulWidget {
@override
State<CounterScreen> createState() => _CounterScreenState();
}
class _CounterScreenState extends State<CounterScreen> {
int _counter = 0;
void _incrementCounter() {
setState(() {
_counter++;
});
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: Text('Counter')),
body: Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text('Count:', style: TextStyle(fontSize: 18)),
Text('$_counter', style: TextStyle(fontSize: 48, fontWeight: FontWeight.bold)),
SizedBox(height: 16),
ElevatedButton(
onPressed: _incrementCounter,
child: Text('Increment'),
),
],
),
),
);
}
}
심화: 위젯 트리·엘리먼트 트리·렌더 트리
Flutter UI는 겉으로는 위젯 트리 하나처럼 보이지만, 프레임워크 내부에서는 설정(Widget), 수명·연결(Element), 픽셀·레이아웃(RenderObject) 이 분리된 세 층으로 동작합니다. 이 분리 덕분에 매번 새 위젯을 만들어도 상태는 엘리먼트 쪽에 유지되고, Hot Reload 후에도 화면 상태가 남습니다.
위젯(Widget) 은 불변(immutable) 설명서입니다. build()가 매번 새 Widget 인스턴스를 만들어도 비용이 낮은 이유는, 실제 트리를 “통째로 교체”하지 않고 기존 엘리먼트가 새 위젯과 타입·키를 비교해 갱신하기 때문입니다. StatelessWidget/StatefulWidget은 모두 이 불변 계층에 속합니다.
엘리먼트(Element) 는 가변 계층으로, 위젯과 렌더 객체 사이를 잇습니다. Element는 위젯 수명 주기(마운트/언마운트), 부모-자식 연결, InheritedWidget 의존성 등을 담당합니다. StatefulElement는 State 객체를 붙잡고 setState 시 해당 서브트리만 다시 빌드할 수 있게 스케줄링합니다. 같은 런타임 타입과 Key가 유지되면 엘리먼트와 State가 재사용되고, 타입이 바뀌거나 키가 달라지면 기존 서브트리를 버리고 새로 붙입니다.
렌더 객체(RenderObject) 는 레이아웃 제약(Constraints), 크기(Size), 페인트, 히트 테스트를 담당하는 렌더 트리의 노드입니다. RenderObjectWidget(예: Padding, RichText)이 대응하는 RenderObject를 만들거나 갱신합니다. 흔히 쓰는 Text는 내부에서 RichText를 만드는 StatelessWidget입니다. 렌더 트리는 위젯 트리와 1:1이 아니며, RenderObjectElement 등을 통해 필요한 만큼만 연결됩니다.
실무에서 이 구조를 염두에 두면 다음이 명확해집니다. (1) 불필요한 위젯 타입 변경은 엘리먼트 재생성을 유발하며, (2) Key 남용/누락은 리스트 재정렬 시 상태가 엉키는 원인이 되며, (3) build는 부모가 다시 빌드되거나 의존한 데이터가 바뀔 때마다 호출될 수 있으므로, 그 안에 파일 읽기나 큰 JSON 파싱 같은 무거운 작업을 두면 프레임이 밀려 jank가 납니다.
심화: 빌드·레이아웃·페인트 파이프라인
한 프레임에서 Flutter는 대략 빌드 → 레이아웃 → 페인트 순으로 진행됩니다. 각 단계는 서로 다른 트리에서 일어나며, 스케줄러(SchedulerBinding)가 VSYNC에 맞춰 작업을 묶습니다.
-
빌드(Build)
setState,notifyListeners, 라우트 전환 등으로 “다시 그려야 함”이 표시되면, 해당Element서브트리에서Widget.build가 호출되어 새 위젯 트리 조각이 만들어집니다. 이때 레이아웃이나 페인트는 아직 확정되지 않습니다. 빌드는 “무엇을 그릴지”를 결정하는 단계입니다. -
레이아웃(Layout)
부모가 자식에게 제약(Constraints) 을 전달하며, 자식은 그 안에서 크기(Size) 를 결정해 올립니다(제약은 아래로, 크기는 위로).Flex,Row,Column등은 이 규칙 위에서 동작합니다. 레이아웃이 바뀌면 해당RenderObject와 필요 시 자식까지 마크되어 이후 레이아웃 패스에서 다시 계산됩니다. -
페인트(Paint) 및 합성
레이아웃이 끝나면RenderObject.paint가 호출되어 레이어(Layer) 트리에 그리기 명령이 쌓입니다.RepaintBoundary는 페인트 경계를 나눠 불필요한 전체 재페인트를 줄입니다. 스크롤·애니메이션·오버레이는 레이어 분리와 밀접합니다.
성능 관점에서 기억할 점은 다음과 같습니다. 빌드는 자주 일어나도 되지만 가볍게, 레이아웃/페인트는 덜 자주 일어나게 설계하는 것이 유리합니다. const 생성자로 빌드 결과를 안정화하며, 큰 리스트는 ListView.builder로 뷰포트 밖 빌드를 피하며, 무거운 연산은 Isolate나 플랫폼 쪽으로 넘깁니다.
심화: 플랫폼 채널(Platform Channel) 메커니즘
Flutter 엔진은 Dart VM과 플랫폼(iOS/Android 등) 사이를 비동기 메시지로 연결합니다. Dart의 MethodChannel·BasicMessageChannel·EventChannel은 모두 이 바이너리 메시지 파이프 위에 올라간 API입니다.
MethodChannel은 요청-응답 패턴에 가깝습니다. Dart에서 invokeMethod를 호출하면 직렬화된 메시지가 엔진을 거쳐 플랫폼 쪽 핸들러로 전달되고, 결과가 다시 Dart로 돌아옵니다. 메서드 이름과 인자는 코덱(예: StandardMethodCodec, JSONMethodCodec)에 따라 인코딩됩니다.
BasicMessageChannel은 메서드 이름 없이 코덱으로 인코딩한 메시지를 양방향으로 주고받을 때 쓰며, EventChannel은 스트림(지속 이벤트)을 플랫폼에서 Dart로 흘려보낼 때 자주 사용됩니다(예: 센서, 배터리, 네이티브 콜백 스트림).
채널 호출은 항상 비동기입니다. 플랫폼 쪽 핸들러는 기본적으로 플랫폼 메인 스레드에서 실행되므로, 오래 걸리는 작업을 그대로 하면 앱 전체가 멈춥니다. 무거운 작업은 백그라운드 스레드로 넘기거나, 채널을 만들 때 백그라운드 태스크 큐를 지정합니다. 반대로 네이티브 쪽에서 채널로 Dart에 메시지를 보낼 때는 메인 스레드에서 호출해야 합니다. 고빈도·대용량 데이터를 매 프레임 주고받는 설계는 채널 오버헤드가 커지므로, 가능하면 배치 처리, 공유 버퍼, 또는 dart:ffi로 대체하는 방안을 검토합니다.
간단한 예시는 다음과 같습니다(실제 네이티브 등록 코드는 플랫폼별로 추가).
import 'package:flutter/services.dart';
class BatteryChannel {
static const _channel = MethodChannel('com.example.app/battery');
Future<int?> getLevel() async {
try {
final level = await _channel.invokeMethod<int>('getBatteryLevel');
return level;
} on PlatformException catch (e) {
// 에러 코드·메시지는 플랫폼과 사전 약속
throw Exception('Battery: ${e.code} ${e.message}');
}
}
}
심화: 상태 관리의 내부 동작
setState는 단순히 변수를 바꾸는 것이 아니라, Element에 “빌드가 필요함” 을 표시하고 프레임에 빌드 작업을 스케줄합니다. BuildOwner는 dirty 엘리먼트를 모아 한 프레임 안에서 정리합니다.
InheritedWidget은 “위에서 아래로 전달되는 데이터”를 엘리먼트 트리의 의존성 그래프로 구현합니다. 자식이 context.dependOnInheritedWidgetOfExactType<T>()로 의존을 등록하면, 상위 InheritedWidget이 새 값으로 교체되고 updateShouldNotify가 true를 반환할 때 의존을 등록한 자식만 다시 빌드됩니다. Theme, MediaQuery 등이 이 패턴입니다.
Provider 패키지는 InheritedWidget을 확장한 자체 위젯으로 값을 트리에 내려보내고, ChangeNotifier를 구독해 notifyListeners가 호출되면 그 값에 의존한 위젯만 다시 빌드합니다. Consumer나 context.watch가 의존을 등록하고, context.read는 등록하지 않습니다. Riverpod은 위젯 트리 꼭대기의 ProviderScope 하나에 프로바이더 상태를 모아 두고, 프로바이더 사이의 의존을 ref.watch로 추적하는 별도 그래프를 둡니다. 그래서 프로바이더 값을 읽는 데 BuildContext가 필요 없고, 존재하지 않는 프로바이더를 찾는 런타임 에러가 생기지 않습니다.
요약하면, 상태 관리 프레임워크의 차이는 “상태를 어디에 두느냐”보다 누가 어떤 Element 서브트리를 다시 build하게 하느냐, 의존성 추적을 어떻게 하느냐의 설계 차이입니다. 디버깅할 때는 Flutter DevTools의 위젯 리빌드 추적(Track widget rebuilds)으로 어떤 위젯이 몇 번 다시 빌드되는지부터 확인하는 것이 효과적입니다.
심화: 프로덕션 Flutter 패턴
아키텍처
화면별 로직만으로는 커지면 한계가 있으므로, 레이어드 구조(presentation / domain / data)나 feature 단위 모듈로 경계를 나눕니다. 상태는 화면 단위 ChangeNotifier/Notifier부터 시작해 도메인이 커지면 Bloc, Riverpod, get_it + injectable 등 팀 규모에 맞게 도입합니다.
성능·안정성
const생성자와 불변 모델로 빌드 비용 절감- 긴 리스트·그리드는 빌더 위젯과 캐시 전략
- 이미지는
cacheWidth/cacheHeight, 적절한 포맷(WebP 등) - 프레임워크 에러는
FlutterError.onError, 그 밖의 비동기 예외는PlatformDispatcher.instance.onError로 수집해 Sentry나 Crashlytics로 보냄 - 테스트:
widget_test, Golden test(픽셀 회귀), 통합 테스트는 팀에 맞게 최소 세트 고정
빌드·배포
--dart-define / --dart-define-from-file로 환경 분리, flavor(Android productFlavors, iOS scheme)로 스테이징·프로덕션 분기. CI에서는 flutter test, flutter analyze, dart format을 게이트로 두며, 코드 서명·스토어 제출은 파이프라인 문서화합니다.
플랫폼·웹
모바일과 웹은 렌더러·스크롤·라우팅 차이가 있으므로 kIsWeb 분기나 ResponsiveFramework 등으로 레이아웃을 나눕니다. 접근성(semantics), 국제화(intl), 오른쪽-왼쪽 언어는 초기에 넣을수록 비용이 적습니다.
Navigation
// 화면 이동
Navigator.push(
context,
MaterialPageRoute(builder: (context) => DetailsScreen()),
);
// 뒤로가기
Navigator.pop(context);
// 데이터 전달
Navigator.push(
context,
MaterialPageRoute(
builder: (context) => DetailsScreen(userId: 1),
),
);
Named Routes
MaterialApp(
initialRoute: '/',
routes: {
'/': (context) => HomeScreen(),
'/details': (context) => DetailsScreen(),
},
);
// 사용
Navigator.pushNamed(context, '/details');
Flutter 공식 문서는 named routes를 대부분의 앱에 권장하지 않습니다. 딥 링크를 받았을 때 동작을 세밀하게 제어하기 어렵고 웹의 브라우저 뒤로 가기와도 잘 맞지 않기 때문입니다. 딥 링크나 웹 지원이 필요하다면 Router API 기반의 go_router 패키지를 쓰는 편이 일반적입니다.
State Management (Provider)
위 「심화: 상태 관리의 내부 동작」 에서 setState, InheritedWidget, 리빌드 스케줄링을 엔진 관점에서 다룹니다. 여기서는 대표적인 선언적 API인 Provider 사용 예입니다.
flutter pub add provider
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
class Counter with ChangeNotifier {
int _count = 0;
int get count => _count;
void increment() {
_count++;
notifyListeners();
}
}
void main() {
runApp(
ChangeNotifierProvider(
create: (context) => Counter(),
child: MyApp(),
),
);
}
class CounterScreen extends StatelessWidget {
@override
Widget build(BuildContext context) {
return Scaffold(
body: Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text('Count:'),
Consumer<Counter>(
builder: (context, counter, child) {
return Text('${counter.count}', style: TextStyle(fontSize: 48));
},
),
ElevatedButton(
onPressed: () {
context.read<Counter>().increment();
},
child: Text('Increment'),
),
],
),
),
);
}
}
API 호출
flutter pub add http
import 'package:http/http.dart' as http;
import 'dart:convert';
class User {
final int id;
final String name;
final String email;
User({required this.id, required this.name, required this.email});
factory User.fromJson(Map<String, dynamic> json) {
return User(
id: json['id'],
name: json['name'],
email: json['email'],
);
}
}
Future<List<User>> fetchUsers() async {
final response = await http.get(Uri.parse('https://api.example.com/users'));
if (response.statusCode == 200) {
List<dynamic> body = jsonDecode(response.body);
return body.map((json) => User.fromJson(json)).toList();
} else {
throw Exception('Failed to load users');
}
}
class UserListScreen extends StatefulWidget {
@override
State<UserListScreen> createState() => _UserListScreenState();
}
class _UserListScreenState extends State<UserListScreen> {
late Future<List<User>> futureUsers;
@override
void initState() {
super.initState();
futureUsers = fetchUsers();
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: Text('Users')),
body: FutureBuilder<List<User>>(
future: futureUsers,
builder: (context, snapshot) {
if (snapshot.hasData) {
return ListView.builder(
itemCount: snapshot.data!.length,
itemBuilder: (context, index) {
final user = snapshot.data![index];
return ListTile(
title: Text(user.name),
subtitle: Text(user.email),
);
},
);
} else if (snapshot.hasError) {
return Center(child: Text('Error: ${snapshot.error}'));
}
return Center(child: CircularProgressIndicator());
},
),
);
}
}
Form
class LoginForm extends StatefulWidget {
@override
State<LoginForm> createState() => _LoginFormState();
}
class _LoginFormState extends State<LoginForm> {
final _formKey = GlobalKey<FormState>();
final _emailController = TextEditingController();
final _passwordController = TextEditingController();
@override
void dispose() {
_emailController.dispose();
_passwordController.dispose();
super.dispose();
}
void _handleSubmit() {
if (_formKey.currentState!.validate()) {
// 검증 통과: 여기서 로그인 API를 호출한다. 비밀번호는 로그로 남기지 않는다.
}
}
@override
Widget build(BuildContext context) {
return Form(
key: _formKey,
child: Column(
children: [
TextFormField(
controller: _emailController,
decoration: InputDecoration(labelText: 'Email'),
validator: (value) {
if (value == null || value.isEmpty) {
return 'Please enter email';
}
return null;
},
),
TextFormField(
controller: _passwordController,
decoration: InputDecoration(labelText: 'Password'),
obscureText: true,
validator: (value) {
if (value == null || value.isEmpty) {
return 'Please enter password';
}
return null;
},
),
SizedBox(height: 16),
ElevatedButton(
onPressed: _handleSubmit,
child: Text('Submit'),
),
],
),
);
}
}