Flutter 튜토리얼 30편: CRUD 작업 구현
요약
핵심 요지
- 문제 정의: 대부분의 앱은 데이터를 생성, 조회, 수정, 삭제하는 CRUD 기능이 필요하다.
- 핵심 주장: HTTP 메서드(GET, POST, PUT, DELETE)를 적절히 조합하면 완전한 CRUD 앱을 만들 수 있다.
- 주요 근거: REST API 규칙을 따르면 서버와 클라이언트가 일관된 방식으로 통신한다.
- 실무 기준: 로딩 상태, 낙관적 업데이트, 에러 복구까지 실제 앱 수준으로 구현한다.
- 한계: 복잡한 상태 관리가 필요하면 Riverpod이나 Bloc 같은 패키지를 고려해야 한다.
문서가 설명하는 범위
- CRUD와 HTTP 메서드 매핑
- 목록 조회와 새로고침
- 아이템 생성과 화면 갱신
- 아이템 수정과 낙관적 업데이트
- 아이템 삭제와 확인 다이얼로그
- 에러 처리와 재시도 로직
읽는 시간: 18분 | 난이도: 중급
참고 자료
- Fetch data from the internet - 데이터 가져오기
- Send data to the internet - 데이터 보내기
- Update data over the internet - 데이터 수정
- Delete data on the internet - 데이터 삭제
문제 상황
29편에서 HTTP 요청의 기본을 배웠습니다. 이제 이걸 조합해서 실제 앱을 만들어야 합니다.
할 일 앱의 요구사항
요구사항 1: 할 일 목록을 서버에서 가져온다 (Read)요구사항 2: 새 할 일을 추가한다 (Create)요구사항 3: 할 일 내용을 수정한다 (Update)요구사항 4: 완료한 할 일을 삭제한다 (Delete)요구사항 5: 네트워크 오류 시 재시도한다문제는 다음과 같습니다.
- 각 작업마다 다른 HTTP 메서드를 사용해야 한다.
- 서버 응답 후 화면을 갱신해야 한다.
- 사용자 경험을 위해 로딩 상태를 표시해야 한다.
- 실패 시 적절한 피드백을 줘야 한다.
이 튜토리얼에서 이 모든 걸 구현합니다.
해결 방법
CRUD 작업은 HTTP 메서드와 1<1로>1로> 매핑됩니다. 이 규칙을 따르면 서버와 일관되게 통신할 수 있습니다.
챕터 1: 데이터 모델과 API 서비스 설계
Why
NOTECRUD 앱을 만들기 전에 두 가지를 먼저 준비해야 합니다.
- 데이터 모델: 서버와 주고받을 데이터 구조
- API 서비스: HTTP 요청을 담당하는 클래스
이렇게 분리하면 UI 코드가 깔끔해지고, 테스트하기도 쉬워집니다.
What
NOTE할 일(Todo) 모델을 정의합니다.
class Todo {final int? id; // 생성 전에는 nullfinal String title;final bool completed;const Todo({this.id,required this.title,this.completed = false,});// JSON → Todofactory Todo.fromJson(Map<String, dynamic> json) {return Todo(id: json['id'] as int?,title: json['title'] as String,completed: json['completed'] as bool? ?? false,);}// Todo → JSONMap<String, dynamic> toJson() {return {if (id != null) 'id': id,'title': title,'completed': completed,};}// 불변 객체 복사 (수정용)Todo copyWith({int? id,String? title,bool? completed,}) {return Todo(id: id ?? this.id,title: title ?? this.title,completed: completed ?? this.completed,);}}
copyWith메서드는 불변 객체의 일부만 바꿀 때 유용합니다.
How
TIPAPI 서비스 클래스를 만듭니다.
import 'dart:convert';import 'package:http/http.dart' as http;class TodoApi {static const _baseUrl = 'https://jsonplaceholder.typicode.com';// 목록 조회 (Read)Future<List<Todo>> fetchTodos() async {final response = await http.get(Uri.parse('$_baseUrl/todos?_limit=20'),);if (response.statusCode == 200) {final List<dynamic> jsonList = jsonDecode(response.body);return jsonList.map((json) => Todo.fromJson(json)).toList();}throw Exception('할 일 목록을 불러오지 못했습니다');}// 생성 (Create)Future<Todo> createTodo(Todo todo) async {final response = await http.post(Uri.parse('$_baseUrl/todos'),headers: {'Content-Type': 'application/json; charset=UTF-8'},body: jsonEncode(todo.toJson()),);if (response.statusCode == 201) {return Todo.fromJson(jsonDecode(response.body));}throw Exception('할 일을 추가하지 못했습니다');}// 수정 (Update)Future<Todo> updateTodo(Todo todo) async {final response = await http.put(Uri.parse('$_baseUrl/todos/${todo.id}'),headers: {'Content-Type': 'application/json; charset=UTF-8'},body: jsonEncode(todo.toJson()),);if (response.statusCode == 200) {return Todo.fromJson(jsonDecode(response.body));}throw Exception('할 일을 수정하지 못했습니다');}// 삭제 (Delete)Future<void> deleteTodo(int id) async {final response = await http.delete(Uri.parse('$_baseUrl/todos/$id'),);if (response.statusCode != 200) {throw Exception('할 일을 삭제하지 못했습니다');}}}모든 HTTP 로직이 한 곳에 모여 있어서 관리하기 쉽습니다.
Watch out
WARNINGbaseUrl 관리: 개발/운영 환경에 따라 URL이 다릅니다. 환경 변수나 설정 파일로 분리하세요.
// 나쁨: 하드코딩static const _baseUrl = 'https://api.myapp.com';// 좋음: 환경별 분리static String get _baseUrl {if (kDebugMode) {return 'http://localhost:3000';}return 'https://api.myapp.com';}싱글톤 패턴: API 클래스를 여러 번 생성하면 비효율적입니다. 의존성 주입이나 싱글톤을 고려하세요.
챕터 2: 목록 조회와 새로고침 (Read)
Why
NOTECRUD에서 가장 먼저 구현할 건 **조회(Read)**입니다. 데이터가 있어야 수정하거나 삭제할 수 있으니까요.
목록 화면에서는 두 가지를 고려해야 합니다.
- 첫 로딩 시 데이터 가져오기
- 당겨서 새로고침 (Pull to Refresh)
What
NOTE
RefreshIndicator와 함께 목록을 구성합니다.class TodoListScreen extends StatefulWidget {@override_TodoListScreenState createState() => _TodoListScreenState();}class _TodoListScreenState extends State<TodoListScreen> {final TodoApi _api = TodoApi();List<Todo> _todos = [];bool _isLoading = true;String? _error;@overridevoid initState() {super.initState();_loadTodos();}Future<void> _loadTodos() async {setState(() {_isLoading = true;_error = null;});try {final todos = await _api.fetchTodos();setState(() {_todos = todos;_isLoading = false;});} catch (e) {setState(() {_error = e.toString();_isLoading = false;});}}@overrideWidget build(BuildContext context) {return Scaffold(appBar: AppBar(title: Text('할 일 목록')),body: _buildBody(),);}Widget _buildBody() {if (_isLoading) {return Center(child: CircularProgressIndicator());}if (_error != null) {return Center(child: Column(mainAxisAlignment: MainAxisAlignment.center,children: [Text(_error!),ElevatedButton(onPressed: _loadTodos,child: Text('다시 시도'),),],),);}return RefreshIndicator(onRefresh: _loadTodos,child: ListView.builder(itemCount: _todos.length,itemBuilder: (context, index) {return TodoTile(todo: _todos[index]);},),);}}
RefreshIndicator는 당겨서 새로고침을 자동으로 처리합니다.
How
TIP할 일 타일 위젯을 분리합니다.
class TodoTile extends StatelessWidget {final Todo todo;final VoidCallback? onToggle;final VoidCallback? onDelete;final VoidCallback? onEdit;const TodoTile({required this.todo,this.onToggle,this.onDelete,this.onEdit,});@overrideWidget build(BuildContext context) {return ListTile(leading: Checkbox(value: todo.completed,onChanged: (_) => onToggle?.call(),),title: Text(todo.title,style: TextStyle(decoration: todo.completed? TextDecoration.lineThrough: null,),),trailing: Row(mainAxisSize: MainAxisSize.min,children: [IconButton(icon: Icon(Icons.edit),onPressed: onEdit,),IconButton(icon: Icon(Icons.delete),onPressed: onDelete,),],),);}}콜백을 props로 받으면 부모 위젯에서 동작을 결정할 수 있습니다.
Watch out
WARNING빈 목록 처리: 데이터가 없을 때도 UI를 제공하세요.
if (_todos.isEmpty) {return Center(child: Column(mainAxisAlignment: MainAxisAlignment.center,children: [Icon(Icons.inbox, size: 64, color: Colors.grey),SizedBox(height: 16),Text('할 일이 없습니다'),TextButton(onPressed: _addTodo,child: Text('첫 번째 할 일 추가하기'),),],),);}스크롤 성능: 목록이 길면
ListView.builder를 사용하세요.ListView에 children을 직접 넣으면 모든 아이템이 한 번에 빌드됩니다.
챕터 3: 아이템 생성 (Create)
Why
NOTE새 할 일을 추가하려면 입력 폼과 POST 요청이 필요합니다.
생성 후에는 두 가지 방법으로 화면을 갱신할 수 있습니다.
- 서버 재조회: 목록 전체를 다시 가져온다 (확실하지만 느림)
- 로컬 추가: 응답받은 아이템만 목록에 추가한다 (빠르지만 동기화 주의)
대부분의 경우 로컬 추가가 좋은 사용자 경험을 제공합니다.
What
NOTE다이얼로그로 새 할 일을 입력받습니다.
Future<void> _addTodo() async {final controller = TextEditingController();final title = await showDialog<String>(context: context,builder: (context) => AlertDialog(title: Text('새 할 일'),content: TextField(controller: controller,decoration: InputDecoration(hintText: '할 일을 입력하세요',),autofocus: true,),actions: [TextButton(onPressed: () => Navigator.pop(context),child: Text('취소'),),TextButton(onPressed: () => Navigator.pop(context, controller.text),child: Text('추가'),),],),);if (title == null || title.isEmpty) return;try {final newTodo = await _api.createTodo(Todo(title: title),);setState(() {_todos.insert(0, newTodo); // 맨 앞에 추가});} catch (e) {ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('추가 실패: $e')),);}}
Navigator.pop(context, value)로 다이얼로그에서 값을 반환합니다.
How
TIPFloatingActionButton으로 추가 버튼을 만듭니다.
@overrideWidget build(BuildContext context) {return Scaffold(appBar: AppBar(title: Text('할 일 목록')),body: _buildBody(),floatingActionButton: FloatingActionButton(onPressed: _addTodo,child: Icon(Icons.add),),);}별도의 입력 화면이 필요하면 새 페이지로 이동합니다.
Future<void> _addTodo() async {final newTodo = await Navigator.push<Todo>(context,MaterialPageRoute(builder: (context) => AddTodoScreen(),),);if (newTodo != null) {setState(() {_todos.insert(0, newTodo);});}}
Watch out
WARNING입력 검증: 빈 문자열을 서버에 보내지 마세요.
if (title == null || title.trim().isEmpty) return;로딩 상태: 요청 중에 버튼을 비활성화하세요.
bool _isCreating = false;Future<void> _addTodo() async {if (_isCreating) return;setState(() => _isCreating = true);try {// ... 생성 로직} finally {setState(() => _isCreating = false);}}중복 방지: 네트워크가 느리면 사용자가 버튼을 여러 번 누를 수 있습니다.
챕터 4: 아이템 수정 (Update)
Why
NOTE수정은 두 가지 시나리오가 있습니다.
- 간단한 토글: 완료 상태 변경 (체크박스 클릭)
- 전체 수정: 내용 변경 (편집 화면)
특히 토글 같은 빈번한 작업에는 낙관적 업데이트(Optimistic Update)가 좋습니다. 서버 응답을 기다리지 않고 먼저 UI를 바꾸는 방식입니다.
What
NOTE낙관적 업데이트로 체크박스를 구현합니다.
Future<void> _toggleTodo(Todo todo) async {// 1. 먼저 UI 업데이트 (낙관적)final index = _todos.indexWhere((t) => t.id == todo.id);final updatedTodo = todo.copyWith(completed: !todo.completed);setState(() {_todos[index] = updatedTodo;});try {// 2. 서버에 요청await _api.updateTodo(updatedTodo);} catch (e) {// 3. 실패 시 롤백setState(() {_todos[index] = todo; // 원래대로 복구});ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('수정 실패: $e')),);}}낙관적 업데이트 흐름:
- 사용자 액션 즉시 UI 변경
- 백그라운드에서 서버 요청
- 성공하면 그대로, 실패하면 롤백
How
TIP내용 수정은 다이얼로그나 새 화면에서 처리합니다.
Future<void> _editTodo(Todo todo) async {final controller = TextEditingController(text: todo.title);final newTitle = await showDialog<String>(context: context,builder: (context) => AlertDialog(title: Text('할 일 수정'),content: TextField(controller: controller,autofocus: true,),actions: [TextButton(onPressed: () => Navigator.pop(context),child: Text('취소'),),TextButton(onPressed: () => Navigator.pop(context, controller.text),child: Text('저장'),),],),);if (newTitle == null || newTitle.isEmpty) return;if (newTitle == todo.title) return; // 변경 없으면 무시final index = _todos.indexWhere((t) => t.id == todo.id);final updatedTodo = todo.copyWith(title: newTitle);setState(() {_todos[index] = updatedTodo;});try {await _api.updateTodo(updatedTodo);} catch (e) {setState(() {_todos[index] = todo;});ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('수정 실패')),);}}TodoTile에서 콜백을 연결합니다.
TodoTile(todo: _todos[index],onToggle: () => _toggleTodo(_todos[index]),onEdit: () => _editTodo(_todos[index]),onDelete: () => _deleteTodo(_todos[index]),)
Watch out
WARNING동시성 문제: 같은 아이템을 빠르게 여러 번 수정하면 문제가 생길 수 있습니다.
// 간단한 해결책: 수정 중인 아이템 추적Set<int> _updatingIds = {};Future<void> _toggleTodo(Todo todo) async {if (_updatingIds.contains(todo.id)) return; // 이미 수정 중_updatingIds.add(todo.id!);// ... 수정 로직_updatingIds.remove(todo.id);}PUT vs PATCH: PUT은 전체 교체, PATCH는 부분 수정입니다. 서버 API에 따라 적절한 메서드를 사용하세요.
챕터 5: 아이템 삭제 (Delete)
Why
NOTE삭제는 되돌리기 어려운 작업입니다. 사용자에게 확인을 받는 게 좋습니다.
삭제 방식도 두 가지가 있습니다.
- 확인 후 삭제: 다이얼로그로 확인 → 삭제
- 즉시 삭제 + 실행 취소: 바로 삭제 → SnackBar로 복구 기회 제공
두 번째 방식이 더 빠른 UX를 제공합니다.
What
NOTE확인 다이얼로그 방식입니다.
Future<void> _deleteTodo(Todo todo) async {final confirmed = await showDialog<bool>(context: context,builder: (context) => AlertDialog(title: Text('삭제 확인'),content: Text('"${todo.title}"을(를) 삭제하시겠습니까?'),actions: [TextButton(onPressed: () => Navigator.pop(context, false),child: Text('취소'),),TextButton(onPressed: () => Navigator.pop(context, true),style: TextButton.styleFrom(foregroundColor: Colors.red),child: Text('삭제'),),],),);if (confirmed != true) return;final index = _todos.indexWhere((t) => t.id == todo.id);setState(() {_todos.removeAt(index);});try {await _api.deleteTodo(todo.id!);} catch (e) {setState(() {_todos.insert(index, todo); // 복구});ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('삭제 실패')),);}}
How
TIP실행 취소(Undo) 방식은 더 나은 UX를 제공합니다.
Future<void> _deleteTodoWithUndo(Todo todo) async {final index = _todos.indexWhere((t) => t.id == todo.id);// 1. 즉시 삭제 (UI)setState(() {_todos.removeAt(index);});// 2. 실행 취소 SnackBar 표시final snackBar = ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('"${todo.title}" 삭제됨'),action: SnackBarAction(label: '실행 취소',onPressed: () {// 복구setState(() {_todos.insert(index, todo);});},),duration: Duration(seconds: 5),),);// 3. SnackBar가 닫힌 후 서버에 삭제 요청final reason = await snackBar.closed;if (reason != SnackBarClosedReason.action) {// 실행 취소 안 했으면 서버 삭제try {await _api.deleteTodo(todo.id!);} catch (e) {// 서버 삭제 실패 시 복구setState(() {_todos.insert(index, todo);});ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('삭제 실패')),);}}}
snackBar.closed는 SnackBar가 어떻게 닫혔는지 알려줍니다.
Watch out
WARNINGDismissible 위젯: 스와이프로 삭제할 수도 있습니다.
Dismissible(key: Key(todo.id.toString()),direction: DismissDirection.endToStart,background: Container(color: Colors.red,alignment: Alignment.centerRight,padding: EdgeInsets.only(right: 16),child: Icon(Icons.delete, color: Colors.white),),confirmDismiss: (direction) async {return await showDialog<bool>(...); // 확인 다이얼로그},onDismissed: (direction) {_deleteTodo(todo);},child: TodoTile(todo: todo),)연속 삭제 주의: 여러 아이템을 빠르게 삭제하면 인덱스가 꼬일 수 있습니다. id로 찾는 방식이 더 안전합니다.
챕터 6: 전체 코드 통합
Why
NOTE지금까지 배운 내용을 하나의 완성된 앱으로 통합합니다. 실제 앱 수준의 코드를 보면 전체 구조를 이해하기 쉽습니다.
What
NOTE완성된 할 일 앱의 주요 부분입니다.
class _TodoListScreenState extends State<TodoListScreen> {final TodoApi _api = TodoApi();List<Todo> _todos = [];bool _isLoading = true;String? _error;Set<int> _updatingIds = {};@overridevoid initState() {super.initState();_loadTodos();}// ReadFuture<void> _loadTodos() async {setState(() {_isLoading = true;_error = null;});try {final todos = await _api.fetchTodos();setState(() {_todos = todos;_isLoading = false;});} catch (e) {setState(() {_error = e.toString();_isLoading = false;});}}// CreateFuture<void> _addTodo() async {// ... (챕터 3 참조)}// UpdateFuture<void> _toggleTodo(Todo todo) async {// ... (챕터 4 참조)}Future<void> _editTodo(Todo todo) async {// ... (챕터 4 참조)}// DeleteFuture<void> _deleteTodo(Todo todo) async {// ... (챕터 5 참조)}@overrideWidget build(BuildContext context) {return Scaffold(appBar: AppBar(title: Text('할 일 목록'),actions: [IconButton(icon: Icon(Icons.refresh),onPressed: _loadTodos,),],),body: _buildBody(),floatingActionButton: FloatingActionButton(onPressed: _addTodo,child: Icon(Icons.add),),);}Widget _buildBody() {if (_isLoading) {return Center(child: CircularProgressIndicator());}if (_error != null) {return _buildErrorState();}if (_todos.isEmpty) {return _buildEmptyState();}return RefreshIndicator(onRefresh: _loadTodos,child: ListView.builder(itemCount: _todos.length,itemBuilder: (context, index) {final todo = _todos[index];return TodoTile(todo: todo,onToggle: () => _toggleTodo(todo),onEdit: () => _editTodo(todo),onDelete: () => _deleteTodo(todo),);},),);}Widget _buildEmptyState() {return Center(child: Column(mainAxisAlignment: MainAxisAlignment.center,children: [Icon(Icons.check_circle_outline, size: 64, color: Colors.grey),SizedBox(height: 16),Text('모든 할 일을 완료했습니다!'),],),);}Widget _buildErrorState() {return Center(child: Column(mainAxisAlignment: MainAxisAlignment.center,children: [Icon(Icons.error_outline, size: 64, color: Colors.red),SizedBox(height: 16),Text('오류가 발생했습니다'),SizedBox(height: 8),ElevatedButton(onPressed: _loadTodos,child: Text('다시 시도'),),],),);}}
Watch out
WARNING상태 관리 한계: 이 방식은 간단한 앱에 적합합니다. 앱이 커지면 다음 문제가 생깁니다.
- 상태가 위젯에 묶여 있어 공유가 어렵다
- 코드가 길어지면 관리가 힘들다
- 테스트하기 어렵다
복잡한 앱에서는 상태 관리 라이브러리를 고려하세요.
- Provider/Riverpod: 간단하고 Flutter 친화적
- Bloc: 이벤트 기반, 예측 가능한 상태 관리
- GetX: 쉬운 학습 곡선, 올인원 솔루션
한계
이 튜토리얼은 기본적인 CRUD 패턴을 다룹니다. 실제 앱에서는 더 많은 기능이 필요합니다.
추가로 고려할 사항:
| 기능 | 이 튜토리얼 | 실제 앱 |
|---|---|---|
| 인증 | X | 토큰 관리, 자동 로그아웃 |
| 페이지네이션 | X | 무한 스크롤, 페이지 로딩 |
| 오프라인 | X | 로컬 캐싱, 동기화 |
| 검색/필터 | X | 쿼리 파라미터, 디바운싱 |
| 정렬 | X | 서버/클라이언트 정렬 |
다음 튜토리얼에서는 WebSocket으로 실시간 데이터를 다루는 방법을 배웁니다.
공유
이 글이 도움이 되었다면 다른 사람과 공유해주세요!