wan의 개발일기
[Flutter] 5. 읽음 표시 + 타이핑 인디케이터로 채팅 디테일 살리기 본문
들어가며
이번 글은 두 커밋을 같이 묶었는데, 사실 둘 사이엔 시간 차이가 꽤 있다. 읽음 표시는 3편(그룹 채팅) 바로 다음에 만들었고, 타이핑 인디케이터는 그로부터 한참 뒤 — 검색 기능, 메시지 삭제, 온라인 상태, 다크모드까지 다 만들고 나서 시리즈의 맨 마지막에 추가한 기능이다. 그 사이에 있는 기능들은 6~7편에서 다룬다.
주제가 둘 다 "실시간 상태를 Firestore로 어떻게 표현하느냐"라는 공통점이 있어서 한 편에 묶었다. 카카오톡의 "1" 안 읽음 숫자와 "상대방이 입력 중입니다"를 직접 만들어본 셈이다.
읽음 표시 — readBy 배열
메시지 문서마다 readBy 배열을 두고, 누가 이 메시지를 읽었는지 UID를 쌓는다. 메시지를 보낼 때 일단 내 UID를 기본으로 넣어둔다 — 내가 보낸 메시지는 당연히 내가 "읽은" 상태니까.
await FirebaseFirestore.instance
.collection('chats').doc(widget.chatId).collection('messages')
.add({
'text': text,
'senderId': currentUser.uid,
'senderEmail': currentUser.email,
'type': 'text',
// 보낸 사람은 자동으로 읽은 것으로 처리
'readBy': [currentUser.uid],
'timestamp': FieldValue.serverTimestamp(),
});
채팅방에 들어와서 메시지 목록을 받을 때마다, 아직 내 UID가 readBy에 없는 메시지를 찾아 업데이트한다. 배열에 값을 추가할 때는 FieldValue.arrayUnion()을 쓰면 중복 추가를 신경 쓸 필요가 없다 — 이미 들어있는 값이면 그냥 무시된다.
void _markMessagesAsRead(List<QueryDocumentSnapshot> messages) {
final currentUser = FirebaseAuth.instance.currentUser!;
for (final msg in messages) {
final data = msg.data() as Map<String, dynamic>;
final readBy = List<String>.from(data['readBy'] ?? []);
if (!readBy.contains(currentUser.uid)) {
msg.reference.update({
'readBy': FieldValue.arrayUnion([currentUser.uid]),
});
}
}
}
이 함수를
StreamBuilder의builder콜백 안에서, 메시지 목록을 받을 때마다 호출한다. 빌드 메서드 안에서 쓰기(write)를 일으키는 게 살짝 꺼려질 수 있는데,readBy에 이미 내 UID가 있으면update()자체를 호출하지 않게 막아뒀기 때문에 무한 루프나 과도한 쓰기로 이어지진 않는다.
안 읽은 사람 수는 "채팅방 멤버 수 - readBy 길이"로 계산한다. 그룹 채팅도 같은 방식으로 동작해서, 멤버가 여러 명이면 다 읽을 때까지 숫자가 줄어드는 걸 볼 수 있다.
final readBy = List<String>.from(msg['readBy'] ?? []);
final unreadCount = _memberCount - readBy.length;
if (unreadCount > 0) {
return Text('$unreadCount', style: const TextStyle(fontSize: 11, color: Colors.amber));
}
return const SizedBox.shrink();
이 숫자는 내가 보낸 메시지 옆에만 표시한다 — 카카오톡처럼 "내가 보낸 메시지를 상대가 몇 명이나 읽었는지"를 보여주는 용도라서, 상대방이 보낸 메시지에는 굳이 필요 없다.
타이핑 인디케이터 — typing 서브컬렉션
메시지 컬렉션과 별개로, chats/{chatId}/typing/{uid}라는 서브컬렉션을 새로 만들었다. 문서 하나에 isTyping 불리언만 두는, 거의 "현재 상태"만 표현하는 가벼운 구조다. 메시지처럼 쌓이는 데이터가 아니라 매번 같은 문서를 덮어쓴다.
입력창에 글자가 바뀔 때마다(onChanged) 내 타이핑 상태를 true로 올리고, Timer로 1초 뒤 자동으로 false로 내린다. 디바운싱과 비슷한 패턴인데, 매 글자마다 Firestore에 쓰지 않으려고 타이머를 계속 리셋하는 게 핵심이다.
void _onTypingChanged(String text) {
final currentUser = FirebaseAuth.instance.currentUser!;
// 타이핑 시작 알림
FirebaseFirestore.instance
.collection('chats').doc(widget.chatId)
.collection('typing').doc(currentUser.uid)
.set({'isTyping': true});
// 입력이 계속되면 타이머를 계속 리셋 (1초 디바운스)
_typingTimer?.cancel();
_typingTimer = Timer(const Duration(seconds: 1), () {
FirebaseFirestore.instance
.collection('chats').doc(widget.chatId)
.collection('typing').doc(currentUser.uid)
.set({'isTyping': false});
});
}
상대방의 타이핑 상태는 그 사람의 typing 문서를 구독해서 받는다. 1:1 채팅에서만 의미가 있어서 그룹 채팅(_isGroup)일 땐 구독하지 않는다.
FirebaseFirestore.instance
.collection('chats').doc(widget.chatId)
.collection('typing').doc(otherUid)
.snapshots()
.listen((snapshot) {
if (mounted) {
setState(() {
_isOtherTyping = snapshot.exists && (snapshot.data()?['isTyping'] ?? false);
});
}
});
화면을 나갈 때는 dispose()에서 내 타이핑 상태를 강제로 false로 내려준다. 타이머가 끝나기 전에 화면을 벗어나면 상대방 화면에 "입력 중..."이 계속 떠 있는 버그가 생기기 때문이다.
@override
void dispose() {
_typingTimer?.cancel();
final user = FirebaseAuth.instance.currentUser;
if (user != null) {
FirebaseFirestore.instance
.collection('chats').doc(widget.chatId)
.collection('typing').doc(user.uid)
.set({'isTyping': false});
}
_messageController.dispose();
_scrollController.dispose();
super.dispose();
}
Node.js로 Socket.io의
typing이벤트를 브로드캐스트하던 것과 비교하면, Firestore 방식은 "이벤트"가 아니라 "상태"를 저장한다는 점이 다르다. 연결이 잠깐 끊겨도 마지막 상태가 문서에 남아있으니, 재연결되면 자동으로 최신 상태를 다시 받는다. 다만 그만큼 읽기/쓰기 횟수가 과금에 들어가니, 디바운스 없이 글자마다 썼다면 비용이 꽤 늘었을 거다.
정리
| 기능 | 핵심 구조 / API |
|---|---|
| 읽음 처리 | 메시지의 readBy 배열 + FieldValue.arrayUnion() |
| 안 읽은 수 계산 | 멤버 수 - readBy.length |
| 타이핑 상태 저장 | chats/{chatId}/typing/{uid} 서브컬렉션 |
| 타이핑 디바운스 | Timer 1초 리셋 방식 |
| 화면 이탈 시 정리 | dispose()에서 isTyping: false 강제 설정 |
다음 글에서는 시간순으로 돌아가서, 읽음 표시 다음에 만든 닉네임/이메일 prefix 검색과 온라인/오프라인 상태 표시를 다룬다.
'Flutter' 카테고리의 다른 글
| [Flutter] 7. 메시지 삭제, 그룹 나가기, 다크모드, 비밀번호 찾기 — 마무리 (0) | 2026.06.24 |
|---|---|
| [Flutter] 6. 닉네임/이메일 prefix 검색 + 온라인 상태 표시 (0) | 2026.06.24 |
| [Flutter] 4. 프로필(닉네임/사진) 만들고 채팅 이미지까지 전송하기 (0) | 2026.06.24 |
| [Flutter] 3. 그룹 채팅 추가하기 + authStateChanges로 로그인 유지 (0) | 2026.06.24 |
| [Flutter] 2. Firestore StreamBuilder로 1:1 실시간 채팅 구현하기 (0) | 2026.06.24 |