모바일 딥링크 전략: Universal Links vs App Links vs Custom Scheme 선택 가이드

모바일

딥링크Universal LinksApp Links모바일 라우팅모바일 마케팅

이 글은 누구를 위한 것인가

  • 푸시 알림, 이메일, 광고에서 앱 특정 화면으로 바로 연결해야 하는 개발자
  • 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 SchemeUniversal/App LinksDeferred
보안취약강함강함
미설치 폴백오류웹 폴백스토어 → 설치 후 딥링크
구현 복잡도낮음중간중간(서드파티 활용)
마케팅 추적어려움가능우수

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 스캐너에서도 동작