๐Ÿ“˜ Paywall SDK โ€“ ์‚ฌ์šฉ ์„ค๋ช…์„œ A๋ถ€ํ„ฐ Z๊นŒ์ง€ (์ƒ˜ํ”Œ JSON ํฌํ•จ)

๋ฐœํ–‰: (2026๋…„ 1์›” 9์ผ ์˜คํ›„ 01:24 GMT+9)
6 min read
์›๋ฌธ: Dev.to

Source: Dev.to

๋ฒˆ์—ญํ•  ํ…์ŠคํŠธ๋ฅผ ์ œ๊ณตํ•ด ์ฃผ์‹œ๋ฉด ํ•œ๊ตญ์–ด๋กœ ๋ฒˆ์—ญํ•ด ๋“œ๋ฆฌ๊ฒ ์Šต๋‹ˆ๋‹ค.

Paywall SDK โ€“ ๋น ๋ฅธ ์ฐธ๊ณ 

๋Œ€์ƒ: Androidโ€ฏKotlin (๋‚ด๋ถ€ ๋ฐ ํ”„๋กœ๋•์…˜ ์•ฑ)
๋ฒ”์œ„:

  • JSON ๊ตฌ์„ฑ ๊ตฌ์กฐ
  • ํŒŒ์‹ฑ ๋ฐ ๋ฐ์ดํ„ฐ ์ œ๊ณต
  • UI ๋ Œ๋”๋ง (Fragment / Dialog)
  • ๊ตฌ๋งค, ํŒ๋งค ๋ฐ ์œ ์ง€ ํ๋ฆ„
  • ์ „ํ™˜ ์ตœ์ ํ™” ๋ฒ ์ŠคํŠธ ํ”„๋ž™ํ‹ฐ์Šค

์•„ํ‚คํ…์ฒ˜ ํ๋ฆ„

Remote Config / API / Local JSON
        โ†“
   PaywallDataProvider
        โ†“
    PaywallRepository
        โ†“
BasePaywallFragment / BasePaywallDialog
        โ†“
   Paywall UI (Full / One / Dialog)

์˜ˆ์‹œ JSON ๊ตฌ์„ฑ

{
  "uiType": "paywall_full_01",
  "ctaId": 0,
  "enable": true,
  "timesOpen": 0,
  "delayButtonX": 2,
  "timesFree": "1,2,9,10,14",
  "showAdsClose": false,
  "packages": [
    {
      "packageId": "test.com.year.discount",
      "packageIdOrigin": "test.com.yearly",
      "packageType": "yearly",
      "ctaId": 1,
      "isPopular": false,
      "isSelected": false
    },
    {
      "packageId": "test.com.month.discount",
      "packageIdOrigin": "test.com.monthly",
      "packageType": "weekly",
      "ctaId": 3,
      "isPopular": false,
      "isSelected": false
    },
    {
      "packageId": "test.com.week.offer",
      "packageType": "weekly",
      "ctaId": 2,
      "isPopular": true,
      "isSelected": true
    }
  ]
}

ํ•„๋“œ ์ •์˜

ํŽ˜์ด์›”โ€‘์ˆ˜์ค€ ํ•„๋“œ

FieldMeaning
uiTypeUI ๋งคํ•‘ (์˜ˆ: paywall_full_01, paywall_dialog_01)
ctaIdํŒจํ‚ค์ง€๋ฅผ ์„ ํƒํ•˜์ง€ ์•Š์•˜์„ ๋•Œ ๊ธฐ๋ณธ CTA
enableํŽ˜์ด์›”์„ ์ผœ๊ธฐ/๋„๊ธฐ
timesOpenํ‘œ์‹œ๋˜๊ธฐ ์ „ ์•ฑ ์‹คํ–‰ ํšŸ์ˆ˜ (0 = ํ•ญ์ƒ)
delayButtonX๋‹ซ๊ธฐ ๋ฒ„ํŠผ์ด ๋‚˜ํƒ€๋‚˜๊ธฐ ์ „ ์ง€์—ฐ ์‹œ๊ฐ„(์ดˆ)
timesFree๋ฌด๋ฃŒ์ธ ์‹คํ–‰ ํšŸ์ˆ˜์˜ ์‰ผํ‘œ ๊ตฌ๋ถ„ ๋ชฉ๋ก
showAdsClose๊ด‘๊ณ ๋ฅผ ํ†ตํ•ด ํŽ˜์ด์›” ๋‹ซ๊ธฐ ํ—ˆ์šฉ

ํŒจํ‚ค์ง€โ€‘์ˆ˜์ค€ ํ•„๋“œ

