7. 버킷리스트 완성하기¶
지금까지 만든 버킷맵은 "구경"은 완벽하게 할 수 있는 앱이었습니다. 진짜 지도가 뜨고, 진짜 장소가 검색되고, 사진까지 나옵니다. 하지만 [가보고 싶어요] 버튼을 누르면 아직 "아직 준비 중입니다"라는 토스트만 나옵니다. 앱을 껐다 켜면 아무것도 남지 않습니다.
이번 장에서 버킷맵의 심장을 만듭니다. 장소를 저장하고, 방문 도장을 찍고, 지도에 주황/초록 마커로 늘 표시하는 것 — 이 모든 것의 바탕에는 데이터베이스가 있습니다.
데이터가 사라지지 않게 하려면 — 데이터베이스¶
여러분 책상에 "자동 정리 서랍"이 하나 있다고 상상해 보십시오. 물건을 넣으면 서랍이 알아서 칸을 나눠 정리해 두고, 꺼낼 때는 "어제 넣은 것만 꺼내 줘", "파란색만 꺼내 줘"라고 조건만 말하면 됩니다. 그리고 이 서랍은 방의 불을 꺼도(앱을 꺼도) 내용물이 그대로 남아 있습니다.
앱에서 이 서랍 역할을 하는 것이 데이터베이스(database) 이다. 변수에 담긴 값은 앱이 종료되면 사라지지만, 데이터베이스에 저장한 값은 폰의 저장 공간에 파일로 남는다.
안드로이드 폰에는 SQLite 라는 작은 데이터베이스가 처음부터 내장되어 있다. 원래 SQLite 를 쓰려면 SQL 이라는 데이터베이스 전용 언어로 명령문을 직접 작성해야 한다. 우리가 사용할 Room 은 구글이 만든 도우미 라이브러리로, 이 SQL 명령문 대부분을 우리 대신 써 준다.
| 직접 SQL 을 쓸 때 (SQLite) | Room 을 쓸 때 |
|---|---|
CREATE TABLE saved_place (...) — 표 만들기 |
클래스에 @Entity 만 붙인다 |
INSERT INTO saved_place VALUES (...) — 한 줄 넣기 |
메서드에 @Insert 만 붙인다 |
SELECT * FROM saved_place WHERE ... — 골라 꺼내기 |
@Query("SELECT ...") 안에 문장만 적으면, 결과를 자바 객체로 바꾸는 일은 Room 이 한다 |
즉 실수하기 쉬운 부분(표 만들기, 값 넣기, 결과를 객체로 변환하기)은 Room 이 맡고, 우리는 "무엇을 저장할지"와 "무엇을 꺼낼지"만 자바 코드로 선언하면 된다.
Room 준비하기¶
먼저 Room 라이브러리를 프로젝트에 추가합니다.
[CH-1 아래 코드를 그대로 작성하고 실행하시오.]¶
app/build.gradle 의 dependencies 블록 맨 아래(6장에서 추가한 Glide 다음 줄)에 두 줄을 추가합니다.
// 로컬 데이터베이스 (7장)
implementation 'androidx.room:room-runtime:2.6.1'
annotationProcessor 'androidx.room:room-compiler:2.6.1'
실행 결과
편집기 위에 나타나는 Sync Now 를 누르면 잠시 후 BUILD SUCCESSFUL 이 표시된다.
[설명]
room-runtime은 앱이 실행될 때 실제로 동작하는 Room 본체이다.annotationProcessor는 처음 보는 종류이다 — 컴파일할 때@Entity,@Dao같은 어노테이션을 읽고, 그에 맞는 코드를 대신 써 주는 도구를 붙이는 선언이다. Room 이 SQL 을 대신 써 준다고 했는데, 그 일이 바로 이room-compiler가 컴파일 시점에 하는 일이다.
장소를 담는 그릇 — SavedPlace¶
서랍에 무엇을 넣을지부터 정합니다. 데이터베이스에서는 저장할 데이터의 모양을 표(테이블) 로 정의하는데, Room 에서는 클래스 하나가 곧 표 하나이다.
[CH-2 아래 코드를 그대로 작성하고 실행하시오.]¶
java/com.example.myapplication 에 SavedPlace 클래스를 새로 만듭니다.
package com.example.myapplication;
import androidx.annotation.NonNull;
import androidx.room.Entity;
import androidx.room.PrimaryKey;
/** 버킷리스트에 담긴 장소 하나 = saved_place 테이블의 한 줄. */
@Entity(tableName = "saved_place")
public class SavedPlace {
public static final String STATUS_WISH = "WISH"; // 가보고 싶은 곳
public static final String STATUS_VISITED = "VISITED"; // 다녀온 곳
@PrimaryKey
@NonNull
public String placeId = ""; // 장소 고유 번호 — 같은 곳을 두 번 담는 것을 막아 준다
public String name;
public String address;
public String rating;
public double lat;
public double lng;
public String imageUrl;
public String status; // WISH 또는 VISITED
public long savedAt; // 담은 시각
public long visitedAt; // 다녀온 시각 (0 이면 아직)
}
[설명]
@Entity(tableName = "saved_place")— 이 클래스가 데이터베이스의saved_place라는 표가 된다. 필드 하나가 표의 열(column) 하나이고,SavedPlace객체 하나가 표의 한 줄(row)이 된다.@PrimaryKey가 붙은placeId는 기본 키 — 표에서 각 줄을 구별하는 고유 번호이다. 6장에서 본 것처럼 Places API 는 장소마다 고유한 id 를 준다. 이것을 기본 키로 쓰면 같은 장소를 두 번 담을 수 없게 데이터베이스가 지켜 준다.- 상태는
"WISH"와"VISITED"딱 두 가지뿐이므로 문자열 상수로 정의했다."WISH"라고 직접 타이핑하다가"Wish"로 오타를 내면 버그가 되지만,SavedPlace.STATUS_WISH를 쓰면 오타가 나는 순간 컴파일 오류로 잡힌다. - 4장에서 만든
Place와 비슷해 보이지만 역할이 다르다 —Place는 검색 결과를 잠깐 담는 그릇이고,SavedPlace는 데이터베이스에 영원히 남는 그릇이다.status,savedAt,visitedAt처럼 "내가 어떻게 했는지"를 기억하는 필드는SavedPlace에만 있다.
서랍을 여닫는 손잡이 — DAO¶
표를 만들었으니, 이제 넣고 꺼내는 방법을 정의합니다. Room 에서는 이것을 DAO(Data Access Object, 데이터 접근 객체) 라는 인터페이스로 선언한다.
[CH-3 아래 코드를 그대로 작성하고 실행하시오.]¶
java/com.example.myapplication 에 SavedPlaceDao 인터페이스를 새로 만듭니다. (New > Java Class 에서 종류를 Interface 로 선택)
package com.example.myapplication;
import androidx.room.Dao;
import androidx.room.Delete;
import androidx.room.Insert;
import androidx.room.OnConflictStrategy;
import androidx.room.Query;
import java.util.List;
@Dao
public interface SavedPlaceDao {
// 같은 placeId 가 이미 있으면 덮어쓴다
@Insert(onConflict = OnConflictStrategy.REPLACE)
void insert(SavedPlace place);
@Query("SELECT * FROM saved_place ORDER BY savedAt DESC")
List<SavedPlace> findAll();
@Query("SELECT * FROM saved_place WHERE status = :status ORDER BY savedAt DESC")
List<SavedPlace> findByStatus(String status);
@Query("SELECT * FROM saved_place WHERE placeId = :placeId LIMIT 1")
SavedPlace findById(String placeId);
@Query("UPDATE saved_place SET status = :status, visitedAt = :visitedAt WHERE placeId = :placeId")
void updateStatus(String placeId, String status, long visitedAt);
@Delete
void delete(SavedPlace place);
}
[설명]
- 인터페이스에 선언만 하면 구현 코드는 Room(room-compiler)이 컴파일할 때 대신 만들어 준다. 우리가 메서드 몸통을 쓸 일이 없다.
insert의onConflict = REPLACE— 기본 키(placeId)가 같은 줄이 이미 있으면 새 값으로 덮어쓴다. 같은 장소를 다시 찜해도 목록에 두 줄이 생기지 않는 이유이다.findAll은 지도 마커용(전부),findByStatus는 내 장소 화면용(가볼 곳/다녀온 곳만)이다.:status처럼 콜론을 붙이면 메서드의 매개변수가 SQL 문장 안으로 들어간다.findById는 상세 화면이 "이 장소가 저장돼 있나? 상태는?"을 알아내는 데 쓴다. 없으면null이 돌아온다.updateStatus는 찜(WISH)을 방문 완료(VISITED)로 바꿀 때 — 방문 도장을 찍을 때 쓴다.ORDER BY savedAt DESC는 "담은 시각의 내림차순" — 최근에 담은 것이 목록 위에 온다.
서랍장 본체 — AppDatabase¶
마지막 부품은 표(SavedPlace)와 손잡이(SavedPlaceDao)를 하나로 묶는 데이터베이스 본체입니다.
[CH-4 아래 코드를 그대로 작성하고 실행하시오.]¶
java/com.example.myapplication 에 AppDatabase 추상 클래스를 새로 만듭니다.
package com.example.myapplication;
import android.content.Context;
import androidx.room.Database;
import androidx.room.Room;
import androidx.room.RoomDatabase;
@Database(entities = {SavedPlace.class}, version = 1, exportSchema = false)
public abstract class AppDatabase extends RoomDatabase {
private static AppDatabase instance;
public abstract SavedPlaceDao savedPlaceDao();
/** 앱 전체에서 데이터베이스를 딱 하나만 만들어 같이 쓴다. */
public static AppDatabase get(Context context) {
if (instance == null) {
instance = Room.databaseBuilder(context.getApplicationContext(),
AppDatabase.class, "bucketmap.db")
// 학습용 설정 — 실무 앱은 화면이 멈출 수 있어 금지 (8장 도전과제 참고)
.allowMainThreadQueries()
.build();
}
return instance;
}
}
[설명]
@Database(entities = {SavedPlace.class}, version = 1, ...)— 이 데이터베이스에 어떤 표들이 들어가는지 알려 준다. 표의 모양(필드)을 바꾸면version을 올려야 하는데, 그 이야기는 8장 도전과제에서 다시 만난다.get()은 데이터베이스를 딱 하나만 만들어 두고 모든 화면이 같이 쓰게 하는 장치이다.instance가 비어 있을 때(최초 1회)만 만들고, 그다음부터는 만들어 둔 것을 돌려준다. 화면마다 서랍장을 새로 사는 것이 아니라, 집에 있는 서랍장 하나를 온 가족이 같이 쓰는 것과 같다."bucketmap.db"는 폰 저장 공간에 실제로 만들어지는 데이터베이스 파일 이름이다.
allowMainThreadQueries — 학습용으로만 허용
화면을 그리는 메인 스레드에서 데이터베이스를 읽고 쓰면, 데이터가 많을 때 화면이 그대로 멈춥니다(안드로이드는 이것을 ANR — 앱이 응답하지 않음 — 이라고 부릅니다). 그래서 실무 앱에서는 이 설정이 금지이고, Room 도 이 설정이 없으면 아예 오류를 냅니다. 버킷맵의 데이터는 많아야 수십 건이라 학습용으로 허용했습니다. 제대로 하는 방법(별도 스레드에서 읽기)은 8장 도전과제 4에서 직접 만들어 봅니다.
새 문자열 등록하기¶
이제 화면에 보여 줄 문구들을 준비합니다. "아직 준비 중입니다"는 오늘로 은퇴입니다.
[CH-5 아래 코드를 그대로 작성하고 실행하시오.]¶
res/values/strings.xml 을 아래 내용으로 바꿉니다. 상태 문구 3개와 토스트 문구 3개가 새로 들어가고, 더 이상 쓰지 않는 toast_not_ready 줄은 지웁니다.
<resources>
<string name="app_name">버킷맵</string>
<string name="btn_find_place">장소 찾기</string>
<string name="btn_my_places">내 장소</string>
<string name="btn_wish">가보고 싶어요</string>
<string name="btn_visited">다녀왔어요</string>
<string name="btn_remove">빼기</string>
<string name="detail_photo_desc">장소 사진</string>
<string name="detail_no_rating">평점 없음</string>
<string name="search_hint">어디로 가고 싶나요?</string>
<string name="btn_search">검색</string>
<string name="chip_wish">가볼 곳</string>
<string name="chip_visited">다녀온 곳</string>
<string name="my_places_empty">아직 저장한 장소가 없습니다</string>
<string name="search_empty">검색 결과가 없습니다</string>
<string name="search_error">검색에 실패했습니다. 인터넷 연결을 확인해 주세요</string>
<string name="status_not_saved">아직 저장하지 않은 장소예요</string>
<string name="status_wish">가보고 싶은 곳으로 저장했어요 🧡</string>
<string name="status_visited">다녀온 곳이에요 ✅</string>
<string name="toast_wish_added">버킷리스트에 담았어요!</string>
<string name="toast_visited">방문 도장 꽝!</string>
<string name="toast_removed">목록에서 뺐어요</string>
</resources>
[설명]
toast_not_ready를 지우면, 그것을 쓰던 곳(상세 화면의 버튼 3개)에 잠시 빨간 오류가 표시된다 — 정상이다. 바로 다음 실습에서 그 코드를 진짜 기능으로 바꾼다.
상세 화면에 진짜 기능 넣기¶
드디어 [가보고 싶어요] 버튼이 일을 하게 만듭니다. 상세 화면의 규칙을 먼저 정리하면 이렇다.
| 이 장소의 상태 | 상태 문구 | 보이는 버튼 |
|---|---|---|
| 저장 안 됨 | 아직 저장하지 않은 장소예요 | [가보고 싶어요] |
| WISH (찜) | 가보고 싶은 곳으로 저장했어요 🧡 | [다녀왔어요] [빼기] |
| VISITED (방문) | 다녀온 곳이에요 ✅ | [빼기] |
[CH-6 아래 코드를 그대로 작성하고 실행하시오.]¶
PlaceDetailActivity.java 전체를 아래 코드로 바꿉니다.
package com.example.myapplication;
import android.os.Bundle;
import android.view.View;
import android.widget.Button;
import android.widget.ImageView;
import android.widget.TextView;
import android.widget.Toast;
import androidx.appcompat.app.AppCompatActivity;
import com.bumptech.glide.Glide;
public class PlaceDetailActivity extends AppCompatActivity {
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";
private SavedPlaceDao dao;
private String placeId;
private TextView statusText;
private Button wishButton;
private Button visitedButton;
private Button removeButton;
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setContentView(R.layout.activity_place_detail);
dao = AppDatabase.get(this).savedPlaceDao();
placeId = getIntent().getStringExtra(EXTRA_ID);
TextView nameText = findViewById(R.id.text_place_name);
TextView addressText = findViewById(R.id.text_place_address);
TextView ratingText = findViewById(R.id.text_place_rating);
statusText = findViewById(R.id.text_status);
nameText.setText(getIntent().getStringExtra(EXTRA_NAME));
addressText.setText(getIntent().getStringExtra(EXTRA_ADDRESS));
String rating = getIntent().getStringExtra(EXTRA_RATING);
if (rating == null) {
ratingText.setText(R.string.detail_no_rating);
} else {
ratingText.setText("⭐ " + rating);
}
// 사진 주소가 있으면 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);
}
wishButton = findViewById(R.id.btn_wish);
visitedButton = findViewById(R.id.btn_visited);
removeButton = findViewById(R.id.btn_remove);
wishButton.setOnClickListener(v -> {
SavedPlace place = new SavedPlace();
place.placeId = placeId;
place.name = getIntent().getStringExtra(EXTRA_NAME);
place.address = getIntent().getStringExtra(EXTRA_ADDRESS);
place.rating = getIntent().getStringExtra(EXTRA_RATING);
place.lat = getIntent().getDoubleExtra(EXTRA_LAT, 0);
place.lng = getIntent().getDoubleExtra(EXTRA_LNG, 0);
place.imageUrl = getIntent().getStringExtra(EXTRA_IMAGE_URL);
place.status = SavedPlace.STATUS_WISH;
place.savedAt = System.currentTimeMillis();
dao.insert(place);
Toast.makeText(this, R.string.toast_wish_added, Toast.LENGTH_SHORT).show();
refreshStatus();
});
visitedButton.setOnClickListener(v -> {
dao.updateStatus(placeId, SavedPlace.STATUS_VISITED, System.currentTimeMillis());
Toast.makeText(this, R.string.toast_visited, Toast.LENGTH_SHORT).show();
refreshStatus();
});
removeButton.setOnClickListener(v -> {
SavedPlace saved = dao.findById(placeId);
if (saved != null) {
dao.delete(saved);
}
Toast.makeText(this, R.string.toast_removed, Toast.LENGTH_SHORT).show();
refreshStatus();
});
}
@Override
protected void onResume() {
super.onResume();
// 화면에 돌아올 때마다 DB 를 다시 읽어 버튼 구성을 맞춘다
refreshStatus();
}
/** 저장 안 됨 / 가보고 싶음 / 다녀옴 — 세 가지 상태에 맞게 화면을 바꾼다. */
private void refreshStatus() {
SavedPlace saved = dao.findById(placeId);
if (saved == null) {
statusText.setText(R.string.status_not_saved);
wishButton.setVisibility(View.VISIBLE);
visitedButton.setVisibility(View.GONE);
removeButton.setVisibility(View.GONE);
} else if (SavedPlace.STATUS_WISH.equals(saved.status)) {
statusText.setText(R.string.status_wish);
wishButton.setVisibility(View.GONE);
visitedButton.setVisibility(View.VISIBLE);
removeButton.setVisibility(View.VISIBLE);
} else {
statusText.setText(R.string.status_visited);
wishButton.setVisibility(View.GONE);
visitedButton.setVisibility(View.GONE);
removeButton.setVisibility(View.VISIBLE);
}
}
}
실행 결과
앱을 실행해 [장소 찾기] → 검색 → 장소를 탭해 상세 화면을 열고, [가보고 싶어요] 를 눌러 봅니다.

