엘리스 AI트랙 4기/프로젝트

KakaoMap API로 "현위치 주변" 장소 검색 목록 보여주기_1탄

남쪽마을밤송이 2022. 6. 1. 04:42

 01. KakaoMap API 사용 배경  

  • 포스팅 순서가 좀 바꼈지만 2차 프로젝트에 이용한 마지막 외부 API인 카카오맵 적용 과정을 기록하려 한다.
  • 프로젝트를 진행하며 마이 페이지에 내 위치 주변 PC방 목록을 보여주고 싶었다.
  • 따라서 "PC방"라는 키워드로 검색한 장소 목록을 출력하려고 하는데, 우리나라 지도가 필요하기 때문에 정확도를 위해 네이버 맵과 카카오 맵 중 API 설명이 보기 편한 카카오 맵 API를 선택했다.
  • 결론적으로 말하면 원하던 기능 구현에는 성공했지만, 생각보다 시간이 오래걸렸고(중간에 다른거 하며 이틀동안 시도) 시행착오를 많이 겪었다. 그리고 결정적으로 배포 이후에는 제대로 동작하지 않았다. 이유는 보안 환경이 아니었기 때문인데 뒤에서 자세히 설명하겠다.

 02. KakaoMap API 사용 준비  

  • 카카오가 제공하는 모든 API를 사용하기 위해서는 Kakao Developers에서 회원가입을 해야 한다.
  • 가입하고 내 어플리케이션에서 원하는 이름으로 앱을 하나 추가한다.

  • 그러면  요약정보 > 앱 키 에서 카카오맵의 appkey 를 확인할 수 있다. 나는 자바스크립트를 사용하니까 JavaScript 키를 확인해둔다.

  • 생성한 앱으로 들어가 왼쪽의 메뉴에서  플랫폼 > 웹  항목에 접근을 허용해 줄 도메인들을 등록한다. 최대 10개까지 등록 가능하다고 한다.

  • 이렇게 벌써(?) KakaoMap API를 사용할 준비를 마쳤다. Kakao Developers에 회원가입을 하니 괜히 뭐 된거 같고 기분이 좋았다😎

 [03 KakaoMap API 사용하기

 (1) 사용할 함수 결정하기 

  • KakaoMap API 페이지 > Web에 들어가면 다양한 Guide와 Sample을 확인할 수 있다. 딱 봐도 엄청 친절한 느낌.
  • 기능이 너무 많기 때문에 일단 Sample로 들어가서 뭘 쓰면 될지 확인했다. 나는 내 주변 피씨방들의 위치를 보여주고 싶었기 때문에 "키워드로 장소검색하고 목록으로 표출하기"가 딱이었다. 너로 결정!

  • 들어가면 Javascript와 HTML코드가 모두 제공되기 때문에 처음엔 그대로 복붙해서 쫌만 고치면 쉽게 원하는 기능이 만들어질 줄 알았다😅

 (2) 라이브러리 불러오기 

  • 일단 명시할 점은 저번 Youtube API 를 포스팅하며 느낀대로 굳이 Back을 거칠 필요가 없는 외부 API는 바로 Front에서 적용하는 것이 더 좋다고 생각했다. 따라서 아래 KakaoMap API 코드는 낯선 React를 붙잡고 이틀간 씨름한 결과라고도 할 수 있다.
  • 코드의 70% 정도는 가이드 코드를 참고했고 30% 정도는 직접 작성하고 수정했다.

  • 가장 먼저 할 일은 index.html 파일에 카카오맵 라이브러리를 호출하는 것이다. 아래 코드를 <head> 태그나 <body> 태그 안에 아무데나 추가해준다.
    • 나는 <head> 태그에 추가했다.
    • 중요한 점은 키워드 검색 기능을 쓰려면 라이브러리는 추가로 불러서 사용해야 한다는 것이다. 아래와 같이 파라메터에 추가하여 로드하면 된다. (참고)
    • 그리고 appkey에는 위에서 발급받은 JavaScript 키를 넣어주는데 외부에 유출되면 안되므로  .env 파일 로 관리하기 위해서는 양 옆에 %를 붙이고 환경변수 이름을 넣어주면 된다. 이 때, React에서 사용할 환경변수 이름은 꼭   REACT_APP_ 으로 시작해야 한다고 하니 주의하자.
<script
      type="text/javascript"
      src="//dapi.kakao.com/v2/maps/sdk.js?appkey=%REACT_APP_KAKAOMAP_API_KEY%&libraries=services"
></script>
  • 위 방법은 사실 아래와 같은 오류를 유발한다. 찾아보니 React가 SPA 방식이라서 나는 오류로 모든 페이지가 index.html을 기반으로 하기 때문에 필요 없는 페이지에서도 KakaoMap API를 호출한다는 그런 오류인 것 같았다.
    • 해결 방법은 KakaoMap API를 사용하는 파일에서 호출하는 방법인데 동작하지 않아서 일단 그대로 뒀다🙄 구글링 결과같은 문제를 겪은 다른 분들의 의견도 큰 문제는 아니라고 하셔서...

 (3) 코드 적용 

  • 본격적으로 가이드 코드를 참고해 css 파일 하나와 함수를 실행할 js 파일 하나를 만들어준다.
  • 일단 css 코드는 가이드 코드에 있는 그대로 사용해줬다가 자꾸 검색 목록이 흰색 글씨로 출력되어 보이지 않는 문제가 있었어서 조금 수정한 버전이다.
더보기

MypageMap.css 전체 코드

.map_wrap {
  display: flex;
  flex-direction: row;
  justify-content: center;
  align-items: center;
}

.map_wrap,
.map_wrap * {
  margin: 0;
  padding: 0;
  font-family: "Malgun Gothic", dotum, "돋움", sans-serif;
  font-size: 12px;
}
.map_wrap a,
.map_wrap a:hover,
.map_wrap a:active {
  color: #000;
  text-decoration: none;
}
.map_wrap {
  position: relative;
  width: 100%;
  height: 500px;
}
#menu_wrap {
  position: absolute;
  top: 0;
  bottom: 0;
  width: 250px;
  margin: 10px 560px 30px 10px;
  padding: 5px;
  overflow-y: auto;
  background: rgba(255, 255, 255, 0.7);
  z-index: 1;
  font-size: 12px;
  border-radius: 10px;
}
.bg_white {
  background: #fff;
}
#menu_wrap hr {
  display: block;
  height: 1px;
  border: 0;
  border-top: 2px solid #5f5f5f;
  margin: 3px 0;
}
#menu_wrap .option {
  text-align: center;
}
#menu_wrap .option p {
  margin: 10px 0;
}
#menu_wrap .option button {
  margin-left: 5px;
}
#placesList li {
  list-style: none;
}
#placesList .item {
  position: relative;
  border-bottom: 1px solid #888;
  overflow: hidden;
  cursor: pointer;
  min-height: 65px;
}
#placesList .item span {
  display: block;
  margin-top: 4px;
}
#placesList .item h5,
#placesList .item .info {
  text-overflow: ellipsis;
  overflow: hidden;
  white-space: nowrap;
}
#placesList .item .info {
  padding: 10px 0 10px 55px;
}
#placesList .info {
  color: black;
}
#placesList .info .gray {
  color: #8a8a8a;
}
#placesList .info .jibun {
  padding-left: 26px;
  background: url(https://t1.daumcdn.net/localimg/localimages/07/mapapidoc/places_jibun.png)
    no-repeat;
}
#placesList .info .tel {
  color: #009900;
}
#placesList .item .markerbg {
  float: left;
  position: absolute;
  width: 36px;
  height: 37px;
  margin: 10px 0 0 10px;
  background: url(https://t1.daumcdn.net/localimg/localimages/07/mapapidoc/marker_number_blue.png)
    no-repeat;
}
#placesList .item .marker_1 {
  background-position: 0 -10px;
}
#placesList .item .marker_2 {
  background-position: 0 -56px;
}
#placesList .item .marker_3 {
  background-position: 0 -102px;
}
#placesList .item .marker_4 {
  background-position: 0 -148px;
}
#placesList .item .marker_5 {
  background-position: 0 -194px;
}
#placesList .item .marker_6 {
  background-position: 0 -240px;
}
#placesList .item .marker_7 {
  background-position: 0 -286px;
}
#placesList .item .marker_8 {
  background-position: 0 -332px;
}
#placesList .item .marker_9 {
  background-position: 0 -378px;
}
#placesList .item .marker_10 {
  background-position: 0 -423px;
}
#placesList .item .marker_11 {
  background-position: 0 -470px;
}
#placesList .item .marker_12 {
  background-position: 0 -516px;
}
#placesList .item .marker_13 {
  background-position: 0 -562px;
}
#placesList .item .marker_14 {
  background-position: 0 -608px;
}
#placesList .item .marker_15 {
  background-position: 0 -654px;
}
#pagination {
  margin: 10px auto;
  text-align: center;
}
#pagination a {
  display: inline-block;
  margin-right: 10px;
}
#pagination .on {
  font-weight: bold;
  cursor: default;
  color: #777;
}
  • js 파일의 전체적인 큰 틀은 다음과 같다. css 파일을 import하고 map을 return할 div 태그들 부분을 작성해준다.
    • 가이드 코드는 HTML 문법으로 작성되어 있었기 때문에 React 문법으로 바꿔주었고 크기도 조정해줬다.
    • 그리고 나의 경우 input값은 필요 없었기 때문에 지워주었다.
