wan의 개발일기

[Flutter] 3. 그룹 채팅 추가하기 + authStateChanges로 로그인 유지 본문

Flutter

[Flutter] 3. 그룹 채팅 추가하기 + authStateChanges로 로그인 유지

wan_ 2026. 6. 24. 11:41

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

들어가며

2편에서 만든 건 어디까지나 1:1 채팅이었다. 이번엔 같은 Firestore 구조를 건드리지 않고 그룹 채팅을 끼워 넣는 작업을 했다. 그리고 같은 커밋에 묶여서, 한참 미뤄뒀던 "앱을 재시작하면 로그인이 풀리는" 문제도 같이 고쳤다.

참고: 이 시점 코드(chat_screen.dart)에는 이미 이미지 전송, 프로필 사진/닉네임 표시 기능이 들어가 있다. 실제로는 프로필(닉네임/사진)과 이미지 전송이 그룹 채팅보다 먼저 만들어졌기 때문이다. 이번 글에서는 그 부분은 건드리지 않고, 그룹 채팅을 위해 새로 바뀐 부분만 짚는다. 프로필/이미지 전송은 4편에서 따로 다룬다.


그룹 채팅, 1:1 채팅과 같은 컬렉션을 쓴다

새 컬렉션을 만들지 않고, 기존 chats 문서에 isGroup 플래그만 추가했다. Java로 치면 한 테이블에 타입 컬럼(discriminator)을 두고 분기하는 것과 같은 패턴이다. 새 테이블/새 화면을 따로 만드는 것보다 기존 StreamBuilder 쿼리를 그대로 재사용할 수 있어서 훨씬 적은 코드로 끝났다.

필드1:1 채팅그룹 채팅
isGroupfalse (또는 없음)true
groupName-그룹 이름
users2명2명 이상

그룹 채팅방 생성 화면

CreateGroupScreen은 완전히 새로 만든 화면이다. 1:1 채팅의 SearchScreen과 비슷하게 이메일로 유저를 찾지만, 한 명이 아니라 여러 명을 _selectedUsers 리스트에 누적시킨다는 점이 다르다.

void _createGroup() async {
  final groupName = _groupNameController.text.trim();
  if (groupName.isEmpty) {
    ScaffoldMessenger.of(context).showSnackBar(
      const SnackBar(content: Text('그룹 이름을 입력해주세요.')),
    );
    return;
  }

  if (_selectedUsers.length < 2) {
    ScaffoldMessenger.of(context).showSnackBar(
      const SnackBar(content: Text('2명 이상 선택해주세요.'))
    );
    return;
  }

  final currentUser = FirebaseAuth.instance.currentUser!;
  final allUids = [currentUser.uid, ..._selectedUsers.map((u) => u['uid'])];
  final allEmails = [currentUser.email, ..._selectedUsers.map((u) => u['email'])];

  // 1:1 채팅과 같은 chats 컬렉션, isGroup 플래그만 다름
  final newChat = await FirebaseFirestore.instance.collection('chats').add({
    'users': allUids,
    'userEmails': allEmails,
    'groupName': groupName,
    'isGroup': true,
    'lastMessage': '',
    'lastMessageTime': FieldValue.serverTimestamp(),
  });
  // ... newChat.id로 ChatScreen 이동
}

참여자를 Chip 위젯으로 보여주고 onDeleted로 제거할 수 있게 했는데, 이건 React에서 태그 입력 UI를 만들 때 배열 상태를 추가/삭제하는 것과 똑같은 패턴이다.

홈 화면 — 그룹/1:1 채팅방 구분

isGroup 값으로 분기해서, 그룹이면 닉네임을 조회할 필요 없이 groupName을 바로 보여준다. 1:1 채팅만 상대방 닉네임/프로필을 가져오기 위해 FutureBuilder를 쓴다.

final isGroup = chat['isGroup'] ?? false;
final displayName = isGroup ? chat['groupName'] ?? '그룹 채팅' : null;

// 그룹이면 FutureBuilder 없이 바로 표시
if (isGroup) {
  return ListTile(
    leading: const CircleAvatar(child: Icon(Icons.group)),
    title: Text(displayName!),
    subtitle: Text(chat['lastMessage'] ?? '', maxLines: 1, overflow: TextOverflow.ellipsis),
    onTap: () => /* ChatScreen으로 이동, otherUserEmail 자리에 displayName 전달 */,
  );
}

