[뒤끝팁] 데이터 복구, 유저 대응 최적화! 데이터 저장 팁

안녕하세요, 개발자님! 게임 서버 뒤끝입니다.
오늘은 데이터 복구에 최적화된 ‘새로운 데이터 저장 방법’을 안내드립니다.

바로 한 유저당 최대 n 일치의 데이터를 가지도록 하는 저장법입니다.
(유저 한 명당 row 1개가 아니라, row n개씩 저장)

지금부터 소개 드리는 방법을 잘 활용해 보신다면 문제가 발생했을 때
보다 원활한 데이터 복구와, 보다 빠른 유저 대응이 가능해 지실 거예요.?

목차는 다음과 같습니다.

0. 저장할 데이터 형식 만들기
1. 데이터 불러오기 및 최신 데이터 적용하기
2. 서버에서 현재 시간을 불러와, 데이터의 indate와 현재 날짜의 Day를 비교하기
3. Day가 같을 경우, 그냥 진행
4. Day가 다를 경우는 이렇게 진행하세요!

가장 간단한 데이터 저장 로직은 다음과 같습니다.

① 데이터 읽어오기
② 데이터가 없을 경우, 데이터 삽입
③ 데이터가 있을 경우, 해당 데이터 적용

즉, 한 테이블에 유저 한 명당 하나의 데이터만 저장하는 방식인데요.

위 방식이 평소에는 문제가 없지만, 에러가 발생하면 기존 데이터가 어떠한 것이었는지 확인이 불가능하다는 단점이 있습니다. 문제 발생 후에 원인을 찾는 것도 상당히 오랜 시간이 소요됩니다.

그래서 오늘은 문제가 발생했을 때 ‘빠르고 정확한 CS가 가능한‘ 데이터 저장 방식을 공유드립니다.

*주의 : DB에 저장하는 데이터에 비례하여 요금이 부과되므로, 데이터의 수는 적을수록 좋습니다.


0. 저장할 데이터 형식 만들기

우선 이번 콘텐츠에서 사용할 데이터를 만들어주겠습니다.

실제 게임이라면 장비, 스테이지 클리어, 업적 등 여러 데이터들이 존재하겠지만
너무 많은 분량의 예제 코드는 두뇌 건강에 해로우니..?‍♀️ 오늘은 레벨, 기본 스텟 등이 있는 플레이어 정보만 포함해 작성을 해보겠습니다.

// Copyright 2013-2022 AFI, Inc. All Rights Reserved.

using System;
using UnityEngine;
using BackEnd;
using LitJson;

{

    public long MyLevel { get; set; }     // LEVEL
    public long MySpeed { get; set; }     // SPEED
    public long MyAtk { get; set; }    // ATTACK
    public long MyMoney { get; set; }    // MONEY
    public DateTime MyLastUpdate { get; set; }     // LastUpdate


    public void Init() {
        MyLevel = MySpeed = MyAtk = 1;
        MyMoney = 0;
        MyLastUpdate = DateTime.Now;
    }

    //언마샬된 rows[0]의 json
    public bool SetData(JsonData json) {
        try {
            MyLevel = long.Parse(json["myLevel"].ToString());
            MySpeed = long.Parse(json["mySpeed"].ToString());
            MyAtk = long.Parse(json["myAtk"].ToString());
            MyMoney = long.Parse(json["myMoney"].ToString());
            MyLastUpdate = DateTime.Parse(json["myLastUpdate"].ToString());

            return true;
        }
        catch (Exception e) {
            Debug.LogError(e);

            return false;
        }
    }

    public Param GetParam() {
        Param param = new Param();

        param.Add("myLevel", MyLevel);
        param.Add("mySpeed", MySpeed);
        param.Add("myAtk", MyAtk);
        param.Add("myMoney", MyMoney);
        param.Add("myLastUpdate", MyLastUpdate);

        return param;
    }
}

Json과 Param에 클래스로 파싱하는 기능이 있지만, 저는 더 정확하고 안전하게 코드를 직접 구현했습니다.

이제 본격적으로 시작해 볼까요?

