이 사이트에는 운동 유형 테스트가 있습니다. 여덟 문항을 고르면 유형이 나오고, 그 유형에 맞는 7일 루틴을 앱에서 받을 수 있습니다. 결과 화면에 "앱에서 이 루틴 받기" 버튼 하나가 있습니다.

이 버튼이 해야 할 일은 셋입니다.

1. 앱이 있으면 → 앱을 열고, 결과 유형을 같이 넘긴다 2. 앱이 없으면 → 스토어로 보낸다 3. 설치하고 돌아오면 → 그 결과를 잃지 않는다

셋 다 브라우저가 도와주지 않습니다.

1. 근본 제약 — 스킴이 실패해도 아무 일이 없다

커스텀 스킴으로 앱을 여는 건 간단합니다.

window.location.href = 'athlentic://fitness-type?type=' + typeKey;

앱이 설치돼 있으면 앱이 뜹니다. 없으면 아무 일도 일어나지 않습니다. 예외도, 이벤트도, 오류 콜백도 없습니다. 페이지는 그대로 있고 사용자는 버튼이 고장 났다고 생각합니다.

그래서 폴백은 전부 간접 추론입니다. "앱이 열렸는가"를 직접 물을 방법이 없으니, "사용자가 이 페이지를 떠났는가"를 대신 봅니다.

2. 페이지를 떠났는지로 판정하기

앱이 실제로 열리면 브라우저는 백그라운드로 밀립니다. 그때 두 이벤트 중 하나가 뜹니다.

  • visibilitychange — 문서가 안 보이게 될 때
  • pagehide — 페이지가 화면에서 내려갈 때

기기와 브라우저마다 무엇이 뜨는지가 달라서 둘 다 겁니다. 그리고 스킴을 던진 뒤 잠깐 기다렸다가, 아직 여기 있으면 앱이 없다고 판단합니다.

