Android 앱 오프라인 우선(Offline-first) 설계 — SQLite 캐시, Retrofit 동기화와 데이터 충돌 해결
Android 앱을 개발할 때 서버 API를 이용해 데이터를 조회하는 경우가 많습니다.
가장 간단한 구현 방식은 사용자가 화면을 열 때마다 Retrofit으로 데이터를 요청하고, 서버 응답을 RecyclerView에 표시하는 것입니다.
하지만 이 방식에는 문제가 있습니다.
인터넷이 끊기면 데이터를 조회할 수 없고, 서버 응답이 느리면 화면에 로딩 상태가 오래 표시됩니다.
특히 도감, 지도, 일정, 메모처럼 사용자가 반복적으로 조회하는 정보를 다루는 앱에서는 네트워크 연결 여부와 관계없이 기본 기능을 사용할 수 있어야 합니다.
이러한 문제를 해결하는 대표적인 방법이 오프라인 우선(Offline-first) 아키텍처입니다.
이번 글에서는 SQLite를 직접 사용하는 Kotlin Android 앱에서 오프라인 우선 구조를 구현하는 방법을 알아보겠습니다.
1. 오프라인 우선이란?
오프라인 우선은 앱의 핵심 기능을 가능한 한 로컬 데이터만으로 제공하고, 네트워크는 데이터를 갱신하거나 서버와 동기화하는 데 사용하는 설계 방식입니다.
일반적인 온라인 중심 구조는 다음과 같습니다.
화면
↓
ViewModel
↓
Retrofit
↓
서버 API
↓
화면에 결과 표시이 구조에서는 서버 요청이 실패하면 화면에 표시할 데이터가 없을 수 있습니다.
반면 오프라인 우선 구조는 다음과 같습니다.
┌───────────────┐
│ Android UI │
└───────┬───────┘
│
▼
ViewModel
│
▼
Repository
│
┌──────────┴──────────┐
▼ ▼
SQLite DB Retrofit API
▲ │
└──── 동기화 ──────────┘핵심은 화면이 서버 응답을 직접 기다리지 않고 SQLite에 저장된 데이터를 먼저 표시한다는 것입니다.
서버에서 새로운 데이터를 가져오면 SQLite를 갱신하고, 변경된 데이터를 다시 화면에 반영합니다.
2. 오프라인 우선 구조의 장점과 단점
| 항목 | 장점 | 고려사항 |
|---|---|---|
| 화면 표시 | 저장된 데이터를 빠르게 표시 | 최초 데이터가 없을 수 있음 |
| 네트워크 장애 | 기존 데이터 조회 가능 | 최신 정보가 아닐 수 있음 |
| 서버 부하 | 반복 요청 감소 | 동기화 정책 필요 |
| 사용자 입력 | 오프라인 저장 가능 | 서버 충돌 처리 필요 |
| 데이터 사용량 | 불필요한 다운로드 감소 | 캐시 용량 관리 필요 |
| 앱 업데이트 | 데이터 유지에 유리 | DB 마이그레이션 필요 |
오프라인 우선이 모든 앱에 적합한 것은 아닙니다.
실시간 주식 시세처럼 최신성이 중요한 정보는 캐시만으로 제공하기 어렵습니다.
반면 생물도감, 여행 정보, 메모, 즐겨찾기처럼 상대적으로 변경이 적은 데이터에는 효과적입니다.
3. 데이터 종류부터 구분하기
오프라인 우선 구조를 설계하기 전에 데이터의 성격을 구분해야 합니다.
예를 들어 조류 정보 앱이라면 다음과 같이 나눌 수 있습니다.
| 데이터 | 저장 위치 | 동기화 정책 |
|---|---|---|
| 조류 기본 정보 | SQLite | 서버에서 갱신 |
| 조류 사진 URL | SQLite | 필요 시 갱신 |
| 이미지 파일 | Glide 캐시 | 요청 시 다운로드 |
| 사용자 메모 | SQLite | 필요 시 서버 동기화 |
| 좋아요 | SQLite | 필요 시 서버 동기화 |
| 앱 설정 | DataStore 등 | 대부분 로컬 관리 |
특히 서버에서 내려받는 기준 데이터와 사용자가 직접 작성한 데이터를 구분하는 것이 중요합니다.
서버 데이터 갱신 과정에서 사용자 메모나 좋아요가 삭제되면 안 되기 때문입니다.
4. SQLite 테이블 설계
이번 예제에서는 서버에서 내려받는 조류 정보와 사용자가 작성하는 메모를 별도로 저장하겠습니다.
CREATE TABLE birds (
id INTEGER PRIMARY KEY,
name_ko TEXT NOT NULL,
scientific_name TEXT,
server_updated_at TEXT
);사용자 메모 테이블은 다음과 같습니다.
CREATE TABLE bird_memos (
id INTEGER PRIMARY KEY AUTOINCREMENT,
bird_id INTEGER NOT NULL UNIQUE,
memo TEXT NOT NULL DEFAULT '',
is_like INTEGER NOT NULL DEFAULT 0,
updated_at TEXT NOT NULL,
sync_status TEXT NOT NULL DEFAULT 'SYNCED'
);sync_status는 서버 동기화 상태를 나타냅니다.
예를 들어 다음과 같은 값을 사용할 수 있습니다.
SYNCED: 서버와 동기화 완료PENDING: 서버에 반영해야 할 변경사항 존재FAILED: 마지막 동기화 시도 실패
단, 실제 서비스에서는 동기화 상태만으로 충돌을 완전히 해결할 수 없습니다.
서버 데이터 버전, 삭제 여부, 작업 순서 등을 추가로 관리해야 할 수 있습니다.
5. SQLiteOpenHelper 구현
Room을 사용하지 않고 SQLite를 직접 관리하는 프로젝트라면 SQLiteOpenHelper를 사용할 수 있습니다.
class AppDatabaseHelper(
context: Context
) : SQLiteOpenHelper(
context,
"app_data.db",
null,
1
) {
override fun onCreate(db: SQLiteDatabase) {
db.execSQL(
"""
CREATE TABLE birds (
id INTEGER PRIMARY KEY,
name_ko TEXT NOT NULL,
scientific_name TEXT,
server_updated_at TEXT
)
""".trimIndent()
)
db.execSQL(
"""
CREATE TABLE bird_memos (
id INTEGER PRIMARY KEY AUTOINCREMENT,
bird_id INTEGER NOT NULL UNIQUE,
memo TEXT NOT NULL DEFAULT '',
is_like INTEGER NOT NULL DEFAULT 0,
updated_at TEXT NOT NULL,
sync_status TEXT NOT NULL DEFAULT 'SYNCED'
)
""".trimIndent()
)
}
override fun onUpgrade(
db: SQLiteDatabase,
oldVersion: Int,
newVersion: Int
) {
// 실제 앱에서는 버전별 마이그레이션 구현
}
}운영 중인 앱에서는 데이터베이스 버전이 변경될 때 기존 데이터를 삭제하는 방식으로 처리하지 않는 것이 중요합니다.
특히 사용자 데이터가 포함된 데이터베이스는 반드시 마이그레이션 정책을 마련해야 합니다.
6. 서버 데이터 모델 정의
서버에서 다음과 같은 JSON을 반환한다고 가정하겠습니다.
[
{
"id": 1,
"name_ko": "참새",
"scientific_name": "Passer montanus",
"updated_at": "2026-10-30T10:00:00Z"
},
{
"id": 2,
"name_ko": "까치",
"scientific_name": "Pica serica",
"updated_at": "2026-10-30T10:05:00Z"
}
]Gson을 사용하는 프로젝트라면 다음과 같이 DTO를 작성할 수 있습니다.
data class BirdDto(
@SerializedName("id")
val id: Long,
@SerializedName("name_ko")
val nameKo: String,
@SerializedName("scientific_name")
val scientificName: String?,
@SerializedName("updated_at")
val updatedAt: String?
)서버에서 제공하는 날짜는 가능하면 시간대가 명확한 형식으로 관리하는 것이 좋습니다.
예제에서는 UTC를 나타내는 Z가 포함된 ISO 8601 형식을 사용했습니다.
7. Retrofit API 구성
Retrofit 인터페이스를 정의합니다.
interface BirdApi {
@GET("api/birds")
suspend fun getBirds(): List<BirdDto>
}Retrofit 인스턴스는 다음과 같이 구성할 수 있습니다.
val retrofit = Retrofit.Builder()
.baseUrl("https://api.example.com/")
.addConverterFactory(
GsonConverterFactory.create()
)
.build()
val birdApi = retrofit.create(
BirdApi::class.java
)api.example.com은 설명용 주소입니다.
실제 프로젝트에서는 운영 중인 API 주소를 사용해야 합니다.
또한 서버 요청에는 적절한 Timeout, 오류 처리, 인증 정책을 적용하는 것이 좋습니다.
8. SQLite에서 데이터를 먼저 조회하기
오프라인 우선 구조에서는 화면이 먼저 SQLite를 조회하도록 구성합니다.
data class Bird(
val id: Long,
val nameKo: String,
val scientificName: String?
)SQLite 조회 함수는 다음과 같이 작성할 수 있습니다.
fun getBirdsFromDb(
helper: AppDatabaseHelper
): List<Bird> {
val result = mutableListOf<Bird>()
helper.readableDatabase.rawQuery(
"""
SELECT
id,
name_ko,
scientific_name
FROM birds
ORDER BY name_ko
""".trimIndent(),
null
).use { cursor ->
while (cursor.moveToNext()) {
result.add(
Bird(
id = cursor.getLong(0),
nameKo = cursor.getString(1),
scientificName =
cursor.getString(2)
)
)
}
}
return result
}이 함수는 디스크 I/O를 수행하므로 메인 스레드에서 직접 실행하지 않아야 합니다.
Coroutine의 Dispatchers.IO를 사용하는 방식이 적합합니다.
9. 서버 데이터 SQLite에 저장하기
서버에서 받은 데이터를 SQLite에 저장하는 함수를 작성합니다.
fun saveBirds(
helper: AppDatabaseHelper,
birds: List<BirdDto>
) {
val db = helper.writableDatabase
db.beginTransaction()
try {
val statement = db.compileStatement(
"""
INSERT INTO birds (
id,
name_ko,
scientific_name,
server_updated_at
)
VALUES (?, ?, ?, ?)
ON CONFLICT(id) DO UPDATE SET
name_ko = excluded.name_ko,
scientific_name =
excluded.scientific_name,
server_updated_at =
excluded.server_updated_at
""".trimIndent()
)
for (bird in birds) {
statement.clearBindings()
statement.bindLong(
1,
bird.id
)
statement.bindString(
2,
bird.nameKo
)
if (bird.scientificName != null) {
statement.bindString(
3,
bird.scientificName
)
} else {
statement.bindNull(3)
}
if (bird.updatedAt != null) {
statement.bindString(
4,
bird.updatedAt
)
} else {
statement.bindNull(4)
}
statement.executeInsert()
}
db.setTransactionSuccessful()
} finally {
db.endTransaction()
}
}이 예제는 서버 데이터를 기존 데이터에 반영하는 기본적인 UPSERT 구조입니다.
다만 ON CONFLICT DO UPDATE 문법은 SQLite 3.24.0 이상에서 지원됩니다.
Android는 OS 버전에 따라 내장 SQLite 버전이 다르므로, minSdk 24처럼 오래된 Android 버전을 지원한다면 해당 문법을 그대로 사용할 수 있는지 확인해야 합니다.
호환성이 필요한 경우에는 기존 행 존재 여부를 확인한 뒤 UPDATE 또는 INSERT를 수행하거나, 데이터 보존 정책을 고려한 다른 방식으로 구현해야 합니다.
또한 서버에서 삭제된 항목은 이 코드만으로 로컬에서 삭제되지 않습니다.
삭제 동기화는 별도로 설계해야 합니다.
10. Repository에서 로컬 조회와 서버 동기화 분리하기
Repository는 로컬 데이터 조회와 서버 동기화를 담당하도록 구성할 수 있습니다.
class BirdRepository(
private val helper: AppDatabaseHelper,
private val api: BirdApi
) {
suspend fun getLocalBirds(): List<Bird> =
withContext(Dispatchers.IO) {
getBirdsFromDb(helper)
}
suspend fun refreshBirds() =
withContext(Dispatchers.IO) {
val remoteBirds =
api.getBirds()
saveBirds(
helper,
remoteBirds
)
}
}이 구조의 장점은 화면에서 네트워크 연결 여부를 직접 판단할 필요가 줄어든다는 것입니다.
화면은 SQLite 데이터를 표시하고, Repository는 서버 데이터를 갱신합니다.
11. ViewModel에서 로컬 데이터 먼저 표시하기
ViewModel에서는 다음과 같은 순서로 동작하도록 구성할 수 있습니다.
SQLite 데이터 조회
화면에 기존 데이터 표시
서버 동기화 시도
동기화 성공 시 SQLite 재조회
최신 데이터로 화면 갱신
class BirdViewModel(
private val repository: BirdRepository
) : ViewModel() {
private val _birds =
MutableStateFlow<List<Bird>>(
emptyList()
)
val birds = _birds.asStateFlow()
private val _refreshError =
MutableStateFlow(false)
val refreshError =
_refreshError.asStateFlow()
fun loadBirds() {
viewModelScope.launch {
_birds.value =
repository.getLocalBirds()
try {
repository.refreshBirds()
_birds.value =
repository.getLocalBirds()
_refreshError.value = false
} catch (e: CancellationException) {
throw e
} catch (e: Exception) {
_refreshError.value = true
}
}
}
}이 예제는 오프라인 우선의 기본 흐름을 설명하기 위한 구현입니다.
실제 서비스에서는 로딩 상태, 마지막 동기화 시각, 빈 데이터 상태, 오류 메시지 등을 함께 관리하는 것이 좋습니다.
또한 ViewModel이 새로 생성될 때마다 전체 데이터를 다운로드하는 것은 비효율적일 수 있으므로 갱신 주기를 설정해야 합니다.
12. 오프라인 상태에서도 화면 유지하기
오프라인 우선 구조에서 중요한 원칙은 서버 요청이 실패해도 기존 화면 데이터를 지우지 않는 것입니다.
예를 들어 다음과 같은 코드는 주의해야 합니다.
try {
val birds = api.getBirds()
adapter.submitList(birds)
} catch (e: Exception) {
adapter.submitList(emptyList())
}네트워크 오류가 발생하면 기존 목록까지 사라질 수 있습니다.
대신 로컬 데이터는 유지하고 동기화 오류만 별도로 표시하는 것이 좋습니다.
조류 검색
[참새]
[까치]
[직박구리]
마지막 동기화: 오전 10:30
현재 인터넷에 연결되지 않았습니다.
저장된 데이터를 표시하고 있습니다.사용자는 인터넷이 끊겨도 기존 데이터를 계속 조회할 수 있습니다.
13. 네트워크 연결 상태 감지하기
Android에서는 ConnectivityManager를 이용해 네트워크 상태를 확인할 수 있습니다.
먼저 Manifest에 다음 권한을 추가합니다.
<uses-permission
android:name="android.permission.ACCESS_NETWORK_STATE" />현재 활성 네트워크의 상태를 확인하는 예제입니다.
fun isNetworkAvailable(
context: Context
): Boolean {
val manager =
context.getSystemService(
ConnectivityManager::class.java
)
val network =
manager.activeNetwork
?: return false
val capabilities =
manager.getNetworkCapabilities(
network
) ?: return false
return capabilities.hasCapability(
NetworkCapabilities.NET_CAPABILITY_INTERNET
) &&
capabilities.hasCapability(
NetworkCapabilities.NET_CAPABILITY_VALIDATED
)
}NET_CAPABILITY_INTERNET은 네트워크가 인터넷 연결 기능을 갖고 있음을 나타냅니다.
NET_CAPABILITY_VALIDATED는 Android가 해당 네트워크의 인터넷 연결을 검증했음을 나타냅니다.
하지만 이 값이 있다고 해서 특정 서버 API 요청이 반드시 성공하는 것은 아닙니다.
따라서 네트워크 상태 확인은 참고 정보로 사용하고, 실제 Retrofit 요청의 실패도 반드시 처리해야 합니다.
14. 네트워크 상태 변경 감지하기
앱이 실행 중일 때 네트워크 연결이 복구되는 상황을 감지할 수도 있습니다.
val callback =
object : ConnectivityManager.NetworkCallback() {
override fun onAvailable(
network: Network
) {
// 네트워크 사용 가능
}
override fun onLost(
network: Network
) {
// 네트워크 연결 해제
}
}콜백 등록은 다음과 같이 할 수 있습니다.
connectivityManager.registerDefaultNetworkCallback(
callback
)더 이상 필요하지 않을 때는 반드시 해제해야 합니다.
connectivityManager.unregisterNetworkCallback(
callback
)다만 onAvailable()이 호출되었다고 해서 서버 연결이 즉시 가능하다고 단정해서는 안 됩니다.
네트워크가 변경되는 동안 실제 연결 가능 여부가 달라질 수 있습니다.
또한 네트워크가 복구될 때마다 전체 데이터를 다운로드하면 불필요한 요청이 발생할 수 있으므로 동기화 정책이 필요합니다.
15. WorkManager로 자동 동기화하기
앱이 백그라운드에 있을 때도 데이터를 갱신해야 한다면 WorkManager를 사용할 수 있습니다.
예를 들어 다음과 같은 조건을 설정할 수 있습니다.
네트워크가 연결되어 있을 때 실행
주기적으로 동기화 시도
실패 시 재시도
앱 프로세스가 종료되어도 작업 유지
WorkManager는 지연 가능한 백그라운드 작업에 적합합니다.
다만 정확한 시각에 실행되는 것은 보장하지 않습니다.
16. 동기화 Worker 구현하기
Coroutine을 사용하는 앱이라면 CoroutineWorker를 사용할 수 있습니다.
class BirdSyncWorker(
context: Context,
workerParams: WorkerParameters
) : CoroutineWorker(
context,
workerParams
) {
override suspend fun doWork(): Result {
return try {
val repository =
RepositoryProvider.get(
applicationContext
)
repository.refreshBirds()
Result.success()
} catch (e: CancellationException) {
throw e
} catch (e: IOException) {
Result.retry()
} catch (e: HttpException) {
if (e.code() == 429 ||
e.code() in 500..599
) {
Result.retry()
} else {
Result.failure()
}
} catch (e: Exception) {
Result.failure()
}
}
}RepositoryProvider는 예제에서 Repository를 가져오는 역할을 하는 프로젝트 전용 구성 요소입니다.
실제 프로젝트에서는 의존성 주입이나 Application 범위의 객체 관리 구조에 맞게 구현해야 합니다.
여기서는 네트워크 오류와 일부 일시적인 서버 오류만 재시도하도록 구성했습니다.
인증 오류나 잘못된 요청을 무조건 재시도하면 서버 부하만 증가할 수 있습니다.
17. 네트워크 조건 설정하기
WorkManager에 네트워크 조건을 설정할 수 있습니다.
val constraints =
Constraints.Builder()
.setRequiredNetworkType(
NetworkType.CONNECTED
)
.build()Worker 등록 시 조건을 적용합니다.
val request =
OneTimeWorkRequestBuilder<
BirdSyncWorker
>()
.setConstraints(
constraints
)
.setBackoffCriteria(
BackoffPolicy.EXPONENTIAL,
30,
TimeUnit.SECONDS
)
.build()
WorkManager.getInstance(
context
).enqueueUniqueWork(
"bird_sync",
ExistingWorkPolicy.KEEP,
request
)ExistingWorkPolicy.KEEP을 사용하면 동일한 이름의 미완료 작업이 있을 때 새 작업을 중복 등록하지 않을 수 있습니다.
단, 작업이 이미 완료된 이후에는 새 작업을 등록할 수 있습니다.
18. 주기적인 동기화 설정하기
주기적으로 데이터를 갱신하려면 PeriodicWorkRequest를 사용할 수 있습니다.
val request =
PeriodicWorkRequestBuilder<
BirdSyncWorker
>(
12,
TimeUnit.HOURS
)
.setConstraints(
constraints
)
.build()
WorkManager.getInstance(
context
).enqueueUniquePeriodicWork(
"bird_periodic_sync",
ExistingPeriodicWorkPolicy.KEEP,
request
)이 예제는 약 12시간 주기의 동기화 작업을 요청합니다.
WorkManager의 주기적 작업은 정확히 12시간마다 실행되는 것을 보장하지 않습니다.
기기의 배터리 상태, Doze, 시스템 스케줄링 등에 따라 실행 시점이 지연될 수 있습니다.
19. 전체 동기화와 증분 동기화
데이터가 적다면 서버의 전체 목록을 다운로드해도 큰 문제가 없을 수 있습니다.
하지만 데이터가 수천 건 이상이라면 매번 전체 데이터를 내려받는 방식은 비효율적입니다.
이때 증분 동기화를 고려할 수 있습니다.
전체 동기화
GET /api/birds모든 데이터를 다운로드합니다.
증분 동기화
GET /api/birds/changes?cursor=abc123마지막 동기화 이후 변경된 데이터만 다운로드합니다.
증분 동기화에서는 단순한 클라이언트 시각보다 서버가 발급한 동기화 커서를 사용하는 방식이 안전할 수 있습니다.
클라이언트와 서버의 시계 차이, 동일한 시각의 여러 변경사항, 페이지 처리 등의 문제를 줄일 수 있기 때문입니다.
20. 서버 동기화 응답 설계
Spring Boot 서버에서 다음과 같은 응답을 제공한다고 가정하겠습니다.
{
"items": [
{
"id": 1,
"name_ko": "참새",
"scientific_name": "Passer montanus"
}
],
"deleted_ids": [15, 27],
"next_cursor": "abc124",
"has_more": false
}각 필드의 의미는 다음과 같습니다.
| 필드 | 용도 |
|---|---|
items | 추가 또는 수정된 데이터 |
deleted_ids | 삭제된 데이터 ID |
next_cursor | 다음 동기화 기준 |
has_more | 추가 페이지 존재 여부 |
서버에서 삭제된 데이터도 클라이언트에 전달해야 합니다.
그렇지 않으면 서버에서 삭제된 항목이 SQLite에 계속 남을 수 있습니다.
21. 삭제된 데이터 처리하기
삭제 동기화는 단순한 DELETE보다 신중하게 처리해야 합니다.
예를 들어 사용자가 특정 조류에 메모를 작성했는데 서버에서 해당 조류 정보가 삭제되었다고 가정하겠습니다.
이때 조류 정보와 함께 사용자 메모까지 삭제하면 데이터 손실이 발생할 수 있습니다.
따라서 다음과 같은 정책을 고려할 수 있습니다.
기본 정보만 삭제하고 사용자 메모는 유지
삭제된 항목을 비활성 상태로 표시
메모가 존재하는 항목은 삭제 대신 보관
사용자에게 데이터 삭제 여부 안내
도감형 앱에서는 기본 정보가 삭제되더라도 사용자의 메모를 유지하는 정책이 적합할 수 있습니다.
22. 동기화 커서 저장하기
마지막 동기화 커서는 로컬에 저장해야 합니다.
예를 들어 다음과 같은 테이블을 사용할 수 있습니다.
CREATE TABLE sync_metadata (
sync_key TEXT PRIMARY KEY,
sync_value TEXT NOT NULL
);동기화 커서를 저장하는 예제입니다.
INSERT INTO sync_metadata (
sync_key,
sync_value
)
VALUES (
'birds_cursor',
'abc124'
)
ON CONFLICT(sync_key)
DO UPDATE SET
sync_value = excluded.sync_value;다만 앞서 설명했듯이 이 UPSERT 문법은 Android 기기의 SQLite 버전에 따라 호환성을 확인해야 합니다.
또한 데이터 변경사항 적용과 커서 저장은 같은 트랜잭션에서 처리하는 것이 중요합니다.
데이터 저장이 실패했는데 커서만 다음 값으로 변경되면 일부 변경사항을 영구적으로 놓칠 수 있기 때문입니다.
23. 사용자 입력을 오프라인에서 저장하기
오프라인 우선 구조에서는 사용자 입력도 먼저 SQLite에 저장할 수 있습니다.
예를 들어 사용자가 메모를 수정하면 다음과 같이 처리합니다.
사용자가 메모 수정
│
▼
SQLite에 즉시 저장
│
▼
sync_status = PENDING
│
▼
화면에 수정 내용 표시
│
▼
네트워크 연결 시 서버 동기화
│
▼
성공하면 SYNCED이 방식은 사용자가 네트워크 연결을 기다리지 않고 메모를 작성할 수 있다는 장점이 있습니다.
24. 메모 변경사항 저장하기
예를 들어 다음과 같은 SQL을 사용할 수 있습니다.
UPDATE bird_memos
SET
memo = ?,
updated_at = ?,
sync_status = 'PENDING'
WHERE bird_id = ?;메모가 없는 경우에는 새 행을 생성해야 합니다.
또한 실제 프로젝트에서는 SQLite 트랜잭션을 이용해 데이터 저장과 동기화 상태 변경을 일관되게 처리해야 합니다.
단순한 sync_status 방식은 구현하기 쉽지만 여러 변경사항의 순서를 보존하기에는 한계가 있습니다.
복잡한 동기화가 필요하다면 별도의 작업 큐를 사용하는 것이 좋습니다.
25. Outbox 패턴이란?
Outbox 패턴은 서버에 전송해야 할 작업을 로컬 데이터베이스에 별도로 기록하는 방식입니다.
예를 들어 다음과 같은 테이블을 만들 수 있습니다.
CREATE TABLE sync_outbox (
id INTEGER PRIMARY KEY AUTOINCREMENT,
entity_type TEXT NOT NULL,
entity_id INTEGER NOT NULL,
operation TEXT NOT NULL,
payload TEXT NOT NULL,
created_at TEXT NOT NULL,
retry_count INTEGER NOT NULL DEFAULT 0
);사용자가 메모를 수정하면 다음 두 작업을 하나의 트랜잭션으로 처리합니다.
bird_memos테이블 업데이트sync_outbox테이블에 전송 작업 추가
이렇게 하면 앱이 종료되더라도 서버에 전송해야 할 작업 기록을 유지할 수 있습니다.
26. Outbox 동기화 흐름
사용자 메모 수정
│
▼
SQLite 트랜잭션
│
├── bird_memos 수정
└── sync_outbox 추가
│
▼
WorkManager 실행
│
▼
서버 API 전송
│
├── 성공 → Outbox 삭제
│
└── 실패 → 재시도 대기서버 전송이 성공했지만 앱이 응답을 받기 전에 종료될 수 있습니다.
이 경우 동일한 작업을 다시 전송할 가능성이 있습니다.
따라서 서버에서는 요청별 고유 ID를 이용해 중복 처리를 방지하는 멱등성 설계를 고려해야 합니다.
27. 데이터 충돌은 왜 발생할까?
오프라인 우선 구조에서 가장 어려운 부분 중 하나는 데이터 충돌입니다.
예를 들어 사용자가 두 기기에서 동일한 메모를 수정했다고 가정하겠습니다.
기기 A
메모: 참새를 공원에서 관찰함
│
▼
오프라인 상태에서 수정
│
▼
메모: 참새 3마리를 관찰함
기기 B
메모: 참새를 오전에 관찰함
│
▼
서버에 먼저 저장이후 기기 A가 온라인으로 복귀하면 어느 데이터를 최종 값으로 사용할지 결정해야 합니다.
이것이 데이터 충돌 문제입니다.
28. 대표적인 충돌 해결 방식
| 방식 | 설명 | 주의사항 |
|---|---|---|
| Last Write Wins | 마지막 변경을 우선 | 이전 데이터 손실 가능 |
| Server Wins | 서버 데이터를 우선 | 로컬 변경 손실 가능 |
| Client Wins | 클라이언트 변경을 우선 | 서버 변경 덮어쓰기 가능 |
| Version Check | 버전 비교 후 충돌 감지 | 별도 처리 필요 |
| Manual Merge | 사용자에게 선택 요청 | UI 구현 복잡 |
개인 메모처럼 데이터 손실이 중요한 기능에서는 단순한 Last Write Wins 방식보다 버전 비교를 통한 충돌 감지를 고려하는 것이 좋습니다.
29. 서버 버전으로 충돌 감지하기
서버 데이터에 버전 번호를 추가할 수 있습니다.
{
"id": 1,
"memo": "참새를 관찰함",
"version": 5
}클라이언트가 메모를 수정할 때 기존 버전을 함께 전달합니다.
{
"memo": "참새 3마리를 관찰함",
"expected_version": 5
}서버의 현재 버전이 5라면 수정할 수 있습니다.
하지만 다른 기기에서 먼저 수정해 서버 버전이 6이 되었다면 충돌을 반환할 수 있습니다.
예를 들어 HTTP 409 Conflict 응답을 사용할 수 있습니다.
클라이언트는 충돌이 발생했을 때 로컬 메모를 보존하고 사용자에게 선택하도록 안내할 수 있습니다.
30. 동기화 실패 시 재시도 정책
모든 오류를 동일하게 재시도해서는 안 됩니다.
| 오류 | 권장 처리 |
|---|---|
| 네트워크 연결 실패 | 재시도 |
| 요청 시간 초과 | 재시도 가능 |
| HTTP 429 | 서버 정책에 따라 지연 후 재시도 |
| HTTP 500·503 | 재시도 가능 |
| HTTP 400 | 요청 데이터 확인 |
| HTTP 401 | 인증 갱신 또는 로그인 필요 |
| HTTP 403 | 권한 문제 확인 |
| HTTP 409 | 충돌 해결 |
| 데이터 파싱 오류 | 스키마 및 응답 확인 |
재시도에는 Exponential Backoff를 적용하는 것이 좋습니다.
예를 들어 다음과 같이 지연 시간이 증가하도록 설계할 수 있습니다.
1차 실패 → 30초 후
2차 실패 → 60초 후
3차 실패 → 120초 후
4차 실패 → 240초 후실제 실행 시점은 WorkManager와 시스템 스케줄링에 따라 달라질 수 있습니다.
또한 서버가 Retry-After 헤더를 제공한다면 해당 정책을 고려하는 것이 좋습니다.
31. 동기화 중 데이터 일관성 유지하기
서버에서 여러 페이지의 데이터를 내려받는 경우 중간에 오류가 발생할 수 있습니다.
예를 들어 다음과 같은 상황입니다.
1페이지 다운로드 성공
2페이지 다운로드 성공
3페이지 다운로드 실패이때 마지막 동기화 커서를 3페이지 이후 값으로 저장하면 누락이 발생할 수 있습니다.
따라서 다음 원칙을 적용하는 것이 좋습니다.
페이지 데이터 저장과 커서 갱신을 원자적으로 처리
실패한 페이지는 다시 요청 가능하도록 구성
동일한 데이터를 다시 받아도 중복되지 않도록 처리
서버가 안정적인 페이지 순서를 보장하도록 설계
삭제 정보도 동일한 동기화 기준에 포함
SQLite 트랜잭션과 서버 커서 정책을 함께 설계하는 것이 중요합니다.
32. 앱 시작 시 전체 동기화하지 않기
앱을 실행할 때마다 전체 데이터를 다운로드하면 오히려 시작 속도가 느려질 수 있습니다.
따라서 마지막 동기화 시각을 기준으로 갱신 여부를 판단할 수 있습니다.
예를 들어 다음과 같은 정책입니다.
| 데이터 | 갱신 정책 예시 |
|---|---|
| 조류 기본 정보 | 하루 1회 |
| 이미지 URL | 필요 시 |
| 사용자 메모 | 변경 시 |
| 좋아요 | 변경 시 |
| 공지사항 | 앱 실행 시 조건부 |
| 앱 설정 | 변경 시 |
이 값들은 예시이며 실제 서비스의 데이터 변경 빈도에 맞게 조정해야 합니다.
특히 사용자가 수동으로 새로고침을 요청하는 경우에는 일반적인 갱신 주기와 별도로 처리할 수 있습니다.
33. 이미지 캐시와 오프라인 지원
Glide를 사용하는 앱에서는 이미지 캐시도 고려할 수 있습니다.
예를 들어 다음과 같이 이미지를 로딩합니다.
Glide.with(imageView)
.load(imageUrl)
.placeholder(
R.drawable.ic_image_placeholder
)
.error(
R.drawable.ic_image_placeholder
)
.into(imageView)Glide는 설정과 요청 조건에 따라 메모리 및 디스크 캐시를 활용할 수 있습니다.
하지만 한 번 표시한 이미지가 영구적으로 오프라인에서 사용 가능하다는 의미는 아닙니다.
캐시가 삭제되거나 저장 공간 관리 정책에 따라 제거될 수 있기 때문입니다.
반드시 오프라인에서 제공해야 하는 이미지는 앱 내부 assets 또는 앱이 관리하는 별도 파일 저장소에 보관하는 방식을 고려해야 합니다.
34. SQLite 데이터와 이미지 저장 전략
도감형 앱에서는 다음과 같이 데이터를 구분할 수 있습니다.
SQLite
├── 조류 기본 정보
├── 이미지 URL
├── 사용자 메모
└── 좋아요
파일 저장소
├── 오프라인 썸네일
└── 필요한 원본 이미지
Glide Cache
└── 일반 이미지 요청 캐시모든 원본 이미지를 앱에 포함하면 APK 또는 AAB 크기가 지나치게 커질 수 있습니다.
따라서 기본 썸네일은 오프라인으로 제공하고 고해상도 이미지는 필요할 때 다운로드하는 방식도 고려할 수 있습니다.
35. 오프라인 우선 앱 테스트 시나리오
오프라인 우선 구조는 정상적인 네트워크 환경에서만 테스트해서는 안 됩니다.
다음과 같은 상황을 확인해야 합니다.
| 테스트 상황 | 확인할 내용 |
|---|---|
| 최초 설치 후 오프라인 | 기본 데이터 또는 안내 화면 |
| 데이터 다운로드 후 오프라인 | 기존 데이터 조회 가능 |
| 오프라인 메모 작성 | 로컬 저장 정상 동작 |
| 네트워크 복구 | 동기화 작업 실행 |
| 서버 오류 | 기존 데이터 유지 |
| 동기화 중 앱 종료 | 작업 재개 및 데이터 일관성 |
| 동일 데이터 중복 수신 | 중복 행 발생 여부 |
| 서버 데이터 삭제 | 로컬 데이터 처리 |
| 두 기기에서 동시 수정 | 충돌 감지 |
| 앱 업데이트 | 사용자 데이터 유지 |
| 저장 공간 부족 | SQLite 오류 처리 |
| 인증 만료 | 사용자 데이터 손실 방지 |
특히 동기화 중 앱이 종료되는 상황과 서버 응답을 받지 못한 상태에서 동일 요청을 재전송하는 상황을 반드시 고려해야 합니다.
36. 개인 개발자에게 추천하는 도입 순서
처음부터 복잡한 양방향 동기화를 구현할 필요는 없습니다.
다음 순서로 단계적으로 적용하는 것이 현실적입니다.
1단계: 로컬 조회 우선
SQLite 데이터를 먼저 표시하고 서버 데이터는 백그라운드에서 갱신합니다.
2단계: WorkManager 자동 동기화
네트워크 조건에 따라 서버 데이터를 갱신합니다.
3단계: 증분 동기화
서버에서 변경된 데이터만 다운로드합니다.
4단계: 사용자 입력 오프라인 저장
메모와 좋아요를 로컬에 먼저 저장합니다.
5단계: Outbox 패턴
서버에 전송해야 할 작업을 안전하게 관리합니다.
6단계: 충돌 해결
여러 기기에서 동일한 데이터를 수정하는 상황을 처리합니다.
앱이 로그인이나 계정 동기화를 지원하지 않는다면 1~3단계만으로도 충분한 경우가 많습니다.
37. 오프라인 우선 설계 체크리스트
로컬 데이터와 서버 데이터 구분
사용자 데이터 별도 테이블 관리
SQLite를 화면 데이터의 기준으로 사용
Retrofit 요청을 메인 스레드에서 실행하지 않음
서버 오류 시 기존 데이터 유지
마지막 동기화 상태 표시
네트워크 연결 상태 감지
WorkManager 네트워크 조건 적용
중복 동기화 작업 방지
전체 동기화와 증분 동기화 구분
서버 삭제 데이터 처리
동기화 커서 저장
데이터 적용과 커서 갱신을 같은 트랜잭션으로 처리
오프라인 사용자 입력 저장
Outbox 패턴 검토
서버 요청 멱등성 확보
데이터 충돌 해결 정책 정의
재시도 가능한 오류 구분
SQLite 버전 호환성 확인
이미지 오프라인 저장 정책 수립
앱 종료 및 네트워크 복구 테스트
앱 업데이트 후 사용자 데이터 유지 확인
38. 마무리
오프라인 우선 설계는 단순히 SQLite에 데이터를 저장하는 기능이 아닙니다.
앱의 데이터 흐름을 로컬 중심으로 구성하고, 서버와의 동기화를 별도 작업으로 관리하는 아키텍처입니다.
특히 Kotlin + MVVM + Retrofit + SQLite를 사용하는 앱에서는 다음 원칙이 중요합니다.
화면은 가능한 한 SQLite 데이터를 먼저 표시합니다.
서버 요청이 실패해도 기존 데이터를 유지합니다.
동기화는 Repository와 WorkManager에서 관리합니다.
서버 데이터와 사용자 데이터를 분리합니다.
변경된 데이터만 다운로드하는 증분 동기화를 고려합니다.
오프라인 사용자 입력은 먼저 로컬에 저장합니다.
중복 요청과 데이터 충돌을 처리할 수 있도록 설계합니다.
동기화 중 앱이 종료되어도 데이터가 손상되지 않도록 합니다.
개인 개발자가 운영하는 정보 검색 앱이라면 먼저 로컬 조회와 서버 갱신을 분리하는 것만으로도 사용자 경험을 개선할 수 있습니다.
이후 서버 계정 동기화가 필요해지면 Outbox 패턴과 충돌 해결 기능을 단계적으로 추가하면 됩니다.
오프라인 우선 설계의 핵심은 인터넷이 없어도 앱이 동작하도록 만드는 것을 넘어, 네트워크가 불안정한 환경에서도 사용자의 데이터와 작업 결과를 안전하게 유지하는 것입니다.
참고 자료
Android Developers — Offline-first data layer
https://developer.android.com/topic/architecture/data-layer/offline-firstAndroid Developers — Data layer
https://developer.android.com/topic/architecture/data-layerAndroid Developers — WorkManager
https://developer.android.com/topic/libraries/architecture/workmanagerAndroid Developers — ConnectivityManager
https://developer.android.com/reference/android/net/ConnectivityManagerAndroid Developers — SQLiteOpenHelper
https://developer.android.com/reference/android/database/sqlite/SQLiteOpenHelperSQLite — UPSERT
https://www.sqlite.org/lang_upsert.htmlSQLite — Transactions
https://www.sqlite.org/lang_transaction.htmlRetrofit 공식 저장소
https://github.com/square/retrofitGlide 공식 문서
https://bumptech.github.io/glide/
태그: Android, Kotlin, Offline-first, SQLite, SQLiteOpenHelper, Retrofit2, Coroutine, WorkManager, MVVM, Repository, 데이터 동기화, Outbox Pattern, 증분 동기화, 데이터 충돌, 네트워크 상태 감지