[Vue] 네이버 지도맵 어플 유무에 따른 fallback 처리

2025. 9. 4. 16:35Vue

 

1. 개요

- 네이버 지도를 활용한 맵 팝업을 구성 

- 네이버 정책상 네이버 지도 url에 위도, 경도 값을 넣어도 출발,도착 경로 설정이 불가

- 개선방향으로 데스크톱 웹에서는 url에 장소명을 넣어 장소를 표출하고 출발, 도착 지점을 사용자가 지정할 수 있도록 구성 

  모바일에서는 네이버 지도 어플이 있는 경우 어플로 이동하고, 없는 경우 웹페이지 자체에서 네이버 지도 영역으로 이동해 

  데스크톱 웹처럼 이용할 수 있도록 구성

 

 

2. 요구되어지는 사항

- 모바일에서 안드로이드 또는 IOS 기기의 네이버 지도 어플이 없는 경우 열린 웹페이지내에서 페이지 이동이 가능하도록

하는 fallback 기능이 필요

** 여기서 fallback_ 이란 개발에서 자주 쓰는 개념으로 쉽게 말하면 **주요 동작이 실패했을 때 대신 실행되는 차선책(백업 경로)”**을 뜻한다. 주요 기능 실패 시 대신 실행되는 예비 동작(대체 경로) 으로 이해하면 된다.

 

 

 

** 전체 완료 코드 _ 설명은 아래에 (필요한 코드 요소만 가져오고 삭제처리함_ 템플릿내 data 요소는 신경쓰지 말 것)

<template>
     <div class="map-content">
       <div class="content-space non-flex">
         <div v-if="resultFlag" class="result-space">
            <h1>총 <span>{{ searchResults.length > 0 ? searchResults.length : '' }}</span>건</h1>
            <p class="mapResult-notice">* 지점에 따른 운영정보 *</p>
            <div class="tableWrappers">
              <table>
                <tbody>
                  <tr v-for="(v, index) in searchResults" :key="`result-${index}`">
                    <!-- <td :class="{ 'is-selected': selectedName === index }" @click="getMarkerText(v, true)">{{ v.name }}</td> -->
                    <td :class="{ 'is-selected': selectedName === index }" @click="getMarkerText(v, true, index)">{{ v.name }}</td>
                    <td class="map-icon addressIcon">{{ v.jibunAddress }}
                      <a href="#" @click.prevent="openRoadLink(v.name)" class="roadLink">길찾기</a>
                    </td>
                    <td class="map-icon callIcon">{{ v.tell }}</td>
                    <td class="map-icon bookingIcon">
                      <a :href="getReservationLink(v.name)" target="blank">예약하기</a>
                      <a :href="getHomepage(v.name)" target="blank" class="homepageIcon">홈페이지</a>
                    </td>
                  </tr>
                </tbody>                 
              </table>
            </div>
         </div>
         <div v-else class="result-space no-result">
            <p>검색결과가 없습니다</p>
         </div>
       </div>
