Google Maps Platform과 Google Cloud를 사용하여 전체 스택 매장 검색 기능 빌드하기

1. 소개

개요

지도에 표시할 장소가 여러 곳 있고 사용자가 이러한 장소를 확인하고 방문할 곳을 식별할 수 있도록 하려 한다고 상상해 보세요. 대표적인 예는 다음과 같습니다.

  • 소매업체 웹사이트의 매장 검색 기능
  • 예정된 선거의 투표소 지도
  • 배터리 재활용 장소 같은 특수 지역 디렉터리

빌드할 내용

이 Codelab에서는 특수한 위치의 실시간 데이터 피드에서 가져오는 위치 검색 기능을 만듭니다. 사용자는 이 기능을 통해 출발지에서 가장 가까운 위치를 찾을 수 있습니다. 이 풀 스택 위치 검색 기능은 매장 위치가 25개 이하로 제한되는 단순한 매장 검색 기능보다 훨씬 많은 수의 장소를 처리할 수 있습니다.

2ece59c64c06e9da.png

과정 내용

이 Codelab에서는 오픈 데이터 세트를 통해 자동 입력된 수많은 매장 위치 메타데이터를 시뮬레이션하여 주요 기술 개념을 중점적으로 학습할 수 있습니다.

  • Maps JavaScript API: 맞춤설정된 웹 지도에 다수의 위치를 표시합니다.
  • GeoJSON: 위치 관련 메타데이터를 저장하는 형식입니다.
  • Place Autocomplete: 사용자가 시작 위치를 더 빠르고 정확하게 제공할 수 있습니다.
  • Go: 애플리케이션 백엔드를 개발하는 데 사용하는 프로그래밍 언어입니다. 백엔드는 데이터베이스와 상호작용하고 프런트엔드에 쿼리 결과를 올바른 JSON 형식으로 다시 반환합니다.
  • App Engine: 웹 앱을 호스팅합니다.

기본 요건

  • HTML 및 자바스크립트 관련 기본 지식
  • Google 계정

2. 설정하기

다음 섹션의 3단계에서 이 Codelab의 Maps JavaScript API, Places API, Distance Matrix API를 사용 설정합니다.

Google Maps Platform 시작하기

Google Maps Platform을 처음 사용한다면 Google Maps Platform 시작하기 가이드를 따르거나 Google Maps Platform 시작하기 재생목록을 시청하여 다음 단계를 완료하세요.

  1. 결제 계정 만들기
  2. 프로젝트를 만듭니다.
  3. 이전 섹션에 표시된 Google Maps Platform API와 SDK를 사용 설정합니다.
  4. API 키를 생성합니다.

Cloud Shell 활성화하기

이 Codelab에서는 Google Cloud에서 실행되는 제품 및 리소스에 대한 액세스 권한을 제공하는 Google Cloud의 명령줄 환경인 Cloud Shell을 사용하므로 프로젝트를 웹브라우저에서 완전하게 호스팅하고 실행할 수 있습니다.

Cloud Console에서 Cloud Shell을 활성화하려면 Activate Cloud Shell89665d8d348105cd.png을 클릭합니다(환경을 프로비저닝하고 연결하는 데 몇 분 정도 소요됩니다).

5f504766b9b3be17.png

이렇게 하면 소개 화면이 표시된 후 브라우저 아래쪽에 새 셸이 열립니다.

d3bb67d514893d1f.png

프로젝트 확인하기

Cloud Shell에 연결되면 인증이 이미 완료되어 있고 프로젝트도 이미 설정 단계에서 선택한 프로젝트 ID로 설정되어 있음을 확인할 수 있습니다.

$ gcloud auth list
Credentialed Accounts:
ACTIVE  ACCOUNT
  *     <myaccount>@<mydomain>.com
$ gcloud config list project
[core]
project = <YOUR_PROJECT_ID>

어떤 이유로 프로젝트가 설정되지 않은 경우 다음 명령어를 실행합니다.

gcloud config set project <YOUR_PROJECT_ID>

AppEngine Flex API 사용 설정하기

AppEngine Flex API는 Cloud Console에서 수동으로 사용 설정해야 합니다. 이렇게 하면 API를 사용 설정할 뿐만 아니라 AppEngine 가변형 환경 서비스 계정을 만들게 됩니다. 이 계정은 사용자를 대신하여 Google 서비스(SQL 데이터베이스 등)와 상호작용하는 인증된 계정입니다.

3. Hello, World

백엔드: Go에서의 Hello World

Cloud Shell 인스턴스에서 먼저 나머지 Codelab의 기본 토대 역할을 하는 Go App Engine Flex 앱을 만듭니다.

Cloud Shell 툴바에서 Open editor 버튼을 클릭하여 새 탭에서 코드 편집기를 엽니다. 이 웹 기반 코드 편집기를 사용하면 Cloud Shell 인스턴스에서 파일을 쉽게 수정할 수 있습니다.

b63f7baad67b6601.png

그런 다음 Open in new window 아이콘을 클릭하여 편집기와 터미널을 새 탭으로 옮깁니다.

3f6625ff8461c551.png

새 탭 하단의 터미널에서 새 austin-recycling 디렉터리를 만듭니다.

mkdir -p austin-recycling && cd $_

그런 다음 작은 Go App Engine 앱을 만들어 모든 것이 제대로 작동하는지 확인합니다. Hello World!

austin-recycling 디렉터리도 편집기 왼쪽의 폴더 목록에 표시됩니다. austin-recycling 디렉터리에 app.yaml이라는 파일을 만듭니다. app.yaml 파일에 다음 콘텐츠를 삽입합니다.

app.yaml

runtime: go
env: flex

manual_scaling:
  instances: 1
resources:
  cpu: 1
  memory_gb: 0.5
  disk_size_gb: 10

이 구성 파일은 App Engine 앱을 구성하여 Go Flex 런타임을 사용합니다. 이 파일의 구성 항목이 의미하는 바에 대한 배경 정보는 Google App Engine Go 표준 환경 문서를 참고하세요.

그런 다음 main.go 파일과 app.yaml 파일을 함께 만듭니다.

main.go

package main

import (
        "fmt"
        "log"
        "net/http"
        "os"
)

func main() {
        http.HandleFunc("/", handle)
        port := os.Getenv("PORT")
        if port == "" {
                port = "8080"
        }
        log.Printf("Listening on port %s", port)
        if err := http.ListenAndServe(":"+port, nil); err != nil {
                log.Fatal(err)
        }
}

func handle(w http.ResponseWriter, r *http.Request) {
        if r.URL.Path != "/" {
                http.NotFound(w, r)
                return
        }
        fmt.Fprint(w, "Hello world!")
}

이 시점에서 이 코드의 역할에 대해 간단하게 살펴보면 도움이 될 것입니다. 포트 8080에서 수신 대기하는 http 서버를 가동하는 패키지 main을 정의하고 "/" 경로와 일치하는 HTTP 요청을 위한 핸들러 함수를 등록합니다.

간단하게 handler라고 부르는 핸들러 함수는 텍스트 문자열 "Hello, world!"를 작성합니다. 이 텍스트는 브라우저에 다시 전달되며 브라우저에서 읽을 수 있습니다. 다음 단계에서는 간단한 하드 코딩 문자열 대신 GeoJSON 데이터를 사용하여 응답하는 핸들러를 만듭니다.

이러한 단계를 수행하면 다음과 같은 형태의 편집기가 생성됩니다.

2084fdd5ef594ece.png

테스트하기

이 애플리케이션을 테스트하기 위해 Cloud Shell 인스턴스에서 App Engine 개발 서버를 실행할 수 있습니다. Cloud Shell 명령줄로 돌아가 다음을 입력합니다.

go run *.go

Cloud Shell 인스턴스에서 개발 서버를 실행 중이며 Hello World 웹 앱이 localhost 포트 8080에서 수신 대기 중이라는 것을 보여주는 로그 출력 줄이 몇 개 표시됩니다. 이 앱에서 Web Preview 버튼을 누르고 Cloud Shell 툴바에서 Preview on port 8080 메뉴 항목을 선택하면 웹브라우저 탭을 열 수 있습니다.

4155fc1dc717ac67.png

이 메뉴 항목을 클릭하면 App Engine 개발 서버에서 'Hello, world!'라는 단어를 표시하는 새 탭이 웹브라우저에서 열립니다.

다음 단계에서는 이 앱에 오스틴시의 재활용 데이터를 추가하고 시각화를 시작합니다.

4. 현재 데이터 가져오기

GeoJSON, GIS 세계의 공통어

이전 단계에서 Go 코드로 웹브라우저에 GeoJSON 데이터를 렌더링하는 핸들러를 작성한다고 설명했습니다. 그럼 GeoJSON이란 무엇일까요?

지리 정보 시스템(GIS) 환경에서는 컴퓨터 시스템 사이에서 지리적 개체에 관한 지식을 전달할 수 있어야 합니다. 지도는 사람이 쉽게 읽을 수 있지만 컴퓨터는 일반적으로 더 쉽게 요약된 형식의 데이터를 선호합니다.

