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를 손쉽게 구축할 수 있습니다. 위 패턴을 토대로 컬렉션 분리, 인덱스 전략, 페이지네이션을 추가해 실제 서비스에 적용해보세요.
'React' 카테고리의 다른 글
| React에서 이미지 드래그 앤 드롭 정렬 기능 구현하기 (0) | 2026.05.25 |
|---|---|
| React 컴포넌트에서 성능 저하 원인 분석 및 제거 방법 (0) | 2026.05.25 |
| React 앱에서 브라우저 히스토리 API 활용하기 (0) | 2026.05.22 |
| React에서 키보드 단축키 시스템 구축하기 (0) | 2026.05.22 |
| React로 차트 컴포넌트 직접 설계 및 최적화하기 (0) | 2026.05.22 |