FieldMeaning
packageIdํŒ๋งค/ํ™œ์„ฑํ™”๋œ SKU
packageIdOrigin์›๋ณธ SKU (ํ• ์ธ ๊ณ„์‚ฐ์— ์‚ฌ์šฉ)
packageTypeweekly / monthly / yearly / lifetime
ctaIdํ•ด๋‹น ํŒจํ‚ค์ง€ ์ „์šฉ CTA
isPopularโ€œPopularโ€ ๋ฐฐ์ง€ ํ‘œ์‹œ
isSelectedํŽ˜์ด์›”์ด ์—ด๋ฆด ๋•Œ ์ž๋™ ์„ ํƒ

ํŒŒ์‹ฑ ๋„์šฐ๋ฏธ

fun String.parsePaywall(): PaywallData? {
    if (isBlank()) return null
    return Json {
        ignoreUnknownKeys = true
        explicitNulls = false
    }.decodeFromString(this)
}

JSON์„ ์œ„ํ•œ ์†Œ์Šค

  • Firebase ์›๊ฒฉ ๊ตฌ์„ฑ
  • ๋ฐฑ์—”๋“œ API
  • ๋กœ์ปฌ ํŒŒ์ผ

๋ฆฌํฌ์ง€ํ† ๋ฆฌ ์‚ฌ์šฉ๋ฒ•

override fun getPaywallConfig(placementType: String): PaywallData? {
    return when (placementType) {
        PAYWALL_HOME_ICON -> homeIconJson.parsePaywall()
        PAYWALL_SETTING   -> settingJson.parsePaywall()
        else -> null
    }
}

override fun getRetentionPlacement(current: String): String? {
    return when (current) {
        PAYWALL_HOME_ICON -> PAYWALL_HOME_ICON_SALE
        else -> null
    }
}
paywallRepository.initialize(isDebugMode = true)

if (paywallRepository.shouldShow(PAYWALL_HOME_ICON)) {
    showPaywall()
}

val data = paywallRepository.getPaywallData(PAYWALL_HOME_ICON)
val packages = paywallRepository.getPackages(data)

์ „์ฒด ํ™”๋ฉด ํŽ˜์ด์›” ํ”„๋ž˜๊ทธ๋จผํŠธ (์˜ˆ์‹œ)

class PaywallFull01Fragment :
    BasePaywallFragment(),
    PackageSubCallback {

    override val bindingInflater = FragmentPaywallFull01Binding::inflate

    private val adapter by lazy { PackageSubAdapter(this) }

    override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
        super.onViewCreated(view, savedInstanceState)

        binding.rcvPackage.apply {
            layoutManager = GridLayoutManager(context, 3)
            adapter = this@PaywallFull01Fragment.adapter
        }

        binding.btnContinue.setOnClickListener {
            adapter.getPaywallProduct()?.let { buy(it.product) }
        }

        binding.imgClose.setOnClickListener { close() }
        binding.txtPolicy.setOnClickListener { openPolicy() }
        binding.txtTerm.setOnClickListener { openTerm() }
    }

    override fun bindData(config: PaywallData, packages: List) {
        adapter.submitData(packages)
    }

    override fun onSelectPackage(paywallProduct: PaywallProduct) {
        binding.btnContinue.setText(
            PaywallCtaUtils.getCtaStringResId(paywallProduct)
        )

        val descRes = PaywallDescUtils.getDescResIds(
            paywallProduct,
            PaywallDescUtils.LayoutType.FULL_SCREEN
        )

        if (descRes.descRes != 0) {
            binding.txtDescription.text = PaywallDescUtils.formatDescription(
                requireContext(),
                descRes.descRes,
                paywallProduct
            )
        }
    }
}

๋‹จ์ผโ€‘ํŒจํ‚ค์ง€ ํŽ˜์ด์›” ๋‹ค์ด์–ผ๋กœ๊ทธ (์˜ˆ์‹œ)

class PaywallDialog01 :
    BasePaywallDialog() {

    override val bindingInflater = DialogPaywall01Binding::inflate
    private var currentItem: PaywallProduct? = null

    override fun bindData(config: PaywallData, packages: List) {
        val item = packages.firstOrNull() ?: return
        currentItem = item

        binding.txtPrice.text = item.product.priceText
        binding.btnContinue.setText(
            PaywallCtaUtils.getCtaStringResId(item)
        )

        val descRes = PaywallDescUtils.getDescResIds(
            item,
            PaywallDescUtils.LayoutType.DIALOG_1_ITEM
        )

        if (descRes.descRes != 0) {
            binding.txtDescription.text = PaywallDescUtils.formatDescription(
                requireContext(),
                descRes.descRes,
                item
            )
        }

        binding.btnContinue.setOnClickListener { currentItem?.let { buy(it) } }
        binding.imgClose.setOnClickListener { close() }
    }
}

