Firebase 연동 — 로그인 & 데이터 저장




“백엔드 없이도 앱을 완성할 수 있습니다.”
이 글에서는 Firebase를 Expo 앱에 연동해 로그인과 데이터 저장을 구현하는 방법을 코드와 함께 살펴봅니다.


Firebase란?

Firebase는 **Google이 제공하는 앱 개발 플랫폼(BaaS)**입니다.
서버 없이도 인증, 데이터베이스, 파일 저장, 분석 등을 빠르게 구현할 수 있습니다.

서비스역할
Authentication이메일, 소셜 로그인
Firestore실시간 NoSQL 데이터베이스
Storage이미지, 파일 저장
Analytics사용자 행동 분석
Cloud Functions서버리스 함수 실행

이 글에서는 가장 많이 쓰이는 AuthenticationFirestore를 다룹니다.


1단계 — Firebase 프로젝트 생성

  1. firebase.google.com 접속 후 로그인
  2. 프로젝트 추가 클릭
  3. 프로젝트 이름 입력 후 생성
  4. 웹 앱 추가 (</> 아이콘 클릭)
  5. 앱 등록 후 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 서비스주요 함수
회원가입AuthenticationcreateUserWithEmailAndPassword()
로그인AuthenticationsignInWithEmailAndPassword()
로그아웃AuthenticationsignOut()
로그인 상태 감지AuthenticationonAuthStateChanged()
데이터 추가FirestoreaddDoc()
데이터 읽기FirestoregetDoc(), getDocs()
실시간 구독FirestoreonSnapshot()
데이터 삭제FirestoredeleteDoc()

다음 편에서는 완성된 앱을 EAS로 빌드하고 앱스토어에 배포하는 방법을 살펴보겠습니다.


시리즈 목차

  • 1편 Expo 개발 환경 세팅 & 첫 앱 실행
  • 2편 화면 구성 & 핵심 컴포넌트
  • 3편 화면 이동 구현 (Expo Router)
  • 4편 상태 관리 & API 연동
  • 5편 디바이스 기능 활용 (카메라, 위치, 알림)
  • 6편 Firebase 연동 ← 현재 글
  • 7편 빌드 & 앱스토어 배포



댓글 남기기