오늘 학습 키워드

Firebase(파이어베이스)

오늘 학습 한 내용을 나만의 언어로 정리하기

챌린지 반 강의 (주제 : Firebase)

Firebase란 무엇인가

  • 앱 개발을 위한 통합 백엔드 서비스
  • 서버를 직접 만들지 않고도 필요한 기능을 클라우드에서 바로 사용할 수 있게 해줌
  • 주요 기능
    • 인증
    • 데이터베이스 : RTDB - Json 트리 구조, 빠른 실시간 동기화
    • 저장소
    • 호스팅
    • 클라우드 함수 : 서버 커스터마이징
    • 분석
    • 원격 구성
    • 푸시 메시지
  • 서버 없이 게임을 만들기 위해 Firebase를 사용함.

Firebase 세팅하기

  1. Firebase 콘솔에서 프로젝트 생성
    • Firebase 콘솔 들어가서 “프로젝트 추가” > 프로젝트 이름 입력해서 생성
  2. 앱 등록 (Unity와 연결)
    • 앱 플랫폼 선택 (게임이라면 안드로이드/ios 둘 다 연결하는 경우 많음)
    • 앱 패키지 이름 입력
      • Other Settings > Identification > Override Default Package Name > Package Name 에 있음
      • (예시 : com.HyerimLee.FirebaseTest)
    • 앱 등록 후, 구성 파일 다운로드하고 그거를 Unity에 StreamingAssets 등, 빌드 시에 무조건 포함되는 폴더에 넣어줌
  3. Unity에 Firebase SDK 설치
    • 이번 실습에서는 FirebaseAuth, FirebaseDatabase, FirebaseFirestore 세 가지만 설치
  4. Unity C# 코드로 Firebase 초기화 진행
using Firebase;
using Firebase.Extensions;
using System;
using System.Threading.Tasks;
using UnityEngine;
 
public class FirebaseManager : MonoBehaviour
{
    /// <summary>의존성 준비 완료 여부</summary>
    public static bool IsReady { get; private set; }
 
    /// <summary>준비 완료 시점 알림</summary>
    public static event Action OnReady;
 
    private static TaskCompletionSource<bool> _readyTcs
        = new TaskCompletionSource<bool>();
 
    private void Awake()
    {
    // CheckAndFixDependenciesAsync 로 의존성을 확인하는데 이걸 비동기로 돌리니까
    // ContinueWithOnmainThread 로 메인스레드에서 동작하게 설정
        Debug.Log("[Firebase] 의존성 확인 중…");
        FirebaseApp.CheckAndFixDependenciesAsync().ContinueWithOnMainThread(t =>
        {
            IsReady = (t.Result == DependencyStatus.Available);
            if (IsReady)
            {
                Debug.Log("[Firebase] 준비 완료");
                _readyTcs.TrySetResult(true);
                OnReady?.Invoke();
            }
            else
            {
                Debug.LogError($"[Firebase] 준비 실패: {t.Result}");
                _readyTcs.TrySetResult(false);
            }
        });
    }
 
    /// <summary>기능 스크립트에서 대기할 때 사용 (await 가능)</summary>
    public static Task<bool> WaitUntilReadyAsync() => _readyTcs.Task;
}
 
 
  • 처음에 스크립트 등록하면 뭐 뜸

  • Yes, Yes, Enable 누르면 됨

  • 빈 오브젝트에 FirebaseManager 달고 실행하면 됨

Firebase 로그인

  • 지원하는 로그인 방식

    1. 이메일/비밀번호
    2. 익명 로그인
    3. 소셜 로그인
    4. 기타 (전화번호 로그인, 커스텀 토큰 등등)
  • 로그인 성공 시 얻는 정보 (FirebaseUser)

    • UserId : 고유 UID 데이터베이스 키로 가장 많이 쓰임
    • Email : 이메일 로그인인 경우
    • IsAnonymous : 익명 로그인인 경우
    • ProviderId : 소셜 / 익명 로그인인 경우
  • 자주 쓰이는 함수들

// 회원가입
Auth.CreateUserWithEmailAndPasswordAsync(email, password)
 
// 로그인
Auth.SignInWithEmailAndPasswordAsync(email, password)
 
// 익명 로그인
Auth.SignInAnonymouslyAsync()
 
