logo

DowanKim

5. 방명록을 구현해보자.

2025년 9월 14일

졸업논문 대체 웹사이트

방명록 구현 과정

전체적인 구현 과정은 다음과 같습니다.

1. 사용자가 메시지 작성 → SelectBox에서 폼 입력

2. 전송 버튼 클릭 → handleSendMessage 호출

3. postMessage 실행 → sendGuestbookMessage로 Firebase에 저장

4. Firebase에 저장 → serverTimestamp()로 서버 시간 기록

5. 실시간 구독 감지 → onValue가 변경 감지

6. 콜백 실행 → subscribeGuestbookhandleValue 호출

7. 데이터 정렬 → createdAt 기준 오름차순 정렬

8. React Query 캐시 업데이트 → queryClient.setQueryData로 캐시 갱신

9. 컴포넌트 리렌더링 → useGuestbookStream이 새 데이터 반환

10. UI 업데이트 → ResultSection이 새 메시지 표시

목표

전시장 관객이 디자이너에게 축하 메시지를 남기고, 실시간으로 모든 메시지를 확인할 수 있는 방명록 기능을 구현합니다.

아래는 구현 과정과 문제 및 해결과정을 설명한 글입니다.


1단계: 데이터 구조 설계

문제: 방명록 메시지 데이터 구조 정의

메시지에 필요한 정보를 정의해야 합니다.

해결: GuestbookMessage 인터페이스 정의

export interface GuestbookMessage { sender: string; message: string; receiver: string; createdAt?: number; }
  • sender: 보내는 사람 이름
  • message: 메시지 내용
  • receiver: 받는 사람 이름 (디자이너 이름 또는 "ALL")
  • createdAt: 생성 시각 (서버 타임스탬프)

문제: Firebase Realtime Database 구조

모든 메시지를 저장하고 실시간으로 동기화해야 합니다.

해결: 단일 경로에 메시지 리스트 저장

const database = getDatabase(app); const GUESTBOOK_PATH = "guestbook";

Firebase 구조:

{ "guestbook": { "-Nxxxxx1": { "sender": "노을", "message": "원합니다", "receiver": "박세은", "createdAt": 1234567890 }, "-Nxxxxx2": { "sender": "노을", "message": "내가 살기 위해서", "receiver": "ALL", "createdAt": 1234567891 } } }
  • guestbook 경로에 모든 메시지 저장
  • Firebase가 자동 생성한 키(-Nxxxxx)로 메시지 식별
  • 실시간 동기화 가능

2단계: 메시지 전송 서비스 함수 구현

문제: 새 메시지를 Firebase에 저장

사용자가 작성한 메시지를 데이터베이스에 저장해야 합니다.

해결: push와 serverTimestamp 사용

export async function sendGuestbookMessage( message: GuestbookMessage ): Promise<void> { const messagesRef = ref(database, GUESTBOOK_PATH); await push(messagesRef, { ...message, createdAt: serverTimestamp(), }); }
  • ref(database, GUESTBOOK_PATH): guestbook 경로 참조
  • push(messagesRef, {...}): 리스트에 새 항목 추가, Firebase가 고유 키 생성
  • serverTimestamp(): 서버 시간 사용으로 클라이언트 시간 차이 방지

문제: 서버 시간 사용 이유

클라이언트 시간은 부정확할 수 있습니다.

해결: serverTimestamp() 사용

  • 서버 시간으로 일관된 정렬 보장
  • 타임존 문제 방지

3단계: 실시간 구독 서비스 함수 구현

문제: 새 메시지가 추가되면 즉시 화면에 반영

폴링 대신 실시간 업데이트가 필요합니다.

해결: onValue로 실시간 구독

