월드 상품 판매

개요

크리에이터는 Marketplace 기능을 활용하여 골드, 크리스탈과 같은 게임 내 재화나 총, 탄약, 회복 물약 등의 아이템을 월드 상품으로 구성하여 사용자에게 판매할 수 있습니다.

판매자의 의무

크리에이터는 판매한 상품이 구매자에게 정상적으로 지급되도록 처리할 의무가 있습니다. 지급 누락이나 설명과 다른 상품 구성, 구매한 상품의 가치 훼손 등 문제가 발생한 경우, 반드시 이에 대한 적절한 해결 조치를 성실히 이행해야 합니다. 지급 의무를 불성실하게 이행하거나 관련 신고가 반복적으로 접수될 경우, 정산 보류, 월드 제재, 나아가 계정 비활성화 등의 불이익이 발생할 수 있습니다.

자세히 알아보기

월드 상품 가이드라인

문의 창구 설정

상품 판매가 포함된 월드는 반드시 소셜 링크를 설정해야 하며, 이는 크리에이터가 상품 관련 문의에 대해 플레이어와 직접 소통할 수 있도록 하기 위함입니다.

소셜 링크가 설정되지 않은 월드는 상품 등록을 할 수 없습니다.

등록한 소셜 링크는 OVERDARE App에서 월드를 클릭하면 노출되는 팝업에서 Detail 버튼을 누르면 노출됩니다.

상품 종류

상품 종류
용도
예시

World Product

1회성으로 지급되는 재화, 아이템 등의 상품

재화 지급(골드, 크리스탈 등)

아이템 지급(검, 도끼, 회복 포션 등)

소모성 아이템(부활, 건물 즉시 설치권 등)

상품 관리

상품은 월드별로 구성되며, 각각 개별적으로 설정할 수 있습니다.

상품을 등록하고 관리하려면 월드를 먼저 Publish해야 합니다. 월드를 Publish 할 때 Owner group을 그룹으로 설정하면 그룹원과 함께 상품을 관리하고 수익을 분배할 수 있습니다.

권한별 기능

권한
상품 조회/등록
상품 수정
수익 분배

월드제작자(개인 월드)

O

O

O

Group Owner(그룹 월드)

O

O

O

Group Member(그룹 월드)

O

X

X

상품 목록 확인

Dashboard의 World 탭에서 월드를 클릭하여 월드 페이지에 진입한 다음, World Product 탭을 클릭하면 등록한 상품을 확인할 수 있습니다.

상품 등록

+ Create World Product 버튼을 눌러 새로운 상품을 등록할 수 있습니다.

상품 ID 복사하기

각 상품 이름 옆에 표시되는 ... 버튼을 누른 다음, Copy Product Id를 클릭해서 상품 ID를 복사할 수 있습니다.

상품 수정

상품 이미지나 이름 영역을 클릭하여 상품의 이미지, 이름, 가격 등을 수정할 수 있습니다.

상품 판매를 위한 스크립트 처리

Marketplace를 통한 상품 구매 기능은 유저의 재화를 소모하는 중요 기능이므로, pcall 등을 활용한 엄격한 예외 처리와 디버깅이 필수적입니다. 이를 통해 런타임 오류나 네트워크 지연 등의 상황에서도 시스템의 안정성을 유지할 수 있으며, 아이템 미지급과 같은 치명적인 문제를 사전에 방지할 수 있습니다.

기능 목록

기능
설명

GetProductInfo(productId, productType)

특정 상품 정보 요청

GetWorldProductsAsync()

모든 월드 상품 정보 요청

PromptProductPurchase(player, productId)

구매 요청

PromptProductPurchaseFinished

구매창이 꺼질 때 필요한 이벤트 처리

ProcessReceipt

구매 성공시 상품 지급 처리 후, 정상 지급건의 영수증 상태를 완료로 변경하기 위한 이벤트

특정 상품 정보 요청(GetProductInfo)

