![[Flutter + Firebase로 북마크 앱 만들기 #4] Firestore 연동 및 북마크 CRUD 구현](https://image.inblog.dev?url=https%3A%2F%2Finblog.ai%2Fapi%2Fog%3Ftitle%3D%255BFlutter%2520%252B%2520Firebase%25EB%25A1%259C%2520%25EB%25B6%2581%25EB%25A7%2588%25ED%2581%25AC%2520%25EC%2595%25B1%2520%25EB%25A7%258C%25EB%2593%25A4%25EA%25B8%25B0%2520%25234%255D%2520Firestore%2520%25EC%2597%25B0%25EB%258F%2599%2520%25EB%25B0%258F%2520%25EB%25B6%2581%25EB%25A7%2588%25ED%2581%25AC%2520CRUD%2520%25EA%25B5%25AC%25ED%2598%2584%26logoUrl%3Dhttps%253A%252F%252Finblog.ai%252Finblog_logo.png%26blogTitle%3DGyeongwon%27s%2520blog&w=2048&q=75)
📌 시리즈 목차
🎯 이번 글에서 다룬 내용
- Firestore Database 연동
- 북마크 CRUD 기능 구현 (생성, 읽기, 수정, 삭제)
- 실시간 데이터 동기화
- 카테고리 필터링
- 검색 기능
- URL 열기 및 복사
- Firestore 인덱스 설정
🗄 Firestore란?
Firebase Firestore의 특징
NoSQL 클라우드 데이터베이스:
- 실시간 동기화
- 오프라인 지원
- 확장 가능한 구조
- 자동 백업
데이터 구조:
bookmarks (컬렉션)
├── document1 (문서)
│ ├── userId: "user123"
│ ├── url: "https://flutter.dev"
│ ├── title: "Flutter"
│ ├── category: "Development"
│ └── ...
├── document2
└── document3
SQL vs NoSQL 비교:
특징 | SQL (관계형) | NoSQL (Firestore) |
구조 | 테이블, 행, 열 | 컬렉션, 문서 |
스키마 | 고정 | 유연 |
관계 | JOIN | 중첩/참조 |
확장 | 수직 | 수평 |
실시간 | 별도 구현 필요 | 내장 |
1️⃣ Bookmark Service 구현
Service Layer의 역할
위치:
lib/features/bookmarks/services/bookmark_service.dart
Service는 Firestore와 직접 통신하는 계층입니다. UI와 데이터베이스를 분리하여 코드 재사용성과 테스트 용이성을 높입니다.
import 'package:cloud_firestore/cloud_firestore.dart';
import '../models/bookmark.dart';
class BookmarkService {
final FirebaseFirestore _firestore = FirebaseFirestore.instance;
final String _collection = 'bookmarks';
// 사용자의 북마크 스트림 (실시간)
Stream<List<Bookmark>> getBookmarksStream(String userId) {
return _firestore
.collection(_collection)
.where('userId', isEqualTo: userId)
.orderBy('createdAt', descending: true)
.snapshots()
.map((snapshot) {
return snapshot.docs
.map((doc) => Bookmark.fromFirestore(doc))
.toList();
});
}
// 북마크 추가
Future<void> addBookmark(Bookmark bookmark) async {
try {
await _firestore
.collection(_collection)
.add(bookmark.toFirestore());
} catch (e) {
throw '북마크 추가 중 오류가 발생했습니다: $e';
}
}
// 북마크 수정
Future<void> updateBookmark(Bookmark bookmark) async {
try {
await _firestore
.collection(_collection)
.doc(bookmark.id)
.update(bookmark.toFirestore());
} catch (e) {
throw '북마크 수정 중 오류가 발생했습니다: $e';
}
}
// 북마크 삭제
Future<void> deleteBookmark(String bookmarkId) async {
try {
await _firestore
.collection(_collection)
.doc(bookmarkId)
.delete();
} catch (e) {
throw '북마크 삭제 중 오류가 발생했습니다: $e';
}
}
// 카테고리별 북마크 가져오기
Stream<List<Bookmark>> getBookmarksByCategory(
String userId,
String category,
) {
return _firestore
.collection(_collection)
.where('userId', isEqualTo: userId)
.where('category', isEqualTo: category)
.orderBy('createdAt', descending: true)
.snapshots()
.map((snapshot) {
return snapshot.docs
.map((doc) => Bookmark.fromFirestore(doc))
.toList();
});
}
// 북마크 검색
Stream<List<Bookmark>> searchBookmarks(
String userId,
String query,
) {
return _firestore
.collection(_collection)
.where('userId', isEqualTo: userId)
.orderBy('createdAt', descending: true)
.snapshots()
.map((snapshot) {
final bookmarks = snapshot.docs
.map((doc) => Bookmark.fromFirestore(doc))
.toList();
// 클라이언트 측 검색 (Firestore는 full-text search 미지원)
return bookmarks.where((bookmark) {
final titleMatch = bookmark.title
.toLowerCase()
.contains(query.toLowerCase());
final urlMatch = bookmark.url
.toLowerCase()
.contains(query.toLowerCase());
final descMatch = bookmark.description
.toLowerCase()
.contains(query.toLowerCase());
return titleMatch || urlMatch || descMatch;
}).toList();
});
}
// 사용자의 모든 카테고리 가져오기
Future<List<String>> getCategories(String userId) async {
try {
final snapshot = await _firestore
.collection(_collection)
.where('userId', isEqualTo: userId)
.get();
final categories = snapshot.docs
.map((doc) => doc.data()['category'] as String)
.toSet()
.toList();
categories.sort();
return categories;
} catch (e) {
throw '카테고리 조회 중 오류가 발생했습니다: $e';
}
}
}
주요 메서드 설명
1. Stream을 사용한 실시간 데이터
getBookmarksStream:
Stream<List<Bookmark>> getBookmarksStream(String userId) {
return _firestore
.collection(_collection)
.where('userId', isEqualTo: userId)
.orderBy('createdAt', descending: true)
.snapshots() // ← 실시간 스트림!
.map((snapshot) { ... });
}
왜 Stream인가?:
- Firestore 데이터 변경 시 자동으로 UI 업데이트
- 추가 코드 없이 실시간 동기화
- 여러 기기에서 동시 작업 가능
동작 방식:
1. 사용자 A가 북마크 추가
2. Firestore에 저장
3. snapshots()가 변경 감지
4. Stream이 새 데이터 emit
5. UI 자동 재빌드
6. 사용자 B의 화면도 자동 업데이트
2. CRUD 작업
Create (추가):
await _firestore.collection('bookmarks').add(bookmark.toFirestore());
Read (읽기):
.snapshots() // 실시간
// 또는
.get() // 한 번만
Update (수정):
await _firestore.collection('bookmarks').doc(id).update(data);
Delete (삭제):
await _firestore.collection('bookmarks').doc(id).delete();
3. 검색 구현
Firestore의 제약:
- Full-text search 미지원
- LIKE 쿼리 미지원
해결 방법:
// 1. 모든 북마크 가져오기
final bookmarks = snapshot.docs.map(...).toList();
// 2. 클라이언트에서 필터링
return bookmarks.where((bookmark) {
final titleMatch = bookmark.title
.toLowerCase()
.contains(query.toLowerCase());
// ...
}).toList();
장단점:
- ✅ 간단한 구현
- ✅ 정확한 검색
- ❌ 데이터가 많으면 느림
- ❌ 네트워크 사용량 증가
대안 (프로덕션 환경):
- Algolia (검색 전문 서비스)
- Elasticsearch
- Firebase Extensions: Search with Algolia
2️⃣ Riverpod Provider 작성
상태 관리 계층
위치:
lib/features/bookmarks/services/bookmark_provider.dart
Provider는 Service와 UI를 연결하고, 앱 전체에서 상태를 공유합니다.
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:flutter_riverpod/legacy.dart'; // Riverpod 3.x
import '../../auth/services/auth_provider.dart';
import 'bookmark_service.dart';
import '../models/bookmark.dart';
// BookmarkService Provider
final bookmarkServiceProvider = Provider<BookmarkService>((ref) {
return BookmarkService();
});
// 북마크 스트림 Provider
final bookmarksStreamProvider = StreamProvider<List<Bookmark>>((ref) {
final bookmarkService = ref.watch(bookmarkServiceProvider);
final currentUser = ref.watch(currentUserProvider);
if (currentUser == null) {
return Stream.value([]);
}
return bookmarkService.getBookmarksStream(currentUser.uid);
});
// 카테고리 목록 Provider
final categoriesProvider = FutureProvider<List<String>>((ref) async {
final bookmarkService = ref.watch(bookmarkServiceProvider);
final currentUser = ref.watch(currentUserProvider);
if (currentUser == null) {
return [];
}
return await bookmarkService.getCategories(currentUser.uid);
});
// 선택된 카테고리 Provider (필터링용)
final selectedCategoryProvider = StateProvider<String?>((ref) => null);
// 필터링된 북마크 Provider
final filteredBookmarksProvider = Provider<List<Bookmark>>((ref) {
final bookmarksAsync = ref.watch(bookmarksStreamProvider);
final selectedCategory = ref.watch(selectedCategoryProvider);
return bookmarksAsync.when(
data: (bookmarks) {
if (selectedCategory == null) {
return bookmarks;
}
return bookmarks
.where((b) => b.category == selectedCategory)
.toList();
},
loading: () => [],
error: (_, __) => [],
);
});
// 검색어 Provider
final searchQueryProvider = StateProvider<String>((ref) => '');
// 검색된 북마크 Provider
final searchedBookmarksProvider = StreamProvider<List<Bookmark>>((ref) {
final bookmarkService = ref.watch(bookmarkServiceProvider);
final currentUser = ref.watch(currentUserProvider);
final query = ref.watch(searchQueryProvider);
if (currentUser == null) {
return Stream.value([]);
}
if (query.isEmpty) {
return bookmarkService.getBookmarksStream(currentUser.uid);
}
return bookmarkService.searchBookmarks(currentUser.uid, query);
});
Provider 계층 구조
┌─────────────────────────────────┐
│ bookmarkServiceProvider │ ← Service 인스턴스
└─────────────────────────────────┘
↓
┌─────────────────────────────────┐
│ bookmarksStreamProvider │ ← 실시간 북마크 스트림
└─────────────────────────────────┘
↓
┌─────────────────────────────────┐
│ selectedCategoryProvider │ ← 선택된 카테고리
└─────────────────────────────────┘
↓
┌─────────────────────────────────┐
│ filteredBookmarksProvider │ ← 필터링된 결과
└─────────────────────────────────┘
Riverpod 3.x 주의사항
StateProvider는 legacy:
import 'package:flutter_riverpod/legacy.dart'; // ← 필수!
final selectedCategoryProvider = StateProvider<String?>((ref) => null);
왜 legacy?:
- Riverpod 3.0부터 StateProvider는 레거시로 이동
- 새로운 방식: @riverpod 어노테이션 + 코드 생성
- 하지만 간단한 상태에는 legacy가 더 편함
3️⃣ 북마크 목록 화면
HomeScreen 구현
위치:
lib/features/bookmarks/screens/home_screen.dart
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:fluttertoast/fluttertoast.dart';
import '../../../core/constants/app_colors.dart';
import '../../../core/constants/app_strings.dart';
import '../../../core/utils/url_launcher_helper.dart';
import '../../auth/services/auth_provider.dart';
import '../services/bookmark_provider.dart';
import '../widgets/bookmark_card.dart';
import '../widgets/add_bookmark_dialog.dart';
import '../widgets/category_filter_section.dart';
import '../widgets/bookmark_search_delegate.dart';
class HomeScreen extends ConsumerWidget {
const HomeScreen({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final authService = ref.read(authServiceProvider);
final bookmarksAsync = ref.watch(bookmarksStreamProvider);
final selectedCategory = ref.watch(selectedCategoryProvider);
return Scaffold(
appBar: AppBar(
title: const Text(AppStrings.myBookmarks),
backgroundColor: AppColors.primary,
foregroundColor: Colors.white,
actions: [
// 검색 버튼
IconButton(
icon: const Icon(Icons.search),
onPressed: () {
showSearch(
context: context,
delegate: BookmarkSearchDelegate(),
);
},
),
// 로그아웃 버튼
IconButton(
icon: const Icon(Icons.logout),
onPressed: () async {
await authService.signOut();
},
),
],
),
body: Column(
children: [
// 카테고리 필터
const CategoryFilterSection(),
const Divider(height: 1),
// 북마크 목록
Expanded(
child: bookmarksAsync.when(
data: (bookmarks) {
// 카테고리 필터링
final filteredBookmarks = selectedCategory == null
? bookmarks
: bookmarks
.where((b) => b.category == selectedCategory)
.toList();
if (filteredBookmarks.isEmpty) {
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
Icons.bookmark_border,
size: 80,
color: AppColors.primary.withOpacity(0.3),
),
const SizedBox(height: 16),
Text(
AppStrings.noBookmarks,
style: const TextStyle(
fontSize: 18,
color: AppColors.textSecondary,
),
),
],
),
);
}
return ListView.builder(
padding: const EdgeInsets.all(16),
itemCount: filteredBookmarks.length,
itemBuilder: (context, index) {
final bookmark = filteredBookmarks[index];
return BookmarkCard(
bookmark: bookmark,
onTap: () => _openBookmark(context, bookmark),
onEdit: () => _editBookmark(context, ref, bookmark),
onDelete: () => _deleteBookmark(context, ref, bookmark),
);
},
);
},
loading: () => const Center(
child: CircularProgressIndicator(),
),
error: (error, stack) => Center(
child: Text('오류: $error'),
),
),
),
],
),
floatingActionButton: FloatingActionButton(
onPressed: () => _showAddBookmarkDialog(context, ref),
backgroundColor: AppColors.primary,
child: const Icon(Icons.add, color: Colors.white),
),
);
}
Future<void> _openBookmark(BuildContext context, Bookmark bookmark) async {
await UrlLauncherHelper.openUrl(bookmark.url);
}
void _showAddBookmarkDialog(BuildContext context, WidgetRef ref) {
showDialog(
context: context,
builder: (context) => const AddBookmarkDialog(),
);
}
void _editBookmark(
BuildContext context,
WidgetRef ref,
Bookmark bookmark,
) {
showDialog(
context: context,
builder: (context) => AddBookmarkDialog(bookmark: bookmark),
);
}
Future<void> _deleteBookmark(
BuildContext context,
WidgetRef ref,
Bookmark bookmark,
) async {
final confirm = await showDialog<bool>(
context: context,
builder: (context) => AlertDialog(
title: const Text('북마크 삭제'),
content: const Text('이 북마크를 삭제하시겠습니까?'),
actions: [
TextButton(
onPressed: () => Navigator.pop(context, false),
child: const Text(AppStrings.cancel),
),
TextButton(
onPressed: () => Navigator.pop(context, true),
style: TextButton.styleFrom(
foregroundColor: AppColors.error,
),
child: const Text(AppStrings.delete),
),
],
),
);
if (confirm == true) {
try {
final bookmarkService = ref.read(bookmarkServiceProvider);
await bookmarkService.deleteBookmark(bookmark.id);
if (context.mounted) {
Fluttertoast.showToast(
msg: AppStrings.bookmarkDeleted,
backgroundColor: AppColors.success,
);
}
} catch (e) {
if (context.mounted) {
Fluttertoast.showToast(
msg: e.toString(),
backgroundColor: AppColors.error,
);
}
}
}
}
}
when 패턴 활용
StreamProvider의 3가지 상태:
bookmarksAsync.when(
data: (bookmarks) {
// 데이터 로드 완료
return ListView.builder(...);
},
loading: () {
// 로딩 중
return CircularProgressIndicator();
},
error: (error, stack) {
// 에러 발생
return Text('오류: $error');
},
)
장점:
- 모든 상태를 명시적으로 처리
- null 체크 불필요
- 타입 안전성
4️⃣ 북마크 카드 위젯
UI 컴포넌트
위치:
lib/features/bookmarks/widgets/bookmark_card.dart
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:fluttertoast/fluttertoast.dart';
import 'package:intl/intl.dart';
import '../../../core/constants/app_colors.dart';
import '../../../core/utils/url_launcher_helper.dart';
import '../models/bookmark.dart';
class BookmarkCard extends StatelessWidget {
final Bookmark bookmark;
final VoidCallback onTap;
final VoidCallback onEdit;
final VoidCallback onDelete;
const BookmarkCard({
super.key,
required this.bookmark,
required this.onTap,
required this.onEdit,
required this.onDelete,
});
@override
Widget build(BuildContext context) {
return Card(
margin: const EdgeInsets.only(bottom: 12),
elevation: 2,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
child: InkWell(
onTap: onTap,
borderRadius: BorderRadius.circular(12),
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// 헤더 (제목 + 액션 버튼)
Row(
children: [
Expanded(
child: Text(
bookmark.title,
style: const TextStyle(
fontSize: 18,
fontWeight: FontWeight.bold,
color: AppColors.textPrimary,
),
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
),
PopupMenuButton(
icon: const Icon(Icons.more_vert),
itemBuilder: (context) => [
PopupMenuItem(
value: 'copy',
child: Row(
children: const [
Icon(Icons.copy, size: 20),
SizedBox(width: 8),
Text('URL 복사'),
],
),
),
PopupMenuItem(
value: 'edit',
child: Row(
children: const [
Icon(Icons.edit, size: 20),
SizedBox(width: 8),
Text('수정'),
],
),
),
PopupMenuItem(
value: 'delete',
child: Row(
children: const [
Icon(Icons.delete, size: 20, color: AppColors.error),
SizedBox(width: 8),
Text('삭제', style: TextStyle(color: AppColors.error)),
],
),
),
],
onSelected: (value) {
if (value == 'copy') {
Clipboard.setData(ClipboardData(text: bookmark.url));
Fluttertoast.showToast(
msg: 'URL이 복사되었습니다',
backgroundColor: AppColors.success,
);
} else if (value == 'edit') {
onEdit();
} else if (value == 'delete') {
onDelete();
}
},
),
],
),
const SizedBox(height: 8),
// URL (클릭 가능)
InkWell(
onTap: () => UrlLauncherHelper.openUrl(bookmark.url),
child: Text(
bookmark.url,
style: const TextStyle(
fontSize: 14,
color: AppColors.primary,
decoration: TextDecoration.underline,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
),
// 설명 (있으면)
if (bookmark.description.isNotEmpty) ...[
const SizedBox(height: 8),
Text(
bookmark.description,
style: const TextStyle(
fontSize: 14,
color: AppColors.textSecondary,
),
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
],
const SizedBox(height: 12),
// 하단 (카테고리 + 날짜)
Row(
children: [
Container(
padding: const EdgeInsets.symmetric(
horizontal: 12,
vertical: 4,
),
decoration: BoxDecoration(
color: AppColors.primary.withOpacity(0.1),
borderRadius: BorderRadius.circular(16),
),
child: Text(
bookmark.category,
style: const TextStyle(
fontSize: 12,
color: AppColors.primary,
fontWeight: FontWeight.w500,
),
),
),
const Spacer(),
Text(
_formatDate(bookmark.createdAt),
style: const TextStyle(
fontSize: 12,
color: AppColors.textSecondary,
),
),
],
),
],
),
),
),
);
}
String _formatDate(DateTime date) {
final now = DateTime.now();
final difference = now.difference(date);
if (difference.inDays == 0) {
return DateFormat('HH:mm').format(date);
} else if (difference.inDays < 7) {
return '${difference.inDays}일 전';
} else {
return DateFormat('yyyy.MM.dd').format(date);
}
}
}
날짜 표시 로직
사용자 친화적 날짜 표시:
오늘: "14:30"
어제: "1일 전"
이번 주: "3일 전"
그 이전: "2025.01.15"
intl 패키지 사용:
import 'package:intl/intl.dart';
DateFormat('yyyy.MM.dd').format(date); // 2025.01.15
DateFormat('HH:mm').format(date); // 14:30
5️⃣ 북마크 추가/수정 다이얼로그
AddBookmarkDialog
위치:
lib/features/bookmarks/widgets/add_bookmark_dialog.dart
핵심 코드:
class AddBookmarkDialog extends ConsumerStatefulWidget {
final Bookmark? bookmark; // null이면 추가, 있으면 수정
const AddBookmarkDialog({
super.key,
this.bookmark,
});
@override
ConsumerState<AddBookmarkDialog> createState() => _AddBookmarkDialogState();
}
class _AddBookmarkDialogState extends ConsumerState<AddBookmarkDialog> {
final _formKey = GlobalKey<FormState>();
late final TextEditingController _urlController;
late final TextEditingController _titleController;
late final TextEditingController _descriptionController;
late String _selectedCategory;
// 기본 카테고리 목록
final List<String> _defaultCategories = [
'General',
'Development',
'Design',
'News',
'Entertainment',
'Education',
'Shopping',
'Social',
];
@override
void initState() {
super.initState();
// 수정 모드면 기존 값으로 초기화
_urlController = TextEditingController(
text: widget.bookmark?.url ?? '',
);
_titleController = TextEditingController(
text: widget.bookmark?.title ?? '',
);
_descriptionController = TextEditingController(
text: widget.bookmark?.description ?? '',
);
_selectedCategory = widget.bookmark?.category ?? 'General';
}
Future<void> _handleSave() async {
if (!_formKey.currentState!.validate()) return;
setState(() => _isLoading = true);
try {
final bookmarkService = ref.read(bookmarkServiceProvider);
final currentUser = ref.read(currentUserProvider);
if (currentUser == null) {
throw '로그인이 필요합니다';
}
final now = DateTime.now();
if (widget.bookmark == null) {
// 새 북마크 추가
final newBookmark = Bookmark(
id: '',
userId: currentUser.uid,
url: _urlController.text.trim(),
title: _titleController.text.trim(),
description: _descriptionController.text.trim(),
category: _selectedCategory,
createdAt: now,
updatedAt: now,
);
await bookmarkService.addBookmark(newBookmark);
if (mounted) {
Navigator.pop(context);
Fluttertoast.showToast(
msg: AppStrings.bookmarkAdded,
backgroundColor: AppColors.success,
);
}
} else {
// 기존 북마크 수정
final updatedBookmark = widget.bookmark!.copyWith(
url: _urlController.text.trim(),
title: _titleController.text.trim(),
description: _descriptionController.text.trim(),
category: _selectedCategory,
updatedAt: now,
);
await bookmarkService.updateBookmark(updatedBookmark);
if (mounted) {
Navigator.pop(context);
Fluttertoast.showToast(
msg: AppStrings.bookmarkUpdated,
backgroundColor: AppColors.success,
);
}
}
} catch (e) {
if (mounted) {
Fluttertoast.showToast(
msg: e.toString(),
backgroundColor
backgroundColor: AppColors.error,
);
}
} finally {
if (mounted) {
setState(() => _isLoading = false);
}
}
}
// ... UI 코드
}
추가 vs 수정 모드
하나의 다이얼로그로 두 기능 처리:
// 추가 모드
AddBookmarkDialog() // bookmark가 null
// 수정 모드
AddBookmarkDialog(bookmark: existingBookmark) // bookmark 전달
로직 분기:
if (widget.bookmark == null) {
// 추가: 새 Bookmark 생성
final newBookmark = Bookmark(
id: '', // Firestore가 자동 생성
userId: currentUser.uid,
url: _urlController.text.trim(),
// ...
);
await bookmarkService.addBookmark(newBookmark);
} else {
// 수정: copyWith로 업데이트
final updatedBookmark = widget.bookmark!.copyWith(
url: _urlController.text.trim(),
title: _titleController.text.trim(),
updatedAt: now,
);
await bookmarkService.updateBookmark(updatedBookmark);
}
Form 유효성 검사
URL 검증:
TextFormField(
controller: _urlController,
validator: (value) {
if (value == null || value.isEmpty) {
return AppStrings.fieldsRequired;
}
if (!value.startsWith('http://') &&
!value.startsWith('https://')) {
return 'http:// 또는 https://로 시작해야 합니다';
}
return null;
},
)
카테고리 선택:
DropdownButtonFormField<String>(
value: _selectedCategory,
decoration: InputDecoration(
labelText: AppStrings.category,
prefixIcon: const Icon(Icons.category),
),
items: _defaultCategories.map((category) {
return DropdownMenuItem(
value: category,
child: Text(category),
);
}).toList(),
onChanged: (value) {
if (value != null) {
setState(() {
_selectedCategory = value;
});
}
},
)
6️⃣ 카테고리 필터링
CategoryFilterSection
위치:
lib/features/bookmarks/widgets/category_filter_section.dart
class CategoryFilterSection extends ConsumerWidget {
const CategoryFilterSection({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final categoriesAsync = ref.watch(categoriesProvider);
final selectedCategory = ref.watch(selectedCategoryProvider);
return categoriesAsync.when(
data: (categories) {
if (categories.isEmpty) {
return const SizedBox.shrink();
}
return Container(
padding: const EdgeInsets.symmetric(vertical: 12, horizontal: 16),
child: SingleChildScrollView(
scrollDirection: Axis.horizontal,
child: Row(
children: [
// 전체 버튼
FilterChip(
label: const Text('전체'),
selected: selectedCategory == null,
onSelected: (selected) {
if (selected) {
ref.read(selectedCategoryProvider.notifier).state = null;
}
},
selectedColor: AppColors.primary,
labelStyle: TextStyle(
color: selectedCategory == null
? Colors.white
: AppColors.textPrimary,
),
),
const SizedBox(width: 8),
// 카테고리 버튼들
...categories.map((category) {
final isSelected = selectedCategory == category;
return Padding(
padding: const EdgeInsets.only(right: 8),
child: FilterChip(
label: Text(category),
selected: isSelected,
onSelected: (selected) {
if (selected) {
ref.read(selectedCategoryProvider.notifier).state =
category;
} else {
ref.read(selectedCategoryProvider.notifier).state =
null;
}
},
selectedColor: AppColors.primary,
labelStyle: TextStyle(
color: isSelected
? Colors.white
: AppColors.textPrimary,
),
),
);
}).toList(),
],
),
),
);
},
loading: () => const SizedBox.shrink(),
error: (_, __) => const SizedBox.shrink(),
);
}
}
동작 방식
1. categoriesProvider가 사용자의 카테고리 로드
2. FilterChip으로 카테고리 버튼 생성
3. 선택 시 selectedCategoryProvider 업데이트
4. filteredBookmarksProvider가 자동으로 필터링
5. UI 자동 재빌드
Riverpod의 자동 반응성:
// 1. 카테고리 선택
ref.read(selectedCategoryProvider.notifier).state = 'Development';
// 2. filteredBookmarksProvider가 자동으로 재계산
final filteredBookmarksProvider = Provider<List<Bookmark>>((ref) {
final selectedCategory = ref.watch(selectedCategoryProvider);
// 자동으로 'Development' 카테고리만 필터링
});
// 3. UI가 자동으로 업데이트
7️⃣ 검색 기능
BookmarkSearchDelegate
위치:
lib/features/bookmarks/widgets/bookmark_search_delegate.dart
Flutter의 SearchDelegate 활용:
class BookmarkSearchDelegate extends SearchDelegate<Bookmark?> {
@override
String get searchFieldLabel => '북마크 검색';
@override
List<Widget> buildActions(BuildContext context) {
return [
if (query.isNotEmpty)
IconButton(
icon: const Icon(Icons.clear),
onPressed: () {
query = '';
},
),
];
}
@override
Widget buildLeading(BuildContext context) {
return IconButton(
icon: const Icon(Icons.arrow_back),
onPressed: () {
close(context, null);
},
);
}
@override
Widget buildResults(BuildContext context) {
return _SearchResults(query: query);
}
@override
Widget buildSuggestions(BuildContext context) {
if (query.isEmpty) {
return const Center(
child: Text('북마크를 검색해보세요'),
);
}
return _SearchResults(query: query);
}
}
SearchDelegate의 메서드
메서드 | 설명 | 호출 시점 |
buildActions | 검색창 우측 액션 (clear 버튼) | 항상 표시 |
buildLeading | 검색창 좌측 (뒤로가기 버튼) | 항상 표시 |
buildSuggestions | 검색 제안 | 타이핑 중 |
buildResults | 검색 결과 | Enter 입력 시 |
실시간 검색 구현
class _SearchResults extends ConsumerWidget {
final String query;
@override
Widget build(BuildContext context, WidgetRef ref) {
// 검색어 업데이트
WidgetsBinding.instance.addPostFrameCallback((_) {
ref.read(searchQueryProvider.notifier).state = query;
});
final searchResults = ref.watch(searchedBookmarksProvider);
return searchResults.when(
data: (bookmarks) {
if (bookmarks.isEmpty) {
return Center(child: Text('검색 결과가 없습니다'));
}
return ListView.builder(
itemCount: bookmarks.length,
itemBuilder: (context, index) {
return BookmarkCard(bookmark: bookmarks[index], ...);
},
);
},
loading: () => CircularProgressIndicator(),
error: (error, _) => Text('오류: $error'),
);
}
}
검색 흐름:
1. 사용자가 검색어 입력
2. searchQueryProvider 업데이트
3. searchedBookmarksProvider가 자동 재계산
4. BookmarkService.searchBookmarks 호출
5. 필터링된 결과 반환
6. UI 자동 업데이트
8️⃣ URL 열기 기능
UrlLauncherHelper
위치:
lib/core/utils/url_launcher_helper.dart
import 'package:url_launcher/url_launcher.dart';
import 'package:fluttertoast/fluttertoast.dart';
import '../constants/app_colors.dart';
class UrlLauncherHelper {
// URL 열기
static Future<void> openUrl(String url) async {
try {
final uri = Uri.parse(url);
if (await canLaunchUrl(uri)) {
await launchUrl(
uri,
mode: LaunchMode.externalApplication,
);
} else {
Fluttertoast.showToast(
msg: 'URL을 열 수 없습니다: $url',
backgroundColor: AppColors.error,
);
}
} catch (e) {
Fluttertoast.showToast(
msg: 'URL 열기 실패: $e',
backgroundColor: AppColors.error,
);
}
}
}
LaunchMode 옵션
// 1. externalApplication - 외부 브라우저에서 열기
LaunchMode.externalApplication
// 2. platformDefault - 플랫폼 기본 방식
LaunchMode.platformDefault
// 3. inAppWebView - 앱 내 웹뷰 (iOS/Android만)
LaunchMode.inAppWebView
플랫폼별 동작:
플랫폼 | externalApplication | platformDefault |
Web | 새 탭에서 열림 | 새 탭에서 열림 |
Android | 기본 브라우저 실행 | 기본 브라우저 실행 |
iOS | Safari 실행 | Safari 실행 |
macOS | 기본 브라우저 실행 | 기본 브라우저 실행 |
URL 복사 기능
Clipboard 사용:
import 'package:flutter/services.dart';
PopupMenuItem(
value: 'copy',
child: Row(
children: const [
Icon(Icons.copy),
Text('URL 복사'),
],
),
onSelected: (value) {
if (value == 'copy') {
Clipboard.setData(ClipboardData(text: bookmark.url));
Fluttertoast.showToast(
msg: 'URL이 복사되었습니다',
backgroundColor: AppColors.success,
);
}
},
)
🔥 Firestore 인덱스 설정
복합 쿼리 인덱스 필요
문제 발생:
[cloud_firestore/failed-precondition]
The query requires an index.
원인:
// 여러 필드를 조합한 쿼리
_firestore
.collection('bookmarks')
.where('userId', isEqualTo: userId) // 필터 1
.orderBy('createdAt', descending: true) // 정렬
Firestore는 복합 쿼리(여러 필드 조합)에 인덱스가 필수입니다.
해결 방법
1. 자동 인덱스 생성 (추천!)
에러 메시지의 URL 클릭:
https://console.firebase.google.com/v1/r/project/bookmark-manager-24e55/firestore/indexes?create_composite=...
- 에러의 긴 URL 복사
- 브라우저에 붙여넣기
- "인덱스 만들기" 버튼 클릭
- 빌드 완료 대기 (1-5분)
2. 수동 인덱스 생성
Firebase Console:
- Firestore Database → 인덱스 탭
- 복합 탭 선택
- 인덱스 추가 클릭
- 설정:
컬렉션 ID: bookmarks
필드:
- userId (오름차순)
- createdAt (내림차순)
쿼리 범위: 컬렉션
- 만들기 클릭
- 빌드 완료 대기
필요한 인덱스 목록
기본 북마크 목록:
bookmarks
- userId (Ascending)
- createdAt (Descending)
카테고리별 조회 (나중에 필요 시):
bookmarks
- userId (Ascending)
- category (Ascending)
- createdAt (Descending)
🔒 Firestore 보안 규칙
현재 테스트 모드 규칙
rules_version = '2';
service cloud.firestore {
match /databases/{database}/documents {
match /{document=**} {
allow read, write: if request.time < timestamp.date(2025, 2, 10);
}
}
}
⚠️ 문제점:
- 누구나 읽기/쓰기 가능
- 만료일 존재
- 보안 취약
프로덕션 보안 규칙 (권장)
rules_version = '2';
service cloud.firestore {
match /databases/{database}/documents {
// bookmarks 컬렉션
match /bookmarks/{bookmarkId} {
// 읽기: 로그인 + 자신의 북마크
allow read: if request.auth != null
&& request.auth.uid == resource.data.userId;
// 쓰기: 로그인 + 자신의 북마크
allow update, delete: if request.auth != null
&& request.auth.uid == resource.data.userId;
// 생성: 로그인 + userId 일치
allow create: if request.auth != null
&& request.auth.uid == request.resource.data.userId;
}
}
}
규칙 설명:
작업 | 조건 | 설명 |
read | 로그인 + userId 일치 | 자신의 북마크만 조회 |
create | 로그인 + userId 일치 | 북마크 생성 시 userId 검증 |
update | 로그인 + userId 일치 | 자신의 북마크만 수정 |
delete | 로그인 + userId 일치 | 자신의 북마크만 삭제 |
보안 규칙 테스트
Firebase Console:
- Firestore Database → 규칙 탭
- 규칙 플레이그라운드 클릭
- 시뮬레이션 테스트:
// 테스트 1: 로그인 안 한 사용자
Location: /bookmarks/abc123
Authentication: Unauthenticated
Read: ❌ Denied
// 테스트 2: 다른 사용자의 북마크
Location: /bookmarks/abc123
Authentication: user123
Resource data: { userId: "user456" }
Read: ❌ Denied
// 테스트 3: 자신의 북마크
Location: /bookmarks/abc123
Authentication: user123
Resource data: { userId: "user123" }
Read: ✅ Allowed
📊 최종 프로젝트 구조
lib/
├── main.dart
├── firebase_options.dart
│
├── core/
│ ├── constants/
│ │ ├── app_colors.dart
│ │ └── app_strings.dart
│ └── utils/
│ └── url_launcher_helper.dart ✅ 새로 추가
│
├── features/
│ ├── auth/
│ │ ├── models/
│ │ │ └── user_model.dart
│ │ ├── screens/
│ │ │ ├── login_screen.dart
│ │ │ └── signup_screen.dart
│ │ └── services/
│ │ ├── auth_service.dart
│ │ └── auth_provider.dart
│ │
│ └── bookmarks/
│ ├── models/
│ │ └── bookmark.dart
│ ├── screens/
│ │ └── home_screen.dart ✅ 개선
│ ├── services/
│ │ ├── bookmark_service.dart ✅ 새로 추가
│ │ └── bookmark_provider.dart ✅ 새로 추가
│ └── widgets/
│ ├── bookmark_card.dart ✅ 새로 추가
│ ├── add_bookmark_dialog.dart ✅ 새로 추가
│ ├── category_filter_section.dart ✅ 새로 추가
│ └── bookmark_search_delegate.dart ✅ 새로 추가
│
└── routes/
└── app_router.dart
💡 구현하면서 배운 점
1. Firestore의 실시간 동기화
Stream의 강력함:
_firestore.collection('bookmarks').snapshots()
단 한 줄로:
- 실시간 데이터 동기화
- 여러 기기 간 자동 업데이트
- 자동 재연결
- 오프라인 지원
전통적인 방식과 비교:
// ❌ 전통적인 방식
Future<List<Bookmark>> getBookmarks() {
// API 호출
// 수동 폴링 필요
// 실시간 업데이트 없음
}
// ✅ Firestore Stream
Stream<List<Bookmark>> getBookmarksStream() {
return _firestore.collection('bookmarks').snapshots();
// 자동 실시간 업데이트!
}
2. Riverpod의 선언적 상태 관리
Provider 계층으로 복잡한 로직을 단순화:
// 데이터 로드
bookmarksStreamProvider
// 필터링
filteredBookmarksProvider
// 검색
searchedBookmarksProvider
각 Provider는:
- 독립적으로 동작
- 자동으로 의존성 관리
- 필요할 때만 재계산
3. 하나의 컴포넌트로 여러 기능
AddBookmarkDialog:
// 추가 모드
AddBookmarkDialog()
// 수정 모드
AddBookmarkDialog(bookmark: existing)
장점:
- 코드 중복 제거
- 일관된 UX
- 유지보수 용이
4. 클라이언트 vs 서버 검색
Firestore의 제약:
- Full-text search 미지원
- LIKE 쿼리 불가
해결 방법:
- 클라이언트 검색 (현재)
- Algolia 연동 (프로덕션)
트레이드오프:
방식 | 장점 | 단점 |
클라이언트 | 간단, 무료 | 데이터 많으면 느림 |
Algolia | 빠름, 강력 | 유료, 복잡 |
5. 인덱스의 중요성
복합 쿼리는 인덱스 필수:
.where('userId', isEqualTo: userId)
.orderBy('createdAt', descending: true)
// ← 인덱스 없으면 에러!
인덱스 없이는:
- 쿼리 실패
- 성능 저하
- 확장성 문제
🎨 UI/UX 개선 포인트
1. 빈 상태 처리
if (bookmarks.isEmpty) {
return Center(
child: Column(
children: [
Icon(Icons.bookmark_border, size: 80),
Text('저장된 북마크가 없습니다'),
Text('+ 버튼을 눌러 북마크를 추가해보세요!'),
],
),
);
}
효과:
- 사용자에게 다음 액션 제시
- 빈 화면 방지
- 친근한 UX
2. 로딩 상태 표시
bookmarksAsync.when(
data: (bookmarks) => ListView(...),
loading: () => CircularProgressIndicator(),
error: (error, _) => Text('오류: $error'),
)
3가지 상태 모두 처리:
- 로딩 중: 스피너
- 완료: 데이터
- 에러: 에러 메시지
3. 확인 다이얼로그
final confirm = await showDialog<bool>(
context: context,
builder: (context) => AlertDialog(
title: Text('북마크 삭제'),
content: Text('이 북마크를 삭제하시겠습니까?'),
actions: [
TextButton(child: Text('취소')),
TextButton(child: Text('삭제')),
],
),
);
if (confirm == true) {
// 삭제 진행
}
효과:
- 실수 방지
- 사용자 확신
- 안전한 UX
4. Toast 알림
Fluttertoast.showToast(
msg: '북마크가 추가되었습니다',
backgroundColor: AppColors.success,
);
장점:
- 방해하지 않는 피드백
- 자동으로 사라짐
- 색상으로 성공/실패 구분
🐛 트러블슈팅
문제 1: 인덱스 에러
에러:
[cloud_firestore/failed-precondition]
The query requires an index.
해결:
- 에러 URL 클릭
- "인덱스 만들기" 클릭
- 1-5분 대기
문제 2: Riverpod 3.x StateProvider
에러:
The function 'StateProvider' isn't defined.
해결:
import 'package:flutter_riverpod/legacy.dart';
문제 3: URL 열기 실패
원인: 잘못된 URL 형식
해결:
validator: (value) {
if (!value.startsWith('http://') &&
!value.startsWith('https://')) {
return 'http:// 또는 https://로 시작해야 합니다';
}
return null;
}
📦 현재 패키지 목록
dependencies:
# Flutter Core
flutter:
sdk: flutter
# Firebase
firebase_core: ^4.1.1
firebase_auth: ^6.1.0
cloud_firestore: ^6.0.2
# State Management
flutter_riverpod: ^3.0.3
# Routing
go_router: ^16.2.4
# Utilities
url_launcher: ^6.3.2
flutter_spinkit: ^5.2.2
fluttertoast: ^9.0.0
intl: ^0.20.2
🔗 GitHub 저장소
진행 중인 코드는 여기에서 확인할 수 있습니다!
이번 작업 커밋
git add .
git commit -m "feat: implement bookmark CRUD with Firestore
- Create BookmarkService for Firestore operations
- Set up Riverpod providers for bookmark state management
- Implement bookmark list screen with real-time updates
- Add bookmark create/update dialog
- Implement category filtering with FilterChip
- Add search functionality with SearchDelegate
- Implement URL launcher for opening bookmarks
- Add URL copy feature
- Create Firestore composite index for queries
- Handle empty states and loading states
- Add confirmation dialogs for delete actions"
git push
🎓 학습 정리
이번 글에서 배운 것
기술적 학습:
- ✅ Firestore Database 연동
- ✅ 실시간 데이터 동기화 (Stream)
- ✅ CRUD 구현
- ✅ 복합 쿼리와 인덱스
- ✅ 클라이언트 측 검색
- ✅ URL 실행 (url_launcher)
상태 관리:
- ✅ StreamProvider로 실시간 데이터
- ✅ FutureProvider로 비동기 데이터
- ✅ StateProvider로 간단한 상태 (legacy)
- ✅ Provider 계층 구조
UI/UX:
- ✅ 빈 상태 처리
- ✅ 로딩 상태 표시
- ✅ 에러 처리
- ✅ 확인 다이얼로그
- ✅ Toast 알림
💭 회고
잘한 점
- Firestore Stream으로 실시간 동기화 구현
- Riverpod Provider 계층으로 깔끔한 상태 관리
- 하나의 다이얼로그로 추가/수정 통합
- 사용자 친화적인 에러 처리
개선할 점
- 검색 기능 최적화 (Algolia 고려)
- 오프라인 지원 강화
- 페이징 구현 (데이터 많을 때)
- 이미지 썸네일 추가
느낀 점
Firestore의 실시간 동기화가 정말 강력했습니다. 별도의 폴링이나 웹소켓 없이도 자동으로 모든 기기가 동기화되는 게 마법 같았어요!
Riverpod 3.x의 legacy 이슈는 처음엔 당황스러웠지만, 간단한 상태에는 StateProvider가 여전히 유용하다는 것을 알게 되었습니다.
가장 어려웠던 부분은 Firestore 인덱스였는데, 에러 메시지의 URL이 자동으로 인덱스 생성 페이지로 연결되는 게 정말 편리했습니다!
🎉 마무리
드디어 완전히 동작하는 북마크 관리 앱이 완성되었습니다! 🎊
구현된 기능:
- ✅ 사용자 인증 (로그인/회원가입)
- ✅ 북마크 추가/수정/삭제
- ✅ 실시간 목록 동기화
- ✅ 카테고리 필터링
- ✅ 검색 기능
- ✅ URL 열기 및 복사
Share article