1. 데이터 불러오기, 그리고 가장 최신의 데이터를 적용하기

이건 게임을 서비스한다면 무조건 구현되어야 하는 기능이죠. 여기에 약간의 로직만 추가해 보겠습니다.

성공 시, CheckNewInDateNeed()를 통해 시간을 비교해서 데이터를 정리해 볼 겁니다.

//플레이어 정보
PlayerInfo playerInfo = new PlayerInfo();

//서버에서 불러온 정보(이후 데이터 삭제에서 사용)
JsonData loadData;

//앞으로 업데이트에 사용할 플레이어정보의 indate
string playerInfo_inDate;

private void GetPlayerData() {
    SendQueue.Enqueue(Backend.GameData.GetMyData, tableName, new Where(), callback => {

        Debug.Log("Player_GetMyData" + callback.ToString());

        if (callback.IsSuccess()) {
            if (callback.FlattenRows().Count > 0) {
                Debug.Log("데이터가 1개이상 존재합니다. 최신 데이터로 적용합니다.");

								loadData = callback.FlattenRows();
                playerInfo.SetData(callback.FlattenRows()[0]);
								playerInfo_inDate = callback.FlattenRows()[0]["inDate"].ToString();

                //2.현재 시간 불러오기 함수
                CheckNewInDateNeed();
            }
            else {
                Debug.LogWarning("데이터가 존재하지 않습니다. 새로 삽입합니다!");
                playerInfo.Init();

                //이대로 게임을 시작하면 됩니다!
                LoadFinish();
            }
        }
        else {
            Debug.LogError("에러가 발생하였습니다. 다시 시도해주세요!" + callback.ToString());
        }
    });
}

위 코드를 잘 보시면, 간간이 LoadFinish()라는 함수가 존재하는데요.

데이터를 불러오고 해당 데이터를 내 캐릭터(클라이언트)에 적용한 뒤, 이 함수를 활용해서 게임을 시작하는 로직을 구현하시면 됩니다.

① 데이터 불러오기(레벨, HP, 돈)
② 그 데이터를 내 캐릭터(클라이언트)에 적용하기
③ 모든 데이터의 적용이 끝나면, 게임 시작하기

LoadFinish(){
	SceneManager.LoadScene("InGame");
}

2. 서버에서 현재 시간을 불러와, 데이터의 indate와 현재 날짜의 Day를 비교하기

이 부분은 이번 콘텐츠의 핵심 기능입니다.

먼저 알아두셔야 할 정보는, 뒤끝에서 사용하는 유니크한 아이디인 inDate가 곧 데이터 생성 날짜라는 점입니다. 이 특성을 이용해서 생성된 날짜와 현재 날짜를 구분하는 로직을 작성해 줄 거예요.

물론 시간을 비교하지 않고 로그인을 할 때마다 데이터를 갱신하는 방법도 있지만, 만약 유저가 게임을 반복적으로 재시작할 경우, 불필요한 데이터가 쌓일 것이기 때문에 오늘은 날짜별로 구분을 했습니다.

*현재 뒤끝에서도 일 별 데이터 복구만 지원해 드리고 있습니다.

로컬 시간은 유저가 기기에서 시간을 조작하면 함께 변경됩니다. 유저의 기기마다 다를 수 있고 변경도 가능하기 때문에, 악용이 되거나 오류 발생의 원인이 되므로 서버 시간을 이용했습니다.

private void CheckNewInDateNeed() {
    //뒤끝에서 inDate는 row의 유니크한 값이자 생성 날짜
    string myLastInDate = loadData[0]["inDate"].ToString();

    DateTime myLastInDateUtc = TimeZoneInfo.ConvertTimeToUtc(System.DateTime.Parse(myLastInDate));

    SendQueue.Enqueue(Backend.Utils.GetServerTime, callback => {
        if (callback.IsSuccess()) {
            //날짜가 하루 이상 차이가 날 경우
            if (myLastInDateUtc.Day != MainSceneManager.Instance.GetServerTime().Day) {

                //4. 데이터 새로 삽입 로직이 추가됩니다.
                InsertOriginalData();
            }
            else {

                // 날짜가 같을 경우(오늘 내에 데이터를 다시 삽입하려고 한 경우)
                // 복원용 데이터를 생성할 일이 없으므로 패스

                //이제 게임을 시작하면 됩니다!
                LoadFinish();
            }
        }
        else {
            Debug.LogError("에러가 발생하였습니다. 다시 시도해주세요!" + callback.ToString());
        }
    });
}

