Flutter를 배우는 첫 번째 관문
Flutter를 처음 열면 거의 모든 코드에서 Widget이라는 단어를 만납니다. 화면 전체도 위젯이고, 글자도 위젯이고, 버튼도 위젯입니다. 심지어 여백, 정렬, 애니메이션, 테마, 화면 전환까지도 위젯으로 표현합니다.
처음에는 이상하게 느껴질 수 있습니다. "버튼은 버튼이고, 여백은 여백이지 왜 전부 위젯이라고 부르지?"라는 생각이 드는 것이 자연스럽습니다. 하지만 바로 여기에 Flutter의 핵심이 숨어 있습니다.
Flutter에서 위젯은 단순히 화면에 보이는 부품만 뜻하지 않습니다. 위젯은 화면이 현재 어떤 모습이어야 하는지 설명하는 객체입니다. 즉, 위젯은 UI를 직접 고치는 도구가 아니라, UI의 설계도에 가깝습니다.
예를 들어 다음 코드를 봅시다.
Text('안녕하세요')이 코드는 화면에 글자를 직접 그리는 명령이라기보다 "이 위치에는 '안녕하세요'라는 텍스트가 있어야 한다"라고 Flutter에게 설명하는 문장입니다.
버튼도 마찬가지입니다.
ElevatedButton(
onPressed: () {},
child: Text('저장'),
)이 코드는 "사용자가 누를 수 있는 버튼이 있고, 그 안에는 '저장'이라는 글자가 있으며, 버튼을 누르면 이 함수가 실행되어야 한다"라고 말합니다.
Flutter 개발을 잘하려면 개별 위젯의 이름을 많이 외우는 것보다, 위젯이 어떤 식으로 조합되고 다시 그려지는지 이해하는 것이 훨씬 중요합니다. 위젯 이름은 필요할 때 문서를 찾아보면 됩니다. 하지만 위젯 트리와 선언형 UI 사고방식을 이해하지 못하면, 앱이 조금만 복잡해져도 "왜 화면이 안 바뀌지?", "왜 여기서 context 에러가 나지?", "왜 build가 계속 호출되지?" 같은 문제에 부딪히게 됩니다.
이 장에서는 Flutter의 진짜 출발점인 위젯의 개념을 차근차근 살펴보겠습니다.

