앱의 특정 화면으로 들어가는 공유 링크는 앱이 이미 설치된 환경까지만 보면 어렵지 않다. HTTPS 링크를 앱 화면과 연결하면 된다. 앱이 없으면 이야기가 달라진다. 사용자는 스토어로 이동해 앱을 설치할 수 있지만, 설치가 끝난 뒤에는 원래 보려던 화면 정보가 사라진다.
요구사항은 단순했다. 사용자가 https://example.com/action/item?id=123을 누르고 앱을 설치한 뒤에도 item?id=123으로 이어져야 했다. 그런데 당시 서비스 사이트는 아임웹에서 운영하고 있었다. 화면을 만들 때는 보이지 않던 호스팅 제약이 딥링크를 연결하는 순간 드러났다.
이 글은 그 제약 때문에 별도 프로덕션 링크 사이트를 만들고, 서비스 안에서 검증한 코드를 두 개의 오픈소스 패키지로 분리한 기록이다. 목표는 특정 서비스의 딥링크 코드를 공개하는 데 그치지 않았다. 도메인과 앱별 식별 설정만 교체하면 코어 구현을 수정하지 않고 다른 서비스에서도 그대로 사용할 수 있는 구조를 만드는 것이었다.
먼저 요약하면: 도메인과 앱 설정만 교체한다
두 패키지는 특정 도메인이나 앱에 고정되어 있지 않다. 운영할 도메인과 앱 식별자, URL scheme, 스토어 주소, payload prefix를 설정으로 주입한다. 그러면 플랫폼 판별, 원본 URL 보존, 스토어 전달, 설치 후 URL 복원 로직은 같은 구현을 재사용한다.
| 프로젝트마다 교체하는 설정 | 패키지가 그대로 제공하는 구현 |
|---|---|
| canonical domain과 허용 domain | 원래 path와 query를 보존한 HTTPS URL 조립 |
| iOS Bundle ID에 연결된 scheme과 App Store 주소 | 앱 실행 시도와 Pasteboard payload 생성 |
| Android package ID와 Play Store 대상 | Intent와 Install Referrer URL 생성 |
| 앱별 prefix와 referrer key | 설치 후 payload 파싱과 공통 결과 반환 |
여기서 "앱 설정"은 iOS Bundle ID와 Android package ID만 뜻하지 않는다. 해당 앱을 가리키는 scheme, 스토어 주소, payload 식별 값까지 포함한다. 이 값들을 설정 객체에서 맞추면 웹 송신 모듈과 React Native 수신 모듈의 코드는 바꿀 필요가 없다.
아임웹에서 막힌 것은 화면이 아니라 소유권 증명이었다
Android App Links와 iOS Universal Links는 앱 설정만으로 끝나지 않는다. 운영하는 도메인도 "이 앱이 이 도메인의 링크를 열어도 된다"는 문서를 제공해야 한다.
- Android는
/.well-known/assetlinks.json에 패키지 이름과 서명 인증서의 SHA-256 지문을 둔다. - iOS는
/.well-known/apple-app-site-association에 App ID와 허용할 경로를 둔다.
두 파일은 정해진 경로에서 HTTPS로 응답해야 한다. 리다이렉트 없이 파일을 내려주는 것도 중요하다. Android 문서와 Apple 문서 모두 웹 서버에서 이 연결 문서를 제공하도록 요구한다.
당시 사용하던 아임웹 환경에서는 /.well-known 아래에 파일을 올려 원하는 응답을 만들 수 없었다. 디자인이나 CMS 기능의 문제가 아니었다. 도메인과 앱의 관계를 증명할 엔드포인트를 직접 제어할 수 없다는 점이 문제였다.
App Links와 Universal Links는 이미 설치된 앱을 여는 기술이다. Deferred Deep Link는 설치 전 클릭 정보를 설치 후 앱 실행까지 넘긴다. 서로 이어져 있지만 같은 기능은 아니다. /.well-known 파일을 제공한다고 설치 후 목적지가 저절로 복원되지는 않는다. 웹과 스토어, 앱 사이에 별도의 전달 규칙이 필요하다.
전체 흐름은 세 층으로 나뉜다
App Links, custom scheme, Install Referrer는 하나의 흐름에 등장하지만 실행 시점과 책임이 다르다.
| 계층 | 실행 시점 | 맡은 일 |
|---|---|---|
| 도메인과 OS 연결 | 설치된 앱에서 HTTPS 링크를 누를 때 | 도메인과 앱의 연결을 확인하고 앱으로 진입시킨다. |
| 웹 송신 측 | 링크가 브라우저에서 열릴 때 | 원래 URL을 보존하고 앱 실행 또는 스토어 이동을 준비한다. |
| 앱 수신 측 | 설치 후 앱이 시작될 때 | 플랫폼별 전달값에서 원래 URL을 복원해 앱 라우터에 넘긴다. |
첫 번째 층은 일반 딥링크를 담당한다. 두 번째와 세 번째 층이 설치 전후를 연결하면서 Deferred Deep Link가 된다. 이 구분에 따라 웹과 앱의 구현 범위도 갈렸다.
먼저 프로덕션 링크 사이트부터 만들었다
아임웹의 일부 페이지만 우회하는 방식보다 링크 진입점을 직접 운영하는 편이 단순했다. Next.js에 /action/[...slug] 형태의 catch-all 라우트를 만들고, 이 라우트가 경로와 쿼리를 그대로 보존한 채 플랫폼별 흐름을 선택하도록 했다.
flowchart LR
A[공유 HTTPS 링크] --> B{OS가 연결된 앱으로 열었는가}
B -->|예| C[App Links 또는 Universal Links로 앱 실행]
B -->|아니오| D[자체 링크 사이트]
D --> E[iOS App Store 또는 Google Play]
E --> F[설치 후 앱 실행]
F --> G[원래 URL 복원]
G --> H[앱 라우터가 목적 화면으로 이동]
한 링크를 끝까지 따라가 보면
예를 들어 사용자가 https://example.com/action/item?id=123을 눌렀다고 하자.
- 앱이 설치되어 있고 OS 연결이 성립하면 앱이 바로 URL을 받는다.
- 브라우저로 진입하면 링크 사이트가
/item과id=123을 원래 HTTPS URL로 조립한다. - iOS는 앱 실행을 시도한 뒤 App Store 버튼을 누르는 시점에 URL을 Pasteboard payload로 남긴다.
- Android는 같은 URL을 Play Store의 referrer에 포함한다.
- 설치 후 앱이 수신 모듈을 호출하면 플랫폼별 채널에서 URL을 읽는다.
- 앱 라우터는 반환된 URL을 해석해
item?id=123에 해당하는 화면으로 이동한다.
사용자가 보는 것은 하나의 링크지만, 내부에서는 OS 연결, 웹 송신, 스토어 이동, 앱 수신이 차례로 이어진다. 링크 사이트는 요청의 경로와 쿼리로 원래 URL을 복원하고, User-Agent에 따라 앱 실행이나 스토어 이동을 준비한다. 설치 후 값을 읽는 책임은 앱 모듈로 분리했다.
구현하면서 의외로 오래 잡힌 부분은 쿼리 문자열이었다. 다른 URL이 쿼리 값으로 들어오면 Next.js의 URLSearchParams를 거치는 동안 다시 인코딩될 수 있다. 웹 모듈은 미들웨어에서 request.nextUrl.search를 x-raw-search 헤더에 보존하고, 페이지가 원문 그대로 URL을 조립할 수 있게 했다.
입력 ?returnTo=https%3A%2F%2Fexample.com%2Fdetail%3Fid%3D123
보존 request.nextUrl.search -> x-raw-search
복원 canonicalHost + routePrefix + path + rawSearch
쿼리를 객체로 바꿨다가 다시 문자열로 만들면 의미는 같아도 표현이 달라질 수 있다. 이 문자열은 다시 scheme URL이나 referrer의 일부가 되므로, Next.js helper는 해석하지 않고 다음 계층으로 전달한다.
처음에는 여기까지가 제품 작업이었다. 프로덕션에서 흐름을 확인하고 나니, 한 서비스의 라우트로만 남겨 두기에는 아까웠다. 다른 도메인이나 다른 앱에서도 결국 같은 전달 코드를 다시 만들어야 했기 때문이다. 오픈소스화의 기준은 이때 정했다.
제품 고유 값만 바꾸면 같은 전달 규칙을 다시 사용할 수 있을까?
도메인과 앱 식별자만 바꿔 재사용하도록 만들었다
처음부터 멀티테넌트 딥링크 서비스를 만들 생각은 없었다. 패키지가 여러 앱을 등록하고 알아서 분기하는 구조도 아니다. 대신 도메인과 앱에 종속되는 값을 코드 밖으로 꺼냈다. 상위 애플리케이션이 요청에 맞는 설정을 선택하고, 라이브러리는 받은 설정으로 URL을 계산한다.
웹 모듈의 설정에는 다음 값이 들어간다.
import type { DeepLinkConfig } from "@bdmakers/react-deferred-link-web";
export const deepLinkConfig: DeepLinkConfig = {
allowedHosts: ["example.com", "www.example.com"],
canonicalHost: "www.example.com",
routePrefix: "/action",
ddlPrefix: "myapp:ddl:",
ddlReferrerKey: "ddl",
ios: {
appScheme: "myapp://",
appStoreUrl: "https://apps.apple.com/app/id0000000000",
},
android: {
packageId: "com.example.app",
},
webFallbackUrl: "https://www.example.com",
};
앱 모듈도 같은 계약에 맞춰 초기화한다.
import { DeferredLink } from "@bdmakers/react-native-deferred-link";
DeferredLink.configure({
domains: ["example.com", "www.example.com"],
ios: {
pasteboardPrefix: "myapp:ddl:",
pasteboardTTLSeconds: 900,
},
android: {
installReferrerParamKey: "ddl",
},
});
두 설정은 따로 존재하지만 몇 가지 값은 반드시 같은 프로토콜을 가리켜야 한다.
| 웹 송신 측 | 앱 수신 측 | 맞춰야 하는 이유 |
|---|---|---|
canonicalHost, allowedHosts | domains | 앱이 허용된 도메인의 URL만 소비하도록 한다. |
ddlPrefix | ios.pasteboardPrefix | iOS가 자기 앱의 Pasteboard payload를 식별한다. |
ddlReferrerKey | android.installReferrerParamKey | Android가 Install Referrer에서 목적지 URL을 찾는다. |
ios.appScheme | 앱의 URL Scheme 설정 | 웹이 설치된 iOS 앱을 먼저 열어 본다. |
android.packageId | Android application ID | Intent와 Play Store 대상 앱을 지정한다. |
여러 도메인을 쓸 수 있다고 해서 하나의 설정이 모든 도메인을 자동 운영하지는 않는다. 각 도메인은 자기 /.well-known 문서를 제공해야 한다. 요청을 받은 애플리케이션도 적절한 설정을 직접 선택한다. 패키지 안에 앱 registry는 없다. 각 앱이 자신의 scheme, package ID, prefix, referrer key로 같은 엔진을 구성하는 방식이다.
공개 저장소의 커밋 이력도 이 순서를 보여 준다. 2026년 3월 27일 React Native 구현을 기존 모노레포에서 추출했고, 3월 30일에는 웹 구현을 core, react, nextjs로 나눈 뒤 테스트와 npm 배포 설정을 붙였다. 라이브러리를 먼저 설계한 것이 아니라, 제품에서 작동한 코드를 꺼내며 공개 API의 경계를 다듬었다.
추출할 때는 먼저 웹과 앱이 주고받을 payload 형식을 고정했다. 이어 도메인, scheme, package ID, prefix를 설정으로 옮기고, URL 조립과 플랫폼 판별을 순수 함수로 분리했다. 제품 UI는 render prop 뒤로, 분석 도구는 tracker 함수 뒤로 밀어냈다. 앱 모듈에서는 화면 이동을 제거하고 URL 결과만 반환했다. 프로덕션에서 확인한 전달 규칙을 기준으로 삼으니 공통 코드와 제품 코드의 경계가 선명해졌다.
헤드리스 설계는 UI를 없애는 일보다 책임을 줄이는 일이었다
링크 랜딩 화면의 문구와 레이아웃, 분석 도구, 앱 라우터는 제품마다 다르다. 그래서 두 패키지는 전달 규칙만 제공하고 제품의 UI와 화면 이동은 결정하지 않도록 나눴다.
@bdmakers/react-deferred-link-web
├── core URL, payload, platform, UTM을 다루는 내부 순수 함수
├── react 플랫폼별 상태와 최소 기본 UI
└── nextjs 원본 쿼리를 보존하는 미들웨어
@bdmakers/react-native-deferred-link
├── TypeScript 공통 설정과 반환 타입
├── Kotlin Android Install Referrer 수신
└── Objective-C++ iOS UIPasteboard 수신
웹의 core는 React에 의존하지 않는다. URL과 payload를 만들고 플랫폼을 판별하는 함수만 담았다. React 컴포넌트는 기본 화면을 제공하지만, render prop으로 전체 UI를 교체할 수 있다. 이벤트 추적도 tracker 함수를 주입한다. Next.js 전용 코드는 별도 entry point로 분리했다. 빌드할 때 React와 Next.js를 번들에 넣지 않고 peer dependency로 남긴 이유도 같다.
<DeepLinkIosClient {...deepLinkProps}>
{({ phase, handleStoreRedirect, handleWebFallback }) =>
phase === "opening" ? (
<p>앱을 여는 중...</p>
) : (
<div>
<button onClick={handleStoreRedirect}>App Store에서 받기</button>
<button onClick={handleWebFallback}>웹에서 계속하기</button>
</div>
)
}
</DeepLinkIosClient>
컴포넌트는 opening과 not-installed 상태, 이동 handler만 소유한다. 버튼의 모양과 문구는 사용하는 서비스가 결정한다. 라이브러리는 landing, 앱 실행 시도, 스토어 이동 이벤트를 tracker에 넘길 뿐, 어느 분석 도구에 기록할지는 알지 못한다.
공개 import 경로도 역할에 맞춰 나눴다. 루트 패키지는 core 함수와 React 컴포넌트를 함께 내보내고, /react는 React 타입과 컴포넌트, /nextjs는 raw query 미들웨어를 제공한다. React와 Next.js는 빌드 결과에 포함하지 않고 외부 의존성으로 남겨 소비 프로젝트의 버전을 따른다.
앱 모듈에는 컴포넌트와 훅이 없다. 네이티브에서 설치 전환 정보를 읽어 구조화된 결과만 JavaScript에 반환하므로 특정 네비게이터나 제품 route schema에 의존하지 않는다.
React Native 쪽은 새 아키텍처에만 고정하지 않았다. TypeScript의 TurboModule Spec을 Codegen 계약으로 두고, JavaScript 진입점에서는 TurboModule을 먼저 찾은 뒤 기존 NativeModules bridge로 폴백한다. Builder Bob은 ESM 코드와 TypeScript 선언을 만들어 npm 패키지로 묶는다. 네이티브 연결 방식이 달라도 JavaScript API는 같다.
웹 모듈은 링크를 보내는 쪽만 맡는다
@bdmakers/react-deferred-link-web은 요청 URL을 앱 실행 URL과 스토어 URL로 바꾸는 송신 측 모듈이다. 공통 계약은 "원래 HTTPS URL을 설치 후 다시 돌려준다"는 데 두고, 설치 경계를 넘는 채널만 플랫폼에 맞췄다.
| 구분 | iOS | Android |
|---|---|---|
| 앱 실행 시도 | custom URL scheme | intent:// URL |
| 설치 이동 시 전달 | 버튼 클릭 시 Pasteboard에 payload 기록 | Play Store referrer에 URL 포함 |
| 앱에서 읽는 채널 | UIPasteboard | InstallReferrerClient |
| 시간 정보 | 웹이 timestamp를 payload에 포함 | Install Referrer API의 클릭 시각 사용 |
iOS는 사용자 클릭 시점에 Pasteboard를 쓴다
iOS에서는 먼저 custom URL scheme으로 앱 실행을 시도한다. 페이지가 2.5초 뒤에도 보이는 상태라면 미설치 안내 화면으로 전환한다. 이 시간은 앱 설치 여부를 정확히 판별하는 값이 아니다. 브라우저가 그대로 남아 있는지를 보고 fallback UI를 선택하는 기준이다.
사용자가 App Store 버튼을 누르면 웹이 다음 형식의 문자열을 Pasteboard에 복사한다.
<prefix><unix_timestamp>|<deep_link_url>
Safari의 Clipboard API는 사용자 동작 없이 쓰기 어렵다. 그래서 페이지 진입과 동시에 복사하지 않고 스토어 버튼을 누르는 순간 payload를 기록한다. 그다음 App Store로 이동한다.
Android는 Play Install Referrer에 URL을 싣는다
Android에서는 원래 HTTPS URL, package ID, Play Store fallback을 포함한 intent:// URL을 만든다. Intent를 처리할 앱이 있으면 앱으로 열리고, 처리할 앱이 없으면 브라우저가 S.browser_fallback_url을 따라 Play Store로 이동한다.
Play Store URL에는 다음 형태의 referrer가 들어간다.
referrer=ddl=<encoded_deep_link_url>
Desktop에서는 앱 실행을 추정하지 않고 두 스토어 링크만 보여 준다. iOS와 Android 컴포넌트는 허용된 UTM 키만 추려 주입된 tracker로 전달한다.
앱 모듈은 설치 후 전달값을 읽고 소비 상태를 남긴다
@bdmakers/react-native-deferred-link은 설치 후 앱이 호출했을 때 링크를 복원하는 수신 측 모듈이다. 첫 실행 여부를 따로 판별하지는 않는다. DeferredLink 객체가 제공하는 메서드는 configure(), getInitialDeferredLink(), clearConsumedDeferredLink() 세 개다.
const result = await DeferredLink.getInitialDeferredLink();
if (result.found && result.url) {
// URL 해석과 화면 이동은 앱의 라우터가 맡는다.
openDeferredRoute(result.url);
}
Android는 Google Play Install Referrer에서 설정한 키를 찾는다. URL의 host가 허용 도메인이나 그 하위 도메인인지 검사하고, 통과한 결과에 android_install_referrer source를 붙여 반환한다. 소비 여부는 SharedPreferences에 기록한다.
iOS는 메인 스레드에서 전역 UIPasteboard를 읽는다. prefix가 맞는 payload만 파싱하고, timestamp가 있으면 기본 900초 TTL을 확인한다. 도메인 검증까지 통과하면 ios_pasteboard source와 URL을 반환하고 NSUserDefaults에 소비 상태를 기록한다.
두 플랫폼의 결과 모양은 같다.
type DeferredLinkResult = {
found: boolean;
source: "android_install_referrer" | "ios_pasteboard" | "none";
url?: string;
rawValue?: string;
clickedAt?: number;
isFirstLaunch?: boolean;
metadata?: Record<string, string>;
};
isFirstLaunch는 공개 타입에 남아 있지만 현재 Android와 iOS 구현은 이 값을 채우지 않는다. 실제 중복 처리는 첫 실행 플래그가 아니라 내부 소비 상태로 막는다.
여기서 "소비"는 성공한 URL의 반복 처리를 막는 내부 플래그다. 모듈은 JavaScript에 결과를 넘기기 전에 상태를 기록하며, 마지막 URL을 다시 꺼내는 공개 API는 없다. 라우팅 실패를 복구해야 한다면 앱 상태에 결과를 보관해야 한다.
clearConsumedDeferredLink()도 시스템 원본이 아니라 SharedPreferences나 NSUserDefaults의 내부 상태만 비운다. 같은 Play referrer나 Pasteboard 문자열이 남아 있다면 다시 읽힐 수 있으므로 테스트나 재시도 흐름에서만 의미를 알고 호출해야 한다.
외부 프로젝트에 붙이는 순서
두 저장소를 따로 보면 웹과 앱 중 어디서 시작해야 할지 헷갈릴 수 있다. 실제 적용은 URL 계약에서 시작하는 편이 낫다.
- 외부에서 유지할 canonical HTTPS URL의 path와 query 규칙을 먼저 정한다.
- 도메인의 연결 문서와 앱의 Associated Domains 또는 intent filter를 구성한다.
- 웹에서 catch-all 라우트를 만들고 플랫폼별 앱 URL과 스토어 URL을 조립한다.
- Next.js에서 중첩 URL을 받는다면 raw query 미들웨어를 해당 라우트에 연결한다.
- 제품 UI는 render prop으로 만들고 필요한 분석 adapter를
tracker에 연결한다. - React Native 앱 시작 시
configure()를 한 번 호출하고, 라우터가 준비된 뒤 초기 URL 결과를 처리한다. - 설치됨과 미설치, iOS와 Android, Desktop, 중첩 query를 나눠 확인한다.
이 순서를 따르면 URL 계약 -> 웹 송신 -> 플랫폼 전달 -> 앱 수신 -> 제품 라우팅의 경계를 한 단계씩 확인할 수 있다. 두 패키지는 /.well-known 문서, 네이티브 링크 설정, 앱 route schema를 자동으로 만들지 않는다. 각 프로젝트가 세 지점을 직접 연결해야 한다.
오픈소스로 꺼내며 확인한 경계
웹 저장소에는 core 순수 함수를 다루는 Vitest 단위 테스트가 26개 있다. 플랫폼 판별 6개, iOS와 Android payload 2개, URL과 scheme, Store, Intent 조립 14개, UTM 추출 4개로 나뉜다. 브라우저 없이 확인할 수 있는 변환 규칙부터 자동화한 셈이다.
릴리스 전 확인 범위는 자동 검사와 실제 환경 검증으로 나눠서 봐야 한다.
| 검증 축 | 자동 검사 | 실제 환경에서 확인할 부분 |
|---|---|---|
| 플랫폼 판별 | User-Agent별 결과 | 모바일 브라우저와 인앱 브라우저의 실제 분기 |
| payload와 URL 조립 | prefix, timestamp, referrer, query | 브라우저에서 스토어까지 전달되는 URL |
| iOS 흐름 | 순수 payload 생성 | 앱 실행 시도, 버튼 클릭, 설치 후 Pasteboard 수신 |
| Android 흐름 | Intent와 Store URL 생성 | Play Store 설치 후 Install Referrer 수신 |
| 앱 소비 상태 | TypeScript 타입 검사 | 첫 처리와 재실행 시 앱 라우팅 동작 |
| Desktop | 플랫폼 판별 | 두 스토어 링크와 제품 UI |
실제 스토어 전환은 단위 테스트만으로 끝낼 수 없다. App Store와 Google Play를 거친 설치, Safari의 사용자 동작, 브라우저별 fallback을 실제 기기에서 확인해야 한다. React 컴포넌트 상태 전환과 Next.js middleware는 현재 자동 테스트 범위 밖이다. React Native 저장소에도 아직 네이티브 자동 테스트가 없다. Android와 iOS 통합 테스트는 남은 과제다.
결론
아임웹에서 /.well-known 파일을 제공할 수 없어 링크 진입점을 직접 운영했다. 어려운 부분은 랜딩 페이지가 아니라 웹에서 만든 URL의 의미를 스토어와 앱까지 유지하는 일이었다.
이 구현은 앱 설치 여부를 정확히 판별하지 않는다. 브라우저로 들어온 경우 iOS는 사용자가 스토어 버튼을 누를 때 Pasteboard에 payload를 남기고, Android는 Install Referrer에 URL을 싣는다. 앱 모듈은 값을 검증해 반환하며 화면 이동과 실패 복구는 앱이 맡는다.
오픈소스로 추출하면서 도메인, Bundle ID와 package ID에 연결되는 앱 식별 값, scheme, Store URL, prefix를 설정으로 옮겼다. 사용하는 팀은 이 설정만 자신의 도메인과 앱에 맞게 교체하면 된다. URL을 보내고 설치 후 다시 읽는 코어 구현은 그대로 재사용한다.
UI, 분석 도구, 앱 라우터는 패키지 밖에 남겼다. 짧은 링크 관리, 대시보드, 사용자 매칭 서버도 제공하지 않는다. 두 패키지의 범위는 셀프 호스팅 환경에서 도메인과 앱 설정을 연결하고, 원래 링크를 설치 후 다시 읽는 데까지다.