google map, places api 적용

최준호·2022년 5월 9일
0

game

목록 보기
10/14
post-thumbnail

솔직하게 말해서 글로 정리하면서 해야했었는데... vue는 잘 몰라서 여러 방법으로 시도하느라 글로 정리를 못했다.

그래도 내가 원하는 기능은 어느정도 실행되도록 만들었고 대략 정리만 해두려고 한다.

👏google map, places api 사용

예전에 eats라는 프로젝트를 만들 때 썼던 api인데 google map을 사용한 이유는 places api랑 같이 사용하기 편해서다. api key 하나를 발급받아서 둘다 적용이 가능하므로 api key 하나만 관리하면 된다.

또 naver 검색 api가 아닌 google places를 사용한 이유는 네이버의 경우 무료 버전은 10개의 결과만 리턴하기 때문이다 ㅜ 그래도 google은 20개까지 반환해주어서 해당 api로 사용했다.

gogle map api

vue3-google-map-api 라이브러리르 설치하여 사용하였고 해당 라이브러리가 기본적으로 모든 기능을 컨트롤하기 가장 편하기도 하고 key를 자동으로 숨겨주어서 사용했다.

npm install -S @fawmi/vue-google-maps

npm으로 설치한 뒤 사용방법대로만 적용하면 되는데 나의 경우

<template>
  <div class="bg-dark text-secondary px-4 py-5 text-center">
    <loading v-model:active="isLoading"
                 :can-cancel="false"
                 :is-full-page="fullPage"/>
    <div class="py-5">
      <h1 class="display-5 fw-bold text-white">뽑기</h1>
      <div class="col-lg-6 mx-auto">
        <p class="fs-5 mb-4">뭐든 현재 위치를 기준으로 랜덤으로 추천해줍니다</p>
        <p class="fs-8 mb-4">(아이폰 사파리는 지원되지 않습니다...)</p>
        
        <p class="fs-5">1. 위치 선택</p>
        <div class="d-grid gap-2 d-sm-flex justify-content-sm-center">
          <button class="btn btn-secondary" @click="location">현재 위치로</button>
          <button class="btn btn-secondary" @click="execDaumPostcode">주소검색</button>
        </div>
        <div>
          현재 기준 위치 : lat : {{center.lat}} lng : {{center.lng}}
        </div>

        <p class="fs-5 mt-2">2. 검색</p>
        <div class="d-grid gap-2 d-sm-flex justify-content-sm-center row">
          <div class="col-md-6"><input class="form-control text-center" type="text" id="keyword" placeholder="ex) 대학교, 병원, 중국집, 한식 ..."/></div>
          <div class="col-md-6"><input class="form-control text-center" type="text" id="radius" placeholder="거리 입력 (m기준 / 기본 500m 반경, 최대 50,000m 검색)"/></div>
        </div>
        <div class="d-grid gap-2 d-sm-flex justify-content-sm-center row mt-1">
          <!-- <div class="col-md-3"><button class="btn btn-secondary" @click="makeMarkers">검색</button></div> -->
        </div>

        <p class="fs-5">3. 뽑기</p>
        <div class="d-grid gap-2 d-sm-flex justify-content-sm-center row mt-1">
          <div class="col-md-6"><button class="btn btn-secondary" @click="makeMarkersSelect">바로 뽑기</button></div>
        </div>
      </div>
    </div>
  </div>

  <div class="b-example-divider mb-0"></div>
  <GMapMap id="googleMap"
      :center="center"
      :zoom="17"
      style="width: 100%; height: 500px"
  >
    <GMapMarker
        :key="index"
        v-for="(m, index) in markers"
        :position="m.position"
        :clickable="true"
        :draggable="false"
        @click="openMarker(m.idx)"
    >
      <GMapInfoWindow
        :opened="openedMarkerID === m.idx || m.infoWindoOpen"
      >
        <div class="card">
          <div class="card-header">
            영업 상태 : {{m.opennow}}
          </div>
          <div class="card-body">
            <h5 class="card-title">{{m.name}}</h5>
            <p class="card-text">
              평점 : {{m.rating}} (총 리뷰 {{m.userRatingsTotal}})
            </p>
            <p class="card-text">
              가격대 : {{m.priceLevel}}
            </p>
            <p class="card-text">
              <b>{{m.name}}</b>{{m.content}}
            </p>
          </div>
        </div>
      </GMapInfoWindow>
    </GMapMarker>
  </GMapMap>
</template>

<script>
import axios from "axios";
import jayeon from "@/axios/jayeon-axios";
import Loading from 'vue-loading-overlay';
import 'vue-loading-overlay/dist/vue-loading.css';

