8.4 KiB
8.4 KiB
id, title, category, status, source_trust_level, verification_status, created_at, updated_at, tags, tech_stack, applied_in, aliases
| id | title | category | status | source_trust_level | verification_status | created_at | updated_at | tags | tech_stack | applied_in | aliases | |||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| mobile-deep-link-verification | Deep Link Verification — Universal / App Links / 검증 | Coding | draft | B | conceptual | 2026-05-09 | 2026-05-09 |
|
|
|
Deep Link Verification
Web URL → app. iOS = Universal Links + AASA, Android = App Links + assetlinks.json. 검증 안 되면 browser 가 처리. Deferred = install 후 의도 복원.
📖 핵심 개념
- Verification: 서버 가 owns this domain.
- AASA (iOS): apple-app-site-association.
- assetlinks (Android): .well-known/assetlinks.json.
- Deferred: install 후 의도 복원 (Branch / Adjust).
💻 코드 패턴
iOS — AASA
// https://example.com/.well-known/apple-app-site-association
// (HTTP/2 + HTTPS + 정확 Content-Type: application/json)
{
"applinks": {
"details": [
{
"appIDs": ["TEAMID.com.example.app"],
"components": [
{ "/": "/order/*" },
{ "/": "/user/*", "?": { "ref": "?*" } },
{ "/": "/admin/*", "exclude": true }
]
}
]
},
"webcredentials": {
"apps": ["TEAMID.com.example.app"]
}
}
iOS — Associated Domains
Xcode → Signing & Capabilities → Associated Domains
+ applinks:example.com
+ webcredentials:example.com # password autofill
// SwiftUI handle
.onContinueUserActivity(NSUserActivityTypeBrowsingWeb) { activity in
guard let url = activity.webpageURL else { return }
handle(url)
}
.onOpenURL { url in
handle(url)
}
func handle(_ url: URL) {
let comps = URLComponents(url: url, resolvingAgainstBaseURL: false)
guard let path = comps?.path else { return }
// ⚠️ Path / params 검증
if path.hasPrefix("/order/") {
let id = String(path.dropFirst("/order/".count))
guard isValidUuid(id) else { return }
navigationStore.navigate(.order(id))
}
}
Android — assetlinks.json
// https://example.com/.well-known/assetlinks.json
[
{
"relation": ["delegate_permission/common.handle_all_urls"],
"target": {
"namespace": "android_app",
"package_name": "com.example.app",
"sha256_cert_fingerprints": ["AA:BB:CC:..."]
}
}
]
→ SHA256 = signing cert. release / debug 둘 다 포함 가능.
# Cert fingerprint 추출
keytool -list -v -keystore my-release-key.keystore
# 또는 Play Console — App signing
Android — Manifest
<activity android:name=".MainActivity" android:exported="true" android:launchMode="singleTask">
<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" />
<data android:host="example.com" />
<data android:pathPattern="/order/.*" />
</intent-filter>
</activity>
→ android:autoVerify="true" = OS 가 install 시 verify.
Android — handle
class MainActivity : AppCompatActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
intent?.data?.let(::handleDeepLink)
}
override fun onNewIntent(intent: Intent) {
super.onNewIntent(intent)
intent.data?.let(::handleDeepLink)
}
private fun handleDeepLink(uri: Uri) {
val path = uri.path ?: return
if (path.startsWith("/order/")) {
val id = path.removePrefix("/order/")
navigate(Screen.Order(id))
}
}
}
Verification check
# iOS
swcutil verify --domain example.com --bundle-identifier com.example.app
# Android
adb shell pm get-app-links com.example.app
# 또는 Play Console → App Links
# Apple validator
https://branch.io/resources/aasa-validator/
검증 실패 원인
iOS:
- AASA 가 redirect / 401
- Content-Type 가 application/json X
- 잘못된 appID (TeamID 정확)
- 옛 cache (다음 install 까지 wait)
Android:
- assetlinks.json HTTP / 잘못
- SHA256 fingerprint 불일치 (debug vs release)
- pathPattern 불일치
- autoVerify 안 한 channel
Cache 무효화
# iOS
xcrun simctl openurl booted https://example.com/test
# Android — clear app links
adb shell pm clear-app-links com.example.app
adb shell pm verify-app-links --re-verify com.example.app
Deferred deep link (install 후 의도 복원)
사용자가 ad → install → first launch.
원래 URL 의 의도 (특정 product) 복원.
iOS / Android 자체 X — 외부 SDK:
- Branch.io
- Adjust
- AppsFlyer
- Singular
// Branch
import branch from 'react-native-branch';
const { params } = await branch.getLatestReferringParams();
if (params.product_id) navigation.navigate('Product', { id: params.product_id });
Custom URL scheme (legacy)
<!-- iOS — Info.plist -->
<key>CFBundleURLTypes</key>
<array>
<dict>
<key>CFBundleURLSchemes</key>
<array><string>myapp</string></array>
</dict>
</array>
<!-- Android -->
<intent-filter>
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data android:scheme="myapp" />
</intent-filter>
→ myapp://order/123 — but Universal/App Links 권장. Custom scheme 위조 가능.
보안 — Path / params 검증
func handle(_ url: URL) {
// 1. Whitelist host
guard url.host == "example.com" else { return }
// 2. Path validation
guard let path = url.path.split(...) else { return }
// 3. Param sanitization
guard let id = url.queryItems["id"], isValidUuid(id) else { return }
// 4. Auth — link 가 login 우회 X
if requiresLogin(path) && !isLoggedIn() {
navigateAfterLogin(.path(path))
return
}
navigate(...)
}
Notification + deep link
// Push payload
{
aps: { alert: { ... } },
link: 'https://example.com/order/42'
}
// Tap notification → openURL
let link = userInfo["link"] as? String
if let url = URL(string: link) { handle(url) }
Web fallback
<!-- App 미설치 시 web 표시 -->
<!-- example.com/order/42 -->
<!DOCTYPE html>
<html>
<head>
<title>Order 42</title>
<meta name="apple-itunes-app" content="app-id=123456789">
<meta property="al:android:url" content="myapp://order/42">
<meta property="al:android:package" content="com.example.app">
</head>
<body>
<p>Open in app</p>
<a href="myapp://order/42">Open in app</a>
<a href="https://apps.apple.com/app/...">Install on iOS</a>
<a href="https://play.google.com/store/apps/...">Install on Android</a>
</body>
</html>
App Clip (iOS) / Instant App (Android)
사용자가 install 안 해도 app 의 작은 부분 실행.
QR / NFC / link 로.
Test
// Maestro
- launchApp:
arguments:
url: "https://example.com/order/42"
# 또는 adb
adb shell am start -W -a android.intent.action.VIEW -d "https://example.com/order/42" com.example.app
# iOS
xcrun simctl openurl booted "https://example.com/order/42"
🤔 의사결정 기준
| 상황 | 추천 |
|---|---|
| 일반 deep link | Universal Links (iOS) + App Links (Android) |
| Marketing campaign | + Deferred (Branch / Adjust) |
| Private (auth callback) | Custom scheme (잠시 OK) |
| App Clip / Instant App | 가벼운 entry point |
| Notification 안 link | Universal Link 또는 custom scheme |
❌ 안티패턴
- AASA / assetlinks 가 redirect: verification 깨짐.
- SHA256 fingerprint 불일치 (release / debug 다름): 옛 release 가 안 됨.
- Path / params 검증 X: SQL / XSS injection.
- Deep link 가 auth 우회: 인증 검사 매번.
- Custom scheme 만 의존: 위조 가능.
- Wildcard path (
*): 의도 외 link. - Web fallback 없음: 미설치 시 dead link.
🤖 LLM 활용 힌트
- AASA + assetlinks + Associated Domains + autoVerify 4종.
- Path / params validate (zod-style).
- Auth check 매번.
- Deferred = Branch / Adjust.