앱에 서버가 생기는 순간
지금까지 우리는 Flutter 앱의 화면을 만들고, 상태를 관리하고, REST API와 로컬 저장소를 다루었다. 이제 앱은 꽤 그럴듯해졌다. 하지만 실제 서비스가 되려면 아직 한 가지가 더 필요하다. 사용자가 로그인해야 하고, 사용자가 만든 데이터가 기기 밖에도 저장되어야 하며, 이미지 같은 파일도 안전하게 보관되어야 한다.
전통적인 방식이라면 여기서 백엔드 서버를 직접 만들어야 한다. 회원가입 API를 만들고, 비밀번호를 안전하게 저장하고, 데이터베이스를 설계하고, 파일 업로드 서버를 붙이고, 인증 토큰을 검증하고, 배포 서버까지 관리해야 한다. 앱 하나 만들려고 시작했는데 어느새 서버 개발, 인프라, 보안 운영까지 한꺼번에 떠안게 되는 셈이다.
Firebase는 이 지점에서 빛난다. Firebase는 인증, 데이터베이스, 파일 저장소, 푸시 알림, 분석, 크래시 리포팅 같은 앱 백엔드 기능을 관리형 서비스로 제공한다. Flutter에서는 FlutterFire라는 공식 플러그인 모음을 통해 Firebase 서비스를 사용할 수 있다.1 쉽게 말해, 우리가 서버를 직접 세우지 않아도 로그인, 데이터 저장, 이미지 업로드가 되는 앱을 빠르게 만들 수 있다.
물론 Firebase가 마법 지팡이는 아니다. 빠르게 시작할 수 있는 만큼 보안 규칙, 데이터 모델, 예외 처리, 요금제와 사용량 제한을 반드시 함께 생각해야 한다. 이번 장에서는 개인 지식 노트 앱을 예로 들어 Firebase Auth, Cloud Firestore, Cloud Storage를 연결하는 기본 흐름을 익힌다. 목표는 단순하다. "로그인한 사용자가 자기 노트와 이미지만 다룰 수 있는 앱"을 만드는 것이다.

