6. 구글 Places API 연동하기¶
4장의 검색 화면은 아직 "연기"를 하고 있습니다. 무엇을 검색하든 경복궁·N서울타워·국립중앙박물관만 나오는 연습용 데이터였지요. 이번 장에서는 진짜 세상의 장소 데이터를 가진 Google Places API 에 물어보고, 진짜 검색 결과와 진짜 사진을 받아 오겠습니다.
이번 장이 끝나면 "제주 흑돼지"를 검색해 진짜 제주 흑돼지 맛집 목록을 받아 볼 수 있습니다.
REST API 와 JSON — 주문서와 답장¶
식당에서 음식을 주문하는 장면을 떠올려 봅시다. 주문서에 정해진 형식으로 "김치찌개 1, 밥 2"라고 적어 주방에 넘기면, 주방은 주문서대로 음식을 만들어 내어 줍니다. 주문서 형식만 지키면 누가 주문하든 같은 결과를 받습니다.
서버가 제공하는 이런 주문 창구를 API(Application Programming Interface)라고 하고, 인터넷 주소(URL)로 주문서를 주고받는 방식을 REST API 라고 한다. 우리 앱(손님)이 정해진 형식의 요청(주문서)을 보내면, 구글 서버(주방)가 결과(답장)를 보내 준다.
주문서와 답장에는 JSON 이라는 글자 형식을 쓴다. JSON 은 이름: 값 쌍을 중괄호로 묶은 데이터 표기법으로, 사람이 읽을 수 있고 컴퓨터가 다루기 쉽다.
{ "textQuery": "떡볶이", "pageSize": 10 }
Google API 키 발급받기¶
5장의 네이버 지도처럼, 구글도 "누가 쓰는지" 확인할 열쇠(API 키)를 요구합니다.
결제 카드 등록이 필요합니다 — 보호자와 함께
구글은 2025년부터 Places API 를 쓰려면 무료 사용량 안에서만 쓰더라도 결제 계정(신용/체크카드) 등록을 필수로 요구합니다. 우리 실습 규모는 월 무료 제공량 안에 충분히 들어가지만, 카드 등록 절차 자체는 피할 수 없습니다. 미성년자는 반드시 보호자와 함께 진행하십시오. 학원·학교 수업이라면 선생님이 발급한 키를 나눠 받는 방법도 있다.
[CH-1 아래 과정을 그대로 진행하시오.]¶
- 브라우저에서
https://console.cloud.google.com(Google Cloud 콘솔)에 접속해 구글 계정으로 로그인한다. - 상단의 프로젝트 선택 → 새 프로젝트로
bucketmap프로젝트를 만든다. - 안내에 따라 결제 계정을 등록한다 (위 경고 참고).
- 왼쪽 메뉴 API 및 서비스 → 라이브러리에서
Places API (New)를 검색해 사용 설정한다. 이름이 비슷한 구버전 "Places API" 가 따로 있으니 반드시 (New) 가 붙은 쪽을 고른다. - API 및 서비스 → 사용자 인증 정보 → 사용자 인증 정보 만들기 → API 키를 눌러 키를 만들고 복사해 둔다.
콘솔 화면이 책과 다르게 보인다면
Google Cloud 콘솔 역시 수시로 개편됩니다. 메뉴가 다르면 공식 문서(https://developers.google.com/maps/documentation/places/web-service/text-search)의 최신 안내를 함께 확인하십시오.
[CH-2 아래 내용을 local.properties 에 반영하시오.]¶
5장에서 비워 두었던 줄에 복사한 키를 붙여 넣는다.
PLACES_API_KEY=발급받은_API_키
키를 코드가 아니라 local.properties 에 두는 이유는 5장에서 배운 그대로다. app/build.gradle 의 buildConfigField 두 줄은 5장에서 이미 만들어 두었으므로, 이제 코드에서 BuildConfig.PLACES_API_KEY 로 꺼내 쓸 수 있다.
통신 부품 추가하기 — Retrofit·Gson·Glide¶
인터넷 통신을 밑바닥부터 만드는 것은 지도를 직접 그리는 것만큼 힘듭니다. 검증된 부품 세 가지를 가져다 씁니다.
[CH-3 아래 코드를 그대로 작성하고 실행하시오.]¶
app/build.gradle 의 dependencies { ... } 안, 5장에서 추가한 지도 부품 아래에 네 줄을 추가하고 Sync Now 를 누른다.
// 인터넷 통신 + JSON 변환 + 사진 로딩 (6장)
implementation 'com.squareup.retrofit2:retrofit:2.9.0'
implementation 'com.squareup.retrofit2:converter-gson:2.9.0'
implementation 'com.google.code.gson:gson:2.10.1'
implementation 'com.github.bumptech.glide:glide:4.12.0'
실행 결과
BUILD SUCCESSFUL in 12s
[설명]
- Retrofit — 주문서를 보내고 답장을 받아 오는 배달원이다. 주소·형식만 알려 주면 통신의 궂은일을 다 해 준다.
- Gson — JSON 글자를 Java 객체로, Java 객체를 JSON 글자로 바꾸는 번역가다.
converter-gson은 Retrofit 과 Gson 을 이어 주는 어댑터다. - Glide — 사진 주소(URL)만 주면 내려받아 ImageView 에 넣어 주는 사진사다. 한 줄로 사진 로딩이 끝난다.
주문서와 답장의 모양을 Java 로 옮기기¶
Places API 의 장소 검색 창구는 https://places.googleapis.com/v1/places:searchText 다. 여기에 보내는 주문서(요청 JSON)는 대략 이런 모양이다.
{
"textQuery": "떡볶이",
"pageSize": 10,
"languageCode": "ko",
"locationBias": {
"circle": {
"center": { "latitude": 37.5665, "longitude": 126.9780 },
"radius": 5000
}
}
}
그리고 돌아오는 답장(응답 JSON)은 대략 이런 모양이다.
{
"places": [
{
"id": "ChIJod7...",
"displayName": { "text": "경복궁" },
"formattedAddress": "대한민국 서울특별시 종로구 사직로 161",
"location": { "latitude": 37.5796, "longitude": 126.9770 },
"rating": 4.6,
"photos": [ { "name": "places/ChIJod7.../photos/AXQ..." } ]
}
]
}
Gson 번역가의 규칙은 단순하다. JSON 의 이름과 똑같은 이름의 필드를 가진 Java 클래스를 만들면, 값이 자동으로 채워진다. JSON 이 중괄호 안에 중괄호를 품으면, 클래스 안에 클래스를 만들면 된다.
[CH-4 아래 코드를 그대로 작성하고 실행하시오.]¶
MainActivity.java 가 있는 곳에 새 클래스 SearchRequest.java 를 만든다.
package com.example.myapplication;
/** Places API 에 보낼 요청 몸체. 필드 이름이 그대로 JSON 키가 된다. */
public class SearchRequest {
public String textQuery;
public int pageSize = 10;
public String languageCode = "ko";
public LocationBias locationBias;
public static class LocationBias {
public Circle circle;
}
public static class Circle {
public Center center;
public double radius;
}
public static class Center {
public double latitude;
public double longitude;
}
public SearchRequest(String textQuery, double lat, double lng) {
this.textQuery = textQuery;
// "이 좌표 주변을 우선으로" 라는 뜻의 원(반지름 5km)을 조립한다
Center center = new Center();
center.latitude = lat;
center.longitude = lng;
Circle circle = new Circle();
circle.center = center;
circle.radius = 5000;
this.locationBias = new LocationBias();
this.locationBias.circle = circle;
}
}
[CH-5 아래 코드를 그대로 작성하고 실행하시오.]¶
같은 곳에 SearchResponse.java 를 만든다.
package com.example.myapplication;
import java.util.List;
/** Places API 가 돌려주는 JSON 을 Gson 이 이 모양대로 채워 준다. */
public class SearchResponse {
public List<ApiPlace> places;
public static class ApiPlace {
public String id;
public DisplayName displayName;
public String formattedAddress;
public Location location;
public Double rating; // 평점이 없는 장소는 null 이라 Double 로 받는다
public List<Photo> photos;
}
public static class DisplayName {
public String text;
}
public static class Location {
public double latitude;
public double longitude;
}
public static class Photo {
public String name;
}
}
[설명]
- 위 JSON 예시와 클래스를 나란히 놓고 비교해 보라.
textQuery,locationBias,circle,center— 이름이 한 글자도 다르지 않다. 이름이 다르면 Gson 은 그 자리를 채우지 못한다. locationBias는 "이 근처를 우선으로 찾아 달라"는 힌트다. 서울에서 "떡볶이"를 검색하는데 부산 가게가 먼저 나오면 곤란하기 때문이다.rating을double이 아니라Double로 쓴 이유 — 평점이 아예 없는 장소는 JSON 에rating이 빠져서 오고, 그 자리는null이 된다. 소문자double은null을 담을 수 없어 앱이 죽는다.- 답장의 모든 필드를 다 옮길 필요는 없다. 우리가 쓸 것만 만들면 Gson 이 나머지는 조용히 버린다.
주문 창구 만들기 — PlacesApi 와 ApiClient¶
[CH-6 아래 코드를 그대로 작성하고 실행하시오.]¶
새 인터페이스 PlacesApi.java 를 만든다. (New → Java Class 에서 종류를 Interface 로 고른다.)
package com.example.myapplication;
import retrofit2.Call;
import retrofit2.http.Body;
import retrofit2.http.Header;
import retrofit2.http.Headers;
import retrofit2.http.POST;
public interface PlacesApi {
// FieldMask: 응답에서 받고 싶은 필드만 골라 적는다 (적을수록 빠르고 싸다)
@Headers("X-Goog-FieldMask: places.id,places.displayName,places.formattedAddress,places.location,places.rating,places.photos")
@POST("v1/places:searchText")
Call<SearchResponse> searchText(@Header("X-Goog-Api-Key") String apiKey,
@Body SearchRequest body);
}
[설명]
- Retrofit 의 특기는 "인터페이스에 주문 방법을 적어 두면, 실제 통신 코드를 알아서 만들어 주는 것"이다. 우리는 형식만 선언한다.
@POST("v1/places:searchText")— 주소 뒷부분과 "몸체를 실어 보내는 POST 방식"을 지정한다.@Body SearchRequest body— CH-4 에서 만든 주문서 객체를 JSON 으로 번역해 몸체에 싣는다.@Header("X-Goog-Api-Key")— 매 요청마다 열쇠를 머리말(header)에 붙인다.@Headers(...)의 FieldMask 는 "답장에서 이 필드들만 주세요"라는 주문 옵션이다. 식당에서 "단무지는 빼 주세요"처럼, 필요한 것만 받으면 통신이 빨라지고 요금 계산에도 유리하다.
[CH-7 아래 코드를 그대로 작성하고 실행하시오.]¶
새 클래스 ApiClient.java 를 만든다.
package com.example.myapplication;
import retrofit2.Retrofit;
import retrofit2.converter.gson.GsonConverterFactory;
public class ApiClient {
private static PlacesApi placesApi;
/** 앱 전체에서 Retrofit 을 한 번만 만들어 같이 쓴다. */
public static PlacesApi getPlacesApi() {
if (placesApi == null) {
Retrofit retrofit = new Retrofit.Builder()
.baseUrl("https://places.googleapis.com/")
.addConverterFactory(GsonConverterFactory.create())
.build();
placesApi = retrofit.create(PlacesApi.class);
}
return placesApi;
}
/** photos[0].name 을 실제로 그림이 내려오는 주소로 바꿔 준다. */
public static String photoUrl(String photoName) {
return "https://places.googleapis.com/v1/" + photoName
+ "/media?maxHeightPx=400&key=" + BuildConfig.PLACES_API_KEY;
}
}
[설명]
baseUrl은 주소의 앞부분,@POST의 값은 뒷부분이다. 붙이면 완전한 창구 주소가 된다.- 배달원(Retrofit)은 하나면 충분하다.
placesApi가 이미 있으면 다시 만들지 않고 재사용한다 — 이런 방식을 싱글턴이라고 한다. - 답장의
photos[].name은 사진 자체가 아니라 사진의 이름표다.photoUrl()이 이름표를 "그림이 내려오는 진짜 주소"로 바꿔 주며, 이 주소에는 우리 열쇠가 함께 들어간다.
진짜 검색 연결하기¶
부품이 다 모였습니다. 검색 화면에서 연습용 데이터를 걷어 내고 진짜 주문을 보냅니다.
[CH-8 아래 코드를 그대로 작성하고 실행하시오.]¶
먼저 res/layout/activity_search.xml 의 검색줄 </LinearLayout> 과 RecyclerView 사이에 빙글빙글 로딩 표시를 추가한다.
<ProgressBar
android:id="@+id/progress_search"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="center_horizontal"
android:visibility="gone" />
[CH-9 아래 코드를 그대로 작성하고 실행하시오.]¶
SearchResultAdapter.java 의 생성자 아래에 목록을 갈아 끼우는 메서드를 추가한다.
/** 새 검색 결과로 목록을 통째로 갈아 끼운다. */
public void updatePlaces(List<Place> newPlaces) {
places.clear();
places.addAll(newPlaces);
notifyDataSetChanged();
}
[CH-10 아래 코드를 그대로 작성하고 실행하시오.]¶
SearchActivity.java 를 아래 내용으로 통째로 바꾼다.
package com.example.myapplication;
import android.content.Intent;
import android.os.Bundle;
import android.view.View;
import android.widget.Button;
import android.widget.EditText;
import android.widget.ProgressBar;
import android.widget.Toast;
import androidx.annotation.NonNull;
import androidx.appcompat.app.AppCompatActivity;
import androidx.recyclerview.widget.LinearLayoutManager;
import androidx.recyclerview.widget.RecyclerView;
import java.util.ArrayList;
import java.util.List;
import retrofit2.Call;
import retrofit2.Callback;
import retrofit2.Response;
public class SearchActivity extends AppCompatActivity {
public static final String EXTRA_CENTER_LAT = "extra_center_lat";
public static final String EXTRA_CENTER_LNG = "extra_center_lng";
private SearchResultAdapter adapter;
private ProgressBar progressBar;
private double centerLat;
private double centerLng;
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setContentView(R.layout.activity_search);
// 메인 화면이 보내 준 지도 중심 좌표 — 없으면 서울 시청
centerLat = getIntent().getDoubleExtra(EXTRA_CENTER_LAT, 37.5665);
centerLng = getIntent().getDoubleExtra(EXTRA_CENTER_LNG, 126.9780);
progressBar = findViewById(R.id.progress_search);
RecyclerView resultList = findViewById(R.id.list_results);
resultList.setLayoutManager(new LinearLayoutManager(this));
adapter = new SearchResultAdapter(new ArrayList<>(), place -> {
Intent intent = new Intent(this, PlaceDetailActivity.class);
intent.putExtra(PlaceDetailActivity.EXTRA_ID, place.id);
intent.putExtra(PlaceDetailActivity.EXTRA_NAME, place.name);
intent.putExtra(PlaceDetailActivity.EXTRA_ADDRESS, place.address);
intent.putExtra(PlaceDetailActivity.EXTRA_RATING, place.rating);
intent.putExtra(PlaceDetailActivity.EXTRA_LAT, place.lat);
intent.putExtra(PlaceDetailActivity.EXTRA_LNG, place.lng);
intent.putExtra(PlaceDetailActivity.EXTRA_IMAGE_URL, place.imageUrl);
startActivity(intent);
});
resultList.setAdapter(adapter);
EditText keywordEdit = findViewById(R.id.edit_keyword);
Button searchButton = findViewById(R.id.btn_search);
searchButton.setOnClickListener(v -> {
String keyword = keywordEdit.getText().toString().trim();
if (keyword.isEmpty()) {
Toast.makeText(this, R.string.search_hint, Toast.LENGTH_SHORT).show();
return;
}
search(keyword);
});
}
private void search(String keyword) {
progressBar.setVisibility(View.VISIBLE);
SearchRequest request = new SearchRequest(keyword, centerLat, centerLng);
ApiClient.getPlacesApi()
.searchText(BuildConfig.PLACES_API_KEY, request)
.enqueue(new Callback<SearchResponse>() {
@Override
public void onResponse(@NonNull Call<SearchResponse> call,
@NonNull Response<SearchResponse> response) {
progressBar.setVisibility(View.GONE);
if (!response.isSuccessful() || response.body() == null
|| response.body().places == null) {
Toast.makeText(SearchActivity.this,
R.string.search_empty, Toast.LENGTH_SHORT).show();
adapter.updatePlaces(new ArrayList<>());
return;
}
adapter.updatePlaces(toPlaces(response.body()));
}
@Override
public void onFailure(@NonNull Call<SearchResponse> call,
@NonNull Throwable t) {
progressBar.setVisibility(View.GONE);
Toast.makeText(SearchActivity.this,
R.string.search_error, Toast.LENGTH_SHORT).show();
}
});
}
/** 바깥 세상(API)의 데이터를 우리 앱의 모양(Place)으로 바꾼다. */
private List<Place> toPlaces(SearchResponse response) {
List<Place> places = new ArrayList<>();
for (SearchResponse.ApiPlace apiPlace : response.places) {
String rating = (apiPlace.rating == null) ? null : String.valueOf(apiPlace.rating);
String imageUrl = null;
if (apiPlace.photos != null && !apiPlace.photos.isEmpty()) {
imageUrl = ApiClient.photoUrl(apiPlace.photos.get(0).name);
}
places.add(new Place(apiPlace.id, apiPlace.displayName.text,
apiPlace.formattedAddress, rating,
apiPlace.location.latitude, apiPlace.location.longitude, imageUrl));
}
return places;
}
}
strings.xml 에는 4장에서 만든 문자열 외에 두 개가 더 필요하다.
<string name="search_empty">검색 결과가 없습니다</string>
<string name="search_error">검색에 실패했습니다. 인터넷 연결을 확인해 주세요</string>
실행 결과