// 로그아웃
Auth.SignOut();
 
// 비밀번호 재설정
Auth.SendPasswordResetEmailAsync(email)
  • 게임/앱에서 활용하는 방법

    • 게스트 로그인 이메일 계정 전환
      • 튜토리얼은 게스트로 시작
      • 게임 저장 데이터는 UID로 관리
      • 나중에 “계정 연동하면 보상 지급” 이메일/구글 계정 연결
    • 소셜 로그인
      • 소셜 계정 하나로 앱 여러 개 이용 가능
      • 탈퇴율 감소
    • 비밀번호 재설정
      • 고객센터 부담 줄음 유저가 직접 메일로 초기화 가능
  • 주의 : 로그인을 하려면 Firebase 콘솔 > Authentication > Sign-in method 활성화 해줘야됨

  • 실제 게임에서는 게스트 로그인 계정 연동 패턴이 가장 흔함

FireStore

  • Firebase가 제공하는 클라우드 문서형(NoSQL) DB
  • 서버 없이 데이터 저장, 조회, 실시간 동기화 가능
  • 데이터 구조
    • 컬렉션 안에 여러 문서를 가짐
    • 문서는 JSON과 유사한 키:값 데이터
    • 문서 안에 또 하위 컬렉션을 둘 수 있음
// 예시
users (컬렉션)
  └─ uid123 (문서)
       ├─ name: "홍길동"
       ├─ level: 15
       └─ items (하위 컬렉션)
            └─ item001 (문서)
  • 주요 기능
    • 저장
    • 조회
    • 실시간 리스너
    • 트랜잭션/배치
  • 게임에서의 활용 예시
    • 유저 프로필 관리
    • 리더보드
    • 채팅 (실시간 리스너 활용)
    • 인벤토리, 퀘스트 진행 상황 저장
  • 장점 : 실시간, 확장성, 구조적 데이터 관리, 강력한 쿼리
  • 주의 : 읽기 요청 수에 따른 과금. 리스너 남용 주의, 보안 규칙 꼭 설정
  • Firestore 콘솔에서 Firestore Database에서 확인할 수 있음
  • UID랑 일치하는 문서를 가져오면 됨

RTDB (Realtime Database)

  • 실시간 동기화용 NoSQL DB
  • 큰 JSON 트리 구조로 데이터를 저장
  • 데이터 변경 시 모든 클라이언트에 즉시 반영
  • Root 아래에 JSON 형태로 데이터를 배치
  • 주요 기능
    • 쓰기
    • 읽기
    • 실시간 리스터
  • 게임에서의 활용 예시
    • 실시간 채팅
    • 멀티플레이 로비
    • 온라인 상태 표시
    • 랭킹/점수판
  • 장점 : 빠른 실시간 동기화, 구현 단순, 저렴
  • 주의 : 쿼리 기능 제한적, 데이터 커지면 구조 관리 어려움 Firestore와 병행
FireStoreRTDB
데이터 구조컬렉션 + 문서JSON 트리
쿼리 기능강력제한적
실시간 동기화지원(리스너)기본 내장
확장성대규모 데이터, 복잡한 구조에 적합간단 구조, 소규모/실시간 적합
오프라인 지원있음있음
비용 과금읽기/쓰기/저장/대역폭 기준 과금읽기/쓰기/저장/대역폭 기준 과금(좀 더 저렴)
대표 사용예사용자 데이터, 인벤토리, 리더보드, 채팅, 로그 저장채팅, 멀티플레이 로비, 온라인 표시, 실시간 동기화
  • Firestore : 정리된 문서함 구조적이고 검색이 필요한 데이터

  • RTDB : 빠른 화이트보드 실시간 공유가 중요한 데이터

  • 자주 쓰는 조합

    • RTDB : 로비/채팅/온라인 상태
    • Firestore : 유저 프로필/진행도/랭킹

