클레딧 앱에 DM 기능을 붙였다. 기술 선택은 Firestore. Django 서버에 채팅 테이블을 만드는 것보다 실시간 동기화가 기본으로 제공되기 때문.

Firestore 데이터 구조

chats/
  {chatRoomId}/
    messages/
      {messageId}/
        text: string
        sender_id: string
        created_at: Timestamp
        is_read: boolean
    participants: [userId1, userId2]
    last_message: string
    last_message_at: Timestamp

채팅방 목록은 chats 컬렉션에, 메시지는 chats/{id}/messages 서브컬렉션에.

실시간 구독

const useMessages = (chatRoomId: string) => {
  const [messages, setMessages] = useState<Message[]>([]);

  useEffect(() => {
    const messagesRef = collection(db, 'chats', chatRoomId, 'messages');
    const q = query(messagesRef, orderBy('created_at', 'asc'));

    const unsubscribe = onSnapshot(q, (snapshot) => {
      const msgs = snapshot.docs.map(doc => ({
        id: doc.id,
        ...doc.data(),
      })) as Message[];
      setMessages(msgs);
    });

    return () => unsubscribe();  // cleanup
  }, [chatRoomId]);

  return messages;
};

onSnapshot이 실시간 리스너다. 컴포넌트 언마운트 시 unsubscribe() 호출하지 않으면 메모리 누수.

메시지 전송

const sendMessage = async (text: string) => {
  const chatRef = doc(db, 'chats', chatRoomId);
  const messagesRef = collection(db, 'chats', chatRoomId, 'messages');

  const batch = writeBatch(db);

  // 메시지 추가
  const newMsgRef = doc(messagesRef);
  batch.set(newMsgRef, {
    text,
    sender_id: currentUserId,
    created_at: serverTimestamp(),
    is_read: false,
  });

  // 채팅방 last_message 업데이트
  batch.update(chatRef, {
    last_message: text,
    last_message_at: serverTimestamp(),
  });

  await batch.commit();
};

writeBatch로 메시지 추가와 채팅방 메타 업데이트를 원자적으로 처리. 메시지만 저장되고 last_message 업데이트 실패하는 케이스를 막기 위해.

Android 키보드 이슈

iOS에서는 잘 됐는데 Android에서 키보드가 올라올 때 채팅 입력창이 키보드 뒤에 가려졌다.

// Android 키보드 동작 설정
<KeyboardAvoidingView
  behavior={Platform.OS === 'ios' ? 'padding' : 'height'}
  style=
  keyboardVerticalOffset={Platform.OS === 'ios' ? 90 : 0}
>

Android에서 behavior='padding'은 잘 안 됐다. height로 바꾸니 해결됐는데, 이건 기기마다 동작이 달라서 테스트 기기를 여러 개로 확인해야 했다.

AndroidManifest.xml 설정도 맞춰야 한다.

<activity
  android:windowSoftInputMode="adjustResize">

adjustPan은 화면 전체가 위로 올라가는 방식이라 채팅 UI에서 어색했다. adjustResize가 맞다.

스로틀링

채팅 입력 중 키 입력마다 Firestore 쓰기가 나가면 비용 문제가 생긴다. 일반 텍스트 메시지는 전송 버튼 누를 때만 쓰면 되는데, “입력 중…” 상태 표시를 위한 타이핑 인디케이터는 쓰로틀이 필요했다.

const throttledUpdateTyping = useCallback(
  throttle(async (isTyping: boolean) => {
    await updateDoc(doc(db, 'chats', chatRoomId), {
      [`typing.${currentUserId}`]: isTyping
    });
  }, 2000),  // 2초마다 최대 1회
  [chatRoomId]
);

자동 스크롤

새 메시지 도착 시 FlatList를 아래로 자동 스크롤.

const flatListRef = useRef<FlatList>(null);

useEffect(() => {
  if (messages.length > 0) {
    flatListRef.current?.scrollToEnd({ animated: true });
  }
}, [messages.length]);

messages 자체가 아니라 messages.length를 dependency로 쓴 건 의도적이다. 메시지 내용이 바뀌어도(읽음 처리 등) 스크롤이 트리거되지 않게.

Firestore 실시간 DB는 소규모 채팅에서는 편하지만 트래픽이 늘면 비용이 빠르게 올라간다. 사용량 모니터링은 필수.