XR플밍 - 12. UnityEngine3D 네트워크 프로그래밍 - 파이어베이스 데이터베이스 (7/18)

이형원·2025년 7월 18일
0

XR플밍

목록 보기
138/215

1. 데이터베이스

1.1 데이터베이스의 사용 이유

우리는 이전에 Json을 이용한 게임 데이터 저장 방식에 대해 알아본 바가 있다. 하지만 이와 같이 게임 데이터를 파일로 관리하는 방법에서, 서버 연결이 있는 멀티 플레이어 게임을 만들 경우 이와 같은 한계점이 있다.

  • 데이터를 파일로 관리하는 경우, 해당 파일의 내용을 가진 컴퓨터에서 해당 내용을 수정한 후 다시 파일을 재업로드하는 방식으로 진행되어야 한다. 하지만 이와 같은 방식은 비효율적이다.

  • 멀티플레이어의 경우 이러한 데이터를 본인 뿐만이 아니라 다른 유저가 데이터를 변경하는 경우가 생길 것이다. 이런 상황에서 계속 파일을 재업로드되는 방식으로 처리하면 속도가 느릴 뿐만 아니라 실시간으로 변경되는 데이터의 처리 순서에서 맞지 않는 순서로 데이터가 처리되는 등의 문제가 발생할 수 있다.

따라서 이러한 게임 데이터를 데이터베이스에 저장하여 서버에 저장해 두는 편이 좋다.

1.2 데이터베이스의 형태

데이터베이스의 형태는 크게 다음과 같이 두 가지 형태로 나뉜다. 간단하게만 알아보려고 한다.

1. SQL

SQL, 즉 구조화 질의 언어(Structured Query Language)는 관계형 데이터베이스를 관리하고 조작하기 위해 특별히 설계된 표준 프로그래밍 언어이다.

단적인 예시로 생각하자면 CSV 테이블 형태로 데이터를 관리하는 것을 SQL 방식이라 생각하면 된다.

  • 장점 : 관리가 용이하고 체계가 잡힌다. 속도가 빠르다
  • 단점 : 확장성이 부족하고 유연하지 않다.

2. NoSQL

NoSQL은 "Not Only SQL"의 약자로, 전통적인 관계형 데이터베이스와 다른 광범위한 데이터베이스 관리 시스템을 의미한다. NoSQL 데이터베이스는 비구조화, 반구조화, 구조화된 데이터를 처리할 수 있도록 설계되었으며, SQL 데이터베이스보다 더 큰 유연성과 확장성을 제공한다. 이는 특히 대량의 데이터와 실시간 웹 애플리케이션을 처리하는 데 유용하다.

단적인 예시로는 트리를 생각하면 좋다.

  • 장점 : 확장성이 좋다.
  • 단점 : 체계를 직접적으로 관리하지 않는 이상 관리하기가 쉽지 않다. 비교적 느리다.

1.3 파이어베이스 데이터베이스

파이어베이스는 위 두 가지 방식 중 NoSQL을 사용하고 있다. 따라서 SQL 데이터베이스 방식보다는 체계가 잘 안 잡힐 수 있지만, 파이어베이스 데이터베이스를 사용하는 장점으로는 게임의 프로토타입을 개발하는 데 강한 장점이 있다는 특징이 있다.

또한 현업에서도 파이어베이스가 사용되는 비율이 높은 편이다. 특히 NoSQL 형식임에도 상당히 빠른 데이터 입출력 덕분에 실시간으로 데이터가 변동하는 게임 개발에서 사용하기 적합하다고 할 수 있다.

  • Cloud Firestore

비교적 최근에 Cloud Firestore라는 최신 파이어베이스 데이터베이스가 새로 나왔으며, 속도가 아주 빠르다고 한다. 하지만 아직 현업에서는 Realtime Database를 쓰는 비중이 높아서 강의는 이 버전을 기준으로 한다. 다만 트렌드상으로 Cloud Firestore로 넘어갈 가능성이 크다는 것은 염두에 두자.

2. 파이어베이스 데이터베이스 사용하기

2.1 파이어베이스 리얼타임 데이터베이스 세팅하기

우리는 이전에 파이어베이스 Authentication을 사용하면서 SDK 파일과 파이어베이스 프로젝트 세팅을 해 놓은 상태이다. 그러면 여기에서 데이터베이스에 대한 테스트를 진행하기 위해 SDK를 추가로 받아보자.