상품 ID(productId)와 상품 타입(Enum.InfoType)에 해당하는 상품 정보를 반환합니다.

local MarketplaceService = game:GetService("MarketplaceService")

local function Request_GetProductInfo(productId)
    local success, errorOrProductInfo = pcall(function()
        return MarketplaceService:GetProductInfo(productId, Enum.InfoType.Product)
    end)
    
    if not success then
        print("Error: " .. errorOrProductInfo .. " / ProductId : " .. productId)
        
    else
        local productInfo = errorOrProductInfo 
        print("World Product Name: " .. tostring(productInfo.Name))
        print("ProductId: " .. tostring(productInfo.ProductId))
        print("ProductType: " .. tostring(productInfo.ProductType))
        print("PriceInBLUC: " .. tostring(productInfo.PriceInBLUC))
        print("Description: " .. tostring(productInfo.Description))
        print("Created: " .. productInfo.Created)
        print("Updated: " .. productInfo.Updated)
        
        -- UI에 출력
    end
end

모든 월드 상품 정보 요청(GetWorldProductsAsync)

모든 월드 상품에 대한 정보를 포함하는 Pages 객체를 반환합니다.

local MarketplaceService = game:GetService("MarketplaceService")

local function Request_GetWorldProductsAsync()
    local success, errorOrWorldProducts = pcall(function()
        return MarketplaceService:GetWorldProductsAsync()
    end) 
    
    if not success then
        print("Error: " .. errorOrWorldProducts)
        
    else
        local worldProducts = errorOrWorldProducts
        
        local pageCount = 1	  
        local dataList = {}
			
        while true do
            local currentPage = worldProducts:GetCurrentPage()	
		    
            -- 마지막 페이지이면 루프 탈출 
            if worldProducts.IsFinished or currentPage == nil then           	
                print(pageCount .. " page IsFinished : " .. tostring(worldProducts.IsFinished))
                break
            else
                worldProducts:AdvanceToNextPageAsync()
                pageCount = pageCount + 1
            end
	    
            -- 한 페이지에 최대 100개의 상품 정보 구성
            for _, productInfo in pairs(currentPage) do		
                local i = #dataList + 1
				
                print("------ " .. i .. " ------")
                print("World Product Name: " .. tostring(productInfo.Name))
                print("ProductId: " .. tostring(productInfo.ProductId))
                
                print("ProductType: " .. tostring(productInfo.ProductType))
                print("PriceInBLUC: " .. tostring(productInfo.PriceInBLUC))
                print("Description: " .. tostring(productInfo.Description))
                print("Created: " .. productInfo.Created)
                print("Updated: " .. productInfo.Updated)
	
                table.insert(dataList, productInfo)
		
                -- UI에 출력
            end
        end
    end
end

구매 요청(PromptProductPurchase)

월드 상품 ID(productId)에 해당하는 상품의 구매를 요청합니다. (시스템 UI로 구매창이 출력됩니다.)

local MarketplaceService = game:GetService("MarketplaceService")

local function Request_PromptProductPurchase(player, productId)
    local success, error = pcall(function()
        MarketplaceService:PromptProductPurchase(player, productId)
    end)
	
    if not success then
        print("Error: " .. error .. " / ProductId : " .. productId)        
    end	
end

구매창 꺼질 때(PromptProductPurchaseFinished)

구매 요청(PromptProductPurchase)을 통해 출력된 구매창이 꺼질 때 이벤트가 호출되며, 구매를 성공하면 isPurchased에 true가 전달되고, 구매를 취소하거나 실패하면 false가 전달됩니다.

이 이벤트는 구매 창을 닫았는지 감지하기 위한 용도로만 사용해야 하며, 구매한 상품에 대한 지급 처리 용도로는 절대 사용하지 않아야 합니다.

local Players = game:GetService("Players")
local MarketplaceService = game:GetService("MarketplaceService")