버튼을 누르는 순간 상태 문구와 버튼 구성이 바뀌고 토스트가 뜬다. 화면을 나갔다가 다시 들어와도 상태가 그대로 남아 있다 — 데이터베이스에 저장되었기 때문이다.
[설명]
- 핵심은
refreshStatus()하나이다 — DB 에게 현재 상태를 물어보고(findById), 그 결과에 따라 화면을 세 갈래로 바꾼다. 버튼을 눌러 DB 를 바꾼 직후에도, 화면에 다시 돌아왔을 때(onResume)도 같은 메서드를 부르면 화면은 항상 DB 와 같은 모습이 된다. View.GONE은 뷰를 화면에서 숨기고 자리도 차지하지 않게 한다. 세 상태에서 각각 어떤 버튼이VISIBLE/GONE인지 위의 표와 코드를 대조해 보면 정확히 일치한다.- [가보고 싶어요] 를 누르면 인텐트 extra 로 받아 둔 값들로
SavedPlace객체를 조립해insert()한다. 2장에서 배운 extra 가 여기서 데이터베이스까지 이어지는 것이다. - [다녀왔어요] 는 줄을 새로 만들지 않고
updateStatus()로 기존 줄의 상태만 바꾼다. [빼기] 는findById()로 찾아서delete()한다.
지도에 버킷리스트 마커 그리기¶
이제 메인 지도가 버킷리스트를 보여 줄 차례입니다. 5장에서 연습으로 꽂았던 경복궁 마커를 걷어 내고, DB 에 저장된 장소 전부를 마커로 그립니다 — 찜은 주황, 다녀온 곳은 초록.
[CH-7 아래 코드를 그대로 작성하고 실행하시오.]¶
MainActivity.java 전체를 아래 코드로 바꿉니다.
package com.example.myapplication;
import android.content.Intent;
import android.os.Bundle;
import android.widget.Button;
import androidx.annotation.NonNull;
import androidx.appcompat.app.AppCompatActivity;
import androidx.core.content.ContextCompat;
import com.naver.maps.geometry.LatLng;
import com.naver.maps.map.CameraUpdate;
import com.naver.maps.map.LocationTrackingMode;
import com.naver.maps.map.MapFragment;
import com.naver.maps.map.NaverMap;
import com.naver.maps.map.NaverMapSdk;
import com.naver.maps.map.OnMapReadyCallback;
import com.naver.maps.map.overlay.Marker;
import com.naver.maps.map.util.FusedLocationSource;
import com.naver.maps.map.util.MarkerIcons;
import java.util.ArrayList;
import java.util.List;
public class MainActivity extends AppCompatActivity implements OnMapReadyCallback {
private static final int LOCATION_PERMISSION_REQUEST_CODE = 1000;
private NaverMap naverMap;
private FusedLocationSource locationSource;
private final List<Marker> markers = new ArrayList<>();
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
// 지도를 그리기 전에 네이버 클라우드에서 발급받은 키를 SDK 에 알려 준다
NaverMapSdk.getInstance(this).setClient(
new NaverMapSdk.NcpKeyClient(BuildConfig.NCP_KEY_ID));
setContentView(R.layout.activity_main);
Button findButton = findViewById(R.id.btn_find_place);
Button myPlacesButton = findViewById(R.id.btn_my_places);
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);
});
myPlacesButton.setOnClickListener(v -> {
Intent intent = new Intent(this, MyPlacesActivity.class);
startActivity(intent);
});
locationSource = new FusedLocationSource(this, LOCATION_PERMISSION_REQUEST_CODE);
MapFragment mapFragment = (MapFragment) getSupportFragmentManager()
.findFragmentById(R.id.map_fragment);
mapFragment.getMapAsync(this);
}
@Override
public void onMapReady(@NonNull NaverMap naverMap) {
this.naverMap = naverMap;
// 시작 위치: 서울 시청 (현위치 권한을 허용하면 내 위치로 이동한다)
naverMap.moveCamera(CameraUpdate.scrollTo(new LatLng(37.5665, 126.9780)));
naverMap.setLocationSource(locationSource);
naverMap.getUiSettings().setLocationButtonEnabled(true);
naverMap.setLocationTrackingMode(LocationTrackingMode.Follow);
refreshMarkers();
}
@Override
protected void onResume() {
super.onResume();
// 상세 화면에서 찜하거나 지우고 돌아왔을 수 있으니 마커를 다시 그린다
refreshMarkers();
}
/** DB 의 저장 장소를 전부 읽어 마커로 그린다 — 찜은 주황, 다녀온 곳은 초록. */
private void refreshMarkers() {
if (naverMap == null) {
return; // 아직 지도가 준비되기 전이면 onMapReady 에서 다시 불린다
}
for (Marker marker : markers) {
marker.setMap(null);
}
markers.clear();
List<SavedPlace> savedPlaces = AppDatabase.get(this).savedPlaceDao().findAll();
for (SavedPlace saved : savedPlaces) {
Marker marker = new Marker();
marker.setPosition(new LatLng(saved.lat, saved.lng));
marker.setCaptionText(saved.name);
marker.setIcon(MarkerIcons.BLACK);
if (SavedPlace.STATUS_WISH.equals(saved.status)) {
marker.setIconTintColor(ContextCompat.getColor(this, R.color.marker_wish));
} else {
marker.setIconTintColor(ContextCompat.getColor(this, R.color.marker_visited));
}
marker.setOnClickListener(overlay -> {
openDetail(saved);
return true;
});
marker.setMap(naverMap);
markers.add(marker);
}
}
private void openDetail(SavedPlace saved) {
Intent intent = new Intent(this, PlaceDetailActivity.class);
intent.putExtra(PlaceDetailActivity.EXTRA_ID, saved.placeId);
intent.putExtra(PlaceDetailActivity.EXTRA_NAME, saved.name);
intent.putExtra(PlaceDetailActivity.EXTRA_ADDRESS, saved.address);
intent.putExtra(PlaceDetailActivity.EXTRA_RATING, saved.rating);
intent.putExtra(PlaceDetailActivity.EXTRA_LAT, saved.lat);
intent.putExtra(PlaceDetailActivity.EXTRA_LNG, saved.lng);
intent.putExtra(PlaceDetailActivity.EXTRA_IMAGE_URL, saved.imageUrl);
startActivity(intent);
}
@Override
public void onRequestPermissionsResult(int requestCode,
@NonNull String[] permissions,
@NonNull int[] grantResults) {
if (locationSource.onRequestPermissionsResult(requestCode, permissions, grantResults)) {
if (!locationSource.isActivated()) {
// 권한을 거부하면 위치 추적만 끈다 — 지도는 그대로 쓸 수 있다
naverMap.setLocationTrackingMode(LocationTrackingMode.None);
}
return;
}
super.onRequestPermissionsResult(requestCode, permissions, grantResults);
}
}
실행 결과
방금 찜한 장소가 있다면, 상세 화면에서 뒤로 가기로 지도에 돌아오는 순간 주황 마커가 나타납니다.