FirebaseDatabase SDK 파일을 넣어보자.

이전 수업에서 파이어베이스 파일 경로를 바꾸지 말라고 강사님이 말씀하신 적이 있었는데, 이것 때문이다.
SDK를 추가로 받는 상황에서 이와 같이 모든 파일을 다운받는 것이 아닌, 신규 파일을 받게 된다.
파일의 경로가 변경되거나 하는 등의 상황이 있으면 파일을 동일한 파일로 인식하지 못하고 중복 다운로드 될 수 있으니 유의하도록 하자.

그 다음으론 파이어베이스 리얼타임 데이터베이스를 만들어보자.

옵션은 이와 같이 데이터베이스의 위치를 결정할 수 있는데, 아시아권이라서 가까운 싱가포르를 선택해도 되고, 아니면 미국으로 선택했을 때 빠른 경우도 있으니 자유롭게 선택하도록 하자.
(본인은 싱가포르로 설정함)

다음으로 보안규칙에 대한 부분으로, 잠금 모드로 시작 을 기본으로 되어 있을 것이다. 하지만 이와 같이 설정을 시작해버리면 모든 잠금을 수동으로 풀어서 데이터베이스 테스트를 진행해야 하므로, 우선은 테스트 모드에서 시작으로 선택한다.

이와 같은 화면이 뜨면 프로젝트 생성 자체는 완료된 것이다.

여기에서 한 가지 작업을 더 해야 하는데, Json 파일을 다시 받아야 한다는 점이다.

이전에 Json 파일을 여기서 다운받아야 한다고 말한 적이 있는데, 이 파일을 다시 받아서 이전의 Authentication만 했을 때의 파일과 어떻게 다른지 확인해보자.

  • 이전 Json 파일

  • 업데이트 된 Json 파일

이와 같이 파일 내용이 달라진 것을 확인할 수 있다. 그러므로 Unity 프로젝트에 있었던 Json 파일과 데스크톱 파일을 삭제한 다음, 새 파일로 다시 넣어서 실행시켜주자.

실행시키면 한 번 오류가 발생했다가 다시 데스크톱 파일이 생성된 것을 확인할 수 있다.

마지막으로 데이터베이스를 연결해줘야 하므로 FirebaseManager를 수정해주자.

using UnityEngine;
using Firebase.Extensions;
using Firebase.Auth;
using Firebase;
using Firebase.Database;

public class FirebaseManager : MonoBehaviour
{
    private static FirebaseManager instance;
    public static FirebaseManager Instance { get { return instance; } }

    private static FirebaseApp app;
    public static FirebaseApp App { get { return app; } }

    private static FirebaseAuth auth;
    public static FirebaseAuth Auth { get { return auth; } }

    private static FirebaseDatabase database;
    public static FirebaseDatabase Database { get { return database; } }

    private void Awake()
    {
        if (instance == null)
        {
            instance = this;
            DontDestroyOnLoad(gameObject);
        }
        else
        {
            Destroy(gameObject);
        }
    }

    private void Start()
    {
        Firebase.FirebaseApp.CheckAndFixDependenciesAsync().ContinueWithOnMainThread(task => {
            Firebase.DependencyStatus dependencyStatus = task.Result;
            if (dependencyStatus == Firebase.DependencyStatus.Available)
            {
                Debug.Log("파이어 베이스 설정이 모두 충족되어 사용할 수 있는 상황");
                app = FirebaseApp.DefaultInstance;
                auth = FirebaseAuth.DefaultInstance;
                database = FirebaseDatabase.DefaultInstance;

                database.GoOnline();
            }
            else
            {
                Debug.LogError($"파이어 베이스 설정이 충족되지 않아 실패했습니다. 이유: {dependencyStatus}");
                app = null;
                auth = null;
                database = null;
            }
        });
    }
}
  • 매우 중요한 부분
    후에 발견된 문제지만, database.GoOnline(); 을 꼭 붙여두도록 한다.
    (실제로 이걸 넣지 않았을 경우 데이터베이스가 실시간으로 업데이트되지 않는 현상이 발견됨)

2.2 데이터베이스의 사용 방법

1. 데이터

