안드로이드 위클리 공부 노트

#749

딥 링크는 받는 쪽과 길 찾는 쪽으로 나눈다 — KMP에서 딥 링크 다루기

원문 Box, Box: Deep Links in Kotlin Multiplatform, KMP Bits

한 줄 요약

딥 링크(알림·메일·공유 주소를 눌렀을 때 첫 화면이 아니라 앱 안의 특정 화면을 바로 여는 링크)를 Android와 iOS가 화면 이동 코드를 함께 쓰는 Kotlin Multiplatform 앱에서 어떻게 나눌지 정리한 글이다. 저자의 답은 하나다. 플랫폼은 URL을 문자열로 받아서 넘기기만 하고, 그 문자열을 읽어 어느 화면으로 갈지 정하는 일은 전부 공용 코드가 한다. 그 위에 앱이 꺼져 있을 때와 켜져 있을 때를 둘 다 받기, 내비게이션이 준비되기 전에 온 링크를 잠시 보류하기, 이상한 링크를 안전하게 버리기 같은 실전 함정을 짚는다.

원문(저자 사이트 kmpbits.com에 먼저 실림)은 "All Rights Reserved"(모든 권리 보유)로 표시되어 있고, 같은 코드를 담은 공개 라이선스 저장소도 찾지 못했다. 그래서 이 글의 코드는 원문 코드를 옮긴 것이 아니라, 원문이 설명하는 구조를 따라 이름과 예시를 바꿔 직접 쓴 스케치다. 예시 앱은 "할 일 앱"(todoapp://task/42)으로 바꿨다. 원문 표지는 경주용 차 사진이라 옮기지 않았고, 대신 본문 구조를 그림으로 새로 그렸다.

먼저 알아 둘 말

서론 — "박스, 박스"

1. 딥 링크의 두 반쪽

딥 링크는 "받는 반쪽"과 "길 찾는 반쪽"으로 나뉘고, 둘은 다른 곳에 있어야 한다.

Android와 iOS가 각자 방식으로 링크를 받아 URL 문자열 하나로 넘기고, 공용 코드의 파서·목적지 타입·핸들러가 화면 이동을 정하는 구조
원문 본문이 설명하는 구조를 한국어로 새로 그림(원문에는 이 그림이 없다). 원문: KMP Bits, Medium

2. 공용 라우터 — 문자열을 목적지로 바꾼다

① 갈 수 있는 곳을 타입으로 정해 둔다

링크로 열 수 있는 화면을 commonMain 에 sealed interface로 정해 둔다.

// commonMain — 직접 쓴 스케치 (원문 구조를 따라 이름을 바꿈)
sealed interface LinkTarget {
    data object Home : LinkTarget
    data class TaskDetail(val taskId: String) : LinkTarget
    data class Project(val projectId: String) : LinkTarget
    data object Invalid : LinkTarget   // 원문의 Unknown 자리
}

② 파서 — 주소를 잘라 목적지를 고른다

원문은 주소를 나누는 데 Ktor의 Url 을 쓴다. 이미 KMP를 지원하고, 정규식(글자 패턴을 기호로 적는 방법)을 직접 짜다가 나중에 후회할 일을 줄여 주기 때문이다.

여기서 원문이 짚은 함정이 하나 있다.

// commonMain — 직접 쓴 스케치
import io.ktor.http.Url

object LinkParser {
    fun parse(raw: String): LinkTarget {
        val url = runCatching { Url(raw) }.getOrNull() ?: return LinkTarget.Invalid

        // todoapp://task/42 는 "task" 가 host 로 읽힌다 → https 모양과 맞추려고 앞에 붙인다
        val parts = buildList {
            if (url.protocol.name !in setOf("http", "https")) add(url.host)
            addAll(url.segments)
        }.filter { it.isNotBlank() }

        val id = parts.getOrNull(1)
        return when (parts.firstOrNull()) {
            null -> LinkTarget.Home
            "task" -> id?.let(LinkTarget::TaskDetail) ?: LinkTarget.Invalid
            "project" -> id?.let(LinkTarget::Project) ?: LinkTarget.Invalid
            else -> LinkTarget.Invalid
        }
    }
}

③ 두 모양을 함께 테스트한다

원문은 커스텀 스킴과 https 주소가 같은 목적지로 가는지 하나의 테스트로 확인한다. 위의 함정을 다시 밟지 않게 묶어 두는 것이다.

// commonTest — 직접 쓴 스케치
class LinkParserTest {
    @Test
    fun 커스텀_스킴과_https가_같은_화면으로_간다() {
        val expected = LinkTarget.TaskDetail("42")
        assertEquals(expected, LinkParser.parse("todoapp://task/42"))
        assertEquals(expected, LinkParser.parse("https://example.com/task/42"))
    }
}

④ Unknown 이 보기보다 중요한 이유

⑤ 핸들러 — 플랫폼이 부르는 단 하나의 입구

파싱과 화면 이동을 잇는 입구를 하나만 두고, 두 플랫폼이 모두 이것만 부른다.

// commonMain — 직접 쓴 스케치
class LinkHandler(private val navigator: AppNavigator) {
    fun handle(raw: String) {
        when (val target = LinkParser.parse(raw)) {
            is LinkTarget.TaskDetail -> navigator.openTask(target.taskId)
            is LinkTarget.Project -> navigator.openProject(target.projectId)
            LinkTarget.Home, LinkTarget.Invalid -> navigator.openHome()
        }
    }
}

3. Android — URL 받기

① 매니페스트에 "이 주소는 내 앱이 연다"고 등록한다

원문은 같은 Activity에 커스텀 스킴과 https App Link를 함께 등록한다.

<!-- AndroidManifest.xml — 직접 쓴 스케치 -->
<activity
    android:name=".MainActivity"
    android:exported="true"
    android:launchMode="singleTask">

    <!-- todoapp://task/42 -->
    <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="todoapp" />
    </intent-filter>

    <!-- https://example.com/task/42 — 확인 파일로 검증받는 App Link -->
    <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="example.com" />
    </intent-filter>
</activity>

② 링크를 두 곳에서 읽는다 — onCreate 와 onNewIntent

// Android — 직접 쓴 스케치
class MainActivity : ComponentActivity() {

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        // 콜드 스타트: 링크가 앱을 켰다.
        // 화면 회전처럼 Activity가 다시 만들어질 때도 같은 intent가 붙어 있으므로,
        // 처음 만들어질 때(savedInstanceState == null)만 처리한다.
        if (savedInstanceState == null) {
            intent?.data?.toString()?.let(linkHandler::handle)
        }
        setContent { App() }
    }

    override fun onNewIntent(intent: Intent) {
        super.onNewIntent(intent)
        // 웜 스타트: 이미 켜진 앱에 새 링크가 왔다.
        intent.data?.toString()?.let(linkHandler::handle)
    }
}

