wan의 개발일기

[Flutter] 6. 닉네임/이메일 prefix 검색 + 온라인 상태 표시 본문

Flutter

[Flutter] 6. 닉네임/이메일 prefix 검색 + 온라인 상태 표시

wan_ 2026. 6. 24. 14:02

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

들어가며

5편(읽음 표시) 다음에 이어진 두 작업이다. 하나는 "이메일을 정확히 다 입력해야만 검색되던" 불편함을 prefix 검색으로 바꾼 것, 다른 하나는 상대방이 지금 앱을 켜놓고 있는지 보여주는 온라인 상태 표시다. 둘 다 Firestore의 쿼리/실시간 구독 기능을 한 단계 더 활용한 사례다.


이메일 → 닉네임/이메일 prefix 검색

그동안 SearchScreenwhere('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

MyAppStatefulWidget으로 바꾸고 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() 구독

다음 글에서는 시리즈의 마지막 편으로, 메시지 삭제와 그룹 채팅방 나가기, 다크 모드, 비밀번호 재설정까지 한꺼번에 정리한다.