예약 포털 (Vue3 + Firebase) - 서비스 오픈까지

15. 동네 (예약) 포털 (Vue 3 + Firebase) - 네이버 지도 상점 위치 보기 개선

그랜파 개발자 2025. 6. 5. 13:30

상점 위치 보기 개선

상점의 주소로 네이버 지도 위에 표시를 하였을 때 실제 위치와 차이가 납니다.

검색 API는 네이버 검색 결과를 뉴스, 백과사전, 블로그, 쇼핑, 웹 문서, 전문정보, 지식iN, 책, 카페글 등 분야별로 볼 수 있는 API입니다. 그 외에 지역 검색 결과와 성인 검색어 판별 기능, 오타 변환 기능을 제공합니다.

지역 검색은 검색 API를 사용해 네이버 지역 서비스에 등록된 업체 및 기관을 검색한 결과를 반환하는 RESTful API입니다. 지역 검색 결과를 XML 형식 또는 JSON 형식으로 반환합니다. API를 호출할 때는 검색어와 검색 조건을 쿼리 스트링(Query String) 형식의 데이터로 전달합니다.
https://developers.naver.com/docs/serviceapi/search/local/local.md

 

상호로 위치를 검색하는 기능을 추가하였습니다.
상호로 위치 검색은 사용자가 입력한 키워드에 대한 검색으로

여러 개의 검색결과를 돌려 줍니다.
네이버 상호 검색은 내부적으로 POI 정보를 반환합니다.

 

📌 POI (Point of Interest): 위치 기반 데이터에서 관심 지점(상점, 식당, 병원, 공원 등)을 의미합니다.

 

https://github.com/inetsos/downtown

 

GitHub - inetsos/downtown: 동네 포털 - Vue 3 + Firebase

동네 포털 - Vue 3 + Firebase. Contribute to inetsos/downtown development by creating an account on GitHub.

github.com

 

주소로 검색

상호로 검색한 결과

검색 결과에서 선택 후

 

네이버 검색 API를 사용하는 방법은?

ChatGPT의 말:

네이버 검색 API는 네이버 개발자 센터에서 제공하는 RESTful API입니다. 로컬, 이미지, 뉴스, 블로그 등 다양한 검색 서비스를 제공합니다. 여기선 **로컬 검색 API(Local Search API)**를 기준으로 설명드릴게요.


✅ 1. 네이버 검색 API 사용을 위한 준비

① 네이버 개발자 센터 접속

👉 https://developers.naver.com/

② 애플리케이션 등록

  • "내 애플리케이션" → 애플리케이션 등록
  • 사용 API 선택: 검색 > 로컬 검색
  • 환경 설정: 웹 서버 등
  • Client IDClient Secret 발급받기

✅ 2. API 요청 예시 (로컬 검색)

📌 엔드포인트

GET https://openapi.naver.com/v1/search/local.json

📌 필수 요청 헤더

X-Naver-Client-Id: 발급받은 Client ID
X-Naver-Client-Secret: 발급받은 Client Secret

📌 쿼리 파라미터 예시