파이어베이스 데이터베이스 화면에 돌아오면, 이와 같은 초기화면에서 마우스를 갖다대면 이와 같이 + 모양이 뜬다. 이어와 같이 + 모양을 선택하여 데이터를 추가하는 방식이다.

우측 상단의 버튼을 통해 데이터베이스에 입력되어 있는 데이터 노드를 전부 열거나 접을 수 있으며, Json으로 내보내거나 가져올 수도 있다.

2. 규칙

규칙에 대한 것은 다음주 강의에서 알아볼 예정이다.

위 사진과 같이 설정이 되어있지 않을 경우 read : true, write : true로 하면 읽기와 쓰기가 자유로워진다.

데이터 백업의 경우 유료 버전에서 가능한 부분이니 넘어가도록 한다.

3. 무료 버전의 데이터 베이스 용량 및 제한사항

여기서 주목해야 할 것은 동시 연결이 100명까지 가능하다는 점과 저장된 크기가 1GB인 점이다.

  • 동시 연결 : 100명 - 유저 수를 포함하여 데이터를 동시에 변경할 수 있는 사람 수를 말한다. 하지만 유저수가 100명 이상이 되면 안 된다는 소리가 아니며, 데이터를 변경하는 주체 수가 100명을 넘으면 안 된다는 소리다.
    만약 해당 인원 수를 넘기면 IsFault 에러로 넘어가게 되며 유료 버전으로의 업그레이드를 고려해야 한다.

  • 저장된 크기 1GB는 언뜻 보기에는 제공되는 용량이 많지 않다고 생각할 수도 있을 것이다. 하지만 데이터베이스는 텍스트 형태로 저장되기 때문에 텍스트 분량으로 1GB면 결코 적은 용량은 아닐 것이다.
    하지만 용량이 넉넉하다고 무작정 데이터베이스를 이용하기 보다는 효율적으로 데이터를 관리할 방법에 대한 고민이 필요할 것이다.

3. 데이터 읽기와 쓰기

이제 데이터의 읽기와 쓰기의 방법을 알아보고자 한다.

3.1 데이터의 구조화

파이어베이스 데이터베이스는 클라우드 호스팅 JSON 트리라고 생각하면 된다. SQL 데이터베이스와 달리 테이블이나 레코드가 없으며 JSON 트리에 추가된 데이터는 연결된 키를 갖는 JSON 구조의 노드가 된다.

  • 파이어베이스 리얼타임 데이터베이스는 32단계까지의 데이터 중첩을 허용하며, 데이터베이스의 특정 위치에서 데이터를 가져오면 모든 하위 노드의 정보를 가져온다. 따라서 변수명이 겹치지 않도록 하는 것이 좋으며 구조를 최대한 평면화하는 것이 좋다.

3.2 데이터 저장(쓰기)

파이어베이스의 공식 문서에 따르면, 데이터를 쓰는 메서드는 다음과 같이 5가지가 있다.

이 중 잘 쓰이지 않는 Push()를 제외하고 나머지는 사용 방식을 알아보려 한다.

1. SetValueAsync()

SetValueAsync()는 해당 메소드 내의 내용대로 해당 경로의 모든 데이터를 변경한다(덮어씌운다).
사용하기 적절한 상황이라고 한다면 최초의 데이터 초기화 과정 등에서 유용하게 사용할 수 있다.
SetRawJsonValueAsync()와 같이 제일 속도가 빠른 방식이다.

우선 아래와 같이 코드를 작성하고, 해당 컴포넌트를 로비 패널에 붙였다.
(로그인을 해야지 데이터베이스를 불러올 수 있기 때문)

using Firebase.Database;
using UnityEngine;
using UnityEngine.UI;

public class DatabaseTester : MonoBehaviour
{
    [SerializeField] Button testButton;
    [SerializeField] string text;
    private void Awake()
    {
        testButton.onClick.AddListener(Test);
    }

    private void Test()
    {
        DatabaseReference reference = FirebaseManager.Database.RootReference;
        reference.SetValueAsync(text);
    }
}

이와 같이 데이터가 실시간으로 전송되는 것을 확인할 수 있다.
여기서 SetValueAsync() 가 전달하는 수 있는 데이터 유형은 다음과 같다.

