[Flutter + Firebase로 북마크 앱 만들기 #4] Firestore 연동 및 북마크 CRUD 구현

도경원's avatar
Oct 09, 2025
[Flutter + Firebase로 북마크 앱 만들기 #4] Firestore 연동 및 북마크 CRUD 구현

📌 시리즈 목차


🎯 이번 글에서 다룬 내용

  • 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=...
  1. 에러의 긴 URL 복사
  1. 브라우저에 붙여넣기
  1. "인덱스 만들기" 버튼 클릭
  1. 빌드 완료 대기 (1-5분)

2. 수동 인덱스 생성

Firebase Console:
  1. Firestore Database → 인덱스 탭
  1. 복합 탭 선택
  1. 인덱스 추가 클릭
  1. 설정:
컬렉션 ID: bookmarks 필드: - userId (오름차순) - createdAt (내림차순) 쿼리 범위: 컬렉션
  1. 만들기 클릭
  1. 빌드 완료 대기

필요한 인덱스 목록

기본 북마크 목록:
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:
  1. Firestore Database → 규칙 탭
  1. 규칙 플레이그라운드 클릭
  1. 시뮬레이션 테스트:
// 테스트 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.
해결:
  1. 에러 URL 클릭
  1. "인덱스 만들기" 클릭
  1. 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

Gyeongwon's blog