logo

DowanKim

6. 방명록에 실시간 구독 방식을 채택했습니다! (그게 뭔데 그래서)

2025년 9월 23일

졸업논문 대체 웹사이트

앞선 글에서 Firebase Realtime Database의 실시간 구독방식을 방명록 구현에 사용하였다고 했습니다. 이 과정에서 선택 이유와 구현 과정을 좀 더 구체적으로 글을 작성하고자 합니다.

실시간 구독이 필요한 이유

일반적인 방식의 한계

일반적으로는 메시지 제출 후 목록을 다시 가져옵니다:

// 일반적인 방식 const handleSend = async () => { await postMessage(message); // 제출 후 목록 다시 가져오기 const messages = await fetchMessages(); setMessages(messages); };

문제점:

  • 내가 보낸 메시지만 반영됨
  • 다른 사용자가 보낸 메시지는 보이지 않음
  • 다른 사람 메시지를 보려면 새로고침 필요

방명록에서 실시간 구독이 필요한 이유

전시장 환경:

  • 여러 관객이 동시에 사용(전시장 곳곳에 qr이 있어서 모바일로도 작성하므로)
  • 한 사람이 보낸 메시지를 다른 사람도 즉시 봐야 함
  • 새로고침 없이 자동 업데이트 필요

시나리오:

  1. 사용자 A가 메시지 전송 → 자신의 화면에 반영
  2. 사용자 B가 메시지 전송 → 자신의 화면에 반영
  3. 문제: 사용자 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(), }); }

동작:

  1. ref(database, "guestbook"): 경로 참조 생성
  2. push(messagesRef, {...}): 리스트에 추가, Firebase가 고유 키 생성
  3. 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 ?? []; }

동작:

  1. 컴포넌트 마운트 시 useEffect 실행
  2. subscribeGuestbook 호출로 구독 시작
  3. unsubscribe 함수를 cleanup으로 반환
  4. 언마운트 시 자동으로 구독 해제

4단계: 페이지에서 사용

// src/Pages/Visitor/index.tsx export const Visitor = () => { const messages = useGuestbookStream(); // 훅만 호출 return ( <> <SelectBox onSendMessage={handleSendMessage} /> <ResultSection messages={messages} /> </> ); };

페이지는 훅만 호출합니다. 구독/해제는 훅 내부에서 처리됩니다.

실제 동작 시나리오

시나리오: 사용자 A가 메시지를 보내면 사용자 B의 화면에 즉시 반영

  1. 사용자 A가 메시지 전송

    sendGuestbookMessage({ sender: "노을", message: "내가 살기 위해서", receiver: "박세은" })

    → Firebase에 저장

  2. Firebase 데이터베이스 업데이트

    { "guestbook": { "-Nabc123": { "sender": "홍길동", "message": "축하합니다!", "receiver": "박세은", "createdAt": 1234567890 } } }
  3. Firebase가 모든 구독자에게 알림

    • onValue로 구독 중인 모든 클라이언트에 변경 알림
  4. 사용자 B의 handleValue 콜백 실행

    handleValue(snapshot) { const value = snapshot.val(); // 최신 데이터 const list = Object.values(value); // 배열로 변환 list.sort(...); // 시간순 정렬 onMessages(list); // React Query 캐시 업데이트 }
  5. 사용자 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는 다음 기준으로 리렌더링을 결정합니다:

  1. 컴포넌트의 props가 변경되었는가?
  2. 컴포넌트의 state가 변경되었는가?
  3. 부모 컴포넌트가 리렌더링되었는가? (단, props가 같으면 스킵 가능)

실제 동작 시나리오

시나리오: 사용자 A가 글을 쓰는 중에 사용자 B가 메시지를 보냄

  1. 사용자 A가 입력 중

    // SelectBox 컴포넌트 내부 const [messageValue, setMessageValue] = useState(""); // 사용자가 "축하합니다!" 입력 중... // messageValue = "축하합니다!"
  2. 사용자 B가 메시지 전송

    // Firebase에 저장됨
  3. 실시간 구독 콜백 실행

    // queries/guestbook.ts subscribeGuestbook(list => { queryClient.setQueryData(["guestbook"], list); // messages 리스트만 업데이트됨 });
  4. 컴포넌트 리렌더링

    • messages가 변경되어 Visitor 컴포넌트 리렌더링
    • React가 자식 컴포넌트 확인:
      • SelectBox: props가 변경되지 않음 → 리렌더링 스킵
      • ResultSection: messages prop이 변경됨 → 리렌더링
  5. 가상 DOM 비교

    // 가상 DOM 비교 이전: <SelectBox ... /> (props: onSendMessage) 현재: <SelectBox ... /> (props: onSendMessage) // React: "props 동일 → 리렌더링 스킵"
  6. 실제 DOM 업데이트

    // SelectBox의 실제 DOM <input value="축하합니다!" // ← 이 값은 그대로 유지됨 onChange={...} /> // messages 변경되어도 // SelectBox는 리렌더링되지 않으므로 // input의 value는 그대로 유지됨 ✅

왜 SelectBox는 리렌더링되지 않나?

// Visitor 컴포넌트 <SelectBox onSendMessage={handleSendMessage} />
  • SelectBox에 전달되는 props는 변하지 않습니다.
  • messagesSelectBox에 전달되지 않습니다.
  • 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의 useEffect cleanup으로 안전하게 구독 해제
  • 입력 필드는 로컬 state로 관리되어 초기화되지 않음
  • React의 리렌더링 최적화로 불필요한 업데이트 방지

이 방식으로 전시장에서 실시간으로 방명록이 업데이트되면서도, 사용자가 글을 쓰는 중에 입력 필드가 초기화되지 않는 경험을 제공할 수 있습니다.