[React] 카카오 맵 API로 지도 검색 앱 구현하기 with TypeScript

nemo·2022년 3월 3일
13

Toy Project

목록 보기
3/4
post-thumbnail

카카오에서 제공하는 MAP API를 활용해 지도 검색 앱을 구현해보자.

📎 Demo

준비 사항

Tech Stack

  • Client: React, TypeScript, SCSS

폴더 구조

map-search-app
├── client
│   ├── public (정적 자원 관리)
│   │   ├── images (이미지 관리)
│   │   └── index.html (지도 API 코드 입력)
│   ├── src
│   │   ├── App.tsx (컴포넌트 구성)
│   │   ├── index.tsx (App.tsx와 index.html 연결)
│   │   ├── App.scss (컴포넌트 스타일)
│   │   ├── common.scss (공통 스타일)
│   │   ├── index.scss (글로벌 스타일)
│   │   └── components
│   │       └── views
│   │           └── LandingPage
│   │               ├── LandingPage.tsx
│   │               └── Sections
│   │                   └── Map.tsx (지도 영역)
│   ├── package-lock.json
│   └── package.json
├── package-lock.json
└── package.json



지도 검색 앱 구현

1. 리액트 & 타입스크립트 설치

npx create-react-app my-app --template typescript


2. 환경 변수

루트 경로에 .env 파일 생성 후 발급 받은 JavaScript 앱 키를 환경 변수로 작성

REACT_APP_KAKAO_API_KEY=발급받은javascriptAPI키

3. JavaScript API 불러오기

<script type="text/javascript" src="//dapi.kakao.com/v2/maps/sdk.js?appkey=발급받은 APP KEY를 넣으시면 됩니다."></script>

실제 지도를 그리는 JavaScript API 코드를 public/index.html <head></head> 사이에 삽입한다.
발급받은 APP KEY는 하드 코딩하면 정보가 노출될 위험이 있으니, .env에서 작성한 환경 변수를 사용하면 된다. URI에 환경 변수를 적용하기 위해서는 %여기사이%에 환경 변수 입력 후 넣으면 된다.


(public/index.html)

<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="utf-8" />
    <link rel="icon" href="%PUBLIC_URL%/favicon.ico" />
    <meta name="viewport" content="width=device-width, initial-scale=1" />
    <meta name="theme-color" content="#000000" />
    <meta name="description" content="Web site created using create-react-app" />
    <link rel="apple-touch-icon" href="%PUBLIC_URL%/logo192.png" />
    <link rel="manifest" href="%PUBLIC_URL%/manifest.json" />

    <!-- Kakao API 불러오기 -->
    <script type="text/javascript" src="//dapi.kakao.com/v2/maps/sdk.js?appkey=%REACT_APP_KAKAO_API_KEY%&libraries=services"></script>

    <title>MAP SEARCH APP</title>
  </head>

  <body>
    <noscript>You need to enable JavaScript to run this app.</noscript>
    <div id="root"></div>
  </body>

</html>

라이브러리는 services, clusterer, drawing 세 가지가 있다. 필요한 경우 아래 형식으로 파라미터를 추가하면 된다.

&libraries=services,clusterer,drawing

  • services: 마커를 클러스터링 할 수 있는 클러스터러 라이브러리
  • clusterer: 장소 검색 과 주소-좌표 변환 을 할 수 있는 services 라이브러리
  • drawing: 지도 위에 마커와 그래픽스 객체를 쉽게 그릴 수 있게 그리기 모드를 지원하는 drawing 라이브러리

4. Landing Page

LandingPage 컴포넌트에 입력 폼을 구현하고, 입력 값을 Map 컴포넌트로 넘긴다.

(LandingPage.tsx)

import React, { useState } from 'react'
import Map from './Sections/Map';

export interface propsType {
  searchKeyword: string
}

const LandingPage = ():JSX.Element => {
  // 입력 폼 변화 감지하여 입력 값 관리
  const [Value, setValue] = useState("");
  // 제출한 검색어 관리
  const [Keyword, setKeyword] = useState("");

  // 입력 폼 변화 감지하여 입력 값을 state에 담아주는 함수
  const keywordChange = (e: { preventDefault: () => void; target: { value: string }; }) => {
    e.preventDefault();
    setValue(e.target.value);
  }

// 제출한 검색어 state에 담아주는 함수
const submitKeyword = (e: { preventDefault: () => void; }) => {
  e.preventDefault();
  setKeyword(Value);
}

// 검색어를 입력하지 않고 검색 버튼을 눌렀을 경우
const valueChecker = () => {
  if (Value === "") {
    alert ("검색어를 입력해주세요.")
  }
}

return (
  <div className="landing-page">
    <div className="landing-page__inner">
      <div className="search-form-container">
        <form className="search-form" onSubmit={ submitKeyword }>
          <label htmlFor="place" className="form__label">
            <input type="text" id="movie-title" className="form__input" name="place" onChange={ keywordChange } placeholder="검색어를 입력해주세요. (ex: 강남 맛집)" required />
            <div className="btn-box">
              <input className="btn form__submit" type="submit" value="검색" onClick={ valueChecker }/>
            </div>
          </label>
        </form>
      </div>
      {/* 제출한 검색어 넘기기 */}
      <Map searchKeyword={ Keyword }/>
    </div>
  </div>
)
}