export default {
  name: 'EatsComponent',
  props: {
    msg: String
  },
  components: { Loading },
  data() {
    const center = {lat: 37.382314, lng: 127.119613};
    const markers = [];
    return {center, markers, isLoading: false, fullPage: true, openedMarkerID : null};
  },
  methods:{
    //지도 출력
    location(){
        if(!navigator.geolocation) {
            const msg = '위치 정보가 지원되지 않습니다.';
            alert(msg);
            throw msg;
        }
        navigator.geolocation.getCurrentPosition(position => {
            this.center = {
              lat : position.coords.latitude,
              lng : position.coords.longitude
            }
        });
    },
    //다음 주소 검색
    execDaumPostcode() {
      new window.daum.Postcode({
        oncomplete: (data) => {
          this.kakaoAddress(data.address);
        },
      }).open();
    },
    //주소를 기준으로 위도, 경도 정보 가져오기
    kakaoAddress(address){
      axios.get('https://dapi.kakao.com/v2/local/search/address',{
        params:{
          query: address
        },
        headers:{
          Authorization : 'KakaoAK '+ process.env.VUE_APP_KAKAO_REST_API_KEY
        }
      })
      .then(res=>{
        const roadAddress = res.data.documents[0].address;
        this.setCenter(roadAddress.x,roadAddress.y);
      })
      .catch(err=>{
        alert('주소를 불러오는데 실패했습니다...');
        console.log(err);
      });
    },
    //지도 위치 변경
    setCenter(x, y){
      const xx = Number(x);
      const yy = Number(y);
      this.center = {
        lat : yy,
        lng : xx
      }
    },
    //지도에 마크 찍어내기
    async makeMarkers(){
      const keyword = document.getElementById('keyword').value;
      const radius = document.getElementById('radius').value;
      if(keyword == ''){
        alert('키워드는 필수값입니다!');
        return;
      }

      const data = {
        lat : this.center.lat,
        lng : this.center.lng,
        keyword : keyword,
        radius : radius,
      }
      await jayeon.post('/google/places',data)
      .then(async res =>{
        const result = res.data;
        if(result.status == 'ZERO_RESULTS'){
          alert('일치하는 결과가 존재하지 않습니다!');
          return;
        }
        const markers = [];
        const range = result.results.length;
        const random = Math.floor( ( Math.random() * range ));

        await result.results.forEach((r, index) => {
          let opennow = '알 수 없음';
          if(typeof r.opening_hours != 'undefined') opennow = r.opening_hours.open_now == true ? '열림':'닫힘';

          let priceLevel = '매우 저렴';
          if(typeof r.price_level != 'undefined'){
            priceLevel = r.price_level == 1 ? '매우 저렴' :
              r.price_level == 2 ? '저렴' :
              r.price_level == 3 ? '보통' :
              r.price_level == 4 ? '비쌈' : 
              r.price_level == 5 ? '매우 비쌈' : '알 수 없음';
          }
          let infoWindoOpen = false;
          if(index == random) infoWindoOpen =true;
          const marker = {
            idx : index,
            position : r.geometry.location, 
            label: r.name.substring(0,1),
            title: r.name, 
            opennow: opennow, 
            name: r.name, 
            content: r.vicinity, 
            rating : r.rating, 
            userRatingsTotal : r.user_ratings_total,
            priceLevel : priceLevel,
            infoWindoOpen : infoWindoOpen
          };
          markers.push(marker);
        });
        this.markers = markers;
      })
      .catch(err=>{
        alert('알 수 없는 오류 발생');
        console.err(err);
      });
    },
    //바로 뽑기
    makeMarkersSelect(){
      this.isLoading = true;
      this.makeMarkers();
      try{
        setTimeout(() => {
          this.isLoading = false;
        }, 1000)
      }catch(err){
        alert(err);
        this.isLoading = false;
      }
    },
    //marker open infoWindow
    openMarker(idx){
      this.openedMarkerID = idx;
    }
  },
  
  
  beforeMount(){
    this.location();
  },
}
</script>

전체 코드는 다음과 같이 작성했다.

프론트 개발자가 아니라서 많이 부족하지만 ㅜㅜ 그래도 최소 기능이 작동되도록 했다.

해당 부분에서 가장 애 먹었던것은 최초에 사용했던 라이브러리가 npm에서 제공하는 vue3-google-map 라이브러리였는데 해당 기능에서 제공하는 기능 중에 infoWindow open method를 실행시키는 방법을 도저히 모르겠더라...ㅜ 지금 생각해보면 index.html에 js를 정의해서 사용했으면 됐을거 같은데 vue가 익숙하지 않아서 생각하지 못하고 다른 라이브러리로 처리했다.

google place api

@RestController
@RequestMapping("/api/google")
@Slf4j
@RequiredArgsConstructor
public class GooglePlacesController {
    private final GoogleService googleService;

    @PostMapping("/places")
    public ResponseEntity<String> places(@RequestBody RequestPlaces requestPlaces){
        log.info("google 검색 요청 : {}");
        ResponseEntity<String> responseEntity = googleService.places(requestPlaces);
        return ResponseEntity.status(responseEntity.getStatusCode()).contentType(MediaType.APPLICATION_JSON).body(responseEntity.getBody());
    }
}

