inblog logo
|
Gyeongwon's blog
    PJT

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

    도경원's avatar
    도경원
    Oct 09, 2025
    [Flutter + Firebase로 북마크 앱 만들기 #4] Firestore 연동 및 북마크 CRUD 구현
    Contents
    📌 시리즈 목차🎯 이번 글에서 다룬 내용🗄 Firestore란?1️⃣ Bookmark Service 구현2️⃣ Riverpod Provider 작성3️⃣ 북마크 목록 화면4️⃣ 북마크 카드 위젯5️⃣ 북마크 추가/수정 다이얼로그6️⃣ 카테고리 필터링7️⃣ 검색 기능8️⃣ URL 열기 기능🔥 Firestore 인덱스 설정🔒 Firestore 보안 규칙📊 최종 프로젝트 구조💡 구현하면서 배운 점🎨 UI/UX 개선 포인트🐛 트러블슈팅📦 현재 패키지 목록🔗 GitHub 저장소🎓 학습 정리💭 회고🎉 마무리

    📌 시리즈 목차

    • #1 프로젝트 초기 설정 및 Firebase 연동
    • #2 프로젝트 구조 설계 및 Riverpod 적용
    • #3 Firebase Authentication 연동 및 로그인 구현
    • #4 Firestore 연동 및 북마크 CRUD 구현 ← 현재 글
    • #5 Firebase Hosting으로 웹 배포하기
    • #6 Android 앱 빌드 및 Play Store 배포 준비
    • #7 스마트 URL 런처 구현 (SNS 앱 연동)
    • #8 스마트 카테고리 관리 시스템
    • #9 모던 UI 디자인 시스템 구축

    🎯 이번 글에서 다룬 내용

    • 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
    Contents
    📌 시리즈 목차🎯 이번 글에서 다룬 내용🗄 Firestore란?1️⃣ Bookmark Service 구현2️⃣ Riverpod Provider 작성3️⃣ 북마크 목록 화면4️⃣ 북마크 카드 위젯5️⃣ 북마크 추가/수정 다이얼로그6️⃣ 카테고리 필터링7️⃣ 검색 기능8️⃣ URL 열기 기능🔥 Firestore 인덱스 설정🔒 Firestore 보안 규칙📊 최종 프로젝트 구조💡 구현하면서 배운 점🎨 UI/UX 개선 포인트🐛 트러블슈팅📦 현재 패키지 목록🔗 GitHub 저장소🎓 학습 정리💭 회고🎉 마무리

    Gyeongwon's blog

    RSS·Powered by Inblog