③ App Link 확인 파일 — assetlinks.json

https 링크를 "앱으로 열까요, 브라우저로 열까요?" 고르는 창 없이 바로 앱으로 열려면, 웹 사이트에 확인 파일을 올려야 한다.

[
  {
    "relation": ["delegate_permission/common.handle_all_urls"],
    "target": {
      "namespace": "android_app",
      "package_name": "com.example.todoapp",
      "sha256_cert_fingerprints": ["AA:BB:…(실제 서명 인증서의 SHA-256 지문)"]
    }
  }
]

4. iOS — 같은 문자열, 다른 문

iOS도 링크를 받는 방법이 둘이다.

{
  "applinks": {
    "details": [
      {
        "appIDs": ["TEAMID1234.com.example.todoapp"],
        "components": [
          { "/": "/task/*" },
          { "/": "/project/*" }
        ]
      }
    ]
  }
}

링크를 받는 코드는 앱의 시작점에 따라 다르다. KMP 화면을 SwiftUI 껍데기 안에 띄우는 경우, 원문은 SwiftUI의 생명주기 수정자(modifier)를 쓴다.

// iOS — 직접 쓴 스케치
import SwiftUI
import shared // KMP 공용 코드 프레임워크

@main
struct TodoApp: App {
    var body: some Scene {
        WindowGroup {
            ComposeRootView()
                // todoapp://task/42
                .onOpenURL { url in
                    LinkBridge.shared.handle(raw: url.absoluteString)
                }
                // https://example.com/task/42
                .onContinueUserActivity(NSUserActivityTypeBrowsingWeb) { activity in
                    if let url = activity.webpageURL {
                        LinkBridge.shared.handle(raw: url.absoluteString)
                    }
                }
        }
    }
}

