
📋 전체 구성 단계 개요
- 개발 환경 설정 - 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 상태 관리
 - 서비스 레이어 분리
 - 모델 기반 데이터 구조
 
✅ 비용 효율적인 설계
- 무료 한도 내 최적화
 - 효율적인 쿼리 구조
 - 캐싱 전략 적용
 
									
									
									
									
댓글