RN → Flutter 전환 시리즈. 딥링크가 실제로 어떻게 라우팅되는지(cold start 처리 등)는 다른 글에서 다룬다. 이 글은 그 앞단, AndroidManifest에서 딥링크가 앱으로 들어오는 입구를 어떻게 구성했는가에 집중한다.

딥링크는 Manifest에서 시작한다

앱이 myapp://... 같은 링크를 받으려면, 먼저 OS에게 **“이 scheme은 내가 처리한다”**고 선언해야 한다. 그 선언이 AndroidManifest.xmlintent-filter다. Flutter든 RN이든 이 부분은 결국 순수 안드로이드 영역이라, 여기가 틀리면 앱 코드가 아무리 완벽해도 링크가 안 들어온다.

MainActivity의 intent-filter

우리 앱 진입 액티비티(MainActivity)에는 intent-filter가 두 개 붙어 있다.

<activity
    android:name=".MainActivity"
    android:exported="true"
    android:launchMode="singleTask"
    ... >

    <!-- 1) 런처 아이콘으로 앱을 켜는 기본 진입 -->
    <intent-filter>
        <action android:name="android.intent.action.MAIN"/>
        <category android:name="android.intent.category.LAUNCHER"/>
    </intent-filter>

    <!-- 2) myapp:// 커스텀 스킴 딥링크 진입 -->
    <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>
</activity>

두 번째 필터가 딥링크의 입구다. 요소 하나하나가 다 이유가 있다.

  • action.VIEW — “이 URI를 열어라”라는 표준 액션.
  • category.DEFAULT — 명시적 컴포넌트 지정 없이 이 액티비티가 후보가 되게 한다.
  • category.BROWSABLE브라우저/웹뷰에서 링크를 눌렀을 때 앱이 뜰 수 있게 한다. 이게 빠지면 웹에서 myapp:// 링크를 눌러도 앱이 안 열린다. 딥링크에서 제일 자주 빠뜨리는 항목.
  • data android:scheme="myapp" — 우리가 소유한 커스텀 스킴.

💡 <data>scheme만 있고 host가 없으면, myapp:// 로 시작하는 모든 링크를 이 앱이 받는다. host별 라우팅(myapp://community-landing/... 등)은 Manifest가 아니라 앱 안의 라우터가 판단한다. Manifest는 “문을 여는” 역할, 라우팅은 앱의 몫으로 역할을 나눴다.

왜 host를 안 나눴나

myapp://community/123, myapp://invite, myapp://community-landing/ownerReview처럼 host가 제각각이다. Manifest에서 host마다 <data>를 늘어놓을 수도 있지만, 그러면 새 딥링크가 생길 때마다 Manifest를 고치고 재배포해야 한다.

그래서 Manifest는 scheme까지만 받고, 그 다음은 전부 앱 라우터가 결정하게 했다. 딥링크 종류가 늘어도 Manifest는 그대로다. 유연성 면에서 이게 훨씬 나았다.

launchMode: singleTop → singleTask

작지만 중요한 변경. 원래 singleTop이었던 걸 singleTask로 바꿨다.

<!-- before -->
android:launchMode="singleTop"
<!-- after -->
android:launchMode="singleTask"

이유는 딥링크로 앱이 여러 번 진입할 때의 태스크/인스턴스 관리 때문이다. singleTask는 이 액티비티를 자기 태스크의 루트로 두고, 이미 떠 있으면 새로 만들지 않고 기존 인스턴스로 onNewIntent를 통해 인텐트를 전달한다. 딥링크가 이미 실행 중인 앱으로 들어올 때 화면이 중복 생성되거나 백스택이 꼬이는 문제를 줄인다.

cold start를 위한 pending 캡처

여기서 Manifest와 네이티브 코드가 만난다. 앱이 **완전히 꺼진 상태(cold start)**에서 딥링크로 켜지면, Flutter 엔진이 준비되기 전에 인텐트가 먼저 도착할 수 있다. 이 타이밍을 놓치면 링크가 유실된다.

