앱을 지웠다 재설치했는데 첫 무료부터 페이월이 떴다 — 범인은 안드로이드 Auto Backup

제 안드로이드 앱은 평생 딱 1회 무료 작명을 줍니다. 그 한 번을 쓰고 나면 유료고요. 그래서 “앱을 지웠다 다시 깔면 무료 1회가 리셋된다”가 제가 정한 설계 규칙이었어요. 그런데 실기기에서 앱을 삭제하고 새로 설치한 직후, 첫 무료 작명을 눌렀더니 대뜸 페이월(유료 안내창)이 떴습니다. “무료 미리보기를 모두 사용했어요.” 방금 깐 앱인데요. 등골이 서늘했죠.

이 버그가 까다로웠던 이유는, 코드를 아무리 봐도 멀쩡했기 때문이에요. 범인은 제 코드가 아니라 안드로이드가 뒤에서 조용히 해주던 일이었거든요.

그래서 이 글이 던지는 질문은 하나입니다. “내가 안 짠 코드가 내 앱의 동작을 바꾸고 있다면, 그걸 어떻게 알아채나?” 결제가 실제로 새는 문제나 로그에 키가 찍히는 문제는 내 코드 안에 원인이 있어서 읽으면 보입니다. 이번 건은 반대예요 — 내 코드에는 아무 잘못이 없고, 플랫폼이 기본값으로 켜둔 기능이 원인이라 코드를 백 번 읽어도 안 나옵니다. 초보 바이브코더가 “분명 로직은 맞는데 왜 안 되지?”에 가장 오래 빠지는 지점이 정확히 여기예요.

증상 — “방금 깐 앱인데 왜 이미 다 썼대?”

무료 쿼터는 “평생 1회”라 2회차부터 막혀야 정상인데, 완전히 삭제하고 재설치한 뒤 1회차부터 막혔습니다. 깨끗하게 새로 깐 설치(fresh install)인데도 앱이 “이 사용자는 이미 무료를 썼다”고 알고 있었다는 뜻이죠.

더 이상했던 건 재현이 들쭉날쭉했다는 점이에요. 예전에 테스트했을 땐 잘 통과했는데, 이번엔 막혔습니다. 될 때도 있고 안 될 때도 있는 버그 — 이런 비결정적인 증상이 원인 찾기를 제일 어렵게 만들죠.

게이트 코드는 멀쩡했다

제일 먼저 의심받은 건 당연히 무료/유료를 가르는 게이트 코드였어요. AI가 짚은 곳은 NamingViewModel.kt 201번째 줄의 이 조건문이었습니다.

// NamingViewModel.kt (게이트 판단)
if (used >= FREE_GENERATION_LIMIT) {
    // 페이월 표시
}

used(지금까지 쓴 무료 횟수)가 한도(FREE_GENERATION_LIMIT, 여기선 1) 이상이면 페이월을 띄우는, 지극히 정상적인 코드예요. 쿼터를 저장·조회하는 QuotaRepository도 멀쩡했고요. 로직엔 잘못이 없었습니다.

그러니 문제는 로직이 아니라 값이었어요. 재설치 직후인데 used가 이미 1로 들어와 있던 겁니다. 새로 깐 앱이 “이미 한 번 썼다”는 값을 어디선가 물려받고 있었다는 뜻이죠. 채점으로 치면, 풀이 과정은 완벽한데 문제지에 이미 남이 답을 적어둔 상태로 시작한 셈이에요. (오늘의 수학쌤 비유는 여기까지.)

진짜 범인 — 안드로이드 Auto Backup

그 “어디선가”가 바로 Auto Backup이었어요.

Auto Backup: 안드로이드가 앱 데이터(설정·작은 파일 등)를 사용자 구글 계정 클라우드에 자동으로 백업해두는 기능. 앱을 다시 깔면 그 데이터를 자동 복원해준다.

