12개의 Flutter 결제 화면을 동일한 구매 로직으로 구조화한 방법
출처: Dev.to

오늘 paywall_kit 라는 Flutter 패키지를 배포했습니다. 이 패키지는 앱에 바로 끼워 넣을 수 있는 12개의 사전 구축된 페이월 화면을 제공합니다. 이번 글에서는 패키지를 만들면서 가장 많은 시간을 들인 아키텍처 문제 하나를 다루고자 합니다. 비록 패키지를 사용하지 않더라도 흥미로운 사례 연구가 될 것이라 생각합니다.
문제
저는 12가지 서로 다른 페이월 레이아웃—캐러셀, 비교표, 평생 구독 등—을 가지고 있었고, 각각이 다음 다섯 가지 작업을 동일하게 수행해야 했습니다.
- 고유한 레이아웃을 렌더링한다.
- 구매 버튼을 표시하고, 누군가의 구매 API를 호출한다.
- 구매 진행 중에는 스피너를 보여준다.
- 성공, 실패, 사용자 취소를 처리한다.
- 구매 복원 링크를 연결한다 (App Store에서는 필수).
이를 처리할 두 가지 명백한 방법이 있었지만, 모두 좋지 않았습니다.
in_app_purchase플러그인을 각 변형에 하드코딩 → 패키지를 사용하는 모든 앱이 해당 구매 백엔드에 고정됩니다. RevenueCat을 쓰는 사람은 전혀 사용할 수 없습니다.- 구매 동작을 콜백으로 전달 → 스피너와 로딩 상태 관리 코드를 12번 복사·붙여넣기 해야 합니다. 로딩 상태에 대한 버그 수정이 필요할 때마다 12개의 파일을 모두 수정해야 합니다.
저는 UI만 다른 변형을 만들고, 구매 로직은 별도 곳에서 처리하고 싶었습니다.
내가 선택한 방법
두 부분으로 구성했습니다.
- 구매 백엔드용 어댑터 인터페이스
- 자신만의 로딩 상태를 관리하는 상태ful 버튼
어댑터
abstract class PaywallAdapter {
Future buy(PaywallProduct product);
Future restore();
}
패키지에는 두 가지 구현이 기본으로 포함됩니다.
| 어댑터 | 설명 |
|---|---|
PreviewAdapter | 즉시 성공 결과를 반환합니다. 페이월을 디자인할 때나 실제 구매를 onCtaTap 콜백에서 직접 처리하고 싶을 때 유용합니다. |
IapAdapter | package:in_app_purchase 를 감싸며 구매 스트림 라이프사이클을 관리합니다. |
RevenueCat, Stripe, 혹은 자체 서버를 사용하려면 직접 구현하면 됩니다. RevenueCat용 레시피는 doc/ADAPTERS.md 에 있으며 약 30줄 정도입니다.
각 변형에 어댑터를 전달하기 위해 InheritedWidget 을 사용합니다.
class PaywallScope extends InheritedWidget {
final PaywallAdapter adapter;
// ...
static PaywallScope of(BuildContext context) =>
context.dependOnInheritedWidgetOfExactType()!;
}
변형 내부에서의 호출 예시:
Future _onContinue() async {
final navigator = Navigator.of(context);
final adapter = PaywallScope.of(context).adapter;
final result = await adapter.buy(_selected);
if (!mounted) return;
navigator.pop(result);
}
이것이 변형이 구매 백엔드와 상호작용하는 전부입니다. adapter.buy 가 Apple StoreKit, RevenueCat, 혹은 Stripe 웹훅을 호출하는지는 전혀 알 필요가 없습니다.
버튼
두 번째 요소는 주요 CTA 버튼입니다. 모든 변형에 버튼이 하나씩 존재하고, 구매가 진행되는 동안 버튼을 비활성화하고 스피너를 보여줘야 합니다. bool _busy = false 같은 보일러플레이트 코드를 12번 복제하는 대신, 버튼 자체가 상태를 관리합니다.
class PaywallPrimaryButton extends StatefulWidget {
final FutureOr Function() onPressed;
// ...
}
class _PaywallPrimaryButtonState extends State {
bool _busy = false;
Future _handleTap() async {
if (_busy) return;
setState(() => _busy = true);
try {
await widget.onPressed();
} finally {
if (mounted) setState(() => _busy = false);
}
}
// build() 은 _busy 가 true 일 때 라벨을 CircularProgressIndicator 로 교체합니다
}
각 변형은 CTA 핸들러만 버튼에 넘겨주면 되고, 버튼이 나머지를 처리합니다. 12개의 변형에 걸쳐 로딩 상태 로직이 한 번도 복제되지 않습니다.
사용자가 보는 모습
이제 라이브러리를 호출하는 코드는 한 줄입니다.
final result = await PaywallKit.show(
context,
variant: PaywallVariant.lifetime,
products: [monthly, annual, lifetime],
copy: PaywallCopy(
headline: 'Unlock everything',
features: ['No ads', 'Cloud sync', 'AI assistant'],
),
adapter: IapAdapter(),
);
switch (result) {
case PaywallPurchased(:final product):
grant(product);
case PaywallRestored(:final products):
restore(products);
case PaywallDismissed():
break;
case PaywallErrored(:final error):
logError(error);
}
PaywallResult 는 sealed class 이므로 switch 문이 완전합니다 (Dart 3). 백엔드를 교체하려면 파라미터만 바꾸면 됩니다.
12가지 변형
참고용으로 제가 만든 변형들과 각각 가장 적합한 상황을 정리했습니다.
| 변형 | 가장 적합한 상황 |
|---|---|
carousel | 스와이프 가능한 기능 하이라이트가 포함된 온보딩 흐름 |
comparison | 다중 티어 제공 (Free / Pro / Lifetime) |
trialToggle | 무료 체험 토글이 있는 구독 |
lifetime | 한 번 구매로 영구적인 모든 기능 제공 |
promo | 기간 제한 프로모션 |
featureList | 단일 CTA와 함께 간단한 기능 목록 |
grid | 기능 카드 그리드 |
split | 한쪽에 이미지가 있는 분할 화면 레이아웃 |
hero | 오버레이 텍스트가 있는 대형 히어로 이미지 |
minimal | 최소한의 방해 요소만 있는 UI |
compact | 작은 화면용 컴팩트 레이아웃 |
custom | 완전 커스텀 레이아웃 (fallback) |
패키지를 직접 살펴보고 어댑터나 UI 변형을 여러분 앱에 맞게 조정해 보세요!
무료 체험 전환 전략
| 패턴 | 설명 |
|---|---|
lifetime | 인디 스타일 일회성 구매 앱 |
soft | “제한된 기능으로 계속하기” 라는 비차단 유도 |
hard | 건너뛸 수 없는 온보딩 차단형 |
winback | 할인과 함께 이탈 구독자를 재유치 |
family | 패밀리 쉐어링을 지원하는 다인 좌석 티어 |
minimal | Pieter Levels 스타일, 단일 가격·단일 CTA |
storytelling | 후기와 사회적 증거가 포함된 긴 스크롤 |
gamified | 진행 링으로 보상 잠금 프레이밍 |
| `reverseTrial |
