개요

0:01:30

무료 체험은 구독 앱에서 가장 효과적인 전환 도구 가운데 하나입니다. Apple은 이를 도입 오퍼(introductory offer)라고 부르며, 다음 세 가지 유형이 있습니다.

  • 무료 체험: 사용자가 정해진 기간 동안 무료로 이용한 뒤, 정가로 청구됩니다.
  • Pay as you go: 사용자가 정해진 청구 주기 동안 할인된 가격을 냅니다.
  • Pay up front: 사용자가 정해진 기간에 해당하는 할인 금액을 한 번에 낸 뒤, 정가로 전환됩니다.

이 가이드는 가장 많이 쓰이고 전환 효과가 가장 큰 무료 체험 유형을 다룹니다. 다른 유형을 감지하고 표시하는 코드 패턴도 동일하며, paymentMode 값만 다릅니다.

Apple ID당 구독 그룹마다 체험은 한 번. Apple은 각 사용자가 도입 오퍼를 이용할 수 있는 횟수를 구독 그룹마다 한 번으로 제한합니다. 이전에 구독했다가 해지한 적이 있다면, 같은 그룹의 어떤 상품에서도 새로운 체험 대상이 되지 않습니다.

1단계: App Store Connect에서 설정하기

0:02:30

RevenueCat이 체험을 표시하려면 먼저 App Store Connect에서 무료 체험을 설정해야 합니다.

  1. App Store Connect에 로그인해 대상 앱을 엽니다.
  2. 사이드바에서 Subscriptions로 이동합니다.
  3. 구독 그룹을 선택한 뒤, 대상 구독 상품을 클릭합니다.
  4. Introductory Offers까지 스크롤한 뒤 + 버튼을 클릭합니다.
  5. Start DateEnd Date를 설정합니다(무기한으로 운영하려면 End Date를 비워 둡니다).
  6. 무료 체험을 만들려면 PriceFree로 설정합니다.
  7. Duration을 선택합니다.
    • 3일, 1주, 2주, 1개월, 2개월, 3개월, 6개월, 1년
  8. EligibilityNew Subscribers Only로 설정합니다(표준적인 선택입니다).
  9. Save를 클릭합니다.
반영 지연: App Store Connect에서 도입 오퍼를 변경하면 Sandbox 환경에 반영되기까지 최대 한 시간이 걸릴 수 있습니다. 테스트에서 체험이 나타나지 않으면 몇 분 기다린 뒤 Offering을 다시 가져오세요.

2단계: 체험 대상 여부 확인하기

0:02:30

Offering을 가져온 뒤, 각 StoreProductintroductoryDiscount 프로퍼티를 살펴보세요. 이 값이 존재하고 그 paymentMode.freeTrial이면, 그 사용자는 해당 상품의 체험 대상입니다.

swift
// Fetch offerings and inspect trial eligibility per package
func fetchOfferingsAndCheckTrials() async {
    do {
        let offerings = try await Purchases.shared.offerings()
        guard let current = offerings.current else { return }

        for package in current.availablePackages {
            let product = package.storeProduct

            if let intro = product.introductoryDiscount {
                switch intro.paymentMode {
                case .freeTrial:
                    // e.g. "7-day free trial"
                    print("\(product.productIdentifier): Free trial for \(intro.subscriptionPeriod)")
                case .payAsYouGo:
                    // Discounted price for N periods
                    print("\(product.productIdentifier): \(intro.localizedPriceString) for \(intro.numberOfPeriods) periods")
                case .payUpFront:
                    // One upfront reduced payment
                    print("\(product.productIdentifier): \(intro.localizedPriceString) upfront")
                default:
                    break
                }
            } else {
                print("\(product.productIdentifier): No introductory offer available")
            }
        }
    } catch {
        print("Failed to fetch offerings: \(error)")
    }
}

특정 사용자가 대상인지(이미 체험을 사용했는지까지 반영해) 확인하려면, RevenueCat 전용 대상 확인 API를 사용하세요.

swift
// Check if a specific user is eligible for the intro offer
let productIds = ["com.yourapp.premium_monthly"]
let eligibility = await Purchases.shared.checkTrialOrIntroDiscountEligibility(productIdentifiers: productIds)

if eligibility["com.yourapp.premium_monthly"]?.status == .eligible {
    // Show "Start your 7-day free trial" messaging
} else {
    // Show regular pricing — user has already used the trial
}

3단계: 페이월 UI에 체험 표시하기

0:03:00

패키지의 introductoryDiscount를 읽어 체험 안내를 조건부로 표시하는 가격 카드 컴포넌트를 만듭니다. 이 패턴은 어떤 RevenueCat Offering 구성에서도 동작하며, App Store Connect에서 설정한 체험 기간에 맞춰 자동으로 조정됩니다.

swift
import SwiftUI
import RevenueCat

struct PackagePricingCard: View {
    let package: Package
    let onPurchase: (Package) -> Void

    private var product: StoreProduct { package.storeProduct }
    private var freeTrial: StoreProductDiscount? {
        guard let intro = product.introductoryDiscount,
              intro.paymentMode == .freeTrial else { return nil }
        return intro
    }

