IAP 상태 조회 패턴 — 상품·주문·구독 상태 한 곳에서 관리하기
IAP의 결제 흐름은 Guides — IAP 결제 흐름에서 다룹니다. 이 가이드는 그 밖의 네 가지 조회 — 상품 목록, 미처리 주문, 완료/환불 내역, 구독 상태 — 를 미니앱이 진입할 때(또는 사용자가 "내 주문"에 들어왔을 때) 어떤 순서로 호출하고 결과를 어디에 반영할지 한 곳에 모읍니다.
네 메서드의 역할 분담
| 메서드 | 답하는 질문 | 데이터 출처 | 호출 빈도 |
|---|---|---|---|
getProductItemList | "지금 팔리는 상품/가격은?" | 콘솔에 등록된 SKU | 상품 화면 진입 시. SKU/가격은 콘솔이 source of truth. |
getPendingOrders | "결제는 됐는데 finalize 안 된 주문이 있나?" | 토스 결제 시스템 | 모든 진입(재진입 포함). 복구 트리거. |
getCompletedOrRefundedOrders | "지난 N일 동안의 완료/환불 내역은?" | 토스 결제 시스템 | "내 주문" 화면 진입 시. 또는 진입 시 환불 반영용. |
getSubscriptionInfo | "이 구독은 지금 살아 있나? 언제 만료?" | 토스 결제 시스템 | 구독성 기능 진입 시. 갱신·환불 반영. |
핵심: 네 메서드 모두 결제 흐름 밖에서 호출 — createOneTimePurchaseOrder / createSubscriptionPurchaseOrder 등의 콜백 안에 끼우지 않습니다.
진입 시 표준 시퀀스
미니앱 진입(앱 entry / "내 주문" 화면 등)에서 한 번에 처리하는 패턴.
import { IAP } from '@apps-in-toss/web-framework';
import { useEffect } from 'react';
function IAPStateBootstrap() {
useEffect(() => {
(async () => {
// 1. 상품 목록은 매번 새로 — 콘솔에서 가격이 바뀌었거나 SKU가 추가될 수 있음.
const { products } = await IAP.getProductItemList();
productStore.set(products);
// 2. 미처리 주문 복구 — 자세한 흐름은 IAP 결제 흐름 가이드 참고.
const { orders } = await IAP.getPendingOrders();
for (const order of orders) {
const ok = await fulfillOnServer(order.orderId); // 서버 fulfillment idempotent
if (ok) await IAP.completeProductGrant({ params: { orderId: order.orderId } });
}
// 3. 환불 반영 — 마지막 동기화 이후 변경된 주문만 확인.
const since = lastSyncedAt ?? Date.now() - 7 * 24 * 60 * 60 * 1000;
const { orders: history } = await IAP.getCompletedOrRefundedOrders({
params: { fromTimestamp: since },
});
for (const order of history) {
if (order.status === 'REFUNDED') {
await revokeGrantOnServer(order.orderId);
}
}
lastSyncedAt = Date.now();
})().catch(console.warn);
}, []);
return null;
}
이 한 컴포넌트가 진입 시 네 가지를 모두 처리합니다. 순서 의도:
- 상품 목록 먼저 — 가격이 바뀌었거나 SKU가 비활성화됐다면 그걸 본 화면이 그려지기 전에 반영.
- 미처리 주문 복구 — 사용자가 결제 직후 앱을 종료해
completeProductGrant가 누락된 경우의 자가 회복. (IAP 결제 흐름 참고.) - 환불 반영 — 사용자가 토스에서 환불을 받았으면 사용자 서버에서 grant를 회수해야 함. 이걸 안 하면 환불받고도 콘텐츠를 계속 쓸 수 있음.
- 구독 상태는 그 화면이 구독에 의존할 때만(아래 별도 섹션).
catch(console.warn)로 감싼 이유: 네 호출 중 하나가 실패해도 다른 흐름은 계속 진행되는 게 UX상 안전합니다. 진짜 실패는 다음 진입 때 다시 시도.
getProductItemList — 콘솔이 source of truth
const { products } = await IAP.getProductItemList();
// products[].sku, products[].price, products[].name 등을 그대로 UI에 렌더.
클라이언트에 SKU/가격을 하드코딩하지 않습니다. 콘솔에서 가격 변경 → 빌드 없이 즉시 반영, 신상품 추가 → UI가 자동 노출. 하드코딩하면 콘솔과 어긋난 가격으로 결제를 시작해 사용자에게 혼란.
// ❌ 안 좋은 패턴: 클라에 하드코딩
const PRODUCTS = [{ sku: 'premium-1month', price: 4900 }];
// ✅ 좋은 패턴: 콘솔이 source of truth
const { products } = await IAP.getProductItemList();
진입할 때마다 새로 부르는 게 정석이지만, 화면 전환마다 호출해 비싸지면 세션 단위 캐시(앱 시작 ~ 종료 사이) 한 번이면 충분합니다. 결제를 시작하기 직전에는 캐시가 stale 일 수 있으므로 결제 시작 전에 한 번 더 갱신.
getPendingOrders — 진입 시마다 호출
const { orders } = await IAP.getPendingOrders();
미니앱 진입 시마다 호출합니다. 결제 → processProductGrant 서버 fulfillment → completeProductGrant 사이 어디서든 사용자가 앱을 종료하면 그 주문이 여기 잡힙니다. 자세한 복구 흐름과 idempotent server fulfillment는 IAP 결제 흐름 — Recovery.
추가 주의:
- 빈 배열이 정상. 매번 빈 배열을 기대하고, 비어있지 않을 때만 fallback이 동작.
- 호출 자체는 가볍습니다. 진입마다 호출해도 부담 없음.
completeProductGrant를 호출하기 전까지 같은 주문이 계속 잡힙니다. 멱등 fulfillment + 항상completeProductGrant까지 도달해야 사라짐.
getCompletedOrRefundedOrders — 환불 동기화
const { orders } = await IAP.getCompletedOrRefundedOrders({
params: { fromTimestamp: lastSyncedAt },
});
for (const order of orders) {
switch (order.status) {
case 'COMPLETED':
// grant가 사용자 서버에 있는지 확인. 없으면 보충(서버 fulfillment 멱등).
break;
case 'REFUNDED':
// **반드시** 사용자 서버에서 grant 회수.
await revokeGrantOnServer(order.orderId);
break;
}
}
fromTimestamp 없이 호출하면 전체 이력을 가져오므로, 마지막 동기화 시각 이후만 요청해서 비용을 줄입니다. localStorage에 lastSyncedAt을 저장하고, 매 진입마다 차분(delta) 조회.
"내 주문" 화면
위 동기화 외에도, 사용자가 "내 주문" 화면을 열면 같은 메서드로 전체 이력을 보여줍니다. 페이지네이션은 SDK 레벨에서 없고, 한 번 호출에 윈도우(fromTimestamp ~ now) 안의 모든 주문이 옵니다.
function OrderHistoryPage() {
const [orders, setOrders] = useState<Order[]>([]);
useEffect(() => {
(async () => {
const result = await IAP.getCompletedOrRefundedOrders({
params: { fromTimestamp: Date.now() - 90 * 24 * 60 * 60 * 1000 }, // 최근 90일
});
setOrders(result.orders);
})().catch(console.warn);
}, []);
return <OrderList orders={orders} />;
}
UI는 두 가지 status를 분리해 표시 — COMPLETED는 평범하게, REFUNDED는 명시적으로 "환불됨" 라벨.
getSubscriptionInfo — 구독 상태 확인
const { subscription } = await IAP.getSubscriptionInfo({
params: { orderId },
});
// subscription.isAccessible, expiresAt, isAutoRenew 등
구독 기반 기능(프리미엄 화면 등)에 진입할 때 호출. 사용자가 토스에서 자동 갱신을 끄거나 환불을 받았다면 여기서 isAccessible === false로 즉시 반영됩니다.
function PremiumGate({ orderId, children }: { orderId: string; children: ReactNode }) {
const [accessible, setAccessible] = useState<boolean | null>(null);
useEffect(() => {
(async () => {
const { subscription } = await IAP.getSubscriptionInfo({ params: { orderId } });
setAccessible(subscription.isAccessible);
if (!subscription.isAccessible) {
await revokeGrantOnServer(orderId);
}
})().catch(() => setAccessible(false));
}, [orderId]);
if (accessible === null) return <Loading />;
if (!accessible) return <UpgradePrompt />;
return <>{children}</>;
}
갱신 시점은 별도로 추적
isAccessible은 "지금 이 순간 접근 가능"인지만 알려줍니다. 갱신 결제가 일어났는지(매월 자동 결제 등)는 위 getCompletedOrRefundedOrders로 새 주문 ID가 들어왔는지 확인하거나, 사용자 서버에 토스 IAP webhook이 도착하는 걸 잡아 추적합니다.
진입 빈도와 캐시 전략
| 메서드 | 호출 빈도 | 캐시 가능? |
|---|---|---|
getProductItemList | 진입 시 1회 + 결제 시작 직전 갱신 | 세션 캐시 OK (앱 entry ~ 종료). |
getPendingOrders | 모든 진입 | 캐시 금지 — 매번 새로. |
getCompletedOrRefundedOrders | 진입 시 1회 + "내 주문" 진입 시 | fromTimestamp 기반 delta로 호출. |
getSubscriptionInfo | 구독성 화면 진입 시 | 짧은 캐시(1–5분) OK, 단 토스 환불·갱신 직후엔 무효화 필요. |
흔한 실수 체크리스트
- 클라이언트에 SKU/가격을 하드코딩하고 있지 않나요? 콘솔 가격 변경이 반영 안 됩니다.
- 진입 시
getPendingOrders를 호출하나요? 빠뜨리면 abnormal termination 후 영구 누수. getCompletedOrRefundedOrders에서REFUNDED케이스에서 grant 회수를 하나요? 누락 시 환불 받고도 콘텐츠 사용 가능.- 구독 상태를 사용자 서버 DB에만 의존하지 않나요? 토스에서 일어난 환불·자동갱신 해지가 거기엔 즉시 반영 안 됩니다 —
getSubscriptionInfo로 한 번 더 검증. getCompletedOrRefundedOrders에fromTimestamp없이 호출하나요? 매번 전체 이력을 받게 됩니다.- 네 호출 중 하나의 실패가 다른 흐름을 막고 있나요?
Promise.allSettled또는 개별try/catch로 격리.
환경별 차이
- 실 토스 앱: 네 호출 모두 정상. 결제·환불은 토스 서버에서 즉시 반영.
- devtools mock:
@ait-co/devtools에서 mock 데이터 — 상품 목록, 임의 pending 주문, 임의 status 등을 패널에서 조작. 실제 결제 동기화는 실기에서만. - 외부 브라우저: 모든 호출이 throw. 미니앱 환경 밖에서는 사용 안 함.
관련 API
api/iap—iap네임스페이스 개요.IAP.getProductItemList— 상품 목록.IAP.getPendingOrders— 미처리 주문 조회.IAP.getCompletedOrRefundedOrders— 완료/환불 이력.IAP.getSubscriptionInfo— 구독 상태.IAP.completeProductGrant— fulfillment 완료 통지(복구 시 필요).
관련 가이드
- Guides — IAP 결제 흐름 —
createOneTimePurchaseOrder/createSubscriptionPurchaseOrder와processProductGrant/completeProductGrant의 결제 시퀀스. 이 가이드는 그 결제 흐름 밖의 상태 조회만 다룹니다.
외부 참조
@apps-in-toss/web-framework— 상위 SDK 패키지.