원래는 편의 기능이에요. 폰을 바꾸거나 앱을 다시 깔아도 설정이 유지되니까요. 문제는 이게 켜져 있는지도 몰랐다는 거예요. 안드로이드 앱은 백업 대상을 정하는 규칙 파일 두 개(backup_rules.xml, data_extraction_rules.xml)를 갖는데, 프로젝트를 처음 만들면 이게 전부 주석 처리된 템플릿으로 들어옵니다. 아무것도 지정 안 하면 어떻게 될까요? allowBackup(백업 허용 여부)이 기본값 true라, 앱 데이터가 통째로 백업 대상이 돼요.

allowBackup: 앱이 Auto Backup의 대상이 될지 정하는 스위치. 따로 끄지 않으면 켜진 것으로 동작한다.

그래서 무료 횟수를 저장하던 파일(quota DataStore, 안에 free_generations_used 값이 들어있음)까지 클라우드에 백업됐고, 재설치할 때 친절하게 복원된 거예요. used=1이 부활한 거죠. “fresh install인데 왜 이미 다 썼대?”의 답이 이거였습니다 — 사실 fresh가 아니었어요.

DataStore: 안드로이드에서 작은 설정값(여기선 무료 사용 횟수)을 저장하는 요즘 방식의 저장소.

안드로이드 공식 문서도 이 동작을 그대로 적어 둡니다. Android 6.0(API 23) 이상을 대상으로 하는 앱은 «automatically participate in Auto Backup»이고, allowBackup은 «The default value is true»예요. 복원 시점은 «Data is restored whenever the app is installed, whether from the Play Store, during device setup (when the system installs previously installed apps), or by running adb install.»이라고 설명하고요. 게다가 복원은 앱을 열 수 있게 되기 전에 끝납니다. 그러니 재설치한 앱이 처음 켜지는 순간, used=1은 이미 제자리에 와 있던 거죠.

무료 횟수가 재설치를 건너온 경로. 첫 무료 작명을 쓰면 used=1이 quota 파일에 저장된다. Auto Backup이 24시간 경과, 유휴, 와이파이 조건에서 이 파일을 구글 계정 클라우드로 올린다. 앱을 지우고 다시 설치하면 앱이 열리기 전에 복원이 끝나 used=1로 시작하고 첫 시도부터 페이월이 뜬다. 수정은 quota 파일 하나만 백업에서 빼는 것이다.
무료 횟수가 재설치를 건너온 경로 — 점선이 PR #7의 수정 지점

왜 어떨 땐 되고 어떨 땐 안 됐나

아까 재현이 들쭉날쭉했다고 했죠. 그 이유도 여기 있었어요. Auto Backup은 아무 때나 도는 게 아니라 공식 문서 기준으로 마지막 백업 뒤 24시간이 지났고, 기기가 유휴 상태이고, 와이파이에 연결돼 있을 때(모바일 데이터 백업에 동의하지 않았다면) 돌아갑니다. 사용자가 기기에서 백업을 켜 둔 것도 조건이고요. 그래서 백업이 아직 안 올라간 타이밍에 테스트하면 복원할 게 없어 통과하고, 백업이 한 번 올라간 뒤에 재설치하면 복원돼서 막혔던 거예요. “될 때도 있고 안 될 때도 있는” 버그의 정체가 바로 이 비결정적 타이밍이었습니다.

어떻게 고쳤나

방향은 간단했어요. 무료 쿼터 파일만 백업에서 빼는 것. AI가 규칙 파일 두 개에 “이건 백업하지 마라”를 한 줄씩 넣었고, 이 수정은 PR #7(versionCode 6, 버전 1.5)로 들어갔습니다.

<!-- backup_rules.xml (API 30 이하) -->
<exclude domain="file" path="datastore/quota.preferences_pb"/>
<!-- data_extraction_rules.xml (API 31 이상) -->
<cloud-backup>
    <exclude domain="file" path="datastore/quota.preferences_pb"/>
</cloud-backup>
<device-transfer>
    <exclude domain="file" path="datastore/quota.preferences_pb"/>
</device-transfer>

여기서 중요한 건 전부 끄지 않았다는 점이에요. PR #7은 무료 쿼터 파일 하나만 제외하고, Room에 저장한 작명 세션 기록 같은 나머지 사용자 데이터는 백업에 그대로 남겼습니다. 그건 폰을 바꿔도 남아 있어야 좋은 데이터니까요. 필요한 것만 정확히 빼는 게 핵심이었어요. (참고로 최신 안드로이드는 규칙 파일이 둘로 갈려서, 옛 버전용·새 버전용 양쪽 다 넣어야 안 샙니다.)

