안정성 · 호환성
네이버 지도 딥링크 - visibilitychange로 fallback 중복 막기
병원 위치를 네이버 지도 앱으로 열었다가 돌아오면, 앱 이동에 성공했는데도 웹 검색 fallback이 또 실행됐다. 원인을 좁혀 보니 코드의 버그가 아니라 브라우저가 "앱으로 잘 갔다"를 알려주지 않는다는 플랫폼의 빈틈이었다. 알 수 없는 상태를 타이머로만 때우던 방식을, 앱 전환의 부수 효과를 관측하는 방식으로 바꿔 메웠다. 같은 PR에서, 그 앞단인 병원 검색이 자꾸 실패하던 문제도 함께 손봤다.
그 앞단 - 병원이 검색되지 않았다
지도로 넘기기 전에 병원을 검색해야 하는데, 여기서 먼저 막혔다. 챗봇이 추천한 병원의 address를
그대로 네이버 지도 검색에 넘겼더니, 주소에 병원명이 없으면 그 주소 자체가 검색돼 정작 병원이
결과에 안 나왔다.
기존: "서울특별시 동대문구 휘경동 휘경로 47 150-12" → 도로명 주소만 검색됨 → 병원 없음 ✗그래서 주소를 병원명 기준으로 보완했다. address에 '의원'·'병원' 키워드가 들어 있으면 그대로 쓰고,
없으면 시 + 군구 + 동면 + 병원명 으로 주소를 재구성했다.
const hospitalKeyword = ['의원', '병원'];
const isHospital = hospitalKeyword.some((keyword) => address.includes(keyword));
const refinedAddress = isHospital
? address
: firstAddress.addressElements
.filter((el) => ['SIDO', 'SIGUGUN', 'DONGMYUN'].includes(el.types[0]))
.map((el) => el.longName)
.join(' ') + ` ${hospital.hospitalName}`;변경: "서울특별시 동대문구 휘경동 휘경우리들내과의원" → 검색 성공 ✓