Firebase Storage

  • 클라우드 파일 저장소
  • Firestore, RTDB는 텍스트/숫자용, Storage는 큰 파일용
  • 버킷 기반
    • 버킷 안에 폴더/파일 경로 구조로 저장
    • 실제로는 Google Cloud Storage 위에서 동작
  • 주요 기능
    • 파일 업로드 (PutFileAsync, PutBytesAsync)
    • 파일 다운로드 (GetFileAsync, GetBytesAsync)
    • URL 생성 (다운로드 링크)
    • 메타 데이터 읽기/수정 (업로드한 유저, 크기, MIME 타입 등)
  • 게임에서 활용 예시
    • 유저 프로필 이미지 저장
    • 스크린샷/리플레이 업로드
    • 커스텀 콘텐츠 공유
    • 이벤트 배너/리소스 업데이트
  • 장점 : 대용량 파일 저장에 최적화, Firebase Auth와 권환 관리 연동 가능
  • 주의 : 읽기/쓰기 과금 있음. 직접 파일을 DB에 넣지 말고 경로/URL만 넣으셈

Firebase Storage + Unity Addressables

  • Storage에 어드레서블 빌드 결과 (AssetBundle)를 올려두면 유니티에서 원격으로 부를 수 있음
  • 연동 순서
    1. 유니티에서 어드레서블을 빌드함 .bundle 파일이랑 catalog.json 생김
    2. Firebase Storage 버킷에 업로드함 (예: /assetbundles/v1/catalog.json)]
    3. 유니티 앱 시작 시 Storage 파일 URL을 받아옴
      • StorageReference.GetDownloadUrlAsync()
      • catalog.json / bundle 파일의 https 링크 획득
    4. 어드레서블 초기화 시 catalog.json의 URL을 지정해서 로딩함
  • 장점
    • 별도의 서버 없이 Firebase Storage로 원격 에셋 배포 가능
    • Firebase 보안 규칙으로 접근 제어 가능 (ex. 로그인 된 유저만 다운 가능 등)
    • 다른 Firebase 기능 (Remote Config)와 연계 가능 실시간 번들 버전 전환 가능
  • 주의점
    • Storage는 CDN(캐싱/빠른 배포) 목적이 아니라 기본 GCS 속도이기 때문에, 대규 트래픽에서는 Cloud CDN 같은 별도 서비스를 권장함
    • Storage에서 Addressavbles catalog/bundle 불러올 때 https URL을 받아야 하기 때문에, GetDownloadUrlAsync() Addressables.LoadContentCatalogAsync(url) 순서를 지켜야 함
    • 파일 권한을 적절히 설정해야 함 (테스트 중에는 공개, 서비스 시에는 Auth 기반 접근 제어)
  • 흐름 예시
using Firebase.Storage;
using UnityEngine;
using UnityEngine.AddressableAssets;
using UnityEngine.ResourceManagement.AsyncOperations;
 
public class AddressablesFromFirebase : MonoBehaviour
{
	void Start()
	{
		var storage = FirebaseStorage.DefaultInstance;
		// 카탈로그 파일의 경로를 가져옴
		var catalogRef = storage.GetRefernece("assetBundles/v1/catalog.json");
		
		// 카탈로그의 다운로드 경로를 받아옴
		catalogRef.GetDownloadUrlAsync().ContinueWith(task =>
		{
		
			if(task.IsCompleted)
			{
			// 다 받아오면 그 다운로드 경로를 저장
				string url = task.Result.ToString();
				// 그리고 어드레서블에서 가져옴
				// 어드레서블에서 다 가져온 뒤에는 OnCatalogLoaded 함수 실행하도록 연결
				Addressables.LoadContentCatalogAsync(url).Completed += OnCatalogLoaded;
			}
		});
	}
	
	void OnCatalogLoaded(AsyncOperationHandle<IResourceLocator> handle)
	{
	// 다운로드 경로 가져오고 어드레서블 준비 완료되면 그때 실행됨
		Debug.Log("Addressables Catalog Loaded from Firebase!");
		// 이후에 Addressables.LoadAssetAsync<T>("Key") 가능
	}
}

Firebase vs Playfab

  • PlayFab : 게임 전용 백엔드 서베스. MS사에서 제공하는 게임 특화 서버임
항목PlayFabFirebase
제공사MicrosoftGoogle
주 사용처게임앱 전반
로그인/인증가능가능
아이템/인벤토리강력 지원직접 구현 필요
분석/통계기본 제공강력한 분석 툴
가격 정책무료+과금무료+과금
  • 게임 서비스는 Playfab이 더 편리
  • 앱 서비스 전반에서는 Firebase가 더 범용적이고 강력함
  • 사용 예시
    • PlayFab : 유저가 결제를 하면 바로 PlayFab 서버가 구매를 검증 아이템 지급
    • Firebase : 유저가 앱을 설치한 후 3일 안에 자주 쓰는 기능 분석 푸시 알림 발송