3. Day가 같을 경우, 그냥 진행

데이터를 비교했을 때, 날짜가 같다는 것은 유저가 이미 그날 접속을 하였음을 의미합니다.

기타 이유로 재접속을 한 상태이니, 굳이 데이터를 새로 삽입할 필요가 없으므로 그냥 인게임으로 넘어가시면 됩니다.

//이제 게임을 시작하면 됩니다!
LoadFinish();

4. Day가 다를 경우는 이렇게 진행하세요!

(1) 데이터 삽입하기

날짜가 다르다면, 유저가 그날 게임에 처음 접속한 경우입니다.
그러므로 데이터를 새로 생성해 주어야 합니다.

1번에서 이미 내 Player 데이터를 적용했으니, 해당 데이터를 Insert 해주면 됩니다. (지금까지 PlayerInfo 함수를 이용한 적이 없으니, 안전한 데이터입니다)

데이터 추가가 완료되면 데이터가 원하는 개수만큼 존재하는지 확인하는 과정을 거칩니다.

private void InsertOriginalData() {
    SendQueue.Enqueue(Backend.GameData.Insert, tableName, playerInfo.GetParam(), callback => {
        Debug.Log("Player_Insert_NewIndate" + callback.ToString());

        if (callback.IsSuccess()) {
            playerInfo_inDate = callback.GetInDate();

            //4-2. 데이터 삭제 로직이 추가됩니다.
            DeleteMyData();
        }
        else {
            Debug.LogError("에러가 발생하였습니다. 다시 시도해주세요!" + callback.ToString());
        }
    });
}

(2) 데이터 삭제하기

(1)번 에서 데이터를 Insert 했다면? 데이터 수가 증가했을 것이므로, 이전 데이터를 삭제해 줍니다.

이전에 불러온 데이터를 이용해서, 내가 저장하고자 하는 데이터의 최대 개수(maxDataCount)보다 많으면 삭제를 진행합니다.

maxDataCount보다 딱! 한 개 더 많을 경우: 맨 뒤의 데이터 하나만 삭제
maxDataCount보다 2개 이상 많을 경우: 지정한 최대치 다음의 자리부터 ~ 서버에서 불러온 데이터 끝 자리까지 (*최대 10개)
maxDataCount보다 적을 경우: 별도의 삭제 로직 없이 게임 시작 (신규 유입 유저)

*주의 : 뒤끝 트랜잭션은 최대 10개까지만 지원합니다. 10개를 초과하면 에러가 발생할 수 있으니 주의해 주세요.
private void DeleteMyData() {

		//deleteRowNum 해당 숫자만큼의 데이터를 제외하고 전부 지워버립니다.
    const int maxDataCount = 4;

    //불러오는 데이터가 4개일 경우
    if (loadData.Count == maxDataCount + 1) {

        string delete_indate = loadData[maxDataCount]["inDate"].ToString();

        SendQueue.Enqueue(Backend.GameData.DeleteV2, tableName, delete_indate, Backend.UserInDate, callback => {
            Debug.Log("Player_Delete" + callback.ToString());

            if (callback.IsSuccess()) {
								//이제 게임을 시작하면 됩니다!
                LoadFinish();
            }
            else {
		            Debug.LogError("에러가 발생하였습니다. 다시 시도해주세요!" + callback.ToString());
            }
        });
    }
    // 자신이 가지고 있을 데이터의 최대 갯수보다 2 개 이상일 경우
    else if (loadData.Count >= maxDataCount + 2) {
        List<TransactionValue> transactionList = new List<TransactionValue>();

        //트랜잭션은 최대 10개까지만 지원하므로 10개를 초과하면 에러가 발생할 수 있다.
        int transCount = 0;
        for (int i = maxDataCount; i < loadData.Count; i++) {

            string inDate = loadData[i]["inDate"].ToString();

            transactionList.Add(TransactionValue.SetDeleteV2(tableName, inDate, Backend.UserInDate));
            transCount++;

            //10개가 되면 그만!
            if(transCount >= 10) {
              break;
            }
        }

        SendQueue.Enqueue(Backend.GameData.TransactionWriteV2, transactionList, callback => {
            Debug.Log("Player_Transaction_Delete" + callback.ToString());

            if (callback.IsSuccess()) {
								//이제 게임을 시작하면 됩니다!
                LoadFinish();
            }
            else {
		            Debug.LogError("에러가 발생하였습니다. 다시 시도해주세요!" + callback.ToString());
            }
        });
    }
    else {
				//이제 게임을 시작하면 됩니다!
        LoadFinish();
    }
}

