wan의 개발일기

[Flutter] 5. 읽음 표시 + 타이핑 인디케이터로 채팅 디테일 살리기 본문

Flutter

[Flutter] 5. 읽음 표시 + 타이핑 인디케이터로 채팅 디테일 살리기

wan_ 2026. 6. 24. 13:59

소스코드 → github.com/Jywan/Flutter_Learning

들어가며

이번 글은 두 커밋을 같이 묶었는데, 사실 둘 사이엔 시간 차이가 꽤 있다. 읽음 표시는 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]),
      });
    }
  }
}

이 함수를 StreamBuilderbuilder 콜백 안에서, 메시지 목록을 받을 때마다 호출한다. 빌드 메서드 안에서 쓰기(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 검색온라인/오프라인 상태 표시를 다룬다.