유연한 플레이어 데이터 스크립트

정선호·2025년 12월 8일

Original

목록 보기
6/7

1. 개요

다량의 데이터가 필요하고 또 생산되는 타이쿤 게임의 서버 및 데이터 관리 직책을 맡게 되었다.
시간적 제한으로 인해 서버는 직접 개발 대신 상용 서버인 뒤끝 서버를 사용하기로 했다.

뒤끝 서버의 차트 시스템을 이용해 다량의 게임 데이터를 효과적으로 관리할 수 있게 되었지만
게임 데이터로부터 파생되는 수많은 플레이어 데이터들을 효율적으로 관리할 필요가 있었다.

따라서 다음과 같은 시스템을 구현했다.
해당 시스템은 뒤끝 서버를 베이스로 작성하였지만, Firebase DataBase와 같은 다른 상용 서버에서도 사용 가능하다.

이 문서에서는 뒤끝의 자세한 사용 방식과 이에 대한 스크립트는 작성하지 않고 스도코드로 대처한다.

2. 요구 조건 및 해결 방안

요구 조건

해당 플레이어 데이터 스크립트의 요구조건은 다음과 같았다.

  • 서버 및 로컬에 원할 때 저장 및 불러오기가 가능해야 한다.
  • 어떠한 데이터 타입이어도 저장 및 불러오기가 가능해야 한다.

간단하지만 참으로 파괴적인 요구 조건이었다. 원래는 서버에만 데이터를 저장하려 했지만

  • 실시간으로 데이터가 확확 바뀌는 타이쿤 게임의 특성
  • 뒤끝 DB 시스템의 부족한 칼럼으로 인해 데이터를 통째로 업로드 해야 함

위와 같은 제약으로 인해 서버 유지비용이 천정부지로 솟게 되어
'로컬 저장 우선' + '원할 때 서버에 데이터 저장 및 불러오기 기능'을 구현하게 되었다.
다만 유료재화의 수량과 일일보상 수령 상황 등의 중요 데이터는 서버에만 저장하고 불러오게 구현하였다.

해결 방안

'서버 및 로컬에 저장 및 불러오기'는 감당할 수 없는 데이터 전송 비용을 방지하기 위해 다음과 같은 방식으로 구현하였다.

  1. 실시간 변경은 항상 로컬에 저장
  2. 중요한 변경점 생성 시에만 서버에 저장
  3. 사용자가 원할 시 일괄적으로 서버에 저장 및 불러오기

최종적으로는 '젤다의 전설'과 같은 콘솔 게임의 저장 방식을 따르게 되었다.
솔로 게임이어서 평소에는 로컬에 저장하되, 과금 재화 등의 중요한 정보는 서버에 즉시 저장하고, 모바일 게임 특성 상 기기변경이 일어날 수 있어 기기 이동을 도와주는 일괄 저장 및 불러오기 기능을 추가하기로 결정했다.
서버에 저장 시에는 항상 로컬 저장을 동시에 수행하여 서버 데이터가 로컬 데이터보다 최신화되지 않도록 하였다.

'어떠한 데이터 타입이어도 저장 및 불러오기가 가능' 문제는 ParamLitJson 시스템을 사용하여 구현할 수 있었다.

  • Param은 뒤끝과 같은 상용 서버에서 데이터를 업로드할 때 일괄적으로 데이터를 전송할 수 있는 객체로 Json string형식으로의 전환이 간단한 Dictionary<string, object> 형식의 객체이다.
  • LitJson은 데이터를 다운로드할 때 전송해주는 데이터 형식이며 이 또한 Json stringDictionary<string, object> 형식으로의 전환이 간단한 객체이다.

객체를 가져올 때는 서버에서 전송해주는 Json데이터를 파싱하여 사용한다. 로컬 데이터 또한 Json으로 저장 및 불러오기를 하면 간단하게 구현 가능하다.

다음은 뒤끝 서버에서의 데이터 전송 및 불러오기의 예시 코드이다.