파라미터 설명
query 검색어 (UTF-8 인코딩 필요)
display 한 번에 가져올 검색 결과 수 (기본 10, 최대 30)
start 검색 시작 위치 (기본 1, 최대 1000)
sort 정렬 방식 (random

✅ 3. JavaScript (fetch) 사용 예시

const query = '강남 스타벅스'
const encodedQuery = encodeURIComponent(query)

fetch(`https://openapi.naver.com/v1/search/local.json?query=${encodedQuery}&display=5`, {
  headers: {
    'X-Naver-Client-Id': 'YOUR_CLIENT_ID',
    'X-Naver-Client-Secret': 'YOUR_CLIENT_SECRET'
  }
})
.then(res => res.json())
.then(data => {
  console.log(data.items) // 검색 결과 배열
})
.catch(err => console.error(err))

✅ 4. 응답 구조 예시

{
  "lastBuildDate": "Mon, 05 Jun 2025 10:00:00 +0900",
  "total": 3,
  "start": 1,
  "display": 3,
  "items": [
    {
      "title": "<b>스타벅스</b> 강남점",
      "link": "http://map.naver.com/local/siteview.nhn?code=123456",
      "category": "카페",
      "description": "",
      "telephone": "02-123-4567",
      "address": "서울 강남구 역삼동 123-45",
      "roadAddress": "서울 강남구 테헤란로 123",
      "mapx": "1271234567",
      "mapy": "371234567"
    }
  ]
}

 

src/views/MapView.vue - 개선된 네이버 지도에서의 상점 위치  보기

<!-- src/views/MapView.vue -->
<template>
  <v-container>
    <h3>{{ name }} 위치</h3>
    <v-btn color="primary" class="mt-2 mb-2" @click="search">
      상호로 위치 검색
    </v-btn> 주소로 상점 위치를 찾는 경우 실제 위치와 차이가 나는 경우가 있습니다.
    <div ref="mapElement" style="width: 100%; height: 400px;"></div> 
    <v-icon
      @click="goBack"
      style="cursor: pointer;"
      aria-label="뒤로가기"
    >
      mdi-arrow-left
    </v-icon>

    <ul v-if="results.length" class="mt-4">
      <li
        v-for="(item, index) in results"
        :key="index"
        class="border-b py-2 cursor-pointer"
        @click="moveToLocation(item)"
      >
        <strong v-html="item.title"></strong><br />
        주소: {{ item.roadAddress || item.address }}<br />
        전화번호: {{ item.telephone || '없음' }}
      </li>
    </ul>

  </v-container>
</template>

<script setup>
import { ref, onMounted, nextTick } from 'vue'
import { useRoute, useRouter } from 'vue-router'

const route = useRoute()
const router = useRouter()

const mapElement = ref(null)
const name = decodeURIComponent(route.query.name || '')
const address = decodeURIComponent(route.query.address || '')

const dong = ref('')
const results = ref([])

let map = null
let marker = null
let infoWindow = null

const search = async () => {
  const encoded = encodeURIComponent(dong.value + ' ' + name)
  const res = await fetch(`/naver-api/search/local.json?query=${encoded}&display=15`, {
    headers: {
      'X-Naver-Client-Id': import.meta.env.VITE_NAVER_CLIENT_ID,
      'X-Naver-Client-Secret': import.meta.env.VITE_NAVER_CLIENT_SECRET,
    }
  })

  const data = await res.json() 
  results.value = data.items || []
}

// 주소 → 좌표 변환 함수
const getCoordinates = (address) => {
  return new Promise((resolve, reject) => {
    naver.maps.Service.geocode({ query: address }, (status, response) => {
      if (status !== naver.maps.Service.Status.OK || !response.v2?.addresses?.length) {
        return reject(new Error('주소를 찾을 수 없거나 오류가 발생했습니다.'))
      }
      const item = response.v2.addresses[0]
      resolve({
        latitude: parseFloat(item.y),
        longitude: parseFloat(item.x),
        roadAddress: item.roadAddress,
        jibunAddress: item.jibunAddress,
        dongName:item.addressElements[2].longName
      })
    })
  })
}

// 지도 초기화
const initMap = async () => {
  await nextTick()
  if (!mapElement.value || !window.naver) {
    console.error('naver 객체 또는 mapElement가 존재하지 않음')
    return
  }

  try {
    const { latitude, longitude, roadAddress, jibunAddress, dongName } = await getCoordinates(address)

    dong.value = dongName;

    map = new naver.maps.Map(mapElement.value, {
      center: new naver.maps.LatLng(latitude, longitude),
      zoom: 15
    })

    marker = new naver.maps.Marker({
      position: new naver.maps.LatLng(latitude, longitude),
      map,
      title: name
    })

    infoWindow = new naver.maps.InfoWindow({
      content: `
        <div id="infoWindowContent" style="padding:10px;min-width:200px;line-height:150%;cursor:pointer;">
          <strong>${name}</strong><br/>
          [도로명 주소] ${roadAddress || '없음'}<br/>
          [지번 주소] ${jibunAddress || '없음'}<br/>
          <small>(클릭하면 닫힘)</small>
        </div>
      `
    })

    infoWindow.open(map, marker)

    // infoWindow 내용 클릭 시 닫기
    setTimeout(() => {
      const el = document.getElementById('infoWindowContent')
      if (el) {
        el.addEventListener('click', () => {
          infoWindow.close()
        })
      }
    }, 0)

  } catch (error) {
    alert(error.message)
  }
}

// 검색 결과 클릭 시 지도 이동 및 마커 표시
const moveToLocation = (item) => {
  if (!map) return

  const lat = parseFloat(item.mapy/10000000)
  const lng = parseFloat(item.mapx/10000000)

  const position = new naver.maps.LatLng(lat, lng)
  map.setCenter(position)
  map.setZoom(15)

  if (marker) {
    marker.setPosition(position)
    marker.setTitle(item.title)
  } else {
    marker = new naver.maps.Marker({
      position,
      map,
      title: item.title
    })
  }

  if (infoWindow) {
    infoWindow.setContent(`
      <div id="infoWindowContent" style="padding:10px;min-width:200px;line-height:150%;cursor:pointer;">
        <strong>${item.title}</strong><br/>
        주소: ${item.roadAddress || item.address}<br/>
        전화번호: ${item.telephone || '없음'}<br/>
        <small>(클릭하면 닫힘)</small>
      </div>
    `)
    infoWindow.open(map, marker)

    // infoWindow 내용 클릭 시 닫기
    setTimeout(() => {
      const el = document.getElementById('infoWindowContent')
      if (el) {
        el.addEventListener('click', () => {
          infoWindow.close()
        })
      }
    }, 0)
  }
}

function goBack() {
  router.back()
}

onMounted(() => {
  if (!window.naver || !window.naver.maps) {
    const script = document.createElement('script')
    script.src = 'https://oapi.map.naver.com/openapi/v3/maps.js?ncpClientId=s781heidm9&submodules=geocoder'
    script.async = true
    script.onload = initMap
    document.head.appendChild(script)
  } else {
    initMap()
  }
})
</script>