wan의 개발일기

[Flutter] 2. Firestore StreamBuilder로 1:1 실시간 채팅 구현하기 본문

Flutter

[Flutter] 2. Firestore StreamBuilder로 1:1 실시간 채팅 구현하기

wan_ 2026. 6. 24. 10:35

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

들어가며

1편에서 만든 로그인/회원가입 위에, 이번엔 진짜 채팅 기능을 올려봤다. 채팅방 목록, 1:1 채팅, 유저 검색까지 한 커밋에 같이 들어간 작업인데, 핵심은 Firestore의 실시간 스트림(snapshots())을 어떻게 화면과 연결하느냐다.

Node.js로 Socket.io 서버를 직접 굴려본 경험이 있는데, Firestore는 그 역할(연결 유지, 브로드캐스트)을 통째로 대신해준다. 대신 데이터 구조를 처음부터 "누가 무엇을 구독할지"에 맞춰 설계해야 한다는 게 다른 점이었다.


Firestore 데이터 구조

채팅 기능을 만들면서 컬렉션을 3개로 나눴다. RDB로 치면 users, chats, messages 세 테이블을 만든 것과 비슷한데, messageschats 문서의 서브컬렉션으로 둬서 "이 채팅방의 메시지만" 효율적으로 구독할 수 있게 했다.

컬렉션구조역할
users/{uid}email, uid, createdAt검색 대상 유저 정보
chats/{chatId}users[], userEmails[], lastMessage채팅방 메타데이터
chats/{chatId}/messagestext, senderId, timestamp채팅방별 메시지 (서브컬렉션)

이 구조를 정하면서 가장 먼저 한 일은, 회원가입할 때 users 컬렉션에 유저 문서를 만드는 것이었다. 1편에서는 FirebaseAuth로 인증만 했지, Firestore에는 아무것도 저장하지 않았다 — 그땐 "검색"이라는 요구사항이 없었기 때문이다.

회원가입 시 유저 문서 저장

final credential = await FirebaseAuth.instance.createUserWithEmailAndPassword(
  email: email,
  password: password,
);

// Firestore에 유저 정보 저장 (검색용)
await FirebaseFirestore.instance
    .collection('users')
    .doc(credential.user!.uid)
    .set({
  'email': email,
  'uid': credential.user!.uid,
  'createdAt': FieldValue.serverTimestamp(),
});

FirebaseAuth의 유저 객체(credential.user)는 이메일/UID 정도만 들고 있고, "이메일로 다른 유저를 검색"하는 쿼리는 지원하지 않는다. 그래서 인증과 별개로 Firestore에 검색 가능한 유저 문서를 복제해두는 패턴을 썼다. Java로 치면 인증 토큰의 Principal과, 검색용 인덱스가 걸린 User 테이블을 분리해두는 것과 같은 느낌이다.

유저 검색 후 채팅 시작하기

SearchScreen에서는 이메일로 정확히 일치하는 유저를 찾고(isEqualTo), 채팅을 시작할 때는 이미 같은 상대와 나눈 채팅방이 있는지 먼저 확인한다. 없으면 새로 만든다.

void _startChat(String otherUid, String otherEmail) async {
  final currentUser = FirebaseAuth.instance.currentUser!;

  // 이미 존재하는 채팅방 확인
  final existing = await FirebaseFirestore.instance
      .collection('chats')
      .where('users', arrayContains: currentUser.uid)
      .get();

  String? chatId;
  for (final doc in existing.docs) {
    final users = List<String>.from(doc['users']);
    if (users.contains(otherUid)) {
      chatId = doc.id;
      break;
    }
  }

  // 없으면 새로 생성
  if (chatId == null) {
    final newChat = await FirebaseFirestore.instance.collection('chats').add({
      'users': [currentUser.uid, otherUid],
      'userEmails': [currentUser.email, otherEmail],
      'lastMessage': '',
      'lastMessageTime': FieldValue.serverTimestamp(),
    });
    chatId = newChat.id;
  }
  // ... chatId로 ChatScreen 이동
}

