GS2-Money2
게임 내 리소스 중 현금에 상당하는 가치를 가진 리소스를 다루는 기능입니다. 일본의 자금결제법상 선불식 지급수단(자가형)에 해당하는 자산을 취급하는 경우에는 반드시 이 기능을 이용해야 합니다.
GS2-Money2 는 단순히 “과금 통화의 잔고"를 다루는 서비스가 아니라, 다음과 같은 회계·법령 대응상의 요건을 함께 처리하는 마이크로서비스입니다.
- 입금 일시·단가별 잔고 관리
- 영수증 검증을 통한 부정 입금 방지
- 지갑의 가감산 이력 보관
- 플랫폼 가이드라인에 따른 통화 격리
- 구독(기간 과금) 계약 상태 관리
- 미사용 잔고 집계(선불식 지급수단 대응)
잔고
GS2-Money2 는 플레이어가 보유한 과금 통화의 잔고를 단순한 수량으로 관리하지 않고, 구매 시의 가치별로 수량을 관리합니다.
예를 들어 100엔으로 과금 통화 100개를 구매한 경우 과금 통화 1개의 가치는 1엔 상당이 됩니다. 동시에 1000엔으로 1200개의 과금 통화를 구매할 수 있다고 합시다. 어디까지나 과금 통화의 단가를 1엔으로 하고 200개는 덤으로 무료 처리하는 것도 하나의 방법이고, 1000엔으로 구매한 경우 단가를 0.8334엔으로 하여 다른 단가로 취급하는 것도 하나의 방법입니다.
후자와 같은 방식을 채택한 경우, GS2-Money2 는 “단가 1엔인 과금 통화의 잔고"와 “단가 0.8334엔인 과금 통화의 잔고"를 각각 나누어 관리하는 기능을 가지고 있습니다.
후자를 선택하는 장점은?
분명히 후자는 회계 처리가 복잡해져 장점이 없는 것처럼 느껴질 수 있습니다. 서비스 제공자 입장에서는 전적으로 그렇습니다. 하지만 입장을 바꿔 게임 플레이어의 입장에서 생각해 봅시다.
게임 안에는 운영진이 나눠준 현금 상당 가치가 0엔인 과금 통화(통칭 무상 통화)가 있습니다. 플레이어가 과금 통화를 구매하도록 유도하기 위해, 유상으로 구매한 과금 통화(통칭 유상 통화)로만 구매할 수 있는 매력적인 상품을 과금 통화 300개에 판매한다고 합시다.
1000엔으로 1200개의 과금 통화를 구매했을 때, 1000개의 유상 통화와 200개의 무상 통화를 부여하도록 처리한 경우 플레이어는 유상 통화 300개로 구매할 수 있는 상품을 3번밖에 구매할 수 없습니다. 반면, 단가를 0.8334엔으로 하여 1200개 전부를 유상 통화로 취급하는 방법이라면 플레이어는 4번 구매할 수 있습니다. 이 차이는 플레이어 심리에 다소나마 영향을 미칩니다.
회계상의 편의를 우선할 것인가, 플레이어의 이익을 우선할 것인가 신중히 검토해야 할 사양입니다.
슬롯
GS2-Money 에서는 지갑을 여러 개 가질 수 있습니다. 이 복수의 지갑을 구분하기 위한 키가 슬롯입니다.
이 기능은 다른 플랫폼에서 구매한 과금 통화를 반입하지 못하도록 하는 플랫포머가 존재하기 때문에, 그 가이드라인을 준수하기 위해 존재하는 기능입니다.
하지만 이러한 가이드라인은 유상 통화에만 적용되므로, 무상 통화에 대해서는 모든 슬롯에서 공유할 수 있는 기능이 있습니다.
네임스페이스의 sharedFreeCurrency 를 활성화하면 무상 통화는 전체 슬롯 공통으로 취급되고, 유상 통화만 슬롯별로 격리됩니다.
graph LR
subgraph Wallets[Wallets]
direction TB
S0["Slot 0 (iOS)<br/>유상 잔고: 1,000"]
S1["Slot 1 (Android)<br/>유상 잔고: 500"]
S2["Slot 2 (Steam)<br/>유상 잔고: 800"]
end
Shared["무상 잔고: 200<br/>(sharedFreeCurrency=true)"] -.공유.-> S0
Shared -.공유.-> S1
Shared -.공유.-> S2구매 통화
GS2-Money2 에서는 GS2-Money 에서 변경된 점으로, 하나의 지갑에 여러 통화로 구매한 과금 통화를 보유할 수 있게 되었습니다.
예를 들어, JPY 로 구매한 유상 통화와 USD 로 구매한 유상 통화를 하나의 지갑 안에서 별도의 잔고로 보유할 수 있습니다.
하루 몇 차례의 집계 처리에서는 통화 단위로 DailyTransactionHistory 가 기록되어, 각 통화별 매출·소비를 개별적으로 파악할 수 있습니다.
소비 우선순위
플레이어가 과금 통화를 소비할 때, 무상 통화를 우선적으로 소비할지, 유상 통화를 우선적으로 소비할지 선택할 수 있습니다. 일반적으로 무상 통화를 우선 소비하는 사양이 채택되지만, 회계상의 사정이 있는 경우 유상 통화를 우선할 수 있습니다.
유상 통화를 소비할 때는 입금한 시기가 오래된 것부터 순서대로 소비됩니다.
네임스페이스의 currencyUsagePriority 에서 다음 중 하나를 지정합니다.
| 설정값 | 설명 |
|---|---|
PrioritizeFree | 무상 통화를 우선적으로 소비(일반적) |
PrioritizePaid | 유상 통화를 우선적으로 소비 |
영수증 검증
게임 배포 플랫폼에서 추가 콘텐츠 구매 시 얻어지는 영수증의 검증 기능을 가지고 있습니다. 영수증을 검증하여 플랫포머가 올바르게 발행한 내용인지 확인함과 동시에, 과거에 게임 내에서 사용한 적이 없는지도 확인합니다.
영수증 검증을 위해서는 네임스페이스의 platformSetting 에 Apple App Store 나 Google Play 의 인증 정보를 등록해 두어야 합니다.
이 기능을 이용하면 부정한 영수증을 이용해 과금 통화를 입수하려는 공격을 회피할 수 있습니다.
지원하는 플랫폼
| 플랫폼 | 설정 키 | 설명 |
|---|---|---|
| Apple App Store | appleAppStore | iOS / iPadOS / macOS 용 과금 |
| Google Play | googlePlay | Android 용 과금 |
| Fake | fake | 개발·QA 용 더미 영수증 발행 |
개발 시에는 fake 를 이용해 영수증을 의사적으로 발행함으로써, 결제를 실행하지 않고 테스트 흐름을 진행할 수 있습니다.
트랜잭션 로그
영수증 검증 이력은 물론이고, 과금 통화의 가산·감산 이력도 모두 기록됩니다. 그리고 매일 몇 차례씩 현재 게임 내에 미사용 과금 통화가 현금 상당액으로 얼마나 풀(pool)되어 있는지를 집계합니다.
상황에 따라 미사용 잔고의 일부를 제3자 기관에 공탁해야 하는 대응이 필요할 수 있는데, 그때 이 계산 결과를 이용할 수 있습니다.
주요 로그·이력 데이터
| 데이터 | 설명 |
|---|---|
DepositEvent | 입금 처리 이력 |
WithdrawEvent | 소비 처리 이력 |
VerifyReceiptEvent | 영수증 검증 이력 |
RefundEvent | 플랫폼으로부터의 환불 통지 이력 |
RefundHistory | 환불 취소 이력 |
DailyTransactionHistory | 1일 단위 통화별 입출금 집계 |
UnusedBalance | 통화별 미사용 잔고의 현재값 |
법적 절차
GS2-Money2 는 각종 법적 절차를 밟는 데 필요한 데이터를 수집하여 API를 통해 접근 가능한 상태로 보관하지만, 법적 절차 자체는 GS2 이용자인 귀하/귀하가 소속된 조직이 실행해야 합니다.
어떤 절차가 필요한지에 대해서는 GS2가 책임질 수 없으므로 조언도 드릴 수 없습니다. 고문 변호사와 상담하시기 바랍니다.
트랜잭션 액션
GS2-Money2 에서는 다음과 같은 트랜잭션 액션을 제공합니다.
- 소비 액션: 잔고 소비, 영수증 검증
- 입수 액션: 잔고 가산
“잔고 가산"을 입수 액션으로 이용함으로써, 특정 이벤트 클리어 시나 가챠의 “덤"으로 직접 과금 통화(유상·무상)를 부여하는 처리를, 트랜잭션 내에서 안전하게 실행할 수 있습니다. 이를 통해 과금과 게임 내 보상을 조합한 유연한 시책이 가능해집니다.
“영수증 검증"을 소비 액션으로 이용함으로써, 영수증을 사용한 구매 처리부터 과금 통화를 가산하기까지를 하나의 트랜잭션으로서 안전하게 처리할 수 있습니다.
기간 과금(구독)
GS2-Money2 에서는 그때그때 과금하는 과금 통화뿐만 아니라 기간 과금(구독)도 다룰 수 있습니다. 미리 StoreSubscriptionContentModel 의 마스터 데이터를 등록하고, scheduleNamespaceId 와 triggerName 을 사용해 GS2-Schedule 과 연동시킴으로써, 계약 기간의 개시·갱신·종료를 자동으로 반영할 수 있습니다.
graph LR Player["플레이어"] -->|구매| Store["App Store / Google Play"] Store -->|영수증| Player Player -->|AllocateSubscriptionStatus| Money2[GS2-Money2] Money2 -->|Trigger| Schedule[GS2-Schedule] Schedule -.기한 관리.-> Money2 Money2 -->|Notification| Player
계약 정보 관리
각 구매는 SubscribeTransaction 에 기록되어, 트랜잭션별 계약 상세나 스토어 정보를 추적할 수 있습니다. 플레이어별 계약 상태는 SubscriptionStatus 에 저장되어, 현재 상태(active / inactive)나 expiresAt 확인이 가능합니다.
AllocateSubscriptionStatus 로 영수증을 사용해 계약 상태를 플레이어에게 연결하고, TakeOverSubscriptionStatus 를 사용해 다른 플레이어에게 인계할 수도 있습니다. 이는 가족끼리 계약을 공유하는 경우나, 자녀 계정에서 성인 계정으로 계약을 인계하는 운용에 이용할 수 있습니다.
라이프사이클에 따른 스크립트 트리거
계약 조작 전후에 스크립트를 실행할 수 있습니다. 동기·비동기 중 하나를 선택할 수 있으며, 외부 서비스 연계에도 대응합니다.
| 트리거 이름 | 주요 용도 |
|---|---|
| subscribeScript | 신규 계약 시(사용자 연결 변경 시는 제외) |
| renewScript | 계약 갱신 시 |
| unsubscribeScript | 해지 시(사용자 연결 변경 시는 제외) |
| takeOverScript | 계약의 사용자 할당을 변경할 때 |
계약 상황 알림
구독 상태가 변화한 경우, changeSubscriptionStatusNotification 에 설정한 푸시 알림을 전송할 수 있습니다.
플레이 중 만료나 해지 같은 변화를 처리하여 UI에 반영하는 용도로 활용하십시오.
스크립트 트리거
네임스페이스에 depositBalanceScript, withdrawBalanceScript, verifyReceiptScript 를 설정하면, 입출금 처리 전후로 커스텀 스크립트를 실행할 수 있습니다. 트리거는 동기·비동기 실행 방식을 선택할 수 있으며, Amazon EventBridge 를 이용한 외부 연계에도 대응합니다.
설정 가능한 주요 이벤트 트리거와 스크립트 설정 이름은 다음과 같습니다.
depositBalanceScript(완료 알림:depositBalanceDone): 입금 처리 전후withdrawBalanceScript(완료 알림:withdrawBalanceDone): 출금 처리 전후verifyReceiptScript(완료 알림:verifyReceiptDone): 영수증 검증 전후
동기 스크립트를 사용해 특정 조건에 해당하는 경우 입출금 처리를 거부하거나, 비동기 스크립트를 이용해 BI 도구에 실시간으로 매출을 전송하는 등의 운용이 가능합니다.
푸시 알림
설정 가능한 주요 푸시 알림과 설정 이름은 다음과 같습니다.
changeSubscriptionStatusNotification: 기간 과금 계약 상황이 변화했을 때 알림
마스터 데이터 운용
마스터 데이터를 등록함으로써 마이크로서비스에서 이용 가능한 데이터나 동작을 설정할 수 있습니다.
마스터 데이터의 종류에는 다음이 있습니다.
StoreContentModel: 판매 콘텐츠 정의StoreSubscriptionContentModel: 정기 구독 콘텐츠 정의
마스터 데이터 등록은 관리 콘솔에서 등록하는 것 외에, GitHub에서 데이터를 반영하거나, GS2-Deploy를 사용해 CI에서 등록하는 워크플로우를 구성하는 것도 가능합니다.
StoreContentModel 예시
판매 콘텐츠에 대한 App Store / Google Play 상품ID의 대응 관계를 정의합니다.
{
"version": "2022-07-13",
"storeContentModels": [
{
"name": "stone_300",
"metadata": "돌 300개 팩",
"appleAppStore": {
"productId": "io.gs2.sample.stone_300"
},
"googlePlay": {
"productId": "io.gs2.sample.stone_300"
}
}
]
}버프에 의한 보정
GS2-Buff 와 연동하면 DepositByUserId 의 count 나 Withdraw·WithdrawByUserId 의 withdrawCount 에 버프를 적용하여 입금량이나 소비량을 일시적으로 증감시킬 수 있습니다. 이벤트나 캠페인에 따라 유연하게 조정할 수 있습니다.
예를 들어, 과금 통화 보너스 캠페인으로 “기간 중 구매한 과금 통화를 10% 증량한다"와 같은 시책을, DepositByUserId 의 count 에 대한 Rate Add 0.1 버프로 구현할 수 있습니다.
구현 예제
잔고를 취득
var item = await gs2.Money2.Namespace(
namespaceName: "namespace-0001"
).Me(
gameSession: GameSession
).Wallet(
slot: 0
).ModelAsync(); const auto Domain = Gs2->Money2->Namespace(
"namespace-0001" // namespaceName
)->Me(
AccessToken
)->Wallet(
0 // slot
);
const auto Future = Domain->Model();
Future->StartSynchronousTask();
if (Future->GetTask().IsError()) return false;
const auto Item = Future->GetTask().Result();var domain = ez.money2.namespace_(
"namespace-0001"
).me(game_session).wallet(
0
)
var async_result = await domain.model()
if async_result.error != null:
push_error(str(async_result.error))
return
var result = async_result.result지갑 목록을 취득
슬롯별 지갑을 일괄로 취득할 수 있습니다. 여러 플랫폼에서 과금 통화를 분리하고 있는 경우의 잔고 표시에 활용할 수 있습니다.
var items = await gs2.Money2.Namespace(
namespaceName: "namespace-0001"
).Me(
gameSession: GameSession
).WalletsAsync(
).ToListAsync(); const auto It = Gs2->Money2->Namespace(
"namespace-0001" // namespaceName
)->Me(
AccessToken
)->Wallets();
TArray<Gs2::UE5::Money2::Model::FEzWalletPtr> Result;
for (auto Item : *It)
{
if (Item.IsError())
{
return false;
}
Result.Add(Item.Current());
}var iterator = ez.money2.namespace_(
"namespace-0001"
).me(
game_session
).wallets(
)
var async_result = await iterator.load()
if async_result.error != null:
# 오류를 처리
push_error(str(async_result.error))
return
var items = async_result.result잔고를 가산
잔고를 가산하는 처리는 게임 엔진용 SDK로는 처리할 수 없습니다.
GS2-Showcase 구매 시의 보상으로 잔고를 가산하는 방법 등으로 구현하십시오.
잔고를 소비
이 API로 잔고 소비 처리를 하는 것은 권장하지 않습니다. GS2-Showcase 와 같은 서비스를 통해 과금 통화를 소비하는 대신 어떠한 처리를 실행할 것을 권장합니다.
var result = await gs2.Money2.Namespace(
namespaceName: "namespace-0001"
).Me(
gameSession: GameSession
).Wallet(
slot: 0
).WithdrawAsync(
withdrawCount: 50,
paidOnly: false
);
var item = await result.ModelAsync(); const auto Future = Gs2->Money2->Namespace(
"namespace-0001" // namespaceName
)->Me(
AccessToken
)->Wallet(
0 // slot
)->Withdraw(
50,
nullptr // paidOnly
);
Future->StartSynchronousTask();
if (Future->GetTask().IsError()) return false;var domain = ez.money2.namespace_(
"namespace-0001"
).me(game_session).wallet(
0
)
var async_result = await domain.withdraw(
50, # withdraw_count
null # paid_only
)
if async_result.error != null:
if async_result.error.type == "ConflictException":
# 지갑 조작 처리가 충돌했습니다. 재시도가 필요합니다
pass
if async_result.error.type == "InsufficientException":
# 지갑 잔액이 부족합니다
pass
push_error(str(async_result.error))
return
var result = async_result.result구독 계약 상태 취득
var item = await gs2.Money2.Namespace(
namespaceName: "namespace-0001"
).Me(
gameSession: GameSession
).SubscriptionStatus(
contentName: "premium-0001"
).ModelAsync(); const auto Domain = Gs2->Money2->Namespace(
"namespace-0001" // namespaceName
)->Me(
AccessToken
)->SubscriptionStatus(
"premium-0001" // contentName
);
const auto Future = Domain->Model();
Future->StartSynchronousTask();
if (Future->GetTask().IsError()) return false;
const auto Item = Future->GetTask().Result();var domain = ez.money2.namespace_(
"namespace-0001"
).me(game_session).subscription_status(
"content-0001"
)
var async_result = await domain.model()
if async_result.error != null:
push_error(str(async_result.error))
return
var result = async_result.result구독 계약 할당
App Store / Google Play 에서 취득한 영수증을 사용해, 플레이어에게 구독 계약을 할당합니다. 영수증 검증도 함께 수행되며, 부정한 영수증을 사용한 계약은 거부됩니다.
var domain = await gs2.Money2.Namespace(
namespaceName: "namespace-0001"
).Me(
gameSession: GameSession
).AllocateSubscriptionStatusAsync(
receipt: receipt
); const auto Future = Gs2->Money2->Namespace(
"namespace-0001" // namespaceName
)->Me(
AccessToken
)->AllocateSubscriptionStatus(
Receipt
);
Future->StartSynchronousTask();
if (Future->GetTask().IsError()) return false;var domain = ez.money2.namespace_(
"namespace-0001"
).me(game_session)
var async_result = await domain.allocate_subscription_status(
"{\"Store\": \"AppleAppStore\", \"TransactionID\": \"transaction-0001\", \"Payload\": \"payload\"}" # receipt
)
if async_result.error != null:
if async_result.error.type == "AlreadyUsedException":
# 이미 해당 기간 과금 계약은 다른 사용자가 사용하고 있습니다
pass
push_error(str(async_result.error))
return
var result = async_result.result구독 계약 인계
다른 플레이어에서 현재 플레이어로 구독 계약의 연결 대상을 변경합니다. 플랫폼 측의 계약 자체는 변경되지 않으며, 게임 내에서의 적용 대상만 변경됩니다.
var domain = await gs2.Money2.Namespace(
namespaceName: "namespace-0001"
).Me(
gameSession: GameSession
).TakeOverSubscriptionStatusAsync(
receipt: receipt
); const auto Future = Gs2->Money2->Namespace(
"namespace-0001" // namespaceName
)->Me(
AccessToken
)->TakeOverSubscriptionStatus(
Receipt
);
Future->StartSynchronousTask();
if (Future->GetTask().IsError()) return false;var domain = ez.money2.namespace_(
"namespace-0001"
).me(game_session)
var async_result = await domain.take_over_subscription_status(
"{\"Store\": \"AppleAppStore\", \"TransactionID\": \"transaction-0001\", \"Payload\": \"payload\"}" # receipt
)
if async_result.error != null:
if async_result.error.type == "LockPeriodNotElapsedException":
# 지난번 사용자 인계 이후 잠금 기간이 경과하지 않았습니다
pass
push_error(str(async_result.error))
return
var result = async_result.result