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

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

남쪽마을밤송이 2022. 6. 1. 17:55

1탄에서 이어지는 내용입니다.

 [04 사용자의 현재 위치 정보 사용하기

 (1) 현재 위치 가져오기 

  • 이 기능을 추가하면서 가장 애를 먹었던 부분이다. 현재 위치 정보를 가져오는 코드는 금방 찾았는데 아무리 구글링을 해도 이걸 기반으로 검색 기능을 구현한 블로그가 없었기 때문...ㅎ 블로그에 의존하던 내 실력의 한계를 느꼈다.

  • 일단 웹에서 내 위치를 가져오는 기능은 Geolocation API를 사용하면 된다. 구글링해서 얻은 코드에 Promise 객체를 return 하도록 함수를 수정하고 KakaoMap에서 사용하는 위치 정보 객체로 변경해서 응답값에 넣어주었다.
const getCurrentCoordinate = async () => {
  console.log("getCurrentCoordinate 함수 실행!!!");
  return new Promise((res, rej) => {
    // HTML5의 geolocaiton으로 사용할 수 있는지 확인합니다.
    if (navigator.geolocation) {
      // GeoLocation을 이용해서 접속 위치를 얻어옵니다.
      navigator.geolocation.getCurrentPosition(function (position) {
        console.log(position);
        const lat = position.coords.latitude; // 위도
        const lon = position.coords.longitude; // 경도

        const coordinate = new kakao.maps.LatLng(lat, lon);
        res(coordinate);
      });
    } else {
      rej(new Error("현재 위치를 불러올 수 없습니다."));
    }
  });
};
  • 여기까지 구현하고 위에서 말한대로 이걸 Kakao Map에 적용하는 방법이 막혀서 그제서야 지푸라기라도 잡는 심정으로 공식문서를 정독했다!

 (2) 현위치를 기준으로 설정하기 

  • 먼저 메인 기능을 수행하는  keywordSearch( )  함수 설명을 봤다. 현재는 인자값으로 검색 키워드와 콜백 함수만 받는데 여기에 options를 추가할 수 있었다! 추가로 어떤 타입이어야 하는지도 상세하게 나와있는...
    • 이걸 보고 위의  getCurrentCoordinate( ) 함수를 만들 때 응답값이 LatLng 객체가 되도록 한 것이다.
    • 나는 많은 옵션 중 아래 세 개를 사용했다. 이걸 또 어떻게 옵션으로 할당하지? 했는데 Object라고 써져있는..!

  • 또 sort 옵션을 쓰기 위해 카카오가 만들어놓은 SortBy라는 객체를 써야하는 거 같길래 눌러봤다. 그럼 아래와 같이 두 가지 입력값을 확인 가능~ 그대로 써주었다.

  • 따라서 아래와 같이 코드를 수정했다.
      async function searchPlaces() {
        console.log("searchPlaces 실행!!!");
        var keyword = "피씨방";
        const currentCoordinate = await getCurrentCoordinate();
        console.log(currentCoordinate);
        var options = {
          location: currentCoordinate,
          radius: 10000,
          sort: kakao.maps.services.SortBy.DISTANCE,
        };

        // 장소검색 객체를 통해 키워드로 장소검색을 요청합니다
        ps.keywordSearch(keyword, placesSearchCB, options);
      }
  • 작성을 하고 보니 별 거 없는데 이걸 알아내기까지 시간이 너무 오래걸렸다..! ㅋㅋㅋㅋㅋ

 (3) 전체 코드 

  • KakaoMap에서 현 위치 기반으로 키워드 검색한 장소 목록을 front에 보여주기 위해 필요한 코드 전체이다.
더보기

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;
}

MyPageMap.js

/*global kakao*/
import "../styles/Mypage/MypageMap.css";
import React, { useEffect } from "react";
import Swal from "sweetalert2";

