React Native 딥링크 완벽 가이드 2026: Universal Links · App Links · Expo Router 실전

React Native 딥링크 세 종류(커스텀 스킴, Universal Links, App Links)를 Expo Router 5와 React Native 0.81 기준으로 설정하고, AASA·assetlinks.json 서빙부터 실기기 테스트, 트러블슈팅까지 실전 코드로 정리합니다.

React Native 딥링크 완벽 가이드 2026

업데이트: 2026년 9월 18일

React Native 딥링크는 myapp:// 같은 커스텀 URI 스킴이나 https://myapp.com/product/42 같은 실제 웹 URL을 눌렀을 때, 웹 브라우저 대신 네이티브 앱의 특정 화면을 열어주는 기술이다. 2026년 기준 실무에서는 세 가지 딥링크가 공존한다. 커스텀 스킴 딥링크, iOS의 Universal Links, 그리고 Android의 App Links. 이 글은 Expo Router 5.x와 Expo SDK 54, React Native 0.81 기준으로 세 가지를 모두 한 앱에서 안정적으로 동작시키는 방법을 처음부터 끝까지 정리한다. 필자가 지난 프로젝트에서 새벽 3시에 AASA 캐시 때문에 고생했던 경험도 함께 녹여둔다.

  • 딥링크에는 커스텀 스킴, Universal Links(iOS), App Links(Android) 세 가지가 있으며, 실무 앱은 세 가지를 모두 구현해야 유입 손실이 없다.
  • Universal Links는 apple-app-site-association 파일을, App Links는 .well-known/assetlinks.json 파일을 HTTPS로 서빙해야 하며 리다이렉트, MIME, 서명 검증이 필수다.
  • Expo Router 5는 파일 기반 라우팅을 딥링크 경로로 자동 매핑하므로 별도의 linking 설정 없이도 app/product/[id].tsx가 /product/42로 열린다.
  • iOS 시뮬레이터에서는 xcrun simctl openurl, Android 에뮬레이터에서는 adb shell am start -W -a android.intent.action.VIEW로 딥링크를 검증한다.
  • 지연 딥링크(Deferred Deep Linking)는 OS가 기본 지원하지 않으므로 Branch, Adjust, AppsFlyer 같은 어트리뷰션 SDK가 필요하다.
  • Universal Links가 열리지 않는 대부분의 원인은 AASA 파일의 Content-Type 오류, 캐시된 도메인 데이터, App ID Prefix 누락 (이 세 가지가 열에 여덟이다).

일단 용어부터 정리하자. 딥링크(Deep Link)는 넓은 개념이고, Universal Links와 App Links는 그 안에 속하는 특정 구현이다. 커스텀 스킴 딥링크는 myapp://product/42 같은 URL이고, 앱이 설치되어 있으면 곧바로 앱을 열지만 앱이 없으면 브라우저에 "이 페이지를 열 수 없습니다" 에러가 뜬다. 반면 Universal Links(iOS)와 App Links(Android)는 진짜 HTTPS URL이라서, 앱이 있으면 앱을 열고 없으면 그 URL이 가리키는 웹페이지가 자연스럽게 열린다.

또 하나 결정적인 차이는 신뢰다. 커스텀 스킴은 누구나 Info.plist나 AndroidManifest.xml에 같은 스킴을 등록할 수 있어서 이론적으로 하이재킹이 가능하다. 하지만 Universal Links와 App Links는 도메인 소유권을 서버 파일로 증명해야 하기 때문에 위조가 불가능하다. 솔직히 말해서, 2026년의 iOS 26과 Android 15+ 환경에서는 SMS, 이메일, QR 코드에서 커스텀 스킴을 아예 차단하는 경우가 점점 늘고 있다. 그래서 마케팅 링크는 반드시 https:// 기반 Universal/App Links로 발행해야 한다. 커스텀 스킴은 내부 화면 이동, 인앱 결제 리다이렉트, OAuth 콜백처럼 도메인 신뢰가 필요 없는 흐름에만 남겨두는 게 요즘의 정석이다.

항목커스텀 스킴Universal Links (iOS)App Links (Android)
URL 형식myapp://pathhttps://myapp.com/pathhttps://myapp.com/path
도메인 소유 증명불필요AASA 파일 필수assetlinks.json 필수
앱 미설치 시 동작에러웹으로 폴백웹으로 폴백
SMS·메일 안정성낮음 (차단됨)높음높음
OAuth 콜백가능가능가능
주 용도내부 라우팅, 결제 복귀공유 링크, 마케팅공유 링크, 마케팅