์‚ฌ์šฉ ์‚ฌ๋ก€ โ€“ ์˜จ๋ณด๋”ฉ, ํ•˜๋“œ ํŽ˜์ด์›”, ๊ธฐ๋Šฅ ์ž ๊ธˆ, ์œ ์ง€, ์„ธ์ผโ€‘์˜คํ”„, ์ดํƒˆ ๋ฐฉ์ง€.

CTA ๋กœ์ง

PaywallCtaUtils.getCtaStringResId(paywallProduct)
  • CTA๋Š” ์ฒดํ—˜ / ์„ธ์ผ / ํ‰์ƒ์— ๋”ฐ๋ผ ๋‹ฌ๋ผ์ง‘๋‹ˆ๋‹ค.
  • packageIdOrigin != null์ด๋ฉด โ†’ ์„ธ์ผ ๋ชจ๋“œ๊ฐ€ ํ™œ์„ฑํ™”๋ฉ๋‹ˆ๋‹ค.
  • ํ• ์ธ %๋Š” ์›๋ž˜ ๊ฐ€๊ฒฉ์„ ๊ธฐ์ค€์œผ๋กœ ๊ณ„์‚ฐ๋ฉ๋‹ˆ๋‹ค.
  • ์›๋ž˜ ๊ฐ€๊ฒฉ์€ ์ทจ์†Œ์„ ์œผ๋กœ ํ‘œ์‹œ๋ฉ๋‹ˆ๋‹ค.

๊ตฌ๋งค ํ๋ฆ„ (SDK์—์„œ ์ฒ˜๋ฆฌ):

  1. ๊ฒฐ์ œ ์š”์ฒญ
  2. ๊ฒ€์ฆ
  3. purchaseStatus ๋ฐฉ์ถœ
Paywall A  โ†’ close
Sale Paywall Dialog โ†’ close
โ†’ No further paywall

Quick Checklist

  • ์œ ํšจํ•œ JSON ๊ตฌ์„ฑ
  • ๋ฐฐ์น˜ โ†’ ๊ตฌ์„ฑ ๋งคํ•‘
  • ์ €์žฅ์†Œ ์ดˆ๊ธฐํ™”
UI ์œ ํ˜•์ผ๋ฐ˜์ ์ธ ํŒจํ‚ค์ง€๋น„๊ณ 
์ „์ฒด ํ™”๋ฉด2โ€‘3๊ฐœ์˜ ํŒจํ‚ค์ง€ํƒ์ƒ‰์— ์ข‹์Œ
๋‹ค์ด์–ผ๋กœ๊ทธ (์„ธ์ผ)1๊ฐœ ํŒจํ‚ค์ง€์ข…๋ฃŒ ์˜๋„ ๋˜๋Š” ์ œํ•œ ์‹œ๊ฐ„ ์ œ์•ˆ์— ์ด์ƒ์ 
์ฃผ๊ฐ„๋‚ฎ์€ ์ง„์ž… ๊ฐ€๊ฒฉ์ž…๋ฌธ์šฉ ํ›…
์—ฐ๊ฐ„์ตœ๊ณ ์˜ ๊ฐ€์น˜์žฅ๊ธฐ ์•ฝ์†์„ ์žฅ๋ ค
ํ‰์ƒ๊ธฐ์ค€ ๊ฐ€๊ฒฉ๊ณ ๊ฐ€ ์—…์…€

Subscription Flow Overview

  • [ ] Test sandbox purchase
  • [ ] Test sale / retention

Soft Paywall (userโ€‘initiated purchase)

  • Light upsell โ€“ does not interrupt the user experience.
  • Allows management of the subscription after purchase.

Kotlin ๊ตฌํ˜„

private val paywallRepository: PaywallRepository by inject()
private val launcher: PaywallLauncher by inject()

// Show the Subscribe CTA only when the paywall is enabled
ctlSub.isVisible =
    paywallRepository
        .getPaywallData(Constants.PaywallConfigKey.PAYWALL_SETTING)
        ?.enable == true

// Text description depends on purchase state
txtSubDes.text = if (paywallRepository.isPurchased()) {
    getString(R.string.unlocked_all_features)
} else {
    getString(R.string.upgrade_to_unlock_all)
}

// Show โ€œManage subscriptionโ€ only for nonโ€‘lifetime purchases
ctlManageSubscription.isVisible =
    paywallRepository.isPurchased() &&
    paywallRepository.getCurrentPackagePurchased()?.type != PandaPackageType.LIFETIME