여기서 또 하나 유의해야 할 것은, 파이어베이스 데이터베이스는 유니티에서 자주 쓰이는 int형과 float형을 지원하지 않으므로, long이나 double형으로 먼저 불러온 후 다시 int와 float형으로 변환해야 한다.
그리고 Dictionary<string, Object>형으로 데이터를 곧잘 저장하는 편이지만, 유니티는 Dictionary에 대한 직렬화를 지원하지 않으므로 조금 불편한 부분이 있을 수 있다. 파이어베이스에서 Dictionary에 대한 직렬화 기능을 지원하고 있다고 하니, 그 방법 및 활용 가능성을 찾아볼 필요성이 있어보인다.

  • 실험 - 입력할 때 오버플로우를 일으켜보면?

혹시나 long 형 등에서 오버플로우를 일으키면 어떻게 되는지도 테스트 해 보았다. long형이 담지 못하는 큰 값을 입력했더니 오버플로우가 된 수치가 입력된 것을 확인할 수 있었다.
이는 유니티 C#의 스크립트 단계에서 오버플로우로 도출된 값이 그대로 데이터베이스에 입력된 것임을 알 수 있었다.

2. SetRawJsonValueAsync()

SetRawJsonValueAsync()은 SetValueAsync()와 마찬가지로 데이터를 덮어씌우는 방식으로 데이터를 쓰는 방식이다. 다만 Json형태로 되는 데이터를 받아서 저장한다는 특징이 있다.

using Firebase.Database;
using UnityEngine;
using UnityEngine.UI;

public class DatabaseTester : MonoBehaviour
{
    [SerializeField] Button testButton;
    [SerializeField] string text;
    private void Awake()
    {
        testButton.onClick.AddListener(Test);
    }

    private void Test()
    {
        DatabaseReference reference = FirebaseManager.Database.RootReference;
        
        string json = JsonUtility.ToJson(text)
        reference.SetRawJsonValueAsync(json);
    }
}

3. 유저별 데이터를 저장하기(폴더링)

다만 위와 같은 경우 특정 노드의 데이터를 전부 바꿔버리는 방식이기에 데이터를 각 폴더별로 관리할 필요가 있다.

가령 A유저가 캐릭터를 생성해서 특정 레벨까지 캐릭터를 키우고 종료했는데, B유저가 접속해서 데이터베이스에 새로운 캐릭터로 데이터가 덮어지면 안 될 것이다. 이를 위해 유저별로 폴더링을 하기 위해 권장하는 방식이 다음과 같이 폴더링하는 기법이다.

using Firebase.Auth;
using Firebase.Database;
using System;
using System.Collections.Generic;
using UnityEngine;
using UnityEngine.UI;

public class DatabaseTester : MonoBehaviour
{
    [SerializeField] Button testButton;

    [SerializeField] PlayerData data;
    private void Awake()
    {
        testButton.onClick.AddListener(Test);
    }

    private void Test()
    {
        FirebaseUser user = FirebaseManager.Auth.CurrentUser;

        DatabaseReference reference = FirebaseManager.Database.RootReference;
      	// UserId라는 고유한 번호를 기준으로 폴더링하고, 데이터를 반영
        DatabaseReference userInfo = reference.Child("UserData").Child(user.UserId);

        string json = JsonUtility.ToJson(data);
        Debug.Log(json);
        
        userInfo.SetRawJsonValueAsync(json);
    }
}

[Serializable]
public class PlayerData
{
    public string Name;
    public int Level;
    public float Speed;
    public bool IsAlive;
    public List<string> Skill;
}

계정이 여러 개일 경우, 아래와 같이 입력되는 것을 확인할 수 있다.

4. UpdateChildrenAsync()

앞선 두 가지 함수의 경우, 데이터 자체를 덮어쓰는 것이기 때문에 기존 변경이 없는 데이터를 보존할 수 없고, 덮어씌우는 과정에서 유지되는 데이터를 깜빡하고 누락할 수 있는 등의 문제가 발생할 수 있다.

이를 방지하기 위해서 데이터의 변동이 있을 경우, UpdateChildrenAsync()를 쓰는 것이 좋다.
(다만 여러 데이터를 한 번에 바꿀 때 권장하는 방식으로, 변경해야 할 데이터가 한 개 정도일 경우, 속도가 빠른 SetValueAsync()를 권장한다.)