직접 실습

로그인 만들기

  • UI 세팅

  • 코드
// FirebaseAuthService.cs
public static class FirebaseAuthService  
{  
    public static FirebaseAuth Auth => FirebaseAuth.DefaultInstance;  
    public static FirebaseUser CurrentUser => Auth.CurrentUser;  
      
    // 게스트 (익명) 로그인  
    // onSuccess : 성공하면 그 정보 가지고 처리할 내용  
    // onError : 에러나면 에러 정보 출력  
    public static void SignInAnonymously(Action<FirebaseUser> onSuccess, Action<string> onError)  
    {  
        Auth.SignInAnonymouslyAsync().ContinueWithOnMainThread(t =>  
        {  
            // 여기서 t 는 Auth 결과가 담긴 태스크  
            if (t.IsFaulted || t.IsCanceled) // 로그인 실패하면  
            {  
                onError?.Invoke(DescribeException(t.Exception));  
            }  
            else // 로그인 성공하면  
            {  
                onSuccess?.Invoke(t.Result.User);  
            }  
        });  
    }  
      
    // 이메일 회원가입  
    // 익명 로그인처럼 onSuccess와 onError를 가짐  
    public static void SignUpWithEmail(string email, string password, Action<FirebaseUser> onSuccess,  
        Action<string> onError)  
    {  
        Auth.CreateUserWithEmailAndPasswordAsync(email, password).ContinueWithOnMainThread(t =>  
        {  
            if (t.IsFaulted || t.IsCanceled) onError?.Invoke(DescribeException(t.Exception));  
            else onSuccess?.Invoke(t.Result.User);  
        });  
    }  
      
    // 이메일 로그인  
    public static void SignInWithEmail(string email, string password, Action<FirebaseUser> onSuccess,  
        Action<string> onError)  
    {  
        Auth.SignInWithEmailAndPasswordAsync(email, password).ContinueWithOnMainThread(t =>  
        {  
            if (t.IsFaulted || t.IsCanceled) onError?.Invoke(DescribeException(t.Exception));  
            else onSuccess?.Invoke(t.Result.User);  
        });  
    }  
      
    // 비밀번호 재설정 메일  
    public static void SendPasswordReset(string email, Action onSuccess, Action<string> onError)  
    {  
        Auth.SendPasswordResetEmailAsync(email).ContinueWithOnMainThread(t =>  
        {  
            if (t.IsFaulted || t.IsCanceled) onError?.Invoke(DescribeException(t.Exception));  
            else onSuccess?.Invoke();  
        });  
    }  
  
    // 익명 계정 -> 이메일 계정으로 연동   
public static void LinkAnonymousToEmail(string email, string password, Action<FirebaseUser> onSuccess,  
        Action<string> onError)  
    {  
        var user = CurrentUser;  
        if (user == null || !user.IsAnonymous)  
        {  
            onError?.Invoke("현재 익명 로그인 상태가 아닙니다.");  
            return;  
        }  
          
        // 이메일 자격 증명을 생성함.  
        // 자격 증명 : 열쇠 만들기  
        // 로그인 : 열쇠로 문 열기  
        // 여기서는 익명 아이디를 이메일 자격 증명과 연결하는 순서로 진행  
        var cred = EmailAuthProvider.GetCredential(email, password);  
        user.LinkWithCredentialAsync(cred).ContinueWithOnMainThread(t =>  
        {  
            if (t.IsFaulted || t.IsCanceled) onError?.Invoke(DescribeException(t.Exception));  
            else onSuccess?.Invoke(t.Result.User);  
        });  
    }  
      
    // 로그아웃  
    public static void SignOut() => Auth.SignOut();  
      
    // 에러 메시지 정리용  
    public static string DescribeException(Exception ex)  
    {  
        if (ex is AggregateException ag)  
        {  
            foreach (var inner in ag.InnerExceptions)  
            {  
                var s = DescribeSingle(inner);  
                // DescribeSingle 안에서 에러를 찾았으면 그걸 반환  
                if (!string.IsNullOrEmpty(s)) return s;  
            }  
        }  
          
        // a ?? b => a가 null 이면 b를 return        // a ?? b ?? c => a가 null 이면 b 검사. b null 이면 c return        return DescribeSingle(ex) ?? ex?.Message ?? "Unknown error";  
    }  
      
      
      
