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

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

📌 시리즈 목차

  • #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 자동완성으로 개발 속도 향상
  • 유지보수 편의성 대폭 증가

🎯 다음 단계 예고

#3편에서 다룰 내용

  1. Firebase Authentication 연동
      • 로그인 화면 구현
      • 회원가입 화면 구현
      • Riverpod Provider 작성
      • 인증 상태 관리
  1. go_router 라우팅 설정
      • 라우트 정의
      • 인증 가드 구현
      • Deep Link 설정
  1. 실제 동작하는 인증 기능 완성

 
Share article

Gyeongwon's blog