export default LandingPage

5. Map 구현

(Map.tsx)

import React, { useEffect } from 'react';
import { propsType } from '../LandingPage';

interface placeType {
  place_name: string,
  road_address_name: string,
  address_name: string,
  phone: string,
  place_url: string
}

// head에 작성한 Kakao API 불러오기
const { kakao } = window as any;

const Map = (props: propsType) => {
  // 마커를 담는 배열
  let markers: any[] = [];

  // 검색어가 바뀔 때마다 재렌더링되도록 useEffect 사용
  useEffect(() => {
    const mapContainer = document.getElementById("map");
    const mapOption = {
      center: new kakao.maps.LatLng(37.566826, 126.9786567), // 지도의 중심좌표
      level: 3 // 지도의 확대 레벨
    };

    // 지도를 생성
    const map = new kakao.maps.Map(mapContainer, mapOption); 

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

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

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

    // 키워드 검색을 요청하는 함수
    function searchPlaces() {
      let keyword = props.searchKeyword;

      if (!keyword.replace(/^\s+|\s+$/g, "")) {
        console.log("키워드를 입력해주세요!");
        return false;
      }

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

    // 장소검색이 완료됐을 때 호출되는 콜백함수
    function placesSearchCB(data: any, status: any, pagination: any) {
      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: string | any[]) {
      const listEl = document.getElementById('places-list'), 
            resultEl = document.getElementById('search-result'),
            fragment = document.createDocumentFragment(), 
            bounds = new kakao.maps.LatLngBounds();

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

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

      for ( var i=0; i<places.length; i++ ) {
        // 마커를 생성하고 지도에 표시
        let placePosition = new kakao.maps.LatLng(places[i].y, places[i].x),
            marker = addMarker(placePosition, i, undefined), 
            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 && listEl.appendChild(fragment);
      if (resultEl) {
        resultEl.scrollTop = 0;
      }

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

    // 검색결과 항목을 Element로 반환하는 함수
    function getListItem(index: number, places: placeType) {

      const el = document.createElement('li');
      let itemStr = `
          <div class="info">
            <span class="marker marker_${index+1}">
              ${index+1}
            </span>
            <a href="${places.place_url}">
              <h5 class="info-item place-name">${places.place_name}</h5>
              ${
                places.road_address_name 
                ? `<span class="info-item road-address-name">
                    ${places.road_address_name}
                   </span>
                   <span class="info-item address-name">
                 	 ${places.address_name}
               	   </span>`
                : `<span class="info-item address-name">
             	     ${places.address_name}
                  </span>`
              }
              <span class="info-item tel">
                ${places.phone}
              </span>
            </a>
          </div>
          `

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

      return el;
    }

    // 마커를 생성하고 지도 위에 마커를 표시하는 함수
    function addMarker(position: any, idx: number, title: undefined) {
      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: { last: number; current: number; gotoPage: (arg0: number) => void }) {
      const paginationEl = document.getElementById('pagination') as HTMLElement;
      let fragment = document.createDocumentFragment();
      let i; 

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

      for (i=1; i<=pagination.last; i++) {
        const el = document.createElement('a') as HTMLAnchorElement;
        el.href = "#";
        el.innerHTML = i.toString();

        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: any, title: string) {
      const content = '<div style="padding:5px;z-index:1;" class="marker-title">' + title + '</div>';

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

    // 검색결과 목록의 자식 Element를 제거하는 함수
    function removeAllChildNods(el: HTMLElement) {
      while (el.hasChildNodes()) {
        el.lastChild &&
          el.removeChild (el.lastChild);
      }
    }

  }, [props.searchKeyword])

  return (
    <div className="map-container">
      <div id="map" className="map"></div>
      <div id="search-result">
        <p className="result-text">
          <span className="result-keyword">
            { props.searchKeyword }
          </span>
          검색 결과
        </p>
        <div className="scroll-wrapper">
          <ul id="places-list"></ul>
        </div>
        <div id="pagination"></div>
      </div>
    </div>
  )
}

export default Map

6. 배포 전에 ...

배포하기 전에 카카오 개발자 페이지에 가서 Web 플랫폼을 수정해야 한다.
로컬에서 작업하기 위해 http://localhost:3000으로 설정해두었다면, 배포하는 사이트의 도메인으로 바꿔준다.

0개의 댓글