📋 전체 구성 단계 개요
- 개발 환경 설정 - Flutter & Firebase CLI 설치
- Firebase 프로젝트 설정 - 콘솔에서 앱 등록 및 구성
- Flutter 프로젝트 생성 - 새 프로젝트 생성 및 기본 설정
- Firebase SDK 통합 - Flutter 앱에 Firebase 연결
- Firestore 개념 이해 - NoSQL 문서형 데이터베이스 구조
- Firebase Authentication 연동 - 사용자 인증 시스템
- Firestore 서비스 구현 - CRUD 작업 및 실시간 동기화
- 보안 규칙 설정 - Firestore 보안 구성
- UI 화면 구현 - 완전한 앱 인터페이스
- 테스트 및 배포 - 앱 빌드 및 배포
- 요금제 및 비용 안내 - 무료/유료 범위 이해
🛠️ 1단계: 개발 환경 설정
1-1. Flutter 설치 확인
flutter doctor
1-2. Firebase CLI 설치
npm install -g firebase-tools
1-3. FlutterFire CLI 설치 (필수)
dart pub global activate flutterfire_cli
1-4. Firebase 도구 검증
firebase --version
flutterfire --version
🔥 2단계: Firebase 프로젝트 설정
2-1. Firebase 로그인
firebase login
2-2. Firebase Console에서 새 프로젝트 생성
- Firebase Console 접속
- "프로젝트 추가" 클릭
- 프로젝트 이름:
my-flutter-app
(실제 사용할 이름으로 변경) - Google Analytics 설정 (선택사항)
2-3. Flutter 앱 등록
Firebase Console에서
- 프로젝트 선택
- "앱 추가" → "Flutter" 선택
- 앱 등록 이름:
My Flutter App
- Android 패키지명:
com.example.myflutterapp
- iOS 번들 ID:
com.example.myFlutterApp
2-4. Firebase 서비스 활성화
Authentication 설정
- Authentication → "시작하기"
- Sign-in method 탭
- 이메일/비밀번호 활성화
- Google 로그인 활성화 (선택사항)
Firestore Database 설정
- Firestore Database → "데이터베이스 만들기"
- 보안 규칙: "테스트 모드에서 시작" (개발용)
- 위치: asia-northeast3 (서울) 선택
Storage 설정
- Storage → "시작하기"
- 보안 규칙: "테스트 모드에서 시작"
- 위치: asia-northeast3 (서울) 선택
📱 3단계: Flutter 프로젝트 생성
3-1. 새 Flutter 프로젝트 생성
flutter create my_flutter_app
cd my_flutter_app
3-2. 필요한 의존성 추가
pubspec.yaml
파일 수정
name: my_flutter_app
description: A Flutter app with Firebase Firestore
publish_to: 'none'
version: 1.0.0+1
environment:
sdk: '>=3.0.0 <4.0.0'
dependencies:
flutter:
sdk: flutter
# Firebase 핵심 패키지
firebase_core: ^2.24.2
# Firebase 서비스 패키지들
firebase_auth: ^4.15.3
cloud_firestore: ^4.13.6
firebase_storage: ^11.5.6
# 상태 관리
provider: ^6.1.1
# UI 및 유틸리티
google_fonts: ^6.1.0
fluttertoast: ^8.2.4
image_picker: ^1.0.4
cached_network_image: ^3.3.0
# 기타
intl: ^0.19.0
dev_dependencies:
flutter_test:
sdk: flutter
flutter_lints: ^3.0.0
flutter:
uses-material-design: true
3-3. 패키지 설치
flutter pub get
🔗 4단계: Firebase SDK 통합
4-1. FlutterFire 자동 구성
프로젝트 루트에서 실행
flutterfire configure
구성 과정
- Firebase 프로젝트 선택:
my-flutter-app
- 플랫폼 선택: Android, iOS (필요한 것 선택)
- Android 패키지명:
com.example.myflutterapp
- iOS 번들 ID:
com.example.myFlutterApp
4-2. Firebase 초기화 코드
lib/main.dart
수정
import 'package:flutter/material.dart';
import 'package:firebase_core/firebase_core.dart';
import 'package:provider/provider.dart';
import 'firebase_options.dart';
import 'services/auth_service.dart';
import 'services/firestore_service.dart';
import 'screens/auth_wrapper.dart';
void main() async {
WidgetsFlutterBinding.ensureInitialized();
// Firebase 초기화
await Firebase.initializeApp(
options: DefaultFirebaseOptions.currentPlatform,
);
runApp(MyApp());
}
class MyApp extends StatelessWidget {
@override
Widget build(BuildContext context) {
return MultiProvider(
providers: [
ChangeNotifierProvider(create: (_) => AuthService()),
Provider(create: (_) => FirestoreService()),
],
child: MaterialApp(
title: 'Flutter Firestore App',
theme: ThemeData(
primarySwatch: Colors.blue,
useMaterial3: true,
fontFamily: 'GoogleSans',
),
home: AuthWrapper(),
debugShowCheckedModeBanner: false,
),
);
}
}
🔎 5단계: Firestore 개념 완전 이해
Firestore란?
Firestore는 Google Firebase 플랫폼의 핵심 서비스 중 하나로, NoSQL 문서형 데이터베이스입니다. Firebase의 2세대 데이터베이스로서 이전 Realtime Database의 발전된 형태입니다.
Firestore의 구조적 특징
문서 중심(Document-based) 구조
📁 Collection (컬렉션)
📄 Document (문서)
🔑 Field: Value (필드: 값)
📁 Sub-collection (하위 컬렉션)
📄 Sub-document (하위 문서)
실제 예시
📁 users (컬렉션)
📄 user123 (문서)
name: "홍길동"
email: "hong@example.com"
createdAt: 2024-01-15
📁 posts (하위 컬렉션)
📄 post001 (하위 문서)
title: "첫 게시글"
content: "안녕하세요"
🏃♂️ 실시간 동기화 특징
- Real-time listener를 통해 데이터 변경사항을 실시간 푸시
- 여러 클라이언트 간 즉시 동기화
- 채팅, 협업 도구, 게임 등에 최적화
🌐 서버리스 클라우드 서비스
- 자동 스케일링 (트래픽에 따라 자동 확장/축소)
- 서버 관리 불필요
- Firebase SDK로 쉬운 접근
💡 Firestore 사용 사례
분야 | 사용 예시 |
---|---|
채팅 서비스 | 실시간 채팅 기록 저장 및 동기화 |
쇼핑몰 | 상품 정보, 사용자 장바구니, 주문 기록 관리 |
IoT 시스템 | 센서 데이터 수집 및 실시간 상태 모니터링 |
협업툴 | 실시간 문서 편집, 주석 작성 등 |
모바일 앱 | 사용자 상태, 설정 저장, 알림 연동 등 |
게임 | 실시간 플레이어 상태, 점수, 랭킹 시스템 |
🧰 Firebase 생태계와의 통합
[Firebase Project]
├── 🔐 Firebase Authentication (사용자 인증)
├── 📊 Firestore Database (문서형 DB)
├── ⚡ Firebase Cloud Functions (서버리스 함수)
├── 🌐 Firebase Hosting (웹 호스팅)
├── 📁 Firebase Storage (파일 저장소)
└── 📈 Firebase Analytics (앱 분석)
통합의 장점
- 단일 프로젝트에서 모든 백엔드 서비스 관리
- 통합된 보안 규칙 및 권한 관리
- 일관된 개발자 경험과 API
🔐 6단계: Firebase Authentication 서비스
6-1. Authentication 서비스 구현
lib/services/auth_service.dart
생성
import 'package:flutter/material.dart';
import 'package:firebase_auth/firebase_auth.dart';
import 'package:fluttertoast/fluttertoast.dart';
class AuthService extends ChangeNotifier {
final FirebaseAuth _auth = FirebaseAuth.instance;
User? _user;
// Getters
User? get user => _user;
bool get isAuthenticated => _user != null;
String? get userId => _user?.uid;
String? get userEmail => _user?.email;
AuthService() {
// 인증 상태 변화 감지
_auth.authStateChanges().listen((User? user) {
_user = user;
notifyListeners();
});
}
// 이메일/비밀번호 회원가입
Future<bool> signUpWithEmail(String email, String password, String displayName) async {
try {
UserCredential result = await _auth.createUserWithEmailAndPassword(
email: email,
password: password,
);
// 사용자 프로필 업데이트
await result.user?.updateDisplayName(displayName);
_user = result.user;
Fluttertoast.showToast(
msg: "회원가입 완료!",
backgroundColor: Colors.green,
textColor: Colors.white,
);
return true;
} on FirebaseAuthException catch (e) {
String message = _getErrorMessage(e.code);
Fluttertoast.showToast(
msg: message,
backgroundColor: Colors.red,
textColor: Colors.white,
);
return false;
} catch (e) {
Fluttertoast.showToast(
msg: "회원가입 실패: 알 수 없는 오류",
backgroundColor: Colors.red,
textColor: Colors.white,
);
return false;
}
}
// 이메일/비밀번호 로그인
Future<bool> signInWithEmail(String email, String password) async {
try {
UserCredential result = await _auth.signInWithEmailAndPassword(
email: email,
password: password,
);
_user = result.user;
Fluttertoast.showToast(
msg: "로그인 성공!",
backgroundColor: Colors.green,
textColor: Colors.white,
);
return true;
} on FirebaseAuthException catch (e) {
String message = _getErrorMessage(e.code);
Fluttertoast.showToast(
msg: message,
backgroundColor: Colors.red,
textColor: Colors.white,
);
return false;
} catch (e) {
Fluttertoast.showToast(
msg: "로그인 실패: 알 수 없는 오류",
backgroundColor: Colors.red,
textColor: Colors.white,
);
return false;
}
}
// 로그아웃
Future<void> signOut() async {
try {
await _auth.signOut();
_user = null;
Fluttertoast.showToast(
msg: "로그아웃 완료",
backgroundColor: Colors.blue,
textColor: Colors.white,
);
} catch (e) {
Fluttertoast.showToast(
msg: "로그아웃 실패",
backgroundColor: Colors.red,
textColor: Colors.white,
);
}
}
// 비밀번호 재설정 이메일 발송
Future<bool> sendPasswordResetEmail(String email) async {
try {
await _auth.sendPasswordResetEmail(email: email);
Fluttertoast.showToast(
msg: "비밀번호 재설정 이메일을 발송했습니다.",
backgroundColor: Colors.blue,
textColor: Colors.white,
);
return true;
} catch (e) {
Fluttertoast.showToast(
msg: "이메일 발송 실패",
backgroundColor: Colors.red,
textColor: Colors.white,
);
return false;
}
}
// Firebase Auth 에러 메시지 한국어 변환
String _getErrorMessage(String errorCode) {
switch (errorCode) {
case 'user-not-found':
return '존재하지 않는 사용자입니다.';
case 'wrong-password':
return '비밀번호가 틀렸습니다.';
case 'email-already-in-use':
return '이미 사용 중인 이메일입니다.';
case 'weak-password':
return '비밀번호가 너무 약합니다.';
case 'invalid-email':
return '유효하지 않은 이메일 형식입니다.';
case 'too-many-requests':
return '너무 많은 요청입니다. 잠시 후 다시 시도해주세요.';
default:
return '인증 오류가 발생했습니다.';
}
}
}
📊 7단계: Firestore 서비스 구현
7-1. 데이터 모델 정의
lib/models/post_model.dart
생성
import 'package:cloud_firestore/cloud_firestore.dart';
class PostModel {
final String? id;
final String title;
final String content;
final String authorId;
final String authorName;
final String authorEmail;
final DateTime createdAt;
final DateTime updatedAt;
final List<String> imageUrls;
final List<String> tags;
final int likeCount;
final List<String> likedBy;
PostModel({
this.id,
required this.title,
required this.content,
required this.authorId,
required this.authorName,
required this.authorEmail,
required this.createdAt,
required this.updatedAt,
this.imageUrls = const [],
this.tags = const [],
this.likeCount = 0,
this.likedBy = const [],
});
// Firestore에 저장할 Map 형태로 변환
Map<String, dynamic> toMap() {
return {
'title': title,
'content': content,
'authorId': authorId,
'authorName': authorName,
'authorEmail': authorEmail,
'createdAt': Timestamp.fromDate(createdAt),
'updatedAt': Timestamp.fromDate(updatedAt),
'imageUrls': imageUrls,
'tags': tags,
'likeCount': likeCount,
'likedBy': likedBy,
};
}
// Firestore 문서에서 PostModel 객체로 변환
factory PostModel.fromMap(Map<String, dynamic> map, String id) {
return PostModel(
id: id,
title: map['title'] ?? '',
content: map['content'] ?? '',
authorId: map['authorId'] ?? '',
authorName: map['authorName'] ?? '',
authorEmail: map['authorEmail'] ?? '',
createdAt: (map['createdAt'] as Timestamp?)?.toDate() ?? DateTime.now(),
updatedAt: (map['updatedAt'] as Timestamp?)?.toDate() ?? DateTime.now(),
imageUrls: List<String>.from(map['imageUrls'] ?? []),
tags: List<String>.from(map['tags'] ?? []),
likeCount: map['likeCount'] ?? 0,
likedBy: List<String>.from(map['likedBy'] ?? []),
);
}
// 게시글 복사 (수정 시 사용)
PostModel copyWith({
String? title,
String? content,
List<String>? imageUrls,
List<String>? tags,
int? likeCount,
List<String>? likedBy,
}) {
return PostModel(
id: id,
title: title ?? this.title,
content: content ?? this.content,
authorId: authorId,
authorName: authorName,
authorEmail: authorEmail,
createdAt: createdAt,
updatedAt: DateTime.now(),
imageUrls: imageUrls ?? this.imageUrls,
tags: tags ?? this.tags,
likeCount: likeCount ?? this.likeCount,
likedBy: likedBy ?? this.likedBy,
);
}
}
7-2. 사용자 모델 정의
lib/models/user_model.dart
생성
import 'package:cloud_firestore/cloud_firestore.dart';
class UserModel {
final String uid;
final String email;
final String displayName;
final String? photoURL;
final DateTime createdAt;
final DateTime lastLoginAt;
final List<String> followedUsers;
final List<String> followers;
final int postCount;
UserModel({
required this.uid,
required this.email,
required this.displayName,
this.photoURL,
required this.createdAt,
required this.lastLoginAt,
this.followedUsers = const [],
this.followers = const [],
this.postCount = 0,
});
Map<String, dynamic> toMap() {
return {
'uid': uid,
'email': email,
'displayName': displayName,
'photoURL': photoURL,
'createdAt': Timestamp.fromDate(createdAt),
'lastLoginAt': Timestamp.fromDate(lastLoginAt),
'followedUsers': followedUsers,
'followers': followers,
'postCount': postCount,
};
}
factory UserModel.fromMap(Map<String, dynamic> map) {
return UserModel(
uid: map['uid'] ?? '',
email: map['email'] ?? '',
displayName: map['displayName'] ?? '',
photoURL: map['photoURL'],
createdAt: (map['createdAt'] as Timestamp?)?.toDate() ?? DateTime.now(),
lastLoginAt: (map['lastLoginAt'] as Timestamp?)?.toDate() ?? DateTime.now(),
followedUsers: List<String>.from(map['followedUsers'] ?? []),
followers: List<String>.from(map['followers'] ?? []),
postCount: map['postCount'] ?? 0,
);
}
}
7-3. Firestore 서비스 구현
lib/services/firestore_service.dart
생성
import 'package:cloud_firestore/cloud_firestore.dart';
import '../models/post_model.dart';
import '../models/user_model.dart';
class FirestoreService {
final FirebaseFirestore _db = FirebaseFirestore.instance;
// 컬렉션 참조
CollectionReference get postsCollection => _db.collection('posts');
CollectionReference get usersCollection => _db.collection('users');
// ==================== 사용자 관련 기능 ====================
// 사용자 정보 생성/업데이트
Future<bool> createOrUpdateUser(UserModel user) async {
try {
await usersCollection.doc(user.uid).set(user.toMap(), SetOptions(merge: true));
return true;
} catch (e) {
print('사용자 정보 저장 실패: $e');
return false;
}
}
// 사용자 정보 조회
Future<UserModel?> getUser(String uid) async {
try {
DocumentSnapshot doc = await usersCollection.doc(uid).get();
if (doc.exists) {
return UserModel.fromMap(doc.data() as Map<String, dynamic>);
}
return null;
} catch (e) {
print('사용자 정보 조회 실패: $e');
return null;
}
}
// 사용자 정보 실시간 스트림
Stream<UserModel?> getUserStream(String uid) {
return usersCollection.doc(uid).snapshots().map((doc) {
if (doc.exists) {
return UserModel.fromMap(doc.data() as Map<String, dynamic>);
}
return null;
});
}
// ==================== 게시글 관련 기능 ====================
// 게시글 추가
Future<String?> addPost(PostModel post) async {
try {
DocumentReference docRef = await postsCollection.add(post.toMap());
// 사용자의 게시글 카운트 증가
await _incrementUserPostCount(post.authorId);
return docRef.id;
} catch (e) {
print('게시글 추가 실패: $e');
return null;
}
}
// 게시글 실시간 스트림
Stream<List<PostModel>> getPostsStream({int limit = 20}) {
return postsCollection
.orderBy('createdAt', descending: true)
.limit(limit)
.snapshots()
.map((snapshot) {
return snapshot.docs.map((doc) {
Map<String, dynamic> data = doc.data() as Map<String, dynamic>;
return PostModel.fromMap(data, doc.id);
}).toList();
});
}
// 특정 사용자의 게시글 조회
Stream<List<PostModel>> getUserPostsStream(String userId) {
return postsCollection
.where('authorId', isEqualTo: userId)
.orderBy('createdAt', descending: true)
.snapshots()
.map((snapshot) {
return snapshot.docs.map((doc) {
Map<String, dynamic> data = doc.data() as Map<String, dynamic>;
return PostModel.fromMap(data, doc.id);
}).toList();
});
}
// 게시글 수정
Future<bool> updatePost(String postId, PostModel updatedPost) async {
try {
await postsCollection.doc(postId).update(updatedPost.toMap());
return true;
} catch (e) {
print('게시글 수정 실패: $e');
return false;
}
}
// 게시글 삭제
Future<bool> deletePost(String postId, String authorId) async {
try {
await postsCollection.doc(postId).delete();
// 사용자의 게시글 카운트 감소
await _decrementUserPostCount(authorId);
return true;
} catch (e) {
print('게시글 삭제 실패: $e');
return false;
}
}
// 게시글 좋아요 토글
Future<bool> togglePostLike(String postId, String userId) async {
try {
DocumentReference postRef = postsCollection.doc(postId);
return await _db.runTransaction((transaction) async {
DocumentSnapshot postDoc = await transaction.get(postRef);
if (!postDoc.exists) {
throw Exception('게시글이 존재하지 않습니다.');
}
Map<String, dynamic> data = postDoc.data() as Map<String, dynamic>;
List<String> likedBy = List<String>.from(data['likedBy'] ?? []);
int likeCount = data['likeCount'] ?? 0;
if (likedBy.contains(userId)) {
// 좋아요 취소
likedBy.remove(userId);
likeCount = (likeCount - 1).clamp(0, double.infinity).toInt();
} else {
// 좋아요 추가
likedBy.add(userId);
likeCount++;
}
transaction.update(postRef, {
'likedBy': likedBy,
'likeCount': likeCount,
});
return true;
});
} catch (e) {
print('좋아요 토글 실패: $e');
return false;
}
}
// ==================== 내부 헬퍼 메서드 ====================
// 사용자 게시글 카운트 증가
Future<void> _incrementUserPostCount(String userId) async {
try {
await usersCollection.doc(userId).update({
'postCount': FieldValue.increment(1),
});
} catch (e) {
print('게시글 카운트 증가 실패: $e');
}
}
// 사용자 게시글 카운트 감소
Future<void> _decrementUserPostCount(String userId) async {
try {
await usersCollection.doc(userId).update({
'postCount': FieldValue.increment(-1),
});
} catch (e) {
print('게시글 카운트 감소 실패: $e');
}
}
}
🔐 8단계: Firebase 보안 규칙 설정
8-1. Firestore 보안 규칙
Firebase Console → Firestore Database → 규칙
rules_version = '2';
service cloud.firestore {
match /databases/{database}/documents {
// 사용자 컬렉션 규칙
match /users/{userId} {
// 인증된 사용자는 모든 사용자의 공개 정보 읽기 가능
allow read: if request.auth != null;
// 본인의 사용자 정보만 생성/수정 가능
allow create, update: if request.auth != null
&& request.auth.uid == userId
&& validateUserData(request.resource.data);
// 본인의 사용자 정보만 삭제 가능
allow delete: if request.auth != null
&& request.auth.uid == userId;
}
// 게시글 컬렉션 규칙
match /posts/{postId} {
// 인증된 사용자만 게시글 읽기 가능
allow read: if request.auth != null;
// 인증된 사용자만 게시글 생성 가능 (본인의 게시글로)
allow create: if request.auth != null
&& request.auth.uid == request.resource.data.authorId
&& validatePostData(request.resource.data);
// 작성자만 수정 가능
allow update: if request.auth != null
&& request.auth.uid == resource.data.authorId
&& validatePostUpdate(request.resource.data, resource.data);
// 작성자만 삭제 가능
allow delete: if request.auth != null
&& request.auth.uid == resource.data.authorId;
}
// 데이터 유효성 검사 함수들
function validateUserData(data) {
return data.keys().hasAll(['uid', 'email', 'displayName', 'createdAt']) &&
data.uid is string &&
data.email is string &&
data.displayName is string &&
data.createdAt is timestamp;
}
function validatePostData(data) {
return data.keys().hasAll(['title', 'content', 'authorId', 'authorName', 'authorEmail', 'createdAt', 'updatedAt']) &&
data.title is string &&
data.content is string &&
data.authorId is string &&
data.authorName is string &&
data.authorEmail is string &&
data.createdAt is timestamp &&
data.updatedAt is timestamp &&
data.title.size() > 0 &&
data.title.size() <= 100 &&
data.content.size() > 0 &&
data.content.size() <= 5000;
}
function validatePostUpdate(newData, oldData) {
// 수정 시 작성자 정보는 변경할 수 없음
return newData.authorId == oldData.authorId &&
newData.authorName == oldData.authorName &&
newData.authorEmail == oldData.authorEmail &&
newData.createdAt == oldData.createdAt &&
validatePostData(newData);
}
}
}
8-2. Storage 보안 규칙
Firebase Console → Storage → 규칙
rules_version = '2';
service firebase.storage {
match /b/{bucket}/o {
// 사용자별 프로필 이미지
match /profile_images/{userId}/{allPaths=**} {
// 본인의 프로필 이미지만 업로드/수정/삭제 가능
allow read: if request.auth != null;
allow write: if request.auth != null && request.auth.uid == userId;
}
// 게시글 이미지
match /post_images/{userId}/{allPaths=**} {
// 본인의 게시글 이미지만 업로드 가능
allow read: if request.auth != null;
allow write: if request.auth != null && request.auth.uid == userId;
}
// 파일 크기 및 형식 제한
match /{allPaths=**} {
allow write: if request.resource.size < 5 * 1024 * 1024 && // 5MB 제한
request.resource.contentType.matches('image/.*');
}
}
}
🖥️ 9단계: UI 화면 구현
9-1. 인증 래퍼
lib/screens/auth_wrapper.dart
생성
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../services/auth_service.dart';
import '../services/firestore_service.dart';
import '../models/user_model.dart';
import 'login_screen.dart';
import 'home_screen.dart';
class AuthWrapper extends StatelessWidget {
@override
Widget build(BuildContext context) {
return Consumer<AuthService>(
builder: (context, authService, child) {
if (authService.isAuthenticated) {
// 사용자 정보를 Firestore에 저장/업데이트
_ensureUserInFirestore(context, authService);
return HomeScreen();
} else {
return LoginScreen();
}
},
);
}
void _ensureUserInFirestore(BuildContext context, AuthService authService) async {
if (authService.user != null) {
final firestoreService = Provider.of<FirestoreService>(context, listen: false);
final user = authService.user!;
final userModel = UserModel(
uid: user.uid,
email: user.email!,
displayName: user.displayName ?? '사용자',
photoURL: user.photoURL,
createdAt: DateTime.now(),
lastLoginAt: DateTime.now(),
);
await firestoreService.createOrUpdateUser(userModel);
}
}
}
9-2. 로그인 화면
lib/screens/login_screen.dart
생성
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../services/auth_service.dart';
class LoginScreen extends StatefulWidget {
@override
_LoginScreenState createState() => _LoginScreenState();
}
class _LoginScreenState extends State<LoginScreen> with TickerProviderStateMixin {
final _formKey = GlobalKey<FormState>();
final _emailController = TextEditingController();
final _passwordController = TextEditingController();
final _nameController = TextEditingController();
bool _isLogin = true;
bool _isLoading = false;
bool _obscurePassword = true;
late AnimationController _animationController;
late Animation<double> _fadeAnimation;
@override
void initState() {
super.initState();
_animationController = AnimationController(
duration: Duration(milliseconds: 1500),
vsync: this,
);
_fadeAnimation = Tween<double>(
begin: 0.0,
end: 1.0,
).animate(CurvedAnimation(
parent: _animationController,
curve: Curves.easeInOut,
));
_animationController.forward();
}
@override
void dispose() {
_animationController.dispose();
_emailController.dispose();
_passwordController.dispose();
_nameController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return Scaffold(
backgroundColor: Colors.grey[50],
body: SafeArea(
child: FadeTransition(
opacity: _fadeAnimation,
child: SingleChildScrollView(
padding: EdgeInsets.all(20.0),
child: Column(
children: [
SizedBox(height: 60),
_buildHeader(),
SizedBox(height: 40),
_buildForm(),
SizedBox(height: 20),
_buildSubmitButton(),
SizedBox(height: 20),
_buildToggleButton(),
if (!_isLogin) ...[
SizedBox(height: 20),
_buildForgotPasswordButton(),
],
],
),
),
),
),
);
}
Widget _buildHeader() {
return Column(
children: [
Container(
width: 80,
height: 80,
decoration: BoxDecoration(
color: Colors.blue,
borderRadius: BorderRadius.circular(20),
boxShadow: [
BoxShadow(
color: Colors.blue.withOpacity(0.3),
spreadRadius: 0,
blurRadius: 20,
offset: Offset(0, 10),
),
],
),
child: Icon(
Icons.flutter_dash,
color: Colors.white,
size: 40,
),
),
SizedBox(height: 20),
Text(
'Flutter Firestore App',
style: TextStyle(
fontSize: 28,
fontWeight: FontWeight.bold,
color: Colors.grey[800],
),
),
SizedBox(height: 8),
Text(
_isLogin ? '로그인하여 시작하세요' : '새 계정을 만들어보세요',
style: TextStyle(
fontSize: 16,
color: Colors.grey[600],
),
),
],
);
}
Widget _buildForm() {
return Form(
key: _formKey,
child: Column(
children: [
if (!_isLogin) ...[
_buildTextField(
controller: _nameController,
label: '이름',
icon: Icons.person,
validator: (value) {
if (value == null || value.trim().isEmpty) {
return '이름을 입력하세요';
}
if (value.trim().length < 2) {
return '이름은 2자 이상이어야 합니다';
}
return null;
},
),
SizedBox(height: 16),
],
_buildTextField(
controller: _emailController,
label: '이메일',
icon: Icons.email,
keyboardType: TextInputType.emailAddress,
validator: (value) {
if (value == null || value.isEmpty) {
return '이메일을 입력하세요';
}
if (!RegExp(r'^[\w-\.]+@([\w-]+\.)+[\w-]{2,4}$').hasMatch(value)) {
return '올바른 이메일 형식을 입력하세요';
}
return null;
},
),
SizedBox(height: 16),
_buildTextField(
controller: _passwordController,
label: '비밀번호',
icon: Icons.lock,
obscureText: _obscurePassword,
suffixIcon: IconButton(
icon: Icon(_obscurePassword ? Icons.visibility : Icons.visibility_off),
onPressed: () {
setState(() {
_obscurePassword = !_obscurePassword;
});
},
),
validator: (value) {
if (value == null || value.length < 6) {
return '비밀번호는 6자 이상이어야 합니다';
}
return null;
},
),
],
),
);
}
Widget _buildTextField({
required TextEditingController controller,
required String label,
required IconData icon,
TextInputType? keyboardType,
bool obscureText = false,
Widget? suffixIcon,
String? Function(String?)? validator,
}) {
return TextFormField(
controller: controller,
keyboardType: keyboardType,
obscureText: obscureText,
validator: validator,
decoration: InputDecoration(
labelText: label,
prefixIcon: Icon(icon, color: Colors.blue),
suffixIcon: suffixIcon,
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
borderSide: BorderSide(color: Colors.grey[300]!),
),
focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
borderSide: BorderSide(color: Colors.blue, width: 2),
),
filled: true,
fillColor: Colors.white,
),
);
}
Widget _buildSubmitButton() {
return SizedBox(
width: double.infinity,
height: 50,
child: ElevatedButton(
onPressed: _isLoading ? null : _submitForm,
style: ElevatedButton.styleFrom(
backgroundColor: Colors.blue,
foregroundColor: Colors.white,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
elevation: 2,
),
child: _isLoading
? SizedBox(
width: 20,
height: 20,
child: CircularProgressIndicator(
strokeWidth: 2,
valueColor: AlwaysStoppedAnimation<Color>(Colors.white),
),
)
: Text(
_isLogin ? '로그인' : '회원가입',
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.bold,
),
),
),
);
}
Widget _buildToggleButton() {
return TextButton(
onPressed: _isLoading ? null : () {
setState(() {
_isLogin = !_isLogin;
_formKey.currentState?.reset();
});
},
child: RichText(
text: TextSpan(
style: TextStyle(color: Colors.grey[600]),
children: [
TextSpan(text: _isLogin ? '계정이 없으신가요? ' : '이미 계정이 있으신가요? '),
TextSpan(
text: _isLogin ? '회원가입' : '로그인',
style: TextStyle(
color: Colors.blue,
fontWeight: FontWeight.bold,
),
),
],
),
),
);
}
Widget _buildForgotPasswordButton() {
return TextButton(
onPressed: _isLoading ? null : _showForgotPasswordDialog,
child: Text(
'비밀번호를 잊으셨나요?',
style: TextStyle(color: Colors.blue),
),
);
}
void _submitForm() async {
if (_formKey.currentState!.validate()) {
setState(() {
_isLoading = true;
});
final authService = Provider.of<AuthService>(context, listen: false);
bool success;
if (_isLogin) {
success = await authService.signInWithEmail(
_emailController.text.trim(),
_passwordController.text,
);
} else {
success = await authService.signUpWithEmail(
_emailController.text.trim(),
_passwordController.text,
_nameController.text.trim(),
);
}
setState(() {
_isLoading = false;
});
if (!success) {
// 에러는 AuthService에서 토스트로 표시됨
}
}
}
void _showForgotPasswordDialog() {
final emailController = TextEditingController();
showDialog(
context: context,
builder: (context) => AlertDialog(
title: Text('비밀번호 재설정'),
content: Column(
mainAxisSize: MainAxisSize.min,
children: [
Text('등록하신 이메일 주소를 입력하시면\n비밀번호 재설정 링크를 보내드립니다.'),
SizedBox(height: 16),
TextField(
controller: emailController,
decoration: InputDecoration(
labelText: '이메일',
border: OutlineInputBorder(),
),
keyboardType: TextInputType.emailAddress,
),
],
),
actions: [
TextButton(
onPressed: () => Navigator.pop(context),
child: Text('취소'),
),
TextButton(
onPressed: () async {
if (emailController.text.isNotEmpty) {
final authService = Provider.of<AuthService>(context, listen: false);
await authService.sendPasswordResetEmail(emailController.text.trim());
Navigator.pop(context);
}
},
child: Text('전송'),
),
],
),
);
}
}
9-3. 홈 화면과 기타 UI 컴포넌트들
홈 화면, 게시글 카드, 게시글 작성 화면, 프로필 화면 등의 구현은 앞서 제공한 코드와 동일합니다.
🧪 10단계: 테스트 및 배포
10-1. 앱 테스트
# 개발 모드로 실행
flutter run
# 디버그 정보 확인
flutter run --verbose
10-2. 일반적인 문제 해결
Gradle 빌드 오류
android/app/build.gradle
수정
android {
compileSdkVersion 34
defaultConfig {
minSdkVersion 21 // Firebase 최소 요구사항
targetSdkVersion 34
multiDexEnabled true
}
}
인터넷 권한 추가
android/app/src/main/AndroidManifest.xml
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
10-3. 앱 빌드
# Android APK 빌드
flutter build apk --release
# Android App Bundle (Play Store용)
flutter build appbundle --release
# iOS 빌드 (macOS에서만)
flutter build ios --release
💰 11단계: Firebase 요금제 및 비용 안내
💸 Firebase 요금제 개요
구분 | Spark Plan (무료) | Blaze Plan (유료, 종량제) |
---|---|---|
월 요금 | 무료 | 사용량 기준 과금 |
Firebase Hosting 연동 | ✅ 가능 | ✅ 가능 |
Firebase Authentication 연동 | ✅ 가능 | ✅ 가능 |
Cloud Functions 연동 | ❌ 불가 | ✅ 가능 |
외부 API 호출 | ❌ 불가 | ✅ 가능 |
Firestore 외부 네트워크 접근 | ❌ 불가 | ✅ 가능 |
🎁 Firebase Authentication 무료 한도
서비스 | 무료 한도 (월 기준) | 유료 요금 |
---|---|---|
사용자 인증 | 무제한 | 무료 |
이메일/비밀번호 | 무제한 | 무료 |
소셜 로그인 | 무제한 | 무료 |
전화번호 인증 | 무제한 (SMS 비용 별도) | SMS당 과금 |
🔹 Firebase Authentication은 거의 모든 기능이 무료입니다.
🔹 전화번호 인증만 SMS 발송 비용이 발생합니다.
🗃️ Firestore 무료 한도 (Spark Plan)
항목 | 무료 한도 (월 기준) | Blaze 요금 (2025년 기준) |
---|---|---|
문서 읽기 | 50,000 회 | $0.06 / 100,000회 |
문서 쓰기 | 20,000 회 | $0.18 / 100,000회 |
문서 삭제 | 20,000 회 | $0.02 / 100,000회 |
스토리지 용량 | 1GB | $0.18 / GB/월 |
네트워크 전송량 (출력) | 1GB | $0.12 ~ $0.23 / GB |
🔹 실시간 동기화 및 오프라인 지원도 무료로 제공
🔹 복합 쿼리, 트랜잭션 등 고급 기능도 무료 한도 내에서 사용 가능
📁 Firebase Storage 무료 한도
항목 | 무료 한도 (월 기준) | Blaze 요금 |
---|---|---|
저장 공간 | 5GB | $0.026 / GB/월 |
다운로드 | 1GB/일 | $0.12 / GB |
업로드 | 무제한 | 무료 |
작업 수 | 50,000 회 | $0.05 / 10,000회 |
⚡ Cloud Functions 요금 (Blaze Plan만 사용 가능)
항목 | 무료 한도 (월 기준) | 유료 요금 |
---|---|---|
호출 횟수 | 2,000,000 회 | $0.40 / 백만 회 |
컴퓨팅 시간 | 400,000 GB-초 | $0.0000025 / GB-초 |
네트워크 | 5GB | $0.12 / GB |
💡 실제 사용 예시별 비용 계산
📱 개인 학습/포트폴리오 앱
- 일일 사용자: 10명
- 월 게시글 읽기: 3,000회
- 월 게시글 작성: 100회
- 스토리지: 100MB
결과: 완전 무료 (Spark Plan 한도 내)
🏢 소규모 비즈니스 앱
- 일일 사용자: 500명
- 월 게시글 읽기: 150,000회
- 월 게시글 작성: 5,000회
- 스토리지: 2GB
예상 비용 (Blaze Plan)
- 읽기 초과분: (150,000 - 50,000) × $0.06/100,000 = $0.06
- 쓰기 초과분: 없음 (20,000 한도 내)
- 스토리지 초과분: (2GB - 1GB) × $0.18 = $0.18
- 월 총 비용: 약 $0.24 (약 320원)
🚀 중간 규모 서비스
- 일일 사용자: 5,000명
- 월 게시글 읽기: 1,500,000회
- 월 게시글 작성: 50,000회
- 스토리지: 10GB
예상 비용 (Blaze Plan)
- 읽기 비용: 1,500,000 × $0.06/100,000 = $0.90
- 쓰기 비용: 50,000 × $0.18/100,000 = $0.09
- 스토리지 비용: 10GB × $0.18 = $1.80
- 월 총 비용: 약 $2.79 (약 3,700원)
⚠️ 비용 관리 팁
1. 사용량 모니터링
// Firebase Console에서 설정 가능
// 예산 알림: $5 달성 시 이메일 알림
// 일일 사용량 제한 설정
2. 효율적인 쿼리 작성
// ❌ 비효율적: 모든 문서를 가져온 후 필터링
Stream<List<PostModel>> getAllPosts() {
return postsCollection.snapshots().map(...);
}
// ✅ 효율적: 필요한 만큼만 가져오기
Stream<List<PostModel>> getRecentPosts() {
return postsCollection
.orderBy('createdAt', descending: true)
.limit(20) // 20개만 가져오기
.snapshots().map(...);
}
3. 캐싱 활용
// 오프라인 캐시 활용으로 읽기 횟수 절약
FirebaseFirestore.instance.settings = const Settings(
persistenceEnabled: true,
cacheSizeBytes: Settings.CACHE_SIZE_UNLIMITED,
);
✅ 무료 사용이 가능한 범위
프로젝트 유형 | 무료 사용 가능 여부 | 비고 |
---|---|---|
개인 학습/포트폴리오 | ✅ 완전 무료 | Spark Plan으로 충분 |
소규모 팀 프로젝트 | ✅ 거의 무료 | 월 $1 이하 예상 |
스타트업 MVP | ⚠️ 저비용 | 월 $5-20 예상 |
중간 규모 서비스 | ❌ 유료 필수 | 월 $50+ 예상 |
🔗 비용 관리 도구
- Firebase Console 사용량 대시보드
- 실시간 사용량 모니터링
- 예산 알림 설정
- 일일/월간 리포트
- Google Cloud Console
- 더 세부적인 비용 분석
- 서비스별 비용 breakdown
- 비용 예측 도구
Firebase는 학습, 테스트, 소규모 프로젝트에는 무료로 충분히 사용할 수 있으며, 실제 서비스로 성장해도 매우 합리적인 비용으로 확장 가능합니다.
특히 Authentication과 Hosting은 거의 무료이고, Firestore도 효율적으로 사용하면 저비용으로 운영할 수 있습니다.
🎯 완성된 앱의 특징
✅ 완전한 사용자 인증 시스템
- 이메일/비밀번호 로그인 및 회원가입
- 비밀번호 재설정 기능
- 사용자 프로필 관리
✅ 실시간 Firestore 데이터베이스
- 게시글 CRUD 작업
- 실시간 데이터 동기화
- 좋아요 시스템
- 태그 기반 분류
✅ 고급 보안 기능
- Firebase 보안 규칙으로 데이터 보호
- 사용자별 권한 관리
- 데이터 유효성 검사
✅ 현대적인 UI/UX
- Material Design 3 적용
- 반응형 디자인
- 애니메이션 효과
✅ 확장 가능한 아키텍처
- Provider 상태 관리
- 서비스 레이어 분리
- 모델 기반 데이터 구조
✅ 비용 효율적인 설계
- 무료 한도 내 최적화
- 효율적인 쿼리 구조
- 캐싱 전략 적용
댓글