Documentation index for AI agents

GS2-Datastore

바이너리 데이터 스토리지 기능

GS2-Datastore를 이용하면 임의의 바이너리 데이터를 서버에 저장할 수 있습니다.

업로드한 데이터에는 접근 권한을 설정할 수 있으며, public(전체 공개), protected(지정한 사용자ID에게만 공개: 최대 100건), private(본인에게만 공개) 중에서 선택합니다.

GS2-Datastore는 UGC나 레이싱 게임의 고스트 데이터와 같은 데이터를 업로드하는 것을 주된 목적으로 하며, 플레이어의 사용자 데이터를 저장하는 데에는 반드시 적합하다고 할 수 없습니다.

왜냐하면 소지품의 소지 수량을 바이너리 데이터로 저장할 경우, 세이브 데이터나 앱 본체의 변조로 아이템을 복제하거나 입수량을 부정하게 늘린 개조 앱으로 플레이하는 것을 허용하게 되기 때문입니다. 소지품의 소지 수량이라면 GS2-Inventory와 같은 전용 마이크로서비스를 이용함으로써 이러한 부정 행위를 막을 수 있습니다.

사용자 데이터 중에서도 설정값 등 변조되더라도 게임 밸런스에 영향을 주지 않는 데이터를 저장하는 것을 부정하는 것은 아닙니다.

유스케이스

GS2-Datastore가 상정하는 대표적인 유스케이스는 다음과 같습니다.

  • 사용자가 게시한 스크린샷이나 리플레이 등 UGC 콘텐츠의 저장
  • 레이싱 게임에서의 고스트 데이터 공유
  • 포토 모드로 촬영한 이미지의 클라우드 공유
  • 게임 설정 등 변조되더라도 게임 밸런스에 영향을 주지 않는 사용자 데이터의 저장
  • 스테이지의 커스텀 맵 데이터 공개·공유

접근 스코프

데이터 오브젝트에는 3가지 접근 스코프가 있으며, 용도에 따라 구분하여 사용합니다.

스코프설명주요 용도
public모든 플레이어가 다운로드 가능UGC 콘텐츠 공개, 고스트 공유
protectedallowUserIds에 지정한 사용자만 다운로드 가능(최대 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)
allowUserIdsprotected 스코프에서 참조를 허용하는 사용자ID 목록
platform업로드에 사용된 플랫폼 정보
status데이터의 상태 (UPLOADING / ACTIVE / DELETED)
generation현재 세대의 식별자
previousGeneration1개 전 세대의 식별자

스크립트 트리거

데이터 오브젝트의 업로드 완료 보고 전후에 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개 전 세대에 한해서만 오래된 세대의 데이터 다운로드를 허용하는 옵션이 존재합니다.

상세 레퍼런스