/*global kakao*/
import "../styles/Mypage/MypageMap.css";
import React, { useEffect } from "react";
import Swal from "sweetalert2";

const MypageMap = () => {
  return (
    <div className="map_wrap">
      <div
        id="map"
        style={{
          width: "800px",
          height: "500px",
          position: "relative",
          overflow: "hidden",
        }}
      ></div>
      <div id="menu_wrap" className="bg_white">
        <hr />
        <ul id="placesList"></ul>
        <div id="pagination"></div>
      </div>
    </div>
  );
};

export default MypageMap;
  • 그리고 메인 기능인 키워드로 장소를 검색하면 검색결과를 목록과 마커로 표시하는 부분을  useEffect  훅으로 감싸준다. 아래 코드로 검색 목록을 띄우는 것을 성공했다.
더보기

현재까지 MypageMap 함수 전체 코드

const MypageMap = () => {
  useEffect(() => {
    // 마커를 담을 배열입니다
    var markers = [];

    var mapContainer = document.getElementById("map"), // 지도를 표시할 div
      mapOption = {
        center: new kakao.maps.LatLng(37.566826, 126.9786567), // 지도의 중심좌표
        level: 3, // 지도의 확대 레벨
      };

    // 지도를 생성합니다
    var map = new kakao.maps.Map(mapContainer, mapOption);

    // 일반 지도와 스카이뷰로 지도 타입을 전환할 수 있는 지도타입 컨트롤을 생성합니다
    var mapTypeControl = new kakao.maps.MapTypeControl();

    // 지도에 컨트롤을 추가해야 지도위에 표시됩니다
    // kakao.maps.ControlPosition은 컨트롤이 표시될 위치를 정의하는데 TOPRIGHT는 오른쪽 위를 의미합니다
    map.addControl(mapTypeControl, kakao.maps.ControlPosition.TOPRIGHT);

    // 지도 확대 축소를 제어할 수 있는  줌 컨트롤을 생성합니다
    var zoomControl = new kakao.maps.ZoomControl();
    map.addControl(zoomControl, kakao.maps.ControlPosition.RIGHT);

    // 장소 검색 객체를 생성합니다
    var ps = new kakao.maps.services.Places();

    // 검색 결과 목록이나 마커를 클릭했을 때 장소명을 표출할 인포윈도우를 생성합니다
    var infowindow = new kakao.maps.InfoWindow({ zIndex: 1 });

    // 키워드로 장소를 검색합니다
    searchPlaces();

    // 키워드 검색을 요청하는 함수입니다
    function searchPlaces() {
      var keyword = "피씨방";

      // 장소검색 객체를 통해 키워드로 장소검색을 요청합니다
      ps.keywordSearch(keyword, placesSearchCB);
    }

    // 장소검색이 완료됐을 때 호출되는 콜백함수 입니다
    function placesSearchCB(data, status, pagination) {
      if (status === kakao.maps.services.Status.OK) {
        // 정상적으로 검색이 완료됐으면
        // 검색 목록과 마커를 표출합니다
        displayPlaces(data);

        // 페이지 번호를 표출합니다
        displayPagination(pagination);
      } else if (status === kakao.maps.services.Status.ZERO_RESULT) {
        alert("검색 결과가 존재하지 않습니다.");
        return;
      } else if (status === kakao.maps.services.Status.ERROR) {
        alert("검색 결과 중 오류가 발생했습니다.");
        return;
      }
    }

    // 검색 결과 목록과 마커를 표출하는 함수입니다
    function displayPlaces(places) {
      var listEl = document.getElementById("placesList"),
        menuEl = document.getElementById("menu_wrap"),
        fragment = document.createDocumentFragment(),
        bounds = new kakao.maps.LatLngBounds(),
        listStr = "";

      // 검색 결과 목록에 추가된 항목들을 제거합니다
      removeAllChildNods(listEl);

      // 지도에 표시되고 있는 마커를 제거합니다
      removeMarker();

      for (var i = 0; i < places.length; i++) {
        // 마커를 생성하고 지도에 표시합니다
        var placePosition = new kakao.maps.LatLng(places[i].y, places[i].x),
          marker = addMarker(placePosition, i),
          itemEl = getListItem(i, places[i]); // 검색 결과 항목 Element를 생성합니다

        // 검색된 장소 위치를 기준으로 지도 범위를 재설정하기위해
        // LatLngBounds 객체에 좌표를 추가합니다
        bounds.extend(placePosition);

        // 마커와 검색결과 항목에 mouseover 했을때
        // 해당 장소에 인포윈도우에 장소명을 표시합니다
        // mouseout 했을 때는 인포윈도우를 닫습니다
        (function (marker, title) {
          kakao.maps.event.addListener(marker, "mouseover", function () {
            displayInfowindow(marker, title);
          });

          kakao.maps.event.addListener(marker, "mouseout", function () {
            infowindow.close();
          });

          itemEl.onmouseover = function () {
            displayInfowindow(marker, title);
          };

          itemEl.onmouseout = function () {
            infowindow.close();
          };
        })(marker, places[i].place_name);

        fragment.appendChild(itemEl);
      }

      // 검색결과 항목들을 검색결과 목록 Element에 추가합니다
      listEl.appendChild(fragment);
      menuEl.scrollTop = 0;

      // 검색된 장소 위치를 기준으로 지도 범위를 재설정합니다
      map.setBounds(bounds);
    }

    // 검색결과 항목을 Element로 반환하는 함수입니다
    function getListItem(index, places) {
      var el = document.createElement("li"),
        itemStr =
          '<span class="markerbg marker_' +
          (index + 1) +
          '"></span>' +
          '<div class="info">' +
          "   <h5>" +
          places.place_name +
          "</h5>";

      if (places.road_address_name) {
        itemStr +=
          "    <span>" +
          places.road_address_name +
          "</span>" +
          '   <span class="jibun gray">' +
          places.address_name +
          "</span>";
      } else {
        itemStr += "    <span>" + places.address_name + "</span>";
      }

      itemStr += '  <span class="tel">' + places.phone + "</span>" + "</div>";

      el.innerHTML = itemStr;
      el.className = "item";

      return el;
    }

    // 마커를 생성하고 지도 위에 마커를 표시하는 함수입니다
    function addMarker(position, idx, title) {
      var imageSrc =
          "https://t1.daumcdn.net/localimg/localimages/07/mapapidoc/marker_number_blue.png", // 마커 이미지 url, 스프라이트 이미지를 씁니다
        imageSize = new kakao.maps.Size(36, 37), // 마커 이미지의 크기
        imgOptions = {
          spriteSize: new kakao.maps.Size(36, 691), // 스프라이트 이미지의 크기
          spriteOrigin: new kakao.maps.Point(0, idx * 46 + 10), // 스프라이트 이미지 중 사용할 영역의 좌상단 좌표
          offset: new kakao.maps.Point(13, 37), // 마커 좌표에 일치시킬 이미지 내에서의 좌표
        },
        markerImage = new kakao.maps.MarkerImage(
          imageSrc,
          imageSize,
          imgOptions
        ),
        marker = new kakao.maps.Marker({
          position: position, // 마커의 위치
          image: markerImage,
        });

      marker.setMap(map); // 지도 위에 마커를 표출합니다
      markers.push(marker); // 배열에 생성된 마커를 추가합니다

      return marker;
    }

    // 지도 위에 표시되고 있는 마커를 모두 제거합니다
    function removeMarker() {
      for (var i = 0; i < markers.length; i++) {
        markers[i].setMap(null);
      }
      markers = [];
    }

    // 검색결과 목록 하단에 페이지번호를 표시는 함수입니다
    function displayPagination(pagination) {
      var paginationEl = document.getElementById("pagination"),
        fragment = document.createDocumentFragment(),
        i;

      // 기존에 추가된 페이지번호를 삭제합니다
      while (paginationEl.hasChildNodes()) {
        paginationEl.removeChild(paginationEl.lastChild);
      }

      for (i = 1; i <= pagination.last; i++) {
        var el = document.createElement("a");
        el.href = "#";
        el.innerHTML = i;

        if (i === pagination.current) {
          el.className = "on";
        } else {
          el.onclick = (function (i) {
            return function () {
              pagination.gotoPage(i);
            };
          })(i);
        }

        fragment.appendChild(el);
      }
      paginationEl.appendChild(fragment);
    }

    // 검색결과 목록 또는 마커를 클릭했을 때 호출되는 함수입니다
    // 인포윈도우에 장소명을 표시합니다
    function displayInfowindow(marker, title) {
      var content =
        '<div style="padding:5px;z-index:1;color:black">' + title + "</div>";

      infowindow.setContent(content);
      infowindow.open(map, marker);
    }

    // 검색결과 목록의 자식 Element를 제거하는 함수입니다
    function removeAllChildNods(el) {
      while (el.hasChildNodes()) {
        el.removeChild(el.lastChild);
      }
    }
  }, []);

  return (
    <div className="map_wrap">
      <div
        id="map"
        style={{
          width: "800px",
          height: "500px",
          position: "relative",
          overflow: "hidden",
        }}
      ></div>
      <div id="menu_wrap" className="bg_white">
        <hr />
        <ul id="placesList"></ul>
        <div id="pagination"></div>
      </div>
    </div>
  );
};
  • 참고로 이 때 나는 이걸 참고해서 지도를 컨트롤 할 수 있는 기능 몇 가지를 더 추가했다.
      // 일반 지도와 스카이뷰로 지도 타입을 전환할 수 있는 지도타입 컨트롤을 생성합니다
      var mapTypeControl = new kakao.maps.MapTypeControl();

      // 지도에 컨트롤을 추가해야 지도위에 표시됩니다
      // kakao.maps.ControlPosition은 컨트롤이 표시될 위치를 정의하는데 TOPRIGHT는 오른쪽 위를 의미합니다
      map.addControl(mapTypeControl, kakao.maps.ControlPosition.TOPRIGHT);

      // 지도 확대 축소를 제어할 수 있는  줌 컨트롤을 생성합니다
      var zoomControl = new kakao.maps.ZoomControl();
      map.addControl(zoomControl, kakao.maps.ControlPosition.RIGHT);

 (4) Front에서 적용한 모습 

  • 위 코드를 실행하면 어디가 기준인지 모를 그냥 대한민국 전체에 "피씨방"이라고 검색한 목록을 출력하게 된다.

  • 여기까지는 가이드 코드에서 크게 벗어나지 않았고 React로 적용하신 분들의 블로그를 참고해서 그래도 차근 차근 해결했다.
  • 문제는 현위치를 기준으로 피씨방 목록을 출력하는 것이었는데 2탄에 이어서 써보겠다.