부록) 전체 코드

아래에 위 1~4번 로직을 모두 합친 코드를 제공해 드립니다.

위 예시에서는 에러가 발생할 때마다 LogError의 출력만 하도록 작성했지만, 아래 로직에서는 함수를 최대 3번까지 다시 시도하도록 구현하였습니다.

그리고 프로세스의 흐름이 쉽게 이해되도록 하기 위해서, callback 뒤에 바로 다음 함수를 호출하는 것이 아니라 enum을 통해 state 값을 변경하면서 다음 코드를 실행하는 방식으로 수정이 되었습니다.

이 부분은 개인 취향이니, 개발자 님의 코딩 스타일대로 변경해 보셔도 좋겠습니다?

// Copyright 2013-2022 AFI, Inc. All Rights Reserved.

using System.Collections.Generic;
using UnityEngine;
using BackEnd;
using System;
using LitJson;

public partial class PlayerManager : MonoBehaviour {

    private static PlayerManager instance;
    public static PlayerManager Instance {
        get {
            return instance;
        }
    }

    //해당 데이터를 저장/불러오기할 테이블 이름
    private const string tableName = "Player_Info";

    // 뒤끝 함수 에러 발생 시 다시 요청할 최대 횟수
    private const int maxRepeatCount = 3;
    // 다시 요청한 횟수
    private int repeatCount = 0;

    // 현재 사용중인 게임정보의 inDate
    private string playerInfo_inDate;
    // 현재 사용중인 플레이어데이터(로컬)
    private PlayerInfo playerInfo;
    // 데이터 불러오기를 통해 가져와진 서버 데이터
    private JsonData loadData;

    void Awake() {
        if (instance == null) {
            instance = this;
        }

        playerInfo = new PlayerInfo();
        repeatCount = 0;
    }

    enum LoadProcess {
        GET_MY_DATA, // 내정보 불러오기
        INIT_NEW_DATA, // 내 정보가 없을 경우 새로 삽입하기(행동 후 Finish로 이동)
        SET_SERVER_DATA_TO_LOCAL, // 서버에서 불러온 데이터 로컬에 삽입
        CHECK_NEW_INDATE_NEED, // 현재 시간과 마지막으로 삽입한 데이터의 시간 비교
        INSERT_ORIGINAL_DATA, // 하루 이상 차이시 복원용 데이터 복사
        DELETE_MY_DATA, // 복원용 데이터가 n개 이상일 경우 n개를 남기고 모두 삭제
        FINISH // 해당 클래스의 데이터 불러오기 완료
    }

    private LoadProcess loadProcess;

    //불러오기 작업이 모두 완료되었을 때 호출, 다음 클래스 불러오기로 넘어감
    private void LoadFinish() {
        GoNextProcess(LoadProcess.FINISH);
    }

    // PlayerData 불러오기 시작(외부 호출용)
    public void StartPlayerDataLoad() {
        GoNextProcess(LoadProcess.GET_MY_DATA);
    }

    // 프로세스 이동, 재시작 카운트 초기화
    void GoNextProcess(LoadProcess process) {
        loadProcess = process;
        repeatCount = 0;

        RunLoadProcess();
    }

