6. 방명록에 실시간 구독 방식을 채택했습니다! (그게 뭔데 그래서)
2025년 9월 23일
졸업논문 대체 웹사이트
앞선 글에서 Firebase Realtime Database의 실시간 구독방식을 방명록 구현에 사용하였다고 했습니다. 이 과정에서 선택 이유와 구현 과정을 좀 더 구체적으로 글을 작성하고자 합니다.
실시간 구독이 필요한 이유
일반적인 방식의 한계
일반적으로는 메시지 제출 후 목록을 다시 가져옵니다:
// 일반적인 방식 const handleSend = async () => { await postMessage(message); // 제출 후 목록 다시 가져오기 const messages = await fetchMessages(); setMessages(messages); };
문제점:
- 내가 보낸 메시지만 반영됨
- 다른 사용자가 보낸 메시지는 보이지 않음
- 다른 사람 메시지를 보려면 새로고침 필요
방명록에서 실시간 구독이 필요한 이유
전시장 환경:
- 여러 관객이 동시에 사용(전시장 곳곳에 qr이 있어서 모바일로도 작성하므로)
- 한 사람이 보낸 메시지를 다른 사람도 즉시 봐야 함
- 새로고침 없이 자동 업데이트 필요
시나리오:
- 사용자 A가 메시지 전송 → 자신의 화면에 반영
- 사용자 B가 메시지 전송 → 자신의 화면에 반영
- 문제: 사용자 A는 사용자 B의 메시지를 볼 수 없음
해결 방법 비교:
방법 1: 제출할 때마다 다시 가져오기
async function sendMessage(message) { await fetch('/api/messages', { method: 'POST', ... }); const messages = await fetch('/api/messages').then(r => r.json()); setMessages(messages); }
- 내 메시지만 보임
- 다른 사람 메시지는 보이지 않음
방법 2: 폴링 (주기적으로 요청)
setInterval(() => { fetch('/api/messages').then(updateMessages); }, 5000);
- 다른 사람 메시지도 보임
- 최대 5초 지연
- 불필요한 요청 발생
방법 3: 실시간 구독 (Firebase) ✅
onValue(messagesRef, (snapshot) => { updateMessages(snapshot.val()); });
- 다른 사람 메시지도 즉시 반영
- 지연 없음
- 효율적
방명록 구현 코드 분석
1단계: 메시지 전송 함수
// src/services/guestbook.ts export async function sendGuestbookMessage( message: GuestbookMessage ): Promise<void> { const messagesRef = ref(database, GUESTBOOK_PATH); await push(messagesRef, { ...message, createdAt: serverTimestamp(), }); }
동작:
ref(database, "guestbook"): 경로 참조 생성push(messagesRef, {...}): 리스트에 추가, Firebase가 고유 키 생성serverTimestamp(): 서버 시간 사용
결과:
{ "guestbook": { "-Nabc123": { "sender": "노을", "message": "내가 살기 위해서", "receiver": "박세은", "createdAt": 1234567890 } } }
2단계: 실시간 구독 함수
// src/services/guestbook.ts export function subscribeGuestbook( onMessages: (messages: GuestbookMessage[]) => void ): () => void { const messagesRef = ref(database, GUESTBOOK_PATH); const handleValue = (snapshot: DataSnapshot) => { // 1. 데이터 가져오기 const value = snapshot.val() as Record<string, GuestbookMessage> | null; // 2. 객체를 배열로 변환 const list: GuestbookMessage[] = value ? Object.values(value) : []; // 3. 시간순으로 정렬 list.sort((a, b) => { const aTime = typeof a.createdAt === "number" ? a.createdAt : 0; const bTime = typeof b.createdAt === "number" ? b.createdAt : 0; return aTime - bTime; }); // 4. 콜백 함수 호출 onMessages(list); }; // 5. 실시간 구독 시작 onValue(messagesRef, handleValue); // 6. 구독 해제 함수 반환 return () => { off(messagesRef, "value", handleValue); }; }
핵심:
onValue: 구독 시작, 변경 시handleValue실행handleValue: 데이터 변환 및 정렬 후 콜백 호출- 반환값: 구독 해제 함수
3단계: React 훅에서 구독 관리
// src/queries/guestbook.ts export function useGuestbookStream() { const queryClient = useQueryClient(); const { data } = useSuspenseQuery({ queryKey: ["guestbook"], queryFn: async () => [], staleTime: Infinity, }); useEffect(() => { // 1. 구독 시작 const unsubscribe = subscribeGuestbook(list => { // 2. React Query 캐시 업데이트 queryClient.setQueryData(["guestbook"], list); }); // 3. cleanup 함수: 컴포넌트 언마운트 시 구독 해제 return unsubscribe; }, [queryClient]); return data ?? []; }
동작:
- 컴포넌트 마운트 시
useEffect실행 subscribeGuestbook호출로 구독 시작unsubscribe함수를 cleanup으로 반환- 언마운트 시 자동으로 구독 해제
4단계: 페이지에서 사용
// src/Pages/Visitor/index.tsx export const Visitor = () => { const messages = useGuestbookStream(); // 훅만 호출 return ( <> <SelectBox onSendMessage={handleSendMessage} /> <ResultSection messages={messages} /> </> ); };
페이지는 훅만 호출합니다. 구독/해제는 훅 내부에서 처리됩니다.
실제 동작 시나리오
시나리오: 사용자 A가 메시지를 보내면 사용자 B의 화면에 즉시 반영
-
사용자 A가 메시지 전송
sendGuestbookMessage({ sender: "노을", message: "내가 살기 위해서", receiver: "박세은" })→ Firebase에 저장
-
Firebase 데이터베이스 업데이트
{ "guestbook": { "-Nabc123": { "sender": "홍길동", "message": "축하합니다!", "receiver": "박세은", "createdAt": 1234567890 } } } -
Firebase가 모든 구독자에게 알림
onValue로 구독 중인 모든 클라이언트에 변경 알림
-
사용자 B의
handleValue콜백 실행handleValue(snapshot) { const value = snapshot.val(); // 최신 데이터 const list = Object.values(value); // 배열로 변환 list.sort(...); // 시간순 정렬 onMessages(list); // React Query 캐시 업데이트 } -
사용자 B의 화면 자동 업데이트
- React Query 캐시 업데이트
- 컴포넌트 리렌더링
- 새 메시지 표시
입력 필드가 초기화되지 않는 이유
내가 글을 작성하고 있는데 api가 자동으로 호출되면 내 글이 초기화 되지 않을까? 라는 질문이 있을 수 있습니다.
입력 필드는 로컬 state로 관리됨
// src/Pages/Visitor/SelectBox.tsx export const SelectBox = ({ onSendMessage }) => { const [toValue, setToValue] = useState(toOptions[0] || ""); const [fromValue, setFromValue] = useState(""); const [messageValue, setMessageValue] = useState(""); // ... };
- 입력 필드 값은
useState로 관리됩니다. - 실시간 구독은
messages리스트만 업데이트합니다. - 두 영역이 분리되어 있어 입력 필드는 영향받지 않습니다.
컴포넌트 구조
// Visitor 컴포넌트 export const Visitor = () => { const messages = useGuestbookStream(); // 실시간 구독 데이터 return ( <> <SelectBox onSendMessage={handleSendMessage} /> <ResultSection messages={messages} /> </> ); };
SelectBox: 입력 폼 (로컬 state)ResultSection: 메시지 목록 (실시간 구독 데이터)
React의 리렌더링 메커니즘
React는 다음 기준으로 리렌더링을 결정합니다:
- 컴포넌트의 props가 변경되었는가?
- 컴포넌트의 state가 변경되었는가?
- 부모 컴포넌트가 리렌더링되었는가? (단, props가 같으면 스킵 가능)
실제 동작 시나리오
시나리오: 사용자 A가 글을 쓰는 중에 사용자 B가 메시지를 보냄
-
사용자 A가 입력 중
// SelectBox 컴포넌트 내부 const [messageValue, setMessageValue] = useState(""); // 사용자가 "축하합니다!" 입력 중... // messageValue = "축하합니다!" -
사용자 B가 메시지 전송
// Firebase에 저장됨 -
실시간 구독 콜백 실행
// queries/guestbook.ts subscribeGuestbook(list => { queryClient.setQueryData(["guestbook"], list); // messages 리스트만 업데이트됨 }); -
컴포넌트 리렌더링
messages가 변경되어Visitor컴포넌트 리렌더링- React가 자식 컴포넌트 확인:
SelectBox: props가 변경되지 않음 → 리렌더링 스킵ResultSection:messagesprop이 변경됨 → 리렌더링
-
가상 DOM 비교
// 가상 DOM 비교 이전: <SelectBox ... /> (props: onSendMessage) 현재: <SelectBox ... /> (props: onSendMessage) // React: "props 동일 → 리렌더링 스킵" -
실제 DOM 업데이트
// SelectBox의 실제 DOM <input value="축하합니다!" // ← 이 값은 그대로 유지됨 onChange={...} /> // messages 변경되어도 // SelectBox는 리렌더링되지 않으므로 // input의 value는 그대로 유지됨 ✅
왜 SelectBox는 리렌더링되지 않나?
// Visitor 컴포넌트 <SelectBox onSendMessage={handleSendMessage} />
SelectBox에 전달되는 props는 변하지 않습니다.messages는SelectBox에 전달되지 않습니다.- React는 props가 변경되지 않으면 해당 컴포넌트를 리렌더링하지 않습니다.
전체 흐름
1. 사용자 A가 입력 중
SelectBox: messageValue = "축하합니다!" (로컬 state)
2. 사용자 B가 메시지 전송
Firebase 업데이트
3. 실시간 구독 콜백 실행
queryClient.setQueryData(["guestbook"], newMessages)
→ messages 변수만 업데이트
4. Visitor 컴포넌트 리렌더링
- messages 변경 감지
- React가 가상 DOM 생성 및 비교
- SelectBox: props 동일 → 리렌더링 스킵 ✅
- ResultSection: messages prop 변경 → 리렌더링 ✅
5. 실제 DOM 업데이트
- SelectBox: 아무것도 안 함 (입력값 유지) ✅
- ResultSection: 새 메시지 추가 ✅
구독 해제는 어디서하는데
useEffect의 cleanup 함수
useEffect(() => { const unsubscribe = subscribeGuestbook(...); // cleanup 함수: 컴포넌트 언마운트 시 실행 return unsubscribe; }, [queryClient]);
언제 실행되나:
- 컴포넌트가 언마운트될 때
- 의존성 배열의 값이 변경될 때
왜 필요한가:
- 메모리 누수 방지
- 불필요한 네트워크 연결 해제
- 성능 최적화
전체 생명주기
1. Visitor 컴포넌트 마운트
↓
2. useGuestbookStream() 훅 실행
↓
3. useEffect 실행 → subscribeGuestbook() 호출
↓
4. onValue()로 Firebase 구독 시작
↓
5. [사용자가 다른 페이지로 이동]
↓
6. Visitor 컴포넌트 언마운트
↓
7. useEffect cleanup 실행 → unsubscribe() 호출
↓
8. off()로 Firebase 구독 해제
사용된 개념
Firebase 함수들
| 함수 | 역할 |
|---|---|
ref(database, path) | 데이터베이스 경로 참조 객체 생성 |
push(ref, data) | 리스트에 새 항목 추가 (고유 키 자동 생성) |
serverTimestamp() | 서버 시간 사용 (클라이언트 시간 차이 방지) |
onValue(ref, callback) | 실시간 구독 시작 (변경 시 콜백 실행) |
off(ref, event, callback) | 구독 해제 |
실시간 구독
- 한 번
onValue를 호출하면, 해당 경로의 데이터가 변경될 때마다 자동으로 콜백이 실행됩니다. - 폴링처럼 주기적으로 요청할 필요가 없고, Firebase가 변경을 감지해 알려줍니다.
React와의 통합
useEffect로 구독 시작- cleanup 함수로 구독 해제
- React Query로 상태 관리
- 컴포넌트는 훅만 호출하면 됨
리렌더링 최적화
- Props가 변경되지 않으면 리렌더링 스킵
- 가상 DOM 비교로 불필요한 실제 DOM 업데이트 방지
- 로컬 state는 리렌더링과 독립적으로 관리
Firebase Realtime Database의 실시간 구독을 사용하면:
- 새 메시지가 추가되면 모든 사용자 화면에 즉시 반영
- 불필요한 요청 없이 효율적으로 동작
- React의
useEffectcleanup으로 안전하게 구독 해제 - 입력 필드는 로컬 state로 관리되어 초기화되지 않음
- React의 리렌더링 최적화로 불필요한 업데이트 방지
이 방식으로 전시장에서 실시간으로 방명록이 업데이트되면서도, 사용자가 글을 쓰는 중에 입력 필드가 초기화되지 않는 경험을 제공할 수 있습니다.