커스텀 스킴 딥링크 설정하기

Expo 프로젝트에서는 app.json에 scheme 한 줄만 넣으면 iOS와 Android 양쪽의 커스텀 스킴이 자동으로 설정된다. 프리빌드 단계에서 이 값이 Info.plist의 CFBundleURLTypes와 AndroidManifest.xml의 <intent-filter>로 변환되기 때문이다. 스킴 이름은 앱 번들 ID와 관계없어도 되지만, 소문자, 숫자, +, -, .만 허용된다. 그리고 다른 앱과 충돌하지 않도록 앱 이름을 포함시키는 게 안전하다.

// app.json
{
  "expo": {
    "name": "MyApp",
    "slug": "my-app",
    "scheme": "myapp",
    "ios": {
      "bundleIdentifier": "com.acme.myapp"
    },
    "android": {
      "package": "com.acme.myapp"
    }
  }
}

순수 React Native(CLI) 프로젝트라면 직접 손을 봐야 한다. iOS는 ios/MyApp/Info.plist에 다음 블록을 추가한다.

<key>CFBundleURLTypes</key>
<array>
  <dict>
    <key>CFBundleURLName</key>
    <string>com.acme.myapp</string>
    <key>CFBundleURLSchemes</key>
    <array>
      <string>myapp</string>
    </array>
  </dict>
</array>

Android는 android/app/src/main/AndroidManifest.xml의 메인 액티비티에 인텐트 필터를 추가하면 된다. 참고로 android:autoVerify="true"는 App Links용이므로 커스텀 스킴에는 붙이지 않는다.

<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>

Universal Links의 핵심은 도메인이 앱을 알고, 앱이 도메인을 안다는 상호 검증이다. 서버 쪽 파일이 apple-app-site-association(줄여서 AASA)이고, 앱 쪽 설정은 Associated Domains capability다. iOS 14 이후로는 Apple의 CDN이 이 파일을 대신 가져다 캐싱한다. 즉, 앱을 재설치할 때마다 사용자 기기가 직접 요청하지 않는다는 뜻이다. 배포 후 즉시 반영되지 않는다는 점을 반드시 기억해두자 (필자는 이걸 몰라서 새벽에 스테이징을 세 번 갈아엎은 적이 있다).

일단 https://myapp.com/.well-known/apple-app-site-association 경로에 다음 JSON을 서빙한다. 확장자는 없고, Content-Type은 application/json이어야 하며, HTTPS 인증서가 유효해야 하고, 리다이렉트(3xx)가 있으면 그 순간 검증이 실패한다.

{
  "applinks": {
    "details": [
      {
        "appIDs": ["ABCDE12345.com.acme.myapp"],
        "components": [
          { "/": "/product/*", "comment": "상품 상세" },
          { "/": "/auth/callback", "comment": "OAuth 콜백" },
          { "/": "/promo/*", "comment": "프로모션 랜딩" }
        ]
      }
    ]
  }
}

ABCDE12345는 Apple Developer 계정의 Team ID이고 그 뒤가 Bundle Identifier다. Xcode에서 Signing & Capabilities → + Capability → Associated Domains를 추가하고 applinks:myapp.com을 넣는다. Expo에서는 app.json에 다음을 추가하면 프리빌드가 자동으로 처리해준다.

// app.json
{
  "expo": {
    "ios": {
      "bundleIdentifier": "com.acme.myapp",
      "associatedDomains": ["applinks:myapp.com"]
    }
  }
}

공식 규격과 컴포넌트 매칭 문법은 Apple의 Supporting Associated Domains 문서가 최종 진리다. 특히 ?(쿼리)와 #(프래그먼트) 매칭, exclude 규칙, 다중 도메인 처리 방법은 앱 규모가 커지면 반드시 필요해진다. EAS 배포 자체가 처음이라면 먼저 EAS Build와 EAS Update 가이드부터 훑어보길 권한다.

Android App Links는 iOS와 개념은 같지만 서명 지문(SHA-256)까지 요구한다는 점이 다르다. Google Play가 서명을 관리하는 경우(App Signing by Google Play), 로컬 keystore가 아니라 Play Console의 App integrity 화면에서 확인한 App signing key certificate의 SHA-256을 사용해야 한다. 이걸 놓치면 아무리 재설치해도 링크가 브라우저로 빠져나간다. 팀에 신규 안드로이드 개발자가 합류할 때마다 한 번씩은 꼭 걸리는 함정이다.