// Param에 값 추가
param.Add("TotalGoldEarned", TotalGoldEarned);

// LitJson에서 값 가져오기
TotalGoldEarned = data.ContainsKey("TotalGoldEarned") ? long.Parse(data["TotalGoldEarned"].ToString()) : 0;

3. 스도 코드

스도코드 개요

전체적인 스도 코드는 다음과 같다. 다음과 같이 나뉘어진다.

  • PlayerDataManager : 모든 플레이어 데이터에 접근할 수 있고 일괄적으로 관리할 수 있게 해주는 싱글턴 매니저 클래스
  • IPlayerData : 로컬 저장, 서버 저장, 데이터 불러오기, 데이터 초기화 등 모든 데이터 클래스가 수행할 함수들을 모아둔 인터페이스. 해당 인터페이스의 함수들은 PlayerDataManager에서 데이터를 일괄 관리할 때 사용하기도 한다.
  • PlayerDataBase : IPlayerData의 함수들을 실제로 구현하고, 각 플레이어 데이터의 추가 및 제거 등을 도와주는 abstract 함수들을 선언한다.
  • PlayerData : 실제로 사용하는 데이터들을 선언하고 해당 데이터의 추가 및 제거에 대한 기능들을 구현한다.

상세 스도코드

해당 스도코드는 각 스크립트가 하는 일을 번호순으로 순차적으로 나열하여 표현한다.

서버와 데이터를 주고받는 스크립트는 Server.LoadData()Server.SaveData(value)로 간소화하였다.
서버에 데이터를 초기화하는 함수는 Server.InitData(value)로 간소화하였다.

로컬에 데이터를 저장하고 불러오는 스크립트는 Local.LoadData()Local.SaveData(value)로 간소화하였다.

IPlayerData

PlayerDataManager에서 일괄적으로 사용할 함수들만을 넣어두었다.
로컬 데이터를 불러오는 함수가 구현되지 않은 이유는 선언 시 기본적으로 로컬 데이터를 불러오기 때문이다.

public interface IPlayerData
{
	// 로컬 저장소에 데이터를 저장하는 함수
    bool SaveDataAtLocal();
    
    // 서버에 데이터를 저장하는 함수
    bool SaveDataAtServer();
    
    
    // 로컬에서 데이터를 가져오는 함수
    bool LoadDataFromLocal();
    
    // 서버에서 데이터를 가져오는 함수
    bool LoadDataFromServer();
    
    
	// 로컬 데이터를 초기화하는 함수
	bool ResetLocalData();
    
    // 로컬과 서버 전체 데이터를 초기화하는 함수
    bool ResetData(); 
}

PlayerDataBase

IPlayerData에서 선언한 함수들을 상세히 구현하고, 이를 상속받는 자식 클래스들에서 어떠한 데이터형이든 대응될 수 있게 자식 클래스들에서 구체화할 abstract 함수들을 작성한다.

public class PlayerDataBase : IPlayerData
{
	// 해당 데이터를 서버에 저장하는 DB 테이블의 이름, 로컬 저장 시에도 파일의 이름으로 사용 가능하다.
	protected string TableName = string.Empty;
    
    
    // 클래스 생성자. TableName을 받아 이를 바탕으로 로컬 혹은 서버에서 데이터를 초기화한다.
    public PlayerDataBase(string tableName)
    {
    	// 1. 테이블 값 입력
    	TableName = tableName;
        
        // 2. 로컬에서 데이터 가져오기, 실패 시 서버에서 데이터 가져오기
        if (LoadDataFromLocal())
        	return;
        
        LoadDataFromServer();
    }
    
    
// 다음 region에서는 자식 클래스에서 각각 저장되는 데이터 형에 맞춰 구현해야 하는 abstract 클래스만을 선언한다.
#region Abstract Data Methods
    
    
    // 클래스 내 모든 데이터를 Param에 저장하여 반환하는 함수
    protected abstract Param GetAllDataParam();
    
    
    // 서버에서 받아온 LitJson을 클래스 내에 각각의 값으로 할당해주는 함수
    protected abstract void SetData(LitJson.JsonData data);
    
    
    // 클래스 내 모든 데이터들에 대해 개발자가 임의로 정한 기본값으로 세팅해주는 함수
	private abstract void SetDefaultData();


#endregion
    
    
// 다음 region에는 IPlayerData에서 선언한 저장 및 불러오기 함수들과 이에 대한 헬퍼 함수들의 구현부를 작성한다.
#region Save/Load Methods


