내가 현재 개발하고 있는 앱은 다양한 딥링크 패턴을 지원하고 있다. 예를 들어 지하철 경로 검색 딥링크는 다음과 같은 형태다.
https://map.example.com/swalk/126.909737/37.583954/126.917297/37.611332?n1=A역&n2=B역
이 URL을 링크로 클릭하거나, 다른 앱에서 일반적인 방식으로 열면 React Navigation의 linking 설정이 URL을 파싱하여 경로 검색 화면으로 정상 이동한다.
문제는 특정 외부 앱과의 연동이었다. 외부 앱에서 "경로 찾기" 버튼을 누르면 우리 앱이 실행은 되지만, 항상 메인 화면에 머물렀다. 앱이 백그라운드에 있든, 완전히 종료된 상태이든 결과는 같았다. 경로 검색 화면으로의 이동이 전혀 일어나지 않았다.
[유니버설 링크와 커스텀 스킴의 차이]
이 문제의 근본 원인은 우리 앱과 외부 앱이 서로 다른 딥링크 방식을 사용하고 있었다는 데 있다.
iOS의 딥링크 방식은 크게 두 가지로 나뉜다. 하나는 유니버설 링크(Universal Links)이고, 다른 하나는 커스텀 URL 스킴(Custom URL Scheme)이다.
유니버설 링크는 https:// 프로토콜을 사용하는 일반적인 웹 URL이다. 앱이 설치되어 있으면 앱이 열리고, 설치되어 있지 않으면 웹 브라우저에서 해당 페이지가 열린다. 도메인 소유자가 서버에 apple-app-site-association 파일을 배포하여 앱과 도메인 간의 연결을 인증하는 방식이다.
https://map.example.com/swalk/126.909737/37.583954/126.917297/37.611332
커스텀 URL 스킴은 앱 고유의 프로토콜을 정의하여 사용하는 방식이다. myapp:// 같은 형태로, 해당 스킴이 등록된 앱을 직접 호출한다. 도메인 인증이 필요 없어 구현이 간단하지만, 앱이 설치되어 있지 않으면 에러가 발생하고, 스킴이 충돌할 가능성도 있다.
myapp://login?sessionID=abc123
우리 앱은 외부로부터 딥링크를 수신할 때 유니버설 링크 방식을 기본으로 사용하고 있었다. React Navigation의 linking 설정도 이에 맞춰 구성되어 있었다.
const linking = {
prefixes: ['https://map.example.com', 'myapp://'],
// ...
};
myapp://은 앱 내부적으로 로그인, 로그아웃 같은 처리에 사용하는 커스텀 스킴이다. 외부 앱으로부터 경로 검색 같은 딥링크를 받을 때는 https://map.example.com/... 형태의 유니버설 링크를 기대하고 있었다.
[외부 앱이 보낸 URL의 실체]
문제는 외부 앱이 유니버설 링크를 직접 호출하지 않았다는 것이다. 외부 앱은 자체적으로 externalMapOpen://이라는 커스텀 URL 스킴을 정의하고, 실제 딥링크 URL을 그 안에 쿼리 파라미터처럼 감싸서 전달하고 있었다.
우리가 기대한 호출 방식은 아래와 같은 형식이다.
https://map.example.com/swalk/126.909737/37.583954/126.917297/37.611332?n1=A역&n2=B역
하지만, 외부 앱이 실제로 보낸 URL은 아래와 같았다.
externalMapOpen://?https://map.example.com/swalk/126.909737/37.583954/126.917297/37.611332?n1=A역&n2=B역
유니버설 링크가 아닌 커스텀 스킴으로 들어온 URL이기 때문에, iOS가 URL을 처리하는 경로 자체가 달라진다. 유니버설 링크는 application(:continue:restorationHandler:)를 통해 전달되지만, 커스텀 스킴은 application(:open:options:)를 통해 전달된다. 그리고 React Navigation은 Linking.getInitialURL()이나 Linking.addEventListener를 통해 수신한 URL을 prefix 목록과 대조하여 매칭을 시도한다.
externalMapOpen://은 prefix 목록 어디에도 없다. 당연히 매칭에 실패하고, React Navigation은 어떤 화면으로도 이동시키지 않는다. 결과적으로 메인 화면에 머무르게 되었던 것이다.
[Xcode를 활용한 실기기 디버깅]
이 문제는 시뮬레이터로는 재현할 수 없었다. 외부 앱이 설치된 실제 iPhone에서만 테스트가 가능했기 때문에, Xcode에 실기기를 연결하여 네이티브 로그를 직접 확인하는 방식으로 디버깅을 진행했다.
Xcode와 실제 나의 아이폰이 연동된 모습

