크로스플랫폼 To-Do 앱 만들기 Part 2: Flutter 기초와 UI 구현
이 글은 Claude Opus 4.6 을 이용해 초안이 작성되었으며, 이후 퇴고를 거쳤습니다.
들어가며#
Part 1에서 Flutter SDK 설치, 에디터 설정, 에뮬레이터 구성 등 개발 환경을 갖추었습니다. 이제 본격적으로 코드를 작성할 차례입니다.
이번 Part 2에서는 Dart 언어의 핵심 문법을 빠르게 훑어본 뒤, Flutter의 위젯 시스템을 이해하고, 실제로 동작하는 To-Do 리스트 앱의 UI를 구현합니다. 이 글을 따라 하면 할 일을 추가하고, 완료 표시하고, 삭제하는 기본 기능이 모두 갖춰진 앱을 만들 수 있습니다.
Dart 언어 핵심 문법#
Flutter는 Dart 언어를 사용합니다. 다른 프로그래밍 언어 경험이 있다면 금방 익숙해질 수 있습니다.
변수와 타입#
Dart는 정적 타입 언어이지만, var 키워드로 타입 추론도 지원합니다.
// 타입 명시
String name = '홍길동';
int age = 30;
double height = 175.5;
bool isStudent = false;
// 타입 추론
var city = '서울'; // String으로 추론
Null Safety#
Dart는 null safety를 기본으로 지원합니다. 변수가 null이 될 수 있으려면 ? 를 붙여야 합니다.
String name = '홍길동'; // null 불가
String? nickname; // null 가능
// null 체크 연산자
print(nickname?.length); // null이면 null 반환
print(nickname ?? '없음'); // null이면 '없음' 반환
클래스와 함수#
class Person {
final String name;
final int age;
// 생성자 축약 문법
Person({required this.name, required this.age});
String greet() {
return '안녕하세요, $name입니다. $age살입니다.';
}
}
// 화살표 함수
int add(int a, int b) => a + b;
컬렉션#
// List
List<String> fruits = ['사과', '바나나', '포도'];
fruits.add('딸기');
// Map
Map<String, int> scores = {
'국어': 90,
'수학': 85,
'영어': 92,
};
scores['과학'] = 88;
async/await#
네트워크 요청이나 파일 읽기 같은 비동기 작업에 async/await 를 사용합니다. Part 3에서 데이터를 저장할 때 본격적으로 활용하겠지만, 기본 문법만 미리 살펴봅니다.
Future<String> fetchData() async {
// 2초 대기 (네트워크 요청 시뮬레이션)
await Future.delayed(Duration(seconds: 2));
return '데이터 로드 완료';
}
Flutter 위젯 시스템 이해#
Flutter에서 화면에 보이는 모든 것은 위젯(Widget) 입니다. 텍스트, 버튼, 레이아웃, 심지어 앱 자체도 위젯입니다.
Widget 트리#
위젯은 트리 구조로 구성됩니다. 상위 위젯이 하위 위젯을 포함하며, 이 트리가 곧 화면의 구조가 됩니다.
MaterialApp
└── Scaffold
├── AppBar
│ └── Text('To-Do')
└── Body
└── ListView
├── ListTile
└── ListTile
StatelessWidget vs StatefulWidget#
- StatelessWidget: 상태가 변하지 않는 위젯입니다. 한 번 그려지면 내용이 바뀌지 않습니다.
- StatefulWidget: 상태가 변할 수 있는 위젯입니다. 사용자 입력이나 데이터 변경에 따라 화면이 다시 그려집니다.
To-Do 앱은 할 일을 추가/삭제/완료 처리해야 하므로 StatefulWidget 을 사용합니다.
BuildContext와 setState#
- BuildContext: 위젯 트리에서 현재 위젯의 위치 정보를 담고 있습니다. 상위 위젯의 데이터에 접근하거나 테마 정보를 가져올 때 사용합니다.
- setState(): StatefulWidget의 상태를 변경할 때 호출합니다. 이 함수를 호출하면 Flutter가 위젯을 다시 빌드하여 화면을 갱신합니다.
setState(() {
todos.add(newTodo); // 상태 변경
});
// → 화면이 자동으로 다시 그려집니다
프로젝트 구조 설계#
Part 1에서 생성한 프로젝트의 lib/ 디렉토리를 아래와 같이 구성합니다.
lib/
├── main.dart // 앱 진입점
├── models/
│ └── todo.dart // To-Do 모델 클래스
├── screens/
│ └── home_screen.dart // 메인 화면
└── widgets/
└── todo_item.dart // 할 일 항목 위젯
터미널에서 디렉토리와 파일을 생성합니다.
cd my_todo_app
mkdir -p lib/models lib/screens lib/widgets
touch lib/models/todo.dart
touch lib/screens/home_screen.dart
touch lib/widgets/todo_item.dart
To-Do 모델 클래스 작성#
할 일 하나를 나타내는 데이터 모델을 정의합니다. lib/models/todo.dart 파일을 열고 아래 코드를 작성합니다.
class Todo {
final String id;
String title;
bool isCompleted;
Todo({
required this.id,
required this.title,
this.isCompleted = false,
});
}
id: 각 할 일을 구별하기 위한 고유 식별자입니다.title: 할 일의 내용입니다.isCompleted: 완료 여부입니다. 기본값은false입니다.
할 일 항목 위젯 작성#
개별 할 일을 표시하는 위젯을 분리합니다. lib/widgets/todo_item.dart 파일에 다음 코드를 작성합니다.
import 'package:flutter/material.dart';
import '../models/todo.dart';
class TodoItem extends StatelessWidget {
final Todo todo;
final ValueChanged<bool?> onToggle;
final VoidCallback onDelete;
const TodoItem({
super.key,
required this.todo,
required this.onToggle,
required this.onDelete,
});
@override
Widget build(BuildContext context) {
return CheckboxListTile(
value: todo.isCompleted,
onChanged: onToggle,
title: Text(
todo.title,
style: TextStyle(
decoration: todo.isCompleted
? TextDecoration.lineThrough
: TextDecoration.none,
color: todo.isCompleted ? Colors.grey : Colors.black,
),
),
secondary: IconButton(
icon: const Icon(Icons.delete, color: Colors.red),
onPressed: onDelete,
),
controlAffinity: ListTileControlAffinity.leading,
);
}
}
이 위젯은 세 가지 콜백을 외부에서 받습니다.
onToggle: 체크박스를 눌렀을 때 호출됩니다.onDelete: 삭제 버튼을 눌렀을 때 호출됩니다.
위젯 자체는 상태를 갖지 않으므로 StatelessWidget 으로 구현합니다.
메인 화면 UI 구현#
lib/screens/home_screen.dart 파일에 메인 화면을 구현합니다. 이 화면이 앱의 핵심입니다.
import 'package:flutter/material.dart';
import '../models/todo.dart';
import '../widgets/todo_item.dart';
class HomeScreen extends StatefulWidget {
const HomeScreen({super.key});
@override
State<HomeScreen> createState() => _HomeScreenState();
}
class _HomeScreenState extends State<HomeScreen> {
final List<Todo> _todos = [];
final TextEditingController _textController = TextEditingController();
void _addTodo() {
final text = _textController.text.trim();
if (text.isEmpty) return;
setState(() {
_todos.add(Todo(
id: DateTime.now().millisecondsSinceEpoch.toString(),
title: text,
));
});
_textController.clear();
}
void _toggleTodo(int index, bool? value) {
setState(() {
_todos[index].isCompleted = value ?? false;
});
}
void _deleteTodo(int index) {
setState(() {
_todos.removeAt(index);
});
}
@override
void dispose() {
_textController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text('나의 To-Do'),
centerTitle: true,
),
body: Column(
children: [
// 할 일 입력 영역
Padding(
padding: const EdgeInsets.all(16.0),
child: Row(
children: [
Expanded(
child: TextField(
controller: _textController,
decoration: const InputDecoration(
hintText: '할 일을 입력하세요',
border: OutlineInputBorder(),
),
onSubmitted: (_) => _addTodo(),
),
),
const SizedBox(width: 12),
ElevatedButton(
onPressed: _addTodo,
child: const Text('추가'),
),
],
),
),
// 구분선
const Divider(height: 1),
// 할 일 목록
Expanded(
child: _todos.isEmpty
? const Center(
child: Text(
'할 일이 없습니다.\n새로운 할 일을 추가해 보세요!',
textAlign: TextAlign.center,
style: TextStyle(
fontSize: 16,
color: Colors.grey,
),
),
)
: ListView.builder(
itemCount: _todos.length,
itemBuilder: (context, index) {
return TodoItem(
todo: _todos[index],
onToggle: (value) => _toggleTodo(index, value),
onDelete: () => _deleteTodo(index),
);
},
),
),
],
),
// 플로팅 버튼 (선택적 추가 방법)
floatingActionButton: FloatingActionButton(
onPressed: () {
// TextField에 포커스를 줍니다
FocusScope.of(context).requestFocus(FocusNode());
Future.delayed(const Duration(milliseconds: 100), () {
_textController.clear();
});
},
child: const Icon(Icons.add),
),
);
}
}
주요 구성 요소를 하나씩 살펴보겠습니다.
AppBar#
appBar: AppBar(
title: const Text('나의 To-Do'),
centerTitle: true,
),
화면 상단에 앱 제목을 표시합니다. centerTitle: true 로 중앙 정렬합니다.
입력 영역#
TextField와 ElevatedButton 을 Row 위젯으로 나란히 배치합니다. onSubmitted 콜백을 설정하면 키보드의 Enter 키로도 할 일을 추가할 수 있습니다.
ListView.builder#
ListView.builder 는 목록의 항목 수만큼 위젯을 동적으로 생성합니다. 항목이 수백 개라도 화면에 보이는 것만 렌더링하므로 성능이 우수합니다.
빈 상태 처리#
할 일이 없을 때 빈 화면 대신 안내 메시지를 표시합니다. 삼항 연산자(_todos.isEmpty ? ... : ...)로 간단하게 분기합니다.
앱 진입점 수정#
lib/main.dart 파일을 열고 기존 코드를 모두 지운 뒤 아래 코드로 교체합니다.
import 'package:flutter/material.dart';
import 'screens/home_screen.dart';
void main() {
runApp(const MyTodoApp());
}
class MyTodoApp extends StatelessWidget {
const MyTodoApp({super.key});
@override
Widget build(BuildContext context) {
return MaterialApp(
title: '나의 To-Do',
theme: ThemeData(
colorScheme: ColorScheme.fromSeed(seedColor: Colors.blue),
useMaterial3: true,
),
home: const HomeScreen(),
debugShowCheckedModeBanner: false,
);
}
}
MaterialApp: Material Design 스타일의 앱을 구성합니다.ThemeData:colorScheme을 사용하여 앱 전체의 색상 테마를 설정합니다.useMaterial3: true로 최신 Material 3 디자인을 적용합니다.debugShowCheckedModeBanner: false: 우측 상단의 “DEBUG” 배너를 숨깁니다.
실행 확인#
터미널에서 앱을 실행합니다.
cd my_todo_app
flutter run
에뮬레이터 또는 연결된 기기에서 다음과 같은 동작을 확인할 수 있습니다.
- 할 일 추가: 텍스트 필드에 내용을 입력하고 “추가” 버튼을 누르거나 Enter를 누릅니다.
- 완료 토글: 항목 왼쪽의 체크박스를 누르면 취소선이 그어지며 완료 처리됩니다.
- 삭제: 항목 오른쪽의 빨간 휴지통 아이콘을 누르면 해당 할 일이 삭제됩니다.
전체 파일 구조 정리#
최종적으로 lib/ 디렉토리는 아래와 같습니다.
lib/
├── main.dart # 앱 진입점, 테마 설정
├── models/
│ └── todo.dart # Todo 모델 (id, title, isCompleted)
├── screens/
│ └── home_screen.dart # 메인 화면 (StatefulWidget)
└── widgets/
└── todo_item.dart # 개별 할 일 위젯 (StatelessWidget)
각 파일의 역할을 정리하면 다음과 같습니다.
| 파일 | 역할 | 위젯 종류 |
|---|---|---|
main.dart |
앱 시작, 테마 설정 | StatelessWidget |
todo.dart |
데이터 모델 정의 | - |
home_screen.dart |
화면 레이아웃, 상태 관리 | StatefulWidget |
todo_item.dart |
개별 항목 표시 | StatelessWidget |
이렇게 파일을 분리하면 코드의 가독성이 높아지고, 나중에 기능을 확장하기에도 유리합니다.
다음 단계#
Part 3에서는 앱을 종료해도 할 일 목록이 유지되도록 로컬 저장소를 연동하고, 상태 관리 패턴을 적용하여 코드 구조를 개선합니다.