Flutter 튜토리얼 30편: CRUD 작업 구현

요약#

핵심 요지#

  • 문제 정의: 대부분의 앱은 데이터를 생성, 조회, 수정, 삭제하는 CRUD 기능이 필요하다.
  • 핵심 주장: HTTP 메서드(GET, POST, PUT, DELETE)를 적절히 조합하면 완전한 CRUD 앱을 만들 수 있다.
  • 주요 근거: REST API 규칙을 따르면 서버와 클라이언트가 일관된 방식으로 통신한다.
  • 실무 기준: 로딩 상태, 낙관적 업데이트, 에러 복구까지 실제 앱 수준으로 구현한다.
  • 한계: 복잡한 상태 관리가 필요하면 Riverpod이나 Bloc 같은 패키지를 고려해야 한다.

문서가 설명하는 범위#

  • CRUD와 HTTP 메서드 매핑
  • 목록 조회와 새로고침
  • 아이템 생성과 화면 갱신
  • 아이템 수정과 낙관적 업데이트
  • 아이템 삭제와 확인 다이얼로그
  • 에러 처리와 재시도 로직

읽는 시간: 18분 | 난이도: 중급


참고 자료#


문제 상황#

29편에서 HTTP 요청의 기본을 배웠습니다. 이제 이걸 조합해서 실제 앱을 만들어야 합니다.

할 일 앱의 요구사항#

요구사항 1: 할 일 목록을 서버에서 가져온다 (Read)
요구사항 2: 새 할 일을 추가한다 (Create)
요구사항 3: 할 일 내용을 수정한다 (Update)
요구사항 4: 완료한 할 일을 삭제한다 (Delete)
요구사항 5: 네트워크 오류 시 재시도한다

문제는 다음과 같습니다.

  • 각 작업마다 다른 HTTP 메서드를 사용해야 한다.
  • 서버 응답 후 화면을 갱신해야 한다.
  • 사용자 경험을 위해 로딩 상태를 표시해야 한다.
  • 실패 시 적절한 피드백을 줘야 한다.

이 튜토리얼에서 이 모든 걸 구현합니다.


해결 방법#

CRUD 작업은 HTTP 메서드와 1<1로> 매핑됩니다. 이 규칙을 따르면 서버와 일관되게 통신할 수 있습니다.

graph LR subgraph CRUD C[Create] R[Read] U[Update] D[Delete] end subgraph HTTP POST[POST] GET[GET] PUT[PUT] DELETE[DELETE] end C --> POST R --> GET U --> PUT D --> DELETE

챕터 1: 데이터 모델과 API 서비스 설계#

Why#

NOTE

CRUD 앱을 만들기 전에 두 가지를 먼저 준비해야 합니다.

  1. 데이터 모델: 서버와 주고받을 데이터 구조
  2. API 서비스: HTTP 요청을 담당하는 클래스

이렇게 분리하면 UI 코드가 깔끔해지고, 테스트하기도 쉬워집니다.

What#

NOTE

할 일(Todo) 모델을 정의합니다.

class Todo {
final int? id; // 생성 전에는 null
final String title;
final bool completed;
const Todo({
this.id,
required this.title,
this.completed = false,
});
// JSON → Todo
factory 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 → JSON
Map<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#

TIP

API 서비스 클래스를 만듭니다.

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#

WARNING

baseUrl 관리: 개발/운영 환경에 따라 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#

NOTE

CRUD에서 가장 먼저 구현할 건 **조회(Read)**입니다. 데이터가 있어야 수정하거나 삭제할 수 있으니까요.

목록 화면에서는 두 가지를 고려해야 합니다.

  1. 첫 로딩 시 데이터 가져오기
  2. 당겨서 새로고침 (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;
@override
void 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;
});
}
}
@override
Widget 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,
});
@override
Widget 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 요청이 필요합니다.

생성 후에는 두 가지 방법으로 화면을 갱신할 수 있습니다.

  1. 서버 재조회: 목록 전체를 다시 가져온다 (확실하지만 느림)
  2. 로컬 추가: 응답받은 아이템만 목록에 추가한다 (빠르지만 동기화 주의)

대부분의 경우 로컬 추가가 좋은 사용자 경험을 제공합니다.

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#

TIP

FloatingActionButton으로 추가 버튼을 만듭니다.

@override
Widget 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

수정은 두 가지 시나리오가 있습니다.

  1. 간단한 토글: 완료 상태 변경 (체크박스 클릭)
  2. 전체 수정: 내용 변경 (편집 화면)

특히 토글 같은 빈번한 작업에는 낙관적 업데이트(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')),
);
}
}

낙관적 업데이트 흐름:

  1. 사용자 액션 즉시 UI 변경
  2. 백그라운드에서 서버 요청
  3. 성공하면 그대로, 실패하면 롤백

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

삭제는 되돌리기 어려운 작업입니다. 사용자에게 확인을 받는 게 좋습니다.

삭제 방식도 두 가지가 있습니다.

  1. 확인 후 삭제: 다이얼로그로 확인 → 삭제
  2. 즉시 삭제 + 실행 취소: 바로 삭제 → 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#

WARNING

Dismissible 위젯: 스와이프로 삭제할 수도 있습니다.

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 = {};
@override
void initState() {
super.initState();
_loadTodos();
}
// Read
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;
});
}
}
// Create
Future<void> _addTodo() async {
// ... (챕터 3 참조)
}
// Update
Future<void> _toggleTodo(Todo todo) async {
// ... (챕터 4 참조)
}
Future<void> _editTodo(Todo todo) async {
// ... (챕터 4 참조)
}
// Delete
Future<void> _deleteTodo(Todo todo) async {
// ... (챕터 5 참조)
}
@override
Widget 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으로 실시간 데이터를 다루는 방법을 배웁니다.


공유

이 글이 도움이 되었다면 다른 사람과 공유해주세요!

Flutter 튜토리얼 30편: CRUD 작업 구현
https://moodturnpost.net/posts/flutter/flutter-crud-operations/
작성자
Moodturn
게시일
2026-01-08
Moodturn

목차