App Clips와 Android Instant Apps: 설치 없이 실행하기

모바일 개발

App ClipsInstant AppsiOSAndroid미니 앱

이 글은 누구를 위한 것인가

  • 카페, 주차장 등 오프라인 매장에 QR/NFC 결제를 붙이려는 팀
  • 설치 없이 앱의 핵심 기능을 경험시키고 싶은 마케팅 개발팀
  • App Clips와 Instant Apps의 용량 제한과 기능 제약을 이해하려는 개발자

들어가며

App Clips(iOS)와 Instant Apps(Android)는 앱을 설치하지 않고 즉시 실행할 수 있는 경량 앱이다. 카페 QR 스캔 → 결제까지 5초면 완료된다. 앱 설치의 마찰을 없애고 전환율을 높이는 효과적인 수단이다.

이 글은 bluefoxdev.kr의 경량 앱 전략 가이드 를 참고하여 작성했습니다.


1. App Clips vs Instant Apps 비교

[iOS App Clips]
크기 제한: 15MB (다운로드 크기)
실행 방법:
  - QR 코드 스캔
  - NFC 태그
  - Safari 스마트 배너
  - 메시지 앱 링크
  - Maps 위치 카드
  - Apple Watch (watchOS 7+)

특징:
  - Apple Pay 사용 가능 (설치 없이 결제)
  - Sign in with Apple 지원
  - 위치 정보 접근 가능
  - 카메라 사용 가능
  - 데이터 임시 저장 (최대 1일)
  - iCloud 동기화 불가

[Android Instant Apps]
크기 제한: 15MB (각 기능 모듈)
실행 방법:
  - URL 클릭 (Try Now 버튼)
  - Google Play 스토어
  - Google 검색 결과
  - Google Assistant

특징:
  - 완전한 Android API 접근
  - Google Pay 지원
  - Play 계정 인증
  - 저장소 접근 제한

[적합한 사용 사례]
  주차장 결제: QR → App Clip → Apple Pay → 완료
  카페 주문: NFC → App Clip → 메뉴 → 결제
  이벤트 티켓: URL → Instant App → 티켓 확인
  게임 체험: Play 스토어 → 설치 없이 레벨 1 플레이

2. iOS App Clip 구현

// App Clip 타겟 - AppClipApp.swift
import SwiftUI
import AppClip

@main
struct CafeOrderClip: App {
    @Environment(\.openURL) private var openURL
    
    var body: some Scene {
        WindowGroup {
            AppClipContentView()
                .onContinueUserActivity(NSUserActivityTypeBrowsingWeb) { activity in
                    handleURL(activity)
                }
        }
    }
    
    private func handleURL(_ activity: NSUserActivity) {
        guard let url = activity.webpageURL else { return }
        
        // URL에서 매장 ID 파싱
        // appclip.example.com/cafe?id=12345
        if let components = URLComponents(url: url, resolvingAgainstBaseURL: false),
           let storeId = components.queryItems?.first(where: { $0.name == "id" })?.value {
            NotificationCenter.default.post(
                name: .storeIdReceived,
                object: storeId
            )
        }
    }
}

// App Clip 메인 뷰
struct AppClipContentView: View {
    @StateObject private var viewModel = AppClipViewModel()
    @State private var storeId: String?
    
    var body: some View {
        NavigationView {
            Group {
                if let store = viewModel.store {
                    QuickOrderView(store: store)
                } else if viewModel.isLoading {
                    ProgressView("매장 정보 로딩 중...")
                } else {
                    Text("매장 정보를 불러올 수 없습니다")
                }
            }
            .navigationTitle("빠른 주문")
            .toolbar {
                ToolbarItem(placement: .navigationBarTrailing) {
                    // 앱 설치 유도
                    Button("앱 설치") {
                        // 앱 스토어 또는 App Clip 배너
                        viewModel.openFullApp()
                    }
                }
            }
        }
        .onReceive(NotificationCenter.default.publisher(for: .storeIdReceived)) { notification in
            storeId = notification.object as? String
            Task { await viewModel.loadStore(id: storeId ?? "") }
        }
    }
}

