5. 방명록을 구현해보자.
2025년 9월 14일
졸업논문 대체 웹사이트
방명록 구현 과정
전체적인 구현 과정은 다음과 같습니다.
1. 사용자가 메시지 작성 → SelectBox에서 폼 입력
2. 전송 버튼 클릭 → handleSendMessage 호출
3. postMessage 실행 → sendGuestbookMessage로 Firebase에 저장
4. Firebase에 저장 → serverTimestamp()로 서버 시간 기록
5. 실시간 구독 감지 → onValue가 변경 감지
6. 콜백 실행 → subscribeGuestbook의 handleValue 호출
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 캐시로 상태를 관리합니다.