“백엔드 없이도 앱을 완성할 수 있습니다.”
이 글에서는 Firebase를 Expo 앱에 연동해 로그인과 데이터 저장을 구현하는 방법을 코드와 함께 살펴봅니다.
Firebase란?
Firebase는 **Google이 제공하는 앱 개발 플랫폼(BaaS)**입니다.
서버 없이도 인증, 데이터베이스, 파일 저장, 분석 등을 빠르게 구현할 수 있습니다.
| 서비스 | 역할 |
|---|---|
| Authentication | 이메일, 소셜 로그인 |
| Firestore | 실시간 NoSQL 데이터베이스 |
| Storage | 이미지, 파일 저장 |
| Analytics | 사용자 행동 분석 |
| Cloud Functions | 서버리스 함수 실행 |
이 글에서는 가장 많이 쓰이는 Authentication과 Firestore를 다룹니다.
1단계 — Firebase 프로젝트 생성
- firebase.google.com 접속 후 로그인
- 프로젝트 추가 클릭
- 프로젝트 이름 입력 후 생성
- 웹 앱 추가 (
</>아이콘 클릭) - 앱 등록 후 Firebase SDK 설정값 복사
// 이런 형태의 설정값을 복사해둡니다
const firebaseConfig = {
apiKey: "AIza...",
authDomain: "my-app.firebaseapp.com",
projectId: "my-app",
storageBucket: "my-app.appspot.com",
messagingSenderId: "123456789",
appId: "1:123456789:web:abc123"
};
2단계 — 패키지 설치 & 초기화
npx expo install firebase
// lib/firebase.ts
import { initializeApp } from 'firebase/app';
import { getAuth } from 'firebase/auth';
import { getFirestore } from 'firebase/firestore';
const firebaseConfig = {
apiKey: process.env.EXPO_PUBLIC_FIREBASE_API_KEY,
authDomain: process.env.EXPO_PUBLIC_FIREBASE_AUTH_DOMAIN,
projectId: process.env.EXPO_PUBLIC_FIREBASE_PROJECT_ID,
storageBucket: process.env.EXPO_PUBLIC_FIREBASE_STORAGE_BUCKET,
messagingSenderId: process.env.EXPO_PUBLIC_FIREBASE_MESSAGING_SENDER_ID,
appId: process.env.EXPO_PUBLIC_FIREBASE_APP_ID,
};
const app = initializeApp(firebaseConfig);
export const auth = getAuth(app);
export const db = getFirestore(app);
# .env 파일 생성 (루트 디렉토리)
EXPO_PUBLIC_FIREBASE_API_KEY=AIza...
EXPO_PUBLIC_FIREBASE_AUTH_DOMAIN=my-app.firebaseapp.com
EXPO_PUBLIC_FIREBASE_PROJECT_ID=my-app
EXPO_PUBLIC_FIREBASE_STORAGE_BUCKET=my-app.appspot.com
EXPO_PUBLIC_FIREBASE_MESSAGING_SENDER_ID=123456789
EXPO_PUBLIC_FIREBASE_APP_ID=1:123456789:web:abc123
API 키 등 민감한 정보는 반드시
.env파일로 관리하고.gitignore에 추가하세요.
3단계 — Authentication (이메일 로그인)
Firebase 콘솔 설정
Authentication → 로그인 방법 → 이메일/비밀번호 활성화
회원가입 & 로그인 구현
// app/auth/register.tsx
import { View, Text, TextInput, TouchableOpacity, StyleSheet } from 'react-native';
import { useState } from 'react';
import { createUserWithEmailAndPassword, updateProfile } from 'firebase/auth';
import { auth } from '@/lib/firebase';
import { useRouter } from 'expo-router';
export default function RegisterScreen() {
const router = useRouter();
const [name, setName] = useState('');
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const [isLoading, setIsLoading] = useState(false);
const handleRegister = async () => {
if (!name || !email || !password) {
alert('모든 항목을 입력해주세요.');
return;
}
try {
setIsLoading(true);
// 회원가입
const { user } = await createUserWithEmailAndPassword(auth, email, password);
// 이름 설정
await updateProfile(user, { displayName: name });
router.replace('/(tabs)');
} catch (error: any) {
if (error.code === 'auth/email-already-in-use') {
alert('이미 사용 중인 이메일입니다.');
} else if (error.code === 'auth/weak-password') {
alert('비밀번호는 6자 이상이어야 합니다.');
} else {
alert('회원가입에 실패했습니다.');
}
} finally {
setIsLoading(false);
}
};
return (
<View style={styles.container}>
<Text style={styles.title}>회원가입</Text>
<TextInput
style={styles.input}
placeholder="이름"
value={name}
onChangeText={setName}
/>
<TextInput
style={styles.input}
placeholder="이메일"
value={email}
onChangeText={setEmail}
keyboardType="email-address"
autoCapitalize="none"
/>
<TextInput
style={styles.input}
placeholder="비밀번호 (6자 이상)"
value={password}
onChangeText={setPassword}
secureTextEntry
/>
<TouchableOpacity
style={[styles.button, isLoading && styles.buttonDisabled]}
onPress={handleRegister}
disabled={isLoading}
>
<Text style={styles.buttonText}>
{isLoading ? '가입 중...' : '회원가입'}
</Text>
</TouchableOpacity>
<TouchableOpacity onPress={() => router.push('/auth/login')}>
<Text style={styles.linkText}>이미 계정이 있으신가요? 로그인</Text>
</TouchableOpacity>
</View>
);
}
const styles = StyleSheet.create({
container: { flex: 1, padding: 24, justifyContent: 'center', gap: 12 },
title: { fontSize: 28, fontWeight: 'bold', marginBottom: 8 },
input: {
borderWidth: 1,
borderColor: '#ddd',
borderRadius: 8,
padding: 14,
fontSize: 16,
backgroundColor: '#fff',
},
button: {
backgroundColor: '#007AFF',
padding: 14,
borderRadius: 8,
alignItems: 'center',
marginTop: 4,
},
buttonDisabled: { backgroundColor: '#aaa' },
buttonText: { color: '#fff', fontSize: 16, fontWeight: '600' },
linkText: { color: '#007AFF', textAlign: 'center', marginTop: 8 },
});
// app/auth/login.tsx
import { View, Text, TextInput, TouchableOpacity, StyleSheet } from 'react-native';
import { useState } from 'react';
import { signInWithEmailAndPassword } from 'firebase/auth';
import { auth } from '@/lib/firebase';
import { useRouter } from 'expo-router';
export default function LoginScreen() {
const router = useRouter();
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const [isLoading, setIsLoading] = useState(false);
const handleLogin = async () => {
try {
setIsLoading(true);
await signInWithEmailAndPassword(auth, email, password);
router.replace('/(tabs)');
} catch (error: any) {
if (error.code === 'auth/user-not-found' || error.code === 'auth/wrong-password') {
alert('이메일 또는 비밀번호가 올바르지 않습니다.');
} else {
alert('로그인에 실패했습니다.');
}
} finally {
setIsLoading(false);
}
};
return (
<View style={styles.container}>
<Text style={styles.title}>로그인</Text>
<TextInput
style={styles.input}
placeholder="이메일"
value={email}
onChangeText={setEmail}
keyboardType="email-address"
autoCapitalize="none"
/>
<TextInput
style={styles.input}
placeholder="비밀번호"
value={password}
onChangeText={setPassword}
secureTextEntry
/>
<TouchableOpacity
style={[styles.button, isLoading && styles.buttonDisabled]}
onPress={handleLogin}
disabled={isLoading}
>
<Text style={styles.buttonText}>
{isLoading ? '로그인 중...' : '로그인'}
</Text>
</TouchableOpacity>
<TouchableOpacity onPress={() => router.push('/auth/register')}>
<Text style={styles.linkText}>계정이 없으신가요? 회원가입</Text>
</TouchableOpacity>
</View>
);
}
const styles = StyleSheet.create({
container: { flex: 1, padding: 24, justifyContent: 'center', gap: 12 },
title: { fontSize: 28, fontWeight: 'bold', marginBottom: 8 },
input: {
borderWidth: 1,
borderColor: '#ddd',
borderRadius: 8,
padding: 14,
fontSize: 16,
backgroundColor: '#fff',
},
button: {
backgroundColor: '#007AFF',
padding: 14,
borderRadius: 8,
alignItems: 'center',
marginTop: 4,
},
buttonDisabled: { backgroundColor: '#aaa' },
buttonText: { color: '#fff', fontSize: 16, fontWeight: '600' },
linkText: { color: '#007AFF', textAlign: 'center', marginTop: 8 },
});
로그인 상태 감지 & 자동 라우팅
// app/_layout.tsx
import { useEffect, useState } from 'react';
import { Stack, useRouter, useSegments } from 'expo-router';
import { onAuthStateChanged, User } from 'firebase/auth';
import { auth } from '@/lib/firebase';
export default function RootLayout() {
const router = useRouter();
const segments = useSegments();
const [user, setUser] = useState<User | null>(null);
const [isReady, setIsReady] = useState(false);
useEffect(() => {
const unsubscribe = onAuthStateChanged(auth, (currentUser) => {
setUser(currentUser);
setIsReady(true);
});
return unsubscribe;
}, []);
useEffect(() => {
if (!isReady) return;
const inAuthGroup = segments[0] === 'auth';
if (!user && !inAuthGroup) {
// 로그인 안 된 상태 → 로그인 화면으로
router.replace('/auth/login');
} else if (user && inAuthGroup) {
// 로그인 된 상태 → 메인 화면으로
router.replace('/(tabs)');
}
}, [user, isReady]);
return <Stack />;
}
4단계 — Firestore (데이터베이스)
데이터 구조 설계
Firestore는 컬렉션 → 문서 구조로 데이터를 저장합니다.
Firestore
├── users (컬렉션)
│ ├── userId1 (문서)
│ │ ├── name: "홍길동"
│ │ └── createdAt: timestamp
│ └── userId2 (문서)
└── posts (컬렉션)
├── postId1 (문서)
│ ├── title: "첫 번째 글"
│ ├── content: "내용..."
│ ├── authorId: "userId1"
│ └── createdAt: timestamp
└── postId2 (문서)
데이터 쓰기 (Create / Update)
import { collection, addDoc, setDoc, doc, serverTimestamp } from 'firebase/firestore';
import { db, auth } from '@/lib/firebase';
// 새 문서 추가 (ID 자동 생성)
const addPost = async (title: string, content: string) => {
const user = auth.currentUser;
if (!user) return;
const docRef = await addDoc(collection(db, 'posts'), {
title,
content,
authorId: user.uid,
authorName: user.displayName,
createdAt: serverTimestamp(),
});
console.log('추가된 문서 ID:', docRef.id);
};
// 특정 ID로 문서 저장 (없으면 생성, 있으면 덮어씀)
const saveUserProfile = async (userId: string, data: object) => {
await setDoc(doc(db, 'users', userId), {
...data,
updatedAt: serverTimestamp(),
}, { merge: true }); // merge: true → 기존 필드 유지하고 업데이트
};
데이터 읽기 (Read)
import {
collection,
doc,
getDoc,
getDocs,
query,
where,
orderBy,
limit,
onSnapshot,
} from 'firebase/firestore';
import { db } from '@/lib/firebase';
// 단일 문서 읽기
const getPost = async (postId: string) => {
const docRef = doc(db, 'posts', postId);
const docSnap = await getDoc(docRef);
if (docSnap.exists()) {
return { id: docSnap.id, ...docSnap.data() };
}
return null;
};
// 컬렉션 전체 읽기 (조건 + 정렬 + 제한)
const getPosts = async () => {
const q = query(
collection(db, 'posts'),
where('authorId', '==', auth.currentUser?.uid), // 내 글만
orderBy('createdAt', 'desc'), // 최신순
limit(20) // 20개 제한
);
const querySnapshot = await getDocs(q);
return querySnapshot.docs.map((doc) => ({
id: doc.id,
...doc.data(),
}));
};
// 실시간 구독 (데이터 변경 시 자동 업데이트)
const subscribeToPost = (postId: string, callback: (data: any) => void) => {
const docRef = doc(db, 'posts', postId);
const unsubscribe = onSnapshot(docRef, (docSnap) => {
if (docSnap.exists()) {
callback({ id: docSnap.id, ...docSnap.data() });
}
});
return unsubscribe; // 컴포넌트 언마운트 시 호출
};
실전 예제 — 게시글 목록 화면
// app/(tabs)/posts.tsx
import { View, Text, FlatList, TouchableOpacity, StyleSheet } from 'react-native';
import { useState, useEffect } from 'react';
import { collection, query, orderBy, onSnapshot } from 'firebase/firestore';
import { db } from '@/lib/firebase';
import { useRouter } from 'expo-router';
type Post = { id: string; title: string; authorName: string; createdAt: any };
export default function PostsScreen() {
const router = useRouter();
const [posts, setPosts] = useState<Post[]>([]);
useEffect(() => {
// 실시간 구독
const q = query(collection(db, 'posts'), orderBy('createdAt', 'desc'));
const unsubscribe = onSnapshot(q, (snapshot) => {
const data = snapshot.docs.map((doc) => ({
id: doc.id,
...doc.data(),
})) as Post[];
setPosts(data);
});
return unsubscribe; // 언마운트 시 구독 해제
}, []);
return (
<View style={styles.container}>
<FlatList
data={posts}
keyExtractor={(item) => item.id}
renderItem={({ item }) => (
<TouchableOpacity
style={styles.item}
onPress={() => router.push(`/post/${item.id}`)}
>
<Text style={styles.title}>{item.title}</Text>
<Text style={styles.author}>{item.authorName}</Text>
</TouchableOpacity>
)}
/>
<TouchableOpacity
style={styles.fab}
onPress={() => router.push('/post/create')}
>
<Text style={styles.fabText}>+</Text>
</TouchableOpacity>
</View>
);
}
const styles = StyleSheet.create({
container: { flex: 1 },
item: {
padding: 16,
borderBottomWidth: 1,
borderBottomColor: '#eee',
backgroundColor: '#fff',
},
title: { fontSize: 16, fontWeight: '600', marginBottom: 4 },
author: { fontSize: 13, color: '#999' },
fab: {
position: 'absolute',
bottom: 24,
right: 24,
width: 56,
height: 56,
borderRadius: 28,
backgroundColor: '#007AFF',
alignItems: 'center',
justifyContent: 'center',
shadowColor: '#000',
shadowOpacity: 0.2,
shadowRadius: 8,
elevation: 4,
},
fabText: { color: '#fff', fontSize: 28, fontWeight: '300' },
});
데이터 삭제 (Delete)
import { doc, deleteDoc } from 'firebase/firestore';
import { db } from '@/lib/firebase';
const deletePost = async (postId: string) => {
await deleteDoc(doc(db, 'posts', postId));
console.log('삭제 완료');
};
Firestore 보안 규칙
Firebase 콘솔 → Firestore → 규칙 탭에서 설정합니다.
기본적으로 로그인한 사용자만 본인의 데이터를 읽고 쓸 수 있도록 설정하는 것이 좋습니다.
// Firestore 보안 규칙 예시
rules_version = '2';
service cloud.firestore {
match /databases/{database}/documents {
// users 컬렉션: 본인 문서만 읽기/쓰기
match /users/{userId} {
allow read, write: if request.auth != null && request.auth.uid == userId;
}
// posts 컬렉션: 로그인한 사용자는 읽기 가능, 본인 글만 쓰기/삭제
match /posts/{postId} {
allow read: if request.auth != null;
allow create: if request.auth != null;
allow update, delete: if request.auth != null
&& request.auth.uid == resource.data.authorId;
}
}
}
정리
| 기능 | Firebase 서비스 | 주요 함수 |
|---|---|---|
| 회원가입 | Authentication | createUserWithEmailAndPassword() |
| 로그인 | Authentication | signInWithEmailAndPassword() |
| 로그아웃 | Authentication | signOut() |
| 로그인 상태 감지 | Authentication | onAuthStateChanged() |
| 데이터 추가 | Firestore | addDoc() |
| 데이터 읽기 | Firestore | getDoc(), getDocs() |
| 실시간 구독 | Firestore | onSnapshot() |
| 데이터 삭제 | Firestore | deleteDoc() |
다음 편에서는 완성된 앱을 EAS로 빌드하고 앱스토어에 배포하는 방법을 살펴보겠습니다.
시리즈 목차
- 1편 Expo 개발 환경 세팅 & 첫 앱 실행
- 2편 화면 구성 & 핵심 컴포넌트
- 3편 화면 이동 구현 (Expo Router)
- 4편 상태 관리 & API 연동
- 5편 디바이스 기능 활용 (카메라, 위치, 알림)
- 6편 Firebase 연동 ← 현재 글
- 7편 빌드 & 앱스토어 배포