이 글은 누구를 위한 것인가
- 푸시 알림, 이메일, 광고에서 앱 특정 화면으로 바로 연결해야 하는 개발자
- Custom URL Scheme과 Universal Links의 차이가 헷갈리는 팀
- 앱이 설치되지 않은 사용자에게도 매끄러운 경험을 제공하고 싶은 PM
들어가며
"프로모션 이메일을 클릭하면 앱의 특정 상품 페이지로 바로 연결"이라는 요구사항은 간단해 보이지만 구현에는 여러 결정이 필요하다. iOS와 Android 각각의 방식이 다르고, 앱 미설치 시 폴백 처리도 고려해야 한다.
Custom URL Scheme은 구현이 쉽지만 보안 취약점이 있다. Universal Links/App Links는 더 안전하고 UX도 좋지만 서버 설정이 필요하다. 각 방식을 언제 쓰는지 정확히 알아야 한다.
이 글은 bluefoxdev.kr의 딥링크 구현 가이드 를 참고하고, 딥링크 전략 관점에서 확장하여 작성했습니다.
1. 딥링크 방식 비교
[딥링크 방식 3가지]
1. Custom URL Scheme
myapp://products/123
- 구현 쉬움
- 다른 앱이 동일 scheme 등록 가능 (보안 취약)
- 앱 미설치 시 오류 페이지 표시
- iOS/Android 모두 지원
2. Universal Links (iOS) / App Links (Android)
https://www.myapp.com/products/123
- 일반 HTTPS URL 사용
- OS가 앱 설치 여부 확인 후 분기
- 앱 미설치 시 웹 페이지로 폴백
- 서버에 검증 파일 필요
3. Deferred Deep Link (지연 딥링크)
- 앱 미설치 사용자가 클릭 → 스토어 → 설치 후 → 딥링크 목적지
- Firebase Dynamic Links, Branch.io 등 서드파티 활용
- 마케팅/광고에서 주로 사용
| 항목 | Custom Scheme | Universal/App Links | Deferred |
|---|---|---|---|
| 보안 | 취약 | 강함 | 강함 |
| 미설치 폴백 | 오류 | 웹 폴백 | 스토어 → 설치 후 딥링크 |
| 구현 복잡도 | 낮음 | 중간 | 중간(서드파티 활용) |
| 마케팅 추적 | 어려움 | 가능 | 우수 |
2. iOS Universal Links
2.1 서버 설정: Apple App Site Association (AASA)
// https://www.myapp.com/.well-known/apple-app-site-association
// Content-Type: application/json (확장자 없음)
{
"applinks": {
"apps": [],
"details": [
{
"appID": "TEAM_ID.com.myapp.ios",
"paths": [
"/products/*",
"/orders/*",
"/profile",
"NOT /admin/*"
]
}
]
}
}
AASA 파일 요구사항:
- https://domain/.well-known/apple-app-site-association
- 또는 https://domain/apple-app-site-association
- Content-Type: application/json
- 리다이렉트 없이 직접 서빙
- CDN 캐시 주의 (Apple이 CDN 우회해서 접근)
2.2 iOS 앱 설정
// 1. Associated Domains 추가 (Xcode > Signing & Capabilities)
// applinks:www.myapp.com
// applinks:myapp.com
// 2. 딥링크 처리 (SceneDelegate 또는 AppDelegate)
class SceneDelegate: UIResponder, UIWindowSceneDelegate {
func scene(_ scene: UIScene, continue userActivity: NSUserActivity) {
guard userActivity.activityType == NSUserActivityTypeBrowsingWeb,
let url = userActivity.webpageURL else { return }
handle(url: url)
}
// Custom URL Scheme 처리 (병행 사용 시)
func scene(_ scene: UIScene, openURLContexts URLContexts: Set<UIOpenURLContext>) {
guard let url = URLContexts.first?.url else { return }
handle(url: url)
}
private func handle(url: URL) {
DeepLinkRouter.shared.route(url: url)
}
}
// DeepLinkRouter.swift
import UIKit
class DeepLinkRouter {
static let shared = DeepLinkRouter()
enum Destination {
case product(id: String)
case order(id: String)
case profile
case home
}
func route(url: URL) {
guard let destination = parse(url: url) else { return }
navigate(to: destination)
}
private func parse(url: URL) -> Destination? {
let components = URLComponents(url: url, resolvingAgainstBaseURL: false)
let path = url.path
// /products/{id}
if path.hasPrefix("/products/") {
let id = String(path.dropFirst("/products/".count))
return .product(id: id)
}
// /orders/{id}
if path.hasPrefix("/orders/") {
let id = String(path.dropFirst("/orders/".count))
return .order(id: id)
}
// /profile
if path == "/profile" { return .profile }
return .home
}
private func navigate(to destination: Destination) {
// NavigationCoordinator로 위임
DispatchQueue.main.async {
NavigationCoordinator.shared.navigate(to: destination)
}
}
}
3. Android App Links
3.1 서버 설정: Digital Asset Links
// https://www.myapp.com/.well-known/assetlinks.json
[
{
"relation": ["delegate_permission/common.handle_all_urls"],
"target": {
"namespace": "android_app",
"package_name": "com.myapp.android",
"sha256_cert_fingerprints": [
"14:6D:E9:83:C5:73:06:50:D8:EE:B9:95:2F:34:FC:64:16:A0:83:42:E6:1D:BE:A8:8A:04:96:B2:3F:CF:44:E5"
]
}
}
]
# SHA256 fingerprint 추출
keytool -list -v -keystore release.keystore -alias my-key-alias
3.2 AndroidManifest.xml 설정
<activity
android:name=".MainActivity"
android:exported="true">
<!-- Custom URL Scheme -->
<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>
<!-- App Links (HTTPS) -->
<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="www.myapp.com"
android:pathPrefix="/products" />
<data
android:scheme="https"
android:host="www.myapp.com"
android:pathPrefix="/orders" />
</intent-filter>
</activity>
// MainActivity에서 딥링크 처리
class MainActivity : AppCompatActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
// 앱 시작 시 딥링크 처리
handleDeepLink(intent)
}
override fun onNewIntent(intent: Intent) {
super.onNewIntent(intent)
// 앱이 이미 실행 중일 때 딥링크
handleDeepLink(intent)
}
private fun handleDeepLink(intent: Intent) {
val data = intent.data ?: return
DeepLinkRouter.route(this, data)
}
}
object DeepLinkRouter {
fun route(activity: AppCompatActivity, uri: Uri) {
val path = uri.path ?: return
when {
path.startsWith("/products/") -> {
val productId = path.removePrefix("/products/")
activity.startActivity(
ProductDetailActivity.intent(activity, productId)
)
}
path.startsWith("/orders/") -> {
val orderId = path.removePrefix("/orders/")
activity.startActivity(
OrderDetailActivity.intent(activity, orderId)
)
}
path == "/profile" -> {
activity.startActivity(Intent(activity, ProfileActivity::class.java))
}
}
}
}
4. Deferred Deep Link (지연 딥링크)
[지연 딥링크 플로우]
사용자가 광고/이메일 클릭
↓
Branch/Firebase 단축 URL 방문
↓
앱 설치 여부 확인
├── 설치됨 → 앱 열기 → 딥링크 화면 이동
└── 미설치 → App Store/Play Store 이동
→ 설치 완료
→ 앱 첫 실행 시 딥링크 목적지 이동
// Firebase Dynamic Links (Android) - 점진적 지원 종료 예정
// Branch.io 대안 활용 권장
// Branch.io SDK
Branch.getInstance().initSession(uri, object : Branch.BranchReferralInitListener {
override fun onInitFinished(referringParams: JSONObject?, error: BranchError?) {
if (error == null && referringParams != null) {
val productId = referringParams.optString("product_id")
if (productId.isNotEmpty()) {
navigateToProduct(productId)
}
}
}
})
5. 딥링크 테스트
# iOS 딥링크 테스트 (시뮬레이터)
xcrun simctl openurl booted "myapp://products/123"
xcrun simctl openurl booted "https://www.myapp.com/products/123"
# Android 딥링크 테스트
adb shell am start -W -a android.intent.action.VIEW \
-d "myapp://products/123" com.myapp.android
adb shell am start -W -a android.intent.action.VIEW \
-d "https://www.myapp.com/products/123" com.myapp.android
# App Links 검증
adb shell pm verify-app-links --re-verify com.myapp.android
adb shell pm get-app-links com.myapp.android
마무리: 방식별 선택 기준
딥링크 방식 선택 가이드:
마케팅 캠페인 (이메일/광고/SNS):
→ Deferred Deep Link (Branch.io 등)
→ 앱 미설치 사용자 전환 최대화
푸시 알림 내 딥링크:
→ Universal Links / App Links
→ 앱이 설치된 사용자 대상이므로 충분
앱 간 연결 (다른 앱에서 내 앱 열기):
→ Custom URL Scheme + Universal Links 병행
→ Custom Scheme은 Universal Links 미지원 시 폴백
내부 QR 코드/NFC:
→ Universal Links / App Links
→ HTTPS URL이 QR 스캐너에서도 동작