본문으로 건너뛰기

IAP 결제 흐름 — processProductGrantcompleteProductGrant

IAP.createOneTimePurchaseOrderIAP.createSubscriptionPurchaseOrder는 결제 인증과 클라이언트-서버 fulfillment 핸드오프를 한 호출에 묶습니다. 이 흐름의 핵심은 두 콜백 — processProductGrant(주문이 결제 인증을 통과한 직후 서버에 권한 부여를 위임)와 IAP.completeProductGrant(서버 fulfillment 완료 후 토스에 종료를 알림). 이 가이드는 그 시퀀스, 책임 분리, 실패 회복 패턴을 한 곳에 정리합니다.

전체 시퀀스 한눈에 보기

[클라이언트 webview] [당신의 서버] [토스]
│ │ │
│ 1. IAP.getProductItemList() │ │
├──────────────────────────────────────────────────────────────► │
│ ◄──────────────────────────────────────────────────────────────┤
│ products[] │ │
│ │ │
│ 2. IAP.createOneTimePurchaseOrder({ sku, ...callbacks }) │
├──────────────────────────────────────────────────────────────► │
│ │ 결제 인증 다이얼로그 │
│ │ │
│ 3. processProductGrant({ orderId }) ◄─┤◄─────────────────────────┤
├──────────────────────────────────────►┤ │
│ │ 4. 서버 fulfillment │
│ │ (DB 권한 부여, 영수증 검증) │
│ ◄──────────────────────────────────────┤ │
│ return true │ │
│ │ │
│ 5. IAP.completeProductGrant({ params: { orderId } }) │
├──────────────────────────────────────────────────────────────► │
│ ◄──────────────────────────────────────────────────────────────┤
│ true │ │
│ │ │
│ 6. onEvent({ type: 'success' }) │ │
│ ◄──────────────────────────────────────────────────────────────┤
│ │ │
│ 7. cleanup() (반환된 함수) │ │

핵심:

  • createOrder 자체가 cleanup 함수를 반환합니다 — 결제 다이얼로그 라이프사이클이 끝나면 반드시 호출해 리스너를 떼세요.
  • processProductGrant 콜백 안에서 서버 fulfillment를 수행하고 성공/실패를 boolean으로 돌려줍니다.
  • completeProductGrant는 fulfillment 후 별도 호출 — 토스에 "처리 끝났음"을 알려 영수증을 확정. 누락하면 같은 주문이 getPendingOrders에 계속 남습니다.

단계 1 — 상품 목록 가져오기

import { IAP } from '@apps-in-toss/web-framework';

const { products } = await IAP.getProductItemList();
// products[].sku를 UI에 노출.

IAP.getProductItemList은 미니앱 콘솔에 등록된 SKU 목록을 돌려줍니다. 클라이언트 하드코딩 금지 — SKU/가격은 콘솔의 source of truth이며, 클라이언트는 응답을 그대로 표시합니다.

단계 2 — 결제 주문 생성

createOneTimePurchaseOrder의 표준 호출 모양:

import { IAP } from '@apps-in-toss/web-framework';

const cleanup = IAP.createOneTimePurchaseOrder({
options: {
sku: 'premium-1month',
processProductGrant: async ({ orderId }) => {
// 서버에 fulfillment 위임. true면 권한 부여 성공.
const ok = await fetch('/api/iap/fulfill', {
method: 'POST',
body: JSON.stringify({ orderId }),
headers: { 'Content-Type': 'application/json' },
}).then((res) => res.ok);

return ok;
},
},
onEvent: async ({ type, data }) => {
if (type === 'success') {
// 토스가 사용자에게 성공을 표시한 직후. completeProductGrant 호출.
await IAP.completeProductGrant({ params: { orderId: data.orderId } });
cleanup();
}
},
onError: (error) => {
console.error('IAP failed', error);
cleanup();
},
});

createSubscriptionPurchaseOrder도 같은 모양이지만 options.offerId를 받고 processProductGrant 콜백이 subscriptionId를 함께 받습니다.

processProductGrant: async ({ orderId, subscriptionId }) => {
// subscriptionId를 서버에 함께 전달해 구독 권한과 매핑.
return await fulfillSubscription({ orderId, subscriptionId });
}

단계 3 — processProductGrant 콜백의 책임

이 콜백이 서버 fulfillment의 진입점입니다. 보통 다음을 수행:

  1. 영수증 검증orderId로 토스 IAP API에 영수증 진위 확인 요청(필요시).
  2. 권한 부여 — DB에 (userId, sku, orderId) 매핑 기록. 중복 방지를 위해 orderId 유니크 제약.
  3. 결과 반환 — 위 두 단계가 모두 성공하면 true, 어느 하나라도 실패하면 false.
// 서버 측 라우트 예시 (Next.js, Express 등 어디서든)
app.post('/api/iap/fulfill', async (req, res) => {
const { orderId } = req.body;

// 1. 영수증 검증.
const verified = await verifyToOrder(orderId);
if (!verified) return res.status(400).json({ ok: false });

// 2. DB에 권한 부여 (idempotent — 같은 orderId 재호출은 no-op).
await db.grants.upsert({
where: { orderId },
create: { userId: req.user.id, sku: verified.sku, orderId },
update: {},
});

res.json({ ok: true });
});