    // 서버에 처음으로 데이터를 추가할 때 사용하는 함수
    private bool InitDataAtServer(Param param = null)
    {
        // 1. 서버 초기화. 실패 시 false 반환
        bool result = Server.InitData(param);
        if (result == false)
        	return false;
        
        // 2. 서버에서 방금 초기화한 데이터를 로드
        return LoadDataFromServer();
    }
    
    
    // 서버에서 데이터를 받아와 로드할 때 사용하는 함수
    protected bool LoadDataFromServer()
    {
    	// 1. 서버에서 데이터 받아오기. 실패 시 데이터 추가
        var data = Server.LoadData();
        if (data == nullOrEmpty)
        	return InitDataAtServer(GetDefaultValueParam());
        
        // 2. 받아온 데이터가 있으면 값을 할당해주고 true 반환
        SetData(data);
        return true;
    }
    
    
    // 로컬에서 데이터를 받아와 로드할 때 사용하는 함수
    protected bool LoadDataFromLocal()
    {
    	// 1. TableName이 없으면 오류이므로 false 반환
        if (string.IsNullOrEmpty(TableName))
            return false;
        
        // 2. 로컬에서 데이터 가져오기. 실패 시 SetDefaultData() 수행 후 false 반환
        var localData = Local.LoadData();
        if (localData == null || localData == default)
        {
            SetDefaultData();
            return false;
        }
        
        // 3. 성공 시 저장된 데이터 확인 후 데이터 설정
        var data = JsonConvert.JsonToObj(localData)
        if (data == nullOrEmpty)
        	SetDefaultData();
        else
        	SetData(data);
        
        return true;
    }
    
    
    // 로컬 저장소에 데이터를 저장하는 함수
    public bool SaveDataAtLocal()
    {
    	// 1. 전체 데이터를 가져와 Json string으로 직렬화한다.
        string jsonData = JsonConvert.ObjToJson(GetAllDataParam());
        
        // 2. 직렬화한 데이터를 저장한다.
        return Local.SaveData(jsonData);
    }
    
    
    // 서버에 데이터를 저장하는 함수
    public bool SaveDataAtServer()
    {
        // 1. 우선 로컬에 데이터를 저장한다. 실패 시에는 즉시 종료한다.
        if (!SaveDataAtLocal())
        	return false;
        
        // 2. 전체 데이터를 가져온다.
        var data = GetAllDataParam();
        
        // 3. 서버에 데이터를 저장한다.
        return Server.SaveData(data);
    }
    
    
	// 로컬 데이터를 초기화하는 함수
	bool ResetLocalData()
    {
    	// 1. 기본 데이터로 초기화한다.
        SetDefaultData();
        
        // 2. 로컬 저장소의 파일을 삭제한다.
        return FileManager.DeleteFile(TableName);
    }
    
    
    // 로컬과 서버 전체 데이터를 초기화하는 함수
    bool ResetData()
    {
    	// 1. 로컬 데이터를 초기화한다.
        if (!ResetLocalData())
        	return false;
        
        // 2. 초기화된 데이터를 서버에 업로드한다.
        var data = GetAllDataParam();
        return Server.SaveData(data);
    }
}

#endregion

~PlayerData

실제 데이터가 선언되어 있는 클래스. PlayerDataBase에서 abstract로 선언했던 함수들을 선언한 데이터 형식에 맞게 구현한다.
또한 각 데이터의 CRUD 도움 함수들도 이곳에서 각각 구현한다.