GeoJSON은 텍사스주 오스틴에 있는 재활용 처리 위치의 좌표 같은 지리 데이터 구조를 인코딩하기 위한 형식입니다. GeoJSON은 인터넷 엔지니어링 태스크포스 표준인 RFC7946으로 표준화되었습니다. GeoJSON은 JSON(JavaScript Object Notation)으로 정의되는데, JSON은 자바스크립트를 표준화한 Ecma InternationalECMA-404로 표준화하였습니다.

중요한 것은 GeoJSON이 지리적 지식을 전달하기 위해 널리 사용되는 유선 형식이라는 것입니다. 이 Codelab에서는 GeoJSON을 다음과 같은 방식으로 사용합니다.

  • Go 패키지를 사용하여 오스틴 데이터를 내부 GIS 전용 데이터 구조로 분석합니다. 이 구조는 요청된 데이터를 필터링하는 데 사용합니다.
  • 요청된 데이터를 웹 서버와 웹브라우저 간에 전송할 수 있도록 직렬화합니다.
  • 자바스크립트 라이브러리를 사용하여 응답을 지도상의 마커로 변환합니다.

이렇게 하면 코드에 입력해야 하는 양을 크게 줄일 수 있습니다. 유선 통신 데이터 스트림을 메모리 내 표현으로 변환하기 위해 파서와 생성기를 작성하지 않아도 되기 때문입니다.

데이터 검색하기

텍사스주 오스틴의 오픈 데이터 포털에서는 공개 리소스에 대한 지리 공간 정보를 누구나 사용할 수 있습니다. 이 Codelab에서는 재활용 처리 위치 데이터 세트를 시각화합니다.

Maps JavaScript API의 데이터 영역을 이용하여 렌더링하는 지도상의 마커를 통해 데이터를 시각화합니다.

먼저 오스틴 웹사이트의 GeoJSON 데이터를 앱으로 다운로드하세요.

  1. Cloud Shell 인스턴스의 명령줄 창에서 [CTRL] + [C]를 입력하여 서버를 종료합니다.
  2. austin-recycling 디렉터리 안에 data 디렉터리를 만들고 해당 디렉터리로 변경합니다.
mkdir -p data && cd data

이제 curl을 사용하여 재활용 위치를 검색합니다.

curl "https://data.austintexas.gov/resource/qzi7-nx8g.geojson" -o recycling-locations.geojson

마지막으로 상위 디렉터리로 다시 변경합니다.

cd ..

5. 위치 매핑하기

먼저 곧 빌드할 '이제는 단순한 Hello World 앱이 아닌' 더 강력한 애플리케이션을 반영할 수 있도록 app.yaml 파일을 업데이트하세요.

app.yaml

runtime: go
env: flex

handlers:
- url: /
  static_files: static/index.html
  upload: static/index.html
- url: /(.*\.(js|html|css))$
  static_files: static/\1
  upload: static/.*\.(js|html|css)$
- url: /.*
  script: auto

manual_scaling:
  instances: 1
resources:
  cpu: 1
  memory_gb: 0.5
  disk_size_gb: 10

