iOS Siri Intent와 App Shortcuts: 음성으로 앱 기능 열기

모바일 개발

iOSSiriApp ShortcutsApp IntentsSpotlight

이 글은 누구를 위한 것인가

  • "시리야, 주문 확인해줘"처럼 Siri로 앱 기능을 열고 싶은 팀
  • App Shortcuts를 구현해서 Spotlight 검색과 연동하고 싶은 개발자
  • NSUserActivity 기반 구현에서 App Intents로 마이그레이션하려는 엔지니어

들어가며

iOS 16에서 App Intents 프레임워크로 App Shortcuts가 크게 개선됐다. 코드로 Siri 명령을 정의하면 사용자가 별도 설정 없이 바로 사용할 수 있다.

이 글은 bluefoxdev.kr의 iOS 시스템 연동 가이드 를 참고하여 작성했습니다.


1. App Shortcuts 아키텍처

[App Intents vs NSUserActivity]

NSUserActivity (구식):
  사용자가 Siri에서 직접 바로 가기 추가 필요
  매개변수 처리 복잡
  Swift UI 통합 제한

App Intents (iOS 16+):
  설치 즉시 Siri에서 사용 가능
  코드로 모든 정의
  SwiftUI PreviewProvider 지원
  Spotlight, Shortcuts 앱 자동 연동

[App Shortcut 구성요소]

AppIntent 프로토콜:
  perform() → 실제 작업 수행
  title, description
  매개변수 정의

AppShortcutsProvider:
  앱의 모든 Shortcut 목록
  Siri 구절(phrase) 정의
  
@Parameter:
  사용자 입력값
  EntityQuery로 목록 제공

[Siri 구절 규칙]
  "주문 확인해줘 \(applicationName)"
  applicationName: 앱 이름 (필수 포함)
  여러 구절 지원 가능
  다국어: appShortcutsSystemImageName

2. App Shortcuts 구현

import AppIntents

// 1. AppIntent 정의: 주문 확인
struct CheckOrderStatusIntent: AppIntent {
    static var title: LocalizedStringResource = "주문 확인"
    static var description = IntentDescription("최근 주문 상태를 확인합니다")
    
    // 매개변수: 주문 번호 (선택적)
    @Parameter(title: "주문 번호", optionsProvider: OrderOptionsProvider())
    var orderId: String?
    
    func perform() async throws -> some IntentResult & ProvidesDialog {
        let orders = try await OrderService.shared.fetchRecentOrders()
        
        if let orderId = orderId,
           let order = orders.first(where: { $0.id == orderId }) {
            return .result(
                dialog: IntentDialog("주문 \(orderId)는 \(order.status.description) 상태입니다.")
            )
        }
        
        guard let latest = orders.first else {
            return .result(dialog: IntentDialog("최근 주문이 없습니다."))
        }
        
        return .result(
            dialog: IntentDialog("최근 주문은 \(latest.status.description) 상태입니다.")
        )
    }
}

// 주문 옵션 제공자
struct OrderOptionsProvider: DynamicOptionsProvider {
    func results() async throws -> [String] {
        let orders = try await OrderService.shared.fetchRecentOrders()
        return orders.map { $0.id }
    }
}

// 2. AppIntent: 장바구니 추가
struct AddToCartIntent: AppIntent {
    static var title: LocalizedStringResource = "장바구니에 추가"
    
    @Parameter(title: "상품명")
    var productName: String
    
    @Parameter(title: "수량", default: 1)
    var quantity: Int
    
    func perform() async throws -> some IntentResult & ProvidesDialog {
        guard let product = try await ProductService.shared.search(name: productName).first else {
            return .result(dialog: IntentDialog("'\(productName)' 상품을 찾을 수 없습니다."))
        }
        
        try await CartService.shared.addItem(productId: product.id, quantity: quantity)
        
        return .result(
            dialog: IntentDialog("\(productName) \(quantity)개를 장바구니에 추가했습니다.")
        )
    }
    
    static var parameterSummary: some ParameterSummary {
        Summary("장바구니에 \(\.$productName) \(\.$quantity)개 추가")
    }
}

// 3. AppShortcutsProvider: Siri 구절 등록
struct MyAppShortcuts: AppShortcutsProvider {
    static var appShortcuts: [AppShortcut] {
        AppShortcut(
            intent: CheckOrderStatusIntent(),
            phrases: [
                "주문 확인해줘 \(.applicationName)",
                "내 주문 어떻게 됐어 \(.applicationName)",
                "배달 언제 와 \(.applicationName)",
            ],
            shortTitle: "주문 확인",
            systemImageName: "shippingbox",
        )
        
        AppShortcut(
            intent: AddToCartIntent(),
            phrases: [
                "\(.applicationName)에서 \(\.$productName) 장바구니에 담아",
                "\(\.$productName) \(.applicationName) 장바구니에 넣어줘",
            ],
            shortTitle: "장바구니 추가",
            systemImageName: "cart.badge.plus",
        )
    }
}

// 4. 앱 업데이트 시 Shortcuts 갱신
@main
struct ShoppingApp: App {
    var body: some Scene {
        WindowGroup { ContentView() }
    }
    
    init() {
        // AppShortcuts 기부 (자동으로 처리되지만 명시적 호출 가능)
        MyAppShortcuts.updateAppShortcutParameters()
    }
}

// 5. Spotlight 검색 통합
struct ProductEntity: AppEntity {
    static var typeDisplayRepresentation: TypeDisplayRepresentation = "상품"
    static var defaultQuery = ProductQuery()
    
    var id: String
    var displayRepresentation: DisplayRepresentation {
        DisplayRepresentation(title: "\(name)", subtitle: "\(price)원")
    }
    
    let name: String
    let price: Int
}

struct ProductQuery: EntityQuery {
    func entities(for identifiers: [String]) async throws -> [ProductEntity] {
        try await ProductService.shared.fetchProducts(ids: identifiers)
            .map { ProductEntity(id: $0.id, name: $0.name, price: $0.price) }
    }
    
    func suggestedEntities() async throws -> [ProductEntity] {
        try await ProductService.shared.fetchPopularProducts()
            .prefix(5)
            .map { ProductEntity(id: $0.id, name: $0.name, price: $0.price) }
    }
}

// 서비스 스텁 (실제 구현 대체)
struct Order { let id: String; var status: OrderStatus }
enum OrderStatus { 
    case preparing, shipped, delivered
    var description: String {
        switch self {
        case .preparing: return "준비 중"
        case .shipped: return "배송 중"
        case .delivered: return "배달 완료"
        }
    }
}
struct Product { let id: String; let name: String; let price: Int }

class OrderService {
    static let shared = OrderService()
    func fetchRecentOrders() async throws -> [Order] { [] }
}
class CartService {
    static let shared = CartService()
    func addItem(productId: String, quantity: Int) async throws {}
}
class ProductService {
    static let shared = ProductService()
    func search(name: String) async throws -> [Product] { [] }
    func fetchProducts(ids: [String]) async throws -> [Product] { [] }
    func fetchPopularProducts() async throws -> [Product] { [] }
}

마무리

App Shortcuts의 핵심 가치는 "설치 즉시 Siri에서 작동"이다. NSUserActivity와 달리 사용자가 Shortcuts 앱에서 따로 설정할 필요가 없다. Siri 구절에 앱 이름을 반드시 포함해야 하며, 자주 쓰는 3-5개 기능만 Shortcut으로 만드는 것이 좋다. 너무 많으면 Siri가 혼란스러워한다.