CLOVA 건강검진 챗봇

안정성 · 호환성

네이버 지도 딥링크 - 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 을 정확히 포착

앱으로 넘어가는 사건의 본질은 "이 화면이 더는 보이지 않게 됐다"이다. 그걸 가장 곧장 표현하는 신호가 visibilitychangehidden이었다. 타이머는 남겨 두되, "앱이 열렸다"의 근거를 시간이 아니라 가시성으로 바꾼 것이다.

남은 한계 - 정직하게 적어두기

이 방식으로도 못 잡는 케이스가 있다. 지도 검색은 챗봇이 넘겨준 hospitalName에 의존하는데, 그 이름 자체가 부정확하면(예: 실제 값이 건대와이(Y)산부인과의원이지만 검색 가능한 이름은 건대와이산부인과의원) 주소 보완으로도 검색이 실패한다. 병원 리스트를 chat.answer에서 받아 그 address를 지도 API로 넘기는 구조라, 이름이 틀린 경우는 프론트에서 대응이 어렵다는 걸 팀에 공유해 뒀다. 또 딥링크 동작은 기기·앱 설치 상태에 따라 달라 배포 후 실기기 확인이 필요한 코드였다.

배운 점

  • 알 수 없는 상태는 시간이 아니라 관측으로 좁힌다. 딥링크 성공 여부를 직접 알 수 없으니 타이머로 때웠지만, 정확한 신호는 "화면이 숨겨졌다"는 관측이었다. 못 받는 값은 부수 효과로 대신 읽는다.
  • 타이머 fallback은 성공 케이스를 구분하지 못한다. "일정 시간 뒤에도 그대로면 실패"라는 가정은 앱이 열려 화면을 떠난 경우까지 실패로 오인한다. 그 구멍을 가시성 관측과 경과 시간 가드로 메웠다.
  • 신호는 감지 대상이 곧 의미다. blur·pagehide·visibilitychange는 비슷해 보여도 관측하는 사건이 다르다. "앱으로 넘어감 = 화면이 숨겨짐"에 정확히 대응하는 신호를 골라야 오탐이 없다.
  • 입력이 틀리면 좋은 로직도 못 구한다. 검색 실패의 일부는 알고리즘이 아니라 넘겨받은 병원명이 부정확한 데 있었다 - 내가 못 고치는 경계를 인정하고 공유하는 것도 문제 해결의 일부였다.