app.yaml 구성은 /, /*.js, /*.css, /*.html에 대한 요청을 정적 파일 모음에 전달합니다. 즉 앱의 정적 HTML 구성요소는 Go 앱이 아닌 App Engine 파일 제공 인프라에서 직접 제공합니다. 이렇게 하면 서버 로드가 줄어들고 제공 속도가 빨라집니다.

지금부터는 Go에서 애플리케이션의 백엔드를 빌드하겠습니다.

백엔드 빌드하기

app.yaml 파일의 한 가지 흥미로운 점은 GeoJSON 파일을 노출하지 않는다는 것입니다. GeoJSON은 Go 백엔드로 처리되고 전송되므로 이후 단계에서 고급 기능을 빌드할 수 있기 때문입니다. 읽어야 할 main.go 파일을 다음과 같이 변경하세요.

main.go

package main

import (
        "fmt"
        "io/ioutil"
        "log"
        "net/http"
        "os"
        "path/filepath"
)

var GeoJSON = make(map[string][]byte)

// cacheGeoJSON loads files under data into `GeoJSON`.
func cacheGeoJSON() {
        filenames, err := filepath.Glob("data/*")
        if err != nil {
                log.Fatal(err)
        }

        for _, f := range filenames {
                name := filepath.Base(f)
                dat, err := ioutil.ReadFile(f)
                if err != nil {
                        log.Fatal(err)
                }
                GeoJSON[name] = dat
        }
}

func main() {
        // Cache the JSON so it doesn't have to be reloaded every time a request is made.
        cacheGeoJSON()

        // Request for data should be handled by Go.  Everything else should be directed
        // to the folder of static files.
        http.HandleFunc("/data/dropoffs", dropoffsHandler)
        http.Handle("/", http.FileServer(http.Dir("./static/")))

        // Open up a port for the webserver.
        port := os.Getenv("PORT")
        if port == "" {
                port = "8080"
        }
        log.Printf("Listening on port %s", port)

        if err := http.ListenAndServe(":"+port, nil); err != nil {
                log.Fatal(err)
        }
}

func helloHandler(w http.ResponseWriter, r *http.Request) {
        // Writes Hello, World! to the user's web browser via `w`
        fmt.Fprint(w, "Hello, world!")
}

func dropoffsHandler(w http.ResponseWriter, r *http.Request) {
        w.Header().Set("Content-type", "application/json")
        w.Write(GeoJSON["recycling-locations.geojson"])
}

Go 백엔드는 이미 유용한 기능을 제공하고 있습니다. AppEngine 인스턴스가 시작과 동시에 이러한 모든 위치를 캐싱한다는 점입니다. 이를 통해 모든 사용자가 새로고침할 때마다 백엔드가 디스크에서 파일을 읽을 필요가 없어 시간이 절약됩니다.

프런트엔드 빌드하기

가장 먼저 해야 할 일은 정적 애셋을 모두 포함하는 폴더를 만드는 것입니다. 프로젝트의 상위 폴더에서 static 폴더를 만듭니다.

mkdir -p static && cd static

이 폴더에 파일 3개를 만들겠습니다.

  • index.html에 한 페이지짜리 매장 검색 기능 앱의 모든 HTML이 포함됩니다.
  • style.css에는 당연히 스타일 지정이 포함됩니다.
  • app.js는 GeoJSON을 검색하고 지도 API를 호출하며 맞춤 지도에 마커를 배치하는 역할을 합니다.

이렇게 파일 3개를 만든 후 static/에 저장합니다.

style.css

html,
body {
  height: 100%;
  margin: 0;
  padding: 0;
}

body {
  display: flex;
}

#map {
  height: 100%;
  flex-grow: 4;
  flex-basis: auto;
}

index.html

<html>
  <head>
    <title>Austin recycling drop-off locations</title>
    <link rel="stylesheet" type="text/css" href="style.css" />
    <script src="app.js"></script>

    <script
      defer
    src="https://maps.googleapis.com/maps/api/js?key=YOUR_API_KEY&v=weekly&libraries=places&callback=initialize&solution_channel=GMP_codelabs_fullstackstorelocator_v1_a"
    ></script>
  </head>

  <body>
    <div id="map"></div>
    <!-- Autocomplete div goes here -->
  </body>
</html>

head 요소의 스크립트 태그에 있는 src URL을 특히 주목하세요.

  • 자리표시자 텍스트인 'YOUR_API_KEY'를 설정 단계에서 생성한 API 키로 바꿉니다. Cloud Console의 APIs & Services -> Credentials 페이지로 이동하여 API 키를 검색하거나 새 키를 생성할 수 있습니다.
  • URL에 매개변수 callback=initialize.가 포함되어 있습니다. 지금부터 그 콜백 함수를 포함하는 자바스크립트 파일을 만들겠습니다. 이때 앱은 백엔드에서 위치를 로드하고 지도 API로 위치를 전송하며 그 결과를 가지고 사용자 지정 위치를 지도에 렌더링하며, 이러한 위치는 웹페이지에서도 근사하게 렌더링됩니다.
  • 매개변수 libraries=places는 나중에 추가될 주소 자동 완성 같은 기능에 필요한 장소 라이브러리를 로드합니다.

app.js

let distanceMatrixService;
let map;
let originMarker;
let infowindow;
let circles = [];
let stores = [];
// The location of Austin, TX
const AUSTIN = { lat: 30.262129, lng: -97.7468 };

async function initialize() {
  initMap();

  // TODO: Initialize an infoWindow

  // Fetch and render stores as circles on map
  fetchAndRenderStores(AUSTIN);

  // TODO: Initialize the Autocomplete widget
}

const initMap = () => {
  // TODO: Start Distance Matrix service

  // The map, centered on Austin, TX
  map = new google.maps.Map(document.querySelector("#map"), {
    center: AUSTIN,
    zoom: 14,
    // mapId: 'YOUR_MAP_ID_HERE',
    clickableIcons: false,
    fullscreenControl: false,
    mapTypeControl: false,
    rotateControl: true,
    scaleControl: false,
    streetViewControl: true,
    zoomControl: true,
  });
};

const fetchAndRenderStores = async (center) => {
  // Fetch the stores from the data source
  stores = (await fetchStores(center)).features;

  // Create circular markers based on the stores
  circles = stores.map((store) => storeToCircle(store, map));
};

const fetchStores = async (center) => {
  const url = `/data/dropoffs`;
  const response = await fetch(url);
  return response.json();
};

const storeToCircle = (store, map) => {
  const [lng, lat] = store.geometry.coordinates;
  const circle = new google.maps.Circle({
    radius: 50,
    strokeColor: "#579d42",
    strokeOpacity: 0.8,
    strokeWeight: 5,
    center: { lat, lng },
    map,
  });

  return circle;
};

이 코드는 매장 위치를 지도에 렌더링합니다. 지금까지 작업한 내용을 테스트하려면 명령줄에서 상위 디렉터리로 돌아갑니다.

cd ..

이제 다음을 사용하여 앱을 개발 모드에서 다시 실행합니다.

go run *.go

이전에 했던 것처럼 미리보기를 실행합니다. 다음과 같은 작은 초록색 원이 있는 지도가 표시됩니다.

58a6680e9c8e7396.png

이미 지도 위치를 렌더링하고 있지만 Codelab은 겨우 절반 밖에 진행되지 않았습니다. 놀라운 사실이죠. 이제 상호작용을 추가해 보겠습니다.

6. 요청 시 세부정보 표시하기

지도 마커의 클릭 이벤트에 응답하기

지도에 많은 마커를 표시하는 일도 좋은 출발점이지만 정말로 필요한 것은 이러한 마커 중 하나를 클릭하고 해당 위치에 대한 정보(예: 업체 이름, 주소)를 확인할 수 있는 방문자입니다. Google 지도 마커를 클릭하면 표시되는 작은 정보 창의 이름은 정보 창입니다.

infoWindow 객체를 만듭니다. initialize 함수에 다음을 추가하여 '// TODO: Initialize an info window'라는 주석 달린 줄을 대체합니다.

app.js - initialize

  // Add an info window that pops up when user clicks on an individual
  // location. Content of info window is entirely up to us.
  infowindow = new google.maps.InfoWindow();

fetchAndRenderStores 함수 정의를 약간 다른 버전으로 바꿉니다. 그러면 추가 인수 infowindow를 사용하여 storeToCircle을 호출하도록 최종 줄이 변경됩니다.

app.js - fetchAndRenderStores

const fetchAndRenderStores = async (center) => {
  // Fetch the stores from the data source
  stores = (await fetchStores(center)).features;

  // Create circular markers based on the stores
  circles = stores.map((store) => storeToCircle(store, map, infowindow));
};

storeToCircle 정의를 약간 더 긴 버전으로 대체하여 정보 창을 세 번째 인수로 사용합니다.

app.js - storeToCircle

const storeToCircle = (store, map, infowindow) => {
  const [lng, lat] = store.geometry.coordinates;
  const circle = new google.maps.Circle({
    radius: 50,
    strokeColor: "#579d42",
    strokeOpacity: 0.8,
    strokeWeight: 5,
    center: { lat, lng },
    map,
  });
  circle.addListener("click", () => {
    infowindow.setContent(`${store.properties.business_name}<br />
      ${store.properties.address_address}<br />
      Austin, TX ${store.properties.zip_code}`);
    infowindow.setPosition({ lat, lng });
    infowindow.setOptions({ pixelOffset: new google.maps.Size(0, -30) });
    infowindow.open(map);
  });
  return circle;
};

위의 새 코드는 지도의 매장 마커를 클릭할 때마다 선택된 매장의 정보가 포함된 infoWindow를 표시합니다.

서버가 여전히 실행 중이라면 중지한 후 다시 시작합니다. 지도 페이지를 새로고침하고 지도 마커를 클릭합니다. 작은 정보 창에 다음과 비슷한 형식으로 업체 이름 및 주소가 표시될 것입니다.

1af0ab72ad0eadc5.png

7. 사용자의 시작 위치 가져오기

일반적으로 매장 검색 기능 사용자는 자신의 위치 또는 여정을 시작할 주소에서 가장 가까운 매장을 알고 싶어 합니다. 사용자가 쉽게 시작 주소를 입력할 수 있도록 Place Autocomplete 검색창을 추가합니다. Place Autocomplete는 다른 Google 검색창에서 자동 완성 기능이 작동하는 것과 비슷한 방식으로 자동 완성 기능을 제공하지만, 예상 검색어가 모두 Google Maps Platform의 장소라는 점이 다릅니다.

사용자 입력란 만들기

style.css 수정으로 돌아가서 자동 완성 검색창 및 결과와 관련된 측면 패널의 스타일 지정을 추가합니다. CSS 스타일을 업데이트하는 동안 매장 정보를 지도와 함께 제공되는 목록으로 표시하는 향후 사이드바 스타일도 추가하겠습니다.

이 코드를 파일 끝에 추가하세요.

style.css

#panel {
  height: 100%;
  flex-basis: 0;
  flex-grow: 0;
  overflow: auto;
  transition: all 0.2s ease-out;
}

#panel.open {
  flex-basis: auto;
}

#panel .place {
  font-family: "open sans", arial, sans-serif;
  font-size: 1.2em;
  font-weight: 500;
  margin-block-end: 0px;
  padding-left: 18px;
  padding-right: 18px;
}

#panel .distanceText {
  color: silver;
  font-family: "open sans", arial, sans-serif;
  font-size: 1em;
  font-weight: 400;
  margin-block-start: 0.25em;
  padding-left: 18px;
  padding-right: 18px;
}

/* Styling for Autocomplete search bar */
#pac-card {
  background-color: #fff;
  border-radius: 2px 0 0 2px;
  box-shadow: 0 2px 6px rgba(0, 0, 0, 0.3);
  box-sizing: border-box;
  font-family: Roboto;
  margin: 10px 10px 0 0;
  -moz-box-sizing: border-box;
  outline: none;
}

#pac-container {
  padding-top: 12px;
  padding-bottom: 12px;
  margin-right: 12px;
}

#pac-input {
  background-color: #fff;
  font-family: Roboto;
  font-size: 15px;
  font-weight: 300;
  margin-left: 12px;
  padding: 0 11px 0 13px;
  text-overflow: ellipsis;
  width: 400px;
}

#pac-input:focus {
  border-color: #4d90fe;
}

#pac-title {
  color: #fff;
  background-color: #acbcc9;
  font-size: 18px;
  font-weight: 400;
  padding: 6px 12px;
}

.hidden {
  display: none;
}

자동 완성 검색창과 슬라이드 아웃 패널은 기본적으로 숨겨져 있다가 필요할 때만 표시됩니다.

index.html에 있는 '"<!-- Autocomplete div goes here -->'라는 주석을 다음 코드로 대체하여 자동 완성 위젯의 div를 준비합니다. 이 수정을 진행하는 동안 슬라이드 아웃 패널을 위한 div도 추가합니다.

index.html

     <div id="panel" class="closed"></div>
     <div class="hidden">
      <div id="pac-card">
        <div id="pac-title">Find the nearest location</div>
        <div id="pac-container">
          <input
            id="pac-input"
            type="text"
            placeholder="Enter an address"
            class="pac-target-input"
            autocomplete="off"
          />
        </div>
      </div>
    </div>

이제 app.js의 끝에 다음 코드를 추가하여 자동 완성 위젯을 지도에 추가하는 함수를 정의합니다.

app.js

const initAutocompleteWidget = () => {
  // Add search bar for auto-complete
  // Build and add the search bar
  const placesAutoCompleteCardElement = document.getElementById("pac-card");
  const placesAutoCompleteInputElement = placesAutoCompleteCardElement.querySelector(
    "input"
  );
  const options = {
    types: ["address"],
    componentRestrictions: { country: "us" },
    map,
  };
  map.controls[google.maps.ControlPosition.TOP_RIGHT].push(
    placesAutoCompleteCardElement
  );
  // Make the search bar into a Places Autocomplete search bar and select
  // which detail fields should be returned about the place that
  // the user selects from the suggestions.
  const autocomplete = new google.maps.places.Autocomplete(
    placesAutoCompleteInputElement,
    options
  );
  autocomplete.setFields(["address_components", "geometry", "name"]);
  map.addListener("bounds_changed", () => {
    autocomplete.setBounds(map.getBounds());
  });

  // TODO: Respond when a user selects an address
};

이 코드는 자동 완성 추천을 주소로만 제한하며(Place Autocomplete로 시설 이름이나 기관 위치가 제안될 수 있으므로) 이러한 주소는 미국 주소로 제한됩니다. 이러한 선택 사양을 추가하면 사용자가 찾는 주소가 나올 때까지 예상 검색어의 범위를 좁히기 위해 입력해야 하는 문자 수가 줄어듭니다.

