wan의 개발일기
[Flutter] 6. 닉네임/이메일 prefix 검색 + 온라인 상태 표시 본문
들어가며
5편(읽음 표시) 다음에 이어진 두 작업이다. 하나는 "이메일을 정확히 다 입력해야만 검색되던" 불편함을 prefix 검색으로 바꾼 것, 다른 하나는 상대방이 지금 앱을 켜놓고 있는지 보여주는 온라인 상태 표시다. 둘 다 Firestore의 쿼리/실시간 구독 기능을 한 단계 더 활용한 사례다.
이메일 → 닉네임/이메일 prefix 검색
그동안 SearchScreen은 where('email', isEqualTo: query)로 이메일을 정확히 일치시켜야만 검색이 됐다. RDB의 LIKE 'query%' 같은 "시작 문자열로 찾기"가 필요했는데, Firestore는 LIKE를 지원하지 않는다. 대신 문자열 범위 비교로 같은 효과를 낸다.
// 이메일 prefix 검색 (시작 문자열 매칭)
final emailResult = await FirebaseFirestore.instance
.collection('users')
.where('email', isGreaterThanOrEqualTo: query)
.where('email', isLessThan: '$query')
.get();
는 유니코드에서 거의 가장 마지막에 가까운 문자다. "query 이상이고 query+보다 작다"는 조건을 걸면,query로 시작하는 모든 문자열이 그 범위에 들어온다. SQL로 치면WHERE email >= 'query' AND email < 'query' || CHR(MAX)정도의 트릭이고, Firestore에서 prefix 검색을 할 때 거의 표준처럼 쓰이는 패턴이다.
닉네임으로도 같은 방식의 쿼리를 한 번 더 날린 뒤, 두 결과를 Map에 합쳐서 중복(이메일+닉네임 둘 다 매칭되는 경우)을 제거한다.
// 닉네임 prefix 검색도 동일하게
final nicknameResult = await FirebaseFirestore.instance
.collection('users')
.where('nickname', isGreaterThanOrEqualTo: query)
.where('nickname', isLessThan: '$query')
.get();
// 중복 제거 후 합치기 — uid를 키로 쓰면 자동으로 중복이 사라진다
final allDocs = <String, Map<String, dynamic>>{};
for (final doc in [...emailResult.docs, ...nicknameResult.docs]) {
if (doc['email'] != currentUser.email) {
allDocs[doc.id] = {
'uid': doc.id,
'email': doc['email'],
'nickname': doc['nickname'] ?? doc['email'],
};
}
}
setState(() {
_searchResults = allDocs.values.toList();
});
두 쿼리를 따로 날리고 클라이언트에서 합치는 이유는, Firestore가 "이 필드 OR 저 필드"로 동시에 prefix 검색하는 복합 쿼리를 지원하지 않기 때문이다. 쿼리 두 번 + 메모리에서 합치기가 현재로선 제일 간단한 우회 방법이다. 같은 검색 로직을 그룹 채팅방 생성 화면(CreateGroupScreen)에도 똑같이 적용했다.
검색 결과 표시도 Text(user['email']) 한 줄에서 닉네임을 제목으로, 이메일을 부제목으로 보여주도록 바꿨다. 자잘하지만 로그인 화면에서 비밀번호 입력 후 엔터로 바로 제출되게 한 것도 이 커밋에서 같이 처리했다.
온라인/오프라인 상태 표시
"지금 이 사람이 앱을 켜놓고 있는가"는 users/{uid} 문서의 isOnline 필드 하나로 표현한다. 이 값을 언제 바꿔주느냐가 핵심이다 — 로그인 시점, 로그아웃 시점, 그리고 앱이 백그라운드로 가거나 다시 돌아오는 시점까지 세 군데를 잡아야 한다.
앱 생명주기 감지 — WidgetsBindingObserver
MyApp을 StatefulWidget으로 바꾸고 WidgetsBindingObserver를 mixin해서, 앱이 포그라운드/백그라운드로 전환될 때마다 콜백을 받는다. 안드로이드의 onResume/onPause나 iOS의 applicationDidBecomeActive와 같은 역할이다.
class _MyAppState extends State<MyApp> with WidgetsBindingObserver {
@override
void initState() {
super.initState();
WidgetsBinding.instance.addObserver(this);
}
@override
void dispose() {
WidgetsBinding.instance.removeObserver(this);
super.dispose();
}
@override
void didChangeAppLifecycleState(AppLifecycleState state) {
final user = FirebaseAuth.instance.currentUser;
if (user == null) return;
if (state == AppLifecycleState.resumed) {
_setOnlineStatus(user.uid, true);
} else {
// 백그라운드로 가거나(paused), 종료되거나(detached) 등 모두 오프라인 처리
_setOnlineStatus(user.uid, false);
}
}
void _setOnlineStatus(String uid, bool isOnline) {
FirebaseFirestore.instance.collection('users').doc(uid).update({
'isOnline': isOnline,
'lastSeen': FieldValue.serverTimestamp(),
});
}
// ...
}
그리고 로그인 직후, 로그아웃 직전에도 같은 필드를 명시적으로 갱신한다.
// login_screen.dart — 로그인 성공 시
final uid = FirebaseAuth.instance.currentUser!.uid;
await FirebaseFirestore.instance.collection('users').doc(uid).update({
'isOnline': true,
'lastSeen': FieldValue.serverTimestamp(),
});
// home_screen.dart — 로그아웃 버튼
final uid = FirebaseAuth.instance.currentUser?.uid;
if (uid != null) {
await FirebaseFirestore.instance.collection('users').doc(uid).update({
'isOnline': false,
'lastSeen': FieldValue.serverTimestamp(),
});
}
await FirebaseAuth.instance.signOut();
이렇게 갱신 지점이 여러 군데로 흩어지는 게 마음에 걸리긴 했다. 로그인/로그아웃/생명주기 세 곳에서 같은 로직을 반복하는 셈인데, 일단은 동작을 우선해서 중복을 남겨뒀다. 나중에 리팩토링한다면 한 군데(예: 인증 상태 변화 리스너)로 모을 수 있을 것 같다.
1:1 채팅방에서 실시간으로 상대방 상태 보기
상대방 정보를 한 번만 조회(get())하던 걸, snapshots() 구독으로 바꿔서 상대가 온라인/오프라인을 전환할 때마다 채팅 화면이 바로 반응하게 했다.
if (query.docs.isNotEmpty) {
final data = query.docs.first.data();
setState(() {
_otherUserNickname = data['nickname'] ?? widget.otherUserEmail;
_otherUserProfileUrl = data['profileImageUrl'];
_isOtherOnline = data['isOnline'] ?? false;
});
// 이후 상태 변화는 실시간으로 감지
query.docs.first.reference.snapshots().listen((snapshot) {
if (snapshot.exists && mounted) {
setState(() {
_isOtherOnline = snapshot.data()!['isOnline'] ?? false;
});
}
});
}
AppBar의 닉네임 아래에 "온라인"/"오프라인" 텍스트를 색깔(초록/회색)로 구분해서 추가했다. 그룹 채팅에는 "상대방 한 명"이라는 개념이 없어서 !_isGroup 조건으로 1:1 채팅에서만 보이게 했다.
정리
| 기능 | 핵심 구조 / API |
|---|---|
| prefix 검색 | isGreaterThanOrEqualTo + isLessThan: '$query' |
| 이메일+닉네임 동시 검색 | 쿼리 2번 후 Map으로 중복 제거 |
| 앱 생명주기 감지 | WidgetsBindingObserver + didChangeAppLifecycleState |
| 온라인 상태 저장 | users/{uid}.isOnline 필드 |
| 실시간 상태 반영 | 유저 문서 snapshots() 구독 |
다음 글에서는 시리즈의 마지막 편으로, 메시지 삭제와 그룹 채팅방 나가기, 다크 모드, 비밀번호 재설정까지 한꺼번에 정리한다.
'Flutter' 카테고리의 다른 글
| [Flutter] 7. 메시지 삭제, 그룹 나가기, 다크모드, 비밀번호 찾기 — 마무리 (0) | 2026.06.24 |
|---|---|
| [Flutter] 5. 읽음 표시 + 타이핑 인디케이터로 채팅 디테일 살리기 (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 |