안드로이드 관광 앱 오프라인 캐시 및 WorkManager 동기화 구현 방법
- 공유 링크 만들기
- X
- 이메일
- 기타 앱
제주도에서 앱을 만들고 운영하다 보면, 중산간 지역이나 해안도로, 또는 사람이 갑자기 몰리는 축제장이나 지하 주차장 같은 곳에서 네트워크가 툭툭 끊긴다는 피드백을 자주 받습니다.
서버 API만 잘 연결해 두면 개발할 때는 아무 문제 없어 보이지만, 실제 현장에서는 네트워크 오류가 예외 상황이 아니라 아주 흔하게 일어나는 일상입니다. 그렇다고 모든 데이터를 무조건 로컬에 다 저장해 두면, 오래된 데이터(종료된 축제나 바뀐 영업시간 등) 때문에 사용자에게 잘못된 안내를 하게 되어 또 문제가 터집니다.
나중에 제가 다시 보면서 바로 적용하려고, Room 캐시와 WorkManager 백그라운드 동기화 구조를 어떻게 잡으면 되는지 실무 기준으로 정리해 둡니다.
내가 운영하며 겪은 주요 문제들
보통 오프라인 캐시 구조를 대충 잡으면 아래와 같은 상황에서 앱이 먹통이 되거나 컴플레인이 들어옵니다.
네트워크 좀 안 터진다고 관광지 목록 전체가 빈 화면으로 나오는 현상
장소 이름 같은 기본 정보와 실시간 혼잡도·운영 정보를 같은 만료 기간(TTL)으로 묶어서 저장해 버리는 실수
오래된 행사 일정이 캐시에 남아 있어서 이미 끝난 축제가 계속 노출되는 문제
사용자가 지금 보는 데이터가 언제 기준 데이터인지 알 수 없는 답답함
앱 켤 때마다 모든 데이터를 새로 동기화하느라 초기 진입이 엄청 느려지는 현상
오프라인 캐시는 단순히 "화면을 빠르게 보여주는 기능"이 아니라, "네트워크가 실패해도 앱의 최소 기능은 유지하게 만드는 설계"로 접근해야 운영할 때 고생을 안 합니다.
1. 데이터 성격별 캐시 기준 나누기
가장 먼저 데이터의 성격에 따라 캐시를 할지 말지, 갱신은 언제 할지 기준부터 나눠야 합니다. 전부 똑같은 주기로 갱신하면 데이터 신뢰도가 떨어집니다.
| 데이터 종류 | 예시 필드 | 캐시 적합도 | 갱신 및 만료 기준 |
| 장소 기본 정보 | 이름, 주소, 좌표, 카테고리 | 높음 | 앱 버전 업데이트 또는 주 단위 동기화 |
| 즐겨찾기 | 사용자가 저장한 장소 | 높음 | 로컬 우선 저장 후 로그인 시 서버 동기화 |
| 이미지 썸네일 | 대표 이미지 URL | 중간 | 이미지 갱신일(updatedAt) 기준 관리 |
| 운영 시간 | 영업시간, 휴무일 정보 | 중간 | 서버 최신값 우선, 실패 시 캐시 노출 + 안내 문구 |
| 행사 일정 | 축제 기간, 공연 시간 | 낮음~중간 | 날짜 지나면 자동 만료되도록 필수 설정 |
| 공지 및 배너 | 긴급 안내, 점검 공지 | 낮음 | 캐시하지 않거나 아주 짧은 시간만 허용 |
| 위치 기반 추천 | 내 주변 맛집/관광지 | 낮음 | 현재 위치와 네트워크 상태에 의존 (캐시 비적합) |
2. Room 캐시 모델 설계 (기본 정보와 메타 정보 분리)
장소 기본 정보와 동기화 관련 메타 정보는 테이블을 분리해서 관리하는 것이 운영할 때 훨씬 깔끔합니다.
@Entity(tableName = "tour_places")
data class CachedTourPlaceEntity(
@PrimaryKey val placeId: String,
val name: String,
val categoryCode: String,
val regionCode: String,
val address: String,
val latitude: Double?,
val longitude: Double?,
val thumbnailUrl: String?,
val sourceName: String,
val sourceUpdatedAt: String?, // 원본 데이터의 최종 수정일
val cachedAt: Long // 로컬에 저장된 시간
)
@Entity(tableName = "sync_metadata")
data class SyncMetadataEntity(
@PrimaryKey val key: String, // 예: "tour_place_sync"
val lastSyncedAt: Long,
val sourceVersion: String?
)
팁: 이렇게 메타 정보 테이블을 따로 파두면, 나중에 UI에 "마지막 업데이트 시간"을 띄워주기도 편하고 캐시 만료나 데이터 버전을 통째로 교체할 때 쿼리 날리기가 아주 수월해집니다.
3. Repository 구현 (네트워크 실패 시 Cache Fallback)
핵심은 서버 요청이 실패했을 때 에러 팝업을 띄우거나 빈 화면을 보여주는 게 아니라, 로컬에 저장된 캐시 데이터를 꺼내와서 데이터가 있는 것처럼 자연스럽게 보여주는 것입니다.
class TourPlaceRepository(
private val localDataSource: TourPlaceLocalDataSource,
private val remoteDataSource: TourPlaceRemoteDataSource
) {
suspend fun getPlaces(regionCode: String?): TourPlaceResult {
// 네트워크 터질 걸 대비해서 일단 로컬 캐시부터 확보
val cachedPlaces = localDataSource.getPlaces(regionCode)
return runCatching {
// 서버에서 최신 데이터 가져오기 시도
val remotePlaces = remoteDataSource.fetchPlaces(regionCode)
// 기존 캐시 날리고 새로 덮어쓰기
localDataSource.replacePlaces(remotePlaces)
TourPlaceResult(
places = remotePlaces.map { it.toUiModel(isStale = false) },
source = DataSourceType.REMOTE,
lastUpdatedAt = System.currentTimeMillis()
)
}.getOrElse {
// 서버 요청 실패하면 아쉬운 대로 캐시 데이터 반환 (isStale = true 표시)
TourPlaceResult(
places = cachedPlaces.map { it.toUiModel(isStale = true) },
source = DataSourceType.CACHE,
lastUpdatedAt = cachedPlaces.maxOfOrNull { it.cachedAt }
)
}
}
}
data class TourPlaceResult(
val places: List<TourPlaceUiModel>,
val source: DataSourceType,
val lastUpdatedAt: Long?
)
enum class DataSourceType { REMOTE, CACHE }
위 코드는 Remote 우선(실패 시 캐시) 구조입니다. 다만 화면에 따라 접근 방식을 다르게 가져가야 합니다.
Cache-first 추천: 관광지 메인 목록, 즐겨찾기, 오프라인 지도 화면 (일단 빠르게 띄워주는 게 중요한 화면)
Remote-first 추천: 행사 일정, 긴급 공지, 실시간 운영 상태 (무조건 최신 정보가 중요한 화면)
4. WorkManager로 백그라운드 동기화 처리
사용자가 앱을 쓰고 있지 않거나, 굳이 로딩 바를 보며 기다릴 필요가 없는 대용량 데이터 갱신은 WorkManager를 사용해서 백그라운드로 돌려버립니다. 네트워크가 연결되었을 때만 돌도록 제약 조건을 걸어두는 게 핵심입니다.
class TourDataSyncWorker(
appContext: Context,
params: WorkerParameters
) : CoroutineWorker(appContext, params) {
override suspend fun doWork(): Result {
return runCatching {
// 백그라운드에서 최신 대용량 관광지 데이터를 받아와서 DB 갱신
// tourSyncRepository.syncAllAllData()
Result.success()
}.getOrElse {
// 실패 시 3번까지는 재시도 정책에 따라 다시 실행하도록 처리
if (runAttemptCount < 3) Result.retry() else Result.failure()
}
}
}
fun enqueueTourDataSync(context: Context) {
// 와이파이나 데이터가 연결되어 있을 때만 실행되도록 제약조건 설정
val constraints = Constraints.Builder()
.setRequiredNetworkType(NetworkType.CONNECTED)
.build()
val request = OneTimeWorkRequestBuilder<TourDataSyncWorker>()
.setConstraints(constraints)
.setBackoffCriteria(
BackoffPolicy.EXPONENTIAL, // 실패 시 재시도 간격을 점진적으로 늘림
30,
TimeUnit.SECONDS
)
.build()
// 대기열에 중복으로 쌓이지 않게 UNIQUE_WORK로 관리 (기존 작업이 있으면 KEEP)
WorkManager.getInstance(context).enqueueUniqueWork(
"tour-data-sync",
ExistingWorkPolicy.KEEP,
request
)
}
주의: 동기화 작업을 그냥 막 집어넣으면 배터리도 엄청 먹고 서버 비용도 터집니다. 반드시
ExistingWorkPolicy.KEEP이나REPLACE를 상황에 맞게 골라서 작업이 중복으로 실행되지 않게 묶어줘야 합니다.
5. 화면에 데이터 기준일 표시하기 (UX 처리)
데이터를 로컬 캐시에서 가져와서 보여줄 때는, 이게 실시간 데이터가 아니라는 점을 사용자가 인지할 수 있도록 UI에서 알려주는 게 좋습니다. 관광지 갔는데 문 닫혀 있으면 앱 신뢰도가 바로 깎이거든요.
일반 목록:
"마지막 업데이트: 2026.06.03"문구 노출네트워크 끊겼을 때:
"오프라인 상태입니다. 저장된 정보를 표시합니다."상단 바 띄우기오래된 운영 정보 캐시 노출 시:
"실시간 운영 정보 확인 불가. 방문 전 확인이 필요합니다."경고 표시행사 기간이 지났을 때: 캐시 데이터를 지우거나
"종료된 행사일 수 있습니다."뱃지 부착
6. 공공데이터 활용 시 라이선스 체크리스트
특히 한국관광공사 TourAPI 같은 공공데이터를 긁어와서 앱 내부에 로컬 DB로 박아두거나 캐싱할 때는 저작권 이용 조건을 반드시 확인해야 합니다. 데이터 자체는 오픈되어 있어도 이미지나 상세 설명문은 제3자 저작권이 걸려 있는 경우가 많습니다.
출처 표시: 오프라인 캐시 데이터를 보여줄 때도 원본 출처(예: 한국관광공사)가 화면에 나오는지 확인
상업적 이용 가능 여부: 인앱 광고(애드센스/애드몹)를 붙인 앱에서 이 데이터를 저장하고 보여줘도 되는지 확인
재배포 제한: 데이터 전체를 앱 에셋 파일(
assets/tour.db) 형태로 통째로 패키징해서 배포해도 되는 조건인지 확인 (가급적 첫 실행 시 서버에서 받아와 로컬 Room에 적재하는 방식을 권장)
주의할 점 및 요약
빈 화면 주지 않기: 네트워크 에러 났다고
catch블록에서emptyList()넘겨주지 마세요. 무조건 로컬 캐시 뒤져서 옛날 데이터라도 꺼내주는 버릇을 들여야 합니다.동기화 고유 정책 관리:
WorkManager쓸 때 작업 이름 중복되거나 무한 루프 돌지 않게 로그 찍어보며 검증해야 합니다.만료된 데이터 청소: 행사 일정 같은 건 날짜 지나면 로컬 DB에서 지워주는 쿼리(
DELETE FROM tour_places WHERE endDate < :today)를 동기화 시점에 주기적으로 실행해 줘야 디스크 용량이 낭비되지 않습니다.
- 공유 링크 만들기
- X
- 이메일
- 기타 앱
댓글
댓글 쓰기