Saving & Loading Data

개요

DataStore를 활용하면 플레이어의 레벨, 경험치, 보유 골드 등 핵심 데이터를 저장하고 불러올 수 있습니다. 이를 통해 플레이어의 진행 상태를 지속적으로 유지하며, RPG와 같은 육성 요소가 포함된 게임을 제작할 수 있습니다.

사용 방법

지원되는 데이터 타입

데이터 타입
지원 여부

number

O

string

O

bool

O

table

O

object

지원되지 않습니다.

함수

지원되지 않습니다.

기본 구조

DataStoreService의 GetDataStore을 사용하여 지정된 이름의 DataStore 객체를 가져올 수 있으며, DataStore 객체에 키-값(Key-Value) 형식으로 데이터를 저장하거나 불러올 수 있습니다.

  • Key (키): 데이터를 식별하는 고유한 이름 (예: PlayerGold)

  • Value (값): 저장할 데이터 (예: 1000)

추가적으로 해당 키에 부가적인 정보를 저장할 수도 있습니다.

  • UserIds (배열): 데이터와 관련한 UserId 리스트

  • Metadata (테이블) : 부가적으로 저장할 메타데이터 정보

위 두 부가정보는 DataStoreKeyInfo를 통해 읽어올 수 있습니다.

local DataStoreService = game:GetService("DataStoreService") 
local GoldStore = DataStoreService:GetDataStore("PlayerGold")

local function LoadMetadata(player)
    local success, errorMessageOrLoadValue, keyInfo = pcall(function()
       -- Key : PlayerName
        return GoldStore:GetAsync(player.UserId)
    end)

    if not success then
        print("errorMessage : ", errorMessageOrLoadValue)
    else
        local loadValue = errorMessageOrLoadValue
        local userIds = keyInfo:GetUserIds()
        local metadata = keyInfo:GetMetadata()
        print(player.Name, "Load PlayerGold : ", loadValue)
        print(" - UserIds : ", table.concat(userIds, ", "))
        print(" - Metadata: ", metadata)
    end
end

기능
설명

GetDataStore(name)

이름에 해당하는 Datastore 객체를 가져오기

GetAsync(key)

Datastore 객체에서 키에 해당하는 데이터를 불러오기

SetAsync(key, value, userIds(*optional), datastoreSetOption(*optional) )

Datastore 객체에서 키에 데이터를 저장 (덮어쓰기)

IncrementAsync(key, delta, userIds(*optional), datastoreSetOption(*optional))

Datastore 객체에서 키에 해당하는 데이터를 증감 (number 타입에 대해서만 동작)

UpdateAsync(key, callback)

Datastore 객체에서 키에 해당하는 데이터를 callback을 통해 업데이트

RemoveAsync(key)

Datastore 객체에서 키에 해당하는 데이터를 삭제

데이터 스토어 가져오기

local DataStoreService = game:GetService("DataStoreService") 
local GoldStore = DataStoreService:GetDataStore("PlayerGold") 

저장하기

local function SaveData(player)
    local success, errorMessageOrLoadValue = pcall(function()
        local saveValue = 1 
        
        -- Key : PlayerName / Value : SaveValue
        GoldStore:SetAsync(player.UserId, saveValue) 
    end)

    if not success then
        print("errorMessage : ", errorMessageOrLoadValue)
    end
end

불러오기

local function LoadData(player)
    local success, errorMessageOrLoadValue = pcall(function()
       -- Key : PlayerName
        return GoldStore:GetAsync(player.UserId)
    end)

    if not success then
        print("errorMessage : ", errorMessageOrLoadValue)
    else
        local loadValue = errorMessageOrLoadValue
        print(player.Name, "Load PlayerGold : ", loadValue)
    end
end

업데이트 하기

DataStore를 이용해 플레이어 데이터를 저장할 때, 단순히 GetAsync와 SetAsync만 사용할 경우 여러 사용자가 동시에 데이터를 읽고 쓸 때 경쟁 상태(race condition)가 발생할 수 있습니다. 이로 인해 한 사용자가 저장한 값이 다른 사용자의 저장 요청에 의해 덮어써지거나, 일부 데이터가 손실되는 문제가 발생할 수 있습니다.

예를 들어 GetAsync로 값을 가져와 처리하는 도중, 다른 이벤트나 서버에서 SetAsync가 호출되어 값이 업데이트 되는 경우, 처리중에 업데이트 되었는지 여부를 확인 할 수 없어 과거 값을 기준으로 하여 계산된 값이 덮어써지면서 직전에 업데이트 된 내용이 날라가게 됩니다.

이러한 경쟁 상태를 방지하고 데이터 무결성을 보장하기 위해서는 보다 안전한 데이터 처리 방식이 필요합니다.

IncrementAsync

숫자 값을 단순히 증가(혹은 감소)시키는 경우에는 IncrementAsync를 사용할 수 있습니다. IncrementAsync는 내부적으로 원자적 연산을 지원하여, 여러 사용자가 동시에 값을 증가시켜도 데이터 충돌 없이 안전하게 누적할 수 있습니다. 따라서 코인, 경험치, 점수 등 단순 누적이 필요한 number 데이터에는 IncrementAsync가 가장 간단하고 효율적인 선택입니다.