빌드 후 실행하면 Xcode 콘솔에 앱의 네이티브 로그가 실시간으로 출력된다. AppDelegate에 심어둔 딥링크 수신 로그를 통해, 외부 앱에서 버튼을 눌렀을 때 앱에 실제로 어떤 URL이 도착하는지 확인할 수 있었다.
AppDelegate: Deep Link received: externalMapOpen://?https://map.example.com/swalk/126.909737/37.583954/126.917297/37.611332?n1=...&n2=...
이 한 줄의 로그 덕분에 원인을 파악할 수 있었다. JavaScript 콘솔만으로는 iOS가 앱에 전달하는 원본 URL의 형태를 정확히 파악하기 어려웠을 것이다.
React Navigation의 linking 설정에서 getInitialURL과 subscribe를 커스터마이즈하여 외부 앱의 래퍼 스킴을 벗겨낸 실제 URL을 전달하도록 수정했다.
수정 전 코드는 Linking에서 받은 URL을 그대로 전달하고 있었다.
[수정 전 코드]
async getInitialURL() {
const url = await Linking.getInitialURL();
return url;
},
subscribe(listener: (url: string) => void) {
const subscription = Linking.addEventListener('url', ({url}) => {
listener(url);
});
return () => subscription.remove();
},
[수정 후 코드]
async getInitialURL() {
let url = await Linking.getInitialURL();
if (url && url.toLowerCase().startsWith('externalmapopen://?')) {
url = url.substring(url.indexOf('?') + 1);
}
return url;
},
subscribe(listener: (url: string) => void) {
const subscription = Linking.addEventListener('url', ({url}) => {
let targetUrl = url;
if (url && url.toLowerCase().startsWith('externalmapopen://?')) {
targetUrl = url.substring(url.indexOf('?') + 1);
}
listener(targetUrl);
});
return () => subscription.remove();
},
url을 유니버설 링크 형식에 맞게 포매팅하니 딥링크가 정상 작동한다!
이 문제는 딥링크의 두 가지 방식, 유니버설 링크와 커스텀 URL 스킴이 충돌하면서 발생한 것이었다. 우리 앱은 https:// 기반의 유니버설 링크를 기대했고, 외부 앱은 자체 커스텀 스킴으로 URL을 래핑하여 전달했다. 같은 목적지 URL이지만 전달 방식이 달랐기에, React Navigation의 prefix 매칭이 실패하고 메인 화면에 머무르는 결과로 이어졌다.
외부 앱과의 딥링크 연동에서는 상대방이 어떤 딥링크 방식을 사용하는지, URL을 어떤 형태로 전달하는지를 사전에 명확히 확인하는 것이 중요하다. 유니버설 링크를 기대하고 있더라도 상대방이 커스텀 스킴으로 보낼 수 있고, 그 반대도 가능하다. 특히 iOS는 두 방식의 처리 경로가 완전히 분리되어 있기 때문에, 한쪽만 대응하면 다른 쪽에서 반드시 문제가 발생한다.
그리고 이런 플랫폼 고유의 동작이 관여하는 문제는 JavaScript 콘솔만으로 디버깅하기 어렵다. 실기기를 Xcode에 연결하여 네이티브 레벨의 로그를 확인하는 것이 원인 파악의 전환점이 되었다.