이번에는 그 마커를 탭해 봅니다. 상세 화면이 열리면 [다녀왔어요] 를 눌러 방문 도장을 찍습니다.

다시 지도로 돌아오면 마커가 초록색으로 변해 있습니다.

[설명]
refreshMarkers()의 전략은 단순하다 — 기존 마커를 전부 지우고, DB 를 다시 읽어 전부 다시 그린다. 마커 몇 개를 어떻게 고칠지 고민하는 것보다, 항상 DB 그대로 다시 그리는 편이 코드도 짧고 틀릴 곳도 없다.markers리스트는 "지우기 위해" 마커들을 기억해 두는 명단이다.refreshMarkers()는 두 곳에서 불린다.onResume()— 다른 화면에 갔다가 무대에 다시 오를 때(2장의 연극 비유), 그리고onMapReady()— 앱을 켠 직후에는 onResume 시점에 지도가 아직 준비되지 않아naverMap == null로 한 번 건너뛰는데, 지도가 준비되는 순간 다시 부르는 것이다.- 마커 색은
MarkerIcons.BLACK이라는 틴트(물들이기) 가능한 기본 아이콘에setIconTintColor()로 3장에서 만들어 둔marker_wish(주황) /marker_visited(초록) 색을 입힌다. setOnClickListener에서return true는 "이 탭은 내가 처리했다"는 뜻이다 — false 를 돌려주면 탭이 지도에도 전달된다.- 마커를 탭하면
openDetail()이 DB 에서 읽은 SavedPlace 의 값들을 extra 에 담아 상세 화면을 연다. 검색 결과에서 열 때와 완전히 같은 방식이라, 상세 화면은 자신이 어디서 열렸는지 신경 쓸 필요가 없다.
내 장소 화면 완성하기¶
마지막 화면입니다. 4장에서 칩만 만들어 둔 내 장소 화면에 진짜 목록을 채웁니다. 목록을 그리는 어댑터부터 만듭니다.
[CH-8 아래 코드를 그대로 작성하고 실행하시오.]¶
java/com.example.myapplication 에 SavedPlaceAdapter 클래스를 새로 만듭니다.
package com.example.myapplication;
import android.view.LayoutInflater;
import android.view.View;
import android.view.ViewGroup;
import android.widget.TextView;
import androidx.annotation.NonNull;
import androidx.recyclerview.widget.RecyclerView;
import java.util.List;
public class SavedPlaceAdapter extends RecyclerView.Adapter<SavedPlaceAdapter.SavedViewHolder> {
public interface OnSavedPlaceClickListener {
void onSavedPlaceClick(SavedPlace place);
}
private final List<SavedPlace> places;
private final OnSavedPlaceClickListener listener;
public SavedPlaceAdapter(List<SavedPlace> places, OnSavedPlaceClickListener listener) {
this.places = places;
this.listener = listener;
}
public void update(List<SavedPlace> newPlaces) {
places.clear();
places.addAll(newPlaces);
notifyDataSetChanged();
}
@NonNull
@Override
public SavedViewHolder onCreateViewHolder(@NonNull ViewGroup parent, int viewType) {
View itemView = LayoutInflater.from(parent.getContext())
.inflate(R.layout.item_place, parent, false);
return new SavedViewHolder(itemView);
}
@Override
public void onBindViewHolder(@NonNull SavedViewHolder holder, int position) {
SavedPlace place = places.get(position);
holder.nameText.setText(place.name);
holder.addressText.setText(place.address);
if (place.rating == null) {
holder.ratingText.setText(R.string.detail_no_rating);
} else {
holder.ratingText.setText("⭐ " + place.rating);
}
holder.itemView.setOnClickListener(v -> listener.onSavedPlaceClick(place));
}
@Override
public int getItemCount() {
return places.size();
}
static class SavedViewHolder extends RecyclerView.ViewHolder {
TextView nameText;
TextView addressText;
TextView ratingText;
SavedViewHolder(View itemView) {
super(itemView);
nameText = itemView.findViewById(R.id.item_name);
addressText = itemView.findViewById(R.id.item_address);
ratingText = itemView.findViewById(R.id.item_rating);
}
}
}
[설명]
- 4장의
SearchResultAdapter와 거의 같다 — 다루는 데이터가Place에서SavedPlace로 바뀌었을 뿐이다. 어댑터를 한 번 더 손으로 써 보는 것이 곧 RecyclerView 복습이다. - 목록 한 줄의 레이아웃은 4장에서 만든
item_place.xml을 그대로 재사용한다. 화면 부품을 잘 만들어 두면 이렇게 여러 화면에서 다시 쓸 수 있다.
[CH-9 아래 코드를 그대로 작성하고 실행하시오.]¶
MyPlacesActivity.java 전체를 아래 코드로 바꿉니다.
package com.example.myapplication;
import android.content.Intent;
import android.os.Bundle;
import android.view.View;
import android.widget.TextView;
import androidx.appcompat.app.AppCompatActivity;
import androidx.recyclerview.widget.LinearLayoutManager;
import androidx.recyclerview.widget.RecyclerView;
import com.google.android.material.chip.ChipGroup;
import java.util.ArrayList;
import java.util.List;
public class MyPlacesActivity extends AppCompatActivity {
private SavedPlaceAdapter adapter;
private ChipGroup chipGroup;
private TextView emptyText;
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setContentView(R.layout.activity_my_places);
chipGroup = findViewById(R.id.chip_group_filter);
emptyText = findViewById(R.id.text_empty);
RecyclerView myPlacesList = findViewById(R.id.list_my_places);
myPlacesList.setLayoutManager(new LinearLayoutManager(this));
adapter = new SavedPlaceAdapter(new ArrayList<>(), place -> {
Intent intent = new Intent(this, PlaceDetailActivity.class);
intent.putExtra(PlaceDetailActivity.EXTRA_ID, place.placeId);
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);
});
myPlacesList.setAdapter(adapter);
// 칩을 바꿔 누를 때마다 목록을 다시 읽는다
chipGroup.setOnCheckedStateChangeListener((group, checkedIds) -> refreshList());
}
@Override
protected void onResume() {
super.onResume();
refreshList();
}
private void refreshList() {
String status = (chipGroup.getCheckedChipId() == R.id.chip_visited)
? SavedPlace.STATUS_VISITED
: SavedPlace.STATUS_WISH;
List<SavedPlace> places = AppDatabase.get(this).savedPlaceDao().findByStatus(status);
emptyText.setVisibility(places.isEmpty() ? View.VISIBLE : View.GONE);
adapter.update(places);
}
}
실행 결과
검색으로 장소를 두세 곳 더 찜해 두고 확인해 봅니다. 지도에는 찜(주황)과 방문(초록) 마커가 함께 표시됩니다.