api를 사용하기 위해 controller를 먼저 작성하고

@Getter
@Setter
public class RequestPlaces {
    private String lat;
    private String lng;
    private String keyword;
    private int radius = 500;
}

dto를 작성했고

public interface GoogleService {
    ResponseEntity<String> places(RequestPlaces requestPlaces);
}
@Service
@RequiredArgsConstructor
@Slf4j
public class GoogleServiceImpl implements GoogleService{

    //google places api
    //https://developers.google.com/maps/documentation/places/web-service/search-nearby
    @Override
    public ResponseEntity<String> places(RequestPlaces requestPlaces) {
        log.info("places 호출");
        WebClient googleClient = WebClient.builder().baseUrl("https://maps.googleapis.com")
                .build();

        //거리 기본값 500
        if(requestPlaces.getRadius() == 0) requestPlaces.setRadius(500);

        ResponseEntity<String> result = googleClient.get().uri(uriBuilder ->
            uriBuilder.path("/maps/api/place/nearbysearch/json")
                .queryParam("location", String.format("%s , %s",requestPlaces.getLat(), requestPlaces.getLng()))
                .queryParam("keyword", requestPlaces.getKeyword())
                .queryParam("radius", requestPlaces.getRadius())
                .queryParam("language", "ko")
                .queryParam("key", "AIzaSyDd4q1fnJ_2BBXJo8TgMA1-0Csgf_y6Ya8")
                .build()
        ).retrieve()
        .toEntity(String.class)
        .block();

        return result;
    }
}

service는 다음과 같이 작성했다. WebClient의 경우 기존에 spring container에 등록했던 WebClient는 내 api로만 요청하도록 작성해두어서 google로 요청할 때는 새로운 WebClient를 생성하도록 코드를 작성했는데... 지금 정리하면서 보니 google places api를 요청할 때마다 WebClient를 생성하느라 자원을 소모해야하는 상황인걸 알았다... google용 WebClient도 spring container에 등록해두고 하나의 객체를 재사용하는 방향으로 다시 작성해야겠다!

여기서 어려웠던 부분은 google places api가 js에서 직접 요청하는 libaray 형식과 server side에서 요청하는 형식이 나눠져 있었는데 js로 server api로 요청을 하니 생전 처음보는 431 에러가 나왔다. 해당 에러는 server에서 예상하는 header의 크기나 url의 크기가 너무 커서 나는 에러였는데 아마 web에서 요청을 하다보니 해당 브라우저의 기본 정보들과 기타 쿠키 같은 정보들이 한번에 같이 요청되어서 그런거 같다. 그래서 api 부분은 front로 하는거 보다 back으로 처리하는게 api key도 노출이 안되고 더 괜찮은거 같아서 해당 방식으로 처리했다!

👏기능 확인

아직 디자인은 하지 않았고 기능만 확인하기 위한 화면이다. 최초 접속시 위치 정보 제공에 동의하면 자동으로 현재 위치로 이동한다.

주소 검새을 누를 경우 daum 주소 검색 api를 사용하여 위치를 변경할 수 있다.

그 후 바로 뽑기를 누를 경우 해당 위치 기준으로 default 값이 500m로 해당 반경 안에서 해당 키워드로 검색하여 반환된 결과들 중 랜덤한 하나의 결과를 선택해서 화면에 노출해준다.

그리고 api에서 정보를 가져오는데 일정 시간이 소요되어 1초의 딜레이를 걸어두어 로딩 화면을 노출하도록 했다. api를 호출하는 func에 async와 await을 사용하여 처리해도 됐는데 setTimeOut으로 처리해보고 싶어서 해당 방법으로 처리했다. 나중에는 다시 func 호출에 맞게끔 변경해야겠다.

vue3가 적용하기는 어려웠지만 그래도 바로 바로 반응하고 화면도 부드럽게 변경되어서 확실히 js로만 처리했던 화면보다 더 깔끔해보여서 좋았던거 같다.

이제 기능을 좀더 사용하기 편하게 유효성 검사와 화면 디자인을 좀 더 깔끔하게 만들어야할거 같다.

😂vue3-google-maps api warning

해당 libarary를 사용하면 다음과 같은 에러 문구가 발생하는데 해당 문구를 구글에 검색해보면 개발자도 해당 에러를 해결하고 싶다고 써놨더라...ㅜㅜ 그냥 써야할거 같다 ㅜㅜ 심각한 에러는 아니고 경고문구니까...

profile
코딩을 깔끔하게 하고 싶어하는 초보 개발자 (편하게 글을 쓰기위해 반말체를 사용하고 있습니다! 양해 부탁드려요!) 현재 KakaoVX 근무중입니다!

0개의 댓글