그런 다음, 생성한 자동 완성 div를 지도의 오른쪽 상단으로 이동하여 응답에서 각 장소에 대해 반환해야 하는 필드를 지정합니다.

마지막으로 initialize 함수 끝에 initAutocompleteWidget 함수를 호출하여 '// TODO: Initialize the Autocomplete widget'이라는 주석을 대체합니다.

app.js - initialize

 // Initialize the Places Autocomplete Widget
 initAutocompleteWidget();

다음 명령어를 실행하여 서버를 다시 시작한 다음 미리보기를 새로고침하세요.

go run *.go

이제 지도의 오른쪽 상단에 자동 완성 위젯이 표시됩니다. 이 위젯은 입력한 내용과 일치하는 미국 주소를 지도의 가시 영역에 우선하여 표시합니다.

58e9bbbcc4bf18d1.png

사용자가 시작 주소를 선택할 때 지도 업데이트하기

이제 사용자가 자동 완성 위젯에서 예상 검색어를 선택하는 경우를 처리하고, 이 위치를 매장까지의 거리를 계산하기 위한 기준으로 사용해야 합니다.

app.jsinitAutocompleteWidget 끝에 다음 코드를 추가하여 '// TODO: Respond when a user selects an address' 주석을 대체합니다.

app.js - initAutocompleteWidget

  // Respond when a user selects an address
  // Set the origin point when the user selects an address
  originMarker = new google.maps.Marker({ map: map });
  originMarker.setVisible(false);
  let originLocation = map.getCenter();
  autocomplete.addListener("place_changed", async () => {
    // circles.forEach((c) => c.setMap(null)); // clear existing stores
    originMarker.setVisible(false);
    originLocation = map.getCenter();
    const place = autocomplete.getPlace();

    if (!place.geometry) {
      // User entered the name of a Place that was not suggested and
      // pressed the Enter key, or the Place Details request failed.
      window.alert("No address available for input: '" + place.name + "'");
      return;
    }
    // Recenter the map to the selected address
    originLocation = place.geometry.location;
    map.setCenter(originLocation);
    map.setZoom(15);
    originMarker.setPosition(originLocation);
    originMarker.setVisible(true);

    // await fetchAndRenderStores(originLocation.toJSON());
    // TODO: Calculate the closest stores
  });

이 코드는 리스너를 추가하여 사용자가 추천 주소 중 하나를 클릭하면 선택한 주소를 중심으로 지도가 재정렬되고 출발지가 거리 계산 기준으로 설정되게 합니다. 거리 계산은 이후 단계에서 구현합니다.

서버를 중지하고 다시 시작한 다음 미리보기를 새로고침하여 자동 완성 검색창에 주소를 입력하면 지도가 재정렬되는지 확인합니다.

8. Cloud SQL을 사용하여 확장하기

Google에서는 매우 훌륭한 매장 위치 검색 기능을 제공하고 있습니다. 이 기능은 앱에서 사용하는 위치가 100개 정도밖에 없다는 사실을 활용하여 파일을 (반복해서 읽는 대신) 백엔드의 메모리로 로드합니다. 하지만 위치 검색 기능이 다른 규모에서 작동해야 한다면 어떨까요? 대규모 지리적 영역에 위치 수백 개가 분산되어 있다면 (또는 전 세계에 수천 개가 분산되어 있다면) 이러한 모든 위치를 메모리에 배치하는 것은 더 이상 좋은 생각이 아니며 구역을 개별 파일로 분할하면 고유한 문제가 발생하게 됩니다.

지금부터 데이터베이스에서 위치를 로드하겠습니다. 이 단계에서는 GeoJSON 파일의 모든 위치를 Cloud SQL 데이터베이스로 마이그레이션하고 요청이 들어올 때마다 결과를 자체 로컬 캐시가 아닌 Cloud SQL 데이터베이스에서 가져오도록 Go 백엔드를 업데이트합니다.

PostGres 데이터베이스를 사용하여 Cloud SQL 인스턴스 만들기

Google Cloud Console을 통해 Cloud SQL 인스턴스를 만들 수 있지만 명령줄을 이용하면 gcloud 유틸리티를 더 쉽게 만들 수 있습니다. Cloud Shell에서 다음 명령어를 사용하여 Cloud SQL 인스턴스를 만듭니다.

gcloud sql instances create locations \
--database-version=POSTGRES_12 \
--tier=db-custom-1-3840 --region=us-central1
  • 인수 locations를 통해 이 Cloud SQL 인스턴스에 부여하려는 이름을 선택합니다.
  • tier 플래그를 사용하면 일부 사전 정의된 머신을 편리하게 선택할 수 있습니다.
  • db-custom-1-3840은 생성 중인 인스턴스에 vCPU 1개와 약 3.75GB의 메모리가 있어야 함을 나타냅니다.

기본 사용자가 postgres인 PostGresSQL 데이터베이스를 바탕으로 Cloud SQL 인스턴스가 생성 및 초기화됩니다. 이 사용자의 비밀번호는 무엇인가요? 좋은 질문입니다. 비밀번호가 없습니다. 로그인하려면 먼저 비밀번호를 구성해야 합니다.

다음 명령어를 이용해 비밀번호를 설정합니다.

gcloud sql users set-password postgres \
    --instance=locations --prompt-for-password

메시지가 표시되면 선택한 비밀번호를 입력합니다.

PostGIS 확장 프로그램 사용 설정하기

PostGIS는 PostGresSQL의 확장 프로그램으로, 표준화된 유형의 지리 공간 데이터를 쉽게 저장할 수 있습니다. 일반적인 상황에서는 전체 설치 과정을 진행해야 PostGIS를 데이터베이스에 추가할 수 있습니다. 다행히 이 프로그램은 Cloud SQL에서 지원하는 PostGresSQL용 확장 프로그램입니다.

Cloud Shell 터미널에서 다음 명령어를 이용해 postgres 사용자로 로그인하여 데이터베이스 인스턴스에 연결합니다.

gcloud sql connect locations --user=postgres --quiet

방금 만든 비밀번호를 입력합니다. 이제 postgres=> 명령어 프롬프트에서 PostGIS 확장 프로그램을 추가합니다.

CREATE EXTENSION postgis;

성공하면 아래와 같이 CREATE EXTENSION이 출력됩니다.

명령어 결과 예시

CREATE EXTENSION

마지막으로 postgres=> 명령어 프롬프트에 종료 명령어를 입력하여 데이터베이스 연결을 종료합니다.

\q

지리적 데이터를 데이터베이스로 가져오기

이제 GeoJSON 파일의 모든 위치 데이터를 새 데이터베이스로 가져와야 합니다.

다행히 이는 잘 알려진 문제이며 인터넷에서 찾을 수 있는 여러 도구를 사용해 자동화할 수 있습니다. 지리 공간 데이터를 저장하는 여러 일반적인 형식을 변환하는 ogr2ogr이라는 도구를 사용하겠습니다. 대표적인 옵션은 잘 알고 계시는 GeoJSON을 SQL 덤프 파일로 변환하는 것입니다. 이를 통해 SQL 덤프 파일을 사용하여 데이터베이스의 테이블과 열을 만들고 GeoJSON 파일에 있는 모든 데이터와 함께 로드할 수 있습니다.

SQL 덤프 파일 만들기

먼저 ogr2ogr을 설치합니다.

sudo apt-get install gdal-bin

그런 다음 ogr2ogr을 사용하여 SQL 덤프 파일을 만듭니다. 이 파일은 austinrecycling이라는 테이블을 만듭니다.

ogr2ogr --config PG_USE_COPY YES -f PGDump datadump.sql \
data/recycling-locations.geojson -nln austinrecycling

위 명령어는 austin-recycling 폴더에서의 실행을 기반으로 합니다. 다른 디렉터리에서 실행하려면 datarecycling-locations.geojson이 저장된 디렉터리의 경로로 바꿉니다.

데이터베이스를 재활용 위치로 채우기

마지막 명령어를 완료하면 명령어를 실행한 디렉터리에 datadump.sql, 파일이 있어야 합니다. 이 디렉터리를 열면 100개가 넘는 SQL 줄이 열려 austinrecycling 테이블이 생성되고 위치가 채워집니다.

이제 데이터베이스 연결을 열고 다음 명령어를 사용하여 스크립트를 실행합니다.

gcloud sql connect locations --user=postgres --quiet < datadump.sql

스크립트가 실행되면 마지막 몇 줄의 출력이 다음과 같이 표시됩니다.

샘플 명령어 결과

ALTER TABLE
ALTER TABLE
ATLER TABLE
ALTER TABLE
COPY 103
COMMIT
WARNING: there is no transaction in progress
COMMIT

Go 백엔드를 업데이트하여 Cloud SQL 사용하기