    // 동일한 함수 다시호출
    void RepeatThisProcess() {
        RunLoadProcess();
    }

    // PlayerLoad의 전체적인 호출
    void RunLoadProcess() {

        //호출할때마다 증가, 다른 프로세스로 이동할 경우 초기화
        repeatCount++;

        // maxRepeatCount 이상 에러 시, 와이파이 미접속이나 서버 장애가 지속된다고 판단하여 불러오기 중지
        // 로드가 제대로 되지 않은 채로 게임이 시작될 경우, 비정상적으로 플레이될 확률이 높음
        if (repeatCount > maxRepeatCount) {
            Debug.LogError("게임에 문제가 발생하였습니다.");
            //ErrorManager.Instance.ShowErrorPannel();
            return;
        }

        switch (loadProcess) {
            case LoadProcess.GET_MY_DATA:
                GetPlayerData();
                break;

            case LoadProcess.INIT_NEW_DATA:
                InitNewData();
                break;

            case LoadProcess.SET_SERVER_DATA_TO_LOCAL:
                SetServerDataToLocal();
                break;

            case LoadProcess.CHECK_NEW_INDATE_NEED:
                CheckNewInDateNeed();
                break;

            case LoadProcess.INSERT_ORIGINAL_DATA:
                InsertOriginalData();
                break;

            case LoadProcess.DELETE_MY_DATA:
                DeleteMyData();
                break;

            case LoadProcess.FINISH:
                MainSceneManager.Instance.GoNextLoadStep();
                break;
        }
    }

    //1. 서버에서 플레이어 데이터 불러오기
    private void GetPlayerData() {
        SendQueue.Enqueue(Backend.GameData.GetMyData, tableName, new Where(), callback => {
            Debug.Log("Player_GetMyData" + callback.ToString());
            if (callback.IsSuccess()) {

                //데이터가 존재할 경우 
                if (callback.FlattenRows().Count > 0) {
                    loadData = callback.FlattenRows();
                    GoNextProcess(LoadProcess.SET_SERVER_DATA_TO_LOCAL);
                }
                else {
                    //데이터가 존재하지 않을 경우
                    GoNextProcess(LoadProcess.INIT_NEW_DATA);
                }

            }
            else {
                RepeatThisProcess();
            }
        });
    }

    //1-2. 데이터가 존재하지 않을 경우, 데이터 초기화 및 초기화된 데이터 삽입
    private void InitNewData() {

        //플레이어 정보 초기화
        playerInfo.Init();

        //초기화된 정보 삽입
        SendQueue.Enqueue(Backend.GameData.Insert, tableName, playerInfo.GetParam(), callback => {
            Debug.Log("Player_Insert_NewData" + callback.ToString());

            if (callback.IsSuccess()) {
                //데이터 적용
                playerInfo_inDate = callback.GetInDate();
                LoadFinish();
            }
            else {
                RepeatThisProcess();
            }
        });
    }

    //2. 데이터가 존재할 경우, json으로 불러온 데이터 적용
    private void SetServerDataToLocal() {
        bool isSuccess = playerInfo.SetData(loadData[0]);
        if (isSuccess) {
            GoNextProcess(LoadProcess.CHECK_NEW_INDATE_NEED);
        }
        else {
            //데이터 적용중 에러 발생!
            Debug.LogError("에러 발생!");
        }
    }

    // 3. 하루마다 복구용 데이터를 만들기 위해 현재시간과 마지막 row의 삽입 날짜를 비교.
    // 만약 하루마다가 아닌 로그인할때마다라고 한다면 바로 INSERT_ORIGINAL_DATA로 이동하면 된다.
    private void CheckNewInDateNeed() {

        //뒤끝에서 inDate는 row의 유니크한 값이자 생성 날짜
        string myLastInDate = loadData[0]["inDate"].ToString();
        // UTC로 통일
        DateTime myLastInDateUtc = TimeZoneInfo.ConvertTimeToUtc(System.DateTime.Parse(myLastInDate));

        //날짜가 하루 이상 차이가 날 경우
        if (myLastInDateUtc.Day != MainSceneManager.Instance.GetServerTime().Day) {

            //복원용 데이터를 만들기
            GoNextProcess(LoadProcess.INSERT_ORIGINAL_DATA);
        }
        else {

            // 날짜가 같을 경우(오늘 내에 데이터를 다시 삽입하려고 한 경우)
            // 복원용 데이터를 생성할 일이 없으므로 패스
            LoadFinish();
        }
    }