검색어를 넣고 검색을 누르면 잠시 로딩이 돌고, 구글이 찾아 준 진짜 장소들이 목록에 나타난다.
에뮬레이터에서 한글 입력이 안 될 때
에뮬레이터는 컴퓨터 키보드의 한글 입력이 그대로 들어가지 않는 경우가 많습니다. 그럴 때는 palace, tteokbokki 처럼 영어로 검색해 보십시오 — 요청에 languageCode="ko" 를 넣어 두었기 때문에 결과는 한글 이름으로 온다. 실제 스마트폰에서는 한글로 검색하면 됩니다.
[설명]
enqueue()는 "줄 세워 두고 다른 스레드가 처리하게 한다"는 뜻이다. 인터넷 답장은 몇 초가 걸릴 수도 있는데, 그동안 화면 담당(메인 스레드)이 기다리며 멈춰 있으면 앱이 얼어붙는다. Retrofit 은 별도 스레드에서 기다렸다가, 결과가 오면 메인 스레드에서onResponse()/onFailure()를 불러 준다.- 답장의 두 갈래 — 서버와 연락 자체가 안 되면(인터넷 끊김 등)
onFailure(), 연락은 됐으면onResponse()로 온다. 연락이 됐어도 열쇠가 틀리는 등 실패일 수 있어isSuccessful()과null을 확인한다. toPlaces()는 바깥 세상의 데이터 모양(SearchResponse.ApiPlace)을 우리 앱의 모양(Place)으로 바꾸는 번역 계층이다. 덕분에 4장에서 만든 어댑터와 목록 화면은 한 줄도 고치지 않고 진짜 데이터를 표시한다.- 검색어가 비어 있으면 요청을 보내지 않고 안내만 한다 — 서버에 가기 전에 막을 수 있는 것은 앱에서 막는 것이 예의다.
지도 중심을 검색에 연결하기¶
주문서의 locationBias 에 넣을 좌표는 어디서 올까요? 메인 화면의 지도가 알고 있습니다.
[CH-11 아래 코드를 그대로 작성하고 실행하시오.]¶
MainActivity.java 의 findButton 리스너를 아래처럼 고친다.
findButton.setOnClickListener(v -> {
Intent intent = new Intent(this, SearchActivity.class);
if (naverMap != null) {
// 지금 보고 있는 지도 중심을 검색 화면에 알려 준다 (주변 우선 검색)
LatLng center = naverMap.getCameraPosition().target;
intent.putExtra(SearchActivity.EXTRA_CENTER_LAT, center.latitude);
intent.putExtra(SearchActivity.EXTRA_CENTER_LNG, center.longitude);
}
startActivity(intent);
});
[설명]
naverMap.getCameraPosition().target이 지금 화면 한가운데의 좌표다. 지도를 부산으로 밀어 놓고 검색하면 부산 주변이 우선으로 나온다.- 데이터를 실어 보내는 방법이 2장에서 배운
putExtra()그대로라는 점에 주목하라. 글자를 보냈던 자리에 숫자(double)를 보냈을 뿐이다.
상세 화면에 사진 띄우기¶
[CH-12 아래 코드를 그대로 작성하고 실행하시오.]¶
PlaceDetailActivity.java 상단의 상수 목록을 7종으로 늘리고, onCreate() 에 사진 로딩을 추가한다. 파일에서 달라지는 부분은 다음과 같다.
public static final String EXTRA_ID = "extra_id";
public static final String EXTRA_NAME = "extra_name";
public static final String EXTRA_ADDRESS = "extra_address";
public static final String EXTRA_RATING = "extra_rating";
public static final String EXTRA_LAT = "extra_lat";
public static final String EXTRA_LNG = "extra_lng";
public static final String EXTRA_IMAGE_URL = "extra_image_url";
// 사진 주소가 있으면 Glide 가 내려받아 ImageView 에 넣어 준다
ImageView imageView = findViewById(R.id.image_place);
String imageUrl = getIntent().getStringExtra(EXTRA_IMAGE_URL);
if (imageUrl != null) {
Glide.with(this).load(imageUrl).into(imageView);
}
import android.widget.ImageView; 와 import com.bumptech.glide.Glide; 도 함께 추가한다.
실행 결과

