inblog logo
|
Gyeongwon's blog
    PJT

    [Flutter + Firebase로 북마크 앱 만들기 #2] 프로젝트 구조 설계 및 Riverpod 적용

    도경원's avatar
    도경원
    Oct 07, 2025
    [Flutter + Firebase로 북마크 앱 만들기 #2] 프로젝트 구조 설계 및 Riverpod 적용
    Contents
    📌 시리즈 목차🎯 이번 글에서 다룰 내용📂 프로젝트 폴더 구조 설계🔄 상태 관리: Riverpod 선택🧭 라우팅: go_router 선택🛠 유틸리티 패키지 설치📦 main.dart 수정 (Riverpod 적용)🎨 상수 파일 생성📊 데이터 모델 설계✅ 테스트 및 확인📦 현재 프로젝트 상태💡 배운 점 & 고민했던 점

    📌 시리즈 목차

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

    🎯 이번 글에서 다룰 내용

    • Clean Architecture 기반 폴더 구조 설계
    • Riverpod 상태 관리 도입
    • go_router 라우팅 설정
    • 데이터 모델 설계
    • 프로젝트 기초 세팅 완료

    📂 프로젝트 폴더 구조 설계

    왜 구조가 중요한가?

    토이 프로젝트라도 처음부터 체계적인 구조를 잡아놓으면:
    • 코드 찾기가 쉬워집니다
    • 기능 추가가 용이합니다
    • 유지보수가 편해집니다
    • 나중에 리팩토링 비용이 줄어듭니다

    선택한 구조: Feature-First Architecture

    lib/ ├── main.dart # 앱 진입점 ├── firebase_options.dart # Firebase 설정 │ ├── core/ # 공통 기능 │ ├── constants/ # 상수 (색상, 문자열) │ ├── utils/ # 유틸리티 함수 │ └── widgets/ # 공통 위젯 │ ├── features/ # 기능별 모듈 │ ├── auth/ # 인증 기능 │ │ ├── models/ # 데이터 모델 │ │ ├── screens/ # 화면 │ │ ├── widgets/ # 위젯 │ │ └── services/ # 비즈니스 로직 │ │ │ └── bookmarks/ # 북마크 기능 │ ├── models/ │ ├── screens/ │ ├── widgets/ │ └── services/ │ └── routes/ # 라우팅 └── app_router.dart

    구조 선택 이유

    Feature-First 방식:
    • ✅ 기능별로 코드가 모여있어 찾기 쉬움
    • ✅ 새 기능 추가 시 해당 폴더만 작업
    • ✅ 팀 협업 시 충돌 최소화
    • ✅ 테스트 작성이 용이
    대안으로 고려했던 Layer-First:
    lib/ ├── models/ # 모든 모델 ├── screens/ # 모든 화면 ├── services/ # 모든 서비스 └── widgets/ # 모든 위젯
    • ❌ 프로젝트가 커지면 파일 찾기 어려움
    • ❌ 기능 간 의존성 파악 힘듦

    폴더 생성

    # core 폴더 mkdir -p lib/core/constants mkdir -p lib/core/utils mkdir -p lib/core/widgets # features 폴더 mkdir -p lib/features/auth/models mkdir -p lib/features/auth/screens mkdir -p lib/features/auth/widgets mkdir -p lib/features/auth/services mkdir -p lib/features/bookmarks/models mkdir -p lib/features/bookmarks/screens mkdir -p lib/features/bookmarks/widgets mkdir -p lib/features/bookmarks/services # routes 폴더 mkdir -p lib/routes

    🔄 상태 관리: Riverpod 선택

    상태 관리란?

    앱의 데이터(상태)를 관리하고, 상태가 변경되면 UI를 자동으로 업데이트하는 방법입니다.

    왜 Riverpod인가?

    선택 이유:
    1. 타입 안전성: 컴파일 타임에 에러 감지
    1. Provider 개선판: Provider의 단점들을 보완
    1. BuildContext 불필요: 어디서든 상태 접근 가능
    1. 테스트 용이: Mock 작성이 쉬움
    1. 최신 트렌드: Flutter 커뮤니티 표준으로 자리잡는 중
    고려했던 대안들:
    패키지
    장점
    단점
    선택 여부
    Provider
    공식 추천, 쉬움
    BuildContext 필요, 런타임 에러 가능
    ❌
    Riverpod
    타입 안전, 강력함
    러닝커브 약간 높음
    ✅
    Bloc
    패턴 명확, 대규모 적합
    보일러플레이트 많음
    ❌
    GetX
    매우 쉬움
    공식 추천 아님, 과도한 기능
    ❌

    Riverpod 설치

    flutter pub add flutter_riverpod
    설치된 버전: flutter_riverpod ^3.0.3

    🧭 라우팅: go_router 선택

    왜 go_router인가?

    선택 이유:
    1. 선언적 라우팅: URL 기반 라우팅 (웹에 필수!)
    1. Deep Link 지원: 웹 URL로 직접 접근 가능
    1. 타입 안전성: 라우팅 오류 컴파일 타임 감지
    1. Flutter 팀 공식: 공식 추천 패키지
    기존 Navigator 방식의 문제:
    // ❌ 명령형, 타입 안전하지 않음 Navigator.push( context, MaterialPageRoute(builder: (context) => DetailScreen()), );
    go_router 방식:
    // ✅ 선언적, 타입 안전 context.go('/detail');

    설치

    flutter pub add go_router
    설치된 버전: go_router ^16.2.4

    🛠 유틸리티 패키지 설치

    필수 패키지들

    # URL 실행 (북마크 열기) flutter pub add url_launcher # 예쁜 로딩 애니메이션 flutter pub add flutter_spinkit # 간단한 토스트 메시지 flutter pub add fluttertoast

    각 패키지 역할

    패키지
    용도
    사용 예시
    url_launcher
    북마크 URL을 브라우저에서 열기
    북마크 클릭 시 실행
    flutter_spinkit
    로딩 인디케이터
    데이터 불러올 때 표시
    fluttertoast
    알림 메시지
    "북마크 저장 완료!"

    📦 main.dart 수정 (Riverpod 적용)

    변경 전

    void main() async { WidgetsFlutterBinding.ensureInitialized(); await Firebase.initializeApp( options: DefaultFirebaseOptions.currentPlatform, ); runApp(const MyApp()); }

    변경 후

    import 'package:flutter/material.dart'; import 'package:firebase_core/firebase_core.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'firebase_options.dart'; void main() async { WidgetsFlutterBinding.ensureInitialized(); await Firebase.initializeApp( options: DefaultFirebaseOptions.currentPlatform, ); // ProviderScope로 앱 감싸기 (Riverpod 필수!) runApp( const ProviderScope( child: MyApp(), ), ); } class MyApp extends StatelessWidget { const MyApp({super.key}); @override Widget build(BuildContext context) { return MaterialApp( title: 'Bookmark Manager', theme: ThemeData( colorScheme: ColorScheme.fromSeed(seedColor: Colors.blue), useMaterial3: true, ), home: const MyHomePage(title: 'Bookmark Manager'), ); } } class MyHomePage extends StatelessWidget { const MyHomePage({super.key, required this.title}); final String title; @override Widget build(BuildContext context) { return Scaffold( appBar: AppBar( backgroundColor: Theme.of(context).colorScheme.inversePrimary, title: Text(title), ), body: const Center( child: Column( mainAxisAlignment: MainAxisAlignment.center, children: <Widget>[ Text( 'Bookmark Manager with Firebase & Riverpod!', style: TextStyle(fontSize: 20), ), ], ), ), ); } }

    핵심 변경사항

    ProviderScope 추가:
    runApp( const ProviderScope( // ← 이 부분이 핵심! child: MyApp(), ), );
    • Riverpod을 사용하려면 반드시 ProviderScope로 앱을 감싸야 함
    • 이제 앱 어디서든 Provider에 접근 가능
    • 상태 관리의 시작점

    🎨 상수 파일 생성

    1. 색상 상수 (app_colors.dart)

    위치: lib/core/constants/app_colors.dart
    import 'package:flutter/material.dart'; class AppColors { // Primary Colors static const Color primary = Color(0xFF2196F3); static const Color secondary = Color(0xFF03A9F4); // Background Colors static const Color background = Color(0xFFF5F5F5); static const Color surface = Colors.white; // Text Colors static const Color textPrimary = Color(0xFF212121); static const Color textSecondary = Color(0xFF757575); // Status Colors static const Color error = Color(0xFFE57373); static const Color success = Color(0xFF81C784); static const Color warning = Color(0xFFFFB74D); // Border Colors static const Color border = Color(0xFFE0E0E0); }
    색상 선택 이유:
    • Material Design 3 기반: Google의 디자인 가이드라인 준수
    • 파란색 계열: 북마크/링크를 연상시키는 색상
    • 가독성: 적절한 대비로 접근성 확보
    Hex 코드 설명:
    • 0xFF는 불투명도 100%를 의미
    • 2196F3는 실제 색상 코드

    2. 문자열 상수 (app_strings.dart)

    위치: lib/core/constants/app_strings.dart
    class AppStrings { // App static const String appName = 'Bookmark Manager'; // Auth static const String login = '로그인'; static const String logout = '로그아웃'; static const String signup = '회원가입'; static const String email = '이메일'; static const String password = '비밀번호'; static const String confirmPassword = '비밀번호 확인'; static const String forgotPassword = '비밀번호를 잊으셨나요?'; // Bookmarks static const String bookmarks = '북마크'; static const String myBookmarks = '내 북마크'; static const String addBookmark = '북마크 추가'; static const String editBookmark = '북마크 수정'; static const String deleteBookmark = '북마크 삭제'; static const String url = 'URL'; static const String title = '제목'; static const String description = '설명'; static const String category = '카테고리'; static const String noBookmarks = '저장된 북마크가 없습니다'; // Actions static const String save = '저장'; static const String cancel = '취소'; static const String delete = '삭제'; static const String edit = '수정'; static const String search = '검색'; // Messages static const String loginSuccess = '로그인 성공!'; static const String loginFailed = '로그인 실패'; static const String signupSuccess = '회원가입 성공!'; static const String signupFailed = '회원가입 실패'; static const String bookmarkAdded = '북마크가 추가되었습니다'; static const String bookmarkUpdated = '북마크가 수정되었습니다'; static const String bookmarkDeleted = '북마크가 삭제되었습니다'; static const String invalidUrl = '유효하지 않은 URL입니다'; static const String invalidEmail = '유효하지 않은 이메일입니다'; static const String passwordTooShort = '비밀번호는 최소 6자 이상이어야 합니다'; static const String passwordsDoNotMatch = '비밀번호가 일치하지 않습니다'; static const String fieldsRequired = '모든 필드를 입력해주세요'; }
    문자열 상수화의 장점:
    1. 오타 방지: 컴파일 타임에 오류 확인
    1. 일관성: 같은 메시지를 여러 곳에서 사용 시 일관성 보장
    1. 다국어 준비: 나중에 i18n 적용 시 쉽게 변환 가능
    1. 유지보수: 문구 변경 시 한 곳만 수정
    사용 예시:
    // ❌ 하드코딩 Text('로그인'); // ✅ 상수 사용 Text(AppStrings.login);

    📊 데이터 모델 설계

    1. Bookmark 모델

    위치: lib/features/bookmarks/models/bookmark.dart
    import 'package:cloud_firestore/cloud_firestore.dart'; class Bookmark { final String id; final String userId; final String url; final String title; final String description; final String category; final DateTime createdAt; final DateTime updatedAt; Bookmark({ required this.id, required this.userId, required this.url, required this.title, this.description = '', this.category = 'General', required this.createdAt, required this.updatedAt, }); // Firestore에서 데이터를 가져올 때 factory Bookmark.fromFirestore(DocumentSnapshot doc) { Map<String, dynamic> data = doc.data() as Map<String, dynamic>; return Bookmark( id: doc.id, userId: data['userId'] ?? '', url: data['url'] ?? '', title: data['title'] ?? '', description: data['description'] ?? '', category: data['category'] ?? 'General', createdAt: (data['createdAt'] as Timestamp).toDate(), updatedAt: (data['updatedAt'] as Timestamp).toDate(), ); } // Firestore에 저장할 때 Map<String, dynamic> toFirestore() { return { 'userId': userId, 'url': url, 'title': title, 'description': description, 'category': category, 'createdAt': Timestamp.fromDate(createdAt), 'updatedAt': Timestamp.fromDate(updatedAt), }; } // 북마크 수정용 copyWith Bookmark copyWith({ String? id, String? userId, String? url, String? title, String? description, String? category, DateTime? createdAt, DateTime? updatedAt, }) { return Bookmark( id: id ?? this.id, userId: userId ?? this.userId, url: url ?? this.url, title: title ?? this.title, description: description ?? this.description, category: category ?? this.category, createdAt: createdAt ?? this.createdAt, updatedAt: updatedAt ?? this.updatedAt, ); } }

    모델 구조 설명

    필드 설계:
    • id: Firestore 문서 ID (고유값)
    • userId: 사용자 구분용 (북마크 소유자)
    • url: 북마크 URL (필수)
    • title: 북마크 제목
    • description: 설명 (선택)
    • category: 카테고리 (기본값: General)
    • createdAt: 생성 시간
    • updatedAt: 수정 시간
    주요 메서드:
    1. fromFirestore: Firestore → Dart 객체
      1. // Firestore에서 읽어올 때 Bookmark bookmark = Bookmark.fromFirestore(doc);
    1. toFirestore: Dart 객체 → Firestore
      1. // Firestore에 저장할 때 await firestore.collection('bookmarks').add(bookmark.toFirestore());
    1. copyWith: 불변 객체 수정
      1. // 제목만 변경 Bookmark updated = bookmark.copyWith(title: '새 제목');

    2. UserModel

    위치: lib/features/auth/models/user_model.dart
    class UserModel { final String uid; final String email; final String? displayName; UserModel({ required this.uid, required this.email, this.displayName, }); factory UserModel.fromFirebaseUser(dynamic user) { return UserModel( uid: user.uid, email: user.email ?? '', displayName: user.displayName, ); } }
    필드 설명:
    • uid: Firebase Authentication 사용자 ID
    • email: 이메일 주소
    • displayName: 표시 이름 (선택사항, nullable)

    ✅ 테스트 및 확인

    앱 실행

    flutter run -d chrome

    확인사항 체크리스트

    에러 없이 실행됨
    "Bookmark Manager with Firebase & Riverpod!" 텍스트 표시
    Hot Reload 정상 동작
    모든 파일 import 에러 없음

    📦 현재 프로젝트 상태

    설치된 패키지

    dependencies: 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

    프로젝트 구조

    lib/ ├── main.dart ✅ ├── firebase_options.dart ✅ │ ├── core/ │ ├── constants/ │ │ ├── app_colors.dart ✅ │ │ └── app_strings.dart ✅ │ ├── utils/ 📁 (준비됨) │ └── widgets/ 📁 (준비됨) │ ├── features/ │ ├── auth/ │ │ ├── models/ │ │ │ └── user_model.dart ✅ │ │ ├── screens/ 📁 (다음 단계) │ │ ├── widgets/ 📁 (다음 단계) │ │ └── services/ 📁 (다음 단계) │ │ │ └── bookmarks/ │ ├── models/ │ │ └── bookmark.dart ✅ │ ├── screens/ 📁 (다음 단계) │ ├── widgets/ 📁 (다음 단계) │ └── services/ 📁 (다음 단계) │ └── routes/ 📁 (다음 단계) └── app_router.dart

    💡 배운 점 & 고민했던 점

    1. 상태 관리 선택의 고민

    처음에는 Provider가 쉬워 보였지만, Riverpod을 선택한 이유:
    • 타입 안전성으로 런타임 에러 방지
    • BuildContext 없이 어디서든 접근 가능
    • 미래를 위한 투자 (러닝 커브는 있지만 결국 더 생산적)

    2. 폴더 구조의 중요성

    토이 프로젝트라고 대충 시작했다가 나중에 리팩토링하는 것보다, 처음부터 체계적으로 시작하는 게 훨씬 효율적이었습니다.

    3. 상수 관리

    처음엔 "이 정도는 하드코딩해도 되지 않을까?" 싶었지만, 상수 파일로 관리하니:
    • 오타로 인한 버그 방지
    • IDE 자동완성으로 개발 속도 향상
    • 유지보수 편의성 대폭 증가

    Share article
    Contents
    📌 시리즈 목차🎯 이번 글에서 다룰 내용📂 프로젝트 폴더 구조 설계🔄 상태 관리: Riverpod 선택🧭 라우팅: go_router 선택🛠 유틸리티 패키지 설치📦 main.dart 수정 (Riverpod 적용)🎨 상수 파일 생성📊 데이터 모델 설계✅ 테스트 및 확인📦 현재 프로젝트 상태💡 배운 점 & 고민했던 점

    Gyeongwon's blog

    RSS·Powered by Inblog