5. 화면 이동에 연결하기 — 너무 일찍 온 링크

놓치기 쉬운 타이밍 문제가 있다. 콜드 스타트로 들어온 링크는 화면 이동 구조가 만들어지기 전에 도착한다.

원문의 해결: 보류 칸(pending) 하나를 둔다.

// commonMain — 직접 쓴 스케치
class LinkHandler(private val navigator: AppNavigator) {
    private var pending: LinkTarget? = null

    fun handle(raw: String) {
        val target = LinkParser.parse(raw)
        if (navigator.isReady) go(target) else pending = target
    }

    fun onNavigatorReady() {
        pending?.let(::go)
        pending = null
    }

    private fun go(target: LinkTarget) = when (target) {
        is LinkTarget.TaskDetail -> navigator.openTask(target.taskId)
        is LinkTarget.Project -> navigator.openProject(target.projectId)
        LinkTarget.Home, LinkTarget.Invalid -> navigator.openHome()
    }
}

6. 브라우저 없이 테스트하기

링크를 담은 웹 페이지가 없어도 된다. 두 플랫폼 모두 명령줄에서 앱에 주소를 쏠 수 있다. 콜드·웜 두 경우를 확인하는 가장 빠른 방법이기도 하다.

Android(기기나 에뮬레이터를 연결한 상태):

adb shell am start -a android.intent.action.VIEW -d "todoapp://task/42" com.example.todoapp

iOS 시뮬레이터:

xcrun simctl openurl booted "todoapp://task/42"

어떻게 확인하나:

7. 내보내기 전에 꼭 확인할 것

8. 저자의 정리

원문을 읽을 때 주의할 점

코드로 보기 — Aide 구조로 옮긴 스케치

원문 코드가 아니다. Aide는 KMP가 아니라 Android 전용 앱이고, 1-b에서 알림을 누르면 Aide 안의 특정 화면(예: 완료 확인할 할 일 카드)을 여는 일이 생긴다. 이 글의 구조를 Aide의 app 모듈(단일 Activity + Navigation 3)에 옮기면 어떤 모양일지 직접 쓴 스케치다. Navigation 3의 실제 API 이름은 Task 4에서 공식 문서로 확정한다.

// app/.../navigation/AideLink.kt

// ① 알림이 Intent 에 실어 보내는 주소: aide://todo/12 · aide://home
//    파서는 순수 Kotlin 이라 JVM 단위 테스트로 검사한다.
object AideLinkParser {
    fun parse(raw: String?): List<NavKey> {
        val uri = raw?.let { runCatching { java.net.URI(it) }.getOrNull() }
            ?: return listOf(HomeKey)
        if (uri.scheme != "aide") return listOf(HomeKey)
        val id = uri.path.trim('/').toLongOrNull()
        return when (uri.host) {
            // ② 뒤로 가기 목록을 함께 만든다 — 뒤로 가면 첫 화면
            "todo" -> if (id != null) listOf(HomeKey, TodoKey(id)) else listOf(HomeKey)
            else -> listOf(HomeKey)
        }
    }
}

// ③ MainActivity — 콜드는 처음 뒤로 가기 목록으로, 웜은 지금 목록을 바꿔서
class MainActivity : ComponentActivity() {
    private val incoming = MutableStateFlow<List<NavKey>?>(null)

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        val start = if (savedInstanceState == null) AideLinkParser.parse(intent?.dataString) else null
        setContent { AideApp(initialBackStack = start ?: listOf(HomeKey), incomingLink = incoming) }
    }

    override fun onNewIntent(intent: Intent) {
        super.onNewIntent(intent)
        incoming.value = AideLinkParser.parse(intent.dataString)
    }
}

배운 점

Aide에 적용하면