function openInApp() { const typeKey = $('share').dataset.type; if (!typeKey) return; const storeUrl = storeHref(); let leftPage = false; const markLeft = () => { leftPage = true; }; document.addEventListener('visibilitychange', markLeft, {once:true}); window.addEventListener('pagehide', markLeft, {once:true}); setTimeout(() => { document.removeEventListener('visibilitychange', markLeft); window.removeEventListener('pagehide', markLeft); if (!leftPage && document.visibilityState === 'visible') { window.location.href = storeUrl; // 앱이 없다고 판단 } }, 1500); window.location.href = 'athlentic://fitness-type?type=' + encodeURIComponent(typeKey); }

세부가 몇 개 있습니다.

타이머를 스킴 이동보다 먼저 건다

setTimeoutlocation.href 대입보다 위에 있습니다. 스킴 이동이 동기적으로 페이지를 정지시키는 경우가 있어서, 나중에 걸면 타이머가 아예 등록되지 않을 수 있습니다.

1.5초

짧으면 앱이 뜨는 중에 스토어로 튕깁니다. 길면 앱 없는 사용자가 멍하니 기다립니다. 여러 값을 시도해본 끝의 타협점입니다. 정답은 없고, 기기 성능에 따라 달라집니다.

플래그와 현재 상태를 둘 다 본다

타임아웃 안에서 leftPage만 보지 않고 document.visibilityState도 확인합니다. 앱에 갔다가 1.5초 안에 돌아온 경우 플래그는 켜져 있지만 지금은 보이는 상태입니다. 이때 스토어로 보내면 안 됩니다. 둘 다 "여기 있다"고 할 때만 폴백합니다.

이 방식은 추론이지 판정이 아닙니다.

사용자가 스킴과 무관하게 앱을 전환하거나 화면을 끄면 "앱이 열렸다"로 오판합니다. 반대로 앱 실행이 아주 느리면 스토어로 잘못 보냅니다. 완벽하게 만들 수 없고, 흔한 경로에서 맞게 만드는 게 목표입니다.

3. 스토어 주소는 플랫폼별로 나뉜다

폴백 대상은 하나가 아닙니다.

const storeHref = () => /Android/i.test(navigator.userAgent) ? 'https://play.google.com/store/apps/details?id=com.ceanlab.athlentic&pli=1' : 'https://apps.apple.com/kr/app/.../id6760968275';

User-Agent 분기는 원래 권장되지 않지만, 여기서는 보내야 할 스토어를 고르는 것이라 대안이 마땅치 않습니다. 기능 감지로 알아낼 수 있는 종류의 정보가 아닙니다.

기본값을 App Store로 둔 것도 의도입니다. 데스크톱에서 눌러도 최소한 앱 페이지가 열려서, 화면에 아무 반응이 없는 것보다는 낫습니다.

4. 설치하고 돌아오면 맥락이 사라진다

여기가 실제로 가장 어려운 부분입니다.

앱이 없어서 스토어로 갔다가, 설치하고, 앱을 처음 켭니다. 이 앱은 사용자가 어떤 결과를 보고 왔는지 모릅니다. 스토어를 거치면서 맥락이 끊깁니다. 애써 여덟 문항을 고른 결과가 사라지고, 앱은 빈 화면으로 시작합니다.

정석은 서버 쪽 디퍼드 딥링크(설치 후 첫 실행에서 원래 링크를 복원해주는 방식)를 붙이는 것인데, 정적 사이트에 서버를 하나 들이는 셈이라 다른 방법을 택했습니다. 클립보드입니다.

// 설치 후 첫 실행에서 유형을 복원할 수 있도록 결과 URL 을 클립보드에 남긴다. try { navigator.clipboard.writeText( location.origin + location.pathname + '?type=' + encodeURIComponent(typeKey) ).catch(() => {}); } catch (error) { /* 클립보드 불가 환경 — 딥링크는 그대로 진행 */ }

앱은 첫 실행에서 클립보드를 확인하고, 우리 도메인의 결과 URL이면 유형을 복원해 "이 결과를 불러올까요?" 카드를 띄웁니다.

여기에도 세부가 둘 있습니다.

  • https 형태로 복사합니다. athlentic://가 아니라요. iOS의 클립보드 URL 감지가 커스텀 스킴은 못 잡기 때문입니다.
  • await를 걸지 않습니다. 클립보드 쓰기를 기다리면 사용자 제스처 컨텍스트가 끊겨서 그 뒤의 스킴 이동이 차단될 수 있습니다. 그래서 fire-and-forget이고, 실패해도 딥링크는 그대로 진행합니다.
클립보드는 보조 경로입니다.

권한이 없거나 브라우저가 막으면 조용히 실패합니다. 그래도 주 흐름(앱 열기 · 스토어 보내기)은 멀쩡히 동작해야 합니다. 그래서 catch가 비어 있습니다 — 여기서 실패는 무시해도 되는 실패입니다.

5. 웹만 고치면 조용히 깨지는 지점

이 기능은 저장소 세 개에 걸쳐 있습니다. 웹이 스킴을 던지고, iOS와 Android가 그걸 파싱합니다. 웹만 바꾸면 아무 오류 없이 기능이 죽습니다.

웹 athlentic://fitness-type?type=turtle 로 이동 iOS 스킴 등록 + type 파라미터 파싱 Android 인텐트 필터 + type 파라미터 파싱 → typeKey 문자열 하나가 세 곳에서 같아야 한다

turtle, fox, dolphin 같은 유형 키를 웹에서만 바꾸면, 앱은 모르는 값을 받고 기본 화면을 띄웁니다. 예외도 안 납니다. 그냥 루틴이 안 맞을 뿐이고, 아무도 버그로 신고하지 않습니다.

그래서 규칙 문서에 별도 항목으로 적어뒀습니다.

typeKey 값을 바꾸면 iOS/Android 의 딥링크 파싱 코드도 함께 고쳐야 한다. 웹만 바꾸면 조용히 깨진다. 딥링크 폴백 로직을 바꾸면 iOS/Android 양쪽 실기기에서 확인해야 한다. 데스크톱 브라우저로는 검증되지 않는다.

이건 빌드 가드로 옮길 수 없는 종류입니다. 검사 대상이 다른 저장소에 있으니까요. 자동화할 수 없으면 최소한 바꿀 때 반드시 보이는 곳에 적어두는 게 차선입니다.

6. 데스크톱에서는 검증되지 않는다

가장 자주 잊는 부분입니다. 이 로직은 데스크톱 브라우저로 확인할 수 없습니다.

  • 커스텀 스킴에 반응할 앱이 없습니다.
  • 앱 전환이 없으니 visibilitychange가 그 의미로 뜨지 않습니다.
  • 스토어 링크는 열리지만 설치 흐름이 없습니다.

데스크톱에서 "눌러보니 스토어로 잘 가네"는 폴백이 항상 발동한다는 뜻이지, 폴백이 정확하다는 뜻이 아닙니다. 실제로 확인해야 하는 건 그 반대 경로 — 앱이 있을 때 스토어로 안 가는가입니다.

실기기에서 봐야 하는 경우는 최소 넷입니다.

앱 설치됨 · iOS → 앱이 열리고 스토어로 안 감 앱 설치됨 · Android → 앱이 열리고 스토어로 안 감 앱 없음 · iOS → 1.5초 뒤 App Store 앱 없음 · Android → 1.5초 뒤 Play Store

7. 정리

딥링크 폴백이 어려운 건 코드가 복잡해서가 아닙니다. 실패를 알 방법이 없다는 제약 위에 얹혀 있기 때문입니다.

  • 앱이 열렸는지 물을 수 없으니 페이지를 떠났는지로 추론합니다. 정확한 판정이 아니라 근사치입니다.
  • visibilitychangepagehide를 둘 다 걸고, 타임아웃에서는 플래그와 현재 상태를 함께 봅니다.
  • 타이머를 스킴 이동보다 먼저 겁니다. 순서가 뒤집히면 폴백이 등록되지 않을 수 있습니다.
  • 보조 경로는 실패해도 주 흐름을 막지 않게 합니다. 클립보드 쓰기에 await를 걸지 않는 이유입니다.
  • 웹·iOS·Android 세 곳이 같은 값을 공유한다는 사실을 문서에 남깁니다. 한 곳만 바꾸면 조용히 깨집니다.

웹에서 앱으로 넘기는 버튼을 만들고 있다면, 성공 경로보다 실패 경로를 먼저 설계하세요. 성공은 한 줄이고, 나머지 전부가 실패를 다루는 코드입니다.