const getCurrentCoordinate = async () => {
  console.log("getCurrentCoordinate 함수 실행!!!");
  console.log("navigator.geolocation", navigator.geolocation);
  return new Promise((res, rej) => {
    // HTML5의 geolocaiton으로 사용할 수 있는지 확인합니다.
    if (navigator.geolocation) {
      // GeoLocation을 이용해서 접속 위치를 얻어옵니다.
      navigator.geolocation.getCurrentPosition(function (position) {
        console.log(position);
        const lat = position.coords.latitude; // 위도
        const lon = position.coords.longitude; // 경도

        const coordinate = new kakao.maps.LatLng(lat, lon);
        res(coordinate);
      });
    } else {
      rej(new Error("현재 위치를 불러올 수 없습니다."));
    }
  });
};

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

      var mapContainer = document.getElementById("map"); // 지도를 표시할 div

      var mapOption = {
        center: new kakao.maps.LatLng(33.450701, 126.570667), // 지도의 중심좌표
        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();
      console.log("ps:", ps);

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

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

      // 키워드 검색을 요청하는 함수입니다
      async function searchPlaces() {
        console.log("searchPlaces 실행!!!");
        var keyword = "피씨방";
        const currentCoordinate = await getCurrentCoordinate();
        console.log(currentCoordinate);
        var options = {
          location: currentCoordinate,
          radius: 10000,
          sort: kakao.maps.services.SortBy.DISTANCE,
        };

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

      // 장소검색이 완료됐을 때 호출되는 콜백함수 입니다
      function placesSearchCB(data, status, pagination) {
        if (status === kakao.maps.services.Status.OK) {
          // 정상적으로 검색이 완료됐으면
          // 검색 목록과 마커를 표출합니다
          console.log(data);
          displayPlaces(data);
          // 페이지 번호를 표출합니다
          displayPagination(pagination);
        } else if (status === kakao.maps.services.Status.ZERO_RESULT) {
          Swal.fire("검색 결과가 존재하지 않습니다.");
          return;
        } else if (status === kakao.maps.services.Status.ERROR) {
          Swal.fire("검색 결과 중 오류가 발생했습니다.");
          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:10px;z-index:1;color:black;font-weight:bold">' +
          title +
          "</div>";

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

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

  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;

보여줄_페이지.js (필요 없는 부분은 삭제한 코드)

import styled from "styled-components";
import MypageMap from "../components/mypage/MypageMap";

const Mypage = () => {
  return (
    <>
      <Header></Header>
      <Main imgUrl={myPageBg}>
        <div className="kakaoMap middle2">
          <div className="topic common">내 주변 PC방 찾기</div>
          <div className="common">
            <MypageMap />
          </div>
        </div>
      </Main>
    </>
  );
};

const Header = styled.div`
  height: 50px;
`;
const Main = styled.div`
  background-size: 100%;
  background-image:
   ${(props) => `url(${props.imgUrl})`};
  min-height: 100vh;
  padding: 15% 0;
  display: flex;
  flex-direction: column;
  align-items: center;
  justify-content: center;

  .middle2 {
    width: 80%;
    margin-bottom: 80px;
    text-align: basis;
    // width: 80%;
    background: rgba(255, 255, 255, 0.6);
    box-shadow: 0 8px 32px #6869d0;
    backdrop-filter: blur(2.5px);
    border-radius: 10px;

    text-transform: uppercase;
    letter-spacing: 0.4rem;
    padding: 20px;
  }
`;

export default Mypage;

 (4) 로컬 서버에서 동작 확인 

  • 사는 곳 보호(?)를 위해 팀원분의 동네에서 잘 동작하는 사진이다.
    • 사실 geolocation API가 웹에서는 엄청난 정확도를 보이는 건 아니지만, 반경 10km 거리순으로 45개의 피씨방이 뜨기 때문에 괜찮을 것 같다.
    • 정확도를 높이는 방법도 찾아봤는데 IP 타겟팅 기술이 어쩌고 해서 관뒀다...

 (5) 배포 서버에서는 작동하지 않는 모습 

  • 그런데 여기서 문제!!! 배포 서버로 가면 검색이 제대로 동작하지 않는다. 지도가 안뜨면 카카오와 통신 오류인가 할거고 오류 메세지라도 뜨면 덜 답답했을텐데 아래처럼만 뜨고 오류도 안 떠서 모든 함수에 콘솔을 찍었다.
    • 아래와 같이 제주도 카카오 본사 위치에 장소 목록 배경만 뜬다...

  • 결론은 Geolocation API가 HTTP 환경에서 동작하지 않는다는 것. 여기까지만 해도 아이참 원래 SSL 적용하려 했는데~ 이유가 하나 늘었군~ 하면서 신났다.

  • 따라서 HTTPS로 변경하기 위해 아래와 같이 LetsEncrypt 인증서를 발급받고 nginx 설정 파일을 열심히 수정했는데... 그랬는데! 엘리스가 443 포트 사용할 수 있다고 해놓고 실제로는 안열어놨었던거다. ARE YOU DEVIL..?
    • 난 그것도 모르고 내가 nginx 설정 잘못 했을거라 생각해서 열심히 삽질했는데 ㅎㅎ
    • 엘리스가 443 쓸 수 있다고 했던거 박제~
    • 내가 이렇게까지 집착한 이유는 1차 프로젝트 기간에도 이틀은 SSL 적용하겠다고 자물쇠 채우겠다고 공을 들였기 때문.... 덕분에 standalone, webroot, DNS 방식 등 인증서 종류도 알고 공부는 했지만 억울해!!!😤

  • 그래서 발표 이틀 전부터 443 포트 열어달라고 요청했는데 시간상(?) 해줄 수 없다며 포기하라고 했다...😥

 [05 느낀점

  • 공식 문서를 가장 처음에 봐야 했는데(심지어 한글로 되어 있으면서) 미루고 미루다 읽었을 때 거기서 답을 찾았기에... 큰 깨달음을 얻고 반성을 했다. 아무리 친절한 블로그도 공식 문서를 먼저 읽고 읽어보자는 거~😉
  • 배포 서버에서 적용되지 않았던 게 너무 아쉽지만 이제 AWS 서버로 옮겨갔으니 거기서 SSL을 적용해 볼 것이다. 적용 후 잘 되는 모습을 추가해야지!
  • 사실 프로젝트 끝나고 한 달이 지나서 이 포스팅을 작성하려니까 분명 시행착오 엄청 많았는데 다 기억이 나지 않았다...ㅎㅎ 그래도 결국 포스팅을 작성했으니 됐다.

  • 이제 3차 프로젝트 시작인데 엄청난 분들 사이에서 내가 팀장을 맡게 되었다 두둥😱
  • 3차 프로젝트에도 역시 포지션은 백엔드를 맡게 되었는데 포스팅할 만한 기능을 맡게 되면 꼭 캡처라도 열심히 해둬야겠다. 2차 때는 구현에 성공하고 나서 캡처하려니 캡처할게 별로 없었고 포스팅할 때 다시 들어가 환경 세팅부터 캡처를 일일이 해야했다..! (물론 복습은 된다)
  • 어쨌든 확실히 책임감이 좀 느껴지는데 더더 열심히 참여하고 9시 스크럼에 지각하지 않기 위해 일찍 자봐야겠다... 제일 어려운 것...😫
  • 엘리스에서의 마지막 프로젝트 화이팅!!!