Firestore로 실시간 채팅 구현 - React Native에서 쓸 때 주의할 것들
실시간 메시지 동기화와 키보드 레이아웃 이슈
앱에 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는 소규모 채팅에서는 편하지만 트래픽이 늘면 비용이 빠르게 올라간다. 사용량 모니터링은 필수.