Firebase 프로젝트와 Flutter 앱 연결하기
Firebase를 Flutter 앱에 붙이는 표준 흐름은 이제 꽤 깔끔해졌다. 예전에는 Android용 google-services.json, iOS용 GoogleService-Info.plist를 직접 내려받아 프로젝트 폴더에 넣고 Gradle 설정도 손으로 만지는 경우가 많았다. 지금은 Firebase CLI와 FlutterFire CLI를 사용하면 대부분의 플랫폼별 설정을 자동화할 수 있다.
먼저 Firebase Console에서 프로젝트를 만든다. 프로젝트 이름은 앱 이름과 비슷하게 지으면 관리하기 쉽다. 예를 들어 이 책의 최종 프로젝트라면 personal-knowledge-notes 같은 이름을 사용할 수 있다. Google Analytics는 당장 분석을 다루지 않는다면 꺼도 되지만, 실제 출시 앱이라면 나중에 사용자 행동 분석에 도움이 되므로 켜는 것도 좋다.
그다음 로컬 개발 환경에서 Firebase CLI에 로그인한다.
firebase loginFlutterFire CLI도 설치한다.
dart pub global activate flutterfire_cli이제 Flutter 프로젝트 루트에서 다음 명령을 실행한다.
flutterfire configure이 명령은 현재 Firebase 계정의 프로젝트 목록을 보여 주고, 어떤 Firebase 프로젝트에 이 Flutter 앱을 연결할지 묻는다. 이어서 Android, iOS, Web, macOS 같은 대상 플랫폼을 선택하게 한다. 선택이 끝나면 FlutterFire CLI는 Firebase 프로젝트 안에 플랫폼별 앱을 등록하고, lib/firebase_options.dart 파일을 생성한다. Firebase 공식 문서에서도 FlutterFire CLI가 선택한 플랫폼에 맞는 Firebase 앱을 만들고 firebase_options.dart 구성 파일을 생성한다고 설명한다.2
여기서 중요한 파일이 바로 firebase_options.dart다. 이 파일에는 Android에서 실행될 때 쓸 Firebase 설정, iOS에서 실행될 때 쓸 Firebase 설정, 웹에서 실행될 때 쓸 Firebase 설정이 들어 있다. 우리는 직접 플랫폼을 구분하지 않고 DefaultFirebaseOptions.currentPlatform만 사용하면 된다.
필요한 패키지를 추가하자.
flutter pub add firebase_core firebase_auth cloud_firestore firebase_storage그리고 main.dart에서 Firebase를 초기화한다.
import 'package:firebase_core/firebase_core.dart';
import 'package:flutter/material.dart';
import 'app.dart';
import 'firebase_options.dart';
Future<void> main() async {
WidgetsFlutterBinding.ensureInitialized();
await Firebase.initializeApp(
options: DefaultFirebaseOptions.currentPlatform,
);
runApp(const App());
}WidgetsFlutterBinding.ensureInitialized()는 Flutter 엔진과 플러그인 시스템을 먼저 준비시키는 코드다. Firebase 초기화는 네이티브 플랫폼 채널을 사용할 수 있으므로 runApp()보다 먼저 이 코드가 필요하다. Firebase.initializeApp()은 현재 앱이 Firebase 프로젝트와 통신할 준비를 마치는 단계다.
연결이 제대로 되었는지 확인하려면 앱을 실행한다.
flutter run이 시점에서 앱이 정상 실행되면 Firebase 기본 연결은 성공한 것이다. 만약 Android 빌드에서 Gradle 오류가 나거나 iOS에서 CocoaPods 오류가 난다면, 대부분은 플랫폼 설정이나 캐시 문제다. 이런 문제는 뒤쪽 에러 해결집에서 다시 정리하겠지만, 지금은 flutter clean, flutter pub get, cd ios && pod install 같은 기본 점검부터 떠올리면 된다.
Firebase Auth로 로그인 붙이기
인증은 앱 백엔드의 문지기다. 사용자가 누구인지 알 수 있어야 그 사람의 노트만 보여 줄 수 있고, 그 사람의 이미지 폴더에만 업로드할 수 있다. Firebase Authentication은 이메일/비밀번호, 전화번호, Google, Apple, 익명 로그인 등 다양한 로그인 방식을 제공한다. 이번 장에서는 가장 기본이 되는 이메일/비밀번호 로그인을 구현한다.
먼저 Firebase Console로 이동한다. 왼쪽 메뉴에서 Authentication을 선택하고, 로그인 방법에서 Email/Password 제공업체를 활성화한다. 패키지를 추가하는 것만으로는 로그인이 되지 않는다. Firebase Console에서 사용할 로그인 제공업체를 켜야 한다. Firebase Auth 문서도 인증 제공업체를 사용하려면 콘솔에서 해당 로그인 방식을 사용 설정해야 한다고 안내한다.3
이제 Auth 기능을 작은 서비스 클래스로 감싸 보자.
import 'package:firebase_auth/firebase_auth.dart';
class AuthRepository {
AuthRepository(this._auth);
final FirebaseAuth _auth;
Stream<User?> authStateChanges() {
return _auth.authStateChanges();
}
User? get currentUser => _auth.currentUser;
Future<UserCredential> signUp({
required String email,
required String password,
}) {
return _auth.createUserWithEmailAndPassword(
email: email,
password: password,
);
}
Future<UserCredential> signIn({
required String email,
required String password,
}) {
return _auth.signInWithEmailAndPassword(
email: email,
password: password,
);
}
Future<void> signOut() {
return _auth.signOut();
}
}Firebase Auth에서 특히 중요한 것은 authStateChanges()다. 이 메서드는 현재 사용자가 로그인했는지, 로그아웃했는지를 Stream<User?>으로 알려 준다. 리스너가 등록되면 현재 인증 상태가 즉시 한 번 전달되고, 이후 로그인 또는 로그아웃이 발생할 때마다 새 이벤트가 전달된다.4
이 흐름은 Flutter와 잘 맞는다. Flutter UI는 상태가 바뀌면 다시 그리면 된다. 인증 상태도 마찬가지다. 사용자가 없으면 로그인 화면을 보여 주고, 사용자가 있으면 홈 화면을 보여 주면 된다.
import 'package:firebase_auth/firebase_auth.dart';
import 'package:flutter/material.dart';
import 'auth_screen.dart';
import 'notes_home_screen.dart';
class AuthGate extends StatelessWidget {
const AuthGate({super.key});
@override
Widget build(BuildContext context) {
return StreamBuilder<User?>(
stream: FirebaseAuth.instance.authStateChanges(),
builder: (context, snapshot) {
if (snapshot.connectionState == ConnectionState.waiting) {
return const Scaffold(
body: Center(child: CircularProgressIndicator()),
);
}
final user = snapshot.data;
if (user == null) {
return const AuthScreen();
}
return const NotesHomeScreen();
},
);
}
}인증 상태가 확인되기 전에 홈 화면을 먼저 보여 주는 것은 좋지 않다. 아주 짧은 순간이라도 로그인한 사용자만 볼 수 있는 화면이 노출될 수 있고, currentUser!처럼 강제 언래핑한 코드가 먼저 실행되어 오류가 날 수 있다. 그래서 ConnectionState.waiting일 때는 로딩 화면을 보여 주는 습관을 들이자.
간단한 로그인 화면은 다음처럼 만들 수 있다.
import 'package:firebase_auth/firebase_auth.dart';
import 'package:flutter/material.dart';
class AuthScreen extends StatefulWidget {
const AuthScreen({super.key});
@override
State<AuthScreen> createState() => _AuthScreenState();
}
class _AuthScreenState extends State<AuthScreen> {
final _emailController = TextEditingController();
final _passwordController = TextEditingController();
var _isLoading = false;
String? _errorMessage;
@override
void dispose() {
_emailController.dispose();
_passwordController.dispose();
super.dispose();
}
Future<void> _submit({required bool isSignUp}) async {
setState(() {
_isLoading = true;
_errorMessage = null;
});
final email = _emailController.text.trim();
final password = _passwordController.text.trim();
try {
if (isSignUp) {
await FirebaseAuth.instance.createUserWithEmailAndPassword(
email: email,
password: password,
);
} else {
await FirebaseAuth.instance.signInWithEmailAndPassword(
email: email,
password: password,
);
}
} on FirebaseAuthException catch (e) {
setState(() {
_errorMessage = _messageFromAuthError(e);
});
} finally {
if (mounted) {
setState(() {
_isLoading = false;
});
}
}
}
String _messageFromAuthError(FirebaseAuthException e) {
switch (e.code) {
case 'invalid-email':
return '이메일 형식이 올바르지 않습니다.';
case 'user-not-found':
case 'wrong-password':
case 'invalid-credential':
return '이메일 또는 비밀번호를 확인해 주세요.';
case 'email-already-in-use':
return '이미 가입된 이메일입니다.';
case 'weak-password':
return '비밀번호를 더 안전하게 입력해 주세요.';
default:
return '인증 중 문제가 발생했습니다. 잠시 후 다시 시도해 주세요.';
}
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('로그인')),
body: ListView(
padding: const EdgeInsets.all(24),
children: [
TextField(
controller: _emailController,
keyboardType: TextInputType.emailAddress,
decoration: const InputDecoration(
labelText: '이메일',
),
),
const SizedBox(height: 12),
TextField(
controller: _passwordController,
obscureText: true,
decoration: const InputDecoration(
labelText: '비밀번호',
),
),
const SizedBox(height: 16),
if (_errorMessage != null)
Text(
_errorMessage!,
style: TextStyle(color: Theme.of(context).colorScheme.error),
),
const SizedBox(height: 16),
FilledButton(
onPressed: _isLoading ? null : () => _submit(isSignUp: false),
child: _isLoading
? const SizedBox.square(
dimension: 20,
child: CircularProgressIndicator(strokeWidth: 2),
)
: const Text('로그인'),
),
const SizedBox(height: 8),
OutlinedButton(
onPressed: _isLoading ? null : () => _submit(isSignUp: true),
child: const Text('회원가입'),
),
],
),
);
}
}여기서는 학습을 위해 UI와 인증 호출을 한 파일에 두었다. 실제 앱에서는 이전 장에서 배운 구조처럼 presentation, application, data 계층으로 분리하는 편이 좋다. 특히 인증 로직은 나중에 Google 로그인, Apple 로그인, 비밀번호 재설정, 이메일 인증 같은 기능이 붙기 쉽다. 처음부터 AuthRepository나 AuthController로 감싸 두면 코드가 훨씬 오래 버틴다.
로그아웃은 간단하다.
IconButton(
onPressed: () async {
await FirebaseAuth.instance.signOut();
},
icon: const Icon(Icons.logout),
)signOut()이 호출되면 authStateChanges() 스트림에 null이 전달되고, AuthGate는 자동으로 로그인 화면을 보여 준다. 이 흐름을 이해하면 "로그인 성공 후 어디로 이동해야 하지?"라는 고민이 줄어든다. 직접 Navigator.pushReplacement()를 남발하기보다 인증 상태를 앱의 최상위 분기점으로 두는 것이 더 안정적이다.
Firestore의 구조 이해하기
이제 로그인한 사용자의 노트를 저장해 보자. Firebase에는 여러 데이터베이스 서비스가 있지만 Flutter 앱에서 범용적으로 많이 사용하는 것은 Cloud Firestore다. Firestore는 SQL 데이터베이스처럼 테이블과 행으로 구성되지 않는다. 대신 collection과 document로 데이터를 저장한다.
컬렉션은 문서들의 묶음이고, 문서는 필드와 값의 묶음이다. 문서 안에는 문자열, 숫자, 불리언, 리스트, 맵, 타임스탬프 같은 값을 저장할 수 있다. 문서 아래에는 다시 하위 컬렉션을 둘 수도 있다.
개인 지식 노트 앱에서는 다음 구조를 사용할 수 있다.
users/{uid}
email: string
createdAt: timestamp
users/{uid}/notes/{noteId}
title: string
content: string
tags: array
imageUrl: string?
imagePath: string?
ownerUid: string
createdAt: timestamp
updatedAt: timestampusers/{uid}/notes/{noteId} 구조의 장점은 명확하다. 특정 사용자의 노트가 그 사용자의 문서 아래에 모인다. 보안 규칙도 단순해진다. "로그인한 사용자의 UID가 경로의 uid와 같으면 허용한다"라고 쓰면 되기 때문이다.
다른 방식도 가능하다. 예를 들어 모든 노트를 최상위 notes 컬렉션에 저장하고 각 문서에 ownerUid 필드를 넣을 수 있다.
notes/{noteId}
ownerUid: string
title: string
content: string이 구조는 전체 공개 노트, 공유 노트, 관리자 검색이 필요한 앱에서 유리할 수 있다. 반면 사용자별 개인 데이터만 다루는 앱이라면 users/{uid}/notes 구조가 이해하기 쉽고 규칙도 간결하다. 데이터 모델에는 절대 정답이 없다. 앱이 데이터를 "어떻게 읽을 것인지"를 먼저 생각하고 구조를 잡아야 한다.
Firestore 패키지는 Flutter에서 Cloud Firestore API를 사용할 수 있게 해 주는 플러그인이다.5 패키지를 이미 추가했다면 다음처럼 Firestore 인스턴스를 사용할 수 있다.
import 'package:cloud_firestore/cloud_firestore.dart';
final db = FirebaseFirestore.instance;사용자별 노트 컬렉션 참조를 만드는 코드는 다음과 같다.
import 'package:cloud_firestore/cloud_firestore.dart';
import 'package:firebase_auth/firebase_auth.dart';
class NoteRepository {
NoteRepository({
FirebaseFirestore? firestore,
FirebaseAuth? auth,
}) : _firestore = firestore ?? FirebaseFirestore.instance,
_auth = auth ?? FirebaseAuth.instance;
final FirebaseFirestore _firestore;
final FirebaseAuth _auth;
String get _uid {
final user = _auth.currentUser;
if (user == null) {
throw StateError('로그인이 필요합니다.');
}
return user.uid;
}
CollectionReference<Map<String, dynamic>> get _notesRef {
return _firestore.collection('users').doc(_uid).collection('notes');
}
}여기서 currentUser!를 바로 쓰지 않고 예외를 던지는 방식으로 감싼 이유는 코드의 의도를 명확히 하기 위해서다. 노트 저장소는 로그인한 사용자만 사용할 수 있다. 만약 로그아웃 상태에서 접근한다면 그것은 정상 흐름이 아니므로 빠르게 오류를 발견해야 한다.
노트 저장, 조회, 수정, 삭제하기
새 노트를 추가하는 메서드를 작성해 보자.
class NoteRepository {
// 앞의 코드 생략
Future<void> addNote({
required String title,
required String content,
List<String> tags = const [],
}) async {
final now = FieldValue.serverTimestamp();
await _notesRef.add({
'title': title,
'content': content,
'tags': tags,
'ownerUid': _uid,
'imageUrl': null,
'imagePath': null,
'createdAt': now,
'updatedAt': now,
});
}
}FieldValue.serverTimestamp()는 클라이언트 기기 시간이 아니라 서버 기준 시간을 저장하게 해 준다. 사용자의 휴대폰 시간이 잘못 설정되어 있어도 데이터 정렬이 크게 흔들리지 않는다. 생성 시각과 수정 시각처럼 중요한 값에는 서버 타임스탬프를 사용하는 습관이 좋다.
노트를 실시간으로 읽는 코드는 snapshots()를 사용한다.
class NoteRepository {
// 앞의 코드 생략
Stream<QuerySnapshot<Map<String, dynamic>>> watchNotes() {
return _notesRef
.orderBy('createdAt', descending: true)
.limit(50)
.snapshots();
}
}snapshots()는 Firestore의 실시간 리스너다. 새 노트가 추가되거나 기존 노트가 수정되면 스트림에 새 쿼리 결과가 전달된다. Flutter에서는 이 스트림을 StreamBuilder나 Riverpod의 StreamProvider와 연결하면 UI가 자동으로 갱신된다.
간단히 StreamBuilder로 표시하면 다음과 같다.
class NotesHomeScreen extends StatelessWidget {
const NotesHomeScreen({super.key});
@override
Widget build(BuildContext context) {
final repository = NoteRepository();
return Scaffold(
appBar: AppBar(
title: const Text('내 노트'),
actions: [
IconButton(
onPressed: () => FirebaseAuth.instance.signOut(),
icon: const Icon(Icons.logout),
),
],
),
body: StreamBuilder<QuerySnapshot<Map<String, dynamic>>>(
stream: repository.watchNotes(),
builder: (context, snapshot) {
if (snapshot.hasError) {
return const Center(child: Text('노트를 불러오지 못했습니다.'));
}
if (!snapshot.hasData) {
return const Center(child: CircularProgressIndicator());
}
final docs = snapshot.data!.docs;
if (docs.isEmpty) {
return const Center(child: Text('아직 노트가 없습니다.'));
}
return ListView.separated(
itemCount: docs.length,
separatorBuilder: (_, __) => const Divider(height: 1),
itemBuilder: (context, index) {
final doc = docs[index];
final data = doc.data();
return ListTile(
title: Text(data['title'] as String? ?? '제목 없음'),
subtitle: Text(
data['content'] as String? ?? '',
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
);
},
);
},
),
floatingActionButton: FloatingActionButton.extended(
onPressed: () {
// 노트 작성 화면으로 이동
},
icon: const Icon(Icons.add),
label: const Text('새 노트'),
),
);
}
}쿼리는 조건과 정렬을 함께 사용할 수 있다.
Stream<QuerySnapshot<Map<String, dynamic>>> watchTaggedNotes(String tag) {
return _notesRef
.where('tags', arrayContains: tag)
.orderBy('createdAt', descending: true)
.limit(30)
.snapshots();
}where, orderBy, limit은 Firestore에서 자주 쓰는 쿼리 조합이다. 다만 조건과 정렬이 복잡해지면 Firestore가 인덱스를 요구할 수 있다. 이때 콘솔에 친절한 링크가 포함된 오류가 나오는데, 그 링크를 열어 필요한 복합 인덱스를 만들면 된다. 에러 메시지를 무서워하지 말자. Firestore 인덱스 오류는 대체로 "이 쿼리를 빠르게 실행하려면 색인을 만들어 주세요"라는 요청에 가깝다.
수정과 삭제도 문서 ID를 기준으로 처리한다.
class NoteRepository {
// 앞의 코드 생략
Future<void> updateNote({
required String noteId,
required String title,
required String content,
required List<String> tags,
}) async {
await _notesRef.doc(noteId).update({
'title': title,
'content': content,
'tags': tags,
'updatedAt': FieldValue.serverTimestamp(),
});
}
Future<void> deleteNote(String noteId) async {
await _notesRef.doc(noteId).delete();
}
}처음에는 Firestore 문서 데이터를 Map<String, dynamic>으로 다루어도 괜찮다. 하지만 앱이 커지면 Note 모델 클래스를 만들고 fromJson, toJson으로 변환하는 편이 안전하다. 필드 이름 오타, 타입 캐스팅 오류, null 처리 문제를 줄일 수 있기 때문이다.
Cloud Storage에 이미지 업로드하기
노트에 이미지를 첨부하려면 파일 저장소가 필요하다. Firestore는 작은 구조화 데이터를 저장하는 데이터베이스이고, Cloud Storage는 이미지, 동영상, 문서 같은 파일을 저장하는 공간이다. 이미지를 Firestore 문서 안에 직접 넣는 것이 아니라, Storage에 업로드한 뒤 다운로드 URL과 파일 경로를 Firestore 문서에 저장하는 방식으로 연결한다.
흐름은 다음과 같다.
사용자가 이미지 파일을 선택한다.
선택한 파일을 Cloud Storage의 사용자별 경로에 업로드한다.
업로드가 끝나면 다운로드 URL을 가져온다.
Firestore 노트 문서에
imageUrl,imagePath,ownerUid,createdAt같은 메타데이터를 저장한다.
이미지 선택은 다음 장 이후의 기기 기능에서 더 자세히 다룬다. 여기서는 File imageFile이 이미 준비되어 있다고 가정하자.
import 'dart:io';
import 'package:firebase_auth/firebase_auth.dart';
import 'package:firebase_storage/firebase_storage.dart';
class StorageRepository {
StorageRepository({
FirebaseStorage? storage,
FirebaseAuth? auth,
}) : _storage = storage ?? FirebaseStorage.instance,
_auth = auth ?? FirebaseAuth.instance;
final FirebaseStorage _storage;
final FirebaseAuth _auth;
String get _uid {
final user = _auth.currentUser;
if (user == null) {
throw StateError('로그인이 필요합니다.');
}
return user.uid;
}
Future<UploadedImage> uploadNoteImage({
required File imageFile,
required String noteId,
}) async {
final fileName = '${DateTime.now().millisecondsSinceEpoch}.jpg';
final path = 'users/$_uid/notes/$noteId/images/$fileName';
final ref = _storage.ref(path);
final taskSnapshot = await ref.putFile(
imageFile,
SettableMetadata(contentType: 'image/jpeg'),
);
final downloadUrl = await taskSnapshot.ref.getDownloadURL();
return UploadedImage(
path: path,
downloadUrl: downloadUrl,
);
}
}
class UploadedImage {
const UploadedImage({
required this.path,
required this.downloadUrl,
});
final String path;
final String downloadUrl;
}Storage 경로에 UID를 포함한 것을 눈여겨보자.
users/{uid}/notes/{noteId}/images/{fileName}이 구조를 사용하면 파일 소유자를 경로만 봐도 알 수 있다. 나중에 Storage 보안 규칙을 작성할 때도 "경로의 UID와 로그인한 사용자의 UID가 같은가?"를 검사하기 쉽다.
이미지를 업로드한 뒤 Firestore 노트 문서에 연결하는 코드는 다음과 같이 작성할 수 있다.
class NoteRepository {
// 앞의 코드 생략
Future<void> attachImage({
required String noteId,
required String imageUrl,
required String imagePath,
}) async {
await _notesRef.doc(noteId).update({
'imageUrl': imageUrl,
'imagePath': imagePath,
'updatedAt': FieldValue.serverTimestamp(),
});
}
}노트 생성과 이미지 업로드를 한 번에 처리하고 싶다면 먼저 빈 노트 문서 ID를 만든 뒤, 그 ID를 Storage 경로에 사용하는 방식이 좋다.
Future<void> createNoteWithImage({
required String title,
required String content,
required File imageFile,
}) async {
final noteRepository = NoteRepository();
final storageRepository = StorageRepository();
final noteRef = noteRepository.createEmptyNoteRef();
final uploadedImage = await storageRepository.uploadNoteImage(
imageFile: imageFile,
noteId: noteRef.id,
);
await noteRef.set({
'title': title,
'content': content,
'tags': <String>[],
'ownerUid': FirebaseAuth.instance.currentUser!.uid,
'imageUrl': uploadedImage.downloadUrl,
'imagePath': uploadedImage.path,
'createdAt': FieldValue.serverTimestamp(),
'updatedAt': FieldValue.serverTimestamp(),
});
}위 코드에서 사용한 createEmptyNoteRef()는 다음처럼 만들 수 있다.
class NoteRepository {
// 앞의 코드 생략
DocumentReference<Map<String, dynamic>> createEmptyNoteRef() {
return _notesRef.doc();
}
}이 방식은 문서 ID를 먼저 확보할 수 있어 파일 경로와 데이터베이스 문서를 깔끔하게 연결할 수 있다. 다만 중간에 업로드는 성공했는데 Firestore 저장이 실패할 수 있다. 실전 앱에서는 이런 경우를 대비해 실패한 파일을 삭제하거나, 업로드 상태를 따로 기록하는 보정 로직이 필요하다.
Future<void> deleteFileByPath(String path) async {
await FirebaseStorage.instance.ref(path).delete();
}파일 업로드는 네트워크 상태에 민감하다. 사용자가 지하철에서 업로드할 수도 있고, 와이파이와 모바일 데이터가 바뀌는 순간에 업로드가 끊길 수도 있다. 따라서 업로드 중 로딩 표시, 실패 시 재시도 버튼, 너무 큰 이미지 압축 같은 UX를 함께 고려해야 한다.
보안 규칙: UID로 내 데이터만 지키기
Firebase를 처음 배울 때 가장 위험한 순간은 "일단 테스트 모드로 열어 두자"라고 생각하는 때다. 테스트 모드는 개발 초기에 편하지만, 그대로 출시하면 누구나 데이터베이스를 읽고 쓸 수 있는 상태가 될 수 있다. Firebase 앱에서 보안은 서버 코드가 아니라 Rules에서 시작된다.
Firestore Rules의 기본 원칙은 단순하다. 인증된 사용자만 접근할 수 있고, 자기 UID에 해당하는 경로만 읽고 쓸 수 있게 한다.
rules_version = '2';
service cloud.firestore {
match /databases/{database}/documents {
function isSignedIn() {
return request.auth != null;
}
function isOwner(userId) {
return isSignedIn() && request.auth.uid == userId;
}
match /users/{userId} {
allow read, write: if isOwner(userId);
match /notes/{noteId} {
allow read, create, update, delete: if isOwner(userId);
}
}
}
}이 규칙은 users/{userId} 아래 데이터에 대해 로그인한 사용자의 UID가 경로의 userId와 같을 때만 읽기와 쓰기를 허용한다. 예를 들어 uid가 abc123인 사용자는 users/abc123/notes에는 접근할 수 있지만 users/otherUser/notes에는 접근할 수 없다.
하지만 이것만으로 모든 검증이 끝나는 것은 아니다. 사용자가 문서 안의 ownerUid 필드를 다른 값으로 조작해 보낼 수도 있다. 더 엄격하게 하려면 생성 시 ownerUid가 로그인 UID와 같은지도 확인할 수 있다.
rules_version = '2';
service cloud.firestore {
match /databases/{database}/documents {
function isSignedIn() {
return request.auth != null;
}
function isOwner(userId) {
return isSignedIn() && request.auth.uid == userId;
}
match /users/{userId}/notes/{noteId} {
allow read: if isOwner(userId);
allow create: if isOwner(userId)
&& request.resource.data.ownerUid == request.auth.uid
&& request.resource.data.title is string
&& request.resource.data.content is string;
allow update: if isOwner(userId)
&& resource.data.ownerUid == request.auth.uid
&& request.resource.data.ownerUid == resource.data.ownerUid;
allow delete: if isOwner(userId)
&& resource.data.ownerUid == request.auth.uid;
}
}
}여기서 request.resource.data는 새로 쓰려는 데이터이고, resource.data는 기존에 저장되어 있던 데이터다. 생성할 때는 ownerUid가 현재 로그인한 사용자의 UID인지 확인한다. 수정할 때는 기존 소유자가 현재 사용자와 같고, 새 데이터에서도 ownerUid가 바뀌지 않는지 확인한다.
Storage Rules도 비슷하게 작성할 수 있다.
rules_version = '2';
service firebase.storage {
match /b/{bucket}/o {
function isSignedIn() {
return request.auth != null;
}
function isOwner(userId) {
return isSignedIn() && request.auth.uid == userId;
}
match /users/{userId}/notes/{noteId}/images/{fileName} {
allow read, write: if isOwner(userId);
}
}
}이 규칙은 users/{uid}/notes/... 경로의 파일을 해당 UID 사용자만 읽고 쓸 수 있게 한다. 앱에서 다운로드 URL을 표시하더라도, 기본적으로 파일 접근 권한은 Storage Rules와 토큰 정책에 영향을 받는다. 공개 이미지 서비스인지, 개인 노트 앱인지에 따라 읽기 권한을 신중히 정해야 한다.
보안 규칙을 작성할 때 기억할 점은 "클라이언트 코드는 믿지 않는다"는 것이다. Flutter 앱에서 버튼을 숨겨도 악의적인 사용자는 직접 요청을 보낼 수 있다. 진짜 접근 제어는 Firebase Rules에서 이루어져야 한다.
Riverpod과 연결하는 간단한 방향
앞에서는 코드를 이해하기 쉽도록 FirebaseAuth.instance와 FirebaseFirestore.instance를 직접 사용했다. 하지만 유지보수 가능한 앱을 만들려면 이전 장에서 배운 Riverpod과 연결하는 편이 좋다. Firebase 인스턴스와 Repository를 Provider로 감싸면 테스트도 쉬워지고 의존성도 명확해진다.
import 'package:cloud_firestore/cloud_firestore.dart';
import 'package:firebase_auth/firebase_auth.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
final firebaseAuthProvider = Provider<FirebaseAuth>((ref) {
return FirebaseAuth.instance;
});
final firestoreProvider = Provider<FirebaseFirestore>((ref) {
return FirebaseFirestore.instance;
});
final authStateProvider = StreamProvider<User?>((ref) {
final auth = ref.watch(firebaseAuthProvider);
return auth.authStateChanges();
});
final noteRepositoryProvider = Provider<NoteRepository>((ref) {
return NoteRepository(
firestore: ref.watch(firestoreProvider),
auth: ref.watch(firebaseAuthProvider),
);
});노트 목록도 StreamProvider로 감쌀 수 있다.
final notesProvider = StreamProvider.autoDispose((ref) {
final repository = ref.watch(noteRepositoryProvider);
return repository.watchNotes();
});이제 UI는 Firebase를 직접 알 필요가 줄어든다. notesProvider를 구독하고, 로딩/성공/실패 상태를 그리면 된다.
class NotesList extends ConsumerWidget {
const NotesList({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final notesAsync = ref.watch(notesProvider);
return notesAsync.when(
loading: () => const Center(child: CircularProgressIndicator()),
error: (error, stackTrace) => const Center(
child: Text('노트를 불러오지 못했습니다.'),
),
data: (snapshot) {
final docs = snapshot.docs;
if (docs.isEmpty) {
return const Center(child: Text('아직 노트가 없습니다.'));
}
return ListView.builder(
itemCount: docs.length,
itemBuilder: (context, index) {
final data = docs[index].data();
return ListTile(
title: Text(data['title'] as String? ?? '제목 없음'),
subtitle: Text(data['content'] as String? ?? ''),
);
},
);
},
);
}
}이 구조를 사용하면 나중에 테스트에서 FirebaseFirestore.instance 대신 가짜 Repository를 주입할 수 있다. 앱이 작을 때는 조금 번거로워 보이지만, 로그인, 노트, 이미지, 검색, 태그, 오프라인 캐시가 붙기 시작하면 이런 분리가 큰 차이를 만든다.
예외 처리와 사용자 경험
Firebase 코드는 대부분 비동기다. 네트워크가 끊기거나, 권한이 없거나, 보안 규칙에서 거절되거나, 파일 용량이 너무 커질 수 있다. 따라서 try-catch 없이 "성공하겠지"라고 작성하면 실제 사용자 환경에서 앱이 쉽게 흔들린다.
Firestore 예외는 FirebaseException으로 받을 수 있다.
try {
await repository.addNote(
title: title,
content: content,
);
} on FirebaseException catch (e) {
if (e.code == 'permission-denied') {
// 보안 규칙에서 거절된 경우
} else if (e.code == 'unavailable') {
// 네트워크 또는 서비스 일시 문제
}
} catch (e) {
// 그 밖의 예외
}사용자에게는 내부 오류 코드를 그대로 보여 주지 않는 편이 좋다. permission-denied라는 말보다 "이 노트에 접근할 권한이 없습니다"가 낫고, unavailable보다 "네트워크 상태를 확인한 뒤 다시 시도해 주세요"가 낫다.
업로드 화면에서는 진행률을 보여 줄 수도 있다.
final uploadTask = ref.putFile(imageFile);
uploadTask.snapshotEvents.listen((snapshot) {
final total = snapshot.totalBytes;
final transferred = snapshot.bytesTransferred;
if (total > 0) {
final progress = transferred / total;
// progress 값을 상태로 저장해 진행률 UI에 표시
}
});
final taskSnapshot = await uploadTask;
final url = await taskSnapshot.ref.getDownloadURL();이런 작은 배려가 앱을 "예제"에서 "서비스"로 바꾼다. 사용자는 업로드가 멈춘 것인지, 진행 중인지, 실패했는지 알고 싶어 한다. 로딩 인디케이터, 진행률, 재시도 버튼은 Firebase 기능만큼이나 중요하다.
요금제와 사용량 제한 생각하기
Firebase는 시작하기 쉽지만 공짜 무제한 서비스는 아니다. 무료 할당량이 있고, 일정 사용량을 넘으면 요금이 발생할 수 있다. 특히 Firestore 읽기 횟수, 쓰기 횟수, 삭제 횟수, Storage 저장 용량, 다운로드 트래픽은 사용량에 따라 비용과 연결된다. Firebase 공식 Flutter 설정 문서도 출시 준비 단계에서 예산 알림 설정과 사용량 및 결제 대시보드 모니터링을 권장한다.6
실전 앱에서는 다음을 습관처럼 점검하자.
Firestore에서 화면 하나를 열 때 너무 많은 문서를 읽지 않는가
snapshots()실시간 리스너를 필요 없는 화면에서도 계속 유지하고 있지 않은가이미지 원본을 그대로 업로드해 Storage 용량과 다운로드 트래픽을 낭비하고 있지 않은가
보안 규칙이 열려 있어 다른 사용자가 대량 요청을 보낼 수 있지 않은가
Firebase Console에서 예산 알림과 사용량 모니터링을 설정했는가
특히 ListView에 노트를 전부 불러오는 방식은 데이터가 적을 때는 괜찮지만, 수천 개가 되면 문제가 된다. limit()을 사용하고, 필요한 경우 페이지네이션을 적용하자. 이미지는 업로드 전에 적절히 압축하고, 썸네일을 따로 만드는 전략도 고려할 수 있다.
Firebase는 백엔드를 빠르게 만들어 주지만, 백엔드 설계 자체를 대신해 주지는 않는다. 데이터가 어떻게 늘어날지, 어떤 화면에서 어떤 쿼리가 반복될지, 누가 어떤 데이터에 접근할 수 있는지는 개발자가 결정해야 한다.
Firebase를 붙인 앱의 큰 그림
이 장에서 만든 흐름을 하나로 묶어 보자. 앱이 시작되면 Firebase.initializeApp()으로 Firebase 연결을 준비한다. AuthGate는 authStateChanges()를 구독해 사용자가 로그인했는지 확인한다. 로그인하지 않았다면 인증 화면을 보여 주고, 로그인했다면 노트 홈 화면을 보여 준다.
노트 홈 화면은 Firestore의 users/{uid}/notes 컬렉션을 snapshots()로 구독한다. 사용자가 노트를 만들면 Firestore 문서가 추가되고, 화면은 자동으로 갱신된다. 이미지가 첨부되면 파일은 Storage의 users/{uid}/notes/{noteId}/images 경로에 저장되고, 다운로드 URL과 파일 경로는 Firestore 문서에 기록된다. Firestore Rules와 Storage Rules는 로그인한 사용자의 UID를 기준으로 자기 데이터만 접근할 수 있게 막는다.
이 구조만으로도 꽤 많은 앱을 만들 수 있다. 개인 노트 앱, 사진 일기 앱, 독서 기록 앱, 운동 기록 앱, 영수증 보관 앱, 작은 팀의 내부 기록 앱까지 모두 비슷한 패턴으로 출발할 수 있다.
중요한 것은 Firebase를 "그냥 붙이는 것"이 아니라 앱 구조 안에 자연스럽게 넣는 것이다. Auth는 사용자 식별을 담당하고, Firestore는 구조화된 데이터를 담당하고, Storage는 파일을 담당한다. Repository는 Firebase API를 앱 도메인에 맞게 감싸고, UI는 상태를 구독해 그린다. 보안 규칙은 클라이언트 바깥에서 마지막 문지기 역할을 한다.
이제 우리 앱은 혼자 화면만 그리는 앱이 아니다. 사용자를 기억하고, 데이터를 클라우드에 저장하고, 이미지를 업로드할 수 있는 앱이 되었다. 다음 장에서는 이 기반 위에 푸시 알림과 딥링크를 더해, 앱 밖에 있는 사용자까지 다시 앱 안으로 데려오는 방법을 배운다.
참고
4Using Firebase Authentication | FlutterFire
이 노트는 보도·비평·교육·연구 목적으로 작성되었습니다. 저작권 문의가 있으시면 에서 알려주세요.
