
최근에 안드로이드 인증/보안 관점 공부하며 allowBackup=true 리스크를 파고들다가, 굉장히 큰 사고 날 수 있는 시나리오를 마주했다. 핵심은, 로컬 내부 저장소 데이터가 마이그레이션되지 못한 상태에서 앱 데이터가 Google로 백업됐을 시, 사용자가 앱을 재설치한다 하더라도 앱이 무조건 죽는다. 즉, '삭제 후 재설치'라는 사용자 입장의 마지막 카드가 무효가 되는 격이다.
이론을 공부하고 시나리오를 추론만 했을 땐, 설마 했지만, 간단한 샘플 앱 3개(Room/SharedPreferences/Keystore)로 업데이트 테스르를 구성해서 검증해보니 재현이 됐다. 더 무서운 건, 백업 경로가 Google Auto Backup뿐 아니라 기기 변경, 제조사 백업 경로(예. 삼성)와 엮이면 체감상 장애 반경이 더 넓어질 수 있다는 점이었다.
안드로이드에서 앱은 일반적으로 로컬에 DB, key-value, File에 로컬 데이터를 저장한다. 여기에, 앱 Manifest상에 allowBackup=true상태일 경우, 시스템은 로컬 데이터를 백업 후, 추후 복원하게 된다. 이때 데이터 포맷 변경이나 키 변경, 마이그레이션 누락 같은 버그가 생기면 위에 말했듯, 앱을 재설치해도 무한히 죽는 큰 사고가 발생한다.
편의를 위해, 앱 업데이트 전 버전을 V1, 이후 버전을 V2로 칭한다.
V1앱에서 로컬 데이터 단순 저장 -> V2앱에서 내부 필드 마이그레이션했으나, 마이그레이션 코드 누락 -> 사용자의 V2앱 업데이트 및 Google Auto Backup 동작 -> 키자마자 앱이 크래시 -> 사용자가 앱 삭제 후 재설치 -> Google Auto Backup 동작 -> 다시 앱 크래시.
즉 마이그레이션된 내부 저장소 필드 기준으로 Google에 백업된 과거 스냅샷이 앱과 충돌하여 크래시를 유발하는 구조이다. 그래서 이 케이스를 모르면 장애 대응 중에 진짜 멘붕 온다.
실험은 샘플앱 3개로 진행했다.
흐름은 위에서 말한것과 동일하게 실험했다.
Room은 제일 전형적인 사고 케이스다. V1의 Entity 클래스에 필드 추가(=DB 컬럼 추가) 상태에서 V2로 컬럼을 추가하고 DB version만 올린 뒤 마이그레이션을 안 넣으면? 앱 시작 시 DB open 단계에서 앱이 죽으며, 앱을 재설치해도 마찬가지다.
@Entity(tableName = "notes")
data class NoteEntity(
@PrimaryKey(autoGenerate = true) val id: Long = 0,
val title: String,
val createdAt: Long
)
@Entity(tableName = "notes")
data class NoteEntity(
@PrimaryKey(autoGenerate = true) val id: Long = 0,
val title: String,
val createdAt: Long,
val category: String <-- 새로 추가
)
SharedPreferences는 "타입 변경" 하나로 바로 터진다. V1에서는 String으로 저장했는데 V2에서 Int로 읽으면 ClassCastException이다. 즉, 앱 재설치해도 무한히 죽는다.
prefs.edit()
.putString(KEY_SESSION_BLOB, "uid=1226|tier=free|ts=${System.currentTimeMillis()}")
.putString(KEY_USER_AGE, "27")
.apply()
val blob = prefs.getString(KEY_SESSION_BLOB, null)
val age = prefs.getString(KEY_USER_AGE, null)
prefs.edit()
.putString(KEY_SESSION_BLOB, "uid:1226;tier:free;ts:${System.currentTimeMillis()}")
.putInt(KEY_USER_AGE, 27)
.apply()
val blob = prefs.getString(KEY_SESSION_BLOB, null)
val age = prefs.getInt(KEY_USER_AGE, 0)
val fields = blob!!.split(";")
val uid = fields[0].substringAfter("uid:")
val tier = fields[1].substringAfter("tier:")
val ts = fields[2].substringAfter("ts:")
AndroidKeystore를 사용한 암호문이 로컬에 남아있는 상황에서, 이를 복호화하려는 키의 alias를 바꾸고 시도(V1에서 생성한 암호문을 V2 alias로 바로 복호화)할 경우, AEADBadTagException 에러를 내며 앱이 죽는다.
val token = "access-token-v1-${System.currentTimeMillis()}"
val encrypted = cryptoStore.encrypt(KEY_ALIAS, token)
prefs.edit().putString(KEY_CIPHERTEXT, encrypted).apply()
val message = try {
val plain = cryptoStore.decrypt(KEY_ALIAS, encrypted)
"encrypted=$encrypted\nplain=$plain"
} catch (t: Throwable) {
"decrypt failed: ${t.javaClass.simpleName}\n${t.message}"
}
private const val KEY_ALIAS = "security_sample_key_v1"
val token = "access-token-v2-${System.currentTimeMillis()}"
val encrypted = cryptoStore.encrypt(KEY_ALIAS_V2, token)
prefs.edit().putString(KEY_CIPHERTEXT, encrypted).apply()
val plain = cryptoStore.decrypt(KEY_ALIAS_V2, encrypted)
statusText.text = "encrypted=$encrypted\nplain=$plain"
private const val KEY_ALIAS_V2 = "security_sample_key_v2_rotated"
여기서 한 가지 더 중요한 포인트가 있다. 위 실험은 같은 기기 내 V1 -> V2 업데이트 기준이었지만, 기기 변경 시나리오에서는 더 쉽게 장애가 발생할 수 있다. 예를 들어 기기 A에서 AndroidKeyStore 키로 "hello"를 암호화해 암호문 x를 저장했고, allowBackup=true로 인해 해당 저장 파일이 백업되었다고 가정하자. 이후 기기 B에서 동일 구글 계정으로 복원하면 암호문 x 자체는 복구까진 성공하지만, 복호화는 실패한다.
이유는 AndroidKeyStore 키가 기기 하드웨어 보안 영역(TEE/StrongBox)에 종속되기에 키가 절대 export될 수 없어, 백업/복원 대상도 아니기 때문이다. 즉, 기기 A에서 만든 키와 기기 B의 키는 물리적으로 다른 키란 것이다. 결국 기기 B에서는 x를 정상 복호화할 수 없고, 복호화 실패 예외를 처리하지 않으면 앱 시작 시 즉시 크래시로 이어진다.
• ┌────────────────────────── Device A ───────────────────────────┐
│ AndroidKeyStore (TEE/StrongBox): key_A │
│ plaintext: "hello" │
│ encrypt(key_A, "hello") -> ciphertext_x │
│ local 저장: ciphertext_x │
└───────────────────────────────────────────────────────────────┘
│
│ allowBackup=true 로 백업 대상 포함
▼
┌──────── Cloud Backup ────────┐
│ ciphertext_x (파일/DB/prefs) │
│ key_A는 없음 (export 불가) │
└──────────────────────────────┘
│
│ 동일 계정으로 기기 변경 후 복원
▼
┌────────────────────────── Device B ──────────────────────────┐
│ restored: ciphertext_x (복원 성공) │
│ AndroidKeyStore: key_B (새 기기 키, key_A와 다름) │
│ decrypt(key_B, ciphertext_x) -> FAIL │
│ │
│ 예외 미처리: 앱 시작 즉시 크래시 │
│ 예외 처리: 암호문/세션 폐기 + 재로그인(self-heal) │
└──────────────────────────────────────────────────────────────┘
즉, Keystore 데이터를 다룰 때는 복호화 실패에 따른 Exception이 반드시 발생할 수 있는 정상 시나리오로 설계해야 한다. 따라서, 앱 시작 시 복호화에 실패하면 해당 암호문/세션 데이터를 안전하게 폐기하고, 재로그인 흐름으로 유도하는것도 해답이다.
// 나쁜 예시: 복호화 실패, Exception처리 안함
val encrypted = load("encrypted_token")
val token = decryptWithKeystore("app_key", encrypted) // -> 앱 크래시
goMain(token)
// 좋은 예시: 복호화 실패를 정상 흐름으로 처리
val encrypted = load("encrypted_token")
if (encrypted == null) {
goLogin()
return
}
try {
token = decryptWithKeystore("app_key", encrypted)
goMain(token)
} catch (e: Exception) {
clear("encrypted_token", "encrypted_refresh_token")
goLogin() // 재로그인 유도 (self-heal)
}
효과는 있으며, 앱은 정상 구동은 된다. 하지만 서비스 운영 관점에서는 모든 사용자가 앱 데이터를 친절히 삭제해주기고 잘 사용하기를 기대할 수 없다으며, 이를 사용자들에게 CS적으로 인지시키는 것 또한 비용이다. 따라서 그럴 땐, 앱의 핫픽스 배포를 진행해야할 수도 있으며, 아래 경우를 생각해볼 수 있다.
AndroidMenifest.xml에 data_extraction_rules.xml, fullBackupContent즉, 백업 예외 정책을 추가AEADBadTagException, InvalidKeyException) 암호문 제거 + 재로그인 유도 로직 필수data_extraction_rules.xml에서 민감/깨지기 쉬운 데이터 제외는 거의 필수다.
<data-extraction-rules>
<cloud-backup>
<exclude domain="sharedpref" path="secure_prefs.xml" />
<exclude domain="database" path="sample_notes.db" />
</cloud-backup>
<device-transfer>
<exclude domain="sharedpref" path="secure_prefs.xml" />
<exclude domain="database" path="sample_notes.db" />
</device-transfer>
</data-extraction-rules>
그리고 보안성 높은 데이터(토큰/세션/암호화 메타)는 noBackupFilesDir 사용을 강하게 추천한다.
val file = File(context.noBackupFilesDir, "session_cache.json")
file.writeText(payload)
따라서 따라서 앱 로컬 저장소 수정 및 base에 merge할 때, 로컬 저장소(DB, key-value, File)의 변경이 생겼을 때, 마이그레이션 누락 시 이를 CI에서 막아버리는 것도 방법일 수 있겠다.
이 이슈의 무서운 점은 앱 크래시를 넘어, 사용자 앱 사용의 거진 마지막 카드인 '재설치'까지 무효화 시킨다는 데 있다. 그래서 해당 원인을 놓치면, 개발팀과 회사의 CS, 사업팀 등의 피로도는 또한 높아질 수 있다.
즉, allowBackup=true는 편의 기능일 수도 있지만 굉장히 큰 운영 리스크를 동반하는 설정이란 것도 알고 있으면 좋다. 반복해서 말하자면, 로컬 데이터 필드를 바꿀 거면, 해당 마이그레이션 코드를 반드시 설계해야하고 단위테스트도 작성할 필요가 있다. 또한 민감하거나 깨지기 쉬운 데이터는 애초에 백업 경로에서 제외해야 한다. 만약, 이를 누락하여 장애가 이미 터졌다면, 사용자 데이터 삭제는 임시 응급처치로 쓰고, 실제 해결은 핫픽스와 백업 정책 수정으로 끝내야 한다.
allowBackup=true설정 시, 반드시 마이그레이션 코드 + 테스트 코드 필수allowBackup=true설정 시, AndroidKeyStore로 암호화된 데이터 복구 전략 필수