    var body: some View {
        VStack(alignment: .leading, spacing: 12) {
            // Trial badge
            if let trial = freeTrial {
                HStack(spacing: 4) {
                    Text("FREE")
                        .font(.caption2)
                        .fontWeight(.black)
                        .foregroundColor(.white)
                        .padding(.horizontal, 6)
                        .padding(.vertical, 2)
                        .background(Color.green)
                        .cornerRadius(4)
                    Text("\(trialPeriodLabel(trial)) free trial")
                        .font(.subheadline)
                        .fontWeight(.semibold)
                        .foregroundColor(.green)
                }
            }

            // Pricing
            HStack(alignment: .firstTextBaseline, spacing: 2) {
                Text(product.localizedPriceString)
                    .font(.title2)
                    .fontWeight(.bold)
                if let period = product.subscriptionPeriod {
                    Text("/ \(periodLabel(period))")
                        .font(.subheadline)
                        .foregroundColor(.secondary)
                }
            }

            // CTA
            Button(action: { onPurchase(package) }) {
                Text(freeTrial != nil ? "Start Free Trial" : "Subscribe Now")
                    .font(.headline)
                    .foregroundColor(.white)
                    .frame(maxWidth: .infinity)
                    .padding(.vertical, 14)
                    .background(Color.accentColor)
                    .cornerRadius(12)
            }

            // Legal disclaimer
            if let trial = freeTrial {
                Text("Free for \(trialPeriodLabel(trial)), then \(product.localizedPriceString)/\(periodLabel(product.subscriptionPeriod)). Cancel anytime.")
                    .font(.caption)
                    .foregroundColor(.secondary)
                    .multilineTextAlignment(.center)
            }
        }
        .padding()
        .background(Color(.secondarySystemBackground))
        .cornerRadius(16)
    }

    private func trialPeriodLabel(_ discount: StoreProductDiscount) -> String {
        let period = discount.subscriptionPeriod
        switch period.unit {
        case .day:   return period.value == 7 ? "7-day" : "\(period.value)-day"
        case .week:  return "\(period.value)-week"
        case .month: return "\(period.value)-month"
        case .year:  return "\(period.value)-year"
        default:     return "trial"
        }
    }

    private func periodLabel(_ period: SubscriptionPeriod?) -> String {
        guard let period = period else { return "period" }
        switch period.unit {
        case .day:   return "day"
        case .week:  return "week"
        case .month: return "month"
        case .year:  return "year"
        default:     return "period"
        }
    }
}

4단계: 구매 후 체험 감지하기

0:02:00

사용자가 "Start Free Trial"을 탭하고 구매가 성공하면, Entitlement의 periodType을 확인해 그 사용자가 활성 체험 중인지 검증합니다. 이를 활용해 "체험 종료까지 X일" 같은 체험 전용 UI를 표시할 수 있습니다.

swift
func purchasePackage(_ package: Package) async {
    do {
        let result = try await Purchases.shared.purchase(package: package)
        let customerInfo = result.customerInfo

        if let entitlement = customerInfo.entitlements["premium"] {
            if entitlement.periodType == .trial {
                // User is in the free trial period
                if let trialEnd = entitlement.expirationDate {
                    let formatter = RelativeDateTimeFormatter()
                    let timeRemaining = formatter.localizedString(for: trialEnd, relativeTo: Date())
                    showSuccessMessage("Trial started! Ends \(timeRemaining).")
                } else {
                    showSuccessMessage("Your free trial has started!")
                }
            } else if entitlement.isActive {
                // User purchased directly (no trial)
                showSuccessMessage("Welcome to Premium!")
            }
        }
    } catch {
        showErrorMessage("Purchase failed: \(error.localizedDescription)")
    }
}

체험 만료와 전환

0:02:00

체험이 끝나면 Apple은 사용자를 유료 구독으로 전환하거나(해지하지 않은 경우), Entitlement를 만료시킵니다. RevenueCat은 이 업데이트를 서버 간 알림(server-to-server notification)으로 받아 customerInfoUpdatedNotification을 발생시키며, 이는 SubscriptionManager가 자동으로 받아 처리합니다.

체험이 끝나기 전에 전환을 적극적으로 유도하려면 willRenew를 확인하세요.

swift
let customerInfo = try await Purchases.shared.getCustomerInfo()
if let premium = customerInfo.entitlements["premium"],
   premium.periodType == .trial {

    if premium.willRenew {
        // Trial will convert — show positive reinforcement
        print("Trial converts automatically in \(daysRemaining(until: premium.expirationDate)) days")
    } else {
        // User cancelled during trial — show win-back prompt
        showWinBackOffer()
    }
}

func daysRemaining(until date: Date?) -> Int {
    guard let date = date else { return 0 }
    return Calendar.current.dateComponents([.day], from: Date(), to: date).day ?? 0
}

서버에서 RevenueCat webhook을 구독하면 TRIAL_STARTED, TRIAL_CONVERTED, TRIAL_CANCELLED 이벤트를 받아 서버 측 후속 처리(이메일, 분석 등)를 실행할 수도 있습니다.


App Store 가이드라인

0:01:30
체험 조건을 명확히 고지하세요. Apple은 체험 기간, 체험 후 청구되는 가격, 해지 방법을 눈에 잘 띄게 표시하도록 요구합니다. 이 정보를 묻어 두면 앱이 거절될 수 있습니다. 페이월 UI와 앱 메타데이터 양쪽에 모두 넣으세요.
체험 후 가격을 눈에 띄게 표시하세요. 체험 후 구독 가격은 사용자가 구매를 확정하기 전에 보여야 합니다. 이후에 얼마가 청구되는지 언급하지 않고 체험 기간만 표시하는 것은 피하세요.
명확한 해지 안내를 제공하세요. 설정 > [사용자 이름] > 구독으로 이동해 관리하거나 해지할 수 있음을 사용자에게 안내하세요. 이 경로를 앱 안(예: 설정이나 페이월)에 넣는 것은 가이드라인 요건이자 사용자 신뢰의 신호이기도 합니다.

관련 가이드