Feature Gating: 중복 컴포넌트 없이 Freemium SaaS를 구축한 방법
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개 수정
- 실제 기능 컴포넌트는 전혀 변경되지 않음
- 앱 전반에 일관된 업그레이드 프롬프트 제공
- 기능과 결제 로직이 깔끔하게 분리
래퍼 패턴 덕분에 결제 관련 고민은 격리되고, 컴포넌트는 자신이 해야 할 일에만 집중할 수 있게 되었습니다.