React Native(Expo)에서 Universal Links & App Links 구현하기 — 가족 초대 딥링크 삽질 후기
1. 들어가며: 링크 하나로 앱을 열고 싶었습니다
DailyBaby는 아기의 수유, 수면, 기저귀 교체 등을 기록하고, 가족 구성원과 데이터를 공유할 수 있는 육아 기록 앱입니다. 가족 공유 기능을 만들면서, 초대 코드를 받은 사람이 링크 한 번 탭으로 앱이 열리고 바로 초대 수락 화면으로 이동하는 경험을 만들고 싶었습니다.
처음에는 단순한 Custom URL Scheme(dailybaby://invite/CODE)으로 시작했지만, 금방 한계에 부딪혔습니다. 카카오톡이나 메시지 앱에서 dailybaby:// 같은 커스텀 스킴 링크는 클릭해도 동작하지 않는 경우가 많고, 앱이 설치되지 않았을 때 아무 반응이 없어서 사용자 경험이 좋지 않았습니다.
결국 iOS의 Universal Links와 Android의 App Links를 도입하기로 했습니다. https://apporbit.xyz/invite/ABC123 같은 일반 HTTPS 링크를 탭하면, 앱이 설치되어 있으면 바로 앱이 열리고, 설치되지 않았으면 웹 브라우저로 이동하는 방식입니다.
간단해 보이지만, 실제로 구현하면서 꽤 많은 삽질을 했고 그 과정을 공유하려 합니다.
2. 전체 아키텍처: 링크가 앱까지 도달하는 흐름
딥링크의 전체 플로우를 먼저 정리하겠습니다.
공유 → 앱 실행까지
- 초대 생성: 사용자 A가 앱에서 가족 초대 코드를 생성합니다.
- 링크 공유:
https://apporbit.xyz/invite/ABC123형태의 링크가 포함된 메시지를 공유합니다. - OS 가로채기: 수신자가 링크를 탭하면, iOS/Android가 해당 도메인이 앱과 연결되어 있는지 확인합니다.
- 앱 실행: 검증이 완료된 도메인이면 앱이 직접 열리고, URL 정보가 앱에 전달됩니다.
- 화면 이동: 앱 내에서 URL을 파싱하여 초대 수락 화면으로 자동 이동합니다.
이 과정에서 Cold Start(앱이 꺼져 있던 상태에서 열림)와 Warm Start(앱이 백그라운드에 있던 상태에서 열림)를 모두 처리해야 합니다. 이 부분이 생각보다 까다로웠습니다.
3. 기술 스택
- React Native (Expo): Managed workflow 기반, EAS Build 사용
- Expo Router: 파일 기반 라우팅 + 딥링크 자동 매핑
- Supabase: 초대 코드 저장 및 검증, RLS 기반 권한 관리
- Zustand: 딥링크로 받은 초대 코드의 상태 관리 (인증 전 코드를 보관했다가 인증 후 처리)
4. 개발 과정의 챌린지 (Deep Dive)
4-1. 초대 코드 체계 변경: UUID → 6자리 숏코드
초기에는 초대 코드를 UUID로 생성했습니다. 550e8400-e29b-41d4-a716-446655440000 같은 형태죠. 기술적으로는 충돌 가능성이 거의 없어 안전하지만, 실 사용에서 치명적인 문제가 있었습니다.
- 카카오톡 등으로 공유된 링크가 너무 길어서 보기 불편함
- 수동 입력이 사실상 불가능
- URL이 길어지면 일부 메신저에서 링크가 잘리는 현상 발생
그래서 6자리 영문 대문자 + 숫자 조합의 숏코드(ABC123)로 변경했습니다. 데이터베이스 레벨에서 유니크 제약을 걸고, 생성 시 충돌이 발생하면 재생성하는 방식으로 구현했습니다.
// 변경 전
https://apporbit.xyz/invite/550e8400-e29b-41d4-a716-446655440000
// 변경 후
https://apporbit.xyz/invite/ABC123
짧은 코드 덕분에 링크도 깔끔해지고, 사용자가 직접 코드를 입력하는 것도 가능해졌습니다.
4-2. iOS Universal Links 설정
iOS에서 Universal Links가 동작하려면 두 가지 조건이 충족되어야 합니다.
첫째, 서버 측 설정입니다. 도메인의 /.well-known/apple-app-site-association (AASA) 경로에 JSON 파일을 호스팅해야 합니다. 이 파일은 "이 도메인의 특정 경로는 이 앱이 처리한다"는 것을 Apple에 알려주는 역할을 합니다.
// https://yourdomain.com/.well-known/apple-app-site-association
{
"applinks": {
"apps": [],
"details": [
{
"appID": "TEAM_ID.com.your.bundleid",
"paths": ["/invite/*"]
}
]
}
}
주의할 점은, 이 파일은 반드시 HTTPS로 서빙되어야 하고, Content-Type이 application/json이어야 합니다. 리다이렉트도 허용되지 않습니다. 처음에 이 파일을 CDN 뒤에 두었더니 캐시 문제로 Apple의 검증이 실패해서 한참을 헤맸습니다.
둘째, 앱 측 설정입니다. Expo의 app.config.js에서 Associated Domains를 추가합니다.
// app.config.js (pseudo)
ios: {
associatedDomains: ["applinks:yourdomain.com"]
}
이렇게 설정하면 빌드 시 iOS entitlements 파일에 자동으로 반영됩니다. 다만, Expo Go에서는 Universal Links가 동작하지 않습니다. 반드시 EAS Build로 실제 빌드를 만들어서 테스트해야 합니다. 이 사실을 몰라서 "왜 안 되지?" 하며 시간을 꽤 낭비했습니다.
4-3. Android App Links 설정
Android도 비슷한 구조입니다. /.well-known/assetlinks.json 파일을 서버에 호스팅하고, 앱에서 Intent Filter를 설정합니다.
// https://yourdomain.com/.well-known/assetlinks.json
[
{
"relation": ["delegate_permission/common.handle_all_urls"],
"target": {
"namespace": "android_app",
"package_name": "com.your.packagename",
"sha256_cert_fingerprints": ["YOUR_SHA256_FINGERPRINT"]
}
}
]
Android에서 가장 헷갈렸던 부분은 SHA-256 인증서 지문입니다. 개발용 키스토어와 프로덕션 키스토어의 지문이 다르기 때문에, Google Play Console에 업로드한 앱 서명 키의 지문을 사용해야 합니다. EAS Build를 사용하는 경우 eas credentials 명령어로 확인할 수 있습니다.
Expo 설정에서는 intentFilters에 autoVerify: true를 추가합니다.
// app.config.js (pseudo)
android: {
intentFilters: [
{
action: "VIEW",
autoVerify: true,
data: {
scheme: "https",
host: "yourdomain.com",
pathPrefix: "/invite"
},
category: ["BROWSABLE", "DEFAULT"]
}
]
}
autoVerify: true가 빠지면 Android는 사용자에게 "이 링크를 어떤 앱으로 열까요?"라는 선택 다이얼로그를 보여줍니다. 이 옵션을 설정하면 시스템이 자동으로 assetlinks.json을 검증하고, 검증이 완료되면 바로 앱이 열립니다.
4-4. Cold Start vs Warm Start 처리
딥링크 구현에서 가장 까다로운 부분은 앱의 상태에 따라 URL을 받는 방식이 다르다는 점입니다.
- Cold Start: 앱이 완전히 종료된 상태에서 링크를 탭하면, 앱이 부팅되면서 URL이 전달됩니다. React Native의
Linking.getInitialURL()로 받습니다. - Warm Start: 앱이 백그라운드에 있는 상태에서 링크를 탭하면, 이벤트 리스너를 통해 URL이 전달됩니다.
Linking.addEventListener("url", handler)로 받습니다.
문제는 Cold Start 시에는 앱이 아직 초기화되지 않은 상태에서 URL이 들어온다는 것입니다. 인증 확인도 안 됐고, 네비게이션 스택도 준비되지 않았는데 "초대 수락 화면으로 이동해"라는 요청이 들어오는 셈입니다.
이 문제를 해결하기 위해, 딥링크로 받은 초대 코드를 Zustand 스토어에 임시 저장해두고, 인증이 완료된 후에 해당 코드가 있으면 자동으로 초대 수락 플로우를 시작하도록 구현했습니다.
// 처리 흐름 (pseudo)
// 1. 앱 시작 시 (Cold Start)
initialURL = getInitialURL()
if (initialURL contains "/invite/") {
code = parseInviteCode(initialURL)
store.setPendingInviteCode(code) // 일단 저장
}
// 2. 앱 실행 중 (Warm Start)
onURLEvent(url) {
code = parseInviteCode(url)
if (isAuthenticated) {
navigateToFamily(code) // 바로 이동
} else {
store.setPendingInviteCode(code) // 저장 후 대기
}
}
// 3. 인증 완료 후
useEffect(() => {
if (pendingInviteCode) {
showInviteModal(pendingInviteCode)
store.clearPendingInviteCode()
}
}, [isAuthenticated])
이 패턴 덕분에 앱 미설치 → 스토어 설치 → 첫 로그인 → 바로 초대 수락이라는 흐름까지 자연스럽게 처리할 수 있었습니다.
4-5. RLS(Row Level Security) 권한 문제
딥링크 자체와는 별개로, 초대 수락 과정에서 Supabase의 RLS 정책 때문에 예상치 못한 오류를 만났습니다. 초대를 수락하면 family_members 테이블에 새로운 row를 INSERT해야 하는데, 기존 RLS 정책은 "아기의 소유자만 family_members를 관리할 수 있다"로 설정되어 있었습니다.
초대를 수락하는 사람은 당연히 아기의 소유자가 아니기 때문에, INSERT가 차단되는 상황이었습니다. 이 문제는 초대 수락 로직을 SECURITY DEFINER 함수로 분리하여 해결했습니다. RLS를 우회하되, 함수 내부에서 초대 코드의 유효성과 상태를 직접 검증하는 방식입니다.
4-6. 디버깅 팁
Universal Links / App Links 구현 중 디버깅이 정말 어려웠습니다. 몇 가지 유용했던 방법을 공유합니다.
- iOS: Apple의 AASA Validator로 AASA 파일이 정상적으로 접근 가능한지 확인할 수 있습니다.
- Android:
adb shell am start -a android.intent.action.VIEW -d "https://yourdomain.com/invite/TEST"명령어로 App Links 동작을 테스트할 수 있습니다. - 공통: 시뮬레이터에서는 Universal Links가 제대로 동작하지 않는 경우가 많습니다. 실 기기 테스트가 필수입니다.
- iOS 캐시 주의: Apple은 앱 설치 시점에 AASA 파일을 다운로드하고 캐시합니다. 서버 설정을 변경한 후에는 앱을 삭제하고 재설치해야 변경 사항이 반영됩니다.
5. 마치며
Universal Links와 App Links는 개념 자체는 단순하지만, 실제 구현에서는 서버 설정, 앱 설정, 인증서, 캐시, 앱 상태 관리 등 여러 요소가 맞물려야 동작하는 까다로운 기능이었습니다.
특히 Expo 환경에서는 Expo Go의 제약 때문에 매번 EAS Build를 해야 해서 피드백 루프가 길어진다는 점이 아쉬웠지만, app.config.js 하나로 iOS와 Android 설정을 동시에 관리할 수 있다는 점은 큰 장점이었습니다.
정리하면 핵심은 세 가지입니다.
- 서버에 검증 파일을 정확히 호스팅할 것 (AASA, assetlinks.json)
- Cold Start와 Warm Start를 모두 처리할 것 (상태 저장 패턴 활용)
- 실 기기에서 테스트할 것 (시뮬레이터와 Expo Go는 믿지 말 것)
DailyBaby는 현재 iOS와 Android 모두 출시되어 있으니, 육아 중이신 분들은 한번 사용해보시면 감사하겠습니다.
👉 iOS 다운로드 | Android 다운로드