중복 채팅방을 만들지 않으려고 클라이언트에서 직접 arrayContains로 내 채팅방을 전부 가져온 뒤, 상대 UID가 포함된 게 있는지 순회하며 찾는다. 채팅방 수가 많아지면 비효율적이지만, 학습 단계에서는 이 정도로 충분했다.

채팅방 목록 — 실시간 스트림

여기가 RDB와 가장 다르게 느껴진 부분이다. 한 번 쿼리하고 끝나는 게 아니라, snapshots()를 구독하면 데이터가 바뀔 때마다 StreamBuilder가 알아서 다시 그려준다.

body: StreamBuilder<QuerySnapshot>(
  stream: FirebaseFirestore.instance
      .collection('chats')
      .where('users', arrayContains: currentUser.uid)
      .orderBy('lastMessageTime', descending: true)
      .snapshots(),
  builder: (context, snapshot) {
    final chats = snapshot.data?.docs ?? [];
    // chats가 바뀔 때마다 이 builder가 다시 호출된다
    return ListView.builder(
      itemCount: chats.length,
      itemBuilder: (context, index) {
        final chat = chats[index].data() as Map<String, dynamic>;
        final otherUserEmail = (chat['userEmails'] as List<dynamic>)
            .firstWhere((email) => email != currentUser.email,
                orElse: () => '알 수 없음');
        return ListTile(
          title: Text(otherUserEmail),
          subtitle: Text(chat['lastMessage'] ?? ''),
          onTap: () => /* ChatScreen으로 이동 */,
        );
      },
    );
  },
),

JS의 addEventListener나 RxJS Observable.subscribe와 본질적으로 같다. 차이는 구독 대상이 로컬 이벤트가 아니라 서버 DB라는 점, 그리고 연결이 끊겼다가 복구돼도 Firestore SDK가 알아서 재동기화해준다는 점이다.

1:1 채팅 화면 — 메시지 송수신

메시지를 보낼 때는 서브컬렉션에 문서를 하나 추가(add)하고, 채팅방 목록에 보일 미리보기를 위해 부모 문서의 lastMessage도 같이 갱신한다.

void _sendMessage() async {
  final text = _messageController.text.trim();
  if (text.isEmpty) return;

  final currentUser = FirebaseAuth.instance.currentUser!;
  _messageController.clear();

  await FirebaseFirestore.instance
      .collection('chats')
      .doc(widget.chatId)
      .collection('messages')
      .add({
    'text': text,
    'senderId': currentUser.uid,
    'senderEmail': currentUser.email,
    'timestamp': FieldValue.serverTimestamp(),
  });

  // 마지막 메시지 업데이트
  await FirebaseFirestore.instance
      .collection('chats')
      .doc(widget.chatId)
      .update({
    'lastMessage': text,
    'lastMessageTime': FieldValue.serverTimestamp(),
  });
}

화면에는 orderBy('timestamp')로 정렬한 메시지 스트림을 띄우고, senderId가 내 UID와 같은지로 좌/우 정렬(말풍선 색)을 나눈다. 이 단계에서는 텍스트 메시지만 지원했고, 이미지 전송은 4편에서 추가된다.

실행 화면

아래 스크린샷은 이후 편에서 추가된 그룹 채팅, 온라인 상태, 읽음 표시까지 포함된 최신 모습이다. 이번 편 코드만으로는 1:1 채팅방 목록과 텍스트 메시지 주고받기까지만 동작한다.

채팅방 목록 화면
채팅방 목록 (※ 그룹 채팅, 안 읽은 메시지 수는 3편/5편에서 추가)
1:1 채팅 화면
1:1 채팅 화면 (※ 온라인 상태 표시는 6편에서 추가)

정리

기능핵심 API / 구조
유저 검색용 문서 저장회원가입 시 users 컬렉션에 set()
중복 채팅방 방지where('users', arrayContains: uid) 후 클라이언트에서 필터
채팅방 목록 실시간 갱신StreamBuilder + snapshots()
메시지 송신서브컬렉션 add() + 부모 문서 lastMessage 갱신

다음 글에서는 1:1 채팅 구조를 그대로 확장해서 그룹 채팅을 어떻게 붙였는지, 그리고 같은 커밋에서 같이 들어간 authStateChanges() 기반 자동 로그인 유지를 다룬다.