export function subscribeGuestbook( onMessages: (messages: GuestbookMessage[]) => void ): () => void { const messagesRef = ref(database, GUESTBOOK_PATH); const handleValue = (snapshot: DataSnapshot) => { const value = snapshot.val() as Record< string, GuestbookMessage & { createdAt?: number } > | null; const list: GuestbookMessage[] = value ? Object.values(value) : []; 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; }); onMessages(list); }; onValue(messagesRef, handleValue); return () => { off(messagesRef, "value", handleValue); }; }
  • onValue(messagesRef, handleValue): guestbook 경로 변경 감지
  • snapshot.val(): 현재 데이터 가져오기
  • Object.values(value): 객체를 배열로 변환
  • list.sort(): createdAt 기준 오름차순 정렬
  • onMessages(list): 콜백으로 정렬된 리스트 전달
  • return () => { off(...) }: 구독 해제 함수 반환

문제: 메시지 정렬

시간순으로 정렬해야 합니다.

해결: createdAt 기준 정렬

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; });
  • typeof a.createdAt === "number"로 타입 체크
  • serverTimestamp()는 초기에는 객체일 수 있어 기본값 0 사용
  • 오름차순 정렬로 오래된 메시지부터 표시

4단계: React Query로 상태 관리

문제: 실시간 스트림을 React Query와 통합

실시간 업데이트를 React Query 캐시에 반영해야 합니다.

해결: useSuspenseQuery + useEffect 조합

export function useGuestbookStream() { const queryClient = useQueryClient(); const { data } = useSuspenseQuery< GuestbookMessage[], Error, GuestbookMessage[] >({ queryKey: ["guestbook"], queryFn: async () => [], staleTime: Infinity, }); useEffect(() => { const unsubscribe = subscribeGuestbook(list => { queryClient.setQueryData(["guestbook"], list); }); return unsubscribe; }, [queryClient]); return data ?? []; }
  • queryKey: ["guestbook"]: 캐시 키
  • queryFn: async () => []: 초기값은 빈 배열 (실제 데이터는 구독으로 받음)
  • staleTime: Infinity: 자동 리페치 비활성화
  • useEffect에서 subscribeGuestbook 구독
  • queryClient.setQueryData: 구독 콜백에서 캐시 업데이트
  • return unsubscribe: cleanup에서 구독 해제

문제: 메시지 전송 후 상태 업데이트

전송 후 UI를 즉시 갱신해야 합니다.

해결: useMutation 사용

