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

도경원's avatar
Oct 07, 2025
[Flutter + Firebase로 북마크 앱 만들기 #2] 프로젝트 구조 설계 및 Riverpod 적용

📌 시리즈 목차


🎯 이번 글에서 다룰 내용

  • 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