검색 결과에서 경복궁을 탭하면, 3장에서 회색으로 비워 두었던 사진 자리에 진짜 경복궁 사진이 나타난다.
[설명]
Glide.with(this).load(주소).into(뷰)— "이 화면에서, 이 주소의 사진을, 이 자리에 넣어라." 내려받기·압축 해제·화면 크기 맞춤을 Glide 가 알아서 한다.- 사진이 없는 장소(
imageUrl == null)는 회색 배경이 그대로 남는다.null을 확인하지 않고load(null)하면 빈 이미지가 되므로 방어해 준다. - CH-7 에서 본 것처럼 사진 주소 끝에는 우리 API 키가 붙어 있다. 주소만 있으면 누구나 사진을 받을 수 있으므로, 이 주소를 밖에 공개하지 않도록 한다.
[self Test 1] - 검색 결과를 5개만 받아 오도록 SearchRequest 를 고쳐 실행해 보시오. 또, 주변 우선 반경 5km(5000)를 500m 로 줄이면 같은 검색어의 결과가 어떻게 달라지는지 비교해 보시오.
[self Test 2] - FieldMask 에 places.userRatingCount (평가 개수)를 추가하고, SearchResponse.ApiPlace 에 public Integer userRatingCount; 필드를 만든 뒤, toPlaces() 안에서 System.out.println(apiPlace.userRatingCount); 로 Logcat 에 찍어 값이 들어오는지 확인해 보시오.