> For the complete documentation index, see [llms.txt](https://docs.overdare.com/llms.txt). Markdown versions of documentation pages are available by appending `.md` to page URLs; this page is available as [Markdown](https://docs.overdare.com/korean/manual/script-manual/advanced-gameplay-systems/ordereddatastore.md).

# 커스텀 리더보드

## 개요

OrderedDataStore를 활용하면 플레이어의 최고 기록, 누적 처치 수, 클리어 시간 같은 **숫자 점수를 영구 저장하고 순위대로 정렬해 조회**할 수 있습니다. 이를 통해 모든 서버의 플레이어가 함께 경쟁하는 전 기간 랭킹, 시즌 랭킹 등 월드만의 리더보드를 직접 만들 수 있습니다.

OrderedDataStore는 DataStore와 같은 키-값 저장소이지만, 값이 **정수로 제한되는 대신 값 기준 정렬 조회**를 제공한다는 점이 다릅니다.

## 어떤 기능을 사용해야 할까요?

<table><thead><tr><th width="300">하고 싶은 것</th><th>사용할 기능</th></tr></thead><tbody><tr><td>점수를 영구 저장하고 원하는 방식으로 정렬·조회해 직접 리더보드 UI를 만들기</td><td><strong>OrderedDataStore</strong></td></tr><tr><td>레벨, 인벤토리 등 다양한 형태의 플레이어 데이터를 저장하기</td><td><a href="/korean/manual/script-manual/advanced-gameplay-systems/datastore.md">DataStore</a></td></tr><tr><td>앱(아웃게임)의 Top Scorer 섹션과 캐릭터 상단에 순위를 표시하기</td><td><a href="/korean/manual/script-manual/advanced-gameplay-systems/worldrankservice.md">WorldRankService</a></td></tr></tbody></table>

## 사용 방법

### 지원되는 데이터 타입

OrderedDataStore에는 **정수만** 저장할 수 있습니다.

<table><thead><tr><th width="146.66668701171875">데이터 타입</th><th>지원 여부</th></tr></thead><tbody><tr><td>정수 (number)</td><td>O</td></tr><tr><td>소수 (number)</td><td>지원되지 않습니다.</td></tr><tr><td>string</td><td>지원되지 않습니다.</td></tr><tr><td>bool</td><td>지원되지 않습니다.</td></tr><tr><td>table</td><td>지원되지 않습니다.</td></tr></tbody></table>

지원되지 않는 값을 저장하면 오류가 발생하며 기존 값은 그대로 유지됩니다. NaN, Inf 역시 저장할 수 없습니다.

소수점이 있는 점수(예: 클리어 시간 12.34초)는 100배처럼 **고정 배율을 곱해 정수로 저장**하고, 화면에 표시할 때 다시 나누어 사용합니다.

```lua
local ClearTime = 12.34
local SaveValue = math.floor(ClearTime * 100)
local DisplayTime = SaveValue / 100
```

### 기본 구조

DataStoreService의 GetOrderedDataStore를 사용하여 지정된 이름의 OrderedDataStore 객체를 가져올 수 있으며, **키-값(Key-Value) 형식**으로 점수를 저장합니다.

* **Key (키)**: 점수의 주인을 식별하는 1\~50자의 문자열 (예: 플레이어의 UserId를 문자열로 변환한 값)
* **Value (값)**: 정렬 기준이 되는 정수 점수 (예: 1500)

DataStore와 달리 **UserIds, Metadata 같은 부가 정보와 버전 기록은 지원하지 않습니다.** 값을 덮어쓰거나 삭제하면 이전 값은 복구할 수 없습니다.

<table><thead><tr><th width="330">기능</th><th>설명</th></tr></thead><tbody><tr><td>DataStoreService:GetOrderedDataStore(name, scope<em>(*optional)</em>)</td><td>이름에 해당하는 OrderedDataStore 객체를 가져오기</td></tr><tr><td>GetSortedAsync(ascending, pagesize, minValue<em>(*optional)</em>, maxValue<em>(*optional)</em>)</td><td>저장된 점수를 정렬해 페이지 단위로 가져오기 (DataStorePages 반환)</td></tr><tr><td>GetAsync(key)</td><td>키에 해당하는 점수를 불러오기</td></tr><tr><td>SetAsync(key, value)</td><td>키에 점수를 저장 (덮어쓰기)</td></tr><tr><td>IncrementAsync(key, delta<em>(*optional)</em>)</td><td>키에 해당하는 점수를 delta만큼 증감 (delta 생략 시 1)</td></tr><tr><td>UpdateAsync(key, callback)</td><td>키에 해당하는 점수를 callback을 통해 업데이트</td></tr><tr><td>RemoveAsync(key)</td><td>키에 해당하는 점수를 삭제</td></tr></tbody></table>

모든 기능은 **서버 스크립트에서만** 사용할 수 있으며, 클라이언트에서 접근하면 오류가 발생합니다.

### 데이터 스토어 가져오기

```lua
local DataStoreService = game:GetService("DataStoreService")
local LeaderboardStore = DataStoreService:GetOrderedDataStore("KillLeaderboard")
```

* 같은 이름으로 다시 호출하면 **같은 객체**를 반환하므로, 여러 스크립트에서 같은 이름으로 가져와도 하나의 저장소를 함께 사용합니다.
* DataStore와 OrderedDataStore는 이름이 같아도 **서로 다른 저장소**입니다. `GetDataStore("Score")`와 `GetOrderedDataStore("Score")`의 값은 섞이지 않습니다.
* 이름, scope, 키는 모두 **대소문자를 구분**합니다. `KillLeaderboard`와 `killLeaderboard`는 다른 저장소입니다.

#### scope로 저장 공간 나누기

두 번째 인자 scope를 지정하면 같은 이름 안에서 저장 공간을 나눌 수 있습니다. scope를 생략하면 `global`을 사용합니다.

```lua
local DataStoreService = game:GetService("DataStoreService")

local Season1Store = DataStoreService:GetOrderedDataStore("KillLeaderboard", "Season1")
local Season2Store = DataStoreService:GetOrderedDataStore("KillLeaderboard", "Season2")
```

### 점수 저장하기

키는 문자열이므로 UserId는 `tostring()`으로 변환해 사용합니다. 숫자를 그대로 전달해도 문자열로 변환되어 저장되지만, 조회 결과의 키는 항상 문자열로 돌아오므로 처음부터 문자열로 다루는 것이 명확합니다.

```lua
local function SaveScore(player, score)
    local success, errorMessage = pcall(function()
        LeaderboardStore:SetAsync(tostring(player.UserId), score)
    end)

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

### 점수 누적하기

단순히 점수를 더하거나 빼는 경우에는 IncrementAsync를 사용합니다. 키가 없으면 delta 값으로 새로 저장되며, 갱신된 점수를 반환합니다.

```lua
local function AddKill(player)
    local success, errorMessageOrNewValue = pcall(function()
        return LeaderboardStore:IncrementAsync(tostring(player.UserId), 1)
    end)

    if not success then
        print("errorMessage : ", errorMessageOrNewValue)
    else
        print(player.Name, "Kill : ", errorMessageOrNewValue)
    end
end
```

### 최고 기록만 갱신하기

"기존 기록보다 높을 때만 저장"처럼 **현재 값을 보고 판단해야 하는 경우**에는 UpdateAsync를 사용합니다. 여러 서버가 같은 키를 동시에 갱신해도 최신 값을 기준으로 판단하므로 기록이 덮어써지지 않습니다.

```lua
local function SaveBestScore(player, score)
    local success, errorMessageOrNewValue = pcall(function()
        return LeaderboardStore:UpdateAsync(tostring(player.UserId), function(currentBest)
            if currentBest ~= nil and currentBest >= score then
                return nil
            end
            return score
        end)
    end)

    if not success then
        print("errorMessage : ", errorMessageOrNewValue)
    elseif errorMessageOrNewValue == nil then
        print(player.Name, "기존 기록 유지")
    else
        print(player.Name, "새 최고 기록 : ", errorMessageOrNewValue)
    end
end
```

OrderedDataStore의 UpdateAsync는 DataStore와 다음이 다릅니다.

* 콜백은 **현재 값 하나만** 인자로 받습니다. DataStoreKeyInfo는 전달되지 않습니다.
* 콜백은 테이블이 아닌 **새 정수 값을 그대로 반환**합니다.
* 저장되면 `갱신된 값`을, 콜백이 nil을 반환해 취소되면 `nil`을 반환합니다. 취소된 경우 현재 값을 돌려주지 않으므로, 반환값으로 저장 여부를 구분할 수 있습니다.

### 점수 불러오기

GetAsync는 저장된 점수를 반환하며, 키가 없으면 nil을 반환합니다. OrderedDataStore는 DataStoreKeyInfo를 지원하지 않으므로 두 번째 반환값은 항상 nil입니다.

```lua
local function LoadScore(player)
    local success, errorMessageOrValue = pcall(function()
        return LeaderboardStore:GetAsync(tostring(player.UserId))
    end)

    if not success then
        print("errorMessage : ", errorMessageOrValue)
    else
        local score = errorMessageOrValue or 0
        print(player.Name, "Score : ", score)
    end
end
```

### 순위 조회하기

GetSortedAsync로 저장된 점수를 정렬해 가져옵니다. 결과는 **DataStorePages** 객체로 반환되며, GetCurrentPage()로 현재 페이지의 항목 배열을 얻습니다.

| 인자        | 설명                                                                 |
| --------- | ------------------------------------------------------------------ |
| ascending | true이면 낮은 점수부터(오름차순), false이면 높은 점수부터(내림차순) 정렬합니다. **생략할 수 없습니다.** |
| pagesize  | 한 페이지에 담을 최대 항목 수입니다. 1\~100 사이로 지정하며, **생략할 수 없습니다.**             |
| minValue  | (선택) 이 값보다 작은 점수는 결과에서 제외합니다. 경계값은 포함됩니다.                          |
| maxValue  | (선택) 이 값보다 큰 점수는 결과에서 제외합니다. 경계값은 포함됩니다.                           |

각 항목은 `key`(문자열)와 `value`(정수) 필드를 가진 테이블입니다.

```lua
local function PrintTop10()
    local success, errorMessageOrPages = pcall(function()
        return LeaderboardStore:GetSortedAsync(false, 10)
    end)

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

    local pages = errorMessageOrPages
    for rank, entry in ipairs(pages:GetCurrentPage()) do
        print(rank, "위 / UserId : ", entry.key, " / Score : ", entry.value)
    end
end
```

저장된 점수가 하나도 없으면 GetCurrentPage()는 nil이 아닌 **빈 테이블**을 반환하므로 별도의 nil 검사 없이 순회할 수 있습니다.

#### 점수 구간으로 조회하기

minValue와 maxValue를 함께 지정하면 특정 점수 구간의 항목만 가져올 수 있습니다. maxValue가 minValue보다 작으면 오류가 발생합니다.

```lua
local success, errorMessageOrPages = pcall(function()
    return LeaderboardStore:GetSortedAsync(false, 50, 1000, 5000)
end)
```

#### 여러 페이지를 순서대로 읽기

IsFinished가 false이면 다음 페이지가 남아 있다는 뜻입니다. AdvanceToNextPageAsync()로 다음 페이지를 불러오며, 이 호출도 실패할 수 있으므로 pcall로 감쌉니다.

```lua
local function ReadAllPages(pages)
    while true do
        for _, entry in ipairs(pages:GetCurrentPage()) do
            print(entry.key, entry.value)
        end

        if pages.IsFinished then
            break
        end

        local success, errorMessage = pcall(function()
            pages:AdvanceToNextPageAsync()
        end)

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

* 마지막 페이지에서 AdvanceToNextPageAsync()를 호출하면 오류가 발생하며, 현재 페이지와 IsFinished는 바뀌지 않습니다.
* 같은 DataStorePages에서 다음 페이지를 불러오는 도중 다시 호출하면, 나중에 호출한 쪽이 **즉시 실패**합니다. 위 예시처럼 하나의 흐름에서 순서대로 호출하세요. 앞선 호출이 성공, 실패, 시간 초과 중 어떤 방식으로든 끝나면 다시 호출할 수 있습니다.
* 서로 다른 DataStorePages 객체는 동시에 진행해도 서로 영향을 주지 않습니다.

### 점수 삭제하기

RemoveAsync를 이용하면 키를 삭제할 수 있으며, 삭제된 점수를 반환합니다. 삭제된 키는 이후 순위 조회 결과에서 제외됩니다.

```lua
local function RemoveScore(userId)
    local success, errorMessageOrRemovedValue = pcall(function()
        return LeaderboardStore:RemoveAsync(tostring(userId))
    end)

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

삭제는 **영구적**이며 복구할 수 없습니다.

## 전체 코드 예시

다음 코드는 플레이어의 **최고 점수를 저장**하고, 서버가 **주기적으로 상위 10명을 조회**해 모든 클라이언트에 전달하는 리더보드 예시입니다.

순위 조회는 요청 한도가 가장 좁은 기능이므로, 클라이언트가 요청할 때마다 조회하지 않고 **서버에서 한 번 조회한 결과를 보관해 나눠 쓰는** 구조로 작성했습니다.

ReplicatedStorage에 **S2C\_UpdateLeaderboard** 이름의 RemoteEvent를 배치한 뒤 사용합니다.

**Script (ServerScriptService)**

```lua
local Players = game:GetService("Players")
local ReplicatedStorage = game:GetService("ReplicatedStorage")
local DataStoreService = game:GetService("DataStoreService")

local S2C_UpdateLeaderboard = ReplicatedStorage:WaitForChild("S2C_UpdateLeaderboard")
local LeaderboardStore = DataStoreService:GetOrderedDataStore("BestScoreLeaderboard")

local TOP_COUNT = 10
local REFRESH_INTERVAL = 60

local TopEntries = {}

local function SaveBestScore(player, score)
    local success, errorMessageOrNewValue = pcall(function()
        return LeaderboardStore:UpdateAsync(tostring(player.UserId), function(currentBest)
            if currentBest ~= nil and currentBest >= score then
                return nil
            end
            return score
        end)
    end)

    if not success then
        warn(player.Name, "SaveBestScore failed : ", errorMessageOrNewValue)
    end
end

local function RefreshTopEntries()
    local success, errorMessageOrPages = pcall(function()
        return LeaderboardStore:GetSortedAsync(false, TOP_COUNT)
    end)

    if not success then
        warn("RefreshTopEntries failed : ", errorMessageOrPages)
        return
    end

    local newEntries = {}
    for rank, entry in ipairs(errorMessageOrPages:GetCurrentPage()) do
        local userId = tonumber(entry.key)
        local player = Players:GetPlayerByUserId(userId)

        local displayName = entry.key
        if player then
            displayName = player.Name
        end

        table.insert(newEntries, {
            Rank = rank,
            Name = displayName,
            Score = entry.value,
        })
    end

    TopEntries = newEntries
    S2C_UpdateLeaderboard:FireAllClients(TopEntries)
end

task.spawn(function()
    while true do
        RefreshTopEntries()
        task.wait(REFRESH_INTERVAL)
    end
end)

Players.PlayerAdded:Connect(function(player)
    S2C_UpdateLeaderboard:FireClient(player, TopEntries)
end)

local function EndRound(player, roundScore)
    SaveBestScore(player, roundScore)
end
```

**LocalScript (StarterPlayerScripts)**

```lua
local ReplicatedStorage = game:GetService("ReplicatedStorage")
local S2C_UpdateLeaderboard = ReplicatedStorage:WaitForChild("S2C_UpdateLeaderboard")

local function OnUpdateLeaderboard(entries)
    for _, entry in ipairs(entries) do
        print(entry.Rank, "위 / ", entry.Name, " / ", entry.Score)
    end
end
S2C_UpdateLeaderboard.OnClientEvent:Connect(OnUpdateLeaderboard)
```

* **SaveBestScore(player, score)**
  * UpdateAsync로 기존 기록과 비교해 더 높을 때만 저장
* **RefreshTopEntries()**
  * GetSortedAsync로 상위 10명을 조회해 TopEntries에 보관하고 모든 클라이언트에 전달
  * 조회에 실패하면 이전 결과를 유지하고 다음 주기에 재시도
  * 저장된 키는 UserId이므로, 이 서버에 접속 중인 플레이어는 이름으로 바꾸어 표시하고 그 외에는 UserId를 그대로 표시
* **PlayerAdded**
  * 새로 입장한 플레이어에게는 새로 조회하지 않고 보관 중인 결과를 전달
* **OnUpdateLeaderboard(entries)**
  * 전달받은 순위를 출력하는 예시이므로, 실제로는 print 대신 리더보드 UI를 갱신하는 코드를 작성

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

퍼블리시 후 모바일 환경에서는 실제 서버에 점수를 저장하고, 모든 서버가 같은 리더보드를 공유합니다.

반면, 스튜디오 테스트 환경에서는 별도 설정 없이 **임시 저장소**를 사용하며, 테스트 플레이가 종료되면 저장한 데이터는 자동으로 삭제됩니다. 실제 저장 여부와 여러 서버 간의 동작은 퍼블리시 환경에서 확인하세요.

## 주의 사항

### 모든 호출은 실패할 수 있습니다

OrderedDataStore의 기능은 네트워크 상태나 요청 한도에 따라 실패할 수 있으며, 실패는 오류로 전달됩니다. **모든 호출을 pcall로 감싸고** 실패했을 때의 동작을 정해 두세요.

실패를 무시하면 순위가 갱신되지 않은 채로 조용히 오래된 정보가 표시될 수 있습니다.

### 실패한 IncrementAsync를 그대로 재시도하지 마세요

저장 호출이 실패했다는 것은 **결과를 받지 못했다는 뜻일 뿐, 저장되지 않았다는 보장은 아닙니다.** 이미 점수가 더해진 상태에서 IncrementAsync를 다시 호출하면 점수가 두 번 더해질 수 있습니다.

재시도가 필요하다면 서버가 알고 있는 최종 점수를 SetAsync로 다시 저장하는 방식이 안전합니다.

### 요청 한도를 고려하세요

OrderedDataStore는 일정 시간 동안 보낼 수 있는 요청 수가 제한되어 있습니다.

* 한도는 **월드 전체**와 **각 서버** 두 단계로 적용되며, 둘 중 하나라도 초과하면 요청이 처리되지 않습니다. 한도는 접속자 수에 따라 늘어나지만, 서버를 잘게 나눈다고 월드 전체 한도가 늘어나지는 않습니다.
* **순위 조회(GetSortedAsync, AdvanceToNextPageAsync)의 한도가 가장 좁습니다.** 클라이언트 요청마다 조회하지 말고, 전체 코드 예시처럼 서버에서 주기적으로 조회한 결과를 나눠 쓰세요.
* 순위 조회는 한도를 넘으면 **즉시 실패**합니다. 잠시 후 다시 시도하세요.
* 저장, 삭제, 단건 불러오기는 한도를 넘으면 실패하지 않고 **처리될 때까지 대기**합니다. 이때 호출이 평소보다 오래 걸리며, 대기 중인 요청이 너무 많이 쌓이면 새 요청은 실패합니다.
* OrderedDataStore의 GetAsync는 **DataStore의 GetAsync와 불러오기 한도를 함께 사용**합니다. 플레이어 데이터를 자주 불러오면 점수 불러오기에 쓸 수 있는 여유도 함께 줄어듭니다.
* 남은 요청 수를 미리 확인하는 기능은 제공되지 않습니다. 요청이 거부될 수 있다는 전제로 pcall과 재시도 간격을 두어 작성하세요.

### 읽은 값은 캐시되지 않습니다

GetAsync와 GetSortedAsync는 호출할 때마다 새로 요청을 보내고 요청 한도를 사용합니다. 같은 값을 여러 곳에서 사용할 때는 한 번 읽어 변수에 담아 나눠 쓰세요.

또한 다른 서버에서 저장한 직후에는 **잠시 동안 이전 값이 조회될 수 있습니다.**

### 정확하게 저장되는 정수의 범위

**-9,007,199,254,740,991 \~ 9,007,199,254,740,991** (±(2^53 - 1)) 범위 안의 정수만 정확하게 저장하고 불러올 수 있습니다.

* 스크립트에서 이 범위를 벗어난 수를 사용하면 저장하기 전에 이미 값이 반올림될 수 있으며, 이 경우 원래 값을 알아낼 수 없습니다.
* 저장된 값을 스크립트에서 정확하게 표현할 수 없으면 근사값을 반환하지 않고 **불러오기가 실패**합니다.

### UpdateAsync 콜백에는 계산만 작성하세요

여러 서버가 같은 키를 동시에 갱신하면 콜백이 최신 값으로 **여러 번 다시 실행될 수 있으며**, 실행 횟수는 보장되지 않습니다.

* 콜백 안에서 RemoteEvent 전송, 다른 저장소에 쓰기 같은 **외부 동작을 수행하지 마세요.** 중복 실행될 수 있습니다.
* 콜백 안에서 `task.wait()` 같은 **대기 함수를 호출하지 마세요.** 콜백이 그 지점에서 중단되어 점수가 저장되지 않습니다. 이때 호출 자체는 오류 없이 nil을 반환하므로 **pcall로는 실패를 알 수 없고**, 출력 창의 오류 로그와 nil 반환값으로만 확인할 수 있습니다.

### 동점인 항목의 순서는 보장되지 않습니다

같은 점수를 가진 항목끼리의 순서는 조회할 때마다 달라질 수 있습니다. 동점자 사이에 고유한 순위가 필요하다면 먼저 달성한 기록이 앞서도록 값에 보조 기준을 합산하는 등 **값 설계 단계에서 기준을 마련**하세요.

### 페이지를 넘기는 동안 순위가 바뀔 수 있습니다

GetSortedAsync의 결과는 조회 시점의 고정된 사본이 아닙니다. 첫 페이지를 읽은 뒤 점수가 추가되거나 삭제되면 다음 페이지에 그 변경이 반영되어, **같은 항목이 두 번 나오거나 누락될 수 있습니다.** 여러 페이지를 표시할 때는 키를 기준으로 중복을 제거하세요.

### 서버 종료 직전의 저장은 일부만 반영될 수 있습니다

서버가 종료되기 시작하면 새로운 요청은 즉시 실패합니다. 대기 중이던 요청은 **같은 키마다 마지막 요청 하나만** 처리되고, 일정 시간 안에 처리되지 않은 요청은 버려집니다.

* 같은 키에 대기 중이던 IncrementAsync가 여러 개라면 마지막 증감분만 반영되어 **앞선 누적분이 사라집니다.**
* 점수를 플레이어가 나갈 때 한꺼번에 저장하지 말고, **게임 진행 중에 나누어 저장**하세요. 종료 시점에 잃는 양이 줄어듭니다.

## 활용 예시

* 킬 수, 획득 코인 등 누적 기록을 IncrementAsync로 쌓아 전 기간 누적 랭킹을 만들 수 있습니다.
* 스피드런처럼 낮은 기록이 좋은 게임은 클리어 시간을 정수로 변환해 저장하고, `GetSortedAsync(true, ...)`로 오름차순 조회합니다.
* scope에 시즌 이름을 지정해 시즌마다 새로운 리더보드를 운영하고, 지난 시즌 기록도 그대로 조회할 수 있습니다.
* minValue와 maxValue로 점수 구간을 조회해 브론즈, 실버, 골드 같은 등급별 순위표를 만들 수 있습니다.
* 앱과 캐릭터 상단의 순위 표시는 WorldRankService로, 월드 안의 상세 리더보드는 OrderedDataStore로 나누어 함께 사용할 수 있습니다.
