---
id: ios-universal-links-deep-linking
title: iOS Universal Links — 웹 URL → 앱 진입
category: Coding
status: draft
source_trust_level: B
verification_status: conceptual
created_at: 2026-05-09
updated_at: 2026-05-09
tags: [ios, deep-link, universal-link, vibe-coding]
tech_stack: { language: "Swift", applicable_to: ["iOS"] }
applied_in: []
aliases: [associated domains, AASA, custom URL scheme, deferred deep link]
---

# iOS Universal Links

> `https://example.com/order/123` → 앱 설치되어 있으면 앱이 처리, 아니면 웹. **AASA 파일 + Associated Domains capability** 필수. 옛 custom scheme (`myapp://`) 보다 안전.

## 📖 핵심 개념
- AASA (apple-app-site-association): 도메인이 어떤 앱과 연결.
- Associated Domains capability: 앱에 도메인 등록.
- onContinueUserActivity: SwiftUI / SceneDelegate.
- Deferred deep link: 앱 설치 후 첫 실행에 의도 복원 — 별도 SDK (Branch, Adjust).

## 💻 코드 패턴

### 1. 서버 — AASA
```json
// https://example.com/.well-known/apple-app-site-association
{
  "applinks": {
    "details": [
      {
        "appIDs": ["TEAMID.com.example.MyApp"],
        "components": [
          { "/": "/order/*" },
          { "/": "/user/*", "?": { "ref": "?*" } },
          { "/": "/admin/*", "exclude": true }
        ]
      }
    ]
  }
}
```

응답 헤더: `Content-Type: application/json`. 인증 / redirect X.

### 2. 앱 — Associated Domains
- Xcode → Signing & Capabilities → Associated Domains.
- `applinks:example.com` 추가.

### 3. SwiftUI — handle
```swift
@main
struct MyApp: App {
    var body: some Scene {
        WindowGroup {
            ContentView()
                .onContinueUserActivity(NSUserActivityTypeBrowsingWeb) { activity in
                    guard let url = activity.webpageURL else { return }
                    handle(url)
                }
                .onOpenURL { url in
                    // Custom scheme (myapp://) 또는 system 호출
                    handle(url)
                }
        }
    }

    func handle(_ url: URL) {
        let comps = URLComponents(url: url, resolvingAgainstBaseURL: false)
        guard let path = comps?.path else { return }
        if path.hasPrefix("/order/") {
            let id = String(path.dropFirst("/order/".count))
            navigationStore.navigate(.order(id))
        }
    }
}
```

### 4. UIKit — SceneDelegate
```swift
func scene(_ scene: UIScene, continue userActivity: NSUserActivity) {
    if userActivity.activityType == NSUserActivityTypeBrowsingWeb,
       let url = userActivity.webpageURL { handle(url) }
}

func scene(_ scene: UIScene, openURLContexts contexts: Set<UIOpenURLContext>) {
    if let url = contexts.first?.url { handle(url) }
}
```

### 5. Custom URL scheme (legacy)
```xml
<!-- Info.plist -->
<key>CFBundleURLTypes</key>
<array>
  <dict>
    <key>CFBundleURLSchemes</key>
    <array><string>myapp</string></array>
  </dict>
</array>
```

`myapp://order/123` — but Universal Links 권장 (위조 가능 / 사용자 confusion).

### 6. Deferred deep link
- 앱 미설치 → App Store 이동 → 설치 후 첫 실행 시 원래 의도 복원.
- iOS 자체 지원 X — Branch.io / Adjust / AppsFlyer SDK.

## 🤔 의사결정 기준
| 상황 | 도구 |
|---|---|
| 외부 URL → 앱 진입 | Universal Links |
| OS 가 호출 (인증 callback) | Universal Link 또는 Custom scheme |
| Push notification 안 deep link | userInfo + handle() |
| 위젯 → 앱 deep link | widgetURL |
| 앱 설치 전 URL 의 의도 복원 | Deferred (외부 SDK) |
| QR / NFC | App Clip + invocation URL |

## ❌ 안티패턴
- **AASA 가 redirect 또는 401**: 앱 인식 못 함. CDN / 로컬 호스팅 정확히.
- **AASA 너무 큼 (1MB+)**: 다운로드 안 됨. 작게.
- **Universal Link 가 같은 도메인의 사파리에서 호출**: 앱 안 열림. 다른 앱에서 들어와야.
- **handle 가 navigation 만 — 인증 검증 누락**: 권한 우회 가능.
- **스킴만 의존**: 위조 가능, 다른 앱이 같은 scheme 등록 가능.
- **path / params 검증 없이 직접 사용**: SQL/XSS 인젝션 가능.

## 🤖 LLM 활용 힌트
- AASA + Associated Domains + onContinueUserActivity 3종.
- path / params 검증 (zod-like) 후 navigation.

## 🔗 관련 문서
- [[iOS_App_Clips]]
- [[iOS_Push_Notifications]]
