만들 결과물
이 코드랩에서는 Next.js 앱에서
@revenuecat/purchases-js SDK를 사용해, RevenueCat Web Billing으로 웹에서
구독을 판매합니다. 결제 게이트웨이로는 Stripe를 사용합니다. 핵심은 이렇습니다.
RevenueCat이 구독 상태의 단일 진실 공급원(source of truth)이므로, 웹에서 이루어진 구매가
pro entitlement를 해제하고, 웹과 모바일 앱이 같은 App User ID를 사용하는 한 모바일
앱에서도 동일한 entitlement가 활성화됩니다.
이 과정을 마치면 다음을 갖추게 됩니다.
- 연결된 Stripe 계정과 RevenueCat에서 구성한
proentitlement, product, offering. - Web Billing 공개 키로 구성한, Next.js(App Router)의 클라이언트 측 RevenueCat provider.
- package를 나열하고 RevenueCat 호스팅 checkout을 시작하는 가격 페이지.
proentitlement로 잠근 프리미엄 콘텐츠.- 공유 App User ID를 통해 모바일에서도 해제되는 동일한 entitlement.
Web Billing과 Stripe의 관계
RevenueCat Web Billing은 RevenueCat 자체 결제 엔진이며, 그 아래에서 Stripe가 결제 게이트웨이로 동작합니다. product, 가격, offering, entitlement는 RevenueCat 안에서 구성하고, RevenueCat이 checkout UI를 렌더링하며 Stripe를 통해 카드를 처리합니다. RevenueCat은 카드 데이터를 저장하지 않고, 결제는 Stripe가 담당합니다.
RevenueCat에는 Stripe 기반 경로가 두 가지 있습니다. 이 코드랩에서는 첫 번째 경로를 사용합니다.
| 경로 | product가 있는 곳 |
|---|---|
| Web Billing(이 코드랩) | RevenueCat에서 구성 |
| Stripe Billing 연동 | Stripe에서 생성해 RevenueCat으로 가져오기 |
Stripe 연결과 Web Billing 구성
여기까지는 모두 RevenueCat 대시보드에서 진행하며, 아직 코드는 작성하지 않습니다.
1. Stripe 계정 연결하기
RevenueCat 계정 설정에서 Connect Stripe account를 클릭하고 Stripe에 RevenueCat 앱을 설치하세요(수동 API 키가 아니라 OAuth 흐름입니다). Stripe는 프로젝트 소유자만 연결할 수 있습니다.
2. Web Billing 앱 만들기
프로젝트에서 새 앱을 추가하고 Web Billing을 선택한 뒤, 결제 게이트웨이로 연결된 Stripe 계정을 지정하세요.
3. product, offering, entitlement 구성하기
- 식별자가
pro인 entitlement를 만듭니다. - product(예: 월간 구독)와 가격을 만든 뒤
pro에 연결합니다. - 기본 offering(나중에
offerings.current로 사용 가능)의 package에 product를 추가합니다.
4. 공개 API 키 가져오기
Web Billing 앱 설정에서 공개 API 키를 복사하세요. 키는 두 개인데, 프로덕션용은 rcb_로
시작하고 샌드박스용은 rcb_sb_로 시작합니다. 개발에는 샌드박스 키를 사용합니다.
Web SDK 설치와 구성
Web SDK를 설치하세요.
npm install --save @revenuecat/purchases-js
Web SDK는 클라이언트 측에서만 실행됩니다. Next.js App Router에서는
useEffect 안에서 SDK를 구성하는 'use client' provider를 둔다는 뜻입니다.
공개 키는 NEXT_PUBLIC_ 환경 변수에서 읽어 오세요(공개 키이므로 브라우저에 노출되어도
예상된 동작이며 안전합니다). 비밀 sk_ 키는 절대 클라이언트 코드에 두지 마세요.
// app/providers/RevenueCatProvider.tsx
'use client';
import { createContext, useContext, useEffect, useState, type ReactNode } from 'react';
import { Purchases, LogLevel } from '@revenuecat/purchases-js';
import type { CustomerInfo } from '@revenuecat/purchases-js';
const API_KEY = process.env.NEXT_PUBLIC_RC_WEB_BILLING_KEY!; // rcb_sb_... in dev
export const ENTITLEMENT_ID = 'pro';
type RCValue = {
customerInfo: CustomerInfo | null;
isPro: boolean;
isReady: boolean;
refresh: () => Promise<void>;
};
const RevenueCatContext = createContext<RCValue | null>(null);
export function RevenueCatProvider({
appUserId,
children,
}: {
appUserId: string;
children: ReactNode;
}) {
const [customerInfo, setCustomerInfo] = useState<CustomerInfo | null>(null);
const [isReady, setIsReady] = useState(false);
// Re-fetch after a purchase (the Web SDK has no update listener).
const refresh = async () => {
try {
setCustomerInfo(await Purchases.getSharedInstance().getCustomerInfo());
} catch (e) {
console.warn('getCustomerInfo failed', e);
}
};
useEffect(() => {
let active = true; // ignore results from a previous appUserId / unmount
setIsReady(false);
(async () => {
Purchases.setLogLevel(LogLevel.Verbose);
// Configure once. On later sign-ins, switch users. Both give us CustomerInfo.
let info: CustomerInfo;
if (!Purchases.isConfigured()) {
Purchases.configure({ apiKey: API_KEY, appUserId });
info = await Purchases.getSharedInstance().getCustomerInfo();
} else {
info = await Purchases.getSharedInstance().changeUser(appUserId);
}
if (active) setCustomerInfo(info);
})()
.catch((e) => console.warn('RevenueCat setup failed', e))
.finally(() => { if (active) setIsReady(true); });
return () => { active = false; };
}, [appUserId]);
const isPro = !!customerInfo && ENTITLEMENT_ID in customerInfo.entitlements.active;
return (
<RevenueCatContext.Provider value={{ customerInfo, isPro, isReady, refresh }}>
{children}
</RevenueCatContext.Provider>
);
}
export function useRevenueCat() {
const ctx = useContext(RevenueCatContext);
if (!ctx) throw new Error('useRevenueCat must be used inside RevenueCatProvider');
return ctx;
}
configure에는
appUserId가 필요합니다. 암묵적인 익명 모드는 없습니다(Purchases.generateRevenueCatAnonymousAppUserId()로
하나 생성할 수는 있지만, 크로스 플랫폼에서는 실제 공유 ID가 필요하며 그것이 다음 단계입니다).
configure를 두 번 호출하면 예외가
발생하므로 isConfigured() 가드가 중요합니다(React StrictMode는 개발 중에 effect를 두 번
실행합니다). 정리(cleanup)의 active 플래그가 이전 appUserId의 결과를
버리므로, 사용자를 빠르게 전환해도 오래된 entitlement 상태가 남지 않습니다.
사용자 식별
웹 구매가 모바일에서 작동하게 하는 단계가 바로 이것입니다. RevenueCat은 같은 App User ID로 로그인한 사람을 플랫폼에 상관없이 동일한 고객으로 취급합니다. 따라서 여러분의 안정적인 사용자 ID(Firebase나 Auth0 같은 인증 또는 ID 공급자에서 가져온 것)를 provider에 전달하고, 모바일 앱에서도 같은 ID를 사용하세요.
// app/layout.tsx
import { RevenueCatProvider } from './providers/RevenueCatProvider';
import { getCurrentUserId } from '../lib/auth'; // your auth/session
export default async function RootLayout({ children }: { children: React.ReactNode }) {
// The SAME id you pass to Purchases.logIn(...) in your mobile app.
const appUserId = await getCurrentUserId();
return (
<html lang="en">
<body>
<RevenueCatProvider appUserId={appUserId}>{children}</RevenueCatProvider>
</body>
</html>
);
}
$RCAnonymousID: 접두사가 붙습니다), 크로스 플랫폼 동작을 해제하는 것은 바로 공유된
식별 ID입니다.
상품과 가격 표시
현재 offering을 가져와 각 package의 현지화된 가격을 읽어 옵니다. 웹에서 product는
pkg.webBillingProduct에 있고, 표시 가격은
webBillingProduct.price.formattedPrice입니다(이미 통화와 함께 형식이 지정되어 있으므로
절대 하드코딩하지 마세요).
// app/hooks/usePackages.ts
'use client';
import { useEffect, useState } from 'react';
import { Purchases } from '@revenuecat/purchases-js';
import type { Package } from '@revenuecat/purchases-js';
export function usePackages() {
const [packages, setPackages] = useState<Package[]>([]);
useEffect(() => {
Purchases.getSharedInstance()
.getOfferings()
.then((offerings) => {
if (offerings.current) {
setPackages(offerings.current.availablePackages);
}
})
.catch((e) => console.warn('getOfferings failed', e));
}, []);
return packages;
}
// In a component:
// const packages = usePackages();
// packages.map((pkg) => (
// <li key={pkg.identifier}>
// {pkg.webBillingProduct.title}: {pkg.webBillingProduct.price.formattedPrice}
// </li>
// ));
구매 실행
purchase({ rcPackage })를 호출하세요. RevenueCat이 호스팅 checkout(기본은 모달, 또는
여러분이 전달한 요소에 마운트)을 표시하고, Stripe를 통해 결제를 수집하며, 갱신된
CustomerInfo로 resolve됩니다. 사용자가 checkout을 닫은 경우(UserCancelledError)는
실제 오류와 구분해서 처리하세요.
// app/components/Paywall.tsx
'use client';
import { Purchases, PurchasesError, ErrorCode } from '@revenuecat/purchases-js';
import type { Package } from '@revenuecat/purchases-js';
import { useRevenueCat, ENTITLEMENT_ID } from '../providers/RevenueCatProvider';
import { usePackages } from '../hooks/usePackages';
export function Paywall() {
const { isPro, refresh } = useRevenueCat();
const packages = usePackages();
const buy = async (pkg: Package) => {
try {
const { customerInfo } = await Purchases.getSharedInstance().purchase({ rcPackage: pkg });
if (ENTITLEMENT_ID in customerInfo.entitlements.active) {
await refresh(); // sync the provider so the UI updates
}
} catch (e) {
if (e instanceof PurchasesError && e.errorCode === ErrorCode.UserCancelledError) {
return; // the user closed the checkout, not an error to surface
}
console.error('Purchase failed', e);
}
};
if (isPro) return <p>You have Pro access. Thanks!</p>;
return (
<ul>
{packages.map((pkg) => (
<li key={pkg.identifier}>
<button onClick={() => buy(pkg)}>
Subscribe for {pkg.webBillingProduct.price.formattedPrice}
</button>
</li>
))}
</ul>
);
}
addCustomerInfoUpdateListener가 없습니다. purchase()가 반환하는
customerInfo를 사용하거나, getCustomerInfo()로 다시 가져오세요(여기서는
refresh()가 그 역할을 합니다).
Entitlement 확인과 콘텐츠 잠금
웹에서 entitlements.active는 entitlement ID를 키로 하는 일반 객체이므로, 문서에 안내된
확인 방식은 in 연산자를 사용합니다. 서버와 클라이언트의 하이드레이션(hydration) 불일치를
피하기 위해(서버 렌더링에는 entitlement가 없습니다), 프리미엄 UI는 entitlement와 "로드 완료" 플래그
두 가지 모두로 잠그세요.
// app/components/PremiumDashboard.tsx
'use client';
import { useRevenueCat } from '../providers/RevenueCatProvider';
import { Paywall } from './Paywall';
export function PremiumDashboard() {
const { isPro, isReady } = useRevenueCat();
if (!isReady) return <p>Loading...</p>; // avoid hydration mismatch
if (!isPro) return <Paywall />; // not subscribed: show the paywall
return <h1>Welcome to the premium dashboard</h1>;
}
provider 안의 isPro 값은 ENTITLEMENT_ID in customerInfo.entitlements.active로
계산됩니다. 일회성 불리언 확인이 필요하면
await Purchases.getSharedInstance().isEntitledTo('pro')를 호출할 수도 있습니다.
'pro' in customerInfo.entitlements.active를
사용합니다. 모바일 SDK는 typeof customerInfo.entitlements.active['pro'] !== 'undefined'를
사용합니다. 둘 다 묻는 내용은 같으니, 플랫폼별로 헷갈리지 않게 구분해서 쓰세요.
모바일에서 동일한 entitlement 해제
여기가 핵심입니다. 모바일 앱은 구매가 웹에서 일어났다는 사실을 알 필요가 없습니다. 같은 App User ID로
로그인하기 때문에, RevenueCat은 이미 pro entitlement를 활성 상태로 보고합니다. React Native에서는
다음과 같습니다.
// Mobile app (react-native-purchases), same RevenueCat project
import Purchases from 'react-native-purchases';
// Sign in with the SAME id used on the web.
await Purchases.logIn(appUserId);
const info = await Purchases.getCustomerInfo();
const isPro = typeof info.entitlements.active['pro'] !== 'undefined';
// isPro is true here if the user subscribed on the web. No restore needed.
이것이 웹에서 RevenueCat을 쓰는 진짜 이유입니다. 두 번째 entitlement 시스템을 만들지 않았습니다. 웹 구매,
모바일 구매, 갱신, 취소가 모두 하나의 고객과 하나의 pro entitlement로 모입니다.
React Native 쪽이 먼저 필요하신가요? 그렇다면 React Native 코드랩과 사용자 식별 가이드를 참고하세요.
샌드박스 테스트와 정리
실제 돈을 쓰지 않고 구매 테스트하기
- 개발에서는 샌드박스 키(
rcb_sb_...)로 SDK를 구성합니다. 자동으로 Stripe 테스트 모드를 사용합니다. - 앱을 실행하고 로그인한 뒤(안정적인 App User ID를 갖기 위해) paywall을 엽니다.
- subscribe를 클릭하고 Stripe 테스트 카드로 호스팅 checkout을 완료합니다(Stripe의 표준 테스트 Visa는
4242 4242 4242 4242이고, 만료일은 미래의 임의 날짜, CVC는 임의 값입니다). - UI가 "You have Pro access"로 바뀌는지 확인한 다음, 같은 사용자가 모바일 앱에서
pro를 표시하는지 확인합니다.
rcb_sb_ 키는 프로덕션에 절대 배포하지 마세요. 그리고 샌드박스 checkout
URL은 실제 entitlement를 해제할 수 있으므로 공유하지 마세요.
여러분이 만든 것
Stripe를 RevenueCat Web Billing에 연결하고, pro entitlement를 구성하고,
@revenuecat/purchases-js로 Next.js에서 구독을 판매하고, entitlement로 콘텐츠를 잠그고,
공유 App User ID를 통해 모바일에서도 동일한 entitlement가 켜지게 했습니다.
계속 나아가기
- Web SDK 퀵스타트: purchases-js 기본을 더 짧게 정리한 참고 자료.
- CustomerInfo 가져오기와 사용자 식별하기: 여기서 사용한 구성 요소.
- RevenueCat: Web Billing 개요와 Web SDK 레퍼런스.
- RevenueCat: Stripe 계정 연결하기.