false를 반환하면 토스가 사용자에게 "결제는 완료됐지만 권한 부여에 실패" 상태를 보여주고, 주문은 pending 상태로 남습니다 — IAP.getPendingOrders로 나중에 회수할 수 있습니다(아래 "복구" 참고).

콜백이 안전해야 한다

processProductGrant는 결제 인증 직후 한 번 호출되지만, 사용자가 결제 흐름을 중단·재시도하는 시나리오를 위해 idempotent해야 합니다. DB 권한 부여는 항상 orderId를 유니크 키로 upsert. 같은 orderId로 두 번 부여하지 않도록 보장하세요.

단계 4 — completeProductGrant로 종료

onEvent에서 type: 'success'를 받은 직후 호출:

import { IAP } from '@apps-in-toss/web-framework';

const result = await IAP.completeProductGrant({ params: { orderId } });
// result: boolean — true면 토스가 영수증을 확정.

IAP.completeProductGrant는 토스 측에서 영수증을 "확정"으로 표시해 같은 주문이 getPendingOrders에 다시 나타나지 않게 합니다. 이걸 호출하지 않으면 사용자가 미니앱을 재방문할 때마다 pending 주문으로 다시 발견됩니다 — 권한은 이미 부여돼 있어 서버 fulfillment는 idempotent하게 통과하지만, UX적으로는 "오래된 주문이 남아 있는" 인상이 됩니다.

단계 5 — cleanup 호출

createOrder 호출의 반환값은 cleanup 함수입니다. 결제 흐름이 끝나면(성공·실패·취소 어느 쪽이든) 반드시 호출:

const cleanup = IAP.createOneTimePurchaseOrder({ ... });
// 흐름이 끝나는 모든 경로에서 cleanup() 호출.

React에서는 useEffect 클린업이나 비동기 흐름의 finally에 묶는 게 자연스럽습니다.

복구 — pending 주문 회수

미니앱 진입 시점에 IAP.getPendingOrders를 호출해, completeProductGrant가 호출되지 못한 채 남은 주문이 있으면 처리하세요. 일반적인 시나리오:

  • 사용자가 processProductGrant 직후 미니앱을 종료해 completeProductGrant가 호출되지 못함.
  • 네트워크 오류로 completeProductGrant가 reject됨.
import { IAP } from '@apps-in-toss/web-framework';
import { useEffect } from 'react';

function IAPRecovery() {
useEffect(() => {
(async () => {
const { orders } = await IAP.getPendingOrders();
for (const order of orders) {
// 서버 fulfillment는 idempotent하므로 다시 시도해도 안전.
const ok = await fetch('/api/iap/fulfill', {
method: 'POST',
body: JSON.stringify({ orderId: order.orderId }),
headers: { 'Content-Type': 'application/json' },
}).then((r) => r.ok);
if (ok) {
await IAP.completeProductGrant({ params: { orderId: order.orderId } });
}
}
})().catch(console.warn);
}, []);

return null;
}

이 컴포넌트를 앱 진입점에 마운트해 두면 결제 중 비정상 종료에서 자동 회복됩니다.

구독 상태 조회

구독 상품의 현재 상태(isAccessible, expiresAt, isAutoRenew 등)는 IAP.getSubscriptionInfo로 조회합니다. processProductGrant에서 부여한 권한과 별개로, 토스 측의 갱신·환불 상태를 추적할 때 사용하세요.

const { subscription } = await IAP.getSubscriptionInfo({
params: { orderId },
});
if (!subscription.isAccessible) {
// 권한 회수: 서버 DB에서도 access를 거둬들임.
}

자주 빠뜨리는 체크리스트

  • processProductGrant의 서버 fulfillment가 idempotent한가? 같은 orderId로 두 번 호출돼도 권한이 두 번 부여되면 안 됩니다.
  • completeProductGrantonEvent({ type: 'success' }) 직후 반드시 호출하는가? 누락 시 pending 주문 누수.
  • createOrder의 cleanup을 모든 경로(성공·실패·취소)에서 호출하는가?
  • 앱 진입 시 getPendingOrders 회수 루틴을 두고 있는가? 없으면 비정상 종료 후 영구 누수.
  • SKU·가격을 클라이언트에서 하드코딩하고 있지는 않은가? getProductItemList 응답을 source of truth로.

환경별 차이

  • 실 토스 앱: 결제 다이얼로그 → 인증 → 영수증 발급 전체 흐름이 정상 동작. 콘솔에 등록된 SKU가 활성 상태인지 확인.
  • devtools mock: @ait-co/devtools mock은 processProductGrant 콜백을 즉시 호출하고 가짜 orderId를 생성합니다. 서버 fulfillment는 별도 mock 필요. 실제 영수증 검증·갱신은 디바이스에서만 검증.
  • 외부 브라우저: createOrder 호출이 throw 합니다.

관련 API

관련 가이드

외부 참조