아래는 예시 클래스이다. Param의 추가 및 LitJson의 수행은 뒤끝 서버에서의 데이터 전송 및 불러오기 예시를 바탕으로 구현하였다.

public class TestPlayerData : PlayerBaseData
{
	public int TestInt;
    public Dictionary<string, string> TestDic;
    
    public TestPlayerData(string tableName) : base(tableName) {}
    
    
    protected Param GetAllDataParam()
    {
    	Param param = new Param();
        
        param.Add("TestInt", TestInt);
        param.Add("TestDic", JsonConvert.ObjToJson(TestDic));
        
        return param;
    }
    
    
    protected void SetData(LitJson.JsonData data)
    {
    	TestInt = data.ContainsKey("TestInt") ? int.Parse(data["TestInt"].ToString()) : 0;
        TestDic = data.ContainsKey("TestDic") ? JsonConvert.JsonToObj(data["TestDic"].ToString()) : new();
    }
    
    
	private void SetDefaultData()
    {
    	TestInt = 0;
        TestDic = new();
    }
}

PlayerDataManager

각각의 PlayerData들을 선언하여 초기화하고, 일괄적으로 관리할 수 있게 해주는 클래스이다.

public class PlayerDataManager : SingleTon
{
	// 모든 데이터를 일괄적으로 관리하기 위해 선언하는 딕셔너리
	private Dictionary<string, IPlayerData> _dict = new();
    
    // 플레이어 데이터 모음
	public TestPlayerData testData;
    public Test2PlayerData test2Data;
    
    
    // 데이터 초기화 함수
    public void Init()
    {
    	testData = new TestPlayerData("TestPlayerData");
        _dict.Add("TestPlayerData", testData);
        
        test2Data= new Test2PlayerData("Test2PlayerData");
        _dict.Add("Test2PlayerData", test2Data);
    }
    
    
    // 로컬 일괄 저장 함수
    public void SaveAllDataAtLocal()
    {
    	foreach (var data in _dict) { data.Value.SaveDataAtLocal(); }
    }
    
    
    // 서버 일괄 저장 함수
    public void SaveAllDataAtServer()
    {
    	foreach (var data in _dict) { data.Value.SaveDataAtServer(); }
    }

    
    // 로컬 일괄 불러오기 함수
    public void LoadAllDataFromLocal()
    {
    	foreach (var data in _dict) { data.Value.LoadDataFromLocal(); }
    }

    
    // 서버 일괄 불러오기 함수
    public void LoadAllDataFromServer()
    {
    	foreach (var data in _dict) { data.Value.LoadDataFromServer(); }
    }
    
    
    // 로컬 일괄 초기화 함수
    public void ResetAllLocalData()
    {
    	foreach (var data in _dict) { data.Value.ResetLocalData(); }
    }
   
    
    // 로컬과 서버 전체 데이터 일괄 초기화 함수
    public void ResetAllData()
    {
    	foreach (var data in _dict) { data.Value.ResetAllData(); }
    }
}

후기 및 추가 팁

해당 코드들을 설계하여 데이터들을 자유롭게 생성할 수 있게 됨으로써 시간 절약을 크게 할 수 있었다.

위의 스도코드에는 작성하지 않았지만, PlayerClassData<T> : PlayerBaseData와 같이 제네릭 클래스를 데이터로 추가하여 관리할 수도 있다. 이는 각각의 고유 데이터를 필요로 하는 직원 시스템 등에 유용하게 사용했다.

또한 .csv를 이용한 게임 데이터 차트 시스템과 연계하여 PlayerData 안에서 원본 GameData까지 가져오게 하는 방법 또한 충분히 가능하다. 이 때는 PlayerClassData<G, T> : PlayerBaseData와 같이 선언하여 G에 GameData 클래스 형식을 선언하면 된다.

profile
학습한 내용을 빠르게 다시 찾기 위한 저장소

0개의 댓글