    // 4. 복원용 데이터를 유지하기 위해 기존 데이터는 남겨두고 새로운 데이터를 삽입, 새로운 데이터의 inDate로 업데이트
    private void InsertOriginalData() {
        SendQueue.Enqueue(Backend.GameData.Insert, tableName, playerInfo.GetParam(), callback => {
            Debug.Log("Player_Insert_NewIndate" + callback.ToString());

            if (callback.IsSuccess()) {
                playerInfo_inDate = callback.GetInDate();

                //삭제할 데이터가 없어도 호출
                GoNextProcess(LoadProcess.DELETE_MY_DATA);
            }
            else {
                RepeatThisProcess();
            }
        });
    }

    // 5. 복원용 데이터가 많이 삽입될수록 DB용량이 커지므로, n개의 데이터만 유지. n개 이상일 경우 오래된 데이터 삭제  
    // 데이터가 정확히 n개라면 하나만 삭제하면 되지만, n개 이상일 경우에는 한번에 삭제하기 위해 Transaction을 사용
    private void DeleteMyData() {

        //deleteRowNum 해당 숫자만큼의 데이터를 제외하고 전부 지워버립니다.
        const int maxDataCount = 4;

        //불러오는 데이터가 4개일 경우
        if (loadData.Count == maxDataCount + 1) {

            string delete_indate = loadData[maxDataCount]["inDate"].ToString();

            SendQueue.Enqueue(Backend.GameData.DeleteV2, tableName, delete_indate, Backend.UserInDate, callback => {
                Debug.Log("Player_Delete" + callback.ToString());

                if (callback.IsSuccess()) {
                    //이제 게임을 시작하면 됩니다!
                    LoadFinish();
                }
                else {
                    Debug.LogError("에러가 발생하였습니다. 다시 시도해주세요!" + callback.ToString());
                }
            });
        }
        // 자신이 가지고 있을 데이터의 최대 갯수보다 2 개 이상일 경우
        else if (loadData.Count >= maxDataCount + 2) {
            List<TransactionValue> transactionList = new List<TransactionValue>();

            //트랜잭션은 최대 10개까지만 지원하므로 10개를 초과하면 에러가 발생할 수 있다.
            int transCount = 0;
            for (int i = maxDataCount; i < loadData.Count; i++) {

                string inDate = loadData[i]["inDate"].ToString();

                transactionList.Add(TransactionValue.SetDeleteV2(tableName, inDate, Backend.UserInDate));
                transCount++;

                //10개가 되면 그만!
                if (transCount >= 10) {
                    break;
                }
            }

            SendQueue.Enqueue(Backend.GameData.TransactionWriteV2, transactionList, callback => {
                Debug.Log("Player_Transaction_Delete" + callback.ToString());

                if (callback.IsSuccess()) {
                    //이제 게임을 시작하면 됩니다!
                    LoadFinish();
                }
                else {
                    Debug.LogError("에러가 발생하였습니다. 다시 시도해주세요!" + callback.ToString());
                }
            });
        }
        else {
            //이제 게임을 시작하면 됩니다!
            LoadFinish();
        }
    }
}

오늘은 조금 색다른 데이터 저장 방법을 소개 드렸는데요!

데이터의 indate와 현재 날짜의 Day를 비교하는 방법을 응용하면
‘?출석 체크 보상 기능‘도 구현할 수 있답니다.

관련 콘텐츠를 소개 드리니, 관심 있는 개발자 님께서는 참고해 보시면 좋을 것 같아요?

추가로 궁금한 점이 있다면 언제든 댓글 남겨주시기 바랍니다.

감사합니다!

1

댓글