무엇을 만드나요
이 코드랩에서는 Expo Router
앱의 프리미엄 화면을 RevenueCat 구독 뒤에 잠급니다. 무료 사용자는 공개 화면을 둘러볼 수 있지만, 프리미엄
라우트는 사용자가 pro Entitlement를 갖기 전까지 아예 내비게이션 트리에 존재하지 않습니다.
구매하면 그 라우트가 나타나고, 별도의 수동 새로고침 없이 앱이 사용자를 그 화면으로 이동시킵니다.
이 코드랩을 마치면 다음을 갖추게 됩니다.
- 무료 Home 화면과 구독 전용 Analytics 화면을 갖춘 탭 앱.
- RevenueCat Entitlement로 동작하는, Expo Router의
<Stack.Protected>를 이용한 라우트 게이팅. - 잠긴 화면을 요청할 때
react-native-purchases-ui로 띄우는 페이월. - 자동 잠금 해제: 구매가 성공하면
CustomerInfo리스너를 거쳐 가드가 전환됩니다. - 첫 Entitlement 확인이 끝나기 전의 "리다이렉트 깜빡임"을 막아 주는 스플래시 게이트.
사전 조건 & 프로젝트 설정
두 가지가 준비돼 있어야 합니다. Expo 프로젝트와 RevenueCat 프로젝트입니다.
1. SDK 53 이상의 Expo 앱
<Stack.Protected>(Guarded Groups라고도 합니다)는 Expo SDK 53 / Expo Router v5에서 처음 나왔습니다.
새 앱을 만드세요.
npx create-expo-app@latest premium-routes
cd premium-routes
2. development build (Expo Go에서는 구매가 되지 않습니다)
RevenueCat은 네이티브 모듈이 필요하므로 Expo Go에서는 실제 구매가 동작하지 않습니다. 반드시 development build를 써야 합니다. dev client를 추가하고 빌드를 만드세요.
npx expo install expo-dev-client
eas build --profile development --platform ios
# or: eas build --profile development --platform android
react-native-purchases에는 Preview API Mode가 들어 있어서 앱이
Expo Go에서 부팅되기는 합니다. 다만 실제 구매는 development build에서만 동작합니다. 구매 테스트는 기기에서 하세요.
3. Entitlement, Offering, 페이월이 있는 RevenueCat 프로젝트
RevenueCat 대시보드에서 다음을 설정하세요.
- 식별자가
pro인 Entitlement(Product Catalog → Entitlements). - 그 Entitlement에 연결된 상품 하나 이상(App Store Connect / Google Play에서 구성).
- Offering(기본 Offering이며
offerings.current로 접근). - 디자인된 페이월이 나타나도록 그 Offering에 연결한 페이월(구성된 페이월이 없으면 RevenueCatUI가 기본 페이월을 보여 줍니다).
Google Play 쪽을 설정하시나요? 그렇다면 Google Play 서비스 계정 설정 가이드를 참고하세요.
RevenueCat 설치 & 구성
두 패키지를 expo install로 설치해 버전이 Expo SDK와 맞도록 하세요. 함께 설치하면 됩니다. react-native-purchases-ui가 맞는 react-native-purchases 버전을 고정해 줍니다.
npx expo install react-native-purchases react-native-purchases-ui
app.json의 plugins 배열에 넣지 마세요. 네이티브 모듈을 설치한 뒤에는 dev build를 다시 빌드하세요.
SDK는 되도록 이른 시점에 한 번 구성하세요. Expo Router 앱에서는 루트 레이아웃인
app/_layout.tsx가 그 시점입니다. 플랫폼마다 public API 키를 쓰고(iOS 키는
appl_, Google Play 키는 goog_로 시작합니다), 구성 전에 로그 레벨을 설정하세요.
// app/_layout.tsx
import { useEffect } from 'react';
import { Platform } from 'react-native';
import { Stack } from 'expo-router';
import Purchases, { LOG_LEVEL } from 'react-native-purchases';
const API_KEYS = {
apple: 'appl_xxxxxxxxxxxxxxxxxxxxxxxx',
google: 'goog_xxxxxxxxxxxxxxxxxxxxxxxx',
};
export default function RootLayout() {
useEffect(() => {
Purchases.setLogLevel(LOG_LEVEL.VERBOSE); // call before configure
if (Platform.OS === 'ios') {
Purchases.configure({ apiKey: API_KEYS.apple });
} else if (Platform.OS === 'android') {
Purchases.configure({ apiKey: API_KEYS.google });
}
}, []);
return <Stack />;
}
process.env.EXPO_PUBLIC_RC_IOS_KEY).
<Stack />만 렌더링하는 동안에는 이렇게 useEffect에
두어도 괜찮습니다. 하지만 어떤 화면이 실행 시점에 Entitlement를 읽기 시작하면(4단계부터), 그 첫 읽기보다 먼저
실행되도록 configure를 모듈 스코프로 옮깁니다. 최종 버전과 그 이유는 9단계에서 설명합니다.
훅으로 Pro 접근 권한 추적하기
라우트 가드에 필요한 것은 단 하나의 불리언입니다. 지금 사용자가 pro Entitlement를 가지고 있는가?
이 값을 한 번 읽은 다음, 변경을 구독해 계속 동기화하는 작은 훅을 만드세요.
// hooks/useProAccess.ts
import { useEffect, useState } from 'react';
import Purchases, { CustomerInfo } from 'react-native-purchases';
export const ENTITLEMENT_ID = 'pro';
export function useProAccess() {
const [isPro, setIsPro] = useState(false);
const [isReady, setIsReady] = useState(false);
useEffect(() => {
let active = true;
// A single function reference, reused for the initial read,
// the listener, and the cleanup.
const update = (info: CustomerInfo) => {
if (active) {
setIsPro(typeof info.entitlements.active[ENTITLEMENT_ID] !== 'undefined');
}
};
// 1) Read the current state once.
Purchases.getCustomerInfo()
.then((info) => { if (active) update(info); })
.catch((e) => console.warn('getCustomerInfo failed', e))
.finally(() => { if (active) setIsReady(true); });
// 2) Subscribe to future changes (purchases, renewals, restores).
Purchases.addCustomerInfoUpdateListener(update);
// 3) Clean up: stop pending state updates and remove the SAME reference.
return () => {
active = false;
Purchases.removeCustomerInfoUpdateListener(update);
};
}, []);
return { isPro, isReady };
}
void를 반환합니다.
addCustomerInfoUpdateListener는 구독 객체를 반환하지 않으므로,
const sub = addCustomerInfoUpdateListener(...); sub.remove()는 잘못된 코드이며 예외를 던집니다.
같은 함수 참조를 그대로 들고 있다가 removeCustomerInfoUpdateListener에 넘겨야 합니다.
CustomerInfo 리스너 가이드를 참고하세요.
isReady는 그 첫 getCustomerInfo()가 완료되기 전까지 false입니다. 사용자의
Entitlement를 실제로 알기 전에 리다이렉트하는 일을 피하려고 9단계에서 이 값을 씁니다.
라우트 구조 잡기
Expo Router는 파일 기반입니다. app/ 아래의 각 파일이 하나의 라우트입니다. 항상 공개인 화면은
(tabs) 그룹에, 구독 전용 화면은 별도의 (premium) 그룹에 넣어 한 단위로 게이팅할 수 있게
하세요.
app/
_layout.tsx # root Stack: RevenueCat config + the route guard
(tabs)/
_layout.tsx # Tabs: Home + Settings (always visible)
index.tsx # Home (free) with an "Unlock Premium" button
settings.tsx # Settings (free) with a "Restore Purchases" button
(premium)/
_layout.tsx # Stack for premium-only screens
analytics.tsx # premium screen, gated by the "pro" entitlement
hooks/
useProAccess.ts # from Step 4
프리미엄 그룹의 레이아웃은 그냥 평범한 스택입니다. 게이팅은 한 단계 위인 루트 레이아웃에서 일어납니다.
// app/(premium)/_layout.tsx
import { Stack } from 'expo-router';
export default function PremiumLayout() {
return <Stack />;
}
탭 레이아웃은 항상 공개인 두 화면을 선언합니다.
// app/(tabs)/_layout.tsx
import { Tabs } from 'expo-router';
export default function TabsLayout() {
return (
<Tabs>
<Tabs.Screen name="index" options={{ title: 'Home' }} />
<Tabs.Screen name="settings" options={{ title: 'Settings' }} />
</Tabs>
);
}
Stack.Protected로 라우트 게이팅하기
이제 핵심입니다. (premium) 그룹을 <Stack.Protected>로 감싸고 Entitlement 불리언을
guard로 넘기세요. guard가 false이면 그 라우트들은 내비게이션 트리에서 빠지고,
딥링크를 포함해 그 라우트를 열려는 어떤 시도든 첫 번째로 접근 가능한 비보호 화면으로 리다이렉트됩니다.
// app/_layout.tsx (guard added)
import { Stack } from 'expo-router';
import { useProAccess } from '../hooks/useProAccess';
export default function RootLayout() {
const { isPro } = useProAccess();
return (
<Stack>
{/* Always available */}
<Stack.Screen name="(tabs)" options={{ headerShown: false }} />
{/* Only mounted while the user holds the "pro" entitlement */}
<Stack.Protected guard={isPro}>
<Stack.Screen name="(premium)" options={{ headerShown: false }} />
</Stack.Protected>
</Stack>
);
}
(tabs)는
Stack.Protected로 감싸지 않았으므로, /(premium)/analytics로 가는 딥링크를 탭한 무료
사용자는 탭 화면으로 되돌려 보내집니다. 대신 전용 페이월 라우트로 보내고 싶다면, 그 라우트는 보호하지 않은 채
두고 앵커로 지정하세요(unstable_settings 이용).
잠긴 사용자를 페이월로 보내기
무료 사용자에게는 이동할 프리미엄 라우트가 없으니, 들어올 수 있는 분명한 길을 주세요. 페이월을 띄우는 "Unlock
Premium" 버튼입니다. react-native-purchases-ui는 사용자에게 Entitlement가 없을 때만 페이월을 띄울
수 있습니다.
// app/(tabs)/index.tsx
import { useEffect, useState } from 'react';
import { View, Text, Button } from 'react-native';
import { useRouter } from 'expo-router';
import RevenueCatUI, { PAYWALL_RESULT } from 'react-native-purchases-ui';
import { useProAccess, ENTITLEMENT_ID } from '../../hooks/useProAccess';
export default function Home() {
const router = useRouter();
const { isPro } = useProAccess();
const [pendingUnlock, setPendingUnlock] = useState(false);
const unlockPremium = async () => {
// Shows the paywall only if the entitlement is missing.
const result = await RevenueCatUI.presentPaywallIfNeeded({
requiredEntitlementIdentifier: ENTITLEMENT_ID,
});
if (result === PAYWALL_RESULT.PURCHASED || result === PAYWALL_RESULT.RESTORED) {
// Express intent. Do NOT navigate yet: the guard flips asynchronously
// when the listener fires, so the route may not be mounted this tick.
setPendingUnlock(true);
}
};
// Navigate only once isPro has actually committed and the route is mounted.
// This avoids a race where router.push targets a still-protected route and
// gets redirected straight back to the tabs.
useEffect(() => {
if (pendingUnlock && isPro) {
setPendingUnlock(false);
router.replace('/analytics'); // group segment "(premium)" is not part of the URL
}
}, [pendingUnlock, isPro, router]);
return (
<View style={{ flex: 1, justifyContent: 'center', padding: 24, gap: 16 }}>
<Text>Free content for everyone.</Text>
<Button title="Unlock Premium" onPress={unlockPremium} />
</View>
);
}
presentPaywallIfNeeded는 PAYWALL_RESULT 값으로 귀결됩니다.
PURCHASED, RESTORED, CANCELLED, ERROR, 또는
NOT_PRESENTED(사용자가 이미 Entitlement를 가지고 있어 아무것도 띄우지 않았을 때 반환)입니다.
CustomerInfo 리스너를 거쳐 isPro가 전환됩니다. 여기서 곧바로 router.push를
호출하면 Stack.Protected 가드가 아직 다시 렌더링되지 않았을 수 있어, 프리미엄 라우트가 존재하지 않고
사용자는 도로 리다이렉트됩니다. useEffect에서 isPro를 기다리면 라우트가 먼저 마운트됨을
보장합니다. 더 큰 앱에서는 이 상태를 context provider로 공유해, 화면마다 하나씩이 아니라 리스너를 하나만 등록하세요.
<RevenueCatUI.Paywall onDismiss={...} /> 컴포넌트로 화면처럼 렌더링한 뒤 그리로 이동할 수도 있습니다.
"잠금 해제" 버튼이라면 명령형 presentPaywallIfNeeded가 코드가 가장 적은 방법입니다.
구매로 자동 잠금 해제하기
구매 결과를 가드에 수동으로 연결할 필요는 없습니다. 구매(또는 복원)가 성공하면 RevenueCat이 4단계의
CustomerInfo 리스너를 실행하고, 이 리스너가 isPro를 true로 만들며, 그러면
Stack.Protected 가드가 전환되어 프리미엄 라우트가 마운트됩니다. 앞 단계에서 이동하기 전에
isPro가 전환되기를(useEffect 안에서) 기다리므로, 리다이렉트는 반드시 존재하는 라우트에
도달합니다.
페이월 UI 대신 직접 버튼을 만들고 싶다면, 패키지를 바로 구매하세요.
import Purchases from 'react-native-purchases';
import { ENTITLEMENT_ID } from '../../hooks/useProAccess';
async function buyPro() {
const offerings = await Purchases.getOfferings();
const pkg = offerings.current?.availablePackages[0];
if (!pkg) return;
try {
const { customerInfo } = await Purchases.purchasePackage(pkg);
if (typeof customerInfo.entitlements.active[ENTITLEMENT_ID] !== 'undefined') {
// Unlocked. The listener will also pick this up and flip the guard.
}
} catch (e: any) {
if (!e.userCancelled) {
// Show a real error; userCancelled just means the user backed out.
console.warn('Purchase failed', e);
}
}
}
설정 화면에 "Restore Purchases" 버튼을 추가해, 새 기기를 쓰는 사용자가 접근 권한을 되찾을 수 있게 하세요.
// app/(tabs)/settings.tsx
import { View, Button } from 'react-native';
import Purchases from 'react-native-purchases';
export default function Settings() {
const restore = async () => {
try {
await Purchases.restorePurchases();
// No navigation needed: the listener flips the guard if access was restored.
} catch (e) {
console.warn('Restore failed', e);
}
};
return (
<View style={{ flex: 1, justifyContent: 'center', padding: 24 }}>
<Button title="Restore Purchases" onPress={restore} />
</View>
);
}
이 메서드들에 대한 자세한 내용은 React Native에서 구매 복원하기와 상품과 가격 가져오기 가이드를 참고하세요.
리다이렉트 깜빡임 막기
피해야 할 미묘한 버그가 하나 있습니다. isPro는 false로 시작하고, 첫
getCustomerInfo() 호출은 비동기입니다. 게이팅된 스택을 곧바로 렌더링하면, 확인이 끝나기도 전에
유료 사용자가 잠깐 무료 사용자로 취급되어 프리미엄 라우트 밖으로 리다이렉트될 수 있습니다.
해결책은 Expo가 인증에 권장하는 것과 같은 패턴입니다. 첫 Entitlement 확인이 끝날 때까지 스플래시 화면을 띄워
두면 됩니다. 훅의 isReady가 바로 그 용도입니다.
// app/_layout.tsx (final)
import { useEffect } from 'react';
import { Platform } from 'react-native';
import { Stack, SplashScreen } from 'expo-router';
import Purchases, { LOG_LEVEL } from 'react-native-purchases';
import { useProAccess } from '../hooks/useProAccess';
const API_KEYS = {
apple: 'appl_xxxxxxxxxxxxxxxxxxxxxxxx',
google: 'goog_xxxxxxxxxxxxxxxxxxxxxxxx',
};
// Run once at module load, BEFORE any component renders. Configuring here (not
// inside an effect) guarantees the SDK is ready before the hook's first
// getCustomerInfo() call, because a child's effects run before its parent's.
SplashScreen.preventAutoHideAsync();
Purchases.setLogLevel(LOG_LEVEL.VERBOSE);
if (Platform.OS === 'ios') {
Purchases.configure({ apiKey: API_KEYS.apple });
} else if (Platform.OS === 'android') {
Purchases.configure({ apiKey: API_KEYS.google });
}
// When a protected route is blocked, redirect to the tabs.
export const unstable_settings = { anchor: '(tabs)' };
export default function RootLayout() {
const { isPro, isReady } = useProAccess();
useEffect(() => {
if (isReady) SplashScreen.hideAsync();
}, [isReady]);
// Hold the splash until the first entitlement check resolves.
if (!isReady) return null;
return (
<Stack>
<Stack.Screen name="(tabs)" options={{ headerShown: false }} />
<Stack.Protected guard={isPro}>
<Stack.Screen name="(premium)" options={{ headerShown: false }} />
</Stack.Protected>
</Stack>
);
}
Purchases.configure를 레이아웃의 useEffect 안에서 호출하면, 훅의 첫
getCustomerInfo()가 SDK 구성보다 먼저 실행되어 실패할 수 있고, 콜드 스타트에서 유료 사용자가
"무료"로 보이게 됩니다. 모듈 로드 시점에(preventAutoHideAsync 옆에서) 구성하면, 어떤 화면이
렌더링되기 전에 한 번, 동기적으로 실행됩니다.
테스트 & 마무리
전체 흐름 테스트하기
- 기기에서 development build를 실행합니다.
npx expo start --dev-client. - 샌드박스 테스터로 로그인합니다(App Store Connect 샌드박스, 또는 Google Play 라이선스 테스터).
- 무료 사용자로서 프리미엄 라우트에 닿을 수 없는지 확인합니다(그 라우트로 가는 딥링크가 탭 화면으로 리다이렉트됩니다).
- Unlock Premium을 탭하고 샌드박스 구매를 완료한 뒤, 앱이 프리미엄 화면으로 자동으로 이동하는지 지켜봅니다.
- 삭제 후 재설치하고 Restore Purchases를 탭해, 접근 권한이 되돌아오는지 확인합니다.
무엇을 만들었나
<Stack.Protected>로 React Native 라우트를 RevenueCat 구독 뒤에 게이팅하고, 잠긴 사용자에게
페이월을 띄웠으며, CustomerInfo 리스너를 거쳐 접근 권한이 자동으로 전환되게 만들었습니다. 여기에
리다이렉트 깜빡임을 막는 스플래시 게이트까지 더했습니다. 같은 패턴은 여러 등급으로도 확장됩니다.
Stack.Protected 가드를 중첩하거나 Entitlement 불리언을 더 추가하면 됩니다.
이어서 보기
- React Native 인앱 구매 & 구독: SDK 연동 전체를 다루는 코드랩.
- CustomerInfo 업데이트 리스너: 리스너 API를 깊이 다룹니다.
- SDK 구성하기와 CustomerInfo 가져오기: 여기서 쓴 기본 구성 요소.
- Expo Router: Protected routes와 RevenueCat: Displaying Paywalls.