// ๐Ÿ“Œ Lifetime subscriptions do **not** need a โ€œmanageโ€ button โ†’ matches Play Store UX
ctlSub.setOnClickListener {
    lifecycleScope.launch {
        val activity = activity ?: return@launch

        when (
            launcher.launch(
                activity,
                Constants.PaywallConfigKey.PAYWALL_SETTING
            )
        ) {
            PaywallResult.Success -> {
                // Update UI after a successful purchase
                ctlManageSubscription.isVisible =
                    paywallRepository.isPurchased() &&
                    paywallRepository.getCurrentPackagePurchased()?.type != PandaPackageType.LIFETIME

                txtSubDes.text = getString(R.string.unlocked_all_features)

                // Disable all ad formats
                NativeAdManager.disableAllAds()
                InterManager.disableAllAds()
                RewardAdsManager.disableAllAds()
                BannerAdManager.disableAllAds()
                PandaResumeAd.INSTANCE?.disableAllAds()
            }

            PaywallResult.Dismissed -> {
                // User closed the paywall
            }

            PaywallResult.NotShown -> {
                // Already purchased or not eligible to show the paywall
            }
        }
    }
}

UI ํ๋ฆ„ ๋‹ค์ด์–ด๊ทธ๋žจ

Setting Screen
   โ†“
Subscribe CTA
   โ†“ click
PaywallLauncher
   โ†“
โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”
โ”‚   Success     โ”‚   Dismissed   โ”‚   NotShown    โ”‚
โ”‚ Disable ads   โ”‚ Log event     โ”‚ Continue app  โ”‚
โ”‚ Update UI     โ”‚               โ”‚               โ”‚
โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ดโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ดโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜

์ฒดํฌ๋ฆฌ์ŠคํŠธ

  • โœ… ์†Œํ”„ํŠธ ํŽ˜์ด์›” โ€“ ๊ฐ•์ œ ๊ตฌ๋งค ์—†์Œ.
  • โœ… enable = false์ผ ๋•Œ ํŽ˜์ด์›”์ด ์ˆจ๊ฒจ์ง.
  • โœ… ํ‰์ƒ ๊ตฌ๋… โ‰  โ€œ๊ตฌ๋… ๊ด€๋ฆฌโ€.
  • โœ… ๊ด‘๊ณ ๋Š” Success์—์„œ๋งŒ ๋น„ํ™œ์„ฑํ™”๋จ.
  • โœ… UI๊ฐ€ ์ฆ‰์‹œ ์—…๋ฐ์ดํŠธ โ€“ ํ™”๋ฉด ์ƒˆ๋กœ๊ณ ์นจ ํ•„์š” ์—†์Œ.
Back to Blog

๊ด€๋ จ ๊ธ€

๋” ๋ณด๊ธฐ ยป

์ƒˆํ•ด ๋ณต ๋งŽ์ด ๋ฐ›์œผ์„ธ์š”, ์ปค๋ฎค๋‹ˆํ‹ฐ!

์†Œ๊ฐœ ์•ˆ๋…•ํ•˜์„ธ์š”, ์ด ๋ธ”๋กœ๊ทธ๋ฅผ ์ฐพ์•„์ฃผ์‹  ๋ชจ๋“  ๋ถ„๋“ค๊ป˜ ์ƒˆํ•ด ๋ณต ๋งŽ์ด ๋ฐ›์œผ์„ธ์š”! Project Overview ์ด ํ”„๋กœ์ ํŠธ์˜ ์ตœ์ข… ๋ชฉํ‘œ๋Š” ์ˆœ์ˆ˜ํ•˜๊ฒŒ dec...

๋‚˜๋Š” ์ฃผ๋ณ€ ๋น›์— ๋”ฐ๋ผ ๋‹คํฌ ๋ชจ๋“œ๋ฅผ ์ „ํ™˜ํ•˜๋Š” ์˜คํ”ˆ์†Œ์Šค ์•ˆ๋“œ๋กœ์ด๋“œ ์•ฑ์„ ๋งŒ๋“ค์—ˆ์Šต๋‹ˆ๋‹ค โ€“ Adaptive Theme

ํ‘œ์ง€ ์ด๋ฏธ์ง€: ๋‚˜๋Š” ์ฃผ๋ณ€ ๊ด‘์— ๋”ฐ๋ผ ๋‹คํฌ ๋ชจ๋“œ๋ฅผ ์ „ํ™˜ํ•˜๋Š” ์˜คํ”ˆ์†Œ์Šค Android ์•ฑ์„ ๋งŒ๋“ค์—ˆ์Šต๋‹ˆ๋‹ค โ€“ Adaptive Theme https://media2.dev.to/dynamic/image/width=100...