무엇을 만드나요

이 코드랩에서는 Expo Router 앱의 프리미엄 화면을 RevenueCat 구독 뒤에 잠급니다. 무료 사용자는 공개 화면을 둘러볼 수 있지만, 프리미엄 라우트는 사용자가 pro Entitlement를 갖기 전까지 아예 내비게이션 트리에 존재하지 않습니다. 구매하면 그 라우트가 나타나고, 별도의 수동 새로고침 없이 앱이 사용자를 그 화면으로 이동시킵니다.

이 코드랩을 마치면 다음을 갖추게 됩니다.

  • 무료 Home 화면과 구독 전용 Analytics 화면을 갖춘 탭 앱.
  • RevenueCat Entitlement로 동작하는, Expo Router의 <Stack.Protected>를 이용한 라우트 게이팅.
  • 잠긴 화면을 요청할 때 react-native-purchases-ui로 띄우는 페이월.
  • 자동 잠금 해제: 구매가 성공하면 CustomerInfo 리스너를 거쳐 가드가 전환됩니다.
  • 첫 Entitlement 확인이 끝나기 전의 "리다이렉트 깜빡임"을 막아 주는 스플래시 게이트.
이 패턴이 왜 중요한가. 인증 역할이 아니라 RevenueCat Entitlement로 게이팅하면, 기기와 재설치를 넘어 구독을 따라 접근 권한이 자동으로 유지됩니다. RevenueCat이 구독 상태의 단일 진실 소스이기 때문입니다.
RevenueCat SDK가 처음이신가요? SDK 구성하기CustomerInfo 가져오기 가이드를 먼저 훑어본 뒤 여기로 돌아오세요.

사전 조건 & 프로젝트 설정

두 가지가 준비돼 있어야 합니다. Expo 프로젝트와 RevenueCat 프로젝트입니다.

1. SDK 53 이상의 Expo 앱

<Stack.Protected>(Guarded Groups라고도 합니다)는 Expo SDK 53 / Expo Router v5에서 처음 나왔습니다. 새 앱을 만드세요.

bash
npx create-expo-app@latest premium-routes
cd premium-routes

2. development build (Expo Go에서는 구매가 되지 않습니다)

RevenueCat은 네이티브 모듈이 필요하므로 Expo Go에서는 실제 구매가 동작하지 않습니다. 반드시 development build를 써야 합니다. dev client를 추가하고 빌드를 만드세요.

bash
npx expo install expo-dev-client
eas build --profile development --platform ios
# or: eas build --profile development --platform android
Expo Go 참고. react-native-purchases에는 Preview API Mode가 들어 있어서 앱이 Expo Go에서 부팅되기는 합니다. 다만 실제 구매는 development build에서만 동작합니다. 구매 테스트는 기기에서 하세요.

3. Entitlement, Offering, 페이월이 있는 RevenueCat 프로젝트

RevenueCat 대시보드에서 다음을 설정하세요.

  • 식별자가 proEntitlement(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 버전을 고정해 줍니다.

bash
npx expo install react-native-purchases react-native-purchases-ui
config plugin은 필요 없습니다. 이 패키지들은 Expo config plugin을 제공하지 않으므로 app.jsonplugins 배열에 넣지 마세요. 네이티브 모듈을 설치한 뒤에는 dev build를 다시 빌드하세요.

SDK는 되도록 이른 시점에 한 번 구성하세요. Expo Router 앱에서는 루트 레이아웃인 app/_layout.tsx가 그 시점입니다. 플랫폼마다 public API 키를 쓰고(iOS 키는 appl_, Google Play 키는 goog_로 시작합니다), 구성 전에 로그 레벨을 설정하세요.

tsx
// 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를 가지고 있는가? 이 값을 한 번 읽은 다음, 변경을 구독해 계속 동기화하는 작은 훅을 만드세요.

tsx
// 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) 그룹에 넣어 한 단위로 게이팅할 수 있게 하세요.

text
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

프리미엄 그룹의 레이아웃은 그냥 평범한 스택입니다. 게이팅은 한 단계 위인 루트 레이아웃에서 일어납니다.

tsx
// app/(premium)/_layout.tsx
import { Stack } from 'expo-router';

export default function PremiumLayout() {
  return <Stack />;
}

탭 레이아웃은 항상 공개인 두 화면을 선언합니다.

tsx
// 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로 넘기세요. guardfalse이면 그 라우트들은 내비게이션 트리에서 빠지고, 딥링크를 포함해 그 라우트를 열려는 어떤 시도든 첫 번째로 접근 가능한 비보호 화면으로 리다이렉트됩니다.

tsx
// 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 이용).
클라이언트 측 전용입니다. 보호 라우트는 보안 경계가 아니라 내비게이션 편의 기능입니다. 중요한 작업이라면 언제나 서버에서, 또는 RevenueCat Entitlement로 프리미엄 접근을 강제하세요.

잠긴 사용자를 페이월로 보내기

무료 사용자에게는 이동할 프리미엄 라우트가 없으니, 들어올 수 있는 분명한 길을 주세요. 페이월을 띄우는 "Unlock Premium" 버튼입니다. react-native-purchases-ui는 사용자에게 Entitlement가 없을 때만 페이월을 띄울 수 있습니다.

tsx
// 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>
  );
}