local function IncrementGold(player, delta)
    local success, errorMessageOrLoadValue = pcall(function()
        return GoldStore:IncrementAsync(player.UserId, delta)
    end)
    
    if not success then
        print("errorMessage : ", errorMessageOrLoadValue)
    else
        local loadValue = errorMessageOrLoadValue
        print(player.Name, "Load PlayerGold : ", loadValue)
    end
end

UpdateAsync

하지만 IncrementAsync는 number 형태 데이터에만 유효하고 단순한 증감 연산외에 곱셉, 나눗셈, 기타 분기 처리등 복잡한 형태의 데이터 업데이트를 제공하지 못합니다.

UpdateAsync 함수는 데이터스토어에서 값을 읽고, 변경하고, 다시 저장하는 과정을 원자적으로 처리할 수 있도록 설계되었습니다. 이를 통해 동시 접근 상황에서도 데이터 손실 없이 안정적으로 값을 갱신할 수 있습니다.

UpdateAsync는 콜백(callback) 함수를 인자로 받아, 현재 저장된 값을 콜백에 전달한 후 콜백이 반환하는 값을 데이터스토어에 저장합니다. 만약 동시에 여러 요청이 들어와 데이터 충돌이 발생하면, UpdateAsync는 자동으로 최신 값을 다시 불러와 콜백을 재실행하여 충돌을 해결합니다. 이 과정은 데이터가 안전하게 갱신될 때까지 반복됩니다.

콜백 함수는 데이터 트랜잭션의 안정성과 무결성을 보장하는 ACID(Aromicity, Consistency, Isolation, Duration) 처리를 가능하게 하며, 반환값에 따라 업데이트의 실제 적용 여부를 결정할 수 있습니다. 예를 들어, 콜백 함수에서 nil을 반환하면 해당 업데이트는 취소됩니다. 이를 활용해 조건부 데이터 업데이트, 무결성 검사 등 다양한 로직을 구현할 수 있습니다.

local function UpdateGold(player, delta)
    local success, errorMessageOrLoadValue, keyInfo = pcall(function()
        return GoldStore:UpdateAsync(player.UserId, function(currentGold, keyInfo)
            local newGold = (currentGold or 0) + delta		
            return { newGold, keyInfo:GetUserIds(), keyInfo:GetMetadata() }
        end)
    end)
    
    if not success then
        print("errorMessage : ", errorMessageOrLoadValue)
    else
        local loadValue = errorMessageOrLoadValue
        print(player.Name, "Load PlayerGold : ", loadValue)
    end
end

데이터 삭제 하기

RemoveAsync를 이용하면 DataStore에 저장된 값을 삭제 할 수 있습니다.

local function RemoveData(player)
    local success, errorMessageOrLoadValue = pcall(function()
        return GoldStore:RemoveAsync(player.UserId)
    end)
    
    if not success then
        print("errorMessage : ", errorMessageOrLoadValue)
    end
end

전체 코드 예시

다음 코드는 플레이어가 게임에 입장할 때, 서버에 저장된 데이터를 불러오고, 저장된 값이 없으면 초기값을 설정한 후, 해당 값을 플레이어의 Attribute로 할당합니다.

이후, 저장 함수가 호출되면, Attribute에 설정된 현재 값을 서버에 저장합니다.

DataManager = {}

local Players = game:GetService("Players") 
local DataStoreService = game:GetService("DataStoreService") 

-- 저장하거나 불러올 다양한 데이터 타입 정의
local PlayerData =
{
    { Name = "PlayerGold", InitValue = 0, Store = nil },
}

for i = 1, #PlayerData do
    PlayerData[i].Store = DataStoreService:GetDataStore(PlayerData[i].Name) 
end

--------------------------------------------------------
-- 현재 값을 서버에저장합니다.
function DataManager:SavePlayerData(player)
    repeat wait() until player:GetAttribute("IsDataLoaded")    
    
    print(player, "> SavePlayerData")        
    
    local text = ">>>> Save : "
    
    for i = 1, #PlayerData do
        local playerData = PlayerData[i]
        local store = playerData.Store        
        
        local success, errorMessageOrLoadValue = pcall(function()
            -- 플레이어의 Attribute 값을 읽고 서버에 저장
            local currentValue = player:GetAttribute(playerData.Name)
            
            store:SetAsync(player.UserId, currentValue) 
            
            return currentValue
        end)
    
        if not success then
            text = text .. "errorMessage : " .. errorMessageOrLoadValue
        else
            local loadValue = errorMessageOrLoadValue
            
            if i > 1 then
                text = text .. ", "
            end
            text = text .. player.Name .. " / Save " .. playerData.Name .. " : " .. tostring(loadValue)
        end
    end
            
    print(player, text)
