iOS는 되는데 안드로이드만 안 된다
Expo로 만든 앱을 운영하고 있다. 배포는 EAS Update로 OTA를 하고, 스토어 빌드는 가끔만 올린다. 9월 초에 심사용으로 잠깐 숨겨둔 문의하기 버튼을 되살리는 OTA를 냈는데, iOS 사용자들은 다음 날부터 잘 받았고 안드로이드 사용자들은 일주일이 지나도 아무도 받지 못했다. 유저 문의가 계속 들어왔다. "문의하기 버튼이 없어요."
EAS 대시보드를 보면 상황이 그대로 보였다. iOS 업데이트는 unique users 20명, 안드로이드 업데이트는 1명. 그 1명은 내 에뮬레이터였다. 그리고 "Embedded update"에 15명이 붙어 있었다. 안드로이드 사용자 전원이 스토어에 내장된 번들만 돌리고 있다는 뜻이다.
재발행을 해도 똑같았다. 채널도 맞고, 런타임 버전도 맞고, 플랫폼도 android로 찍혀 있는데 아무도 안 받는다.
틀린 결론을 두 번 냈다
첫 번째 결론은 "안드로이드는 프로세스가 오래 살아서 콜드 스타트가 안 온다"였다. expo-updates는 기본 설정에서 업데이트를 다운로드해두고 다음 콜드 스타트에 적용한다. 안드로이드는 홈으로 나가도 프로세스가 며칠씩 살아 있으니 적용 시점이 늦어진다는 논리였다. 그럴싸했지만 일주일 동안 한 명도 못 받았다는 걸 설명하지 못했다. 사람들은 폰을 재부팅한다.
두 번째 결론은 좀 더 그럴싸했다. 실기기에 adb를 붙여서 로그를 보니 업데이트 체크가 0.1초 만에 "업데이트 없음"으로 끝났다. 서버가 204를 준다는 뜻이다. 그래서 서버에 직접 curl을 날려봤다.
expo-runtime-version: 2.1.6 으로 보내면 200
expo-runtime-version: 1.3.0 으로 보내면 204
그리고 폰에 설치된 APK를 뽑아서 aapt2로 리소스를 덤프했더니 이런 게 나왔다.
base.apk string/expo_runtime_version () "2.1.6"
split_config.ko.apk string/expo_runtime_version (ko) "1.3.0"
안드로이드의 expo-updates 설정은 AndroidManifest의 meta-data에 @string/expo_runtime_version 참조로 들어간다. 리소스 참조니까 기기 언어에 맞는 값으로 해석된다. 한국어 기기는 ko split에 들어 있는 "1.3.0"을 쓴다. 1.3.0은 우리 앱이 3월에 출시했던 버전이다. 그 런타임용 업데이트는 없으니 서버는 204를 주고, 기기는 영원히 임베디드 번들만 돌린다. iOS는 Expo.plist에 문자열이 그대로 박히니까 영향이 없다. 증상과 완벽하게 맞았다.
여기서 "그럼 왜 ko split에 1.3.0이 들어 있나"가 남았다. values-ko/strings.xml에 그 값이 있는 트리에서 빌드했다는 것 말고는 설명이 안 되는데, 우리 프로젝트에는 그런 파일이 없다. 그래서 두 번째 결론을 냈다. "누가 낡은 로컬 android 폴더로 AAB를 만들어서 수동 업로드한 것이다." Play Console에서 해당 버전 코드의 AAB를 내려받아 검사했더니 깨끗했다. EAS가 만든 산출물과 SHA256이 같았다. 그럼 폰에 깔린 건 다른 경로로 들어온 거라고 생각했다. 내부 앱 공유 같은 걸로.
이 결론이 틀렸다는 건 금방 드러났다. 실사용자들은 내부 테스터가 아닌데 계속 문의가 들어왔다. Play에 올라간 AAB가 깨끗한데 Play에서 설치한 폰은 오염돼 있다. 둘 다 사실이라면 Play가 중간에서 뭔가를 하고 있다는 뜻이다.
독일어 split이 답을 줬다
앞선 조사 과정에서 기기 언어를 독일어로 바꿔본 적이 있었다. 그러고 한 시간쯤 뒤에 Play가 조용히 split_config.de.apk를 새로 내려줬다. 그 파일을 덤프해봤다.
string/app_name (de) "Preis"
string/expo_splash_screen_resize_mode (de) "enthalten"
string/expo_runtime_version (de) "1.3.0"
앱 이름 Prize가 Preis로, 스플래시 설정값 contain이 enthalten으로 번역돼 있었다. 우리는 독일어 리소스를 만든 적이 없고, AAB 안에도 없다. 이건 기계번역이다. Play가 우리 앱의 문자열을 번역해서 언어 split을 만들어 배포하고 있었다.
Play Console 도움말을 찾아보니 "앱 문자열 자동 번역"이라는 기능이 있다. 사용자 확보 > 번역 > 앱 문자열 메뉴에서 켤 수 있고, Gemini로 번역해준다. 켜두면 새 앱 번들이 업로드될 때마다 자동으로 번역이 생성되고, 기존 번역은 이후 릴리스로 이어진다고 되어 있다. 우리는 3월에 이 기능을 켰던 것 같다. 그때 앱 버전이 1.3.0이었다.
그러니까 이렇게 된 거다. Play는 expo_runtime_version이라는 문자열도 번역 대상으로 봤다. "1.3.0"을 번역하면 "1.3.0"이다. 그 번역본이 ko, en, ja 리소스로 저장됐고, 이후 버전을 아무리 올려도 그 번역본이 이어졌다. 그래서 2.1.6 빌드를 올려도 한국어 기기는 ko split의 "1.3.0"을 런타임 버전으로 읽는다. 어떤 언어를 쓰든 Play가 번역해둔 언어라면 다 같은 상태다. 사실상 전원이다.
translatable="false"는 소용이 없었다. 위에서 번역된 expo_splash_screen_resize_mode는 Expo가 생성할 때부터 translatable="false"가 붙어 있는 문자열이다. Play 자동 번역은 그 속성을 보지 않는다.
내가 왜 못 찾았나
AAB만 봤기 때문이다. 업로드한 파일이 깨끗하면 배포도 깨끗할 거라고 가정했다. Play가 업로드된 번들에 없는 리소스를 생성해서 배포한다는 가능성은 목록에 없었다. 실제로 Play에서 설치된 기기의 split을 뽑아서 보지 않았으면 끝까지 몰랐을 거다.
그리고 에뮬레이터 테스트가 나를 속였다. 에뮬레이터에는 bundletool로 AAB에서 직접 split을 만들어 설치했다. 그건 Play를 거치지 않으니 당연히 깨끗하고, OTA도 잘 받았다. 그래서 "빌드는 정상"이라는 잘못된 확신이 생겼다. 스토어 배포 문제를 재현하려면 Play를 거쳐 설치된 기기를 봐야 한다.
고친 것
두 갈래로 대응했다. 하나는 지금 당장 고통받는 사용자, 하나는 재발 방지다.
지금 사용자들을 위해서는 런타임 1.3.0으로 안드로이드 전용 OTA를 발행했다. 처음엔 이게 좀 이상하게 들렸다. 2.1.6 앱에 1.3.0이라고 라벨을 붙여 보낸다니. 하지만 runtimeVersion은 코드가 아니라 서버가 업데이트를 매칭하는 데 쓰는 꼬리표일 뿐이다. 기기가 자기를 1.3.0이라고 소개하고 있으니 꼬리표를 거기에 맞춰준 것이고, 내용물은 스토어 빌드와 같은 커밋에서 뽑은 2.1.6용 JS 번들이다. 네이티브 호환은 그대로다.
발행할 때는 dev 브랜치가 이미 다음 버전 작업 중이었기 때문에, 스토어 빌드 커밋으로 git worktree를 따로 만들어서 거기서 발행했다. app.config.js에 환경변수가 있을 때만 android.runtimeVersion을 덮어쓰는 코드를 넣고, EAS 빌드 워커 안에서 그 변수가 보이면 예외를 던지게 했다. OTA 전용 우회로가 빌드에 섞여 들어가는 건 막아야 하니까.
발행 직후 문제의 폰을 완전 종료하고 다시 켰더니 로그가 바뀌었다.
+0.0s Check
+0.7s CheckCompleteAvailable
+2.5s DownloadComplete
+6.9s Restart
일주일 내내 0.1초 만에 "없음"으로 끝나던 게 처음으로 다운로드까지 갔다.
재발 방지는 config plugin으로 했다. Expo의 finalized mod를 써서 다른 모든 플러그인이 끝난 뒤 AndroidManifest의 EXPO_RUNTIME_VERSION 값을 @string 참조에서 리터럴 문자열로 바꿔준다. 리소스 참조가 아니면 언어 split이 뭘 하든 상관없다. 다음 스토어 빌드부터 유효하다. 그리고 업로드 전 검사 스크립트에 "매니페스트가 @string 참조면 실패"를 추가했다. 이전 검사는 AAB 리소스 테이블만 봐서 이 문제를 통과시켰다.
Play Console의 자동 번역은 끄기로 했다. 앱 이름이 Preis로 나가는 것부터가 이미 사고다.
정리
배운 것을 몇 줄로 줄이면 이렇다.
업로드한 산출물과 배포되는 산출물은 다를 수 있다. Play는 언어 split을 생성해서 끼워 넣는다. 스토어 문제를 검증할 때는 스토어에서 설치된 기기를 봐야 한다.
설정값을 문자열 리소스로 두면 로컬라이즈될 수 있다. Expo가 기본으로 그렇게 생성하고, 대부분은 문제가 없다. 하지만 Play 자동 번역을 켜는 순간 그 리소스는 번역 대상이 된다. translatable="false"도 막아주지 않았다.
증상을 설명하는 그럴싸한 가설이 나왔을 때, 그 가설이 설명하지 못하는 사실이 하나라도 남아 있으면 아직 끝난 게 아니다. "실사용자들이 계속 문의한다"는 사실이 두 번째 결론을 죽였다. 그 한 줄을 무시했으면 새 빌드를 올리고도 같은 문제를 다시 겪었을 거다.
같은 증상을 겪는 분이 있다면 Play에서 설치된 기기에서 이걸 먼저 확인해보시길 권한다.
adb shell pm path <패키지명>
adb pull <split_config.xx.apk 경로>
aapt2 dump resources split_config.xx.apk | grep -A1 expo_runtime_version
괄호 안에 언어 코드가 붙은 값이 나오면 같은 문제다.
'React-Native' 카테고리의 다른 글
| [RN] iOS는 멀쩡한데 안드로이드만 버벅인다 - React Native 홈 화면 이미지 최적화기 (0) | 2026.05.08 |
|---|---|
| [RN] firebase dynamic link없이 딥링크를 사용한 공유하기 기능 구현 (1) | 2026.01.12 |
| [RN] 안드로이드 에뮬레이터 harfbuzz text shaping으로 인한 프로세스 종료 (0) | 2026.01.07 |
| [RN] Google Spread Sheet를 이용한 다국어 처리 설계 (0) | 2025.12.30 |
| [RN] React-Native에 Toss PG 연결하기 (version1) (0) | 2025.12.23 |