이제 데이터베이스에 모든 데이터가 있으므로 코드를 업데이트해야 합니다.

프런트엔드를 업데이트하여 위치 정보 전송하기

프런트엔드에 대한 아주 작은 업데이트부터 시작하겠습니다. 쿼리를 실행할 때마다 모든 단일 위치를 프런트엔드로 전달할 필요가 없는 범위를 대상으로 이 앱을 작성하고 있으니 프런트엔드에서 사용자가 관심을 가지는 위치 관련 기본 정보를 전달해야 합니다.

app.js를 열고 fetchStores 함수 정의를 이 버전으로 대체하여 사용자가 관심을 보이는 위도와 경도를 URL에 포함합니다.

app.js - fetchStores

const fetchStores = async (center) => {
  const url = `/data/dropoffs?centerLat=${center.lat}&centerLng=${center.lng}`;
  const response = await fetch(url);
  return response.json();
};

Codelab의 이 단계를 완료하면 응답에서는 center 매개변수에 제공된 지도 좌표에서 가장 가까운 매장만 반환합니다. initialize 함수에서 최초 가져오기의 경우 이 실습에서 제공하는 샘플 코드는 텍사스주 오스틴의 중앙 좌표를 사용합니다.

이제 fetchStores에서는 매장 위치의 하위 집합만 반환하기 때문에 사용자가 시작 위치를 변경할 때마다 매장을 다시 가져와야 합니다.

새 출처를 설정할 때마다 위치를 새로고침하도록 initAutocompleteWidget 함수를 업데이트합니다. 이를 위해 두 가지를 수정해야 합니다.

  1. initAutocompleteWidget에서 place_changed 리스너의 콜백을 찾습니다. 기존 원을 삭제하는 줄의 주석 처리를 삭제하면 사용자가 Place Autocomplete 검색창에서 주소를 선택할 때마다 해당 줄이 실행됩니다.