https://myapp.com/.well-known/assetlinks.json 경로에 다음을 서빙한다.

[
  {
    "relation": ["delegate_permission/common.handle_all_urls"],
    "target": {
      "namespace": "android_app",
      "package_name": "com.acme.myapp",
      "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"
      ]
    }
  }
]

Expo 앱에서는 app.json에 인텐트 필터를 정의하면 프리빌드가 AndroidManifest.xml에 android:autoVerify="true"와 함께 심어준다.

// app.json
{
  "expo": {
    "android": {
      "package": "com.acme.myapp",
      "intentFilters": [
        {
          "action": "VIEW",
          "autoVerify": true,
          "data": [
            {
              "scheme": "https",
              "host": "myapp.com",
              "pathPrefix": "/product"
            },
            {
              "scheme": "https",
              "host": "myapp.com",
              "pathPrefix": "/auth"
            }
          ],
          "category": ["BROWSABLE", "DEFAULT"]
        }
      ]
    }
  }
}

설치 후 adb shell pm get-app-links com.acme.myapp로 검증 상태가 verified인지 확인한다. verified가 아니라 none 또는 1024:false 같은 값이 뜨면, assetlinks.json 서명이 잘못됐거나 앱이 시스템에 다시 인덱싱되지 않은 것이다. Android 12+에서는 사용자가 설정 → 앱 → 기본 열기에서 수동으로 링크를 앱에 연결해줄 수도 있으니, 스토어 리뷰에 그 경로를 안내해두면 클레임이 확 줄어든다.

Expo Router에서 딥링크 다루기

Expo Router 5의 큰 장점 중 하나는 딥링크 매핑을 자동으로 처리한다는 점이다. app/ 폴더의 파일 구조가 곧 URL 구조이기 때문에 별도의 linking 설정이 필요 없다. 그러니까 app/product/[id].tsx가 있으면 myapp://product/42도, https://myapp.com/product/42도 같은 화면을 연다. 문법 안내와 팁은 Expo Router v5 파일 기반 라우팅 가이드에서 자세히 다뤘다.

다만 초기 URL을 화면 내부에서 활용하려면 expo-linking과 expo-router의 훅을 직접 다뤄야 한다. 아래 예시는 앱이 딥링크로 열렸을 때 파라미터를 로깅하고, 필요하면 리다이렉트하는 패턴이다.

// app/product/[id].tsx
import { useEffect } from 'react';
import { useLocalSearchParams, useRouter } from 'expo-router';
import * as Linking from 'expo-linking';

export default function ProductScreen() {
  const { id } = useLocalSearchParams<{ id: string }>();
  const router = useRouter();

  useEffect(() => {
    // 유효하지 않은 id는 홈으로 되돌린다
    if (!/^\d+$/.test(id)) {
      router.replace('/');
      return;
    }

    // 콜드 스타트로 열린 최초 URL만 별도 처리하고 싶을 때
    Linking.getInitialURL().then((url) => {
      if (url) console.log('cold-start deep link:', url);
    });
  }, [id]);

  return null; // 실제 UI
}

Expo Router는 내부적으로 스킴을 자동 감지하지만, 웹 도메인은 앱 코드가 알아야 한다. Linking.createURL('product/42')를 호출하면 개발 중에는 exp://192.168.x.x:19000/--/product/42, 프로덕션에서는 myapp://product/42가 생성된다. 그래서 인앱 공유 시트에 붙일 URL은 반드시 createURL을 통해 만드는 게 안전하다.

React Navigation의 linking 설정

Expo Router를 쓰지 않고 순수 React Navigation 7을 쓰는 프로젝트라면 NavigationContainer의 linking prop에 매핑을 직접 정의해야 한다. 이 방식은 명시적이지만, 화면이 늘어날수록 관리 비용이 커진다. React Navigation을 푸시 알림 딥링크 처리 가이드와 함께 쓰는 팀에서 자주 나오는 실전 구성은 다음과 같다.

// App.tsx
import { NavigationContainer, LinkingOptions } from '@react-navigation/native';
import * as Linking from 'expo-linking';

