Android 관광 앱 즐겨찾기 · 최근 본 장소 로컬 저장소 분리 구현 (Room, DataStore 운영 팁)
관광 앱 서비스를 개발하고 운영하다 보면, 사용자가 마음에 드는 관광지를 저장하는 '즐겨찾기'와 방금 본 곳을 다시 찾는 '최근 본 장소' 기능은 거의 필수적으로 들어가게 됩니다.
저의 경우도 처음에는 구현이 만만해 보여서 SharedPreferences에 ID 목록 몇 개를 대충 밀어 넣는 방식으로 시작했습니다. 하지만 서비스를 이래저래 운영하다 보니 데이터가 꼬이거나 로컬 용량이 무제한으로 늘어나는 등 여러 한계에 부딪히게 되더군요.
자꾸 까먹기도 하고, 다음에 비슷한 기능을 설계할 때 바로 보고 따라 하려고 실무 관점에서 깔끔하게 정리해 둡니다.
운영하면서 겪기 쉬운 문제들
아무 생각 없이 그냥 설계하면 나중에 꼭 아래와 같은 버그나 운영상 이슈가 터집니다.
데이터 성격을 구분 안 함: 즐겨찾기(명시적 저장)와 최근 본 장소(자동 흔적)를 같은 테이블이나 방식으로 저장해서 삭제 정책이 꼬입니다.
원본 데이터 복사 저장: 장소 이름, 주소, 이미지 URL까지 로컬 DB에 통째로 복사해 두었다가, 나중에 공공데이터 원본이 갱신되어도 로컬에는 옛날 정보가 그대로 남아있는 현상이 생깁니다.
무제한 데이터 누적: 최근 본 장소의 개수 제한을 안 걸어두면 사용자 기기에 데이터가 끝도 없이 쌓여 앱이 무거워집니다.
개인정보 및 로그아웃 이슈: 로그아웃이나 회원 탈퇴를 했는데도 로컬에 사용자 흔적이 그대로 남아있거나, 위치 좌표나 방문 기록을 아무 고지 없이 저장해 보안 검수에 걸리기도 합니다.
핵심은 사용자의 행위 데이터(ID, 시간)와 공공데이터 원본을 철저히 분리하는 것입니다.
구조 잡기: Room과 DataStore 역할 분담
둘 중 하나만 쓰려고 고집할 필요가 없습니다. 데이터의 형태에 따라 나누는 것이 훨씬 깔끔합니다.
Room (로컬 DB): 즐겨찾기 목록, 최근 본 장소 목록처럼 여러 항목을 저장하고 정렬, 검색, 개수 제한(Limit) 등이 필요한 구조화된 리스트 데이터에 사용합니다.
DataStore: 마지막으로 선택한 지역, 기본 정렬 기준, 지도 앱 기본 선택값 같은 단순 Key-Value 형태의 설정값이나 앱의 상태 저장에 적합합니다.
데이터 모델 및 DAO 구현 예시
실제 운영 환경을 고려해 사용자 ID나 실제 좌표 등을 걷어낸 핵심 구조입니다. 즐겨찾기와 최근 본 장소 테이블을 확실하게 분리합니다.
@Entity(tableName = "favorite_places")
data class FavoritePlaceEntity(
@PrimaryKey val placeId: String,
val savedAt: Long, // 저장된 시간 기준 정렬용
)
@Entity(tableName = "recent_places")
data class RecentPlaceEntity(
@PrimaryKey val placeId: String,
val viewedAt: Long, // 최근 본 시간 갱신용
)
@Dao
interface TourUserPlaceDao {
// --- 즐겨찾기 관련 ---
@Query("SELECT * FROM favorite_places ORDER BY savedAt DESC")
suspend fun getFavorites(): List<FavoritePlaceEntity>
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun saveFavorite(entity: FavoritePlaceEntity)
@Query("DELETE FROM favorite_places WHERE placeId = :placeId")
suspend fun deleteFavorite(placeId: String)
// --- 최근 본 장소 관련 ---
@Query("SELECT * FROM recent_places ORDER BY viewedAt DESC LIMIT :limit")
suspend fun getRecentPlaces(limit: Int): List<RecentPlaceEntity>
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun upsertRecent(entity: RecentPlaceEntity)
// 오래된 기록을 잘라내어 무제한 누적 방지
@Query("DELETE FROM recent_places WHERE placeId NOT IN (SELECT placeId FROM recent_places ORDER BY viewedAt DESC LIMIT :limit)")
suspend fun trimRecentPlaces(limit: Int)
@Query("DELETE FROM recent_places")
suspend fun clearRecentPlaces()
}
실무 팁:
trimRecentPlaces같은 쿼리를 통해 최근 본 장소의 개수를 주기적으로 제한해 줘야 로컬 DB가 비대해지는 것을 막을 수 있습니다. 보통 20개~50개 정도로 제한을 둡니다.
원본 데이터와 사용자 데이터 조합하기
즐겨찾기 테이블에는 진짜 최소한의 데이터(placeId, savedAt)만 남깁니다. 화면에 보여줄 장소 이름, 주소, 썸네일 이미지는 공공데이터 로컬 DB나 서버 API에서 그때그때 ID로 매핑해서 가져오는 구조가 안전합니다.
class FavoritePlaceRepository(
private val userPlaceDao: TourUserPlaceDao,
private val placeDataSource: TourPlaceDataSource,
) {
suspend fun getFavoritePlaces(): List<TourPlaceUiModel> {
// 1. 로컬에서 즐겨찾기 ID 목록을 먼저 가져옴
val favorites = userPlaceDao.getFavorites()
val placeIds = favorites.map { it.placeId }
// 2. 데이터 소스(서버 또는 공공 DB)에서 최신 장소 정보를 가져와 Map으로 변환
val places = placeDataSource.findPlacesByIds(placeIds)
.associateBy { it.id }
// 3. 즐겨찾기 순서에 맞춰 최신 데이터 조합
return favorites.mapNotNull { favorite ->
places[favorite.placeId]?.toUiModel(savedAt = favorite.savedAt)
}
}
suspend fun toggleFavorite(placeId: String, isFavorite: Boolean) {
if (isFavorite) {
userPlaceDao.deleteFavorite(placeId)
} else {
userPlaceDao.saveFavorite(
FavoritePlaceEntity(
placeId = placeId,
savedAt = System.currentTimeMillis(),
),
)
}
}
}
이렇게 짜두면 장소 정보(이름 변경, 이미지 교체 등)나 저작권 출처 표기 정책이 바뀌어도, 즐겨찾기 화면에서 항상 최신 정보를 띄워줄 수 있어서 유지보수가 정말 편해집니다.
개인정보 보안 및 운영 기준 체크포인트
관광 앱이라도 사용자가 조회한 장소 목록은 개인의 민감한 관심사가 될 수 있습니다. 특히 구현할 때 아래 기준들을 신경 써야 나중에 보안 검수나 개인정보 처리방침 이슈로 고생 안 합니다.
최근 본 장소 저장 시점: 상세 화면에 진입하자마자 찍기보다는, 화면 로딩이 완료되어 실제 콘텐츠가 유저에게 노출되었을 때 기록하는 것이 좋습니다.
민감 카테고리 필터링: 관광지 외에 병원, 종교, 상담 관련 장소 등이 앱 내에 포함되어 있다면, 해당 카테고리는 최근 본 장소 자동 저장에서 제외하는 처리를 고민해봐야 합니다.
삭제/초기화 UX 제공: 최근 본 장소는 사용자가 원할 때 언제든 지울 수 있도록 '전체 삭제' 버튼을 반드시 제공해야 합니다.
로그아웃 및 탈퇴 대응: 비로그인 상태나 공용 기기 사용 환경을 고려하여, 로그아웃 시 로컬에 저장된 최근 본 장소와 검색 기록은 날려주는 정책이 안전합니다.
개발 완료 전 체크리스트
[ ] 즐겨찾기와 최근 본 장소를 별도 테이블로 완전히 분리했는가?
[ ] 로컬 DB에 장소 원본 데이터를 중복으로 복사 저장하지 않고
placeId중심으로 관리하는가?[ ] 최근 본 장소의 최대 저장 개수 제한(
LIMIT) 처리가 되어있는가?[ ] 최근 본 장소 전체 삭제 기능을 화면에 구현했는가?
[ ] 구조화된 목록은 Room을 쓰고, 단순 설정값은 DataStore를 썼는가?
[ ] 내부 개발 로그나 에러 로그에 사용자 ID나 실제 좌표 정보가 무방비하게 찍히지 않는가?
[ ] (서버 동기화 시) 로컬 데이터와 서버 데이터 간의 동기화 및 충돌 처리 기준이 명확한가?
마치며
처음에는 단순한 편의 기능처럼 보이지만, 파고들면 사용자 데이터 관리와 개인정보 보호 정책이 엮여 있는 핵심 기능입니다. 초기에 구조를 잘못 잡아두면 나중에 앱 업데이트할 때 DB 마이그레이션 하느라 골치가 아파집니다.
리스트 데이터는 Room으로 쪼개고 설정값은 DataStore로 명확하게 분리해서 구현하는 것을 추천합니다.
댓글
댓글 쓰기