Android 앱 — iOS 셸 동등의 Capacitor + Kotlin 앱과 Play 출시 준비까지 (2026-08-23)
- 기간: 2026-08-23 (세션 2개 — 1차 세션이 조사·권고 경로·오너 결정 D1~D5 질문까지, 2차 세션이 Phase 0~4 연속 집행)
- 랜딩: PR #605(Phase 1,
e72aa360) · #610(Phase 2,7c97ebaf) · #611(Phase 3,6a381c26) · #612(Phase 4,5310b539) · #616(기록,1355d358). 마이그레이션·엣지 0건. 웹 변경은 #605의 전송층 한 곳뿐이며 Vercel 자동 배포 - CI: #605 Repository checks 성공. #611·#612 머지 시점에는 GitHub Actions 결제 오류로 잡이 시작되지 않아 리베이스 전 동일 내용의 성공 런 + 로컬 게이트로 머지했고, 오너가 예산을 올린 뒤
1355d358에 full lane을 디스패치해 scope·verify· migration-smoke 전부 success (run32648205335) - 정본: 네이티브 셸
android/app/src/main/java/com/dekerd/barbelic/(MainActivity.kt·NativeBridge.kt·AppEnvironment.kt·NativeAuthTransaction.kt·SecureStore.kt), 빌드 환경docs/platform/android-setup.md, 서명·출시docs/platform/android-release.md, 브리지 계약docs/contracts/native-bridge.md(전송층 2종 병기), 웹 전송층src/react/services/barbelicNav.tsnativeTransport() - 게이트:
tests/auth/androidNativeAuthContract.test.mjs(11건, iOS 계약 테스트 동등) — scope 레인 - 식별자:
docs/process/brand-identity.md에 Android 식별자 병기(applicationId·BuildConfig 키·Keystore 별칭)
1. 배경
오너가 2026-08-23 "안드로이드 앱 개발 조사"를 요청했다. 당시 앱은 iOS뿐이었다 — Capacitor 8 셸이 번들 대신 원격 https://www.barbelic.com을 열고, MainViewController.swift(1,843줄)가 소셜 로그인·세션 보존·화면 잠금 해제를 네이티브 브리지 v1으로 처리한다. 조사 결론은 "Android도 같은 구조로, Kotlin으로 브리지를 동등 구현"이었고, 대안(TWA/PWA)은 빠르지만 브리지 기능이 없어 iOS와 사용자 경험이 갈라진다는 점을 함께 보고했다.
2차 세션에서 오너가 결정 5건을 전부 권고안대로 확정하고 "Phase 0~4 중지 없이 쭉"을 지시했다.
2. 문제 제기
웹의 네이티브 탐지가 WebKit 전용이었다
barbelicNav.hasNativeBridge()는 window.webkit.messageHandlers.barbelicNative만 본다. Android WebView에는 그 객체가 없으므로 Android 셸을 만들어도 웹이 "네이티브 아님"으로 판정해 브라우저 OAuth 플로우로 흐른다 — Google은 임베디드 WebView OAuth를 차단하므로 로그인 자체가 막힌다. 전송층 하나를 추가해야 했다.
Android에는 iOS가 기대는 시스템 부품이 없다
ASWebAuthenticationSession(외부 인증 창 + 취소 콜백), Keychain, Sign in with Apple 시스템 시트, iOS의 암묵적 안전영역. 각각 Chrome Custom Tabs(취소 콜백 없음), Android Keystore, Apple 웹 플로우(Services ID), edge-to-edge + inset 처리로 대응해야 했고, 시스템 뒤로가기 버튼이라는 iOS에 없는 입력도 새로 정의해야 했다.
이 머신에 Android 툴체인이 없었다
JDK 8·11만 있고 Android SDK/Studio가 없었다. iOS처럼 "코드만 쓰고 오너가 빌드" 하면 왕복마다 멈춘다. Android는 Windows에서 빌드·에뮬레이터·실기기가 전부 되므로 설치를 권고했다.
3. 해결 방안
원칙 (오너 결정 D1~D5, 2026-08-23 — 전부 권고안 채택)
| # | 결정 | 채택 |
|---|---|---|
| D1 | 앱 경로 | Capacitor Android + Kotlin 브리지 동등(TWA 아님). iOS 브리지 계약을 그대로 재사용, 세션 보존·화면 잠금 해제 유지 |
| D2 | Play 계정 유형 | 조직 개발자 계정. D-U-N-S 필요, 개인 계정의 "닫힌 테스트 12명×14일" 면제 |
| D3 | 이 Windows 머신에 Android Studio 설치 | 설치. 빌드·에뮬레이터·실기기 검증을 직접 수행(Mac 불필요) |
| D4 | 셸 구조 | 원격 origin 유지. 웹 배포 즉시 앱 반영, 스토어 재심사 없음 — iOS와 동일 |
| D5 | 첫 출시 범위 | 세션 이미지 저장 제외. sessionDownloadImage는 안내 토스트, 2차 업데이트에서 추가 |
오너 지시: "이대로 중지 없이 쭉 가줘 phase 첨부터 끝까지."
접근
- iOS Swift를 사양서로 삼아 Kotlin으로 옮긴다 — 메시지 8종·이벤트 5종·capability 3종·검증 규칙·TTL·오류 문구까지 같게. 다른 것은 전송층과 시스템 부품뿐이며, 그 차이는
android-setup.md"iOS와 다른 점"에 모아 적는다. - 웹 변경은 최소로:
notifyNative가 WebKit 핸들러가 있으면 객체를, 없고window.barbelicNative가 있으면 JSON 문자열을 보낸다. capability 게이트·이벤트·컨트롤러는 손대지 않는다. iOS에 무해하므로 먼저 머지할 수 있다. - Phase를 PR로 나눈다(셸 → 브리지 → 시스템바·뒤로가기·키보드 → 출시). 각 PR은 에뮬레이터 실측으로 닫는다.
- 로그인 없이도 브리지를 검증한다: debug 빌드의 WebView에 Chrome DevTools 프로토콜로 붙어 메시지를 직접 주입하고 CustomEvent 응답을 받는다. 실제 provider 계정은 실기기 단계로 미룬다.
- 비밀은 레포 밖: 업로드 키스토어와 비밀번호는
D:\LiftGuild-2026\secrets\, 레포에는 git 제외keystore.properties참조만 둔다.
4. 적용한 내용
Phase 0 — 환경 (머신, 레포 변경 없음)
winget으로 Android Studio 2026.1.3 + Microsoft OpenJDK 21, cmdline-tools로 헤드리스 SDK(platforms;android-36· build-tools;36.0.0·platform-tools·emulator·system-images;android-36;google_apis;x86_64), AVD barbelic_api36 (pixel_7, -no-window, 부팅 35~85초). 디스크는 C: 57GB 여유 중 약 12GB 사용.
Phase 1 — 셸 부팅 (#605)
npx cap add android스캐폴드 위에 Kotlin 셸.MainActivity가CapConfig.Builder.setServerUrl(https://www.barbelic.com/)로 원격 origin을 연다(iOSinstanceDescriptor()동등).app/build.gradle이BARBELIC_SERVER_ORIGIN·BARBELIC_SUPABASE_AUTH_ORIGIN·BARBELIC_CALLBACK_SCHEME을BuildConfig로 주입(iOS xcconfig 동등), 같은 값이 manifest placeholder로barbelic://auth/callbackintent-filter에 들어간다.singleTask+onNewIntent→ 콜백 메일박스.- 런처 아이콘은 iOS AppIcon 1024px을 정사각 축소(브랜드 파생 규칙 준수) + 검정 바탕 adaptive. Capacitor 기본 스플래시· 예제 테스트 제거, 스플래시는 검정 바탕(iOS LaunchScreen 바탕 동일).
allowBackup=false. - 웹 전송층
nativeTransport()+global.d.tsbarbelicNative타입.profileController문구 "iOS 앱 브리지 구현 후" → "Barbelic 앱에서". npmandroid:sync/android:open/android:build/android:bundle. - gitignore:
capacitor.settings.gradle·app/capacitor.build.gradle(절대 node_modules 경로 포함, sync가 재생성)·local.properties. - 에뮬레이터에서 로그인 화면 렌더 확인. 상태바·내비바가 흰색인 것은 Phase 3로 넘겼다.
Phase 2 — 브리지 동등 (#610)
NativeBridge.kt:WebViewCompat.addWebMessageListener("barbelicNative", {serverOrigin})+addDocumentStartJavaScript(capability 3종). 수신 시isMainFrame·sourceOrigin·현재 WebView URL 3중 검증,v: 1만 처리. 메시지 8종 —startSocialLogin(URL 검증 → 트랜잭션 begin → Custom Tabs),persist/restore/clearNativeAuthSession,nativePkceStorage(get/set/remove ACK),workoutActive(FLAG_KEEP_SCREEN_ON),sessionDownloadImage(D5 토스트),nativeAuthComplete(sign-in 성공 시 300ms 뒤 검정 커버 + 홈 재부팅, link는 유지). 콜백 →barbelic:native-auth재시도 루프(150×200ms,__barbelicNativeAuthEventBridgeInstalled게이트).AppEnvironment.validatedSocialAuthorizationUrl: Supabase origin·/auth/v1/authorize·provider 일치·code_challenge≥43·s256·redirect_to의 auth_action/provider/transaction_id 바인딩; link는 kakao/google/apple 공식 endpoint + 정확한 Supabase/auth/v1/callback.NativeAuthTransactionStore: 앱 전용 SharedPreferences, 10분 TTL·60초 미래 허용, 상수시간 비교, action/provider 일치, 1회 소비.NativeAuthCallback.parse는 fragment → query 폴백, code 또는 error 필수.SecureStore: Android Keystore AES-256-GCM 키(com.dekerd.barbelic.auth.v1)로 암호화한 IV+ciphertext를com.dekerd.barbelic.authSharedPreferences에. 세션(refresh token ≤16KB·userId ≤256)·PKCE verifier(43~128자,/recovery접미 허용, 10분 TTL). 복호·검증 실패 = corrupt → 삭제.- Custom Tabs 취소: 콜백이 없으므로
onResume1초 유예 뒤 미도착이면ok:false "로그인이 취소되었습니다.". MainActivity.load()오버라이드로 첫 내비게이션 전에 브리지 설치.- 계약 테스트 10건(식별자·origin 제한·v1·트랜잭션 바인딩·TTL·URL 검증·Custom Tabs·Keystore·토큰 비노출·웹 전송층).
Phase 3 — 시스템바·뒤로가기·키보드 (#611)
- 흰 바의 원인: Capacitor
SystemBars플러그인이 데코 배경을 테마windowBackground로 다시 칠한다. 테마에windowBackground·statusBarColor·navigationBarColor검정 +setPluginsConfiguration({SystemBars:{style:"dark"}}). WebView 140+는 inset을 CSSenv(safe-area-inset-*)로 통과(viewport-fit=cover), 그 이전은 Capacitor가 패딩. - 시스템 뒤로가기:
OnBackPressedDispatcher콜백이history.state.lg를 읽어root가 아니면 JShistory.back()(웹barbelicNavpopstate가 레이어를 닫음), 루트면moveTaskToBack(true). 다른 문서로 절대 되돌아가지 않는다. - 키보드: manifest
adjustResize+ Capacitor IME inset → WebView 축소. 계약 테스트 +1.
Phase 4 — 출시 준비 (#612)
- 업로드 키스토어(RSA 4096, 2054 만료) 생성 → 레포 밖.
app/build.gradle이keystore.properties가 있을 때만signingConfigs.release적용.versionName 1.0.0.npm run android:bundle→app-release.aab3.2MB,jarsigner -verifyOK. android/play/: 512 아이콘·1024×500 피처 그래픽·로그인 스크린샷.android-release.md: 서명·번들·검증 명령, Play Console 오너 절차 7단계, 데이터 안전 답변 표(개인정보처리방침 v3 기준), 스토어 설명 초안, 실기기 체크리스트, 제외 범위.
작업 중 드러난 것
- 공유 체크아웃 충돌: 세션 시작 시 깨끗하다던 작업 트리에 다른 세션(피드 좋아요·댓글)의 수정이 있었다.
git checkout -b를 즉시 되돌리고 scratchpad 워크트리(node_modules 정션)로 옮겼다. 사이드 브랜치는 처음부터 워크트리. local.properties의sdk.dir은 슬래시 경로여야 한다. 단일 백슬래시면 Gradle이 "파일 이름 구문" IOException으로 죽는다.sdkmanager --licenses는 PowerShell 파이프로는 수락되지 않고 bashyes |가 필요하다.- Chromium 히스토리 조작 개입: 사용자 활성화 없이 넣은
pushState는WebView.canGoBack()이 false. 뒤로가기를goBack()대신 JShistory.back()으로 바꿨다. - 리베이스 force-push 뒤
pull_request런이 생성되지 않는 경우가 있었다 →gh workflow run policy-contract.yml --ref <branch>. - GitHub Actions 결제 오류: #611 디스패치부터 "job was not started because recent account payments have failed or your spending limit needs to be increased". 동일 내용의 성공 런 + 로컬 게이트로 머지하고 PR에 근거를 남겼다. 오너가 예산을 올린 뒤 main이 다른 세션 머지로 계속 움직여 push 런의 verify가 concurrency로 취소되기에,
1355d358에 임시 브랜치를 세워 full lane을 디스패치(전부 success)하고 브랜치를 지웠다. - 워크트리 제거: 정션
rmdir→\\?\장경로로android/app/build삭제 →git worktree remove(Filename too long 회피). - 계정 삭제 기능 부재: 앱에는 데이터 내보내기만 있다. Play는 계정을 만드는 앱에 삭제 요청 웹 URL을 요구한다 — 런북에 개인정보처리방침 v3 권리 행사 절 URL로 대응하되 심사 요구 시 후속 트랙이 필요하다고 적었다.
5. 적용 결과
| 항목 | 결과 |
|---|---|
| Android 앱 | 없음 → android/ Kotlin 셸 + 브리지 v1 동등. gradlew assembleDebug 4.3MB · bundleRelease 3.2MB(서명 verified) |
| 웹 네이티브 탐지 | WebKit 전용 → WebKit 객체 / Android JSON 문자열 2경로. capability 게이트·이벤트 불변, iOS 무영향 |
| 브리지 왕복(에뮬레이터, CDP 주입) | capability 3종 true, restore missing, PKCE set stored → get available(Keystore 암복호), Custom Tab 기동, 취소 ok:false, 모의 콜백 → 트랜잭션 소비 → Production 웹이 code 교환 시도 → nativeAuthComplete 수신, ok:true → 검정 커버 + 홈 재로드, KEEP_SCREEN_ON on/off |
| Production 웹 ↔ Android 실동작 | #605 배포 뒤 주입 없이 웹이 workoutActive·nativePkceStorage·restoreNativeAuthSession 송신. 실제 "Google로 계속하기" 탭 → PKCE 저장 → startSocialLogin(실제 Supabase URL 검증 통과) → Chrome Custom Tab → 취소 → 버튼 3개 pending 해제 |
| 시스템바 | 흰색 → 검정 + 밝은 아이콘(상태바·내비바 행 샘플 27/27 검정) |
| 뒤로가기 | 앱 종료(Capacitor 기본) → 레이어 닫기 / 루트에서 백그라운드(프로세스 유지). 활성화 있는 pushState·없는 pushState 둘 다 닫힘 |
| 키보드 | innerHeight 839 → 527(adjustResize), 뒤로가기로 키보드만 닫힘 |
| 게이트 | Android 계약 테스트 11/11, tsc 0, brand/utf8/dead-css/boundary/unused/manifest 통과, full lane success(32648205335) |
| 마이그레이션·엣지 | 0건 |
6. 이번 개선으로 향상된 것
iOS와 Android가 한 계약을 쓴다
native-bridge.md의 메시지·이벤트·capability가 두 플랫폼에 같다. 웹은 "네이티브인지"만 묻고 플랫폼을 모른다. 새 브리지 기능은 계약 한 줄 + Swift/Kotlin 각 한 곳이다.
세션 보존과 로그인이 iOS와 같은 강도다
Keystore로 암호화한 refresh session·PKCE verifier, 10분 TTL 트랜잭션, URL 검증, 1회 소비, 토큰이 custom scheme에 실리지 않는 규칙 — iOS 계약 테스트와 짝을 이루는 Android 계약 테스트가 scope 레인에서 잠근다.
빌드·검증이 이 머신에서 끝난다
Gradle 빌드, headless 에뮬레이터, CDP 프로브, AAB 서명까지 Windows에서 닫힌다. iOS처럼 "오너 Xcode 빌드 대기"가 없다. 다음 Android 변경은 PR 안에서 에뮬레이터 실측까지 붙여 나온다.
출시 절차가 문서다
android-release.md에 서명·번들·Play Console 단계·데이터 안전 답변·스토어 설명이 있다. 오너가 할 일은 계정과 업로드, 실기기 확인뿐이다.
남은 것
오너가 직접 해야 하는 것:
- Google Play 조직 개발자 계정 — D-U-N-S 번호·사업자 정보, 등록비 $25. → 앱 생성(
Barbelic, 한국어, 무료) → Play App Signing "Google 생성 키" → 내부 테스트 트랙에npm run android:bundle산출app-release.aab업로드. 절차·입력값은android-release.md. - 실기기 체크리스트 — 갤럭시·픽셀 각 1대. Kakao·Google·Apple 실제 계정 로그인(이 트랙에서 유일하게 미검증), 앱 완전 종료 후 세션 복원, provider 연결, 로그아웃, 운동 중 화면 유지, 뒤로가기·키보드.
- 계정 삭제 정책 판단 — 앱 내 계정 삭제 기능이 없다. 첫 제출은 개인정보처리방침 v3 URL로 대응하고, Play 심사가 앱 내 삭제 경로를 요구하면 프로필 "계정 삭제"(RPC + 웹 페이지) 후속 트랙 발주.
- 업로드 키스토어 백업 —
D:\LiftGuild-2026\secrets\barbelic-android-upload.jks+barbelic-android-keystore.properties를 이 머신 밖에도 보관. Play App Signing이라 분실 시 재설정은 가능하지만 절차가 길다. - 스토어 스크린샷 — 로그인 후 홈·기록·일지 화면을 실기기에서 캡처(현재는 로그인 화면 1장).
트랙 밖(후속 발주 시):
- 세션 이미지 저장(D5 제외분): WebView 캡처 + MediaStore 저장 — Phase 하나 분량.
- 갤럭시 WebView(삼성 인터넷 아님 — Android System WebView) 실측은 실기기 체크리스트에 포함.