-
[iOS][앱스토어] 인앱결제, 구독 붙이기개인사업자/앱배포및심사 2026. 6. 19. 15:04
개요
iOS 앱에 구독을 붙이려면 코드만 먼저 열심히 짜면 안된다.
앱스토어커넥트에 구독 상품을 먼저 만들고, 그 상품의 Product ID를 iOS 코드에서 StoreKit으로 불러오는 구조다.
대충 순서는 아래처럼 보면 된다.
- App Store Connect에서 유료 앱 계약/세금/계좌 상태 확인
- 앱 선택
- Monetization > Subscriptions에서 구독 그룹 생성
- 구독 상품 생성
- 가격, 기간, 판매 국가, 로컬라이징, 심사용 스크린샷 입력
- Sandbox 계정 만들어서 테스트
- iOS 앱에서 StoreKit으로 상품 조회/구매/복원/권한 확인 구현
생각보다 App Store Connect 쪽에서 챙길게 많다.
Product ID 하나 잘못 만들면 다시 못 고치는 항목도 있어서, 처음부터 이름을 좀 정리해두고 시작하는 것을 추천함.
먼저 확인할 것
인앱결제나 구독을 팔려면 App Store Connect에서 Paid Apps Agreement가 활성화되어 있어야 한다.
Apple 문서 기준으로, 유료 앱을 판매하거나 In-App Purchase를 제공하려면 Account Holder가 Paid Apps Agreement에 동의해야 한다.
메뉴는 대략 아래쪽이다.
App Store Connect > Business > Agreements여기서 Paid Apps가 Active인지 확인한다.
계약만 하고 끝나는게 아니라 세금, 계좌 정보까지 필요한 경우가 있어서 이 부분이 비어있으면 상품을 다 만들어도 마지막에 막힌다.
구독 메뉴 들어가기
앱스토어커넥트에서 앱을 선택한 다음, 왼쪽 메뉴에서 아래로 들어간다.
Apps > 내 앱 선택 > Monetization > Subscriptions
처음 구독을 붙이는 앱이면 Subscription Group부터 만든다.
구독 그룹은 월간/연간처럼 같은 혜택을 다른 기간으로 파는 상품들을 묶는 단위라고 보면 된다.
예를 들어 Premium 월간, Premium 연간을 만든다면 보통 같은 Subscription Group 안에 넣는다.
Apple 문서에서도 대부분의 앱은 하나의 구독 그룹을 만드는 것이 좋다고 설명한다. 같은 그룹 안에서는 사용자가 동시에 하나의 구독만 살 수 있기 때문.
Subscription Group 만들기
Subscriptions 화면에서 Subscription Groups 옆의 + 버튼을 누른다.

Reference Name은 내부 관리용 이름이라 유저에게 보이지 않는다.
예를 들면 이런 식으로 넣으면 된다.
DailyWeight Premium MyApp Premium MyApp Pro너무 깊게 고민할 필요는 없지만, 나중에 Sales and Trends 같은데서 볼 이름이라 앱 이름 + 등급 정도로 잡는게 무난하다.
구독 상품 만들기
그룹을 만들고 나면 그 안에 실제 구독 상품을 만든다.

여기서 중요한 값은 Product ID다.
StoreKit 코드에서 이 Product ID로 상품을 가져오기 때문에, 앱스토어커넥트와 코드가 정확히 일치해야 한다.
예시는 아래처럼 잡으면 된다.
com.gnosis.dailyweight.premium.monthly com.gnosis.dailyweight.premium.yearly내가 보통 선호하는 형태는 아래다.
com.회사명.앱이름.상품등급.기간주의할 점은 Product ID는 저장 후 수정이 안된다.
그리고 삭제해도 같은 앱 안에서 재사용이 안되므로, 오타 나면 그냥 새로운 ID를 만들어야 한다.
이 부분이 은근히 잔인함.
구독 기간 선택
상품을 만들 때 구독 기간을 고른다.

일반적으로는 월간이면
1 month, 연간이면1 year를 고른다.월간/연간 둘 다 만들거라면 상품을 두 개 만든다.
혜택이 같고 기간만 다르면 같은 그룹, 같은 레벨에 두면 된다.
가격 설정
그 다음 가격을 설정한다.

