GS2-Datastore
GS2-Datastore를 이용하면 임의의 바이너리 데이터를 서버에 저장할 수 있습니다.
업로드한 데이터에는 접근 권한을 설정할 수 있으며, public(전체 공개), protected(지정한 사용자ID에게만 공개: 최대 100건), private(본인에게만 공개) 중에서 선택합니다.
GS2-Datastore는 UGC나 레이싱 게임의 고스트 데이터와 같은 데이터를 업로드하는 것을 주된 목적으로 하며, 플레이어의 사용자 데이터를 저장하는 데에는 반드시 적합하다고 할 수 없습니다.
왜냐하면 소지품의 소지 수량을 바이너리 데이터로 저장할 경우, 세이브 데이터나 앱 본체의 변조로 아이템을 복제하거나 입수량을 부정하게 늘린 개조 앱으로 플레이하는 것을 허용하게 되기 때문입니다. 소지품의 소지 수량이라면 GS2-Inventory와 같은 전용 마이크로서비스를 이용함으로써 이러한 부정 행위를 막을 수 있습니다.
사용자 데이터 중에서도 설정값 등 변조되더라도 게임 밸런스에 영향을 주지 않는 데이터를 저장하는 것을 부정하는 것은 아닙니다.
유스케이스
GS2-Datastore가 상정하는 대표적인 유스케이스는 다음과 같습니다.
- 사용자가 게시한 스크린샷이나 리플레이 등 UGC 콘텐츠의 저장
- 레이싱 게임에서의 고스트 데이터 공유
- 포토 모드로 촬영한 이미지의 클라우드 공유
- 게임 설정 등 변조되더라도 게임 밸런스에 영향을 주지 않는 사용자 데이터의 저장
- 스테이지의 커스텀 맵 데이터 공개·공유
접근 스코프
데이터 오브젝트에는 3가지 접근 스코프가 있으며, 용도에 따라 구분하여 사용합니다.
| 스코프 | 설명 | 주요 용도 |
|---|---|---|
public | 모든 플레이어가 다운로드 가능 | UGC 콘텐츠 공개, 고스트 공유 |
protected | allowUserIds에 지정한 사용자만 다운로드 가능(최대 100건) | 친구에게만 공유 |
private | 업로드한 사용자 본인만 다운로드 가능 | 개인 세이브 데이터, 설정값 |
스코프와 허용 사용자는 UpdateDataObject로 나중에 변경하는 것도 가능합니다.
아키텍처
GS2-Datastore는 바이너리 데이터의 저장 위치 등을 기록한 메타데이터를 관리하며, 실제 바이너리 데이터의 저장에는 외부 클라우드 스토리지를 이용하고 있습니다. 그렇기 때문에 업로드·다운로드 처리는 여러 단계를 필요로 합니다.
이 처리 흐름은 게임 엔진용 SDK에서는 래핑된 고수준 API가 제공되므로 신경 쓸 필요가 없지만, 각종 프로그래밍 언어용 SDK에는 고수준 API가 제공되지 않으므로 이용자가 직접 여러 단계를 처리해야 합니다.
업로드 프로세스
sequenceDiagram actor Player as 플레이어 participant Namespace as GS2-Datastore#Namespace participant Storage as Cloud Storage Player->>Namespace: PrepareUpload Namespace-->>Player: Cloud Storage URL Player->>Storage: Upload Payload Storage-->>Player: OK Player->>Namespace: DoneUpload Namespace->>Storage: Check exists Namespace-->>Player: OK
다운로드 프로세스
sequenceDiagram actor Player as 플레이어 participant Namespace as GS2-Datastore#Namespace participant Storage as Cloud Storage Player->>Namespace: PrepareDownload Namespace-->>Player: Cloud Storage URL Player->>Storage: Download Storage-->>Player: Payload
업로드 처리 중의 다운로드
GS2-Datastore는 이미 업로드한 데이터를 갱신할 수 있습니다. Prepare ReUpload를 호출한 후부터 Done Upload를 호출할 때까지는 갱신 전의 기존 파일을 다운로드할 수 있는 상태가 유지되며, 어중간한 데이터가 다운로드되는 일은 없습니다.
업로드 데이터의 과거 버전 취득
GS2-Datastore에서는 과거 30일분의 과거 버전에 접근할 수 있도록 되어 있습니다. 데이터 오브젝트의 갱신 이력(DataObjectHistory)을 취득함으로써 과거 각 세대의 ID를 취득할 수 있으며, 그 세대 ID를 지정하여 데이터를 다운로드하는 것이 가능합니다.
이는 삭제된 데이터에도 적용되며, 삭제 요청 후 30일 후에 실제로 삭제됩니다. 단, 법적 요건에 따라 데이터 삭제를 수행한 경우에는 이 조건에 해당하지 않을 수 있습니다.
데이터 크기와 상태
1개의 데이터 오브젝트의 최대 크기는 10MB입니다. 업로드 중에는 UPLOADING, 완료되면 ACTIVE, 삭제 요청 후에는 DELETED로 전이합니다. DELETED 상태의 데이터는 30일 이내라면 restoreDataObject로 복원할 수 있습니다.
stateDiagram-v2 [*] --> UPLOADING: PrepareUpload UPLOADING --> ACTIVE: DoneUpload ACTIVE --> UPLOADING: PrepareReUpload ACTIVE --> DELETED: DeleteDataObject DELETED --> ACTIVE: RestoreDataObject (30일 이내) DELETED --> [*]: 30일 경과
데이터 오브젝트의 주요 속성
| 속성 | 설명 |
|---|---|
dataObjectId | 데이터 오브젝트의 고유 ID(GRN) |
name | 데이터 오브젝트 이름. 사용자마다 고유 |
userId | 업로드한 사용자의 ID |
scope | 접근 스코프 (public / protected / private) |
allowUserIds | protected 스코프에서 참조를 허용하는 사용자ID 목록 |
platform | 업로드에 사용된 플랫폼 정보 |
status | 데이터의 상태 (UPLOADING / ACTIVE / DELETED) |
generation | 현재 세대의 식별자 |
previousGeneration | 1개 전 세대의 식별자 |
스크립트 트리거
데이터 오브젝트의 업로드 완료 보고 전후에 GS2-Script를 호출하는 이벤트 트리거를 설정할 수 있습니다. 동기 실행으로 완료 보고를 거부하거나, 비동기 실행으로 Amazon EventBridge를 이용한 외부 연동도 가능합니다.
설정할 수 있는 주요 이벤트 트리거와 스크립트 설정명은 다음과 같습니다.
doneUploadScript(완료 통지:doneUploadDone): 업로드 완료 보고 전후
동기 실행 스크립트로 업로드를 거부함으로써, 이미지의 내용을 별도 서비스에서 검사하여 부적절한 콘텐츠라면 등록을 거부하는 운영이 가능합니다.
구현 예제
데이터 업로드
데이터 업로드는 PrepareUpload → 클라우드 스토리지로의 PUT → DoneUpload의 3단계로 구성되지만, 게임 엔진용 SDK에서는 UploadAsync 호출 1회로 완결됩니다.
var result = await gs2.Datastore.Namespace(
namespaceName: "namespace-0001"
).Me(
gameSession: GameSession
).UploadAsync(
name: "dataObject-0001",
scope: "public",
data: data
);
var item = await result.ModelAsync();
var dataObjectId = item.DataObjectId; const auto Domain = Gs2->Datastore->Namespace(
"namespace-0001" // namespaceName
)->Me(
AccessToken
);
const auto Future = Domain->Upload(
"dataObject-0001", // name
data, // data
"public" // scope
);
Future->StartSynchronousTask();
if (Future->GetTask().IsError()) return false;var client = Gs2DatastoreClient.new(connection)
var async_result = await client.upload(
game_session, "namespace-0001", "public", null, data, "dataObject-0001"
)
if async_result.error != null:
push_error(str(async_result.error))
return
var item = async_result.result기존 데이터의 재업로드
같은 이름의 데이터 오브젝트에 대해 새로운 바이너리를 기록할 때는 재업로드를 사용합니다.
재업로드 중에도 DoneUpload가 호출될 때까지는 갱신 전의 기존 데이터를 가져올 수 있습니다.
var domain = await gs2.Datastore.Namespace(
namespaceName: "namespace-0001"
).Me(
gameSession: GameSession
).DataObject(
dataObjectName: "dataObject-0001"
).ReUploadAsync(
data: newData
); const auto Domain = Gs2->Datastore->Namespace(
"namespace-0001" // namespaceName
)->Me(
AccessToken
)->DataObject(
"dataObject-0001" // dataObjectName
);
const auto Future = Domain->ReUpload(
NewData
);
Future->StartSynchronousTask();
if (Future->GetTask().IsError()) return false;var client = Gs2DatastoreClient.new(connection)
var data_object = ez.datastore.namespace_(
"namespace-0001"
).me(game_session).data_object("dataObject-0001")
var async_result = await client.re_upload(
game_session, "namespace-0001", data_object._domain, new_data
)
if async_result.error != null:
push_error(str(async_result.error))
return데이터 다운로드(데이터 오브젝트 ID 지정)
var binary = await gs2.Datastore.Namespace(
namespaceName: "namespace-0001"
).Me(
gameSession: GameSession
).DownloadAsync(
dataObjectId: dataObjectId
); const auto Domain = Gs2->Datastore->Namespace(
"namespace-0001" // namespaceName
)->Me(
AccessToken
);
const auto Future = Domain->Download(
dataObjectId // dataObjectId
);
Future->StartSynchronousTask();
if (Future->GetTask().IsError()) return false;var client = Gs2DatastoreClient.new(connection)
var async_result = await client.download(
game_session, "namespace-0001", data_object_id
)
if async_result.error != null:
push_error(str(async_result.error))
return
var binary = async_result.result데이터 다운로드(사용자ID와 데이터 오브젝트 이름 지정)
var binary = await gs2.Datastore.Namespace(
namespaceName: "namespace-0001"
).User(
userId: "user-0001"
).DataObject(
dataObjectName: "dataObject-0001"
).DownloadByUserIdAndDataObjectNameAsync(
); const auto Domain = Gs2->Datastore->Namespace(
"namespace-0001" // namespaceName
)->User(
"user-0001" // userId
)->DataObject(
"dataObject-0001" // dataObjectName
);
const auto Future = Domain->DownloadByUserIdAndDataObjectName(
);
Future->StartSynchronousTask();
if (Future->GetTask().IsError()) return false;var domain = ez.datastore.namespace_(
"namespace-0001"
).user(
"user-0001"
).data_object(
"dataObject-0001"
)
var async_result = await domain.prepare_download_by_user_id_and_data_object_name(
)
if async_result.error != null:
push_error(str(async_result.error))
return
var result = async_result.result자신이 업로드한 데이터의 다운로드(데이터 오브젝트 이름 지정)
var binary = await gs2.Datastore.Namespace(
namespaceName: "namespace-0001"
).Me(
gameSession: GameSession
).DataObject(
dataObjectName: "dataObject-0001"
).DownloadOwnAsync(
); const auto Domain = Gs2->Datastore->Namespace(
"namespace-0001" // namespaceName
)->Me(
AccessToken
)->DataObject(
"dataObject-0001" // dataObjectName
);
const auto Future = Domain->DownloadOwn(
);
Future->StartSynchronousTask();
if (Future->GetTask().IsError()) return false;var client = Gs2DatastoreClient.new(connection)
var async_result = await client.download_own_data(
game_session, "namespace-0001", "dataObject-0001"
)
if async_result.error != null:
push_error(str(async_result.error))
return
var binary = async_result.result자신이 업로드한 데이터의 목록 취득
var items = await gs2.Datastore.Namespace(
namespaceName: "namespace-0001"
).Me(
gameSession: GameSession
).DataObjectsAsync(
).ToListAsync(); const auto It = Gs2->Datastore->Namespace(
"namespace-0001" // namespaceName
)->Me(
AccessToken
)->DataObjects();
TArray<Gs2::UE5::Datastore::Model::FEzDataObjectPtr> Result;
for (auto Item : *It)
{
if (Item.IsError())
{
return false;
}
Result.Add(Item.Current());
}var iterator = ez.datastore.namespace_(
"namespace-0001"
).me(
game_session
).data_objects(
)
var async_result = await iterator.load()
if async_result.error != null:
# 에러를 처리
push_error(str(async_result.error))
return
var items = async_result.result데이터 오브젝트의 접근 스코프 변경
UpdateDataObject를 이용하면 접근 스코프나 허용 사용자 목록을 나중에 변경할 수 있습니다.
예를 들어, 처음에는 private로 생성하고 공유 준비가 완료된 시점에 public으로 전환하는 운영이 가능합니다.
var domain = await gs2.Datastore.Namespace(
namespaceName: "namespace-0001"
).Me(
gameSession: GameSession
).DataObject(
dataObjectName: "dataObject-0001"
).UpdateDataObjectAsync(
scope: "protected",
allowUserIds: new [] { "user-0002", "user-0003" }
); const auto Domain = Gs2->Datastore->Namespace(
"namespace-0001" // namespaceName
)->Me(
AccessToken
)->DataObject(
"dataObject-0001" // dataObjectName
);
const auto Future = Domain->UpdateDataObject(
"protected", // scope
{ "user-0002", "user-0003" } // allowUserIds
);
Future->StartSynchronousTask();
if (Future->GetTask().IsError()) return false;var domain = ez.datastore.namespace_(
"namespace-0001"
).me(game_session).data_object(
"dataObject-0001"
)
var async_result = await domain.update_data_object(
"public", # scope
null # allow_user_ids
)
if async_result.error != null:
push_error(str(async_result.error))
return
var result = async_result.result데이터 오브젝트 삭제
삭제 요청 후 30일 이내라면 RestoreDataObject로 복원할 수 있습니다.
await gs2.Datastore.Namespace(
namespaceName: "namespace-0001"
).Me(
gameSession: GameSession
).DataObject(
dataObjectName: "dataObject-0001"
).DeleteDataObjectAsync(
); const auto Domain = Gs2->Datastore->Namespace(
"namespace-0001" // namespaceName
)->Me(
AccessToken
)->DataObject(
"dataObject-0001" // dataObjectName
);
const auto Future = Domain->DeleteDataObject(
);
Future->StartSynchronousTask();
if (Future->GetTask().IsError()) return false;var domain = ez.datastore.namespace_(
"namespace-0001"
).me(game_session).data_object(
"dataObject-0001"
)
var async_result = await domain.delete_data_object(
)
if async_result.error != null:
if async_result.error.type == "InvalidStatusException":
# DataObject가 조작 가능한 상태가 아닙니다
pass
push_error(str(async_result.error))
return
var result = async_result.result데이터 오브젝트의 갱신 이력 취득
과거 세대를 취득함으로써 롤백이나 과거 리플레이 열람 등을 구현할 수 있습니다.
var items = await gs2.Datastore.Namespace(
namespaceName: "namespace-0001"
).Me(
gameSession: GameSession
).DataObject(
dataObjectName: "dataObject-0001"
).DataObjectHistoriesAsync(
).ToListAsync(); const auto It = Gs2->Datastore->Namespace(
"namespace-0001" // namespaceName
)->Me(
AccessToken
)->DataObject(
"dataObject-0001" // dataObjectName
)->DataObjectHistories();
TArray<Gs2::UE5::Datastore::Model::FEzDataObjectHistoryPtr> Result;
for (auto Item : *It)
{
if (Item.IsError())
{
return false;
}
Result.Add(Item.Current());
}var iterator = ez.datastore.namespace_(
"namespace-0001"
).me(
game_session
).data_object(
"dataObject-0001"
).data_object_histories(
)
var async_result = await iterator.load()
if async_result.error != null:
# 에러를 처리
push_error(str(async_result.error))
return
var items = async_result.result오래된 세대의 데이터에 대한 접근 제한
데이터를 다운로드할 때는 세대ID를 지정하여 구체적인 파일을 특정합니다. 세대ID를 다운로드 요청에 추가함으로써 목록을 조회한 시점의 데이터를 확실히 다운로드하는 것을 보장할 수 있습니다.
단, 언제까지나 오래된 세대의 데이터에 접근할 수 있는 것이 바람직하지 않은 경우도 있습니다. 그렇기 때문에 데이터 소유자 이외에는 갱신 후 60분 이내이면서 1개 전 세대에 한해서만 오래된 세대의 데이터 다운로드를 허용하는 옵션이 존재합니다.