인앱결제 (IAP)
Google Play 및 App Store 인앱결제·구독 상품을 JavaScript로 연동합니다.
개요
Google Play Billing과 App Store StoreKit 2를 WebView에서 JavaScript로 제어합니다. 상품 조회·구매·복원·완료 확인(Acknowledge)을 제공하며, 서버 영수증 검증은 고객님의 웹 서버를 통해 수행합니다(아래 서버 검증 참고).
| 메서드 | 설명 |
|---|---|
queryProducts | 스토어 상품 목록 및 가격 조회 |
purchase | 구매 플로우 시작 |
acknowledgePurchase | 구매 완료 확인 (Android 전용, 3일 내 필수) |
restorePurchases | 이전 구매 복원 (재설치 등) |
지원 플랜: Pro
| 플랫폼 | 구현 방식 |
|---|---|
| Android | Google Play Billing |
| iOS | StoreKit 2 |
전체 구매 흐름
올바른 순서: queryProducts → purchase → 서버 검증(고객 웹 서버 → Unveily) → acknowledgePurchase (Android만)
서버 검증은 영수증 위변조 방지를 위해 필수이며, 앱이 아니라 고객님의 웹 서버가 Unveily API(/api/iap/verify)를 호출합니다. SDK에는 별도의 serverVerify 메서드가 없습니다 — 구매 결과를 고객님 웹 서버로 전달해 검증합니다. 자세한 내용은 아래 서버 검증 섹션을 참고하세요.
iOS(StoreKit 2)는 purchase() 완료 시 자동으로 finish 처리되므로 acknowledgePurchase 호출이 필요 없습니다.
queryProducts
스토어에 등록된 상품의 정보와 현재 가격을 가져옵니다. Android는 Google Play 상품 ID, iOS는 App Store Connect 상품 ID를 사용합니다.
window.unveilyBridge.iap.queryProducts(
["monthly_pro", "yearly_pro"], // 상품 ID 배열
"subs", // "inapp" 또는 "subs"
"onProductsLoaded" // 콜백 함수명
);
function onProductsLoaded(result) {
const { products, error } = result;
if (error) {
console.error("상품 조회 실패:", error);
return;
}
products.forEach(p => {
console.log(`${p.title}: ${p.price}`);
});
}파라미터
| 파라미터 | 타입 | 설명 |
|---|---|---|
productIds | string[] | 조회할 상품 ID 배열 (최대 50개) |
type | "inapp" | "subs" | 일회성 상품 또는 구독 |
callback | string | 결과를 받을 전역 함수명 |
응답 형식
{
"products": [
{
"productId": "monthly_pro",
"title": "Pro 월간 구독",
"description": "모든 Pro 기능 이용",
"type": "subs",
"price": "₩9,900",
"priceAmountMicros": 9900000000,
"priceCurrencyCode": "KRW"
}
]
}오류 시: { "error": "오류 메시지" }
purchase
구매 화면을 띄웁니다. 사용자가 결제를 완료하거나 취소하면 콜백이 호출됩니다.
window.unveilyBridge.iap.purchase(
"monthly_pro", // 상품 ID
"subs", // 상품 유형
"onPurchaseResult" // 콜백 함수명
);
function onPurchaseResult(result) {
if (!result.success) {
if (result.cancelled) return; // 사용자가 취소
console.error("구매 실패:", result.error);
return;
}
// 구매 성공 → 고객님 웹 서버로 전달하여 서버 검증 (아래 "서버 검증" 참고)
// Android: result.purchaseToken / iOS: result.signedTransaction
verifyOnYourServer(result);
}파라미터
| 파라미터 | 타입 | 설명 |
|---|---|---|
productId | string | 구매할 상품 ID |
type | "inapp" | "subs" | 상품 유형 |
callback | string | 결과를 받을 전역 함수명 |
응답 형식
Android 성공 시:
{
"success": true,
"productId": "monthly_pro",
"purchaseToken": "购买token...",
"orderId": "GPA.1234-5678",
"purchaseTime": 1713456789000,
"purchaseState": 1
}iOS 성공 시:
{
"success": true,
"productId": "monthly_pro",
"transactionId": "2000000123456789",
"purchaseTime": 1713456789000,
"signedTransaction": "<Apple 서명 트랜잭션 (JWS 문자열)>"
}취소 시: { "success": false, "cancelled": true }
오류 시: { "success": false, "error": "오류 메시지" }
서버 검증에 쓰이는 값
서버 검증 시 Android는 purchaseToken, iOS는 signedTransaction(Apple이 서명한 JWS)을 고객님 웹 서버로 전달합니다. iOS의 transactionId는 식별용이며, 위변조 검증은 서명된 signedTransaction으로 수행됩니다.
구독 업그레이드 / 다운그레이드
기존 구독을 다른 구독으로 변경(업그레이드/다운그레이드)할 때는 purchase에 옵션 객체를 추가로 전달하는 오버로드를 사용합니다. 옵션 객체는 콜백 함수명 앞에 위치합니다.
window.unveilyBridge.iap.purchase(
"yearly_pro", // 새 상품 ID
"subs", // 구독
{
oldPurchaseToken: "기존 구매 토큰", // 현재 구독의 purchaseToken
replacementMode: 1 // 대체(프로레이션) 모드
},
"onPurchaseResult" // 콜백 함수명
);플랫폼 차이
이 옵션 객체는 Android(Google Play Billing) 에서만 사용됩니다. iOS는 옵션 객체를 조용히 무시합니다 — App Store의 구독 그룹(subscription group)이 업그레이드/다운그레이드를 자동으로 처리하기 때문입니다.
replacementMode 값 (Android):
| 값 | 모드 | 설명 |
|---|---|---|
1 | WITH_TIME_PRORATION | 기본값. 남은 기간을 새 요금 기준으로 환산 |
2 | CHARGE_PRORATED_PRICE | 차액을 즉시 청구 |
3 | WITHOUT_PRORATION | 다음 갱신일에 새 요금 적용 |
5 | CHARGE_FULL_PRICE | 전액 즉시 청구 |
6 | DEFERRED | 현재 구독 만료 후 변경 적용 |
기능 게이팅 — coming_soon 상태
인앱 결제는 라이선스 기능 "iap"(Pro)와 서버 측 iapEnabled 플래그가 모두 활성화되어야 동작합니다. 아직 활성화되지 않았다면 queryProducts·purchase가 { "status": "coming_soon" }을 반환하므로, 웹앱에서 이 상태를 처리해 안내 UI를 표시하세요.
function onPurchaseResult(result) {
if (result.status === "coming_soon") {
alert("인앱 결제는 곧 제공될 예정입니다.");
return;
}
// ... 정상 처리
}서버 검증 (고객 웹 서버 경유)
영수증 위변조·이중 청구를 막으려면 서버 검증이 필수입니다. SDK는 Unveily를 직접 호출하지 않습니다 — 구매 결과를 고객님의 웹 서버로 보내고, 고객님 웹 서버가 Unveily API(/api/iap/verify)를 호출합니다. (대부분의 웹앱은 이미 웹 서버가 있으므로 별도 IAP 서버 구축이 필요 없습니다.)
purchase 성공 후, (Android는) acknowledgePurchase 전에 검증하세요.
서버 검증 없이 기능을 제공하면 영수증 위변조에 취약해집니다.
흐름
앱(SDK 구매) → 구매 결과 → 웹콘텐츠가 고객님 웹 서버로 전송
→ 고객님 웹 서버 → Unveily POST /api/iap/verify → 검증 결과
플랫폼별 전달값:
- Android: purchaseToken + 단기 googleAccessToken (웹 서버가 서비스 계정으로 생성)
- iOS: signedTransaction (Apple 서명 JWS) — 자격증명 불필요1) 웹앱 — 구매 결과를 내 서버로 전송
// purchase 콜백(onPurchaseResult)에서 호출
async function verifyOnYourServer(result) {
const payload = result.purchaseToken
? { platform: "android", productId: result.productId, productType: "subs",
purchaseToken: result.purchaseToken }
: { platform: "ios", productId: result.productId, productType: "subs",
signedTransaction: result.signedTransaction };
const res = await fetch("/api/verify-iap", { // 고객님 웹 서버 엔드포인트
method: "POST", headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
});
const verify = await res.json();
if (!verify.success) { alert("결제 검증 실패. 고객센터에 문의해 주세요."); return; }
unlockProFeatures();
// Android만 Acknowledge (iOS는 StoreKit 2 자동 처리)
if (result.purchaseToken) {
window.unveilyBridge.iap.acknowledgePurchase(result.purchaseToken, "onAckResult");
}
}2) 고객님 웹 서버 — Unveily 호출 (relay)
자격증명(Google 서비스 계정)은 고객님 웹 서버에만 두고 Unveily에 저장하지 않습니다.
// 예: Node.js / Express
import { GoogleAuth } from "google-auth-library";
const UNVEILY_LICENSE_KEY = process.env.UNVEILY_LICENSE_KEY;
app.post("/api/verify-iap", async (req, res) => {
const { platform, productId, productType, purchaseToken, signedTransaction } = req.body;
const body = { licenseKey: UNVEILY_LICENSE_KEY, platform, productId, productType };
if (platform === "android") {
body.packageName = process.env.ANDROID_PACKAGE_NAME;
body.purchaseToken = purchaseToken;
body.googleAccessToken = await getGoogleAccessToken(); // ↓ 단기 토큰 생성
} else {
body.bundleId = process.env.IOS_BUNDLE_ID;
body.signedTransaction = signedTransaction; // Apple 서명 JWS 그대로 전달
}
const r = await fetch("https://api.actuallyworks.net/api/iap/verify", {
method: "POST", headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
});
res.status(r.status).json(await r.json());
});
// 서비스 계정 JSON으로 단기 access token 생성 (장기키는 서버 밖으로 나가지 않음)
async function getGoogleAccessToken() {
const auth = new GoogleAuth({
keyFile: process.env.GOOGLE_SERVICE_ACCOUNT_JSON_PATH,
scopes: ["https://www.googleapis.com/auth/androidpublisher"],
});
const client = await auth.getClient();
const { token } = await client.getAccessToken();
return token;
}보안
- 단기 토큰을 전송하세요 — 서비스 계정 JSON(장기키)은 고객님 웹 서버 밖으로 내보내지 마세요. 위처럼 ~1시간짜리 access token만 생성해 전달합니다.
- 토큰·서명 트랜잭션을 로그에 남기지 마세요.
- 모든 통신은 HTTPS.
licenseKey는 서버 환경변수로 보관(앱에 노출 금지).
Unveily 응답 형식 (/api/iap/verify)
{
"success": true,
"data": {
"productId": "monthly_pro",
"orderId": "GPA.1234-5678",
"purchaseTime": 1713456789000,
"isAcknowledged": false,
"storeApiVerified": true
}
}오류 시: { "success": false, "message": "오류 메시지" }
storeApiVerified 필드
storeApiVerified: true — Google Play API(Android) 또는 Apple 서명 검증(iOS)으로 실시간 검증 완료
storeApiVerified: false — Android에서 googleAccessToken 미전달 시 (DB 기록만 수행)
Google Play 서비스 계정 설정은 IAP 연동 가이드를 참고하세요. iOS는 자격증명 없이 서버가 Apple 서명(JWS)을 검증합니다.
acknowledgePurchase
구매를 확인 처리합니다. Google Play는 3일 내에 Acknowledge가 없으면 자동 환불 처리합니다.
Android 전용 — 3일 내 필수
이 메서드는 Android(Google Play) 전용입니다.
iOS(StoreKit 2)는 purchase() 완료 시 자동으로 finish 처리되므로 별도 호출이 불필요합니다.
Android에서는 서버 검증이 완료된 후 반드시 acknowledgePurchase를 호출해야 합니다.
누락 시 Google이 자동으로 환불하고 구독을 취소합니다.
function acknowledgePurchase(purchaseToken) {
window.unveilyBridge.iap.acknowledgePurchase(
purchaseToken, // 구매 토큰
"onAckResult" // 콜백 함수명
);
}
function onAckResult(result) {
if (result.success) {
console.log("구매 확인 완료");
// UI 업데이트, 구독 상태 저장 등
} else {
console.error("확인 실패:", result.error);
}
}파라미터
| 파라미터 | 타입 | 설명 |
|---|---|---|
purchaseToken | string | purchase에서 받은 구매 토큰 (Android) |
callback | string | 결과를 받을 전역 함수명 |
응답 형식
성공: { "success": true }
오류: { "success": false, "error": "오류 메시지" }
restorePurchases
앱 재설치, 기기 변경 등의 상황에서 이전 구매 내역을 복원합니다. 앱 시작 시 또는 사용자가 "구매 복원" 버튼을 탭할 때 호출합니다.
- Android: Google Play의 활성 구독 목록을 반환합니다.
- iOS: StoreKit 2의
Transaction.currentEntitlements를 기반으로 복원합니다.
window.unveilyBridge.iap.restorePurchases(
"subs", // 복원할 유형
"onRestoreResult" // 콜백 함수명
);
function onRestoreResult(result) {
const { purchases, error } = result;
if (error) {
console.error("복원 실패:", error);
return;
}
const active = purchases.filter(p => p.isAcknowledged || p.transactionId);
if (active.length > 0) {
unlockProFeatures();
}
}응답 형식
Android:
{
"purchases": [
{
"productId": "monthly_pro",
"purchaseToken": "...",
"orderId": "GPA.1234-5678",
"purchaseTime": 1713456789000,
"purchaseState": 1,
"isAcknowledged": true
}
]
}iOS:
{
"purchases": [
{
"productId": "monthly_pro",
"transactionId": "2000000123456789",
"purchaseTime": 1713456789000,
"isAcknowledged": true,
"signedTransaction": "<Apple 서명 트랜잭션 (JWS)>"
}
]
}전체 구현 예시
// ── 전역 상태 ──────────────────────────────────────────
let currentProductId = null;
// ── 1. 앱 시작 시 기존 구독 복원 ────────────────────────
window.addEventListener("load", () => {
if (window.unveilyBridge?.iap) {
window.unveilyBridge.iap.restorePurchases("subs", "onRestoreResult");
}
});
// ── 2. 구매 버튼 ─────────────────────────────────────────
function startSubscription(productId) {
currentProductId = productId;
window.unveilyBridge.iap.purchase(productId, "subs", "onPurchaseResult");
}
// ── 3. 구매 결과 → 고객님 웹 서버로 검증 (relay) ────────
window.onPurchaseResult = async function(result) {
if (!result.success) return;
// Android: purchaseToken / iOS: signedTransaction(JWS) 를 내 서버로 전달
const payload = result.purchaseToken
? { platform: "android", productId: currentProductId, productType: "subs",
purchaseToken: result.purchaseToken }
: { platform: "ios", productId: currentProductId, productType: "subs",
signedTransaction: result.signedTransaction };
const res = await fetch("/api/verify-iap", { // 고객님 웹 서버가 Unveily 호출
method: "POST", headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
});
const verify = await res.json();
if (!verify.success) {
alert("결제 검증에 실패했습니다. 고객센터에 문의해 주세요.");
return;
}
unlockProFeatures();
// Android만 Acknowledge 필요 (iOS는 StoreKit 2가 자동 처리)
if (result.purchaseToken) {
window.unveilyBridge.iap.acknowledgePurchase(result.purchaseToken, "onAckResult");
}
};
// ── 5. Acknowledge 완료 ─────────────────────────────────
window.onAckResult = function(result) {
if (result.success) console.log("구독 활성화 완료");
};
// ── 복원 처리 ────────────────────────────────────────────
window.onRestoreResult = function(result) {
const active = (result.purchases || []).filter(
p => p.isAcknowledged || p.transactionId
);
if (active.length > 0) unlockProFeatures();
};
function unlockProFeatures() {
// Pro 기능 UI 활성화
document.body.classList.add("pro-user");
}관련 문서
- IAP 연동 가이드 — Google Play Console 및 App Store Connect 설정
- 앱 정보 Bridge —
getInfo()로 라이선스 플랜 확인