presentPaywallIfNeededPAYWALL_RESULT 값으로 귀결됩니다. PURCHASED, RESTORED, CANCELLED, ERROR, 또는 NOT_PRESENTED(사용자가 이미 Entitlement를 가지고 있어 아무것도 띄우지 않았을 때 반환)입니다.

await 직후가 아니라 effect에서 이동하는 이유는? 구매가 성공하면 비동기 CustomerInfo 리스너를 거쳐 isPro가 전환됩니다. 여기서 곧바로 router.push를 호출하면 Stack.Protected 가드가 아직 다시 렌더링되지 않았을 수 있어, 프리미엄 라우트가 존재하지 않고 사용자는 도로 리다이렉트됩니다. useEffect에서 isPro를 기다리면 라우트가 먼저 마운트됨을 보장합니다. 더 큰 앱에서는 이 상태를 context provider로 공유해, 화면마다 하나씩이 아니라 리스너를 하나만 등록하세요.
전면 페이월 라우트가 더 좋으신가요? 페이월을 <RevenueCatUI.Paywall onDismiss={...} /> 컴포넌트로 화면처럼 렌더링한 뒤 그리로 이동할 수도 있습니다. "잠금 해제" 버튼이라면 명령형 presentPaywallIfNeeded가 코드가 가장 적은 방법입니다.

구매로 자동 잠금 해제하기

구매 결과를 가드에 수동으로 연결할 필요는 없습니다. 구매(또는 복원)가 성공하면 RevenueCat이 4단계의 CustomerInfo 리스너를 실행하고, 이 리스너가 isProtrue로 만들며, 그러면 Stack.Protected 가드가 전환되어 프리미엄 라우트가 마운트됩니다. 앞 단계에서 이동하기 전에 isPro가 전환되기를(useEffect 안에서) 기다리므로, 리다이렉트는 반드시 존재하는 라우트에 도달합니다.

페이월 UI 대신 직접 버튼을 만들고 싶다면, 패키지를 바로 구매하세요.

tsx
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" 버튼을 추가해, 새 기기를 쓰는 사용자가 접근 권한을 되찾을 수 있게 하세요.

tsx
// 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에서 구매 복원하기상품과 가격 가져오기 가이드를 참고하세요.

리다이렉트 깜빡임 막기

피해야 할 미묘한 버그가 하나 있습니다. isProfalse로 시작하고, 첫 getCustomerInfo() 호출은 비동기입니다. 게이팅된 스택을 곧바로 렌더링하면, 확인이 끝나기도 전에 유료 사용자가 잠깐 무료 사용자로 취급되어 프리미엄 라우트 밖으로 리다이렉트될 수 있습니다.

해결책은 Expo가 인증에 권장하는 것과 같은 패턴입니다. 첫 Entitlement 확인이 끝날 때까지 스플래시 화면을 띄워 두면 됩니다. 훅의 isReady가 바로 그 용도입니다.

tsx
// 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>
  );
}
모듈 스코프에서 구성하는 이유. React에서는 자식의 effect가 부모의 effect보다 먼저 실행됩니다. Purchases.configure를 레이아웃의 useEffect 안에서 호출하면, 훅의 첫 getCustomerInfo()가 SDK 구성보다 먼저 실행되어 실패할 수 있고, 콜드 스타트에서 유료 사용자가 "무료"로 보이게 됩니다. 모듈 로드 시점에(preventAutoHideAsync 옆에서) 구성하면, 어떤 화면이 렌더링되기 전에 한 번, 동기적으로 실행됩니다.

테스트 & 마무리

전체 흐름 테스트하기

  1. 기기에서 development build를 실행합니다. npx expo start --dev-client.
  2. 샌드박스 테스터로 로그인합니다(App Store Connect 샌드박스, 또는 Google Play 라이선스 테스터).
  3. 무료 사용자로서 프리미엄 라우트에 닿을 수 없는지 확인합니다(그 라우트로 가는 딥링크가 탭 화면으로 리다이렉트됩니다).
  4. Unlock Premium을 탭하고 샌드박스 구매를 완료한 뒤, 앱이 프리미엄 화면으로 자동으로 이동하는지 지켜봅니다.
  5. 삭제 후 재설치하고 Restore Purchases를 탭해, 접근 권한이 되돌아오는지 확인합니다.
샌드박스는 느립니다. Apple 샌드박스는 구매를 완료하는 데 15초 이상 걸릴 수 있습니다. 정상입니다. Offering이 비어서 돌아오면, 상품이 Entitlement와 Offering에 연결됐는지, 그리고 development build(Expo Go가 아님)로 실행 중인지 다시 확인하세요.

무엇을 만들었나

<Stack.Protected>로 React Native 라우트를 RevenueCat 구독 뒤에 게이팅하고, 잠긴 사용자에게 페이월을 띄웠으며, CustomerInfo 리스너를 거쳐 접근 권한이 자동으로 전환되게 만들었습니다. 여기에 리다이렉트 깜빡임을 막는 스플래시 게이트까지 더했습니다. 같은 패턴은 여러 등급으로도 확장됩니다. Stack.Protected 가드를 중첩하거나 Entitlement 불리언을 더 추가하면 됩니다.

이어서 보기