이 사이트에는 운동 유형 테스트가 있습니다. 여덟 문항을 고르면 유형이 나오고, 그 유형에 맞는 7일 루틴을 앱에서 받을 수 있습니다. 결과 화면에 "앱에서 이 루틴 받기" 버튼 하나가 있습니다.
이 버튼이 해야 할 일은 셋입니다.
셋 다 브라우저가 도와주지 않습니다.
1. 근본 제약 — 스킴이 실패해도 아무 일이 없다
커스텀 스킴으로 앱을 여는 건 간단합니다.
앱이 설치돼 있으면 앱이 뜹니다. 없으면 아무 일도 일어나지 않습니다. 예외도, 이벤트도, 오류 콜백도 없습니다. 페이지는 그대로 있고 사용자는 버튼이 고장 났다고 생각합니다.
그래서 폴백은 전부 간접 추론입니다. "앱이 열렸는가"를 직접 물을 방법이 없으니, "사용자가 이 페이지를 떠났는가"를 대신 봅니다.
왜 커스텀 스킴을 골랐나
웹에서 앱으로 넘기는 방법은 셋입니다. 셋 다 써보고 이 구조를 골랐습니다.
표만 보면 Universal Link / App Link 가 압도적입니다. 실패라는 개념 자체가 없으니까요 — 앱이 있으면 앱이 가로채고, 없으면 그 URL이 그냥 웹으로 열립니다. 폴백 코드가 필요 없습니다.
그런데 대가가 있습니다.
- 도메인에 연결 파일을 올려야 합니다. iOS는
/.well-known/apple-app-site-association, Android는/.well-known/assetlinks.json입니다. 앱의 팀 ID·서명 지문과 묶여 있어서 앱 서명이 바뀌면 웹 쪽 파일도 바뀝니다. - OS가 캐시합니다. 파일을 고쳐도 기기에 반영되기까지 시차가 있고, 그 사이 상태는 관측이 어렵습니다.
- 같은 URL이 두 가지로 열립니다. 앱에서 열린 경우와 웹에서 열린 경우가 같은 주소를 쓰므로, "웹으로 보고 싶다"는 경로를 따로 만들어야 합니다.
즉 Universal Link 는 폴백 문제를 배포 설정 문제로 바꾸는 선택입니다. 지금 이 사이트에서는 진입점이 버튼 하나뿐이고 앱 저장소가 따로 있어서, 웹 쪽에 앱 서명과 묶인 파일을 두는 비용이 폴백 20줄보다 커 보였습니다. 진입점이 늘어나면 판단이 뒤집힐 수 있습니다.
2. 페이지를 떠났는지로 판정하기
앱이 실제로 열리면 브라우저는 백그라운드로 밀립니다. 그때 두 이벤트 중 하나가 뜹니다.
visibilitychange— 문서가 안 보이게 될 때pagehide— 페이지가 화면에서 내려갈 때
기기와 브라우저마다 무엇이 뜨는지가 달라서 둘 다 겁니다. 그리고 스킴을 던진 뒤 잠깐 기다렸다가, 아직 여기 있으면 앱이 없다고 판단합니다.
세부가 몇 개 있습니다.
타이머를 스킴 이동보다 먼저 건다
setTimeout이 location.href 대입보다 위에 있습니다. 스킴 이동이 동기적으로 페이지를 정지시키는 경우가 있어서, 나중에 걸면 타이머가 아예 등록되지 않을 수 있습니다.
1.5초
짧으면 앱이 뜨는 중에 스토어로 튕깁니다. 길면 앱 없는 사용자가 멍하니 기다립니다. 여러 값을 시도해본 끝의 타협점입니다. 정답은 없고, 기기 성능에 따라 달라집니다.
값을 고르는 기준은 두 실패 중 어느 쪽이 더 나쁜가입니다. 앱이 있는 사람을 스토어로 보내는 건 이미 고객인 사람에게 "설치하세요" 라고 말하는 것이라 눈에 띄게 나쁩니다. 앱이 없는 사람이 1.5초 기다리는 건 그냥 조금 느린 겁니다. 그래서 비대칭을 감안해 긴 쪽으로 기울였습니다.
기다리는 동안 버튼 상태를 바꿔주는 것이 체감에 크게 작용합니다. 1.5초는 짧지만, 아무 반응이 없는 1.5초는 깁니다.
플래그와 현재 상태를 둘 다 본다
타임아웃 안에서 leftPage만 보지 않고 document.visibilityState도 확인합니다. 앱에 갔다가 1.5초 안에 돌아온 경우 플래그는 켜져 있지만 지금은 보이는 상태입니다. 이때 스토어로 보내면 안 됩니다. 둘 다 "여기 있다"고 할 때만 폴백합니다.
사용자가 스킴과 무관하게 앱을 전환하거나 화면을 끄면 "앱이 열렸다"로 오판합니다. 반대로 앱 실행이 아주 느리면 스토어로 잘못 보냅니다. 완벽하게 만들 수 없고, 흔한 경로에서 맞게 만드는 게 목표입니다.
오판은 네 가지 모양으로 나옵니다
어떤 오판이 남아 있는지 알고 있어야 버그 신고를 읽을 수 있습니다.
아래 두 줄이 실제로 신고가 들어오는 유형입니다. 둘 다 앱이 있는 사람을 스토어로 보내는 경우고, 원인은 같습니다 — 1.5초 안에 가시성 변화가 안 왔습니다.
위 두 줄은 조용합니다. 아무도 "스토어로 안 갔어요"라고 신고하지 않습니다. 그래서 실제 발생률을 모릅니다. 이걸 알려면 클릭과 설치를 이어 붙이는 계측이 필요한데, 그건 정적 사이트에 분석 도구를 하나 들이는 이야기가 됩니다.
3. 스토어 주소는 플랫폼별로 나뉜다
폴백 대상은 하나가 아닙니다.
User-Agent 분기는 원래 권장되지 않지만, 여기서는 보내야 할 스토어를 고르는 것이라 대안이 마땅치 않습니다. 기능 감지로 알아낼 수 있는 종류의 정보가 아닙니다.
기본값을 App Store로 둔 것도 의도입니다. 데스크톱에서 눌러도 최소한 앱 페이지가 열려서, 화면에 아무 반응이 없는 것보다는 낫습니다.
4. 설치하고 돌아오면 맥락이 사라진다
여기가 실제로 가장 어려운 부분입니다.
앱이 없어서 스토어로 갔다가, 설치하고, 앱을 처음 켭니다. 이 앱은 사용자가 어떤 결과를 보고 왔는지 모릅니다. 스토어를 거치면서 맥락이 끊깁니다. 애써 여덟 문항을 고른 결과가 사라지고, 앱은 빈 화면으로 시작합니다.
고를 수 있는 방법은 넷이었습니다.
세 번째는 이 문제에 안 맞습니다. Universal Link 는 앱이 이미 있을 때 링크를 가로채는 물건이라, "앱이 없어서 스토어에 갔다 온" 경로에는 아무 도움이 안 됩니다. 이 둘을 같은 것으로 묶어 설명하는 글이 많은데 해결하는 문제가 다릅니다.
첫 번째가 정석입니다. 다만 기기 지문(IP·화면 크기·OS 버전)으로 클릭과 설치를 이어 붙이는 방식이라 서버가 필요하고, 개인정보 처리 범위가 늘어납니다. 정적 사이트에 서버를 하나 들이는 셈입니다.
그래서 남은 방법을 택했습니다. 클립보드입니다.
앱은 첫 실행에서 클립보드를 확인하고, 우리 도메인의 결과 URL이면 유형을 복원해 "이 결과를 불러올까요?" 카드를 띄웁니다.
여기에도 세부가 둘 있습니다.
https형태로 복사합니다.athlentic://가 아니라요. iOS의 클립보드 URL 감지가 커스텀 스킴은 못 잡기 때문입니다.await를 걸지 않습니다. 클립보드 쓰기를 기다리면 사용자 제스처 컨텍스트가 끊겨서 그 뒤의 스킴 이동이 차단될 수 있습니다. 그래서 fire-and-forget이고, 실패해도 딥링크는 그대로 진행합니다.
권한이 없거나 브라우저가 막으면 조용히 실패합니다. 그래도 주 흐름(앱 열기 · 스토어 보내기)은 멀쩡히 동작해야 합니다. 그래서 catch가 비어 있습니다 — 여기서 실패는 무시해도 되는 실패입니다.
5. 웹만 고치면 조용히 깨지는 지점
이 기능은 저장소 세 개에 걸쳐 있습니다. 웹이 스킴을 던지고, iOS와 Android가 그걸 파싱합니다. 웹만 바꾸면 아무 오류 없이 기능이 죽습니다.
turtle, fox, dolphin 같은 유형 키를 웹에서만 바꾸면, 앱은 모르는 값을 받고 기본 화면을 띄웁니다. 예외도 안 납니다. 그냥 루틴이 안 맞을 뿐이고, 아무도 버그로 신고하지 않습니다.
그래서 규칙 문서에 별도 항목으로 적어뒀습니다.
이건 빌드 가드로 옮길 수 없는 종류입니다. 검사 대상이 다른 저장소에 있으니까요. 자동화할 수 없으면 최소한 바꿀 때 반드시 보이는 곳에 적어두는 게 차선입니다.
어디에 적느냐도 중요합니다. 규칙 문서에 무엇을 적을지를 정할 때 기준으로 삼은 게 "이 파일을 고치려는 사람이 그 문장을 보게 되는가" 였습니다. 딥링크 항목은 세 저장소 중 어디에 적어도 나머지 둘에서는 안 보입니다. 그래서 웹 쪽 규칙 문서에 적고, 동시에 해당 함수 바로 위에 주석으로도 남겼습니다. 중복이지만 여기서는 중복이 맞습니다 — 코드를 고치는 사람은 규칙 문서를 열지 않습니다.
더 나은 방법이 있다면 세 저장소가 공유하는 한 곳에서 키 목록을 생성하는 것입니다. 키가 여덟 개뿐이라 아직 그 비용을 치르지 않았지만, 유형이 늘면 그때가 옵니다.
6. 데스크톱에서는 검증되지 않는다
가장 자주 잊는 부분입니다. 이 로직은 데스크톱 브라우저로 확인할 수 없습니다.
- 커스텀 스킴에 반응할 앱이 없습니다.
- 앱 전환이 없으니
visibilitychange가 그 의미로 뜨지 않습니다. - 스토어 링크는 열리지만 설치 흐름이 없습니다.
데스크톱에서 "눌러보니 스토어로 잘 가네"는 폴백이 항상 발동한다는 뜻이지, 폴백이 정확하다는 뜻이 아닙니다. 실제로 확인해야 하는 건 그 반대 경로 — 앱이 있을 때 스토어로 안 가는가입니다.
실기기에서 봐야 하는 경우는 최소 넷입니다.
실제로는 브라우저까지 곱해집니다. iOS 의 Chrome·Firefox·네이버 앱은 전부 WebKit 위에 있지만 스킴 처리와 가시성 이벤트가 같지 않습니다. 특히 인앱 브라우저(카카오톡·인스타그램에서 링크를 눌러 들어온 경우)가 자주 다르게 동작합니다.
세 번째 줄이 설계에 영향을 줬습니다. 인앱 브라우저는 "앱에서 열까요?" 확인 팝업을 띄우는 경우가 있는데, 사용자가 이 팝업을 늦게 수락하면 그 사이 1.5초가 지나 스토어로 갑니다. 팝업이 뜨는 동안에는 가시성이 바뀌지 않기 때문입니다. 지금 구현은 이 경우를 못 막습니다. 알고 남겨둔 구멍입니다.
7. 자주 나오는 질문
Universal Link 를 쓰면 이 글의 문제가 전부 사라지나요?
폴백 문제는 사라집니다. 대신 도메인 연결 파일을 앱 서명과 맞춰 유지하는 문제가 생기고, OS 캐시 때문에 배포 직후 상태를 관측하기 어려워집니다. 그리고 설치 후 맥락 복원(4절)은 여전히 남습니다 — Universal Link 는 앱이 이미 있을 때의 이야기입니다.
blur 나 focus 로 판정하면 안 되나요?
blur 는 페이지가 아니라 창의 포커스를 봅니다. 주소창을 클릭해도 뜨고, 일부 브라우저는 앱 전환에서 안 뜹니다. visibilitychange 가 문서 가시성을 직접 보기 때문에 이 목적에 더 정확합니다. pagehide 를 같이 거는 건 iOS 에서 visibilitychange 가 안 오는 경우가 있어서입니다.
iframe 으로 스킴을 던지는 옛날 방법은요?
보이지 않는 iframe 의 src 에 스킴을 넣는 방법이 널리 쓰였습니다. 지금 브라우저에서는 대부분 막혀 있고, 사용자 제스처 없이 스킴이 열리는 걸 차단하는 정책과도 충돌합니다. location.href 가 현재의 정답입니다.
타임아웃을 없애고 가시성 이벤트만 쓰면 안 되나요?
안 됩니다. 앱이 없을 때는 아무 이벤트도 안 옵니다. 이벤트가 안 오는 걸 감지하려면 결국 시간을 재야 합니다. 타임아웃이 곧 "아무 일도 안 일어났다"의 정의입니다.
클립보드에 URL 을 남기는 게 사용자에게 불쾌하지 않나요?
iOS 는 앱이 클립보드를 읽을 때 상단에 배너를 띄웁니다. 그래서 읽는 시점이 자연스러워야 합니다 — 첫 실행에서 한 번, 그리고 읽은 내용이 우리 도메인의 결과 URL 일 때만 카드를 띄웁니다. 아니면 조용히 버립니다. 무엇을 위해 읽었는지가 화면에 바로 보이면 배너는 설명이 됩니다.
이 로직을 자동 테스트로 덮을 수 있나요?
부분만요. 스토어 URL 선택, 타이머 등록 순서, 클립보드 실패 시에도 스킴 이동이 진행되는지는 단위 테스트로 덮을 수 있습니다. 덮이지 않는 건 "앱이 실제로 뜨는가" 하나인데 그게 이 기능의 본체입니다. 그래서 실기기 확인이 빠지지 않습니다.
8. 정리
딥링크 폴백이 어려운 건 코드가 복잡해서가 아닙니다. 실패를 알 방법이 없다는 제약 위에 얹혀 있기 때문입니다.
- 앱이 열렸는지 물을 수 없으니 페이지를 떠났는지로 추론합니다. 정확한 판정이 아니라 근사치입니다.
visibilitychange와pagehide를 둘 다 걸고, 타임아웃에서는 플래그와 현재 상태를 함께 봅니다.- 타이머를 스킴 이동보다 먼저 겁니다. 순서가 뒤집히면 폴백이 등록되지 않을 수 있습니다.
- 보조 경로는 실패해도 주 흐름을 막지 않게 합니다. 클립보드 쓰기에
await를 걸지 않는 이유입니다. - 웹·iOS·Android 세 곳이 같은 값을 공유한다는 사실을 문서에 남깁니다. 한 곳만 바꾸면 조용히 깨집니다.
- 남은 오판을 목록으로 적어둡니다. 지울 수 없다면 최소한 알고 있어야 버그 신고를 읽을 수 있습니다.
웹에서 앱으로 넘기는 버튼을 만들고 있다면, 성공 경로보다 실패 경로를 먼저 설계하세요. 성공은 한 줄이고, 나머지 전부가 실패를 다루는 코드입니다.