</template>
<script>
export default {

  data() {
    return {
      roadLinks : [],
    }
  },
  mounted(){   
    this.getMapStoreList();
  },
  methods: {
     openRoadLink(name) {
      const found = this.roadLinks.find(l => l.name === name);
      if (!found) return;

      const nmapUrl = found.mobileLink ? found.mobileLink : "#" // 모바일인 경우 ex) nmap://route/pubtrans?dlat=...&dlng=...&appname=mywebapp
      const webUrl  = found.webLink ? found.webLink : "#"   // 웹인 경우

      const isMobile = window.innerWidth < 1023;
      const isAndroid = /Android/i.test(navigator.userAgent);
      const isIOS = /iPhone|iPad|iPod/i.test(navigator.userAgent);

      if (!isMobile) {
        // PC는 웹으로
        window.open(webUrl, '_blank');
        return;
      }

      //안드로이드 경우
      if (isAndroid) {
        // Android는 intent://가 가장 안정적 (앱 없으면 자동으로 webUrl로 폴백)
        // mobileLink에 들어간 쿼리(dlat, dlng, dname, appname)를 그대로 재사용.
        const u = new URL(nmapUrl.replace('nmap://', 'https://')); // 파싱용 편법
        const path = u.pathname.replace(/^\/+/, ''); // route/pubtrans 등
        const qs = u.search.replace(/^\?/, '');      // dlat=..&dlng=..&...

        const intentUrl =
          `intent://${path}?${qs}` +
          `#Intent;scheme=nmap;package=com.nhn.android.nmap;` +
          `S.browser_fallback_url=${encodeURIComponent(webUrl)};end`;

        // 새 창 대신 현재 창 이동이 더 안정적(권장)
        window.location.href = intentUrl;
        return;
      }

      //IOS 경우
      if (isIOS) {
        let navigated = false;  // 앱 전환 감지 플래그
        let t;

        const cancelFallback = () => {
          if (navigated) return;
          navigated = true;
          clearTimeout(t);
          document.removeEventListener('visibilitychange', onVis);
          window.removeEventListener('pagehide', onHide);
          window.removeEventListener('blur', onBlur);
          // 숨김 iframe 제거
          if (iframe && iframe.parentNode) iframe.parentNode.removeChild(iframe);
        };

        //document.hidden >> 현재 문서가 사용자에게 보이는지 안보이는지 확인 => false = 현재 페이지 보고있음(활성 탭, 전환 안됨) true = 페이지 숨겨짐(다른 앱, 탭으로 전환)
        const onVis  = () => { if (document.hidden) cancelFallback(); };
        const onHide = () => cancelFallback();
        const onBlur = () => cancelFallback();

        //visibilitychange >> 브라우저가 페이지의 가시성 상태(보이는지/숨겨졌는지)가 바뀔 때 발생하는 이벤트
        document.addEventListener('visibilitychange', onVis);
        window.addEventListener('pagehide', onHide);
        window.addEventListener('blur', onBlur);

        //IOS safari 보완 1) 숨김 iframe으로 앱 실행 "시도"(앱 없으면 조용히 실패)
        const iframe = document.createElement('iframe');
        iframe.style.display = 'none';
        iframe.src = nmapUrl; // nmap://route/...
        document.body.appendChild(iframe);

        // 2) 폴백 타이머 (앱 없으면 웹으로 이동)
        t = setTimeout(() => {
          if (!navigated) {
            cancelFallback();
            window.location.href = webUrl; // 현재 창에서 웹 폴백
          }
        }, 1200); // 800~1500ms 권장

        // 3) 아주 짧은 재확인(일부 iOS에서 visibilitychange 지연 보완)
        setTimeout(() => {
          if (document.hidden) cancelFallback();
        }, 200);

        return;
      }

      // 그 외 모바일 환경은 웹으로
      window.location.href = webUrl;
    },
    getHomepage(name){
      const found = this.roadLinks.find(linkObj => linkObj.name === name);
      return found ? found.homepage : '#'; // 없으면 기본값
    },
    getReservationLink(name){
      const found = this.roadLinks.find(linkObj => linkObj.name === name);
      return found ? `https://www.로그인할페이지.co.kr/login?${found.loginStoreIndex}` : '#'; // 없으면 기본값
    },
   /* 지점 불러오기 */
    getMapStoreList(){
      const data = {};
        const callback = {
          success: (response) => {
            this.roadLinks = response.data.map((item) => ({
              name : item.name,
              webLink: `https://map.naver.com/v5/search/${item.name}`,
              homepage : item.homepageUrl ? item.homepageUrl : '#',
              loginStoreIndex : item.storeIdx? item.storeIdx : '',
              mobileLink : `nmap://route/pubtrans?dlat=${item.latitude}&dlng=${item.longitude}&appname=mywebapp`
             }
            ))
          },
          error: (error) => {
            console.log('지점목록 불러오기 실패:', error);
          }
        }
       this.$api.chat.getMapStoreList(data, callback);
    }
  }
}
</script>

 

 

3. 이슈사항 

 IOS  safari 의 경우, 어플이 없으면 없다는 알림창이 뜨는데 이 알림창 이후로 자동 폴백이 진행되지 않았다.

=> 이유가 궁금해서 gpt에 물어보니 아래와 같은 답변이 나왔다.

 

gpt답변

 

 

결론 >> 사파리/OS 레벨의 기본동작이라 다른 처리가 필요하다는 것.

 

 

해결책  ** 숨김 iframe 방식

const iframe = document.createElement('iframe');
iframe.style.display = 'none';
iframe.src = nmapUrl; // nmap://...
document.body.appendChild(iframe);

 

  • 앱 설치 O → iOS가 네이버 지도 앱 실행 → document.hidden / pagehide 이벤트 발생 → 타이머 취소 → 웹 폴백 안 실행됨
  • 앱 설치 X → iframe 로드 실패 → 팝업 없이 조용히 무시됨→ 일정 시간 타이머가 만료 → window.location.href = webUrl 실행 → 웹 네이버 지도 열림

✅ 정리 

 

  • 숨김 iframe을 쓰면 앱 미설치 시 오류 팝업이 전혀 뜨지 않는다.
  • 앱이 없거나 앱 실행 실패 → 조용히 무시된 뒤, 우리가 넣은 타이머 폴백 로직으로 웹 네이버 지도가 열리게 된다.
  • 앱이 있으면 → 정상적으로 앱으로 전환되고, 이벤트 감지로 웹 폴백은 실행되지 않는다.