TL;DR
- URI를 기반으로 어플리케이션을 연결하는 기술 딥 링크에는 크게 2가지 방식이 존재한다.
- URI Scheme:
youtube://
처럼 어플리케이션을 지칭하는 문자열 앱 스킴(App Scheme)을 명시하는 방식 - 도메인 기반 딥 링크: 특정 도메인과 어플리케이션을 1:1 연결하는 방식
- URI Scheme:
- 모바일 웹에서 딥 링크가 설정된 도메인 하위 페이지로 이동해야 하지만 딥 링크 동작을 원치는 않을 경우 아래와 같이 회피해 보자.
import {useSearchParams} from 'react-router-dom';
// 회피용 라우트를 만들고 이 컴포넌트를 연결한다.
const Bridge = () => {
const [params] = useSearchParams();
const targetUrl = params.get('url') || '/';
// Note. 과거 버전 IOS는 setTimeout을 포함해야 의도대로 동작함
window.location.replace(targetUrl);
setTimeout(() => {
window.location.replace(targetUrl);
}, 1);
return null;
};
export default Bridge;
// 사용례
<a href="https://anteater-lab.link/bridge?url=https://m.anteater-lab.link/payments">결제하기</a>
이하 그리 중요하진 않은 내용들
오타쿠식 강제연결
철권의 등장인물 알리사 보스코노비치는 설정상 로봇으로서, 그 컨셉 따라 대부분의 기술명이 IT 용어로 이루어져 있다. 그중 앉으면서 상대방의 다리를 툭 치는 기술 "딥 링크(1LK)". 2008년 첫 등장부터 특유의 상대하기 짜증 나는 성능 덕분에 딥 링크는 알리사의 상징적인 기술 중 하나로 철권 유저들 사이에 악명을 떨치고 있다. 그런데 이 게임뿐만이 아니라 현실 속 딥 링크 또한 프론트엔드 개발자의 짜증을 유발하기도 한다. 딥 링크란 무엇일까? 딥 링크는 어떤 까다로운 동작을 유발할까? 그것을 어떻게 회피해야 할까?
모바일 퍼스트
스마트폰은 인간의 새로운 장기가 되어가고 있다. 극단적인 사례로서 무신사는 한때 PC 웹 서비스를 종료하고 모바일 웹 형태로만 서비스를 제공했던 바가 있다. 비록 10개월 만에 다시 돌아오긴 했으나, 이는 기업들이 생각하는 PC와 모바일 간의 중요도 저울이 한쪽으로 크게 기울고 있단 증거 중 하나일 것이다. 국내 스마트폰 사용자는 한국 전체 인구의 95%에 해당한다고 한다. 그 거대한 스마트폰 사용자의 파이를 먹기 위해 각종 웹 기반 서비스들은 오늘도 적극적으로 사용자에게 더 좋은 모바일 기기 경험을 제공하려고 노력하고 있다.
하지만 아무리 그렇게 폭발적인 수요에 힘입어 모바일 기기가 발전하고 있더라도 여전히 그 성능엔 물리적인 한계가 있다. 게다가 네이티브 어플리케이션이 아닌 웹 브라우저 내부에서 실행되는 웹 페이지라면 그 한계는 한 단계 더 심해질 것이다. 일례로, 구매한 지 4년 정도 된 내 갤럭시 S21의 삼성 브라우저에서 트위터나 지메일 같은 사이트를 접속하면 이젠 쓰기 힘들 정도로 버벅댄다. 글자 하나 타이핑하는 데 1초씩 걸리면 누가 그 서비스를 이용하고 싶어 할까. 이런 상황에서 어떻게든 모바일 사용자들을 붙잡기 위해 엔지니어들이 선택한 방법은, 모바일 웹 브라우저를 최대한 피하도록 만드는 것이었다. 네이티브 어플리케이션을 사용한다면 모바일 기기의 성능을 더 끌어올릴 수 있을 테니.
딥 링크(Deeplink 또는 Deep Linking)란, URI를 통해 어플리케이션 내부의 구체적인 위치를 명시하는 방법을 의미한다. 예를 들어, https://youtube.com/watch?v=w66Kzs7UIHk
이런 링크를 모바일 기기로 들어간다고 했을 때, 사용성 나쁘고 성능적 제한도 있는 모바일 웹 브라우저에서 웹 페이지 형태로 보여주는 것이 아니라 해당 링크에 연결된 유튜브 네이티브 어플리케이션의 특정 위치로 이동하도록 만들 수 있다는 것이다. 오늘 글도 그렇고 대부분의 맥락에선 모바일 기기를 사용하는 입장에서 쓰이는 용어이긴 하지만, 원론적으론 "어플리케이션의 내부 위치를 URI와 연결"이라는 개념은 어떤 환경에서도 적용될 수 있다. 예를 들면, PC에서 Notion을 웹 브라우저로 접속했을 때도 어플리케이션으로 열기라는 버튼이 포함된 토스트 UI가 노출되는 것을 볼 수 있다.
딥 링크 삼국시대
딥 링크란 어떤 특정 플랫폼에 종속되는 기술이 아니라 일종의 제안 같은 것이다. 그래서 그 제안을 어떻게 구현할 것인가는 각 플랫폼 개발사에 달려있고, 서비스 개발자들은 그 방식에 맞춰 따라가면 된다.
URI Scheme
먼저 소개할 방식은 가장 플랫폼에 덜 종속적인 방식이라고 할 수 있다. 바로 딥 링크만을 위한 URI를 만드는 것. 우선 팀 버너스리가 정의한 URI의 구조는 다음과 같다.
URI = scheme ":" ["//" authority] path ["?" query] ["#" fragment]
웹 사이트의 주소를 나타낼 땐 scheme
부분에 http
혹은 https
가 들어간다. 하이퍼텍스트 기반 통신 프로토콜 HTTP를 사용해 해당 주소로 접속하라는 의미이다. 이처럼 스킴(Scheme)은 그 주소로 접근하기 위해 사용해야 하는 프로토콜이나 방법을 명시하기 위해 사용된다. http
/https
, ftp
, mailto
, ssh
등 IANA에 공식적으로 등록된 스킴을 대부분 사용하는데, 그렇다고 꼭 URI에 등록된 스킴만 사용하라는 법칙은 없다. 등록된 스킴 대신 URI의 스킴 자리에 앱 스킴(App Scheme)이라는 것을 명시할 수 있다.
youtube://watch?v=w66Kzs7UIHk
예를 들어 앱 스킴을 사용하면 위와 같이 유튜브 어플리케이션 내부에서 특정 영상을 재생하는 화면을 URI로 표현할 수 있다. 개념적으로는 이렇지만 그렇다고 무턱대고 저 링크를 써먹으려고 한다면 동작하지 않을 것이다. 가령 개발자 도구를 열고 window.open('youtube://watch?v=w66Kzs7UIHk')
이렇게 입력한다면 다음과 같은 응답이 출력되는 것을 볼 수 있다.
window.open(`youtube://watch?v=w66Kzs7UIHk`);
// Failed to launch 'youtube://watch?v=w66Kzs7UIHk' because the scheme does not have a registered handler.
공식적으로 등록되지 않은 스킴이란 말은 사용자의 PC에라도 등록이 되어야 사용할 수 있다는 의미이기 때문이다. 오늘의 주제는, 그리고 FE 엔지니어에게 필요한 정보는 스킴을 사용하는 법이지 등록하는 법은 아니기 때문에 그냥 그렇다고 치고 넘어가도록 하자. 파편적으로 정보만 던져두자면, 각 플랫폼 네이티브 어플리케이션 개발 도구에서 설정법을 알려줄 것이다.
앱 스킴 방법의 장점은 간단하다는 것이다. URI의 그 목적에 맞게 명시적으로 앱 내부의 특정 화면을 사용자와 연결시켜 줄 수 있다. 또 한 가지 장점은 서버에 의존하지 않아도 된다는 점이다. 예를 들어 youtube://watch?v=w66Kzs7UIHk
라는 링크는 보이듯 그 프로토콜이 HTTP가 아니다. 때문에 링크의 이동은 로컬에서 youtube
라는 스킴을 실행할 수 있는 어플리케이션을 찾아 실행하는 흐름으로 실행된다. 발견되지 않으면 특정 웹 서버에 요청을 보내는 것이 아니라 그 링크를 실행할 수 없다는 에러를 보내고 끝인 것이다.
하지만 이건 역으로 단점으로 다가오기도 한다. 이 이미지를 다시 보면서 UX 측면을 생각해 보자. 그렇게 별도의 웹 서버를 타지 않는 방식이라면 위 경로, "앱 미설치 시(App not installed)" 상황에 어떻게 대응해야 할까? 앱을 대신하는 대체 모바일 웹 페이지로 보내주거나 앱을 설치할 수 있는 스토어로 보내줘야 하는데, 만약 다음과 같이 페이지 이동을 하려 했다면 골치가 아플 것이다.
location.href = "youtube://watch?v=w66Kzs7UIHk";
// Do nothing
youtube
를 실행할 수 있는 네이티브 어플리케이션이 없다면 위 코드의 실행 후 웹 페이지에선 아무런 일도 일어나지 않는다. 에러조차 발생하지 않기 때문에, 이 실행이 실패했다는 것을 상정하는 방식은 다음과 같은 코드가 제안된다.
location.href = "youtube://watch?v=w66Kzs7UIHk";
// 윗 줄에서 새 페이지로 전환이 되었다면 자바스크립트 실행이 안될 건데 이 타임아웃이 동작했다는 것은
// 필시 어플리케이션이 제대로 실행되지 않았다는 의미일 것이다.
setTimeout(function () {
// 어플리케이션 이동은 포기하고 웹 페이지로 이동
location.href = "https://www.youtube.com/watch?v=w66Kzs7UIHk";
}, 2000); // 대충 적당한 시간을 기다려봄
이런 썩 마음에 들지 않는 코드 말고도 또 다른 문제가 있다. 공식적으로 등록되지 않은 스킴이란 말은 언제든 중복이 발생할 수 있다는 의미를 내포한다는 점이다.
예를 들어 store
라는 스킴을 가진 어플리케이션이 여럿이라면 위와 같이 사용자에게 셋 중 하나를 선택하라는 UI가 노출되어야 한다. 사소해 보여도 이런 것 하나하나가 모여서 사용자의 귀찮음을 만드는 것이다. 실수로 "항상" 버튼을 한 번 눌렀다가 다음번에 다른 앱으로 링크를 열어야 할 때 곤란해질 수도 있다. 이런 중복 문제는 앱이 기기에 설치되지 않았을 때 자연스럽게 앱 설치를 유도하기도 어렵게 만든다. 사람이야 딱 보면 알겠지만 사전 정보가 하나도 없는 OS 입장에선 "아니 youtube
라는 스킴은 뭘로 실행해야 하는 거야? 마켓에서 무슨 어플을 받아야 하는 거야?"라는 고민에 빠지게 된다.
도메인 기반 딥 링크
하지만 다행히도 웹을 기반으로 제공되는 서비스라면 하나쯤 고유 식별자를 가지고 있기 마련이다. 도메인 네임(Domain Name)은 특정 서비스를 식별하는 고유한 문자열을 뜻한다. "anteater-lab.link" - 이것이 그 문자열이 대표적인 사례이다. 이 역시 IANA에서 관리하고 있는 자원 중 하나임과 동시에, 스킴과 달리 웹 서비스와 1대1로 연결되는 고유성을 갖고 있다는 특징이 있다. 그만큼 귀중한 자원이라 공짜로는 쉽게 쓰기 어렵고, 나도 이 블로그를 위해 매달 거금 0.5$를 AWS에게 바치고 있다. 요즘 환율도 안 좋은데 말이야.
아무튼 웹 서비스 입장에서 이왕 이렇게 비싼 귀중품 하나씩은 마련해야만 하는 처지에 그걸 더 잘 써보면 좋지 않을까라는 것이다. 말한 대로 도메인 네임은 고유하기 때문에 도메인 네임을 기반으로 딥 링크를 구현하면 스킴 중복 문제에서 자유로워진다. 또한 그 링크를 사용하는 입장에서도 굳이 어플리케이션 진입을 위해 앱 스킴이 포함된 별도의 링크 문자열로 분기 처리를 할 필요가 없게 된다.
여기서 중요한 것은 불필요한 네트워크 요청이 발생하지 않게 만드는 것이다. 웹 브라우저의 동작을 유도하든, OS의 동작을 유도하든, JS로 직접 구현을 하든, 어쨌든 웹 페이지를 진입한 다음에야 상황을 판별해 앱을 실행하는 것은 바람직하지 않다. OS에서 "이 도메인의 URL은 유튜브 앱을 실행하면 된다" 같은 정보를 알고 있다면 굳이 웹 페이지에 대한 불필요한 요청을 보낼 필요도 없이 앱을 실행할 수 있다. 그것을 위해서 웹 서버와 도메인이 필요하다. 웹 서버를 사용하지 않기 위해 웹 서버가 필요한 아이러니한 상황.
- Android(App Links): https://domain.name/.well-known/assetlinks.json
- IOS(Universal Links): https://domain.name/.well-known/apple-app-site-association
이처럼 특정 도메인과 특정 어플리케이션을 직접 연결하는 방법을 안드로이드 진영에선 앱 링크(App Links)라고 부르고, IOS에선 유니버설 링크(Universal Links)라고 부른다. assetlinks.json
파일 또는 apple-app-site-association
파일을 우리 웹 서버에 등록해 둔 다음 앱 설치 시 이 파일들을 참고하도록 연결하는 방식이다. 각 두 파일의 상세한 내용은 조금씩 다르지만 공통적으로 앱과 도메인의 연결이 신뢰할 수 있다는 것을 증명하는 역할을 한다. 따라서 OS는 앱 설치 시점에 이 파일들을 캐싱해 두고, 사용자의 브라우징 동안 등록된 경로로 사용자가 진입하려 할 때 서버로 요청을 보내는 대신 앱으로 안전하게 바로 진입할 수 있도록 만들 수 있는 것이다.
딥 링크 흘리기
하지만 때론 이런 상황이 올 때도 있다.
이번 문단에선 조금 독특한 상황에 대해 다뤄보고자 한다. 딥 링크 편한 것은 알겠지만, 아주 가끔 그 동작이 불필요한 순간이 오기도 한다. 가령 구글이나 애플에게 수수료가 떼이는 것이 마음에 들지 않아 사용자들이 결제 페이지에서는 앱을 벗어나 모바일 웹 페이지를 사용하도록 교묘히 유도해야 할 수도 있다. 그런데 말했듯이 딥 링크는 조금 더 편한 사용자 경험을 위한 기능이며, 별도의 얼럿 UI 출력 없이 모바일 웹과 앱 간의 전환이 자연스러울수록 더 편한 사용자 경험이 될 것이다. 그래서 모바일 브라우저에 열린 외부 사이트에서 우리 서비스의 결제 페이지로 향하는 버튼을 눌렀더니 딥 링크 설정에 의해 앱이 강제 실행되어 버린다면?
물론 각 OS에 맞춰 딥 링크를 구성하는 과정에서 어떤 경로에서 딥 링크가 동작해야 하는지 설정하는 옵션은 제공된다. 하지만 과거에 어떤 사유로 인하여 우리 서비스 도메인의 모든 하위 경로에서 딥 링크가 동작하도록 설정이 되어있었고, 신규 기능의 서비스 배포 데드라인이 코 앞인 상황에 이것을 뒤늦게 발견한 상황이라면 여유롭게 그 설정을 수정하고 있을 순 없다. (상당히 지엽적인 상황인 것은 맞다.)
우선 기술적인 측면에서 여유롭게 그 설정을 수정하고 있을 수 없는 이유는 딥 링크 설정 파일을 변경했다고 그 동작이 서비스에 즉시 반영되는 것은 아니기 때문이다. 앞서 설명했듯 딥 링크 설정은 앱의 설치 시점에 사용자의 기기에 저장되어 불필요한 추가 요청을 만들지 않는 구조이다. 심지어 애플이 제공하는 안내사항에선 해당 파일에 대한 요청이 우리 웹 서버로 바로 가는 것이 아니라 애플의 CDN으로 간다고 설명하고 있다. apple-app-site-association
를 급히 수정해 봤자 사용자의 기기, 그리고 애플 CDN에 캐싱된 데이터에 가로막히게 된다. 모든 사용자의 어플리케이션을 강제 업데이트 하면 가능할지도 모르지만 고작 이런 일에 사용하기엔 리스크가 큰 작업이다.
두 번째로는 현실적인 문제가 있다. 딥 링크라는 동작 자체가 여러 유관 부서들 사이에 위치하는 종류의 작업이란 점이다. 이건 레거시와 히스토리가 적잖이 쌓여있는 집단에서나 겪을법한 특수 상황에 가깝다. 우선 딥 링크 설정이 왜 그렇게 되었는지부터 파악을 해야 하며, 앱 개발 부서에겐 앱 사용을 막기 위한 앱 업데이트를 요청해야 한다. 이렇듯 정책적으로 골치 아픈 상황을 피하기 위해 FE 개발자는 현행 딥 링크 정책을 유지한 채 어떻게든 우회한다는 행복 버튼을 눌러버리는 것이다.
이 참에 내가 이 글을 쓰게 된 이유를 설명하자면, 실제로 전혀 예상하지 못했던 이 이슈가 배포 일정 직전에 튀어나와 당시 큰 패닉에 빠졌었기 때문이다. 당시 패닉 속에서 머리를 열심히 굴려 얻어낸 정보와 결론은 다음과 같다. 도메인 기반의 딥 링크 동작은 OS가 버튼 터치 따위의 사용자 입력을 하이재킹해 실행되는 것이다. 그럼 사용자 입력이 아니면 되잖아?
<!-- 이 링크를 클릭 시 앱 강제 진입이 발생했다. -->
<a href="https://m.anteater-lab.link/payments">결제하기</a>
<!-- 그럼 이런 경로를 만들어주면 어떨까? -->
<a href="https://anteater-lab.link/bridge?url=https://m.anteater-lab.link/payments">결제하기</a>
https://m.anteater-lab.link/
: 접속 시 앱으로 실행되도록 설정된 모바일 웹 사이트 도메인https://anteater-lab.link/
: 앱과 관련 없는 PC 웹 사이트 도메인
문제 상황은 https://anteater-lab.link/
하위 페이지에서 https://m.anteater-lab.link/
하위 페이지로 이동해야 하고 앱 진입은 되지 않아야 한다는 것이다. 그리고 이 사례에서 다행이었던 점은 https://anteater-lab.link/
도메인의 웹 페이지는 우리 팀에서 관리하는 프로젝트라는 점이고 따라서 그 하위에 추가 라우트를 만들 권한 정도는 있었다는 것이다. 그리하여 위처럼 /bridge
라는 경로를 추가하였고, 쿼리 스트링으로 URL을 받도록 설정하였다. /bridge
로 접속할 경우 브라우저에서 렌더링 할 컴포넌트는 다음과 같이 구성한다.
'use client';
import {useSearchParams} from 'react-router-dom';
const Bridge = () => {
const [params] = useSearchParams();
const targetUrl = params.get('url') || '/';
// Note. 과거 버전 IOS는 setTimeout을 포함해야 의도대로 동작함
window.location.replace(targetUrl);
setTimeout(() => {
window.location.replace(targetUrl);
}, 1);
return null;
};
export default Bridge;
React Router가 아닌 다른 라이브러리를 사용한다면 useSearchParams
대신 상응하는 다른 API를 사용하면 된다. 원리는 간단하다. 도메인 기반 딥 링크는 OS에서 브라우저의 사용자 입력을 인식해서 실제 웹 서버로 요청을 보내지 않고 앱을 실행하도록 만든다. 그래서 Proxyman이나 Fiddler 같은 네트워크 탐지 도구로 확인해 보면 https://m.anteater-lab.link/
로 향하는 링크를 클릭해도 실제 요청이 발생하지 않는 것을 볼 수 있다. 그래서 링크 클릭 시 해당 도메인이 아닌 딥 링크 설정이 되지 않은 도메인으로 보낸 다음, 리다이렉션을 실행해 현재 페이지를 원하는 주소로 변경하는 방식을 사용한 것이다.
코드 내 몇 가지 디테일에 대해서 더 설명해 보자면,
location.href.replace
: 리다이렉트 된 페이지에서 다시 우회용 페이지로 뒤로 가기 할 수 없도록replace()
로 페이지를 이동시킨다.setTimeout
: 리다이렉션을 그냥 동기적으로 수행하는 것이 아니라 이벤트 루프의 힘을 빌려 비동기로 실행한다. IOS 과거 버전에서는 이렇게 비동기 처리를 해주지 않으면 원하는 대로 동작하지 않는다는 것 같다.
사실 이렇게 한다고 또 100% 회피가 되는 것도 아니다. 높은 확률로 위와 같은 모달이 사용자에게 노출될 것이다. (게다가 원인 미상의 이유로 이 모달 노출과 동시에 앱이 실행되어 버리기도 한다.) 여기서 사용자가 취소를 해줘야지 비로소 개발자의 의도대로 앱 진입이 회피된다. 이번 글에서는 어떻게든 딥 링크를 타지 않도록 회피하는 방법을 기록하였으나, 애플이 이건 버그라고 판단해 버리는 순간 이마저도 막히게 될 것이다. 결국은 정책을 잘 조정해 보자. 요즘 느끼는 게 있는데 소프트웨어 엔지니어가 꼭 코드만 열심히 쓴다고 능사가 아니더라고.
내가 배운 것
- 딥 링크의 2가지 방식 (URI Scheme vs 도메인 기반)
- OS마다 다른 도메인 기반 딥 링크를 부르는 이름 (유니버설 링크 vs 앱 링크)
- 도메인 기반 딥 링크를 회피하는 방법
아무튼 나를 괴롭혔던 저 상황은 IOS 기기에서만 발생하긴 했고, 안드로이드에선 재현되진 않았다. 이 참에 IOS 테스트 기기를 하나 따로 마련해야 하는 건가 하는 고민도 생겼다. 게다가 아이폰 하나 산다고 능사가 아닌 것이, 기껏 우회법 떠올려 놨더니 이번엔 과거 버전 IOS에서 또 안된다고 이슈가 올라와서 다시 골머리를 썩였던 탓에 애플에 대한 불만이 이만저만이 아닌 상황이 되었다. 유독 혼자 버그가 심한데 과거 버전 Safari 설치도 어렵단 말이지.