![[Flutter + Firebase로 북마크 앱 만들기 #2] 프로젝트 구조 설계 및 Riverpod 적용](https://image.inblog.dev?url=https%3A%2F%2Finblog.ai%2Fapi%2Fog%3Ftitle%3D%255BFlutter%2520%252B%2520Firebase%25EB%25A1%259C%2520%25EB%25B6%2581%25EB%25A7%2588%25ED%2581%25AC%2520%25EC%2595%25B1%2520%25EB%25A7%258C%25EB%2593%25A4%25EA%25B8%25B0%2520%25232%255D%2520%25ED%2594%2584%25EB%25A1%259C%25EC%25A0%259D%25ED%258A%25B8%2520%25EA%25B5%25AC%25EC%25A1%25B0%2520%25EC%2584%25A4%25EA%25B3%2584%2520%25EB%25B0%258F%2520Riverpod%2520%25EC%25A0%2581%25EC%259A%25A9%26logoUrl%3Dhttps%253A%252F%252Finblog.ai%252Finblog_logo.png%26blogTitle%3DGyeongwon%27s%2520blog&w=2048&q=75)
📌 시리즈 목차
- #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인가?
선택 이유:
- 타입 안전성: 컴파일 타임에 에러 감지
- Provider 개선판: Provider의 단점들을 보완
- BuildContext 불필요: 어디서든 상태 접근 가능
- 테스트 용이: Mock 작성이 쉬움
- 최신 트렌드: Flutter 커뮤니티 표준으로 자리잡는 중
고려했던 대안들:
패키지 | 장점 | 단점 | 선택 여부 |
Provider | 공식 추천, 쉬움 | BuildContext 필요, 런타임 에러 가능 | ❌ |
Riverpod | 타입 안전, 강력함 | 러닝커브 약간 높음 | ✅ |
Bloc | 패턴 명확, 대규모 적합 | 보일러플레이트 많음 | ❌ |
GetX | 매우 쉬움 | 공식 추천 아님, 과도한 기능 | ❌ |
Riverpod 설치
flutter pub add flutter_riverpod
설치된 버전:
flutter_riverpod ^3.0.3
🧭 라우팅: go_router 선택
왜 go_router인가?
선택 이유:
- 선언적 라우팅: URL 기반 라우팅 (웹에 필수!)
- Deep Link 지원: 웹 URL로 직접 접근 가능
- 타입 안전성: 라우팅 오류 컴파일 타임 감지
- 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 = '모든 필드를 입력해주세요';
}
문자열 상수화의 장점:
- 오타 방지: 컴파일 타임에 오류 확인
- 일관성: 같은 메시지를 여러 곳에서 사용 시 일관성 보장
- 다국어 준비: 나중에 i18n 적용 시 쉽게 변환 가능
- 유지보수: 문구 변경 시 한 곳만 수정
사용 예시:
// ❌ 하드코딩
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
: 수정 시간
주요 메서드:
- fromFirestore: Firestore → Dart 객체
// Firestore에서 읽어올 때
Bookmark bookmark = Bookmark.fromFirestore(doc);
- toFirestore: Dart 객체 → Firestore
// Firestore에 저장할 때
await firestore.collection('bookmarks').add(bookmark.toFirestore());
- copyWith: 불변 객체 수정
// 제목만 변경
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편에서 다룰 내용
- Firebase Authentication 연동
- 로그인 화면 구현
- 회원가입 화면 구현
- Riverpod Provider 작성
- 인증 상태 관리
- go_router 라우팅 설정
- 라우트 정의
- 인증 가드 구현
- Deep Link 설정
- 실제 동작하는 인증 기능 완성
Share article