그래서 MainActivity(Kotlin)에서 인텐트를 받자마자 pending으로 저장해뒀다가, Flutter가 준비된 뒤 꺼내 쓰게 했다. singleTask라서 onCreate(최초 진입)와 onNewIntent(이미 떠 있을 때) 두 경로 모두 커버해야 한다.

class MainActivity: FlutterActivity() {
    private val PREFS_NAME = "myapp_prefs"
    private val PENDING_KEY = "pending_push_deeplink"

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        capturePendingDeeplink(intent)      // cold start
    }

    override fun onNewIntent(intent: Intent) {
        super.onNewIntent(intent)
        capturePendingDeeplink(intent)      // 이미 실행 중일 때
    }

    private fun capturePendingDeeplink(intent: Intent?) {
        val extras = intent?.extras ?: return
        val url = extras.getString("url") ?: extras.getString("data.url")
        if (!url.isNullOrEmpty()) {
            getSharedPreferences(PREFS_NAME, MODE_PRIVATE)
                .edit()
                .putString(PENDING_KEY, url)
                .apply()
        }
    }
}

Dart 쪽에서는 app_links 플러그인으로 warm/cold 링크 스트림을 받되, 위에서 저장한 native pending 값도 같이 읽어 초기 진입을 처리한다. (이 라우팅 직렬화/중복 방지 로직은 별도 글 주제라 여기선 생략한다.)

곁다리: 다른 scheme들도 여기 산다

같은 Manifest에 딥링크 말고도 여러 콜백용 intent-filter가 공존한다. 정리해두면 나중에 헷갈리지 않는다.

<!-- 카카오 로그인 콜백 (SDK 2.0: AuthCodeHandlerActivity) -->
<activity android:name="com.kakao.sdk.flutter.auth.AuthCodeHandlerActivity" android:exported="true">
  <intent-filter android:label="flutter_web_auth">
      <action android:name="android.intent.action.VIEW" />
      <category android:name="android.intent.category.DEFAULT" />
      <category android:name="android.intent.category.BROWSABLE" />
      <data android:scheme="${kakao_scheme}" android:host="oauth"/>
  </intent-filter>
</activity>

<!-- 애플 로그인 콜백 -->
<activity android:name="com.aboutyou.dart_packages.sign_in_with_apple.SignInWithAppleCallback"
    android:exported="true">
  <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="myappsigninwithapple" />
      <data android:path="callback" />
  </intent-filter>
</activity>
  • 카카오 콜백 scheme은 ${kakao_scheme} 플레이스홀더로 두고 gradle의 manifestPlaceholders로 주입한다. 키를 Manifest에 하드코딩하지 않는다.
  • 이 콜백 액티비티들은 로그인 SDK 전용 진입점이라 MainActivity와 별도 액티비티로 둔다. 딥링크(myapp)와 인증 콜백(kakao, myappsigninwithapple)의 책임을 섞지 않는 게 포인트다.

딥링크를 받으려면 android:exported="true"가 필요하다. Android 12+에서는 intent-filter를 가진 컴포넌트에 exported 명시가 강제되니, 값이 빠지면 아예 빌드가 안 된다.

정리

딥링크 구성은 결국 역할 분담이다.

  • Manifest — “누가 문을 여는가”: scheme 선언(myapp), BROWSABLE, singleTask.
  • 네이티브(MainActivity) — “cold start 인텐트를 놓치지 않기”: onCreate/onNewIntent에서 pending 캡처.
  • 앱 라우터(Dart) — “어디로 보낼 것인가”: host/path 기반 실제 라우팅.

Manifest에 scheme만 받고 host 라우팅은 앱에 맡긴 덕에, 딥링크가 늘어도 Manifest는 손대지 않는다. 그리고 launchModeBROWSABLE 같은 사소해 보이는 속성이 실제로는 “링크가 앱까지 도달하느냐”를 가른다.