local function OnPromptPurchaseFinished(userId, productId, isPurchased)
    local player = Players:GetPlayerByUserId(userId)
    
    print(player.Name .. " / ProductID : " .. productId .. " / isPurchased : " .. tostring(isPurchased))
end
MarketplaceService.PromptProductPurchaseFinished:Connect(OnPromptPurchaseFinished)

구매 성공시 지급 처리(ProcessReceipt)

구매 성공한 상품 중에서 아직 지급 처리가 되지 않은 영수증 정보를 반환하는 이벤트가 호출됩니다.

영수증 상태

상태
설명
재호출 여부

NotProcessedYet

상품 미지급 상태

호출 조건에 따라 다시 호출될 수 있음

PurchaseGranted

상품 지급 완료 상태

다시 호출되지 않음

호출 조건

  • 월드 상품을 성공적으로 구매했을 때(구매 성공 팝업이 사용자에게 표시되었을 때)

    • 미처리 상품이 있는 상태에서 새로운 상품 구매시, 이전 미처리건도 함께 호출됩니다.

  • 사용자가 서버에 접속(재접속)했을 때

지급 상태 변경 방법

  • 상품 지급 처리 후에, Enum.ProductPurchaseDecision.PurchaseGranted를 반환합니다.

주의사항

  • ProcessReceipt 이벤트 연결은 서버측 Script에서 한 번만 설정해야 합니다.

  • 이 콜백은 시간 제한 없이 yield 가능하며, 서버가 실행 중인 한 응답이 돌아올 때까지 유효합니다.

  • 미지급된 영수증이 여러개인 경우 각각 호출되며, 콜백 호출 순서는 비결정적(non-deterministic)입니다.

  • 사용자가 서버에 있어야 콜백이 호출됩니다.

    • 단, 콜백의 결과는 사용자가 서버에 없어도 백엔드에 기록될 수 있습니다.

  • 콜백에서 PurchaseGranted를 반환해도 백엔드 기록이 실패할 수 있으며, 이런 경우 영수증의 상태는 변경되지 않습니다. (미지급 상태 유지)

  • 미지급 상태의 상품은 자금이 지급 보류 상태(Escrow)로 보관됩니다.

local Players = game:GetService("Players")
local MarketplaceService = game:GetService("MarketplaceService")

local ProductDeliverer = {}

-----------------------------------------------------------------------------
-- 아래와 같이 테이블에 상품 번호 별로 함수를 구성하여 상품마다 지급 로직을 구현할 수 있습니다.
ProductDeliverer[여기에 상품 번호를 넣으세요.] = function(player)
    local success, resultOrError = pcall(function()
        -- player에게 상품 지급 및 DataStore를 이용한 저장 처리 
        
        -- Tip.
        -- DataStore로 상품 정보를 저장할 때는 
        -- 네트워크 충돌이나 경쟁 상태(race condition)를 방지하기 위해
        -- IncrementAsync 또는 UpdateAsync로 처리하는 것을 권장합니다.
                
        -- 지급 및 저장 처리가 성공적으로 완료된 경우 true 반환
        return true
    end)
    
    if success and resultOrError then
        return true
        
    else
        return false, resultOrError
    end
end