private void Test()
{
    // 초기 세팅할 때만 한 번 사용
    FirebaseUser user = FirebaseManager.Auth.CurrentUser;

    DatabaseReference reference = FirebaseManager.Database.RootReference;
    DatabaseReference userInfo = reference.Child("UserData").Child(user.UserId);

    string json = JsonUtility.ToJson(data);
    Debug.Log(json);
    
    userInfo.SetRawJsonValueAsync(json);


    // 게임 중 단독 변화 상황에 대해서는 아래와 같이 반영
    DatabaseReference levelRef = userInfo.Child("Level");
    levelRef.SetValueAsync(3);
    
    // 데이터의 변동이 발생했을 때 UpdateCHildrenAsync로 변동 내용을 반영
    Dictionary<string, object> dictionary = new Dictionary<string, object>();
	dictionary["Level"] = 5;
	dictionary["Speed"] = 20.1;

	userInfo.UpdateChildrenAsync(dictionary);
}

5. RunTransaction()

오류를 겪은 탓에 다음주에 다룰 예정이다.
오류의 원인은 데이터가 업데이트되지 않는 부분에서 발생한 것으로 보인다.

6. 데이터 삭제

데이터의 삭제는 덮어씌우는 방식에서 null로 적용하는 방식, 아니면 RemoveValue()로 호출해도 된다.

SetValueAsync(data) = null;
UpdateChildrenAsync(특정 데이터) = null;
RemoveValue();

3.3 데이터 읽기

데이터를 읽어오는 방식은 GetValueAsync()를 한 번 호출하여 데이터를 불러오거나, Json 데이터로 다시 가져오는 방식이 있다.

  • GetValueAsync 호출
private void Test2()
{
    FirebaseUser user = FirebaseManager.Auth.CurrentUser;

    DatabaseReference root = FirebaseManager.Database.RootReference;
    DatabaseReference userInfo = root.Child("UserData").Child(user.UserId);

    userInfo.GetValueAsync()
        .ContinueWithOnMainThread(task =>
        {
            if(task.IsCanceled)
            {
                Debug.LogError("값 가져오기 취소됨");
                return;
            }
            if(task.IsFaulted)
            {
                Debug.LogError($"값 가져오기 실패. 실패 이유 : {task.Exception}");
                return;
            }

            DataSnapshot snapshot = task.Result;
            Debug.Log($"snapshot child count : {snapshot.ChildrenCount}");
            
            bool isAlive = (bool)snapshot.Child("IsAlive").Value;
            Debug.Log($"IsAlive : {isAlive}");

            long level = (long)snapshot.Child("Level").Value;
            Debug.Log($"Level : {level}");

            string name = (string)snapshot.Child("Name").Value;
            Debug.Log($"Name : {name}");

            float speed = (float)(double)snapshot.Child("Speed").Value;
            Debug.Log($"Speed : {speed}");

            List<object> skill = (List<object>)snapshot.Child("Skill").Value;
            for (int i = 0; i < skill.Count; i++)
            {
                Debug.Log($"Skill : {skill[i]}");
            }
        });
}
  • GetRawJsonValue()
private void Test2()
{
    FirebaseUser user = FirebaseManager.Auth.CurrentUser;

    DatabaseReference root = FirebaseManager.Database.RootReference;
    DatabaseReference userInfo = root.Child("UserData").Child(user.UserId);

    userInfo.GetValueAsync()
        .ContinueWithOnMainThread(task =>
        {
            if (task.IsCanceled)
            {
                Debug.LogError("값 가져오기 취소됨");
                return;
            }
            if (task.IsFaulted)
            {
                Debug.LogError($"값 가져오기 실패. 실패 이유 : {task.Exception}");
                return;
            }

            DataSnapshot snapshot = task.Result;
            Debug.Log($"snapshot child count : {snapshot.ChildrenCount}");

            string json = snapshot.GetRawJsonValue();
            Debug.Log(json);

            PlayerData playerData = JsonUtility.FromJson<PlayerData>(json);
            Debug.Log($"IsAlive : {playerData.IsAlive}");
            Debug.Log($"Name : {playerData.Name}");
            Debug.Log($"Speed : {playerData.Speed}");
            Debug.Log($"Level : {playerData.Level}");
            for (int i = 0; i < playerData.Skill.Count; i++)
            {
                Debug.Log($"Skill[{i}] : {playerData.Skill[i]}");
            }
        });
}

profile
게임 만들러 코딩 공부중

0개의 댓글