    // 에러 하나에 대한 메시지  
    private static string DescribeSingle(Exception ex)  
    {  
        if (ex is FirebaseException fe)  
        {  
            // AuthError 코드 매핑 시도  
            if (Enum.IsDefined(typeof(AuthError), fe.ErrorCode))  
            {  
                var code = (AuthError)fe.ErrorCode;  
                switch (code)  
                {  
                    case AuthError.EmailAlreadyInUse: return "이미 사용 중인 이메일입니다.";  
                    case AuthError.InvalidEmail: return "이메일 형식이 올바르지 않습니다.";  
                    case AuthError.WeakPassword: return "비밀번호가 너무 약합니다.";  
                    case AuthError.WrongPassword: return "비밀번호가 올바르지 않습니다.";  
                    case AuthError.UserNotFound: return "해당 사용자를 찾을 수 없습니다.";  
                    case AuthError.UserDisabled: return "비활성화된 계정입니다.";  
                    case AuthError.AccountExistsWithDifferentCredentials: return "다른 인증 수단으로 이미 가입된 계정입니다.";  
                    case AuthError.RequiresRecentLogin: return "민감 작업: 최근 로그인(재인증) 필요.";  
                    case AuthError.CredentialAlreadyInUse: return "이미 다른 계정에 연동된 자격 증명입니다.";  
                    default: return $"Firebase Auth 오류: {code} / Firebase Auth 메세지: {ex}";  
                }  
            }  
            return $"Firebase 오류({fe.ErrorCode}): {fe.Message}";  
        }  
        return null;  
    }  
}
// LoginUI.cs
public class LoginUI : MonoBehaviour  
{  
    private string email;  
    private string password;  
    public TextMeshProUGUI uidText;  
  
    public void OnEditEmail(string email)  
    {  
        this.email = email;  
    }  
      
    public void OnEditPassword(string pw)  
    {  
        this.password = pw;  
    }  
      
    public void OnClickGuestLogin()  
    {  
        FirebaseAuthService.SignInAnonymously(  
            (user) =>  
            {  
                Debug.Log($"게스트 로그인 성공! UID = {user.UserId}, IsAnon = {user.IsAnonymous}");  
                UidTextSetting(user);  
            },  
            Debug.LogError);  
    }  
  
    public void OnClickEmailLogin()  
    {  
        FirebaseAuthService.SignInWithEmail(email, password,  
            (user) =>  
            {  
                Debug.Log($"로그인 성공! UID = {user.UserId}, IsAnon = {user.IsAnonymous}");  
                UidTextSetting(user);  
            },  
            Debug.LogError);  
    }  
      
    public void OnClickEmailSignUp()  
    {  
        FirebaseAuthService.SignUpWithEmail(email, password,  
            (user) =>  
            {  
                Debug.Log($"회원가입 성공! UID = {user.UserId}, IsAnon = {user.IsAnonymous}");  
                UidTextSetting(user);  
            },  
            Debug.LogError);  
    }  
  
    public void OnClickResetPassword()  
    {  
        FirebaseAuthService.SendPasswordReset(email,   
            () => Debug.Log($"{email} <- 이메일로 비밀번호 초기화 이메일 전송 완료"),  
            Debug.LogError);  
    }  
  
    public void OnLinkCredential()  
    {  
        FirebaseAuthService.LinkAnonymousToEmail(email, password,   
            (user) =>  
            {  
                Debug.Log($"게스트에서 이메일로 계정 연결 완료");  
                UidTextSetting(user);  
            },  
            Debug.LogError);  
    }  
  
    public void UidTextSetting(FirebaseUser user)  
    {  
        uidText.text = user.UserId;  
    }  
}
  • 유니티 로그와 Firebase 콘솔에서 확인

  • 게스트 이메일 연결 확인 완료

Firestore 사용해보기

  • 파이어베이스 콘솔에서 파이어스토어 데이터베이스 사용 설정
// FireStoreService.cs
public class FirestoreService : MonoBehaviour  
{  
      