const linking: LinkingOptions<RootStackParamList> = {
  prefixes: [
    Linking.createURL('/'),          // myapp://
    'https://myapp.com',             // Universal / App Links
    'https://*.myapp.com'
  ],
  config: {
    screens: {
      Home: '',
      Product: 'product/:id',
      AuthCallback: 'auth/callback',
      NotFound: '*'
    }
  },
  async getInitialURL() {
    // 푸시 알림에서 넘어온 딥링크도 함께 처리
    const url = await Linking.getInitialURL();
    if (url) return url;
    const response = await Notifications.getLastNotificationResponseAsync();
    return response?.notification.request.content.data?.url ?? null;
  }
};

export default function App() {
  return (
    <NavigationContainer linking={linking} fallback={<SplashScreen />}>
      {/* ... */}
    </NavigationContainer>
  );
}

getInitialURL을 커스터마이즈하면 콜드 스타트로 열린 알림의 데이터 페이로드까지 라우팅에 흘려보낼 수 있다. 이 부분은 백그라운드 알림 탭 처리와 헷갈리기 쉬우니, Linking.addEventListener('url', ...)와의 역할 차이를 정확히 이해해두자. 콜드 스타트는 getInitialURL, 이미 실행 중인 앱은 addEventListener가 담당한다. 딱 두 줄 요약이지만 실무에서 반복해서 헷갈리는 지점이다.

딥링크는 실기기, 시뮬레이터, 에뮬레이터에서 각각 검증 방법이 다르다. 자주 쓰는 커맨드부터 정리해두면 QA 사이클이 몇 분 단위로 짧아진다. 아래 스니펫만 팀 위키에 붙여둬도 신규 QA 분들이 훨씬 빨리 붙는다.

# iOS 시뮬레이터 - 커스텀 스킴
xcrun simctl openurl booted "myapp://product/42"

# iOS 시뮬레이터 - Universal Link
xcrun simctl openurl booted "https://myapp.com/product/42"

# Android 에뮬레이터/기기 - 커스텀 스킴
adb shell am start -W -a android.intent.action.VIEW \
  -d "myapp://product/42" com.acme.myapp

# Android - App Link (패키지 지정 없이, 사용자가 브라우저에서 누른 것과 동일)
adb shell am start -W -a android.intent.action.VIEW \
  -d "https://myapp.com/product/42"

# Android - App Links 검증 상태 확인
adb shell pm get-app-links com.acme.myapp

실기기에서는 iMessage로 링크를 자신에게 보내거나, 메모 앱에 붙여놓고 롱프레스 → 열기로 확인하는 방식이 정석이다. Safari 주소창에서 붙여넣기로 여는 것은 iOS가 Universal Links를 무시하고 웹으로 열도록 설계되어 있어서 신뢰할 수 없다. 개발 중인 iOS 앱을 다시 링크에 연결하려면 앱 삭제 후 재설치가 가장 확실한 방법이고, macOS의 Console 앱에서 swcd 프로세스를 필터링하면 AASA 다운로드 상태와 오류 원인이 보인다.

자동화 관점에서는 Maestro 같은 도구를 활용해 딥링크 진입 후 UI 상태까지 검증하는 편이 안전하다. E2E 자동화 흐름은 React Native 테스트 가이드의 Maestro 섹션에 실행 가능한 스크립트 예시가 있다.

지연 딥링크와 어트리뷰션

지연 딥링크(Deferred Deep Linking)는 앱이 설치되지 않은 상태에서 링크를 눌렀을 때, 스토어로 이동해 설치 후 첫 실행에서 원래 목적지로 이어주는 흐름이다. 그런데 iOS와 Android 어느 쪽도 OS 차원에서 이걸 기본 제공하지 않는다. 그래서 Branch, Adjust, AppsFlyer, Kochava 같은 어트리뷰션 SDK가 존재한다. 이들은 클릭 시점의 IP, User-Agent, 타임스탬프를 서버에 기록해두었다가, 설치 후 첫 실행에서 같은 지문을 매칭해 원본 URL을 앱에 되돌려준다.

정확도는 100%가 아니라 90% 안팎이다. 특히 iOS 14.5 이후의 App Tracking Transparency 프롬프트 응답에 따라 크게 떨어진다. 프로모션 캠페인의 클릭당 단가를 실제 화면 단위 전환과 이어붙이려면 Universal/App Links만으로는 부족하고 어트리뷰션 SDK가 필수다. 반면 이미 설치된 앱 사용자가 링크를 통해 특정 화면으로 오는 경우라면 어트리뷰션 SDK가 없어도 Universal/App Links로 충분하다. 조직 규모와 마케팅 예산에 맞춰 판단하자.

