본문 바로가기

React

React와 Firebase Firestore 실시간 데이터 연동하기

React에서 Firebase Firestore를 이용해 실시간으로 데이터가 흐르는 UI를 만드는 방법을 정리했습니다. onSnapshot으로 구독하고, CRUD를 구성하며, 커스텀 훅으로 재사용성을 높이는 실무 패턴을 다룹니다.

1. 목표와 개요

- 실시간 데이터: Firestore의 onSnapshot으로 컬렉션/문서 변경을 즉시 반영합니다.

- 안전한 CRUD: 서버 타임스탬프와 최소한의 검증으로 안정적인 쓰기를 구성합니다.

- 재사용성: 커스텀 훅으로 구독 로직을 캡슐화합니다.

- 성능/비용: limit, 인덱스, 옵저버 해제 등 필수 최적화를 적용합니다.

2. 설치와 초기 설정

프로젝트에 Firebase를 설치하고 Firestore 인스턴스를 초기화합니다. Vite 기준 환경변수를 사용합니다.

// 1) 설치
// npm i firebase

// 2) src/lib/firebase.js
import { initializeApp } from 'firebase/app'
import { getFirestore, enableIndexedDbPersistence, serverTimestamp } from 'firebase/firestore'

const firebaseConfig = {
  apiKey: import.meta.env.VITE_FIREBASE_API_KEY,
  authDomain: import.meta.env.VITE_FIREBASE_AUTH_DOMAIN,
  projectId: import.meta.env.VITE_FIREBASE_PROJECT_ID,
  storageBucket: import.meta.env.VITE_FIREBASE_STORAGE_BUCKET,
  messagingSenderId: import.meta.env.VITE_FIREBASE_MESSAGING_SENDER_ID,
  appId: import.meta.env.VITE_FIREBASE_APP_ID,
}

const app = initializeApp(firebaseConfig)
const db = getFirestore(app)

// 옵셔널: 오프라인 퍼시스턴스
if (typeof window !== 'undefined') {
  enableIndexedDbPersistence(db).catch(() => {
    // 여러 탭 혹은 브라우저 제한으로 실패할 수 있음. 무시 가능
  })
}

export { db, serverTimestamp }

- 환경변수 예시: VITE_FIREBASE_API_KEY, VITE_FIREBASE_AUTH_DOMAIN, VITE_FIREBASE_PROJECT_ID 등

- Firestore 콘솔에서 프로젝트 생성 및 Firestore 활성화가 필요합니다.

3. 실시간 구독(onSnapshot)으로 목록 렌더링

onSnapshot은 해당 쿼리에 매칭되는 문서들의 변경을 스트림으로 전달합니다. 구독 해제(unsubscribe)를 반드시 반환하세요.

// src/components/TodoList.jsx
import { useEffect, useState } from 'react'
import { collection, query, orderBy, limit, onSnapshot } from 'firebase/firestore'
import { db } from '../lib/firebase'

function TodoList() {
  const [todos, setTodos] = useState([])
  const [loading, setLoading] = useState(true)
  const [error, setError] = useState(null)

  useEffect(() => {
    const q = query(
      collection(db, 'todos'),
      orderBy('createdAt', 'desc'),
      limit(20)
    )

    const unsub = onSnapshot(
      q,
      (snap) => {
        const data = snap.docs.map((d) => ({ id: d.id, ...d.data() }))
        setTodos(data)
        setLoading(false)
      },
      (err) => {
        setError(err)
        setLoading(false)
      }
    )

    return () => unsub()
  }, [])

  if (loading) return <p>로딩...</p>
  if (error) return <p>에러: {error.message}</p>

  return (
    <ul>
      {todos.map((t) => (
        <li key={t.id}>{t.text}{t.done ? ' ✅' : ''}</li>
      ))}
    </ul>
  )
}

export default TodoList

- orderBy와 where를 함께 쓰는 경우 해당 필드 조합에 대한 인덱스가 필요할 수 있습니다.

4. CRUD: 추가/수정/삭제 패턴

작성 시 serverTimestamp를 사용해 정렬 가능한 타임스탬프를 기록합니다.

// src/components/TodoForm.jsx
import { useState } from 'react'
import { addDoc, collection } from 'firebase/firestore'
import { db, serverTimestamp } from '../lib/firebase'

