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 누락 (이 세 가지가 열에 여덟이다).
딥링크와 Universal Links의 차이는 무엇인가
일단 용어부터 정리하자. 딥링크(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://path
https://myapp.com/path
https://myapp.com/path
도메인 소유 증명
불필요
AASA 파일 필수
assetlinks.json 필수
앱 미설치 시 동작
에러
웹으로 폴백
웹으로 폴백
SMS·메일 안정성
낮음 (차단됨)
높음
높음
OAuth 콜백
가능
가능
가능
주 용도
내부 라우팅, 결제 복귀
공유 링크, 마케팅
공유 링크, 마케팅
커스텀 스킴 딥링크 설정하기
Expo 프로젝트에서는 app.json에 scheme 한 줄만 넣으면 iOS와 Android 양쪽의 커스텀 스킴이 자동으로 설정된다. 프리빌드 단계에서 이 값이 Info.plist의 CFBundleURLTypes와 AndroidManifest.xml의 <intent-filter>로 변환되기 때문이다. 스킴 이름은 앱 번들 ID와 관계없어도 되지만, 소문자, 숫자, +, -, .만 허용된다. 그리고 다른 앱과 충돌하지 않도록 앱 이름을 포함시키는 게 안전하다.
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)가 있으면 그 순간 검증이 실패한다.
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 경로에 다음을 서빙한다.
설치 후 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을 푸시 알림 딥링크 처리 가이드와 함께 쓰는 팀에서 자주 나오는 실전 구성은 다음과 같다.
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 다운로드 상태와 오류 원인이 보인다.
지연 딥링크(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는 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% 안팎으로 떨어질 수 있습니다.