-----------------------------------------------------------------------------
-- 상품 구매 성공시 호출되는 영수증 처리용 콜백
local function OnProcessReceipt(receiptInfo)	
    -- 영수증 정보
    local success, error = pcall(function()	
        print("PurchaseId: " .. receiptInfo.PurchaseId)
        print("UserId: " .. receiptInfo.PlayerId)
        print("ProductId: " .. receiptInfo.ProductId)
        print("CurrencySpent: " .. receiptInfo.CurrencySpent)
        print("PurchaseDateTime: " .. receiptInfo.PurchaseDateTime)
    end)
    
    if not success then
        print("Error: " .. tostring(error))
        return Enum.ProductPurchaseDecision.NotProcessedYet
    end
    
    -- 플레이어가 유효하면
    local productId = receiptInfo.ProductId        
    local userId = receiptInfo.PlayerId
    
    local player = Players:GetPlayerByUserId(userId)  
    if player == nil then
        print("Error: player is nil")
        return Enum.ProductPurchaseDecision.NotProcessedYet	
    end  
    
    -- 상품 지급 함수 호출
    local delivererFunc = ProductDeliverer[productId]
    local success, error = delivererFunc(player)
    
    -- 상품 지급 성공시
    if success then
        -- 지급 완료 상태 반환
        print("Item delivery successful / ProductId : " .. productId)
        return Enum.ProductPurchaseDecision.PurchaseGranted
        
    -- 상품 지급 실패시
    else
        print("Error: " .. tostring(error))
        return Enum.ProductPurchaseDecision.NotProcessedYet
    end
end
MarketplaceService.ProcessReceipt = OnProcessReceipt

테스트 구매

스튜디오의 테스트 플레이 환경에서도 상품 구매 및 지급 처리를 확인할 수 있습니다. (재화가 차감되지 않습니다.)

수익 통계

수익 통계 정보는 UTC 기준 00:00 시간에 매일 갱신되며, Dashboard의 Analytics 탭에서 총 수익(Total Revenue)월드별 수익(Revenue열)을 확인할 수 있습니다.

월드 이름 영역을 클릭하여 Analytics Overview 페이지에 진입한 다음, 본문 하단의 Revenue Summmary 영역에서 상품별 판매 수익을 확인할 수 있습니다.

지급 상태 등 영수증 정보 확인하기

Export All Transactions 영역의 표시한 버튼을 눌러 판매한 상품의 지급 상태, 판매 날짜, 구매자 이름 및 Account ID 등 영수증 정보를 csv 파일로 확인할 수 있습니다.

DeliveryStatus

csv 파일 내 지급 상태(DeliveryStatus)의 각 항목에 대한 설명은 아래 표를 참고하세요.

상태
설명
점검 항목

DELIVERED

스크립트에서 지급이 정상적으로 처리되었으며, 영수증 서버에서도 상태가 지급으로 변경됨

DELIVERY_FAILED

스크립트에서 지급 처리 중 실패를 반환했거나, 영수증 서버에서 상태 변경 처리에 실패함

스크립트 점검 구매자에게 월드 재입장 안내

CALLBACK_MISSING

스크립트에서 ProcessReceipt가 정의되지 않음

스크립트 점검

수익

수익 분배

곧 지원될 예정입니다.

수익 정산

곧 지원될 예정입니다.

활용 예시

  • 인게임 재화를 무료/유료로 구분한 뒤, 유료 재화의 가치를 높이고 월드 상품으로 판매하여 사용자 구매를 유도하고 수익을 촉진할 수 있습니다.

  • 일반 스킨보다 더 높은 퀄리티의 스킨을 월드 상품으로 구성하면, 플레이어의 개성과 꾸미기 욕구를 자극하여 구매로 이어질 수 있습니다.

  • 무기, 포션, 재화 등을 묶은 패키지 상품을 구성하면, 개별 아이템 대비 높은 가성비를 통해 구매 전환율을 높일 수 있습니다.

  • 전투 중 사망했을 때 즉시 부활할 수 있는 소모성 아이템을 월드 상품으로 구성하면, 게임 흐름을 유지하면서 자연스러운 구매를 유도할 수 있습니다.

  • 시즌 패스와 같은 지속형 상품을 통해 장기적인 사용자 리텐션과 수익을 동시에 확보할 수 있습니다.

  • 유료 상품 위주의 과도한 구성보다는, 일정 수준의 무료 상품을 적절히 제공하여 사용자 만족도를 높이고, 신뢰를 바탕으로 한 자발적인 구매를 유도하는 것이 더욱 효과적입니다.

Last updated