위젯은 화면의 현재 상태를 설명한다
Flutter의 UI 코드는 대부분 다음과 같은 형태를 가집니다.
class MyApp extends StatelessWidget {
const MyApp({super.key});
@override
Widget build(BuildContext context) {
return MaterialApp(
home: Scaffold(
appBar: AppBar(
title: Text('나의 앱'),
),
body: Center(
child: Text('Hello Flutter'),
),
),
);
}
}이 코드는 명령형으로 화면을 하나씩 조작하지 않습니다. "앱은 MaterialApp이고, 그 안에 Scaffold가 있고, 위에는 AppBar, 가운데에는 Text가 있다"라고 구조를 설명합니다.
이런 방식을 선언형 UI라고 부릅니다. 선언형 UI에서는 "어떻게 바꿀지"보다 "현재 상태에서 화면이 무엇이어야 하는지"를 코드로 표현합니다.
반대로 명령형 UI 방식이라면 이런 식으로 생각합니다.
텍스트 객체를 찾는다.
텍스트 내용을 바꾼다.
버튼 색상을 찾아서 수정한다.
목록에 새 항목을 직접 추가한다.선언형 UI에서는 다르게 생각합니다.
현재 count 값이 3이다.
그러면 화면에는 '3'이라는 글자가 보여야 한다.
현재 로그인 상태가 true다.
그러면 홈 화면을 보여야 한다.
현재 데이터가 비어 있다.
그러면 빈 상태 안내 문구를 보여야 한다.Flutter는 이 선언을 바탕으로 실제 화면을 효율적으로 갱신합니다. 개발자는 "버튼을 찾아서 글자를 바꿔라"라고 명령하지 않습니다. 대신 "상태가 이렇게 바뀌었으니, 이 상태에 맞는 화면은 이렇다"라고 다시 설명합니다.
중요한 점은 위젯이 대부분 불변 객체라는 것입니다. 불변 객체란 한 번 만들어진 뒤 내부 값이 바뀌지 않는 객체를 말합니다. Text('A')를 만든 다음 그 객체 안의 글자를 B로 바꾸는 식이 아닙니다. 대신 Text('B')라는 새 위젯 설명을 다시 만듭니다.
처음에는 "화면을 매번 새로 만든다고? 비효율적인 것 아닌가?"라는 걱정이 들 수 있습니다. 하지만 Flutter는 위젯 설명 전체를 실제 화면에 무식하게 다시 그리는 것이 아닙니다. 개발자가 새 위젯 트리를 제공하면, Flutter 내부에서 이전 구조와 새 구조를 비교하고 필요한 부분만 갱신합니다.
즉, 우리는 단순하게 코드를 쓰고, Flutter는 똑똑하게 갱신합니다.
위젯은 조립식 블록이다
Flutter UI는 레고 블록처럼 작은 위젯을 조합해서 만듭니다. 텍스트 하나, 버튼 하나, 여백 하나가 각각 블록입니다. 이 블록들이 서로 감싸고 포함하면서 하나의 화면을 이룹니다.
다음 예제를 봅시다.
Center(
child: Padding(
padding: const EdgeInsets.all(24),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(Icons.check_circle, size: 64),
SizedBox(height: 16),
Text('저장되었습니다'),
SizedBox(height: 16),
ElevatedButton(
onPressed: () {},
child: Text('확인'),
),
],
),
),
)이 코드를 화면 구조로 풀어 보면 다음과 같습니다.
Center
└─ Padding
└─ Column
├─ Icon
├─ SizedBox
├─ Text
├─ SizedBox
└─ ElevatedButton
└─ Text이렇게 위젯이 부모와 자식 관계로 연결된 구조를 Widget Tree, 즉 위젯 트리라고 부릅니다. 트리는 나무처럼 뿌리에서 가지가 뻗어 나가는 구조입니다. Flutter 앱은 루트 위젯에서 시작해 수많은 자식 위젯으로 뻗어 나갑니다.
이 관점에 익숙해지면 UI를 보는 눈이 달라집니다. 예쁜 카드 UI를 봐도 "이건 Card 안에 Padding이 있고, 그 안에 Row와 Column이 조합되어 있겠구나"라고 상상할 수 있습니다. 앱 화면을 디자인 이미지로만 보는 것이 아니라, 위젯 트리로 번역할 수 있게 됩니다.
Flutter 실력이 늘어난다는 것은 결국 화면을 위젯 트리로 자연스럽게 생각할 수 있게 된다는 뜻이기도 합니다.
StatelessWidget: 상태가 없는 위젯
Flutter에서 직접 위젯 클래스를 만들 때 가장 먼저 만나는 것이 StatelessWidget입니다. 이름 그대로 "상태가 없는 위젯"입니다.
여기서 상태란 화면을 다시 그리게 만드는 값이라고 생각하면 쉽습니다. 예를 들어 현재 카운트 숫자, 입력 중인 텍스트, 선택된 탭, 로그인 여부, 서버에서 받아온 목록 데이터는 모두 상태가 될 수 있습니다.
반면 단순히 전달받은 값을 보여주기만 하는 위젯은 내부 상태가 필요 없습니다.
class ProfileName extends StatelessWidget {
const ProfileName({
super.key,
required this.name,
});
final String name;
@override
Widget build(BuildContext context) {
return Text(
name,
style: const TextStyle(
fontSize: 20,
fontWeight: FontWeight.bold,
),
);
}
}ProfileName은 이름을 받아서 화면에 보여줄 뿐입니다. 스스로 이름을 바꾸지 않습니다. 이런 위젯은 StatelessWidget으로 만드는 것이 적절합니다.
StatelessWidget은 단순하고 예측하기 쉽습니다. 같은 입력을 주면 같은 UI를 만듭니다. 그래서 가능하면 많은 위젯을 StatelessWidget으로 유지하는 것이 좋습니다. 상태가 적을수록 코드의 흐름이 단순해지고, 테스트하기도 쉬워집니다.
하지만 앱에는 사용자의 행동에 따라 바뀌는 화면이 반드시 필요합니다. 버튼을 누르면 숫자가 증가하고, 체크박스를 누르면 선택 상태가 바뀌고, 입력창에 글자를 쓰면 값이 변합니다. 이런 경우에는 상태를 다룰 수 있는 위젯이 필요합니다.
StatefulWidget: 바뀌는 값을 품은 위젯
StatefulWidget은 상태를 가질 수 있는 위젯입니다. 정확히 말하면 StatefulWidget 자체가 상태를 저장한다기보다, 연결된 State 객체가 상태를 저장합니다.
가장 유명한 카운터 예제를 살펴보겠습니다.
class CounterPage extends StatefulWidget {
const CounterPage({super.key});
@override
State<CounterPage> createState() => _CounterPageState();
}
class _CounterPageState extends State<CounterPage> {
int count = 0;
void increase() {
setState(() {
count++;
});
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text('카운터'),
),
body: Center(
child: Text(
'$count',
style: const TextStyle(fontSize: 48),
),
),
floatingActionButton: FloatingActionButton(
onPressed: increase,
child: const Icon(Icons.add),
),
);
}
}여기서 count는 상태입니다. 사용자가 버튼을 누르면 count 값이 바뀌고, 그 값에 따라 화면에 보이는 숫자도 바뀌어야 합니다.
StatefulWidget은 두 부분으로 나뉩니다.
StatefulWidget클래스는 위젯의 설정 정보를 담당합니다.State클래스는 바뀔 수 있는 값을 보관합니다.createState()는 이 위젯과 연결될 상태 객체를 만듭니다.
처음에는 왜 굳이 둘로 나누는지 번거롭게 느껴질 수 있습니다. 하지만 Flutter의 위젯은 불변 설명서에 가깝기 때문에, 바뀌는 값은 별도의 State 객체에 보관하는 편이 구조적으로 더 자연스럽습니다.
StatefulWidget을 써야 하는지 판단하는 기준은 간단합니다. 이 위젯 안에서 시간이 지나거나 사용자 행동에 따라 바뀌는 값을 직접 보관해야 하는가? 그렇다면 StatefulWidget을 고려합니다.
예를 들어 다음과 같은 경우에는 StatefulWidget이 자연스럽습니다.
버튼을 누를 때마다 숫자가 증가하는 화면
사용자가 선택한 탭 인덱스를 기억하는 화면
체크박스의 선택 여부를 내부에서 관리하는 위젯
애니메이션 컨트롤러처럼 생명주기 관리가 필요한 객체를 가진 위젯
반대로 부모로부터 받은 데이터를 단순히 보여주기만 한다면 StatelessWidget이 더 적절합니다.
class CounterText extends StatelessWidget {
const CounterText({
super.key,
required this.count,
});
final int count;
@override
Widget build(BuildContext context) {
return Text(
'$count',
style: const TextStyle(fontSize: 48),
);
}
}CounterText는 숫자를 직접 증가시키지 않습니다. 숫자를 받아서 보여주기만 합니다. 따라서 상태가 없습니다.
실전에서는 큰 화면 하나를 전부 StatefulWidget으로 만드는 대신, 바뀌는 부분과 보여주는 부분을 작은 위젯으로 나누는 것이 좋습니다. 예를 들어 CounterPage가 상태를 관리하고, CounterText는 숫자 표시만 담당하게 만들면 코드가 훨씬 읽기 쉬워집니다.
build 메서드는 UI를 설명하는 함수다
Flutter 위젯 클래스에는 거의 항상 build 메서드가 등장합니다.
@override
Widget build(BuildContext context) {
return Text('Hello');
}build는 이름 그대로 UI를 만들어 내는 함수입니다. 하지만 여기서 "만든다"는 표현을 조심해야 합니다. build는 실제 픽셀을 직접 그리지 않습니다. 대신 현재 상태에 맞는 위젯 설명을 반환합니다.
예를 들어 로그인 상태에 따라 다른 화면을 보여주고 싶다면 이렇게 쓸 수 있습니다.
@override
Widget build(BuildContext context) {
if (isLoggedIn) {
return const HomePage();
}
return const LoginPage();
}이 코드는 아주 선언적입니다. "로그인되어 있으면 홈 화면, 아니면 로그인 화면"이라고 현재 상태에 맞는 UI를 설명합니다.
목록 데이터가 비어 있을 때도 마찬가지입니다.
@override
Widget build(BuildContext context) {
if (notes.isEmpty) {
return const Center(
child: Text('아직 작성한 노트가 없습니다.'),
);
}
return ListView.builder(
itemCount: notes.length,
itemBuilder: (context, index) {
return ListTile(
title: Text(notes[index].title),
);
},
);
}상태가 비어 있으면 빈 상태 UI를 반환하고, 데이터가 있으면 리스트 UI를 반환합니다. 이런 식으로 build 메서드 안에서는 현재 상태를 기준으로 "지금 화면이 어떠해야 하는가"를 표현합니다.
중요한 습관이 하나 있습니다. build 메서드는 여러 번 호출될 수 있다고 생각해야 합니다. Flutter는 필요할 때마다 build를 다시 호출합니다. 따라서 build 안에 오래 걸리는 작업이나 부작용이 큰 작업을 넣으면 안 됩니다.
예를 들어 다음과 같은 코드는 피해야 합니다.
@override
Widget build(BuildContext context) {
fetchUserFromServer(); // 좋지 않은 예
return const Text('사용자 정보');
}서버 요청은 시간이 오래 걸리고, build가 여러 번 호출될 때마다 반복 실행될 수 있습니다. 이런 작업은 이후 장에서 배울 initState, 상태관리 도구, 비동기 provider, repository 같은 구조로 분리해야 합니다.
build는 가능한 한 가볍고 예측 가능해야 합니다. 상태를 읽고, 그 상태에 맞는 위젯을 반환하는 역할에 집중하는 것이 좋습니다.
BuildContext는 위젯 트리 속 현재 위치다
build 메서드의 매개변수에는 항상 BuildContext가 등장합니다.
Widget build(BuildContext context) {
return Container();
}초보자에게 BuildContext는 꽤 추상적으로 느껴집니다. 이름도 어렵습니다. 하지만 처음에는 이렇게 이해하면 충분합니다.
BuildContext는 현재 위젯이 위젯 트리 안에서 어디에 있는지 알려주는 위치 정보입니다.
Flutter 앱은 거대한 위젯 트리입니다. 그 안에서 어떤 위젯이 테마 정보를 찾거나, 화면 전환을 요청하거나, 가까운 부모 위젯의 정보를 읽으려면 "내가 트리의 어디에 있는지" 알아야 합니다. 그때 사용하는 것이 context입니다.
예를 들어 현재 테마의 색상을 읽을 수 있습니다.
final colorScheme = Theme.of(context).colorScheme;현재 화면에서 다른 화면으로 이동할 수도 있습니다.
Navigator.of(context).push(
MaterialPageRoute(
builder: (context) => const DetailPage(),
),
);화면 크기 정보를 가져올 수도 있습니다.
final size = MediaQuery.of(context).size;이 코드들이 가능한 이유는 context를 통해 현재 위치에서 위쪽 부모들을 따라가며 필요한 정보를 찾을 수 있기 때문입니다.
여기서 자주 만나는 실수가 있습니다. 아직 특정 위젯 아래에 들어가지 않은 context로 그 위젯을 찾으려는 경우입니다.
예를 들어 Scaffold가 제공하는 기능을 사용하려면, 해당 context가 Scaffold 아래쪽에 있어야 합니다. 트리의 위치가 맞지 않으면 "이 context에서는 Scaffold를 찾을 수 없다"는 식의 에러가 발생할 수 있습니다.
이 문제를 해결할 때 종종 Builder 위젯을 사용합니다.
Scaffold(
body: Builder(
builder: (context) {
return Center(
child: ElevatedButton(
onPressed: () {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('저장되었습니다')),
);
},
child: const Text('스낵바 보이기'),
),
);
},
),
)Builder는 새로운 BuildContext를 만들어 줍니다. 덕분에 Scaffold 아래쪽에 있는 위치 정보로 ScaffoldMessenger 같은 기능을 사용할 수 있습니다.
지금은 BuildContext를 완벽히 이해하지 않아도 괜찮습니다. 다만 이것만 기억하세요. context는 단순한 옵션 꾸러미가 아니라, 위젯 트리 안에서 현재 위젯의 위치를 나타내는 손잡이입니다.
Widget Tree, Element Tree, RenderObject는 무엇이 다를까
Flutter를 공부하다 보면 Widget Tree, Element Tree, RenderObject라는 용어를 만나게 됩니다. 입문 단계에서 이 개념을 깊이 파고들 필요는 없지만, 대략적인 차이를 알고 있으면 Flutter가 화면을 갱신하는 방식을 이해하는 데 큰 도움이 됩니다.
먼저 Widget Tree는 우리가 코드로 작성하는 UI 설명입니다.
Column(
children: const [
Text('제목'),
Text('내용'),
],
)이 코드는 위젯 트리를 만듭니다. 위젯은 가볍고 불변입니다. 상태가 바뀌면 새 위젯 트리가 만들어질 수 있습니다.
하지만 실제 앱이 실행되는 동안 Flutter는 단순히 위젯 객체만 들고 있지 않습니다. 위젯의 위치와 생명주기를 관리하는 중간 관리자도 필요합니다. 그 역할을 하는 것이 Element입니다.
Element Tree는 위젯과 실제 렌더링 객체 사이를 연결하는 구조입니다. 위젯이 "설명서"라면, 엘리먼트는 "현장에서 그 설명서를 관리하는 담당자"에 가깝습니다. 위젯이 새로 만들어져도 Flutter는 가능한 한 기존 엘리먼트를 재사용하면서 무엇이 바뀌었는지 비교합니다.
마지막으로 RenderObject는 실제 배치와 그리기에 가까운 일을 담당합니다. 크기를 계산하고, 위치를 정하고, 화면에 그릴 정보를 처리합니다.
간단히 비유하면 이렇습니다.
Widget은 "무엇을 보여줄지"에 대한 설계도입니다.
Element는 위젯의 위치와 생명주기를 관리하는 연결 관리자입니다.
RenderObject는 크기, 배치, 그리기처럼 실제 화면 표현에 가까운 일을 담당합니다.
입문 단계에서는 다음 정도로 이해하면 충분합니다.
Widget Tree: 개발자가 작성하는 UI 설명
Element Tree: Flutter가 위젯의 위치와 상태 연결을 관리하는 구조
RenderObject Tree: 실제 레이아웃과 페인팅을 담당하는 구조왜 이런 구조가 필요할까요? 위젯은 자주 새로 만들어질 수 있습니다. 만약 위젯이 새로 만들어질 때마다 실제 화면 요소까지 전부 버리고 다시 만든다면 성능이 나빠질 것입니다. Flutter는 위젯을 가벼운 설명으로 유지하고, 엘리먼트와 렌더 객체를 통해 필요한 부분만 효율적으로 업데이트합니다.
그래서 build가 여러 번 호출된다고 해서 겁먹을 필요는 없습니다. build는 새 설명을 만드는 과정이고, Flutter는 내부적으로 그 설명을 바탕으로 바뀐 부분을 똑똑하게 반영합니다.
setState는 화면을 직접 바꾸지 않는다
StatefulWidget을 사용할 때 가장 자주 만나는 메서드가 setState입니다.
setState(() {
count++;
});많은 입문자가 setState를 "화면을 새로 그리는 함수"라고 이해합니다. 완전히 틀린 말은 아니지만, 조금 더 정확히 말하면 setState는 상태가 바뀌었음을 Flutter에게 알리는 함수입니다.
setState 안에서 상태 값을 바꾸면 Flutter는 이 위젯이 다시 빌드되어야 한다고 표시합니다. 그러면 적절한 시점에 build 메서드가 다시 호출되고, 바뀐 상태에 맞는 새 위젯 트리가 만들어집니다.
흐름을 순서대로 보면 다음과 같습니다.
사용자가 버튼을 누른다.
→ onPressed 콜백이 실행된다.
→ setState 안에서 count 값이 바뀐다.
→ Flutter가 이 State를 다시 빌드 대상으로 표시한다.
→ build 메서드가 다시 호출된다.
→ 새 count 값이 반영된 Text 위젯이 반환된다.
→ Flutter가 바뀐 부분을 화면에 반영한다.다음 코드를 다시 봅시다.
void increase() {
setState(() {
count++;
});
}여기서 setState가 직접 Text 위젯을 찾아가 숫자를 바꾸는 것이 아닙니다. count라는 상태를 바꾸고, 그 상태를 사용하는 build가 다시 실행되도록 요청합니다.
그래서 Flutter에서는 다음과 같은 사고방식이 중요합니다.
화면을 직접 고치지 않는다.
상태를 바꾼다.
상태에 맞는 화면을 다시 설명한다.이 흐름이 선언형 UI의 핵심입니다.
그렇다면 setState를 언제 써야 할까요? 해당 위젯 내부에서만 필요한 간단한 상태를 다룰 때 적합합니다. 예를 들어 토글 버튼, 임시 선택값, 간단한 카운터처럼 작은 범위의 상태라면 setState로 충분합니다.
하지만 여러 화면에서 공유해야 하는 로그인 상태, 서버에서 받아온 사용자 정보, 앱 전체 설정, 장바구니 데이터처럼 범위가 넓은 상태라면 setState만으로 관리하기 어렵습니다. 이때는 이후 장에서 배울 Provider, Riverpod, Bloc 같은 상태관리 도구가 필요해집니다.
정리하면 setState는 나쁜 도구가 아닙니다. 다만 작은 방 안의 전등 스위치 같은 도구입니다. 방 하나의 불을 켜고 끄는 데는 훌륭하지만, 건물 전체의 전력을 관리하려면 더 체계적인 시스템이 필요합니다.
StatelessWidget과 StatefulWidget을 나누는 현실적인 기준
초보자가 자주 묻는 질문이 있습니다.
"이건 StatelessWidget으로 만들어야 하나요, StatefulWidget으로 만들어야 하나요?"
처음에는 헷갈리는 것이 당연합니다. 판단 기준을 너무 어렵게 잡을 필요는 없습니다. 다음 질문을 해보면 됩니다.
이 위젯이 자기 안에서 바뀌는 값을 직접 가지고 있어야 하는가?
대답이 "아니오"라면 StatelessWidget으로 시작하세요. 나중에 상태가 필요해지면 그때 StatefulWidget으로 바꿔도 됩니다. Flutter 개발에서는 처음부터 완벽한 구조를 맞히는 것보다, 상태의 위치를 점점 더 적절한 곳으로 옮겨 가는 감각이 중요합니다.
예를 들어 사용자 프로필 카드는 이렇게 만들 수 있습니다.
class UserProfileCard extends StatelessWidget {
const UserProfileCard({
super.key,
required this.name,
required this.email,
});
final String name;
final String email;
@override
Widget build(BuildContext context) {
return Card(
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(name, style: Theme.of(context).textTheme.titleLarge),
const SizedBox(height: 8),
Text(email),
],
),
),
);
}
}이 위젯은 이름과 이메일을 받아서 보여주기만 합니다. 내부에서 값을 수정하지 않으므로 StatelessWidget이 적절합니다.
반면 비밀번호 보이기/숨기기 기능이 있는 입력창은 내부 상태가 필요할 수 있습니다.
class PasswordField extends StatefulWidget {
const PasswordField({super.key});
@override
State<PasswordField> createState() => _PasswordFieldState();
}
class _PasswordFieldState extends State<PasswordField> {
bool obscureText = true;
@override
Widget build(BuildContext context) {
return TextField(
obscureText: obscureText,
decoration: InputDecoration(
labelText: '비밀번호',
suffixIcon: IconButton(
icon: Icon(
obscureText ? Icons.visibility : Icons.visibility_off,
),
onPressed: () {
setState(() {
obscureText = !obscureText;
});
},
),
),
);
}
}여기서는 obscureText 값이 사용자 행동에 따라 바뀝니다. 이 값은 해당 입력창 내부에서만 필요하므로 StatefulWidget과 setState로 충분합니다.
상태의 위치를 정할 때는 "누가 이 값을 알아야 하는가?"를 생각해 보세요. 비밀번호 표시 여부는 비밀번호 입력창만 알면 됩니다. 하지만 로그인한 사용자 정보는 여러 화면에서 필요할 수 있습니다. 그럴 때는 더 위쪽으로 상태를 올리거나, 상태관리 도구를 사용하는 방향으로 설계합니다.
위젯을 작게 나누면 생각이 쉬워진다
Flutter를 처음 배우면 하나의 build 메서드 안에 모든 코드를 넣기 쉽습니다. 작은 예제에서는 문제가 없어 보입니다. 하지만 화면이 조금만 커져도 금방 이런 코드가 됩니다.
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(...),
body: Padding(
padding: ...,
child: Column(
children: [
// 프로필 영역
// 통계 영역
// 버튼 영역
// 리스트 영역
// 하단 안내 영역
],
),
),
);
}처음에는 한눈에 보여서 편해 보이지만, 곧 문제가 생깁니다. 어디서 무엇을 담당하는지 찾기 어렵고, 작은 UI 하나를 수정하려고 해도 거대한 코드 덩어리를 뒤져야 합니다.
이럴 때는 의미 있는 단위로 위젯을 나누는 것이 좋습니다.
class DashboardPage extends StatelessWidget {
const DashboardPage({super.key});
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text('대시보드'),
),
body: const Padding(
padding: EdgeInsets.all(16),
child: Column(
children: [
ProfileSummary(),
SizedBox(height: 16),
TodayStats(),
SizedBox(height: 16),
RecentNoteList(),
],
),
),
);
}
}이렇게 나누면 DashboardPage는 전체 구조만 보여줍니다. 프로필 요약은 ProfileSummary, 오늘의 통계는 TodayStats, 최근 노트 목록은 RecentNoteList가 담당합니다. 코드가 화면의 의미 단위와 가까워집니다.
위젯을 나눌 때 무조건 작은 파일을 많이 만들 필요는 없습니다. 처음에는 같은 파일 안에서 private 위젯 클래스로 나눠도 충분합니다. 중요한 것은 "이 부분이 어떤 역할을 하는가"가 코드에서 드러나게 만드는 것입니다.
다만 메서드로 위젯을 반환하는 방식은 주의해서 사용해야 합니다.
Widget buildProfile() {
return Row(...);
}간단한 경우에는 괜찮지만, 재사용하거나 독립적인 역할이 있는 UI라면 별도 위젯 클래스로 분리하는 편이 좋습니다. 별도 위젯으로 만들면 생성자에서 필요한 값을 명확히 받을 수 있고, Flutter가 위젯 트리를 관리하기도 더 좋아집니다.
const는 작은 습관이지만 큰 차이를 만든다
위젯을 보다 보면 const가 자주 등장합니다.
const Text('Hello')const는 컴파일 시점에 값을 확정할 수 있는 객체를 만들 때 사용합니다. Flutter 위젯은 불변 객체이므로, 값이 바뀌지 않는 위젯에는 const를 붙일 수 있습니다.
const SizedBox(height: 16)const Icon(Icons.home)const Center(
child: Text('비어 있습니다'),
)const를 붙이면 Flutter가 불필요하게 같은 객체를 새로 만들지 않아도 됩니다. 물론 작은 앱에서는 차이를 크게 느끼지 못할 수 있습니다. 하지만 const를 붙이는 습관은 성능뿐 아니라 코드의 의미를 분명하게 하는 데도 도움이 됩니다.
const가 붙은 위젯은 "이 위젯은 외부 상태에 따라 바뀌지 않는다"는 신호이기도 합니다. 반대로 변수 값을 사용하는 위젯에는 const를 붙일 수 없습니다.
Text(name) // name은 실행 중 값이므로 const를 붙일 수 없음처음부터 모든 곳에 완벽하게 const를 붙이려고 스트레스 받을 필요는 없습니다. Dart 분석기가 "여기에는 const를 붙일 수 있다"고 알려주는 경우가 많습니다. 그 힌트를 따라가다 보면 자연스럽게 감이 생깁니다.
상태가 바뀌면 위젯이 다시 태어난다
이제 지금까지 배운 내용을 하나의 작은 예제로 연결해 보겠습니다.
class LikeButton extends StatefulWidget {
const LikeButton({super.key});
@override
State<LikeButton> createState() => _LikeButtonState();
}
class _LikeButtonState extends State<LikeButton> {
bool liked = false;
@override
Widget build(BuildContext context) {
return IconButton(
icon: Icon(
liked ? Icons.favorite : Icons.favorite_border,
color: liked ? Colors.red : Colors.grey,
),
onPressed: () {
setState(() {
liked = !liked;
});
},
);
}
}처음 화면이 만들어질 때 liked는 false입니다. 따라서 build는 빈 하트 아이콘과 회색을 사용한 IconButton을 반환합니다.
사용자가 버튼을 누르면 setState 안에서 liked가 true로 바뀝니다. Flutter는 "이 상태 객체의 UI를 다시 계산해야겠구나"라고 판단합니다. 그러면 build가 다시 실행되고, 이번에는 채워진 하트 아이콘과 빨간색이 적용된 IconButton이 반환됩니다.
여기서 중요한 것은 기존 아이콘을 직접 찾아서 바꾼 코드가 없다는 점입니다. 우리는 오직 상태를 바꾸고, 상태에 따라 어떤 위젯을 반환할지 설명했을 뿐입니다.
이것이 Flutter다운 코드입니다.
liked가 false이면 빈 하트.
liked가 true이면 채워진 하트.
사용자가 누르면 liked를 반대로 바꾼다.이 정도로 생각할 수 있다면 이미 선언형 UI의 핵심에 꽤 가까이 온 것입니다.
위젯 트리를 읽는 연습
Flutter 개발자는 코드를 위에서 아래로만 읽지 않습니다. 부모와 자식 관계를 머릿속으로 그리며 읽습니다. 다음 코드를 보고 구조를 상상해 봅시다.
Scaffold(
appBar: AppBar(
title: const Text('노트'),
),
body: ListView(
padding: const EdgeInsets.all(16),
children: const [
Card(
child: ListTile(
title: Text('Flutter 공부하기'),
subtitle: Text('Widget Tree 복습'),
),
),
Card(
child: ListTile(
title: Text('Firebase 연결'),
subtitle: Text('로그인 기능 만들기'),
),
),
],
),
)구조는 이렇게 볼 수 있습니다.
Scaffold
├─ AppBar
│ └─ Text
└─ ListView
├─ Card
│ └─ ListTile
│ ├─ Text
│ └─ Text
└─ Card
└─ ListTile
├─ Text
└─ Text이렇게 트리로 바꿔 보면 각 위젯의 역할이 더 명확해집니다. Scaffold는 화면의 기본 뼈대를 만들고, AppBar는 상단 바를 담당하고, ListView는 스크롤 가능한 목록을 만들고, Card는 각 항목을 카드처럼 감싸고, ListTile은 제목과 부제목이 있는 목록 항목을 표현합니다.
앞으로 레이아웃, Material 3, 리스트, 라우팅, 상태관리를 배울 때도 이 관점이 계속 필요합니다. 새로운 위젯을 만날 때마다 "이 위젯은 부모인가, 자식인가?", "어떤 값을 받아서 어떤 UI를 설명하는가?", "이 위젯 안에 상태가 필요한가?"라고 질문해 보세요.
Flutter식 사고방식으로 전환하기
Flutter를 배우는 초반에는 문법보다 사고방식 전환이 더 중요할 때가 많습니다. 특히 기존에 HTML, CSS, JavaScript, Android XML, iOS UIKit, 데스크톱 UI 프레임워크를 경험한 사람일수록 처음에는 어색할 수 있습니다.
Flutter에서는 화면을 직접 수정하기보다 상태와 위젯의 관계를 설계합니다. 버튼 클릭 이벤트가 발생했을 때 "이 텍스트를 찾아서 바꿔야지"가 아니라 "이 이벤트로 어떤 상태가 바뀌고, 그 상태라면 화면이 어떻게 표현되어야 하지?"라고 생각해야 합니다.
이 차이가 작아 보여도 앱이 커질수록 매우 중요해집니다. 로그인 상태, API 로딩 상태, 에러 상태, 오프라인 캐시 상태, 검색어, 필터, 선택된 탭, 알림 권한처럼 앱에는 수많은 상태가 존재합니다. 이 상태들이 UI와 연결되는 방식을 잘 설계하면 앱은 예측 가능해집니다. 반대로 상태가 여기저기 흩어지고, 화면을 직접 고치려는 코드가 많아지면 작은 수정에도 버그가 생깁니다.
Flutter의 기본 흐름은 다음 한 문장으로 정리할 수 있습니다.
상태가 바뀌면, Flutter는 그 상태에 맞는 위젯 트리를 다시 만든다.
이 문장을 이해하면 앞으로 배울 거의 모든 주제가 하나로 연결됩니다. 레이아웃은 위젯 트리를 어떻게 배치할지의 문제입니다. Material 3는 위젯에 일관된 디자인 언어를 입히는 방법입니다. 상태관리는 상태가 많아졌을 때 그것을 어디에 두고 어떻게 전달할지의 문제입니다. 라우팅은 현재 앱 상태에 따라 어떤 화면 트리를 보여줄지의 문제입니다.
그러니 이 장의 핵심은 단순히 StatelessWidget과 StatefulWidget의 차이를 외우는 것이 아닙니다. Flutter 앱을 "상태에 따라 다시 설명되는 위젯 트리"로 바라보는 눈을 갖는 것입니다.
마무리
이번 장에서는 Flutter의 가장 중요한 개념인 위젯을 살펴보았습니다. 위젯은 화면의 현재 모습을 설명하는 불변 객체이며, 작은 위젯들이 부모와 자식 관계로 조합되어 위젯 트리를 이룹니다. StatelessWidget은 내부에서 바뀌는 상태가 필요 없는 위젯이고, StatefulWidget은 사용자 행동이나 시간의 흐름에 따라 바뀌는 값을 다룰 수 있는 위젯입니다.
또한 build 메서드는 현재 상태에 맞는 UI 설명을 반환하는 함수이며, BuildContext는 위젯 트리 안에서 현재 위치를 나타내는 중요한 단서입니다. Flutter 내부에서는 개발자가 작성한 Widget Tree를 바탕으로 Element Tree와 RenderObject가 함께 동작하며, 실제 화면 갱신을 효율적으로 처리합니다.
무엇보다 setState는 화면을 직접 수정하는 함수가 아닙니다. 상태가 바뀌었음을 알리고, 그 결과로 build가 다시 실행되게 만드는 장치입니다.
다음 장에서는 이 위젯들을 실제 화면 위에 어떻게 배치하는지 배웁니다. Container, Row, Column, Stack, Expanded, Padding, ListView 같은 레이아웃 위젯을 익히면 머릿속의 위젯 트리가 실제 앱 화면으로 살아나기 시작할 것입니다.
이 노트는 보도·비평·교육·연구 목적으로 작성되었습니다. 저작권 문의가 있으시면 에서 알려주세요.