// 1:1 채팅은 상대방 정보 조회 (FutureBuilder)
return FutureBuilder<QuerySnapshot>(/* ... */);

플로팅 버튼도 1개에서 2개로 늘려서, 그룹 만들기(CreateGroupScreen)와 1:1 채팅 시작(SearchScreen)을 구분했다. FloatingActionButton을 두 개 겹쳐 쓰려면 heroTag를 각각 다르게 줘야 한다 — 안 그러면 "동일한 hero tag" 에러가 난다.

채팅 화면 — 그룹 여부에 따른 표시 차이

그룹 채팅방인지 아닌지는 chats/{chatId} 문서를 한 번 읽어서 _isGroup 상태로 저장해둔다. 그룹이면 상대방 닉네임을 조회할 필요가 없으므로 _loadOtherUserInfo() 호출 자체를 건너뛴다.

Future<void> _loadChatInfo() async {
  final doc = await FirebaseFirestore.instance
    .collection('chats')
    .doc(widget.chatId)
    .get();

  if (doc.exists) {
    setState(() => _isGroup = doc.data()!['isGroup'] ?? false);
  }

  // 그룹이면 "상대방 1명"이라는 개념이 없으니 조회하지 않음
  if (!_isGroup) {
    _loadOtherUserInfo();
  }
}

그룹 채팅에서는 누가 보낸 메시지인지 구분이 안 되면 헷갈리기 때문에, 내가 보낸 게 아닌 메시지 위에 발신자 이메일을 작게 표시했다.

if (!isMe && _isGroup)
  Padding(
    padding: const EdgeInsets.only(left: 4, bottom: 2),
    child: Text(
      msg['senderEmail'] ?? '',
      style: const TextStyle(fontSize: 11, color: Colors.grey),
    ),
  ),

또 AppBar 제목이 긴 그룹 이름 때문에 화면을 넘치는(overflow) 버그가 있어서, TextExpanded로 감싸고 overflow: TextOverflow.ellipsis를 추가해 말줄임 처리했다. Flutter에서 Row 안의 Text가 너비를 넘기면 노란/검은 줄무늬 오버플로우 에러가 뜨는데, 흔히 겪는 실수다.


자동 로그인 유지 — authStateChanges

1편부터 계속 미뤄왔던 부분인데, 그룹 채팅과 함께 묶여서 고쳤다. main.dart의 진입점을 StreamBuilder로 감싸서, Firebase의 인증 상태가 바뀔 때마다 로그인 화면과 홈 화면을 자동으로 전환하게 했다.

home: StreamBuilder<User?>(
  stream: FirebaseAuth.instance.authStateChanges(),
  builder: (context, snapshot) {
    if (snapshot.connectionState == ConnectionState.waiting) {
      return const Scaffold(body: Center(child: CircularProgressIndicator()));
    }
    if (snapshot.hasData) {
      // Firebase SDK가 기기에 캐싱해둔 세션이 있으면 자동으로 로그인된 상태로 시작
      return const HomeScreen();
    }
    return const LoginScreen();
  },
),

JS의 이벤트 리스너처럼, 로그인/로그아웃이 일어날 때마다 이 스트림이 새 값을 흘려준다. 그 전까지는 로그인에 성공하면 Navigator.pushReplacement로 수동으로 화면을 넘기는 방식만 있었는데, 앱을 껐다 켜면 다시 로그인 화면부터 시작해야 했다. 이제는 앱 진입점 자체가 로그인 여부를 판단하니 그 문제가 해결됐다.


정리

기능핵심 구조 / API
그룹/1:1 구분chats 문서의 isGroup 플래그
그룹 생성CreateGroupScreen + 다중 유저 선택
그룹 메시지 발신자 표시senderEmail + _isGroup 분기
AppBar 오버플로우 수정Expanded + TextOverflow.ellipsis
자동 로그인 유지authStateChanges() 스트림 구독

다음 글에서는 시간을 살짝 되돌려서, 그룹 채팅보다 먼저 만들었던 프로필(닉네임/사진)채팅 이미지 전송을 다룬다. 이번 글에서 이미 코드에 등장했던 _otherUserProfileUrl, _sendImage() 같은 부분이 어떻게 만들어졌는지 그 편에서 설명한다.