wan의 개발일기
[Flutter] 3. 그룹 채팅 추가하기 + authStateChanges로 로그인 유지 본문
들어가며
2편에서 만든 건 어디까지나 1:1 채팅이었다. 이번엔 같은 Firestore 구조를 건드리지 않고 그룹 채팅을 끼워 넣는 작업을 했다. 그리고 같은 커밋에 묶여서, 한참 미뤄뒀던 "앱을 재시작하면 로그인이 풀리는" 문제도 같이 고쳤다.
참고: 이 시점 코드(
chat_screen.dart)에는 이미 이미지 전송, 프로필 사진/닉네임 표시 기능이 들어가 있다. 실제로는 프로필(닉네임/사진)과 이미지 전송이 그룹 채팅보다 먼저 만들어졌기 때문이다. 이번 글에서는 그 부분은 건드리지 않고, 그룹 채팅을 위해 새로 바뀐 부분만 짚는다. 프로필/이미지 전송은 4편에서 따로 다룬다.
그룹 채팅, 1:1 채팅과 같은 컬렉션을 쓴다
새 컬렉션을 만들지 않고, 기존 chats 문서에 isGroup 플래그만 추가했다. Java로 치면 한 테이블에 타입 컬럼(discriminator)을 두고 분기하는 것과 같은 패턴이다. 새 테이블/새 화면을 따로 만드는 것보다 기존 StreamBuilder 쿼리를 그대로 재사용할 수 있어서 훨씬 적은 코드로 끝났다.
| 필드 | 1:1 채팅 | 그룹 채팅 |
|---|---|---|
isGroup | false (또는 없음) | true |
groupName | - | 그룹 이름 |
users | 2명 | 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) 버그가 있어서, Text를 Expanded로 감싸고 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() 같은 부분이 어떻게 만들어졌는지 그 편에서 설명한다.
'Flutter' 카테고리의 다른 글
| [Flutter] 6. 닉네임/이메일 prefix 검색 + 온라인 상태 표시 (0) | 2026.06.24 |
|---|---|
| [Flutter] 5. 읽음 표시 + 타이핑 인디케이터로 채팅 디테일 살리기 (0) | 2026.06.24 |
| [Flutter] 4. 프로필(닉네임/사진) 만들고 채팅 이미지까지 전송하기 (0) | 2026.06.24 |
| [Flutter] 2. Firestore StreamBuilder로 1:1 실시간 채팅 구현하기 (0) | 2026.06.24 |
| [Flutter] 1. Firebase Auth로 이메일 로그인/회원가입 만들기 (0) | 2026.06.24 |