end
-- 플레이어가 게임을 떠날 때 자동 저장 처리
Players.PlayerRemoving:Connect(function(player) DataManager:SavePlayerData(player) end)

--------------------------------------------------------
-- 서버에 저장된 값을 불러옵니다.
function DataManager:LoadPlayerData(player)
    print(player, "> LoadPlayerData")    
    
    local text = ">>>> Load : "    
    
    for i = 1, #PlayerData do
        local playerData = PlayerData[i]
        local store = playerData.Store        
        
        local success, errorMessageOrLoadValue = pcall(function()            
            return store:GetAsync(player.UserId)
        end)
    
        if not success then
            text = text .. "errorMessage : " .. errorMessageOrLoadValue
        else
            local loadValue = errorMessageOrLoadValue
            
            -- 저장된 값이 없으면 초기값(InitValue)을 설정 후 서버에 저장
            if loadValue == nil then      
                loadValue = playerData.InitValue    
                            
                store:SetAsync(player.UserId, loadValue) 
            end    
            
            -- 불러온 값을 플레이어의 Attribute로 설정
            player:SetAttribute(playerData.Name, loadValue)
            
            if i > 1 then
                text = text .. ", "
            end
            text = text .. player.Name .. " / Load " .. playerData.Name .. " : " .. tostring(loadValue)
        end
    end
    
    player:SetAttribute("IsDataLoaded", true)
    
    print(player, text)
end

--------------------------------------------------------
-- 플레이어가 게임에 입장시, 데이터를 불러옵니다.
local function LoadPlayerDataWhenEnter(player)
    local function onAddCharacter(character)
        print(player.Name ..  " LoadPlayerDataWhenEnter")    
        
        DataManager:LoadPlayerData(player)    
    end
    player.CharacterAdded:Connect(onAddCharacter)    
end
Players.PlayerAdded:Connect(LoadPlayerDataWhenEnter)

--------------------------------------------------------
-- 데이터 불러오기 예제
local function LoadExample(player)
    DataManager:LoadPlayerData(player)
end

-- 데이터 저장하기 예제
local function SaveExample(player)        
    -- 저장 전에 값 변경하는 예제 코드
    for i = 1, #PlayerData do
        local playerData = PlayerData[i]
        
        local currentValue = player:GetAttribute(playerData.Name)         
        
        if currentValue ~= nil then
            local newValue = currentValue + 1
            
            player:SetAttribute(playerData.Name, newValue) 
        end
    end        
    
    DataManager:SavePlayerData(player)
end

  • LoadPlayerDataWhenEnter(player)

    • 플레이어가 게임에 접속하면 호출\

  • DataManager:LoadPlayerData(player)

    • 서버에서 저장된 플레이어 데이터를 불러옴(GetAsync)

      • 저장된 값이 없으면 초기값(InitValue)을 설정 후 서버에 저장

      • 불러온 값을 플레이어의 Attribute로 설정\

  • DataManager:SavePlayerData(player)

    • 플레이어의 Attribute 값을 읽고 서버에 저장(SetAsync)

    • PlayerRemoving 이벤트를 통해 플레이어가 게임을 떠날 때 자동 저장 처리

활용 예시

DataStore에서 데이터를 저장하거나 불러올 때 Key 값을 어떻게 설정하느냐에 따라 개별 유저 데이터 뿐만 아니라 게임 전체 데이터를 관리할 수도 있습니다.

예를 들어, Key 값을 Player.Name 대신 RaceGameLeaderBoard와 같은 형식으로 설정하면, 특정 플레이어의 데이터가 아닌 서버에 저장된 랭킹 정보를 관리할 수 있습니다.

이러한 방식으로, 리더보드, 이벤트 진행 상태, 서버 설정 등과 같은 게임 전역 데이터를 저장하고 불러올 수 있습니다.

퍼블리시 및 테스트 환경의 동작 차이

퍼블리시 후 모바일 환경에서는 실제 서버에 데이터를 저장하고 불러옵니다. 반면, 스튜디오 테스트 환경에서는 데이터를 로컬에 임시 저장하며, 스튜디오 종료 시 해당 데이터는 자동으로 삭제됩니다.

주의 사항

  • 데이터 로딩이 완료되기 전에 플레이어가 게임을 진행하면 오류가 발생할 수 있으므로, 로딩이 완료될 때까지 로딩 UI를 표시하세요.

  • 저장할 데이터는 가급적 작고 단순한 구조로 설계하세요.

  • 과도한 요청은 데이터 저장 실패를 유발할 수 있으므로, 짧은 시간 내 반복 저장을 방지하세요.

    • API 호출은 분당 150회를 초과할 수 없습니다. 초과 요청시 서버에 제한이 발생할 수 있습니다.

  • 갑작스러운 게임 종료나 서버 충돌로 인해 데이터가 손실될 수 있으므로 주기적으로 데이터가 저장되도록 구현하세요.

  • 저장 또는 불러오기가 실패할 수 있으므로, pcall을 사용하여 오류를 방지하고 재시도 로직을 추가하세요.

Last updated