Feature Gating: 중복 컴포넌트 없이 Freemium SaaS를 구축한 방법

발행: (2025년 12월 13일 오전 10:27 GMT+9)
5 min read
원문: Dev.to

Source: Dev.to

문제점

우리는 분석 대시보드에 구독 티어를 추가해야 했습니다. 일부 기능은 무료 플랜에서 업그레이드 프롬프트를 표시하고, 다른 기능은 완전히 숨겨야 했습니다. 하지만 다음은 원하지 않았습니다:

  • 제한이 필요한 모든 컴포넌트를 일일이 수정하기
  • 기존 테스트를 깨뜨리기
  • 컴포넌트가 결제 로직을 알게 만들기
  • “잠긴” 상태에 대한 UI를 중복 작성하기

해결책: 래퍼 컴포넌트

컴포넌트를 수정해 접근 권한을 확인하는 대신, 컴포넌트를 래핑합니다:

// Before: No restrictions

// After: Gated by feature flag

컴포넌트 자체는 순수하게 유지됩니다. 결제 로직은 한 곳에만 존재합니다.

두 가지 게이팅 모드

FeatureGate에 두 가지 동작을 구현했습니다:

모드 1: 완전 숨김 (기본)

// If the user doesn't have access, the component doesn't render.

UI 흐름이 빈틈 없이 자연스럽게 유지됩니다.

모드 2: 업그레이드 프롬프트 표시

// This shows a standardized upgrade card with the plan requirement and CTA.

플랜 요구사항과 CTA가 포함된 표준화된 업그레이드 카드를 보여줍니다.

이를 구동하는 Hook

const { hasAccess, isLoading, planName } = useFeatureAccess('reading_insights');

이 Hook은:

  • 현재 사용자의 플랜을 확인하고
  • 해당 기능에 접근 권한이 있는지 반환하며
  • 로딩 상태를 제공하고
  • 메시지에 사용할 플랜 이름을 제공합니다

페이지‑레벨 게이트 처리

업그레이드가 필요한 전체 페이지의 경우, 라우트 레벨에서 체크를 추가했습니다:

export default function ComparePage() {
  const { hasAccess, isLoading, planName } = useFeatureAccess('document_comparison');

  if (!isLoading && !hasAccess) {
    return (
      <>
        <h3>Document Comparison</h3>
        <p>Currently on: {planName}</p>
        <button onClick={() => router.push('/settings/subscription')}>
          Upgrade to Business
        </button>
      </>
    );
  }

  // Normal page content...
}

초기 반환 패턴을 사용해 잠긴 상태를 최상단에 격리했습니다.

이번 커밋에서 바뀐 점

특히 분석 페이지를 보면:

// Before

// After

각 분석 위젯이 독립적으로 게이트됩니다. 무료 사용자는 기본 메트릭만 보고, 유료 사용자는 전체 세부 정보를 볼 수 있습니다.

테스트상의 이점

가장 큰 장점? 컴포넌트가 테스트 가능하게 유지됩니다:

// Component test – no billing logic
it('renders device breakdown', () => {
  render();
  expect(screen.getByText('Mobile')).toBeInTheDocument();
});

// Integration test – with feature gate
it('hides device breakdown for free users', () => {
  mockUser({ plan: 'free' });
  render(
    <FeatureGate feature="device_analytics">
      <DeviceBreakdown />
    </FeatureGate>
  );
  expect(screen.queryByText('Mobile')).not.toBeInTheDocument();
});

주의점: 초기 반환과 Hook

링크‑디테일 페이지에서 문제가 발생했습니다. 초기 코드:

const { hasAccess } = useFeatureAccess('reading_insights');

// 🚫 This violates Rules of Hooks
if (!hasAccess) {
  return <Redirect />;
}

const someOtherHook = useSomeHook(); // Hook called conditionally!

수정: 모든 Hook을 먼저 호출하고 접근 권한을 체크합니다.

const { hasAccess } = useFeatureAccess('reading_insights');
const someOtherHook = useSomeHook();
const router = useRouter();

// ✅ Now we can return early safely
if (!hasAccess) {
  return <Redirect />;
}

설정은 한 곳에

모든 기능 정의는 하나의 설정 파일에 모여 있습니다:

const FEATURE_ACCESS = {
  free: ['basic_analytics'],
  pro: ['basic_analytics', 'reading_insights', 'device_analytics'],
  business: [
    'basic_analytics',
    'reading_insights',
    'device_analytics',
    'document_comparison',
    'ab_tests',
  ],
};

Pro에 포함될 항목을 바꾸고 싶다면 객체 하나만 수정하면 됩니다.

결과

  • 기능 게이트가 적용된 파일 17개 수정
  • 실제 기능 컴포넌트는 전혀 변경되지 않음
  • 앱 전반에 일관된 업그레이드 프롬프트 제공
  • 기능과 결제 로직이 깔끔하게 분리

래퍼 패턴 덕분에 결제 관련 고민은 격리되고, 컴포넌트는 자신이 해야 할 일에만 집중할 수 있게 되었습니다.

Back to Blog

관련 글

더 보기 »