시+군구+동면+병원명으로 보완 → 휘경우리들내과의원이 정확히 검색.주소를 동까지만 붙이고 그 뒤 상세 주소는 뺀 건 의도적이다 - 상세 주소를 더 붙이면 오히려 검색이
실패하는 경우가 있어, 사용자가 실제로 입력하는 수준인 동 영역까지만 두는 편이 나았다. 병원명만
넣지 않은 것도 이유가 있다 - 같은 상호가 타 지역에 있으면 엉뚱한 곳이 먼저 뜨기 때문에, 지역 주소를
함께 붙여 좁혔다. 이 refinedAddress로 딥링크·fallback URL을 만든다.
const deeplinkUrl = `nmap://search?query=${refinedAddress}&appname=...`;
const fallbackUrl = `https://m.map.naver.com/search2/search.naver?query=${refinedAddress}&...`;
openNaverMapAppOrWeb(deeplinkUrl, fallbackUrl, setUrl);문제 - 성공했는데 fallback이 또 열렸다
이제 deeplinkUrl로 네이버 지도 앱을 열고, 앱이 없으면 웹 검색 fallback을 보여준다. 의도는
단순하다 - 앱 이동에 실패했을 때만 fallback이 열려야 한다. 그런데 앱 이동에 성공한 뒤 브라우저로
복귀하면, 불필요하게 fallback(웹 검색)까지 열렸다. 지도 앱을 닫고 돌아왔더니 난데없이 웹 검색이 떠
있는 셈이었다.
원인 - 딥링크는 성공/실패를 돌려주지 않는다
딥링크(nmap:// 같은 커스텀 스킴)는 요청일 뿐, "앱이 실제로 열렸는지"를 콜백이나 Promise로
알려주지 않는다. location.href = deeplinkUrl을 실행한 코드는 그 뒤에 앱이 떴는지, 스킴을 처리할
앱이 없어 아무 일도 없었는지 구분할 수 없다.
그래서 기존에는 타이머 fallback만 썼다 - 딥링크를 시도하고 일정 시간 뒤에 웹으로 보낸다. 문제는 이 방식이 앱이 열린 경우까지 구분하지 못한다는 데 있다. 앱이 떠서 웹뷰가 백그라운드로 내려가도 예약된 타이머는 취소되지 않고 남아, 복귀 시점에 fallback을 터뜨린다.
해결 - 앱 전환의 부수 효과를 관측한다
딥링크가 성공을 직접 알려주지 않는다면, 앱으로 넘어갈 때 브라우저에 생기는 변화를 대신 관측하면
된다. 앱으로 이동하면 브라우저 탭/웹뷰가 hidden으로 전환된다. 이걸 visibilitychange로 감지해
hidden이 되면 fallback 타이머를 취소했다.
export const openNaverMapAppOrWeb = (
deeplinkUrl: string,
fallbackUrl: string,
setWebFrameUrl: (url: string) => void,
setOpenWeb?: (open: boolean) => void,
) => {
const clickedAt = +new Date();
location.href = deeplinkUrl; // 앱 딥링크 시도
// 딥링크 성공 여부를 추적하는 타이머
const checkIfDeeplinkOpened = setTimeout(() => {
if (+new Date() - clickedAt < 2000) {
setWebFrameUrl(fallbackUrl); // 앱이 안 열렸을 때만 웹 검색
if (setOpenWeb) setOpenWeb(true);
}
}, 1500);
// hidden 전환 = 앱으로 이동 성공 → 타이머 취소
const onVisibilityChange = () => {
if (document.visibilityState === 'hidden') {
clearTimeout(checkIfDeeplinkOpened);
document.removeEventListener('visibilitychange', onVisibilityChange);
}
};
document.addEventListener('visibilitychange', onVisibilityChange);
};핵심은 타이머와 관측을 경주시키는 것이다. 앱이 열리면 visibilitychange가 타이머보다 먼저 와
타이머를 지운다. 앱이 없으면 화면이 그대로라 hidden이 오지 않고, 타이머가 살아남아 fallback을 연다.
[앱이 있는 경우]
location.href = deeplinkUrl → 앱 실행 → 웹뷰 hidden
→ visibilitychange 발화 → clearTimeout → fallback 취소 ✓
[앱이 없는 경우]
location.href = deeplinkUrl → 처리할 앱 없음 → 화면 그대로(visible)
→ hidden 안 옴 → 타이머 1.5초 만료 → fallback(웹 검색) 실행 ✓타이머 콜백의 +new Date() - clickedAt < 2000 가드도 같은 방어를 한 겹 더 얹은 것이다. 앱이 열려
기기가 잠깐 멈췄다 깨어나면 JS 타이머가 뒤늦게 발화할 수 있는데, 클릭 후 2초가 넘게 지났다면 이미
앱을 다녀온 것으로 보고 fallback을 열지 않는다. 리스너는 hidden을 잡은 순간 스스로 제거해, 관측이
이기든 타이머가 이기든 남는 리스너가 없게 했다.
왜 timeout 단독이 아니라 visibilitychange였나
앱 이동을 감지할 신호 후보가 몇 개 있었다. 각자 관측하는 대상이 다르다.
| 신호 | 무엇을 감지하나 | 이 케이스에서 |
|---|---|---|
setTimeout 단독 | 시간 경과만 | 앱이 열려도 발화 → 중복 fallback |
blur | 창이 포커스를 잃음 | 앱 전환 외에도 자주 발생 → 오탐 소지 |
pagehide | 페이지가 버려짐 | 앱 전환은 파기가 아니라 백그라운드라 안 맞음 |
visibilitychange | 탭이 보이는지/숨는지 | 앱 전환 = hidden 을 정확히 포착 |
앱으로 넘어가는 사건의 본질은 "이 화면이 더는 보이지 않게 됐다"이다. 그걸 가장 곧장 표현하는 신호가
visibilitychange의 hidden이었다. 타이머는 남겨 두되, "앱이 열렸다"의 근거를 시간이 아니라
가시성으로 바꾼 것이다.
남은 한계 - 정직하게 적어두기
이 방식으로도 못 잡는 케이스가 있다. 지도 검색은 챗봇이 넘겨준 hospitalName에 의존하는데, 그
이름 자체가 부정확하면(예: 실제 값이 건대와이(Y)산부인과의원이지만 검색 가능한 이름은
건대와이산부인과의원) 주소 보완으로도 검색이 실패한다. 병원 리스트를 chat.answer에서 받아 그
address를 지도 API로 넘기는 구조라, 이름이 틀린 경우는 프론트에서 대응이 어렵다는 걸 팀에 공유해
뒀다. 또 딥링크 동작은 기기·앱 설치 상태에 따라 달라 배포 후 실기기 확인이 필요한 코드였다.
배운 점
- 알 수 없는 상태는 시간이 아니라 관측으로 좁힌다. 딥링크 성공 여부를 직접 알 수 없으니 타이머로 때웠지만, 정확한 신호는 "화면이 숨겨졌다"는 관측이었다. 못 받는 값은 부수 효과로 대신 읽는다.
- 타이머 fallback은 성공 케이스를 구분하지 못한다. "일정 시간 뒤에도 그대로면 실패"라는 가정은 앱이 열려 화면을 떠난 경우까지 실패로 오인한다. 그 구멍을 가시성 관측과 경과 시간 가드로 메웠다.
- 신호는 감지 대상이 곧 의미다.
blur·pagehide·visibilitychange는 비슷해 보여도 관측하는 사건이 다르다. "앱으로 넘어감 = 화면이 숨겨짐"에 정확히 대응하는 신호를 골라야 오탐이 없다. - 입력이 틀리면 좋은 로직도 못 구한다. 검색 실패의 일부는 알고리즘이 아니라 넘겨받은 병원명이 부정확한 데 있었다 - 내가 못 고치는 경계를 인정하고 공유하는 것도 문제 해결의 일부였다.