Aide는 웹 사이트도 공유 링크도 없는 Android 전용 앱이다. 그래서 https App Links는 쓸 일이 없다. 하지만 "알림을 누르면 앱 안의 특정 화면으로 간다" 는 딥 링크의 받는 쪽·길 찾는 쪽 문제를 그대로 가지고 있다.

  1. 알림이 여는 화면을 주소 하나로 받는다 — 가져온다.
    • 1-b의 "완료 확인" 알림처럼 Aide 안의 화면을 여는 알림은, PendingIntent(알림을 눌렀을 때 실행할 동작을 미리 담아 두는 Android 객체)의 Intent에 aide://todo/12 같은 주소를 넣어 MainActivity 로 보낸다.
    • 화면마다 Intent에 다른 값(extra)을 넣고 Activity에서 하나하나 꺼내는 대신, 위 스케치처럼 파서 하나·목적지 타입·처리하는 곳 하나로 모은다.
    • Aide 1-a 계획(Task 12)도 이미 "알람마다 PendingIntent 가 겹치지 않게 Intent의 data URI에 key를 넣는다"고 적어 두었다. 같은 생각을 화면 이동에도 쓰는 셈이다.
  2. singleTask 와 onCreate·onNewIntent 둘 다 받기 — 가져온다. 시점은 Task 4다.
    • 지금 Aide의 MainActivity 매니페스트에는 launchMode 가 없다.
    • Task 4(앱 셸 — 단일 Activity + Navigation 3)를 만들 때 처음부터 singleTask 와 onNewIntent 를 넣어 두면, 1-b에서 알림을 붙일 때 고칠 곳이 없다.
  3. 보류 칸 — 바꿔서 가져온다.
    • Navigation 3는 뒤로 가기 목록을 우리가 들고 있는 리스트로 다룬다. 그래서 콜드 스타트는 원문의 보류 칸 대신 링크로 처음 목록을 만들어 넘긴다(스케치 ③).
    • Task 4에서 Navigation 3의 실제 API(rememberNavBackStack 등)가 처음 목록을 받는 방식을 확인하고 정한다. 처음 목록을 넘기기 어렵다면 원문 방식의 보류 칸으로 돌아간다.
  4. 뒤로 가기 목록을 일부러 만든다 — 가져온다.
    • 알림에서 할 일 카드로 왔을 때 뒤로 가기 → 첫 화면이 자연스럽다. Aide의 첫 화면은 아바타가 있는 "앱의 얼굴"이라 돌아갈 곳으로도 맞다.
  5. 이상한 링크와 이미 사라진 대상 — 가져온다.
    • 알림을 받은 뒤 사용자가 그 할 일을 이미 지웠을 수 있다. 할 일 화면은 번호로 찾지 못하면 앱을 죽이지 말고 첫 화면으로 돌아가며 짧게 알려 준다.
    • Aide 리마인더가 이미 따르는 원칙("울리기 전에 그 일정이 아직 있는지 한 번 더 확인하고, 없으면 조용히 넘어간다")과 같은 방향이다.
  6. 1-a의 리마인더 알림 — 해당 없다.
    • 리마인더 알림은 이미 "누르면 폰 기본 캘린더 앱에서 그 일정을 연다"로 정했다. Aide 안의 화면으로 오지 않으니 이 구조가 필요 없다.
  7. 08:30 숫자 알림을 누르면 어디로 가는지 — 아직 정해지지 않았다. 정해야 한다.
    • 1-a 설계(4.5절)와 Task 14에는 알림을 띄우는 것까지만 있고, 누르면 어떤 화면이 열리는지가 없다.
    • 자연스러운 답은 Aide 첫 화면(aide://home)이다. 1-b에서는 그 순간 미리 만들어 둔 AI 브리핑을 아바타가 바로 말하게 된다. Task 14를 할 때 이 한 줄을 정한다.
  8. https App Links와 확인 파일 — 지금은 안 한다.
    • Aide에는 웹 사이트나 남에게 보내는 공유 링크가 없다. 알림은 앱 스스로 만드는 것이라 검증이 필요 없다.
    • 2차의 GitHub 로그인을 브라우저 방식으로 만든다면, 로그인이 끝난 뒤 브라우저가 앱으로 돌아오는 주소(예: aide://oauth/github?code=…&state=…)가 바로 커스텀 스킴 딥 링크다. 그때는 원문의 "밖에서 온 값은 믿지 않는다"에 따라 state 값(로그인을 시작할 때 앱이 만든 임의 값)이 내가 보낸 것과 같은지 꼭 검사한다. 커스텀 스킴은 다른 앱도 등록할 수 있기 때문이다.

출처