GS2-Inbox
시스템이나 운영진이 플레이어에게 메시지나 선물을 전달하는 구조를 구현합니다.
게임에서는 “보상 배포(사과)”, “로그인 보너스”, “과금 특전”, “이벤트 보상” 등 플레이어에게 비동기적으로 보상을 전달해야 하는 상황이 자주 발생합니다. GS2-Inbox는 메시지의 큐잉, 읽음/안읽음 관리, 보상 첨부, 유효기간에 따른 자동 삭제 기능을 제공하며, 게임의 라이프사이클 전반에 걸쳐 프레젠트 박스 기능을 담당합니다.
sequenceDiagram participant Ops as 운영 / 서버 participant Inbox as GS2-Inbox participant Player as 플레이어 Ops->>Inbox: 메시지 송신 (보상 첨부) Inbox-->>Player: receiveNotification 알림 Player->>Inbox: 메시지 목록 조회 Player->>Inbox: 메시지 개봉 (Read) Inbox->>Player: 첨부 보상 지급 (트랜잭션) Player->>Inbox: 읽은 메시지 삭제
메시지
읽음 관리
메시지는 읽음 상태를 가집니다. 읽음 상태의 메시지를 목록에 남길지, 목록에서 삭제할지 설정할 수 있습니다.
네임스페이스 설정의 isAutomaticDeletingEnabled를 활성화하면, 읽음 처리되는 시점에 메시지가 자동으로 삭제됩니다.
프레젠트 박스 목록에 아직 수령하지 않은 메시지만 남기는 사양을, 서버 측의 추가 구현 없이 구현할 수 있습니다.
보상 첨부
메시지에는 보상을 첨부할 수 있습니다. 읽음 플래그를 세우는 대신, 메시지에 첨부된 보상을 수령할 수 있습니다.
첨부 가능한 보상은 GS2의 트랜잭션 메커니즘을 통한 획득 액션(readAcquireActions)으로 표현되므로, GS2-Inventory, GS2-Money, GS2-Experience 등 임의의 마이크로서비스 리소스를 보상으로 배포할 수 있습니다.
유효기간
메시지에는 유효기간(expiresAt)을 설정할 수 있습니다.
유효기간이 지난 메시지는 읽지 않은 상태, 개봉 후 읽음 상태와 관계없이 자동으로 삭제됩니다.
첨부된 보상을 수령하지 않았더라도 삭제됩니다.
메시지의 라이프사이클
stateDiagram-v2 [*] --> Unread: 메시지 수신 Unread --> Read: 개봉 (Read) Unread --> Expired: 유효기간 만료 Read --> Deleted: 삭제 (Delete) Read --> AutoDeleted: isAutomaticDeletingEnabled Read --> Expired: 유효기간 만료 Deleted --> [*] AutoDeleted --> [*] Expired --> [*]
글로벌 메시지
특정 플레이어가 아니라 모든 플레이어에게 동일한 메시지를 배포하고 싶을 때는 “글로벌 메시지"를 사용합니다.
글로벌 메시지는 마스터 데이터로 정의하며, 각 플레이어가 ReceiveGlobalMessageAsync를 호출했을 때 아직 수신하지 않은 메시지를 해당 플레이어의 수신함에 복사합니다.
| 기능 | 설명 |
|---|---|
expiresAt | 절대 시각으로 메시지 유효기간을 지정 |
expiresTimeSpan | 수신 시점부터의 상대 기간으로 유효기간을 결정 (예: 수신 후 3일간) |
messageReceptionPeriodEventId | GS2-Schedule의 이벤트ID를 지정하여, 특정 기간 동안에만 글로벌 메시지를 수신할 수 있도록 함 |
{
"version": "2018-04-20",
"globalMessages": [
{
"name": "welcome",
"metadata": "신규 사용자용 선물",
"readAcquireActions": [
{
"action": "Gs2Money:DepositByUserId",
"request": "{\"namespaceName\":\"money-0001\",\"slot\":1,\"userId\":\"#{userId}\",\"price\":0,\"count\":100}"
}
],
"expiresTimeSpan": { "days": 7 }
}
]
}마스터 데이터의 종류에는 다음이 있습니다.
GlobalMessage: 모든 플레이어에게 배포하는 메시지 정의
마스터 데이터의 등록은 관리 콘솔에서 등록하는 것 외에, GitHub에서 데이터를 반영하거나, GS2-Deploy를 사용해 CI에서 등록하는 워크플로우를 구성할 수 있습니다.
스크립트 트리거
네임스페이스에 receiveMessageScript·readMessageScript·deleteMessageScript를 설정하면, 메시지 수신, 개봉, 삭제 시 전후로 커스텀 스크립트를 실행할 수 있습니다. 스크립트는 동기·비동기 실행 방식을 선택할 수 있으며, 비동기 방식에서는 GS2-Script나 Amazon EventBridge를 이용한 외부 연동도 가능합니다.
설정 가능한 주요 이벤트 트리거와 스크립트 설정명은 다음과 같습니다.
receiveMessageScript(완료 알림:receiveMessageDone): 메시지 수신 전후readMessageScript(완료 알림:readMessageDone): 메시지 개봉 전후deleteMessageScript(완료 알림:deleteMessageDone): 메시지 삭제 전후
푸시 알림
설정 가능한 주요 푸시 알림과 설정명은 다음과 같습니다.
receiveNotification: 메시지 수신 시 알림
알림 대상 단말이 오프라인인 경우 모바일 푸시 알림으로 전달하는 설정도 지정할 수 있어, 확실한 전송을 실현할 수 있습니다.
GS2-Gateway의 WebSocket 연결을 가진 플레이어에게는 즉시 알림을, 연결되어 있지 않은 플레이어에게는 iOS / Android의 모바일 푸시 알림을 자동으로 전송하는 배포 전략을, 추가 구현 없이 실현할 수 있습니다.
트랜잭션 액션
GS2-Inbox에서는 다음과 같은 트랜잭션 액션을 제공합니다.
- 소비 액션: 메시지 개봉, 메시지 삭제
- 획득 액션: 메시지 송신
“메시지 송신"을 획득 액션으로 사용함으로써, 상점에서 상품 구매 시나 퀘스트 클리어 시의 보상으로 플레이어의 프레젠트 박스에 직접 메시지(아이템 포함)를 전달하는 처리를 트랜잭션 내에서 완결시킬 수 있습니다. 이를 통해 보상 지급 타이밍을 유연하게 제어할 수 있으며, 플레이어의 인벤토리가 가득 찬 경우에도 일단 프레젠트 박스에 보상을 대피시키는 운영이 용이해집니다.
구현 예제
메시지 송신
메시지 송신은 게임 엔진용 SDK에서는 직접 호출할 수 없습니다. 일반적으로 서버 사이드 스크립트나, 위의 “트랜잭션 액션"을 통해 실행됩니다.
운영진의 일괄 배포인 경우 관리 콘솔이나 GS2-Deploy를 통해 송신할 수 있습니다.
게임 내에서 “메시지 송신"을 보상이나 연출의 트리거로 사용하고 싶은 경우, GS2-Exchange나 GS2-Quest의 acquireActions에 “Gs2Inbox:SendMessageByUserId“를 포함시킵니다.
수신한 메시지 목록 가져오기
var items = await gs2.Inbox.Namespace(
namespaceName: "namespace-0001"
).Me(
gameSession: GameSession
).MessagesAsync(
).ToListAsync(); const auto It = Gs2->Inbox->Namespace(
"namespace-0001" // namespaceName
)->Me(
AccessToken
)->Messages(
);
TArray<Gs2::UE5::Inbox::Model::FEzMessagePtr> Result;
for (auto Item : *It)
{
if (Item.IsError())
{
return false;
}
Result.Add(Item.Current());
}var iterator = ez.inbox.namespace_(
"namespace-0001"
).me(
game_session
).messages(
)
var async_result = await iterator.load()
if async_result.error != null:
# 오류를 처리
push_error(str(async_result.error))
return
var items = async_result.result읽지 않은 메시지만 목록 가져오기
var items = await gs2.Inbox.Namespace(
namespaceName: "namespace-0001"
).Me(
gameSession: GameSession
).MessagesAsync(
isRead: false
).ToListAsync(); const auto It = Gs2->Inbox->Namespace(
"namespace-0001" // namespaceName
)->Me(
AccessToken
)->Messages(
false // isRead
);
TArray<Gs2::UE5::Inbox::Model::FEzMessagePtr> Result;
for (auto Item : *It)
{
if (Item.IsError())
{
return false;
}
Result.Add(Item.Current());
}var iterator = ez.inbox.namespace_(
"namespace-0001"
).me(
game_session
).messages(
false
)
var async_result = await iterator.load()
if async_result.error != null:
# 오류를 처리
push_error(str(async_result.error))
return
var items = async_result.result읽은 메시지만 목록 가져오기
var items = await gs2.Inbox.Namespace(
namespaceName: "namespace-0001"
).Me(
gameSession: GameSession
).MessagesAsync(
isRead: true
).ToListAsync(); const auto It = Gs2->Inbox->Namespace(
"namespace-0001" // namespaceName
)->Me(
AccessToken
)->Messages(
true // isRead
);
TArray<Gs2::UE5::Inbox::Model::FEzMessagePtr> Result;
for (auto Item : *It)
{
if (Item.IsError())
{
return false;
}
Result.Add(Item.Current());
}var iterator = ez.inbox.namespace_(
"namespace-0001"
).me(
game_session
).messages(
true
)
var async_result = await iterator.load()
if async_result.error != null:
# 오류를 처리
push_error(str(async_result.error))
return
var items = async_result.result메시지 개봉
개봉하면 메시지가 Read 상태로 전환됨과 동시에 readAcquireActions에 정의된 보상 지급 처리가 트랜잭션으로 실행됩니다.
네임스페이스 설정에서 isAutomaticDeletingEnabled를 활성화한 경우, 개봉과 동시에 메시지가 삭제됩니다.
var result = await gs2.Inbox.Namespace(
namespaceName: "namespace-0001"
).Me(
gameSession: GameSession
).Message(
messageName: "message-0001"
).ReadAsync(
); const auto Future = Gs2->Inbox->Namespace(
"namespace-0001" // namespaceName
)->Me(
AccessToken
)->Message(
"message-0001" // messageName
)->Read(
);
Future->StartSynchronousTask();
if (Future->GetTask().IsError()) return false;var domain = ez.inbox.namespace_(
"namespace-0001"
).me(game_session).message(
"message-0001"
)
var async_result = await domain.read(
)
if async_result.error != null:
if async_result.error.type == "MessageExpiredException":
# 메시지의 유효기간이 만료되었습니다
pass
push_error(str(async_result.error))
return
var result = async_result.result메시지 일괄 개봉
여러 메시지를 한꺼번에 개봉하여, 각각의 첨부 보상을 하나의 트랜잭션으로 수령합니다. 보상 지급 처리는 내부적으로 순차 실행되므로, 동일한 아이템이 대량으로 첨부된 메시지를 병렬로 개봉했을 때 발생할 수 있는 갱신 레이트 제한 문제를 회피할 수 있습니다.
var result = await gs2.Inbox.Namespace(
namespaceName: "namespace-0001"
).Me(
gameSession: GameSession
).BatchReadAsync(
messageNames: new [] {
"message-0001",
"message-0002",
"message-0003",
}
); const auto Future = Gs2->Inbox->Namespace(
"namespace-0001" // namespaceName
)->Me(
AccessToken
)->BatchRead(
[]
{
const auto v = MakeShared<TArray<FString>>();
v->Add("message-0001");
v->Add("message-0002");
v->Add("message-0003");
return v;
}()
);
Future->StartSynchronousTask();
if (Future->GetTask().IsError()) return false;var domain = ez.inbox.namespace_(
"namespace-0001"
).me(game_session)
var async_result = await domain.batch_read(
[
"message-0001",
"message-0002",
] # message_names
)
if async_result.error != null:
if async_result.error.type == "MessageExpiredException":
# 메시지의 유효기간이 만료되었습니다
pass
push_error(str(async_result.error))
return
var result = async_result.result메시지 삭제
var result = await gs2.Inbox.Namespace(
namespaceName: "namespace-0001"
).Me(
gameSession: GameSession
).Message(
messageName: "message-0001"
).DeleteAsync(
); const auto Future = Gs2->Inbox->Namespace(
"namespace-0001" // namespaceName
)->Me(
AccessToken
)->Message(
"message-0001" // messageName
)->Delete(
);
Future->StartSynchronousTask();
if (Future->GetTask().IsError()) return false;var domain = ez.inbox.namespace_(
"namespace-0001"
).me(game_session).message(
"message-0001"
)
var async_result = await domain.delete(
)
if async_result.error != null:
push_error(str(async_result.error))
return
var result = async_result.result글로벌 메시지 수신
글로벌 메시지 중 아직 수신하지 않은 메시지가 있는 경우, 자신의 수신함에 메시지를 복사합니다. 로그인 시점이나 타이틀 화면 로드 완료 시점 등, 타이밍을 게임 측에서 제어할 수 있습니다.
var result = await gs2.Inbox.Namespace(
namespaceName: "namespace-0001"
).Me(
gameSession: GameSession
).ReceiveGlobalMessageAsync(
); const auto Future = Gs2->Inbox->Namespace(
"namespace-0001" // namespaceName
)->Me(
AccessToken
)->ReceiveGlobalMessage(
);
Future->StartSynchronousTask();
if (Future->GetTask().IsError()) return false;var domain = ez.inbox.namespace_(
"namespace-0001"
).me(game_session)
var async_result = await domain.receive_global_message(
)
if async_result.error != null:
push_error(str(async_result.error))
return
var result = async_result.result자주 묻는 질문
메시지 일괄 개봉이 가능한가요?
네, BatchReadAsync를 사용하면 여러 메시지를 하나의 트랜잭션으로 한꺼번에 개봉할 수 있습니다. 자세한 내용은 메시지 일괄 개봉을 참조하십시오.
또한 개별 ReadAsync를 병렬로 호출하는 것도 가능하지만, 보상 지급 처리가 각 마이크로서비스에서 정의한 제한에 도달하지 않는지 충분히 고려해야 합니다.
예를 들어, GS2-Inventory에서는 동일한 아이템의 소지 수량 갱신이 1초에 3회까지로 정의되어 있습니다. 동일한 아이템이 첨부된 10개의 메시지를 병렬로 개봉하면 이 제한을 초과할 수 있습니다.
이 제한을 초과하더라도, 원칙적으로 결과가 유실되는 일은 없습니다.
다만, 개봉 API를 호출한 후 비동기로 동작하는 보상 지급 처리가 완료되기까지 시간이 걸릴 수 있습니다.
이런 경우에도 BatchReadAsync라면 내부에서 순차적으로 실행되므로, 레이트 제한에 걸리지 않고 확실하게 보상을 받을 수 있습니다.
메시지의 읽지 않은 개수 배지를 표시하고 싶습니다
MessagesAsync(isRead: false)로 가져온 목록의 건수를 읽지 않은 개수로 표시할 수 있습니다. 수신 알림(receiveNotification)을 구독해 두면, 새 메시지가 도착한 시점에 클라이언트 측에서 다시 가져올 수 있습니다.