12개의 Flutter 결제 화면을 동일한 구매 로직으로 구조화한 방법

발행: (2026년 5월 29일 AM 12:28 GMT+9)
8 분 소요
원문: Dev.to

출처: Dev.to

“12개의 Flutter 페이월 스크린을 동일한 구매 로직을 공유하도록 구조화한 방법” 표지 이미지

jay limbani

오늘 paywall_kit 라는 Flutter 패키지를 배포했습니다. 이 패키지는 앱에 바로 끼워 넣을 수 있는 12개의 사전 구축된 페이월 화면을 제공합니다. 이번 글에서는 패키지를 만들면서 가장 많은 시간을 들인 아키텍처 문제 하나를 다루고자 합니다. 비록 패키지를 사용하지 않더라도 흥미로운 사례 연구가 될 것이라 생각합니다.

문제

저는 12가지 서로 다른 페이월 레이아웃—캐러셀, 비교표, 평생 구독 등—을 가지고 있었고, 각각이 다음 다섯 가지 작업을 동일하게 수행해야 했습니다.

  1. 고유한 레이아웃을 렌더링한다.
  2. 구매 버튼을 표시하고, 누군가의 구매 API를 호출한다.
  3. 구매 진행 중에는 스피너를 보여준다.
  4. 성공, 실패, 사용자 취소를 처리한다.
  5. 구매 복원 링크를 연결한다 (App Store에서는 필수).

이를 처리할 두 가지 명백한 방법이 있었지만, 모두 좋지 않았습니다.

  • in_app_purchase 플러그인을 각 변형에 하드코딩 → 패키지를 사용하는 모든 앱이 해당 구매 백엔드에 고정됩니다. RevenueCat을 쓰는 사람은 전혀 사용할 수 없습니다.
  • 구매 동작을 콜백으로 전달 → 스피너와 로딩 상태 관리 코드를 12번 복사·붙여넣기 해야 합니다. 로딩 상태에 대한 버그 수정이 필요할 때마다 12개의 파일을 모두 수정해야 합니다.

저는 UI만 다른 변형을 만들고, 구매 로직은 별도 곳에서 처리하고 싶었습니다.

내가 선택한 방법

두 부분으로 구성했습니다.

  1. 구매 백엔드용 어댑터 인터페이스
  2. 자신만의 로딩 상태를 관리하는 상태ful 버튼

어댑터

abstract class PaywallAdapter {
  Future buy(PaywallProduct product);
  Future restore();
}

패키지에는 두 가지 구현이 기본으로 포함됩니다.

어댑터설명
PreviewAdapter즉시 성공 결과를 반환합니다. 페이월을 디자인할 때나 실제 구매를 onCtaTap 콜백에서 직접 처리하고 싶을 때 유용합니다.
IapAdapterpackage: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패밀리 쉐어링을 지원하는 다인 좌석 티어
minimalPieter Levels 스타일, 단일 가격·단일 CTA
storytelling후기와 사회적 증거가 포함된 긴 스크롤
gamified진행 링으로 보상 잠금 프레이밍
`reverseTrial
0 조회
Back to Blog

관련 글

더 보기 »