로블록스 Developer Product 보상이 안 들어올 때: ProcessReceipt 점검법
구매 창이 닫혔다는 이벤트는 결제 성공 증명이 아닙니다. Developer Product 보상은 서버의 ProcessReceipt에서 영수증을 확인하고, 같은 PurchaseId가 다시 와도 결과가 한 번만 반영되게 저장한 뒤에만 PurchaseGranted를 반환해야 합니다. 보상 누락과 이중 지급은 서로 반대 문제처럼 보이지만 대부분 “어느 시점에 완료로 인정했는가”를 따라가면 원인을 좁힐 수 있습니다.
2026년 10월 5일 Windows 데스크톱 브라우저에서 Roblox Creator Hub의 Developer Products 문서와 MarketplaceService API의 ProcessReceipt 설명·공식 코드 예제를 대조했습니다. 구매 창, 영수증, 보상 지급, DataStore 기록과 Roblox 백엔드의 영수증 완료는 서로 다른 단계이며, 특히 같은 영수증이 두 서버에서 처리될 수 있다는 제한을 기준으로 점검 순서를 다시 구성했습니다. 실제 Robux를 쓰거나 Developer Product를 생성·구매하지 않았고 Roblox Studio 라이브 서버에서 영수증을 재현하지도 않았습니다. 아래 내용은 공개 문서 조사와 코드 경로 검토 범위이며, 실제 결제 테스트는 소유한 테스트 게임과 낮은 가격의 상품에서 진행해야 합니다.
PromptProductPurchaseFinished로 보상을 지급하지 마세요. 서버의 ProcessReceipt에서 상품 ID를 허용 목록으로 확인하고, PurchaseId를 기준으로 한 번만 반영됐음을 영구 저장한 뒤 PurchaseGranted를 반환해야 합니다.1. 구매 창이 닫힘·결제 성공·보상 완료는 같은 신호가 아닙니다
공식 Developer Products 문서에서 가장 먼저 확인한 경고는 PromptProductPurchaseFinished가 발생했다고 구매 성공이 보장되지 않는다는 점입니다. 이 이벤트는 구매 프롬프트가 끝난 시점을 UI에 알릴 수 있지만, 서버가 재화나 아이템을 지급하는 근거로 쓰면 취소·오류·지연 상태와 실제 영수증 처리를 섞게 됩니다.
| 신호 | 알 수 있는 것 | 보상 지급 근거 |
|---|---|---|
PromptProductPurchase() 호출 성공 | 구매 창 표시 요청이 오류 없이 실행됨 | 아니요 |
PromptProductPurchaseFinished | 구매 창 흐름이 끝남 | 아니요 |
ProcessReceipt(receiptInfo) | Roblox가 미해결 영수증 처리를 서버에 요청함 | 예. 검증·저장 성공 뒤에만 |
PurchaseGranted 반환 | 게임이 보상을 지급했다고 Roblox에 확인함 | 마지막 단계 |
LocalScript는 상품 정보를 표시하고 구매 창을 여는 역할까지 맡을 수 있습니다. 재화 증가, 인벤토리 추가와 영수증 완료 결정은 ServerScriptService의 서버 Script에 둡니다. 클라이언트가 “결제했다”는 RemoteEvent를 보내게 하고 서버가 그대로 보상하는 구조는 사용하지 않습니다.
2. 영수증 한 건은 다섯 단계를 통과해야 합니다
보상 누락을 찾을 때 코드를 한꺼번에 읽기보다 영수증이 멈춘 단계를 표시하면 빠릅니다.
- 호출: 서버가
ProcessReceipt콜백을 받았는지 확인합니다. - 대상:
PlayerId의 플레이어가 현재 서버에 있고,ProductId처리 함수가 존재하는지 확인합니다. - 중복:
PurchaseId가 이미 지급된 영수증인지 영구 기록에서 확인합니다. - 지급·저장: 보상 상태와 처리한 PurchaseId를 중간에 갈라지지 않게 저장합니다.
- 확인: 위 단계가 성공했을 때만
PurchaseGranted, 그렇지 않으면NotProcessedYet를 반환합니다.
콜백이 호출되지 않았다는 것과 콜백이 NotProcessedYet를 반환했다는 것은 다릅니다. 첫 번째는 Script 위치·콜백 등록·실제 상품과 경험 연결을 확인하고, 두 번째는 서버 로그와 DataStore 오류·플레이어 존재·상품 처리 함수 결과를 확인합니다.
3. receiptInfo는 가격 문자열보다 식별자를 먼저 봅니다
MarketplaceService API는 receiptInfo에 구매를 구분할 필드를 제공합니다. 문제를 재현할 때 전체 table을 무조건 출력하기보다 아래 값과 처리 단계만 구조화해 남기는 편이 안전합니다.
| 필드 | 용도 | 실수하기 쉬운 점 |
|---|---|---|
PurchaseId | 특정 구매 한 건의 고유 ID, 중복 방지 키 | ProductId나 PlayerId를 대신 키로 쓰면 반복 구매를 구분하지 못함 |
PlayerId | 구매한 이용자의 UserId | 현재 콜백 서버에 Player 객체가 없을 수 있음 |
ProductId | 어떤 Developer Product인지 처리 함수 선택 | 클라이언트가 보낸 상품 ID와 대조해 결정하지 않음 |
PlaceIdWherePurchased | 구매가 발생한 Place 확인 | 현재 실행 중 PlaceId와 다를 수 있음 |
CurrencySpent | 실제로 사용된 Robux 금액 확인 | 지역 가격·가격 최적화가 있으면 모든 이용자가 같은 금액이라고 가정하지 않음 |
ProductPurchaseChannel | 상품을 구매한 경로 확인 | 게임 밖 Store 구매도 ProcessReceipt 경로로 들어올 수 있음 |
반복 구매 가능한 상품에서 PlayerId .. ':' .. ProductId를 중복 키로 만들면 두 번째 정상 구매를 이미 처리된 것으로 오인합니다. 한 구매 단위는 반드시 PurchaseId로 구분해야 합니다.
4. 반환값은 “지금 지급했는가”가 아니라 “지급됐음을 확실히 아는가”로 정합니다
| 현재 상태 | 반환값 | 이유 |
|---|---|---|
| 보상과 PurchaseId 기록이 함께 성공 | PurchaseGranted | 다시 처리할 필요 없음 |
| 이미 같은 PurchaseId가 지급 완료로 저장됨 | PurchaseGranted | 중복 지급 없이 영수증을 닫음 |
| 플레이어가 현재 서버에 없음 | NotProcessedYet | 다음 재전달 때 처리 |
| DataStore 읽기·쓰기 실패 | NotProcessedYet | 완료 여부를 확정할 수 없음 |
| 등록되지 않은 ProductId | NotProcessedYet | 코드·상품 설정을 고칠 때까지 보상 미확정 |
| 보상 함수가 실패하거나 결과가 불명확 | NotProcessedYet | PurchaseGranted로 닫으면 복구 기회를 잃음 |
공식 문서에 따르면 NotProcessedYet는 일정 초마다 자동 재시도되는 타이머가 아닙니다. 같은 서버에서 다른 Developer Product 구매가 성공적으로 시작되거나, 이용자가 같은 경험의 서버에 다시 들어오는 등의 시점에 콜백이 다시 올 수 있습니다. 따라서 “몇 초 뒤 자동으로 고쳐질 것”이라고 안내하지 말고 미해결 상태가 오래 갈 수 있음을 운영 로그에 반영해야 합니다.
5. 같은 PurchaseId가 두 번 와도 결과는 한 번이어야 합니다
MarketplaceService 문서는 특정 구매의 ProcessReceipt가 두 서버에서 동시에 실행될 수 있다고 명시합니다. 이용자가 첫 서버의 콜백이 끝나기 전에 다른 서버에 들어오면 생길 수 있습니다. 서버 메모리 table에만 처리 ID를 넣는 방식은 서버가 다르면 공유되지 않고 재시작하면 사라지므로 중복 방지가 아닙니다.
영구 재화처럼 한 DataStore 레코드에서 관리하는 보상은 플레이어 프로필의 UpdateAsync() 안에서 다음 두 변경을 같은 반환값에 넣는 방법을 검토할 수 있습니다.
processedReceipts[PurchaseId]가 이미 있으면 재화를 다시 더하지 않습니다.- 없으면 재화를 더하고 같은 레코드에 PurchaseId 처리 표시를 함께 저장합니다.
- UpdateAsync가 실패하면 완료로 인정하지 않고
NotProcessedYet를 반환합니다.
보상과 영수증 기록을 서로 다른 DataStore 키에 따로 쓰면 첫 번째 쓰기 뒤 두 번째가 실패하는 사이 상태가 생깁니다. 보상은 들어갔지만 기록이 없으면 재전달 때 이중 지급되고, 기록부터 저장했는데 보상이 실패하면 영수증은 처리됐지만 이용자는 받지 못합니다. 여러 키·외부 시스템을 함께 바꾸는 상품은 단순 예제보다 별도의 거래 상태 모델과 복구 작업이 필요합니다.
6. 서버 코드는 상품 라우팅·영구 처리·반환을 분리합니다
아래 코드는 완성된 결제 시스템이 아니라 콜백의 경계를 보여 주는 뼈대입니다. grantOnceAndSave()는 자신의 프로필 저장 구조에서 보상과 PurchaseId 기록을 한 번의 안전한 상태 변경으로 구현해야 합니다.
-- ServerScriptService의 Script
local MarketplaceService = game:GetService("MarketplaceService")
local Players = game:GetService("Players")
local PRODUCT_HANDLERS = {
[1234567890] = function(receiptInfo, player)
return grantOnceAndSave(
player.UserId,
receiptInfo.PurchaseId,
function(profile)
profile.Coins += 100
end
)
end,
}
local function processReceipt(receiptInfo)
local player = Players:GetPlayerByUserId(receiptInfo.PlayerId)
if not player then
return Enum.ProductPurchaseDecision.NotProcessedYet
end
local handler = PRODUCT_HANDLERS[receiptInfo.ProductId]
if not handler then
warn("등록되지 않은 Developer Product", receiptInfo.ProductId)
return Enum.ProductPurchaseDecision.NotProcessedYet
end
local ok, grantedOrAlreadyGranted = pcall(
handler,
receiptInfo,
player
)
if ok and grantedOrAlreadyGranted == true then
return Enum.ProductPurchaseDecision.PurchaseGranted
end
warn("영수증 처리 미완료", receiptInfo.PurchaseId)
return Enum.ProductPurchaseDecision.NotProcessedYet
end
-- 한 서버에서 한 번만 지정합니다.
MarketplaceService.ProcessReceipt = processReceipt
grantOnceAndSave()가 단순히 메모리 값을 더하고 true를 반환하면 서버 종료와 재전달에 안전하지 않습니다. 반대로 DataStore 저장은 성공했지만 화면의 leaderstats 갱신만 실패한 경우, 저장된 프로필을 다시 복제해 UI를 회복해야지 같은 PurchaseId 보상을 다시 더하면 안 됩니다.
공식 API 예제도 PurchaseHistory DataStore와 UpdateAsync()를 사용하며 “대부분의 실패 시나리오를 처리하지만 교차 서버 데이터 실패를 완전히 막지는 않는다”고 코드 주석에 범위를 밝힙니다. 예제를 그대로 붙여 넣기보다 자신의 프로필이 어디에서 원본이 되는지부터 결정하세요.
7. 화면 증상별로 멈춘 단계를 찾습니다
| 증상 | 우선 확인 | 잘못된 임시 해결 |
|---|---|---|
| 결제했는데 즉시 보상이 없음 | 서버 로그의 콜백 진입, Player 존재, ProductId handler, DataStore 오류 | PromptProductPurchaseFinished에서 한 번 더 지급 |
| 재접속 뒤 보상이 들어옴 | 첫 처리 때 NotProcessedYet 사유와 저장 실패 | 재접속 지급을 정상 완료로 간주하고 오류를 숨김 |
| 같은 상품이 두 번 지급됨 | PurchaseId 영구 중복 검사, 두 서버 동시 처리, 보상·기록 분리 | PlayerId+ProductId로 이후 정상 구매까지 차단 |
| 일부 상품만 계속 실패 | ProductId 오타, PRODUCT_HANDLERS 누락, 보상 대상 Instance 부재 | 알 수 없는 상품도 PurchaseGranted 반환 |
| 서버 재시작 후 중복 지급 | 메모리 table만 사용했는지, DataStore 기록 성공 여부 | 서버별 debounce 시간 늘리기 |
| 구매 UI는 성공인데 영수증 로그 없음 | ProcessReceipt를 한 Script에서 등록했는지, 실제 경험·상품 연결 | 클라이언트 RemoteEvent를 영수증 대신 사용 |
MarketplaceService.ProcessReceipt는 한 서버 Script에서 한 번만 지정해야 합니다. 여러 Script가 서로 콜백을 덮어쓰면 마지막 할당만 남아 상품마다 다른 코드가 실행된다고 기대할 수 없습니다. 상품별 처리는 하나의 productId → handler 표로 모으는 편이 추적하기 쉽습니다.
8. 실제 Robux 테스트 전에 실패 경로를 먼저 검증합니다
공식 문서는 Developer Product 테스트에도 실제 Robux가 들 수 있다고 안내합니다. 반복 구매를 시작하기 전에 콜백 핵심 로직을 ModuleScript 함수로 분리하고 가짜 receiptInfo를 넣어 아래 상태 전이를 먼저 확인하세요. 가짜 영수증은 로컬 로직 테스트용일 뿐 Roblox 백엔드 결제를 완료하지 않습니다.
- 알려진 ProductId와 처음 보는 PurchaseId를 넣으면 보상과 처리 표시가 한 번 저장되는지 확인합니다.
- 같은 PurchaseId를 다시 넣어도 잔액·아이템 수가 늘지 않고 성공 결과만 반환되는지 확인합니다.
- 알 수 없는 ProductId는 보상 없이 미처리 상태가 되는지 확인합니다.
- 플레이어 없음, 프로필 미로드, DataStore 오류를 각각 만들고 PurchaseGranted가 반환되지 않는지 확인합니다.
- 두 서버가 같은 PurchaseId를 거의 동시에 처리하는 Server & Clients 시나리오에서 한 번만 반영되는지 확인합니다.
- 영구 저장은 성공했지만 leaderstats·UI 복제가 실패한 경우 재접속 후 저장값으로 화면이 복구되는지 확인합니다.
- 그다음 낮은 가격의 실제 상품 한 개로 구매하고 서버 로그·저장값·재접속 결과를 함께 확인합니다.
게임 밖 Store 판매를 켜는 경우 공식 External Purchase Test Mode 절차를 따르되 테스트 상품에도 실제 Robux가 든다는 안내를 확인하세요. 테스트 모드에서 영수증 상태가 Closed로 바뀌고 게임 입장 뒤 보상이 한 번 지급되는지 확인한 다음 외부 판매를 활성화합니다.
9. 로그에는 결제 정보 전체가 아니라 처리 상태를 남깁니다
운영 로그에서 필요한 것은 “누가 얼마를 썼는가”를 공개적으로 찍는 것이 아니라 영수증이 어느 단계에서 멈췄는지입니다. PurchaseId 전체를 그대로 외부 로그에 장기간 보낼 필요가 없다면 일부를 마스킹하거나 내부 상관 ID를 사용하세요.
- 서버 job ID와 PlaceId
- PurchaseId의 안전한 추적값
- ProductId와 PlayerId
- 단계: received, player-missing, unknown-product, already-granted, grant-failed, datastore-failed, granted
- DataStore 오류 종류와 재시도 횟수
- 최종 반환값과 처리 시각
같은 영수증의 received가 여러 서버 job ID에서 보이면 동시 처리 가능성을 확인합니다. granted 뒤 다시 received가 왔을 때 보상이 늘지 않고 already-granted로 끝나는 것이 정상 복구 신호입니다. 공개 서버 로그를 분리해서 보는 방법은 F9 Developer Console 확인법, 저장 실패 자체는 DataStore 저장 실패 점검법을 함께 참고하세요.
10. 공개 전 점검표
- ProcessReceipt를 ServerScriptService의 한 Script에서 한 번만 지정했습니다.
- PromptProductPurchaseFinished·RemoteEvent·클라이언트 변수로 보상을 지급하지 않습니다.
- 모든 판매 중 ProductId가 서버 handler 표에 있습니다.
- PurchaseId로 영구 중복 검사를 하며 PlayerId+ProductId를 구매 ID처럼 쓰지 않습니다.
- 보상과 처리 표시가 서로 다른 성공 상태로 갈라지지 않습니다.
- 플레이어 없음·저장 실패·알 수 없는 상품은 NotProcessedYet입니다.
- 이미 지급된 PurchaseId는 중복 보상 없이 PurchaseGranted입니다.
- 오류가 나도 무조건 PurchaseGranted를 반환하는 fallback이 없습니다.
- 동일 영수증 동시 서버 처리와 재접속 재전달을 시험했습니다.
- 가격 표시가 필요하면 서버에 하드코딩한 숫자 대신 최신 상품 정보와 지역 가격 조건을 확인합니다.
지역 가격·가격 최적화로 화면과 실제 결제 금액이 달라지는 문제는 로블록스 지역 가격 확인법에서 별도로 확인할 수 있습니다. 영수증 처리와 가격 표시는 연결되어 있지만, 화면에 보인 가격이 보상 완료의 증거는 아닙니다.
공식 출처·갱신일·적용 범위
아래 Roblox Creator Hub 1차 자료를 2026년 10월 5일 확인했습니다. 공개 Markdown 메타데이터에는 Developer Products 문서가 2026년 10월 2일 21:48:49 UTC, MarketplaceService API가 2026년 10월 2일 21:48:51 UTC 갱신으로 표시됐습니다.
- Developer Products — 반복 구매 상품의 생성·판매, ProcessReceipt 사용 의무, PromptProductPurchaseFinished 경고, 게임 밖 판매 Test Mode와 영수증 처리 예제를 확인했습니다.
- MarketplaceService.ProcessReceipt — receiptInfo 필드, PurchaseGranted·NotProcessedYet, 미해결 영수증 재전달 조건, 동시 서버 처리 가능성, 콜백 1회 등록과 DataStore 예제의 범위를 확인했습니다.
적용 범위: Roblox Developer Product의 서버 영수증 처리와 인게임 보상 누락·중복을 진단하는 일반 절차입니다. Pass, 구독, 아바타 상품, Robux Transfer, 실제 환불·정산·세금, 계정별 판매 자격은 다루지 않습니다. 실제 결제·DataStore 부하·교차 서버 장애를 실행해 측정한 결과가 아니며, 금전과 이용자 자산이 걸린 기능이므로 공개 전 소유한 테스트 경험에서 최신 Creator Hub 문서와 실제 로그를 기준으로 검증하세요.