한 번은 손으로 지워야 한다: 이 exclude는 “앞으로 백업하지 마라”일 뿐, 이미 기기에 복원돼 있는 used=1 값을 자동으로 지워주진 않아요. 그래서 새 빌드를 깐 뒤 한 번은 앱 설정 → 저장공간 삭제(또는 adb shell pm clear <패키지>)로 낡은 값을 밀어낸 다음 다시 검증해야 합니다.

이 수정은 무엇으로 확인했나 — 검증의 한계

솔직히 적어 둘 게 있어요. 이 수정은 단위테스트로 증명한 게 아닙니다. PR #7 기록에는 «유닛테스트로는 못 잡는 클래스(백업/복원 경로는 인스트루먼트 밖). diff 리뷰로 검증.»이라고 적혀 있어요. 백업과 복원은 기기와 구글 계정 사이에서 일어나니, 테스트 코드 안에서는 재현할 수가 없었던 거죠. 그래서 확인은 위 박스처럼 저장공간을 한 번 비운 뒤 실기기에서 “무료 1회 → 2회차 페이월”을 다시 보는 재검증(R4)으로 넘어갔습니다. PR #7 이후 작업 기록(WORKLOG·인수 테스트 보고)에서 이 수정의 R4 실기기 통과 기록은 찾을 수 없습니다(07-26 통과 목록: M0·M1·R2·R3·R1a·R1b·R5).

이 수정에는 동전의 뒷면도 있어요. 쿼터를 백업에서 빼면, 앱 데이터를 지울 때마다 무료 1회가 되살아납니다. 커밋 메시지는 이걸 알고 받아들였다고 적었어요. «FREE 평생 1회 쿼터는 “재설치 = 리셋”이 설계 불변식(앱 데이터 삭제로 우회 가능, 고지 완료)». 7월 26일 작업 기록은 이 구멍을 «FREE 쿼터 파밍(앱 데이터 삭제로 무료 무한)»이라 부르고, 결제 손실이 아니라 Gemini 호출 비용이며 근본 해결은 서버측 쿼터라 Phase 4로 미뤘다고 적었습니다. 9월 28일 현재 코드의 규칙 파일에도 같은 exclude 줄이 그대로 있어요.

그래서 뭘 하면 되나

“재설치했는데 왜 예전 상태가 남아 있지?” 싶은 버그를 만나면, 이 순서로 의심해보세요.

신호판단액션
로직은 맞는데 값이 이미 채워져 시작🟡 코드 아닌 데이터 출처 문제저장값이 어디서 오는지(복원 경로) 추적
fresh install인데 예전 설정이 남음🔴 Auto Backup 복원 의심백업 규칙 XML이 주석 템플릿인지 확인
재현이 들쭉날쭉(될 때·안 될 때)🟡 비결정적 타이밍백업 조건(24시간 경과·유휴·와이파이) 의심
리셋돼야 할 값이 살아남음🔴 그 파일만 exclude나머지 데이터는 백업 유지(선택적 제외)

한 문장 요약: 재설치인데 옛 상태가 남는다면 십중팔구 Auto Backup 복원이다 — 리셋돼야 할 파일만 백업 규칙에서 exclude하고, 이미 복원된 값은 한 번 손으로 지운 뒤 재검증하라.

이 버그가 알려준 건, 내 앱의 상태가 내 코드만으로 정해지지 않는다는 사실이었어요. 안드로이드가 뒤에서 백업·복원을 대신 해주는데, 그 기본 동작을 모르면 “분명 새로 깔았는데 왜?”에서 한참을 헤맵니다. 편의 기능이 내 설계 규칙(“재설치=리셋”)과 부딪힌 거죠. 플랫폼이 기본으로 뭘 해주는지 한 번쯤 들여다보는 게, 이런 유령 같은 버그를 더 빨리 잡는 길이더라고요.


댓글 남기기