    private static FirebaseFirestore DB => FirebaseFirestore.DefaultInstance;  
      
    // 문서 쓰기  
    public static void Set(string col, string docId, Dictionary<string, object> data, bool merge = false)  
    {  
        // 병합할거면 병합하고 아니면 그냥 씀  
        var task = merge   
? DB.Collection(col).Document(docId).SetAsync(data, SetOptions.MergeAll)  
            : DB.Collection(col).Document(docId).SetAsync(data);  
        task.ContinueWithOnMainThread(t =>  
        {  
            if (t.IsFaulted || t.IsCanceled) Debug.LogError(FSUtil.Err(t.Exception));  
            else Debug.Log($"Set {(merge ? "(Merge)" : "")} OK: {col}/{docId}");  
        });  
    }  
      
    // 업데이트  
    public static void Update(string col, string docId, Dictionary<string, object> data)  
    {  
        DB.Collection(col).Document(docId).UpdateAsync(data)  
            .ContinueWithOnMainThread(t =>  
            {  
                if (t.IsFaulted || t.IsCanceled) Debug.LogError(FSUtil.Err(t.Exception));  
                else Debug.Log($"Update OK: {col}/{docId}");  
            });  
    }  
      
    // 문서 읽기  
    public static Dictionary<string, object> Get(string col, string docId)  
    {  
        Dictionary<string, object> data = new ();  
        DB.Collection(col).Document(docId).GetSnapshotAsync()  
            .ContinueWithOnMainThread(t =>  
            {  
                if (t.IsFaulted || t.IsCanceled) Debug.LogError(FSUtil.Err(t.Exception));  
                var snap = t.Result;  
                if (!snap.Exists)  
                {  
                    Debug.Log($"{col}/{docId}에 문서 없음");  
                    data = null;  
                    return;  
                }  
                else  
                {  
                    data = snap.ToDictionary();  
                    return;  
                }  
            });  
        return data;  
    }  
  
    // 문서 삭제  
    public static void Delete(string col, string docId)  
    {  
        DB.Collection(col).Document(docId).DeleteAsync()  
            .ContinueWithOnMainThread(t =>  
            {  
                if (t.IsFaulted || t.IsCanceled) Debug.LogError(FSUtil.Err(t.Exception));  
                else Debug.Log($"🗑Delete OK: {col}/{docId}");  
            });  
    }  
  
    // 상위 N개 추출  
    public static void TopN(string collection, string orderByField, int limit = 10, bool desc = true,  
        string displayNameField = "name")  
    {  
        Query q = DB.Collection(collection);  
        // 오름차순 or 내림차순으로 조회  
        q = desc ? q.OrderByDescending(orderByField) : q.OrderBy(orderByField);  
        // Limit 개수만큼 지정  
        q = q.Limit(limit);  
  
        q.GetSnapshotAsync().ContinueWithOnMainThread(t =>  
        {  
            if (t.IsFaulted || t.IsCanceled)  
            {  
                Debug.LogError(t.Exception != null ? t.Exception.ToString() : "TopN 쿼리 실패 (예외 정보 없음)");  
                return;  
            }  
  
            // 결과물을 List<DocumentSnapShot> 으로 가져옴  
            var docs = t.Result.Documents.ToList();  
  
            if (docs.Count == 0)  
            {  
                Debug.Log("결과 없음 (문서가 없거나 인덱스/규칙 문제 가능)");  
                return;  
            }  
              
            int rank = 1;  
            foreach (var doc in docs)  
            {  
                var data = doc.ToDictionary();  
                string score = ReadNumber(data, orderByField);  
                string name = ReadString(data, displayNameField);  
                // 예: #1  abc123  score=9999  name=홍길동  
                Debug.Log($"#{rank,2}  {doc.Id}  {orderByField}={score}  {displayNameField}={name}");  
                rank++;  
            }  
        });  
    }  
      