app.js - initAutocompleteWidget

  autocomplete.addListener("place_changed", async () => {
    circles.forEach((c) => c.setMap(null)); // clear existing stores
    // ...
  1. 선택한 출발지가 변경될 때마다 originLocation 변수가 업데이트됩니다. 'place_changed' 콜백 끝에서 '// TODO: Calculate the closest stores' 줄 위의 주석 처리를 제거하여 해당 새 출발지를 fetchAndRenderStores 함수에 대한 새로운 호출에 전달합니다.

app.js - initAutocompleteWidget

    await fetchAndRenderStores(originLocation.toJSON());
    // TODO: Calculate the closest stores

백엔드를 업데이트하여 플랫 JSON 파일 대신 CloudSQL 사용하기

플랫 파일 GeoJSON 읽기 및 캐싱 삭제하기

먼저 main.go를 변경하여 플랫 GeoJSON 파일을 로드하고 캐시하는 코드를 삭제합니다. 또한 다른 파일에서 Cloud SQL로 구동되는 함수를 작성하여 dropoffsHandler 함수를 제거할 수도 있습니다.

새로운 main.go는 훨씬 짧아집니다.

main.go

package main

import (

        "log"
        "net/http"
        "os"
)

func main() {

        initConnectionPool()

        // Request for data should be handled by Go.  Everything else should be directed
        // to the folder of static files.
        http.HandleFunc("/data/dropoffs", dropoffsHandler)
        http.Handle("/", http.FileServer(http.Dir("./static/")))

        // Open up a port for the webserver.
        port := os.Getenv("PORT")
        if port == "" {
                port = "8080"
        }
        log.Printf("Listening on port %s", port)
        if err := http.ListenAndServe(":"+port, nil); err != nil {
                log.Fatal(err)
        }
}

위치 요청을 위한 새 핸들러 만들기

이제 오스틴 재활용 디렉터리에 locations.go라는 다른 파일을 만들겠습니다. 먼저 위치 요청에 대한 핸들러를 다시 구현해야 합니다.

locations.go

package main

import (
        "database/sql"
        "fmt"
        "log"
        "net/http"
        "os"

        _ "github.com/jackc/pgx/stdlib"
)

// queryBasic demonstrates issuing a query and reading results.
func dropoffsHandler(w http.ResponseWriter, r *http.Request) {
        w.Header().Set("Content-type", "application/json")
        centerLat := r.FormValue("centerLat")
        centerLng := r.FormValue("centerLng")
        geoJSON, err := getGeoJSONFromDatabase(centerLat, centerLng)
        if err != nil {
                str := fmt.Sprintf("Couldn't encode results: %s", err)
                http.Error(w, str, 500)
                return
        }
        fmt.Fprintf(w, geoJSON)
}

핸들러는 다음과 같은 중요한 작업을 처리합니다.

  • 요청 객체에서 위도와 경도를 가져옵니다(두 항목을 URL에 어떻게 추가했는지 기억나시나요? ).
  • GeoJSON 문자열을 반환하는 getGeoJsonFromDatabase 호출을 시작합니다(이 호출은 나중에 작성합니다).
  • ResponseWriter를 사용하여 GeoJSON 문자열을 응답에 출력합니다.

다음으로 동시 사용자를 통해 데이터베이스 사용량을 효율적으로 확장할 수 있도록 연결 풀을 만들겠습니다.

연결 풀 만들기

연결 풀은 서버가 서비스 사용자 요청에 재사용할 수 있는 활성 데이터베이스 연결 모음입니다. 서버가 모든 활성 사용자의 연결을 생성하고 삭제하는 데 시간을 소비할 필요가 없어 활성 사용자가 증가할 때 발생하는 오버헤드가 대폭 줄어듭니다. 이전 섹션에서 가져왔던 github.com/jackc/pgx/stdlib. 라이브러리는 Go에서 연결 풀을 작업하는 데 자주 사용하는 라이브러리입니다.

locations.go 끝에서 연결 풀을 초기화하는 (main.go에서 호출하는) initConnectionPool 함수를 생성합니다. 명확한 설명을 위해 이 스니펫에서는 일부 도우미 메서드를 사용합니다. configureConnectionPool에서는 연결 수나 연결당 전체 기간 같은 풀 설정을 쉽게 조정할 수 있습니다. mustGetEnv는 호출을 래핑하여 필요한 환경 변수를 가져오므로 인스턴스에 중요한 정보(예: 연결할 데이터베이스의 IP 또는 이름)가 누락되면 유용한 오류 메시지가 발생할 수 있습니다.

locations.go

// The connection pool
var db *sql.DB

// Each struct instance contains a single row from the query result.
type result struct {
        featureCollection string
}

func initConnectionPool() {
        // If the optional DB_TCP_HOST environment variable is set, it contains
        // the IP address and port number of a TCP connection pool to be created,
        // such as "127.0.0.1:5432". If DB_TCP_HOST is not set, a Unix socket
        // connection pool will be created instead.
        if os.Getenv("DB_TCP_HOST") != "" {
                var (
                        dbUser    = mustGetenv("DB_USER")
                        dbPwd     = mustGetenv("DB_PASS")
                        dbTCPHost = mustGetenv("DB_TCP_HOST")
                        dbPort    = mustGetenv("DB_PORT")
                        dbName    = mustGetenv("DB_NAME")
                )

                var dbURI string
                dbURI = fmt.Sprintf("host=%s user=%s password=%s port=%s database=%s", dbTCPHost, dbUser, dbPwd, dbPort, dbName)

                // dbPool is the pool of database connections.
                dbPool, err := sql.Open("pgx", dbURI)
                if err != nil {
                        dbPool = nil
                        log.Fatalf("sql.Open: %v", err)
                }

                configureConnectionPool(dbPool)

                if err != nil {

                        log.Fatalf("initConnectionPool: unable to connect: %s", err)
                }
                db = dbPool
        }
}

// configureConnectionPool sets database connection pool properties.
// For more information, see https://golang.org/pkg/database/sql
func configureConnectionPool(dbPool *sql.DB) {
        // Set maximum number of connections in idle connection pool.
        dbPool.SetMaxIdleConns(5)
        // Set maximum number of open connections to the database.
        dbPool.SetMaxOpenConns(7)
        // Set Maximum time (in seconds) that a connection can remain open.
        dbPool.SetConnMaxLifetime(1800)
}

// mustGetEnv is a helper function for getting environment variables.
// Displays a warning if the environment variable is not set.
func mustGetenv(k string) string {
        v := os.Getenv(k)
        if v == "" {
                log.Fatalf("Warning: %s environment variable not set.\n", k)
        }
        return v
}

데이터베이스에서 위치를 쿼리하고 반환 시 JSON을 가져옵니다.

지금부터 지도 좌표를 표시하고 가장 가까운 위치 25개를 반환하는 데이터베이스 쿼리를 작성하겠습니다. 이러한 쿼리와 현대적인 고급 데이터베이스 기능을 바탕으로 데이터를 GeoJSON로 반환합니다. 최종 결과를 프런트엔드 코드에서 구분할 수 있다면 어떤 것도 변하지 않는다는 의미입니다. URL에 대한 요청을 시작하고 여러 GeoJSON을 가져오기 전을 기준으로 합니다. 이제 이 쿼리는 URL에 대한 요청을 실행하고 여러 GeoJSON을 다시 가져옵니다.

다음은 이러한 놀라운 작업을 처리하는 함수입니다. 방금 locations.go 아래에 작성한 핸들러와 연결 풀링 코드 뒤에 다음 함수를 추가합니다.

locations.go

func getGeoJSONFromDatabase(centerLat string, centerLng string) (string, error) {

        // Obviously you can one-line this, but for testing purposes let's make it easy to modify on the fly.
        const milesRadius = 10
        const milesToMeters = 1609
        const radiusInMeters = milesRadius * milesToMeters

        const tableName = "austinrecycling"

        var queryStr = fmt.Sprintf(
                `SELECT jsonb_build_object(
                        'type',
                        'FeatureCollection',
                        'features',
                        jsonb_agg(feature)
                )
        FROM (
                        SELECT jsonb_build_object(
                                        'type',
                                        'Feature',
                                        'id',
                                        ogc_fid,
                                        'geometry',
                                        ST_AsGeoJSON(wkb_geometry)::jsonb,
                                        'properties',
                                        to_jsonb(row) - 'ogc_fid' - 'wkb_geometry'
                                ) AS feature
                        FROM (
                                        SELECT *,
                                                ST_Distance(
                                                        ST_GEOGFromWKB(wkb_geometry),
                                                        -- Los Angeles (LAX)
                                                        ST_GEOGFromWKB(st_makepoint(%v, %v))
                                                ) as distance
                                        from %v
                                        order by distance
                                        limit 25
                                ) row
                        where distance < %v
                ) features
                `, centerLng, centerLat, tableName, radiusInMeters)

        log.Println(queryStr)

        rows, err := db.Query(queryStr)

        defer rows.Close()

        rows.Next()
        queryResult := result{}
        err = rows.Scan(&queryResult.featureCollection)
        return queryResult.featureCollection, err
}

이 함수는 주로 데이터베이스에 대한 요청을 실행하는 데 필요한 설정, 해체 및 오류 처리를 수행합니다. 데이터베이스 계층에서 흥미로운 작업을 많이 수행하여 코드에서 이러한 항목을 성공적으로 구현하는 실제 SQL을 살펴보겠습니다.

다음은 문자열이 파싱되고 다음과 같이 모든 문자열 리터럴이 올바른 장소에 반환되면 실행되는 원시 쿼리입니다.

parsed.sql

SELECT jsonb_build_object(
        'type',
        'FeatureCollection',
        'features',
        jsonb_agg(feature)
    )
FROM (
        SELECT jsonb_build_object(
                'type',
                'Feature',
                'id',
                ogc_fid,
                'geometry',
                ST_AsGeoJSON(wkb_geometry)::jsonb,
                'properties',
                to_jsonb(row) - 'ogc_fid' - 'wkb_geometry'
            ) AS feature
        FROM (
                SELECT *,
                    ST_Distance(
                        ST_GEOGFromWKB(wkb_geometry),
                        -- Los Angeles (LAX)
                        ST_GEOGFromWKB(st_makepoint(-97.7624043, 30.523725))
                    ) as distance
                from austinrecycling
                order by distance
                limit 25
            ) row
        where distance < 16090
    ) features

이 쿼리는 단일 기본 쿼리이자 일부 JSON 래핑 함수로 간주될 수 있습니다.

SELECT * ... LIMIT 25는 각 위치의 모든 필드를 선택합니다. 선택이 끝나면 (PostGIS의 지리적 측정 함수 모음 중 일부인) ST_DISTANCE 함수를 사용하여 데이터베이스에 있는 각 위치와 프런트엔드에서 사용자가 제공한 위치의 위도/경도 쌍 사이의 거리를 결정합니다. 주행 거리를 제공할 수 있는 Distance Matrix와는 달리 GeoSpatial 거리라는 점을 명심해야 합니다. 그런 다음 효율성을 높이기 위해 이 거리를 사용하여 사용자가 지정한 위치와 가장 가까운 25개의 위치를 정렬하고 반환합니다.

**SELECT json_build_object(‘type', ‘F**eature')는 이전 쿼리를 래핑하여 결과를 가져오고 이러한 결과를 이용해 GeoJSON Feature 객체를 빌드합니다. 예기치 않게 이 쿼리에서는 최대 반경이 적용되며 '16090'은 Go 백엔드에서 지정하는 엄격한 제한인 10마일을 미터로 변환한 수입니다. 이 WHERE 절이 (각 위치의 거리를 결정하는) 내부 쿼리에 대신 추가되지 않은 이유는 WHERE 절을 검사할 때 필드를 계산하지 않는 SQL의 실행 방식 때문입니다. 실제로 이 WHERE 절을 내부 쿼리로 옮기려고 하면 오류가 발생합니다.

**SELECT json_build_object(‘type', ‘FeatureColl**ection') 이 쿼리는 GeoJSON FeatureCollection 객체의 JSON 생성 쿼리에서 모든 결과 행을 래핑합니다.

프로젝트에 PGX 라이브러리 추가하기

프로젝트에 종속성 하나를 추가해야 합니다. 바로 연결 풀을 사용 설정하는 PostGres 드라이버 및 툴킷입니다. 가장 쉬운 방법은 Go 모듈을 사용하는 것입니다. Cloud Shell에서 다음 명령어를 사용하여 모듈을 초기화합니다.

go mod init my_locator

다음으로 이 명령어를 실행하여 종속성에 대한 코드를 스캔하고 모드 파일에 종속성 목록을 추가한 후 다운로드합니다.

go mod tidy

마지막으로 AppEngine Flex에서 컨테이너를 쉽게 빌드할 수 있도록 이 명령어를 실행하여 종속성을 프로젝트 디렉터리에 직접 가져옵니다.

go mod vendor

좋습니다. 이제 테스트할 준비가 끝났습니다.

테스트하기

좋습니다. LOT가 끝났습니다. 제대로 작동하는지 확인해보겠습니다.

개발 머신(Cloud Shell 포함)이 데이터베이스와 연결될 수 있도록 Cloud SQL Proxy를 사용하여 데이터베이스 연결을 관리해야 합니다. Cloud SQL 프록시를 설정하는 방법은 다음과 같습니다.

  1. 여기로 이동하여 Cloud SQL Admin API를 사용 설정합니다.
  2. 로컬 개발 머신을 사용하는 경우 Cloud SQL 프록시 도구를 설치합니다. Cloud Shell을 사용 중인 경우 이 단계를 건너뛰어도 됩니다. 이미 설치된 상태이기 때문입니다. 지침에서는 서비스 계정을 참조합니다. 계정이 이미 생성되었으니 해당 계정에 필요한 권한을 추가하는 방법을 다음 섹션에서 설명하겠습니다.
  3. (Cloud Shell 또는 자체 터미널에서) 새 탭을 만들어 프록시를 시작합니다.

bcca42933bfbd497.png

  1. https://console.cloud.google.com/sql/instances/locations/overview에 접속하고 아래로 스크롤하여 연결 이름 필드를 찾습니다. 다음 명령어에서 사용할 이름을 복사합니다.
  2. 탭에서 이 명령어를 이용해 Cloud SQL 프록시를 실행하여 CONNECTION_NAME을 이전 단계에서 표시된 연결 이름으로 바꿉니다.
cloud_sql_proxy -instances=CONNECTION_NAME=tcp:5432

Cloud Shell의 첫 번째 탭으로 돌아가 Go가 데이터베이스 백엔드와 통신하는 데 필요한 환경 변수를 정의한 다음 서버를 이전과 같은 방법으로 실행합니다.

(아직 하지 않았다면) 프로젝트의 루트 디렉터리로 이동합니다.

cd YOUR_PROJECT_ROOT

다음 환경 변수 5개를 만듭니다(YOUR_PASSWORD_HERE을 위에서 만든 비밀번호로 바꿉니다).

export DB_USER=postgres
export DB_PASS=YOUR_PASSWORD_HERE
export DB_TCP_HOST=127.0.0.1 # Proxy
export DB_PORT=5432 #Default for PostGres
export DB_NAME=postgres

로컬 인스턴스를 실행합니다.

go run *.go

미리보기 창을 엽니다. 이 창은 아무것도 변경되지 않은 것처럼 작동합니다. 사용자는 출발지를 입력하고, 지도를 확대하고, 재활용 위치를 클릭할 수 있습니다. 하지만 이제 이 창은 데이터베이스를 바탕으로 하며 확장할 수 있습니다.

9. 가장 가까운 매장 표시하기

Directions API는 Google 지도 앱에서 경로를 요청할 때와 마찬가지로, 단일 출발지와 단일 목적지를 입력하여 두 지점 간의 경로를 수신하는 방식으로 작동합니다. Distance Matrix API는 여기에서 더 나아가 이동 시간 및 거리를 기반으로 가능한 여러 출발지와 목적지 간 최적의 쌍을 식별합니다. 이 경우에는 사용자가 선택한 주소와 가장 가까운 매장을 찾을 수 있도록 하나의 출발지와 더불어 일련의 매장 위치를 목적지로 제공합니다.

출발지에서 각 매장까지의 거리를 추가하기

initMap 함수 정의의 시작 부분에 있는 '// TODO: Start Distance Matrix service' 주석을 다음 코드로 대체합니다.

app.js - initMap

distanceMatrixService = new google.maps.DistanceMatrixService();

app.js의 끝에 calculateDistances라는 새 함수를 추가합니다.

app.js

async function calculateDistances(origin, stores) {
  // Retrieve the distances of each store from the origin
  // The returned list will be in the same order as the destinations list
  const response = await getDistanceMatrix({
    origins: [origin],
    destinations: stores.map((store) => {
      const [lng, lat] = store.geometry.coordinates;
      return { lat, lng };
    }),
    travelMode: google.maps.TravelMode.DRIVING,
    unitSystem: google.maps.UnitSystem.METRIC,
  });
  response.rows[0].elements.forEach((element, index) => {
    stores[index].properties.distanceText = element.distance.text;
    stores[index].properties.distanceValue = element.distance.value;
  });
}

const getDistanceMatrix = (request) => {
  return new Promise((resolve, reject) => {
    const callback = (response, status) => {
      if (status === google.maps.DistanceMatrixStatus.OK) {
        resolve(response);
      } else {
        reject(response);
      }
    };
    distanceMatrixService.getDistanceMatrix(request, callback);
  });
};

이 함수는 전달된 출발지를 단일 출발지로 사용하고 일련의 매장 위치를 목적지로 사용하여 Distance Matrix API를 호출합니다. 그런 다음 매장의 ID, 사람이 읽을 수 있는 문자열로 표현된 거리, 미터 단위의 거리를 숫자 값으로 저장하여 객체 배열을 만들고 이 배열을 정렬합니다.

initAutocompleteWidget 함수를 업데이트하여 장소 자동 완성 검색창에서 새 출발지를 선택할 때마다 매장 거리를 계산하게 합니다. initAutocompleteWidget 함수 하단에 있는 '// TODO: Calculate the closest stores' 주석을 다음 코드로 바꿉니다.

app.js - initAutocompleteWidget

    // Use the selected address as the origin to calculate distances
    // to each of the store locations
    await calculateDistances(originLocation, stores);
    renderStoresPanel();

거리를 기준으로 정렬된 매장 목록 보기 표시

사용자는 가장 가까운 지점에서 가장 먼 지점 순으로 정렬된 매장 목록을 볼 수 있습니다. calculateDistances 함수에서 수정한 목록을 사용하여 각 매장의 측면 패널 목록을 채우고 매장의 표시 순서를 알립니다.

app.js의 끝에 renderStoresPanel()storeToPanelRow()라는 새 함수 두 개를 추가합니다.

app.js

function renderStoresPanel() {
  const panel = document.getElementById("panel");

  if (stores.length == 0) {
    panel.classList.remove("open");
    return;
  }

  // Clear the previous panel rows
  while (panel.lastChild) {
    panel.removeChild(panel.lastChild);
  }
  stores
    .sort((a, b) => a.properties.distanceValue - b.properties.distanceValue)
    .forEach((store) => {
      panel.appendChild(storeToPanelRow(store));
    });
  // Open the panel
  panel.classList.add("open");
  return;
}

const storeToPanelRow = (store) => {
  // Add store details with text formatting
  const rowElement = document.createElement("div");
  const nameElement = document.createElement("p");
  nameElement.classList.add("place");
  nameElement.textContent = store.properties.business_name;
  rowElement.appendChild(nameElement);
  const distanceTextElement = document.createElement("p");
  distanceTextElement.classList.add("distanceText");
  distanceTextElement.textContent = store.properties.distanceText;
  rowElement.appendChild(distanceTextElement);
  return rowElement;
};

서버를 다시 시작하고, 다음 명령어를 실행하여 미리보기를 새로고침합니다.

go run *.go

마지막으로 자동 완성 검색창에 텍사스주 오스틴 주소를 입력하고 추천 검색어 중 하나를 클릭합니다.

그러면 지도가 이 주소를 중심으로 표시되며 선택한 주소로부터의 거리를 순서대로 나열한 사이드바가 표시됩니다. 아래 그림 예시를 참고하세요.

96e35794dd0e88c9.png

10. 지도 스타일 지정하기

지도를 눈에 잘 띄게 하는 효과적인 방법은 스타일을 추가하는 것입니다. 클라우드 기반 지도 스타일 지정을 사용하면 지도의 맞춤설정이 클라우드 기반 지도 스타일 지정(베타)을 통해 Cloud Console에서 제어됩니다. 베타가 아닌 기능을 이용해 지도 스타일을 지정하고 싶다면 지도 스타일 지정 문서를 참고하여 프로그래밍 방식의 지도 스타일 지정을 위한 json을 생성하세요. 아래는 클라우드 기반 지도 스타일 지정(베타) 관련 지침입니다.

지도 ID 만들기

먼저 Cloud Console을 열고 검색창에 '지도 관리'를 입력합니다. '지도 관리(Google 지도)'라는 이름의 결과를 클릭합니다. 64036dd0ed200200.png

화면 상단(검색창 바로 아래)에 새 지도 ID 만들기라는 버튼이 표시됩니다. 버튼을 클릭하고 원하는 이름을 입력합니다. 지도 유형에는 자바스크립트를 선택해야 하며 추가 옵션이 표시되면 목록에서 벡터를 선택합니다. 최종 결과는 아래 이미지처럼 표시됩니다.

70f55a759b4c4212.png

'다음'을 클릭하면 새 지도 ID가 표시됩니다. 원한다면 지금 복사할 수 있지만 나중에 쉽게 찾을 수 있으니 걱정하지 않아도 됩니다.

다음으로 지도에 적용할 스타일을 만들어 보겠습니다.

지도 스타일 만들기

Cloud Console의 지도 섹션이 계속 표시된다면 왼쪽의 탐색 메뉴 하단에서 '지도 스타일'을 클릭합니다. 또는 지도 ID를 생성할 때처럼 검색창에 '지도 스타일'을 입력하고 아래 그림과 같이 결과에서 '지도 스타일 Google 지도)'을 선택하면 올바른 페이지를 찾을 수 있습니다.

9284cd200f1a9223.png

그런 다음 상단에 있는 '+ 새 지도 스타일 만들기' 버튼을 클릭합니다.

  1. 이 실습에서 표시하는 지도의 스타일과 일치하게 하고 싶다면 'JSON 가져오기' 탭을 클릭하고 아래 JSON Blob을 붙여넣습니다. 직접 지도를 만들고 싶다면 시작할 지도 스타일을 선택하세요. 이어서 다음을 클릭합니다.
  2. 방금 만든 지도 ID를 선택하여 지도 ID를 이 스타일에 연결하고 다음을 다시 클릭합니다.
  3. 이때 지도 스타일을 추가로 맞춤설정하는 옵션이 제공됩니다. 탐색하고 싶은 내용이 있다면 스타일 편집기에서 맞춤설정을 클릭한 다음 원하는 지도 스타일이 완성될 때까지 색상과 옵션을 선택하세요. 그렇지 않다면 건너뛰기를 클릭합니다.
  4. 다음 단계에서 스타일 이름과 설명을 입력한 다음 저장 및 게시를 클릭합니다.

다음은 첫 번째 단계에서 가져올 선택적 JSON Blob입니다.

[
  {
    "elementType": "geometry",
    "stylers": [
      {
        "color": "#d6d2c4"
      }
    ]
  },
  {
    "elementType": "labels.icon",
    "stylers": [
      {
        "visibility": "off"
      }
    ]
  },
  {
    "elementType": "labels.text.fill",
    "stylers": [
      {
        "color": "#616161"
      }
    ]
  },
  {
    "elementType": "labels.text.stroke",
    "stylers": [
      {
        "color": "#f5f5f5"
      }
    ]
  },
  {
    "featureType": "administrative.land_parcel",
    "elementType": "labels.text.fill",
    "stylers": [
      {
        "color": "#bdbdbd"
      }
    ]
  },
  {
    "featureType": "landscape.man_made",
    "elementType": "geometry.fill",
    "stylers": [
      {
        "color": "#c0baa5"
      },
      {
        "visibility": "on"
      }
    ]
  },
  {
    "featureType": "landscape.man_made",
    "elementType": "geometry.stroke",
    "stylers": [
      {
        "color": "#9cadb7"
      },
      {
        "visibility": "on"
      }
    ]
  },
  {
    "featureType": "poi",
    "elementType": "labels.text.fill",
    "stylers": [
      {
        "color": "#757575"
      }
    ]
  },
  {
    "featureType": "poi.park",
    "elementType": "labels.text.fill",
    "stylers": [
      {
        "color": "#9e9e9e"
      }
    ]
  },
  {
    "featureType": "road",
    "elementType": "geometry",
    "stylers": [
      {
        "color": "#ffffff"
      }
    ]
  },
  {
    "featureType": "road.arterial",
    "elementType": "geometry",
    "stylers": [
      {
        "weight": 1
      }
    ]
  },
  {
    "featureType": "road.arterial",
    "elementType": "labels.text.fill",
    "stylers": [
      {
        "color": "#757575"
      }
    ]
  },
  {
    "featureType": "road.highway",
    "elementType": "geometry",
    "stylers": [
      {
        "color": "#bf5700"
      }
    ]
  },
  {
    "featureType": "road.highway",
    "elementType": "geometry.stroke",
    "stylers": [
      {
        "visibility": "off"
      }
    ]
  },
  {
    "featureType": "road.highway",
    "elementType": "labels.text.fill",
    "stylers": [
      {
        "color": "#616161"
      }
    ]
  },
  {
    "featureType": "road.local",
    "elementType": "geometry",
    "stylers": [
      {
        "weight": 0.5
      }
    ]
  },
  {
    "featureType": "road.local",
    "elementType": "labels.text.fill",
    "stylers": [
      {
        "color": "#9e9e9e"
      }
    ]
  },
  {
    "featureType": "transit.line",
    "elementType": "geometry",
    "stylers": [
      {
        "color": "#e5e5e5"
      }
    ]
  },
  {
    "featureType": "transit.station",
    "elementType": "geometry",
    "stylers": [
      {
        "color": "#eeeeee"
      }
    ]
  },
  {
    "featureType": "water",
    "elementType": "geometry",
    "stylers": [
      {
        "color": "#333f48"
      }
    ]
  },
  {
    "featureType": "water",
    "elementType": "labels.text.fill",
    "stylers": [
      {
        "color": "#9e9e9e"
      }
    ]
  }
]

코드에 지도 ID 추가하기

지금까지 지도 스타일을 만드는 방법을 살펴보았습니다. 이 지도 스타일을 지도에서 실제로 어떻게 사용해야 할까요? 두 가지 사항을 약간 변경해야 합니다.

  1. index.html의 스크립트 태그에 지도 ID를 URL 매개변수로 추가합니다.
  2. initMap() 메서드에서 지도를 만들 때 지도 ID를 생성자 인수로 Add합니다.

HTML 파일에서 Maps JavaScript API를 로드하는 스크립트 태그를 아래의 로더 URL로 교체하여 'YOUR_API_KEY' 및 'YOUR_MAP_ID'의 자리표시자를 대체합니다.

index.html

...
<script async defer src="https://maps.googleapis.com/maps/api/js?key=YOUR_API_KEY&v=weekly&libraries=places&callback=initialize&map_ids=YOUR_MAP_ID&solution_channel=GMP_codelabs_fullstackstorelocator_v1_a">
  </script>
...

상수 map이 정의된 app.jsinitMap 메서드에서 mapId 속성 줄의 주석 처리를 삭제하고 'YOUR_MAP_ID_HERE'을 방금 만든 지도 ID로 교체합니다.

app.js - initMap

...

// The map, centered on Austin, TX
 const map = new google.maps.Map(document.querySelector('#map'), {
   center: austin,
   zoom: 14,
   mapId: 'YOUR_MAP_ID_HERE',
// ...
});
...

서버를 다시 시작합니다.

go run *.go

미리보기를 새로고침하면 기본 설정에 따른 스타일이 적용된 지도가 표시됩니다. 다음은 위의 JSON 스타일 지정을 사용하는 예입니다.

2ece59c64c06e9da.png

11. 프로덕션에 배포하기

이제까지 한 것처럼 개발 머신 또는 Cloud Shell의 로컬 웹서버에서뿐만이 아니라 AppEngine Flex에서도 실행 중인 앱은 아주 쉽게 확인할 수 있습니다. 프로덕션 환경에서 데이터베이스 액세스가 작동하게 하려면 몇 개를 추가해야 합니다. 이 내용은 App Engine Flex에서 Cloud SQL로의 연결에 관한 문서 페이지에서 설명합니다.

App.yaml에 환경 변수 추가하기

먼저 로컬에서 테스트하는 데 사용한 모든 환경 변수를 애플리케이션의 app.yaml 파일 하단에 추가해야 합니다.

  1. https://console.cloud.google.com/sql/instances/locations/overview에서 인스턴스 연결 이름을 찾아보세요.
  2. app.yaml 끝에 다음 코드를 붙여넣습니다.
  3. YOUR_DB_PASSWORD_HERE를 이전에 postgres 사용자 이름에 만든 비밀번호로 바꿉니다.
  4. YOUR_CONNECTION_NAME_HERE을 1단계의 값으로 바꿉니다.

app.yaml

# ...
# Set environment variables
env_variables:
    DB_USER: postgres
    DB_PASS: YOUR_DB_PASSWORD_HERE
    DB_NAME: postgres
    DB_TCP_HOST: 172.17.0.1
    DB_PORT: 5432

#Enable TCP Port
# You can look up your instance connection name by going to the page for
# your instance in the Cloud Console here : https://console.cloud.google.com/sql/instances/
beta_settings:
  cloud_sql_instances: YOUR_CONNECTION_NAME_HERE=tcp:5432

이 앱은 AppEngine Flex를 통해 연결되므로 DB_TCP_HOST172.17.0.1 값이 있어야 합니다**.** 이 앱은 이전처럼 프록시를 통해 Cloud SQL과 통신하기 때문입니다.

AppEngine Flex 서비스 계정에 SQL 클라이언트 권한 추가하기

Cloud Console의 IAM-관리자 페이지로 이동하고 이름이 service-PROJECT_NUMBER@gae-api-prod.google.com.iam.gserviceaccount.com 형식과 일치하는 서비스 계정을 찾습니다. App Engine Flex에서는 이 서비스 계정을 사용하여 데이터베이스에 연결합니다. 행 끝에 있는 수정 버튼을 클릭하고 'Cloud SQL 클라이언트' 역할을 추가합니다.

b04ccc0b4022b905.png

프로젝트 코드를 Go 경로에 복사하기

코드를 실행하려면 AppEngine이 Go 경로에서 관련 파일을 찾을 수 있어야 합니다. 현재 위치가 프로젝트 루트 디렉터리인지 확인합니다.

cd YOUR_PROJECT_ROOT

디렉터리를 Go 경로에 복사합니다.

mkdir -p ~/gopath/src/austin-recycling
cp -r ./ ~/gopath/src/austin-recycling

해당 디렉터리로 변경합니다.

cd ~/gopath/src/austin-recycling

앱 배포하기

gcloud 도구를 사용하여 앱을 배포합니다. 배포하는 데는 시간이 조금 걸립니다.

gcloud app deploy

browse 명령어를 사용하면 클릭 시 엔터프라이즈급의 근사한 완전 관리형 매장 검색 기능을 체험할 수 있는 링크를 얻게 됩니다.

gcloud app browse

Cloud Shell 외부에서 gcloud를 실행한 경우 gcloud app browse를 실행하면 새 브라우저 탭이 열립니다.

12. (추천) 삭제하기

이 Codelab을 진행해도 BigQuery 처리 및 Maps Platform API 호출의 무료 등급 한도에서 벗어나지는 않습니다. 하지만 교육 실습으로만 진행했으며 향후 요금 발생을 원하지 않는 경우 프로젝트 관련 리소스를 삭제하는 가장 쉬운 방법은 프로젝트 자체를 삭제하는 것입니다.

프로젝트 삭제하기

GCP Console에서 Cloud Resource Manager 페이지로 이동합니다.

프로젝트 목록에서 작업 중인 프로젝트를 선택하고 삭제를 클릭합니다. 프로젝트 ID를 입력하라는 메시지가 표시됩니다. 프로젝트 ID를 입력하고 종료를 클릭합니다.

아니면 다음 명령어를 실행하고 GOOGLE_CLOUD_PROJECT 자리표시자를 프로젝트 ID로 바꿔 gcloud를 이용해 Cloud Shell에서 바로 전체 프로젝트를 삭제하는 방법도 있습니다.

gcloud projects delete GOOGLE_CLOUD_PROJECT

13. 축하합니다

축하합니다. Codelab을 성공적으로 완료했습니다.

마지막 페이지까지 훑어보셨을 수도 있습니다. 축하합니다. 마지막 페이지까지 훑어보셨습니다.

이 Codelab 과정에서 다음 기술을 사용했습니다.

추가 자료

이러한 모든 기술은 아직 알아야 하는 내용이 많습니다. 다음은 이번 Codelab에서 다루지는 못했지만 개발자의 특정 요구에 적합한 매장 검색 기능 솔루션을 빌드하는 데 도움이 되는 유용한 링크입니다.