Universal Links가 열리지 않을 때 체크리스트

실전에서 열에 아홉은 다음 다섯 가지 중 하나다. 순서대로 짚어보자.

  1. AASA/assetlinks.json 헤더: 브라우저나 curl -I로 응답 헤더를 확인해 Content-Type이 application/json인지, 3xx 리다이렉트가 없는지, HTTPS 인증서가 유효한지 검증한다. Cloudflare Rocket Loader나 Vercel의 트레일링 슬래시 리다이렉트가 자주 범인이다.
  2. App ID Prefix 오탈자: ABCDE12345.com.acme.myapp에서 앞부분은 Team ID이지 App ID Prefix가 아닌 경우도 있다. Apple Developer 포털에서 정확한 값을 복사한다.
  3. SHA-256 지문: Play 관리 서명이 켜져 있으면 로컬 keystore 지문은 무용지물이다. Play Console의 App integrity에서 서명 지문을 다시 복사한다.
  4. 캐시: iOS는 앱 재설치가 필요하고, Android는 adb shell pm verify-app-links --re-verify com.acme.myapp로 재검증을 강제할 수 있다.
  5. 스킴 충돌: 같은 도메인을 여러 앱이 등록했거나, 안드로이드에서 사용자가 다른 앱을 기본으로 지정해둔 경우가 있다. 설정에서 초기화한다.

이 다섯 가지를 훑고도 문제가 남는다면, 서버 로그로 Apple의 CDN 요청(User-Agent: AASA-Bot)이 실제로 파일을 받아갔는지 확인한다. 앱 설치 직후 몇 초간 swcd가 재시도하는 로그도 Console 앱에서 관찰할 수 있어서, 로컬 iOS 문제인지 서버 문제인지 빠르게 분리된다. 관련 문제는 Android App Links 검증 공식 문서와 Apple의 Allowing apps and websites to link to your content가 가장 정확한 참고 자료다.

자주 묻는 질문

딥링크와 Universal Links는 어떻게 다른가요?

딥링크는 앱의 특정 화면을 여는 링크 전체를 뜻하는 상위 개념이고, Universal Links는 iOS가 도메인 소유권을 AASA 파일로 검증하는 HTTPS 기반 딥링크의 구현체입니다. 커스텀 스킴 딥링크(myapp://)와 달리 Universal Links는 앱이 없어도 웹으로 폴백되고, 스킴 하이재킹이 불가능합니다.

Expo에서도 Universal Links가 되나요?

됩니다. app.json의 ios.associatedDomains에 applinks:myapp.com을 추가하고, 해당 도메인의 /.well-known/apple-app-site-association에 AASA JSON을 서빙하면 EAS Build가 Associated Domains capability까지 자동으로 붙여줍니다. 관리 워크플로에서도 그대로 동작합니다.

iOS 시뮬레이터에서 딥링크는 어떻게 테스트하나요?

xcrun simctl openurl booted "myapp://product/42" 명령이 표준입니다. Universal Links는 시뮬레이터에서 AASA 검증이 제한적이라 실기기 테스트가 최종 검증에 필요하며, iMessage로 자기 자신에게 링크를 보내 눌러 확인하는 방식이 가장 안정적입니다.

assetlinks.json은 어떤 SHA-256을 넣어야 하나요?

Google Play가 서명을 관리하면(App Signing by Google Play) Play Console의 App integrity → App signing key certificate의 SHA-256을 사용해야 합니다. 로컬 keystore로 서명한 APK를 사이드로드해서 테스트할 때는 로컬 keystore의 SHA-256도 함께 넣어두면 두 환경 모두에서 검증됩니다.

지연 딥링크는 앱스토어가 지원하지 않나요?

iOS와 Android 어느 스토어도 앱 미설치 상태의 원본 URL을 설치 후 앱에 전달하는 기능을 기본 제공하지 않습니다. 정확도 있는 지연 딥링크가 필요하면 Branch, Adjust, AppsFlyer 같은 어트리뷰션 SDK를 통합해야 하며, iOS 14.5 이후 ATT 응답에 따라 매칭 정확도는 90% 안팎으로 떨어질 수 있습니다.

Jake Morrison
저자 소개 Jake Morrison

React Native lead engineer who's shipped six apps and learned six different lessons. Bullish on the New Architecture.