본문 바로가기
프로그램 (PHP,Python)

Flutter 앱 개발, Firebase 백엔드 붙이고 인증부터 Firestore 연동, 배포까지

by 날으는물고기 2025. 6. 8.

Flutter 앱 개발, Firebase 백엔드 붙이고 인증부터 Firestore 연동, 배포까지

728x90

📋 전체 구성 단계 개요

  1. 개발 환경 설정 - Flutter & Firebase CLI 설치
  2. Firebase 프로젝트 설정 - 콘솔에서 앱 등록 및 구성
  3. Flutter 프로젝트 생성 - 새 프로젝트 생성 및 기본 설정
  4. Firebase SDK 통합 - Flutter 앱에 Firebase 연결
  5. Firestore 개념 이해 - NoSQL 문서형 데이터베이스 구조
  6. Firebase Authentication 연동 - 사용자 인증 시스템
  7. Firestore 서비스 구현 - CRUD 작업 및 실시간 동기화
  8. 보안 규칙 설정 - Firestore 보안 구성
  9. UI 화면 구현 - 완전한 앱 인터페이스
  10. 테스트 및 배포 - 앱 빌드 및 배포
  11. 요금제 및 비용 안내 - 무료/유료 범위 이해

🛠️ 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에서 새 프로젝트 생성

  1. Firebase Console 접속
  2. "프로젝트 추가" 클릭
  3. 프로젝트 이름: my-flutter-app (실제 사용할 이름으로 변경)
  4. Google Analytics 설정 (선택사항)

2-3. Flutter 앱 등록

Firebase Console에서

  1. 프로젝트 선택
  2. "앱 추가" → "Flutter" 선택
  3. 앱 등록 이름: My Flutter App
  4. Android 패키지명: com.example.myflutterapp
  5. iOS 번들 ID: com.example.myFlutterApp

2-4. Firebase 서비스 활성화

Authentication 설정

  1. Authentication → "시작하기"
  2. Sign-in method 탭
  3. 이메일/비밀번호 활성화
  4. Google 로그인 활성화 (선택사항)

Firestore Database 설정

  1. Firestore Database → "데이터베이스 만들기"
  2. 보안 규칙: "테스트 모드에서 시작" (개발용)
  3. 위치: asia-northeast3 (서울) 선택

Storage 설정

  1. Storage → "시작하기"
  2. 보안 규칙: "테스트 모드에서 시작"
  3. 위치: 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

구성 과정

  1. Firebase 프로젝트 선택: my-flutter-app
  2. 플랫폼 선택: Android, iOS (필요한 것 선택)
  3. Android 패키지명: com.example.myflutterapp
  4. 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+ 예상

🔗 비용 관리 도구

  1. Firebase Console 사용량 대시보드
    • 실시간 사용량 모니터링
    • 예산 알림 설정
    • 일일/월간 리포트
  2. Google Cloud Console
    • 더 세부적인 비용 분석
    • 서비스별 비용 breakdown
    • 비용 예측 도구

Firebase는 학습, 테스트, 소규모 프로젝트에는 무료로 충분히 사용할 수 있으며, 실제 서비스로 성장해도 매우 합리적인 비용으로 확장 가능합니다.

특히 Authentication과 Hosting은 거의 무료이고, Firestore도 효율적으로 사용하면 저비용으로 운영할 수 있습니다.

🎯 완성된 앱의 특징

완전한 사용자 인증 시스템

  • 이메일/비밀번호 로그인 및 회원가입
  • 비밀번호 재설정 기능
  • 사용자 프로필 관리

실시간 Firestore 데이터베이스

  • 게시글 CRUD 작업
  • 실시간 데이터 동기화
  • 좋아요 시스템
  • 태그 기반 분류

고급 보안 기능

  • Firebase 보안 규칙으로 데이터 보호
  • 사용자별 권한 관리
  • 데이터 유효성 검사

현대적인 UI/UX

  • Material Design 3 적용
  • 반응형 디자인
  • 애니메이션 효과

확장 가능한 아키텍처

  • Provider 상태 관리
  • 서비스 레이어 분리
  • 모델 기반 데이터 구조

비용 효율적인 설계

  • 무료 한도 내 최적화
  • 효율적인 쿼리 구조
  • 캐싱 전략 적용

📚 추가 학습 자료

728x90
그리드형(광고전용)

댓글