    // ===== 7) 쿼리: where + order + limit =====  
    public static void WhereOrderLimit(  
        string col, string whereField, object equalsValue,  
        string orderField, int limit, bool desc = false)  
    {  
        Query q = DB.Collection(col).WhereEqualTo(whereField, equalsValue);  
        q = desc ? q.OrderByDescending(orderField) : q.OrderBy(orderField);  
        q = q.Limit(limit);  
  
        q.GetSnapshotAsync().ContinueWithOnMainThread(t =>  
        {  
            if (t.IsFaulted || t.IsCanceled)  
            {  
                var ex = t.Exception;  
                if (ex != null) Debug.LogError(FSUtil.Err(ex));  
                else Debug.LogError("Firestore 작업 실패 (예외 정보 없음)");  
                return;  
            }  
            Debug.Log($"Query {col} where {whereField}=={equalsValue} order {orderField} {(desc?"DESC":"ASC")} limit {limit}");  
            foreach (var doc in t.Result.Documents)  
                Debug.Log($" - {doc.Id} => {FSUtil.DictToString(doc.ToDictionary())}");  
        });  
    }  
      
    // 실시간 리스너 문서  
    public static ListenerRegistration ListenDoc(string col, string docId)  
    {  
        return DB.Collection(col).Document(docId)  
            .Listen(MetadataChanges.Include, snapshot =>   
            {  
                if (!snapshot.Exists)  
                {  
                    Debug.Log($"(Doc) 없음: {col}/{docId}");  
                    return;  
                }  
  
                // 메타데이터 변경 이벤트까지 받고 싶으면 MetadataChanges.Include 전달  
                Debug.Log($"(Doc) {col}/{docId} => {FSUtil.DictToString(snapshot.ToDictionary())}");  
            });  
    }  
  
    // 실시간 리스너 쿼리  
    public static ListenerRegistration ListenQuery(string col, string whereField, object equalsValue)  
    {  
        return DB.Collection(col).WhereEqualTo(whereField, equalsValue).Listen(MetadataChanges.Include,  
            snap =>  
            {  
                Debug.Log($"(Query) {col} where {whereField}=={equalsValue}");  
                foreach (var doc in snap.Documents)  
                    Debug.Log($" - {doc.Id} => { FSUtil.DictToString(doc.ToDictionary())}");  
            }  
        );  
    }  
  
    // 배치  
    public static void DemoBatch()  
    {  
        var batch = DB.StartBatch();  
        var a = DB.Collection("demo").Document("A");  
        var b = DB.Collection("demo").Document("B");  
  
        batch.Set(a, new Dictionary<string, object> {  
            {"val", 1}, {"ts", Timestamp.GetCurrentTimestamp()}  
        });  
        batch.Update(b, new Dictionary<string, object> {  
            {"count", FieldValue.Increment(1)}  
        });  
  
        batch.CommitAsync().ContinueWithOnMainThread(t =>  
        {  
            if (t.IsFaulted || t.IsCanceled) Debug.LogError(FSUtil.Err(t.Exception));  
            else Debug.Log("Batch Commit OK");  
        });  
    }  
  
    // 트랜잭션 써서 카운트 증가  
    public static void TxIncrement(string col, string docId, string field)  
    {  
        DB.RunTransactionAsync(async tx =>  
        {  
            var docRef = DB.Collection(col).Document(docId);  
            var snap = await tx.GetSnapshotAsync(docRef);  
  
            long cur = 0;  
            if (snap.Exists && snap.TryGetValue(field, out long v)) cur = v;  
  
            long next = cur + 1;  
            tx.Update(docRef, new Dictionary<string, object> { { field, next } });  
            return next;  
        })  
        .ContinueWithOnMainThread(t =>  
        {  
            if (t.IsFaulted || t.IsCanceled) Debug.LogError(FSUtil.Err(t.Exception));  
            else Debug.Log($"Tx Counter = {t.Result}  ({col}/{docId}.{field})");  
        });  
    }  
      
    // 오브젝트의 타입에 따라 다르게 읽기  
    static string ReadNumber(IDictionary<string, object> dict, string field)  
    {  
        if (dict == null || !dict.TryGetValue(field, out var v) || v == null) return "null";  
        if (v is long l) return l.ToString();  
        if (v is int i)  return i.ToString();  
        if (v is double d) return d.ToString("0.######");  
        if (v is float f)  return f.ToString("0.######");  
        return v.ToString();  
    }  
      
    static string ReadString(IDictionary<string, object> dict, string field)  
    {  
        if (dict != null && dict.TryGetValue(field, out var v) && v != null) return v.ToString();  
        return "";  
    }  
}