// Apple Pay 결제 (App Clip에서 사용 가능)
struct QuickOrderView: View {
    let store: Store
    @State private var selectedItems: [MenuItem] = []
    @StateObject private var paymentManager = PaymentManager()
    
    var totalAmount: Decimal {
        selectedItems.reduce(0) { $0 + $1.price }
    }
    
    var body: some View {
        VStack {
            // 메뉴 목록 (경량화)
            List(store.popularItems) { item in
                MenuItemRow(item: item, isSelected: selectedItems.contains(item)) {
                    if selectedItems.contains(item) {
                        selectedItems.removeAll { $0.id == item.id }
                    } else {
                        selectedItems.append(item)
                    }
                }
            }
            
            // Apple Pay 버튼
            if !selectedItems.isEmpty {
                ApplePayButton(action: {
                    paymentManager.requestPayment(
                        amount: totalAmount,
                        storeId: store.id,
                        items: selectedItems
                    )
                })
                .frame(height: 50)
                .padding()
            }
        }
    }
}

// App Clip 크기 최적화 체크리스트
// 1. 이미지: SF Symbols 최대 활용 (번들 크기 0)
// 2. 네트워크: 메뉴 이미지는 URL 로딩, 번들 미포함
// 3. 코드: 앱과 코드 공유하되 별도 타겟 빌드
// 4. 프레임워크: 꼭 필요한 것만 링크

class AppClipViewModel: ObservableObject {
    @Published var store: Store?
    @Published var isLoading = false
    
    func loadStore(id: String) async {
        await MainActor.run { isLoading = true }
        
        // 경량 API 호출 (필수 데이터만)
        let store = try? await APIClient.fetchStoreQuick(id: id)
        
        await MainActor.run {
            self.store = store
            self.isLoading = false
        }
    }
    
    func openFullApp() {
        // 앱 설치 안 된 경우 App Store, 설치된 경우 앱 열기
        let appStoreURL = URL(string: "https://apps.apple.com/app/id123456789")!
        UIApplication.shared.open(appStoreURL)
    }
}

struct Store { let id: String; let name: String; let popularItems: [MenuItem] }
struct MenuItem: Identifiable { let id: String; let name: String; let price: Decimal }
extension Notification.Name { static let storeIdReceived = Notification.Name("storeIdReceived") }

// MARK: - Xcode 설정
// 1. File > New > Target > App Clip
// 2. Associated Domains: appclips:appclip.example.com
// 3. App Clip Experience 등록 (App Store Connect)
//    - URL 패턴 설정
//    - 헤더 이미지, 제목, 부제목
//    - NFC/QR 태그 연결
// Android - Instant App 모듈 구성
// settings.gradle.kts
// include(":base", ":feature_order", ":instantapp")

// feature_order/build.gradle.kts
plugins {
    id("com.android.dynamic-feature")
}

android {
    defaultConfig {
        // Instant App 지원
    }
}

// InstantAppActivity.kt
class QuickOrderActivity : AppCompatActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        
        // URL에서 매장 ID 추출
        val storeId = intent.data?.getQueryParameter("id")
        
        setContent {
            QuickOrderScreen(
                storeId = storeId,
                onInstallApp = {
                    // 앱 설치 유도
                    startActivity(Intent(Intent.ACTION_VIEW,
                        Uri.parse("market://details?id=com.example.app")))
                }
            )
        }
    }
}

// AndroidManifest.xml (feature_order)
// <activity android:name=".QuickOrderActivity">
//     <intent-filter android:autoVerify="true">
//         <action android:name="android.intent.action.VIEW" />
//         <category android:name="android.intent.category.DEFAULT" />
//         <category android:name="android.intent.category.BROWSABLE" />
//         <data android:scheme="https" android:host="order.example.com" />
//     </intent-filter>
// </activity>

마무리

App Clips는 15MB 제한 내에서 최고의 사용자 경험을 만드는 것이 핵심이다. SF Symbols로 이미지 번들을 최소화하고, 네트워크로 콘텐츠를 로드하며, Apple Pay로 결제 마찰을 없애야 한다. 앱 설치 유도는 경험을 완료한 직후가 가장 효과적이다. "주문 완료! 앱을 설치하면 주문 내역을 확인할 수 있어요"처럼 명확한 가치를 제시해야 전환율이 높아진다.