export function usePostGuestbookMessage() { return useMutation({ mutationFn: (payload: GuestbookMessage) => sendGuestbookMessage(payload), onSuccess: () => { // RTDB onValue 실시간 구독이 캐시를 갱신하므로 무효화 불필요 }, }); }
  • mutationFn: 메시지 전송 함수
  • onSuccess: 실시간 구독이 자동으로 캐시를 갱신하므로 수동 무효화 불필요

5단계: UI 컴포넌트 구현

문제: 메시지 작성 폼

받는 사람 선택, 보내는 사람 입력, 메시지 입력이 필요합니다.

해결: SelectBox 컴포넌트 구현

export const SelectBox = ({ toOptions, onToChange, onFromChange, onSendMessage, }: SelectBoxProps) => { const [toValue, setToValue] = useState(toOptions[0] || ""); const [fromValue, setFromValue] = useState(""); const [messageValue, setMessageValue] = useState(""); const [isToOpen, setIsToOpen] = useState(false); const handleToChange = (value: string) => { setToValue(value); setIsToOpen(false); onToChange?.(value); }; const handleFromChange = (value: string) => { setFromValue(value); onFromChange?.(value); }; const handleSend = () => { if (toValue && fromValue && messageValue) { onSendMessage?.(toValue, fromValue, messageValue); // 폼 초기화 setToValue(toOptions[0] || ""); setFromValue(""); setMessageValue(""); } };
  • toValue: 받는 사람 (드롭다운)
  • fromValue: 보내는 사람 (텍스트 입력)
  • messageValue: 메시지 내용 (textarea)
  • isToOpen: 드롭다운 열림 상태
  • handleSend: 전송 시 유효성 검사 후 전송 및 폼 초기화

문제: 메시지 표시

메시지를 카드 형태로 표시해야 합니다.

해결: ResultBox 컴포넌트 구현

export const ResultBox = ({ sender, message, receiver, isEmpty = true, }: ResultBoxProps) => { return ( <Container> {!isEmpty ? ( <> <Receiver>To. {receiver}</Receiver> <Message>{message}</Message> <Sender>From. {sender}</Sender> </> ) : ( <EmptyMessage>메시지를 남겨보세요</EmptyMessage> )} </Container> ); };
  • Receiver: 받는 사람 표시
  • Message: 메시지 내용 (줄바꿈 지원)
  • Sender: 보내는 사람 표시
  • isEmpty: 빈 상태 표시

문제: 메시지를 3개 컬럼으로 균등 분배

높이를 기준으로 가장 낮은 곳에 새로운 메세지가 들어가야 합니다.

해결: 높이 기반 분배 알고리즘

export const ResultSection = ({ messages }: ResultSectionProps) => { // 각 컬럼의 메시지들을 분배 const columns: Message[][] = [[], [], []]; messages.forEach((message, messageIndex) => { // 처음 3개 메시지는 순서대로 배치 if (messageIndex < 3) { columns[messageIndex].push(message); return; } // 4번째 메시지부터는 높이를 고려해서 배치 const columnHeights = columns.map(col => { if (col.length === 0) return 0; // 각 ResultBox의 실제 높이 계산 const gapHeight = 0.83; // vw 단위 let totalHeight = 0; col.forEach((msg, index) => { // 메시지 길이에 따른 높이 추정 const messageLines = Math.max(1, Math.ceil(msg.message.length / 30)); // 한 줄당 약 30자 const estimatedHeight = Math.max( 11.67, 11.67 + (messageLines - 1) * 1.5 ); // 줄당 약 1.5vw 추가 totalHeight += estimatedHeight; if (index > 0) totalHeight += gapHeight; }); return totalHeight; }); const lowestColumnIndex = columnHeights.indexOf(Math.min(...columnHeights)); columns[lowestColumnIndex].push(message); });
  • 처음 3개는 각 컬럼에 하나씩 배치
  • 4번째부터는 각 컬럼의 높이를 계산해 가장 낮은 컬럼에 배치
  • 메시지 길이로 높이 추정
  • gapHeight로 간격 고려

6단계: 메인 페이지에서 통합

문제: 메시지 전송 및 실시간 표시

전송과 표시를 연결해야 합니다.

해결: Visitor 페이지에서 통합

export const Visitor = () => { const { mutateAsync: postMessage } = usePostGuestbookMessage(); const messages = useGuestbookStream(); const handleSendMessage = async ( toValue: string, fromValue: string, messageValue: string ) => { await postMessage({ sender: fromValue, message: messageValue, receiver: toValue, }); // 구독 콜백이 목록을 갱신합니다 (낙관적 업데이트 불필요) }; return ( <Container> <Title>VISITOR'S BOOK</Title> <Tag>응원의 한마디를 남겨보세요.</Tag> <MainContainer> <VisitorContainer> <SelectBox toOptions={[ "ALL", "고영은", "공태우", "김가빈", "김관욱", "김도완", "김민채", "김예솔", "김진혁", "남현서", "박세은", "박정훈", "정일후", "천후민", "최보윤", ]} onSendMessage={handleSendMessage} /> </VisitorContainer> <ErrorBoundary> <Suspense fallback={<SuspenseFallback />}> <ResultSection messages={messages} /> </Suspense> </ErrorBoundary> </MainContainer> </Container> ); };
  • usePostGuestbookMessage(): 메시지 전송 훅
  • useGuestbookStream(): 실시간 메시지 스트림
  • handleSendMessage: 전송 처리
  • SelectBox: 메시지 작성 폼
  • ResultSection: 메시지 표시 (3개 컬럼)

Firebase Realtime Database의 onValue로 새 메시지를 즉시 반영하고, React Query 캐시로 상태를 관리합니다.