function TodoForm() {
  const [text, setText] = useState('')

  const submit = async (e) => {
    e.preventDefault()
    const trimmed = text.trim()
    if (!trimmed) return

    await addDoc(collection(db, 'todos'), {
      text: trimmed,
      createdAt: serverTimestamp(),
      done: false,
    })

    setText('')
  }

  return (
    <form onSubmit={submit}>
      <input
        value={text}
        onChange={(e) => setText(e.target.value)}
        placeholder="할 일을 입력하세요"
      />
      <button type="submit">추가</button>
    </form>
  )
}

export default TodoForm
// 업데이트/삭제 유틸
import { doc, updateDoc, deleteDoc } from 'firebase/firestore'
import { db } from '../lib/firebase'

export const toggleDone = async (todo) => {
  await updateDoc(doc(db, 'todos', todo.id), { done: !todo.done })
}

export const removeTodo = async (id) => {
  await deleteDoc(doc(db, 'todos', id))
}

- onSnapshot이 이미 실시간으로 반영하므로, 낙관적 업데이트는 선택 사항입니다. 단, 네트워크 지연 시 UX 향상을 위해 로컬 상태를 즉시 반영 후 실패 시 롤백하는 패턴을 적용할 수 있습니다.

5. 커스텀 훅으로 재사용성 높이기

구독/해제, 로딩/에러 상태를 훅으로 캡슐화합니다. 경로만 바꿔 다양한 컬렉션에 재사용할 수 있습니다.

// src/hooks/useCollection.js
import { useEffect, useMemo, useState } from 'react'
import { collection, onSnapshot, query as buildQuery } from 'firebase/firestore'
import { db } from '../lib/firebase'

export function useCollection(path, builder) {
  const [data, setData] = useState([])
  const [loading, setLoading] = useState(true)
  const [error, setError] = useState(null)

  const ref = useMemo(() => collection(db, path), [path])
  const queryRef = useMemo(() => (builder ? builder(ref) : ref), [ref, builder])

  useEffect(() => {
    setLoading(true)
    const unsub = onSnapshot(
      queryRef,
      (snap) => {
        setData(snap.docs.map((d) => ({ id: d.id, ...d.data() })))
        setLoading(false)
      },
      (err) => {
        setError(err)
        setLoading(false)
      }
    )
    return () => unsub()
  }, [queryRef])

  return { data, loading, error }
}

// 사용 예시
// import { orderBy, limit } from 'firebase/firestore'
// const { data: todos, loading } = useCollection('todos', (ref) => buildQuery(ref, orderBy('createdAt', 'desc'), limit(20)))

- 동일 쿼리로 여러 컴포넌트가 구독하면 비용이 늘어납니다. 상위에서 한 번 구독하고 props로 내려주거나, 캐싱/전역상태 라이브러리와 함께 사용하는 것을 고려합니다.

6. 성능/비용 최적화 체크리스트

- limit 필수: 실시간 스트림은 문서 수만큼 초기 스냅샷이 전송됩니다. 항상 limit로 절단합니다.

- 정렬/필터 인덱스: where + orderBy 조합은 복합 인덱스가 필요합니다. 콘솔 안내로 생성하세요.

- 구독 해제: useEffect에서 반드시 unsubscribe를 반환해 메모리/비용 누수를 방지합니다.

- 페이지네이션: startAfter 커서로 다음 페이지를 가져오고, 첫 페이지만 실시간, 나머지는 getDocs(비실시간) 혼합 전략을 씁니다.

- 필드 최소화: 문서 크기를 줄이고, 리스트 화면에는 필요한 필드만 보이도록 설계합니다.

- 오프라인 퍼시스턴스: enableIndexedDbPersistence로 네트워크 불안정 시 UX를 개선합니다.

7. 흔한 에러와 디버깅 팁

- Missing or insufficient permissions: Firestore 보안 규칙을 점검하세요. 인증 기반 소유권 필터(예: createdBy == request.auth.uid)를 적용합니다.

- Index required: where/orderBy 조합에서 에러 메시지에 인덱스 생성 링크가 포함됩니다. 링크로 즉시 생성하세요.

- Too many listeners: 컴포넌트 마운트마다 구독이 중복되지 않도록 의존성 배열과 unsubscribe를 확인합니다.

- Query requires an orderBy: 범위 필터(>, <= 등) 사용 시 동일 필드를 orderBy로 정렬해야 합니다.

8. 마치며

Firestore의 onSnapshot과 React 훅을 결합하면 별도의 소켓 없이도 실시간 UI를 손쉽게 구축할 수 있습니다. 위 패턴을 토대로 컬렉션 분리, 인덱스 전략, 페이지네이션을 추가해 실제 서비스에 적용해보세요.