기준 통화와 가격을 고르면 App Store Connect가 국가별 가격을 자동 계산해준다.
나는 처음 붙일 때는 너무 복잡하게 할인/프로모션까지 같이 안가는 편이 낫다고 생각한다.
일단 기본 월간/연간 구독만 붙이고, 구매/복원/샌드박스 테스트가 안정적으로 되는지 먼저 보는게 덜 피곤하다.
로컬라이징 입력
구독 그룹에도 로컬라이징이 있고, 구독 상품에도 로컬라이징이 있다.
대충 이런 식이다.
Subscription Group Localization - 표시 이름 - 설명 Subscription Localization - 표시 이름 - 설명사용자가 실제로 볼 수 있는 이름/설명이라 한국어 앱이면 한국어는 넣어두는게 좋다.
예를 들면:
표시 이름: 프리미엄 월간 구독 설명: 광고 제거와 프리미엄 기능을 한 달 동안 사용할 수 있습니다.여기서 심사용 문구랑 실제 앱 paywall 문구가 너무 다르면 나중에 리뷰할 때 헷갈릴 수 있으니, 앱에서 보여주는 혜택과 맞춰두는게 좋다.
심사용 스크린샷과 노트
구독 상품 상세 화면 아래쪽에 Review Information이 있다.

여기에는 App Review가 확인할 수 있는 스크린샷과 노트를 넣는다.
스크린샷은 실제 앱 안에서 구독 상품이 보이는 화면, 보통 paywall 화면을 넣으면 된다.
리뷰 노트에는 이런 식으로 적는다.
This subscription unlocks premium features and removes ads. Please open Settings > Premium to view the subscription screen. Test account: email@example.com / password테스트 계정이 필요한 앱이면 꼭 넣는다.
로그인해야 paywall까지 갈 수 있는데 테스트 계정을 안넣어두면, 심사에서 괜히 한 번 더 왕복하게 된다.
Sandbox 계정 만들기
실제 결제 테스트는 내 개인 Apple ID로 하는게 아니라 Sandbox 계정으로 한다.
메뉴는 아래쪽이다.
App Store Connect > Users and Access > Sandbox
오른쪽 + 버튼을 눌러 테스트 계정을 만든다.
중요한 점은 sandbox 계정 이메일은 실제 Apple Account나 App Store 구매에 사용한 적 없는 이메일이어야 한다.
Apple 문서 기준으로 Sandbox 계정은 최대 10,000개까지 만들 수 있고, 테스트 용도로만 사용된다.
테스트 기기에서는 일반 Apple ID를 로그아웃할 필요 없이, App Store/Sandbox 계정 쪽에 별도로 로그인해서 테스트한다.
iOS 코드 붙이기
StoreKit 2 기준으로 보면 필요한 코드는 크게 네 덩어리다.
- 상품 ID 정의
- 상품 목록 불러오기
- 구매/복원하기
- 현재 구독 권한 확인하기
아래는 가장 기본 형태다.
import StoreKit enum SubscriptionProductID { static let monthly = "com.gnosis.dailyweight.premium.monthly" static let yearly = "com.gnosis.dailyweight.premium.yearly" static let all: Set<String> = [ monthly, yearly ] } @MainActor final class SubscriptionStore: ObservableObject { @Published private(set) var products: [Product] = [] @Published private(set) var isPremium = false private var updatesTask: Task<Void, Never>? init() { updatesTask = listenForTransactions() } deinit { updatesTask?.cancel() } func loadProducts() async { do { let products = try await Product.products(for: SubscriptionProductID.all) self.products = products.sorted { $0.price < $1.price } } catch { self.products = [] } } func purchase(_ product: Product) async throws { let result = try await product.purchase() switch result { case .success(let verification): let transaction = try checkVerified(verification) await refreshEntitlement() await transaction.finish() case .userCancelled: break case .pending: break @unknown default: break } } func restore() async { do { try await AppStore.sync() await refreshEntitlement() } catch { // 복원 실패 처리는 앱 UX에 맞게 토스트 등으로 처리 } } func refreshEntitlement() async { var hasPremium = false for await result in Transaction.currentEntitlements { guard let transaction = try? checkVerified(result) else { continue } if SubscriptionProductID.all.contains(transaction.productID) { hasPremium = true break } } isPremium = hasPremium } private func listenForTransactions() -> Task<Void, Never> { Task { [weak self] in for await result in Transaction.updates { guard let self else { continue } guard let transaction = try? self.checkVerified(result) else { continue } await self.refreshEntitlement() await transaction.finish() } } } private func checkVerified<T>(_ result: VerificationResult<T>) throws -> T { switch result { case .unverified: throw StoreError.failedVerification case .verified(let safe): return safe } } } enum StoreError: Error { case failedVerification }실제 앱에서는
isPremium을 단순 UserDefaults 플래그처럼 영구 저장해두고 끝내면 안된다.로컬 캐시는 할 수 있지만, 기준은 StoreKit의 현재 권한이어야 한다.
그래서 앱 실행 시점, 앱이 foreground로 돌아온 시점, 구매 성공 후, 복원 후에
refreshEntitlement()를 다시 호출하는게 좋다.SwiftUI 앱이면 대충 이런 느낌으로 연결한다.
@main struct MyApp: App { @StateObject private var subscriptionStore = SubscriptionStore() @Environment(\.scenePhase) private var scenePhase var body: some Scene { WindowGroup { ContentView() .environmentObject(subscriptionStore) .task { await subscriptionStore.loadProducts() await subscriptionStore.refreshEntitlement() } .onChange(of: scenePhase) { _, newPhase in guard newPhase == .active else { return } Task { await subscriptionStore.refreshEntitlement() } } } } }Paywall은 상품을 StoreKit에서 불러와서 표시한다.
가격을 하드코딩하지 말고
product.displayPrice를 쓰는게 좋다.import SwiftUI import StoreKit struct PaywallView: View { @EnvironmentObject private var subscriptionStore: SubscriptionStore var body: some View { VStack(spacing: 16) { Text("Premium") .font(.title.bold()) ForEach(subscriptionStore.products, id: \.id) { product in Button { Task { try? await subscriptionStore.purchase(product) } } label: { HStack { VStack(alignment: .leading) { Text(product.displayName) .font(.headline) Text(product.description) .font(.subheadline) .foregroundStyle(.secondary) } Spacer() Text(product.displayPrice) .font(.headline) } } .buttonStyle(.borderedProminent) } Button("구매 복원") { Task { await subscriptionStore.restore() } } } .padding() .task { await subscriptionStore.loadProducts() } } }Xcode StoreKit 테스트
앱스토어커넥트에 올리기 전에 Xcode StoreKit Configuration으로 먼저 테스트하는게 좋다.
File > New > StoreKit Configuration File여기에 App Store Connect에서 만든 것과 같은 Product ID를 넣는다.
그 다음 Scheme 설정에서 StoreKit Configuration 파일을 지정한다.
Edit Scheme > Run > Options > StoreKit Configuration이렇게 해두면 샌드박스 계정 없이도 로컬에서 구매 흐름을 테스트할 수 있다.
다만 최종 확인은 Sandbox 계정으로 실제 기기/TestFlight에서 해보는게 안전하다.
Product.products가 빈 배열로 나올 때
StoreKit 붙이다보면 제일 자주 보는게 이거다.
Product.products(for:)를 호출했는데 상품이 안나온다.이럴 때는 보통 아래 중 하나다.
- Product ID 오타
- Bundle ID가 App Store Connect 앱과 다름
- Paid Apps Agreement, 세금, 계좌 정보가 덜 끝남
- 구독 상품의 가격/로컬라이징/심사용 정보가 덜 입력됨
- Sandbox 반영까지 시간이 덜 지남
- Xcode StoreKit Configuration에 다른 상품 ID를 넣음
Apple 문서에도 상품 메타데이터 변경이 sandbox에 나타나기까지 최대 1시간 걸릴 수 있다고 되어 있다.
방금 만들고 바로 안나온다고 코드만 계속 뜯으면 삽질하기 좋다.
심사 올릴 때 체크
최종적으로는 앱 버전 심사를 올릴 때 구독 상품도 같이 제출해야 한다.
체크리스트는 이 정도.
Paid Apps Agreement 활성화 세금/계좌 정보 입력 Subscription Group 생성 Subscription Product 생성 Product ID와 코드 일치 가격 설정 판매 국가 설정 구독 그룹 로컬라이징 입력 구독 상품 로컬라이징 입력 Review Information 스크린샷 업로드 Review Notes 입력 Sandbox 계정 테스트 앱 안에서 구매/복원 버튼 제공 개인정보 처리방침/이용약관 링크 준비특히 복원 버튼은 꼭 넣자.
구독/인앱결제 앱에서 구매 복원이 없으면 유저도 불편하고 심사에서도 좋게 보일 이유가 없다.
최종 정리
구독 붙이기는 코드보다 App Store Connect 설정에서 더 피곤한 느낌이 있다.
코드는 StoreKit 2 기준으로 보면 생각보다 구조가 단순하다.
상품 불러오고, 구매하고, 복원하고, 현재 권한 확인한다.
문제는 Product ID, 계약 상태, 로컬라이징, 심사용 스크린샷 같은 콘솔 값들이 하나라도 덜 맞으면 앱에서 상품이 안나온다는 점이다.
그래서 처음에는 최소 구독 상품 하나만 만들어서 끝까지 테스트해보고,
그 다음 월간/연간, 할인, introductory offer 같은걸 붙이는게 정신건강에 좋을 듯.
참고 링크
'개인사업자 > 앱배포및심사' 카테고리의 다른 글