[내 장소] 화면에서 [가볼 곳] 칩을 선택하면 찜한 장소들이 나옵니다.

[다녀온 곳] 칩으로 바꾸면 방문 도장을 찍은 장소만 나옵니다.

[설명]
refreshList()는 지금 선택된 칩을 확인해(getCheckedChipId)findByStatus()에 넘길 상태 문자열을 정하고, 결과 목록을 어댑터에 넣는다. 칩 = 조건, DAO = 조건대로 꺼내 주는 서랍 손잡이 — 이 장 첫 비유 그대로이다.- 목록이 비어 있으면
text_empty("아직 저장한 장소가 없습니다")를 보여 주고, 있으면 숨긴다. 빈 화면에 아무것도 없으면 사용자는 고장으로 오해한다. - 여기서도 갱신은 전부
onResume()— 목록에서 상세로 갔다가 [빼기] 를 하고 돌아와도, 목록이 스스로 최신 상태로 돌아온다.
정리¶
버킷맵이 완성되었습니다. 이 장에서 배운 것을 한 줄씩 정리하면 이렇다.
- 데이터베이스는 앱을 꺼도 남는 저장소이고, 안드로이드에는 SQLite 가 내장되어 있다.
- Room 은
@Entity(표),@Dao(넣고 꺼내는 방법),@Database(본체) 세 부품으로 SQL 을 대신 써 준다. - 화면 갱신의 규칙은 하나 — DB 가 진실이고, 화면은 onResume 마다 DB 를 다시 읽어 따라간다.
[self Test 1] - 다녀온 곳을 다시 "가볼 곳"으로 되돌리는 [다시 가볼래요] 버튼을 만들어 보시오.
- 상세 화면 레이아웃에 버튼을 하나 추가하고, VISITED 상태일 때만 보이게 한다.
- (힌트) 이미 있는
updateStatus()를 그대로 재사용할 수 있다. 상태는STATUS_WISH로, 방문 시각은 0 으로 되돌리면 된다.
[self Test 2] - 내 장소 목록을 "최근에 담은 순"이 아니라 "이름 가나다순"으로 바꿔 보시오.
- (힌트) 고칠 곳은
SavedPlaceDao의 SQL 문장 한 곳뿐이다.ORDER BY뒤를 바꾼다.