전체 글
약 6분

AX 엔지니어링 1 — 형제 앱의 부품으로 새 앱을 올리는 공식

한 사람과 AI가 형제 앱의 디자인 컴포넌트, 광고·결제·평가·업데이트 문지기, 다국어 구조와 출시 파이프라인으로 APK 추출기를 만들었습니다. 첫 커밋 다음 날 밤 비공개 테스트 트랙에 올리기까지 고친 것들과, 다른 앱도 쓰게 된 스토어 이미지 엔진을 기록합니다.

#AX #Android #CI/CD #AdMob #디자인 시스템 #다국어 #출시

2026년 10월 26일 밤 11시 10분에 APK 추출기의 첫 커밋을 남겼습니다. 다음 날 밤 9시 48분, develop에서 main으로 합친 코드를 푸시했습니다. GitHub Actions가 번들을 만들어 Google Play 내부 테스트 트랙에 올리고, 같은 번들을 비공개 테스트(alpha)로 옮겼습니다.

APK 추출기는 폰에 설치된 앱을 설치 파일로 꺼내 저장하거나 공유하는 앱입니다. Twinkle AI Labs에서는 제가 방향을 정하고 AI와 함께 제품을 만듭니다. 이번에는 Claude와 Codex가 형제 앱의 부품을 가져와 새 앱에 맞추고, 코드와 문구를 대조하며 작업했습니다. 한 사람과 AI가 첫 커밋 다음 날 밤에 비공개 테스트 트랙까지 올릴 수 있었던 과정을 적습니다.

첫 커밋은 환율 계산기의 몸이었습니다

첫 커밋 메시지에는 «환율 계산기의 몸 · 앱 읽기와 파일 이름의 셈»이라고 적었습니다. 디자인 토큰(ui/theme), 공통 컴포넌트(ui/components), 설정 화면의 줄, 약관 화면과 광고 배선을 환율 계산기에서 가져왔습니다. 옮긴 부품의 패키지 이름은 kr.twinklelabs.apkextractor로 바꿨습니다. 앱 목록을 읽고 APK를 저장·공유하는 기능은 이 앱에 맞춰 구현했습니다.

디자인 시스템 Aurora Ledger의 색, 반지름, 간격, 글자 크기는 토큰으로 정의되어 있습니다. 컴포넌트와 토큰을 함께 옮겼으므로 설정의 줄이나 버튼을 다시 디자인할 필요가 없었습니다. 앱 아이콘과 UI 아이콘도 루트의 공통 도구인 brand/app-icon.mjs와 brand/ui-icons.py로 만들었습니다.

네 개의 문지기를 세 앱에서 가져왔습니다

27일 새벽에는 광고·결제·평가·업데이트를 붙였습니다. 요청은 각각 짧았습니다.

  • «추출 한 번 할 때마다 전면광고»
  • «광고 제거 3900원 추가하자 형제앱처럼»
  • «앱 추출할때마다 바로 평가기능 추가»
  • «자동업데이트 기능도 넣어줘»

전면 광고의 InterstitialGate는 Pocket PDF에서, 결제의 BillingGate는 환율 계산기에서 가져왔습니다. 평가의 ReviewGate와 인앱 업데이트의 UpdateGate는 물타기 계산기를 바탕으로 했습니다. 이 파일들은 SDK를 호출할 시점과 조건을 맡는 문지기입니다. 출처와 변경 이유도 주석에 남겼습니다. BillingGate.kt의 일부입니다.

/**
 * 광고 제거 결제의 문지기 — **환율 계산기의 것을 그대로 옮겼다** (2026-10-27, Founder «광고 제거 3900원 추가하자 형제앱처럼»).
 * 값은 여기 적지 않는다 — Play 콘솔의 상품 값(3,900원)을 스토어가 알려 주는 대로 보인다([priceText]).
 */

3,900원은 당시 콘솔에 정한 상품 가격이고, 표시할 값은 스토어에서 받아옵니다. 광고 제거를 샀는지 아직 읽지 못한 동안에는 배너도 보이지 않도록 했습니다. 이 판단은 나중에 AdPlacement.kt의 순수 함수로 떼어 테스트했습니다.

internal fun showsAdsOf(isAdsRemoved: Boolean?, hasAdUnits: Boolean, availability: ConsentGate.Availability): Boolean =
    isAdsRemoved == false && hasAdUnits && availability != ConsentGate.Availability.UNAVAILABLE

구매 여부를 모르는 null 상태에서는 이 함수가 false를 돌려줍니다.

전면 광고는 저장이 끝났거나 공유할 파일이 준비된 뒤에 표시합니다. 미리 불러온 광고가 있고 앱이 앞에 떠 있을 때만 보여 주며, 광고를 기다리게 하지는 않습니다. 저장 토스트와 공유 창은 광고가 닫힌 뒤에 열립니다.

평가를 요청하는 빈도는 바꿨습니다. 물타기 계산기에서는 다섯 번째 저장과 첫 공유에 요청하도록 제한했지만, 여기서는 추출마다 요청합니다. 실제로 평가 창을 보여 줄지는 Google의 할당량에 달려 있습니다. ReviewGate는 저장 토스트가 뜬 뒤, 공유라면 공유 창에서 돌아온 뒤에 요청합니다.

UpdateGate는 내려받는 동안 앱을 계속 쓸 수 있는 유연한 업데이트를 기본으로 하고, 다운로드가 끝나면 재시작할지 묻습니다.

약관과 열한 언어도 같은 벌입니다

이용약관과 개인정보 처리방침은 참고한 앱의 EULA에서 백업 용도, 꺼낸 앱의 권리, 제3자 광고, 인앱 결제 환불, 면책 조항을 골라 각각 12조와 9절로 썼습니다. 정본은 약관 사이트 Polaris에 두고, 앱에는 사본을 넣었습니다. 약관 화면은 사본을 먼저 보여 준 뒤 정본을 받아 갱신하는 구조를 이어 썼습니다.

다국어는 «다국어도 pocket pdf 처럼 세워줘»라는 요청으로 시작했습니다. Pocket PDF의 열한 언어 구성과 폴더 구조, 언어 선택 서랍, Localized를 가져왔습니다. 앱 안에서 언어를 고르면 LocalContext, LocalConfiguration, LocalResources를 함께 바꿔 화면에 바로 반영합니다. 번역문은 APK 추출기의 기능에 맞춰 새로 채웠습니다.

한국어를 제외한 열 언어는 번역가 역할의 AI가 한 언어씩 맡았습니다. 언어마다 빠짐과 형식, 화면과 말투, 법적 충실성을 차례로 검토해 모두 서른 번 점검했습니다. scripts/i18n-check.py는 빠진 항목과 자리 표시자, 복수형, 한글 잔재를 검사하고, AI 점검자는 원문과 번역문을 대조했습니다.

이 과정에서 한국어 원본도 고쳤습니다. 영어, 독일어, 프랑스어, 이탈리아어, 포르투갈어 번역가가 «앱 n개»를 «Apps: 3» 같은 꼴로 옮겨 왔습니다. 원본이 <string>이라 단수와 복수를 나눌 자리가 없었습니다. 수가 붙는 여덟 항목을 <plurals>로 바꾸자 번역도 자연스러운 단수·복수 문장으로 쓸 수 있었습니다.

가져온 부품에 테스트가 닿게

같은 날 새벽, «SOLID & SRP 점검 개선 및 리팩토링 진행 50 사이클»을 맡겼습니다. 읽기 전용 점검에서 나온 66항목 중 50항목을 한 사이클에 한 커밋씩 고쳤습니다. 테스트는 180개에서 265개로 늘었고, 매 사이클 단위 테스트와 디버그 빌드를 통과시켰습니다.

먼저 테스트가 없던 뷰모델과 저장소에 테스트를 붙였습니다. 이어 전면 광고·결제·업데이트·망 감시가 액티비티의 수명주기를 직접 듣도록 바꿨습니다. InterstitialGate.kt의 주석에 이전 구조를 바꾼 이유가 남아 있습니다.

/**
 * **제 수명은 스스로 듣는다** (2026-10-27) — [lifecycle] 이 끝나면 실어 둔 광고를 내려놓는다([close]).
 * ↩ 공개 `clear()` 였고 `MainActivity.onDestroy` 가 손으로 불렀다 (2026-10-27 까지) — 문지기 다섯이 «놓아 준다»를
 * `clear` · `stop` · `close` · `onDestroy` 네 동사로 부르고 있었다.
 */

이제 전면 광고 문지기는 Lifecycle이 끝나는 알림을 받아 스스로 광고를 내려놓습니다. 액티비티에서 정리 함수를 하나씩 호출하던 코드도 걷었습니다.

출시 파일도 형제 앱에서 가져왔습니다

release.yml은 환율 계산기에서 왔고, 그쪽은 Pocket PDF에서 가져온 파일입니다. main에 앱 코드를 푸시하면 GitHub Actions가 서명 파일을 Secrets에서 복원하고 테스트를 돌린 뒤, 번들을 내부 테스트 트랙에 올립니다. 이어 같은 번들을 alpha로 옮깁니다.

versionCode는 빌드 스크립트의 lastManualVersionCode에 GitHub Actions의 실행 번호를 더합니다. android/app/build.gradle.kts에는 다음과 같이 적혀 있습니다.

val lastManualVersionCode = 1
versionCode = System.getenv("GITHUB_RUN_NUMBER")?.toIntOrNull()
    ?.plus(lastManualVersionCode) ?: lastManualVersionCode
versionName = "1.0.2"

워크플로도 lastManualVersionCode를 이 파일에서 grep으로 읽습니다. 기준값을 두 곳에서 관리하지 않으므로 출시 때 사람이 정하는 번호는 versionName입니다.

첫 번들 1.0.0(1)은 27일 로컬에서 만들어 내부 테스트 초안으로 올렸고, 같은 날 비공개 테스트 검토에 제출했습니다. 앱 등록과 AdMob 단위 발급, 데이터 보안과 콘텐츠 등급을 포함한 Play 선언 열 개는 브라우저를 연결한 Claude가 진행했습니다. 스토어 자리를 만드는 일은 자동 출시를 시작하기 전에 필요했습니다.

그날 밤 1.0.1은 워크플로의 첫 실행으로 versionCode 2가 됐습니다. 28일의 1.0.2는 두 번째 실행으로 3이 됐습니다. 두 번 모두 develop에서 main으로 --no-ff 머지 후 푸시해 올렸고, 업로드할 때 콘솔을 열지는 않았습니다.

1.0.1 실행은 마지막 번들 보관 단계에서 GitHub 아티팩트 저장 한도에 걸렸습니다. 내부 테스트 업로드와 alpha 승격은 성공했지만 실행 전체는 실패로 표시됐습니다. 다음 날 보관 단계를 걷은 뒤 1.0.2 실행은 전부 통과했습니다. 다만 두 출시 모두 콘솔의 게시 개요와 검토 상태까지 확인한 기록은 없습니다. 여기서 말하는 비공개 테스트 업로드는 CI 로그의 [completed]를 기준으로 합니다.

복사한 뒤에야 드러난 것들

형제 앱에서 쓰던 부품이어도 새 앱에서 확인할 일은 남았습니다. 루트의 admob-ids.sh와 policy-bundle.sh부터 그랬습니다. 광고 단위 ID와 약관 사본을 검사하는 스크립트인데, 검사 대상 표에 APK 추출기가 없었습니다. 통과했다는 결과에 이 앱은 포함되지 않았습니다. 표에 추가하고 다시 검사했습니다.

디버그 빌드에서는 보이지 않던 오류도 있었습니다. 광고 SDK가 의존하는 WorkManager 2.7.0은 Room 2.2.5를 쓰는데, 출시 빌드의 코드 축소 도구 R8이 WorkDatabase_Impl을 지웠습니다. Room이 이름으로 찾는 클래스가 사라져 앱이 켜자마자 종료됐습니다. 디버그 빌드는 코드를 줄이지 않아 같은 오류가 나지 않았습니다.

임시 서명한 출시 빌드를 에뮬레이터에서 실행해 문제를 찾고 고쳤습니다. 실제 광고 단위가 들어 있으므로 네트워크는 끈 상태였습니다. 1.0.1을 올리기 전에도 같은 방식으로 출시 빌드를 켜서 저장과 공유 창까지 확인했습니다.

문서에도 고칠 곳이 있었습니다. 제품의 CLAUDE.md와 매니페스트 주석은 네트워크를 쓰는 기능이 광고뿐이라고 적고 있었습니다. 스토어 등록정보 초안도 그 말을 따라 썼습니다. Codex에게 초안을 코드와 대조하게 했을 때 나온 열한 지적 중 하나가 이 문장이었습니다. 약관 정본을 받는 연결과 Play의 결제·평가·업데이트가 빠져 있었습니다. 문서를 고치고, 옛 문장은 되돌린 결정이라는 표시인 ↩와 함께 남겼습니다.

스토어 이미지는 다음 앱의 부품이 됐습니다

스토어 이미지를 만드는 도구는 이번 앱에서 새로 만들었습니다. Robolectric 테스트가 실제 Compose 화면을 1080×2400으로 찍으면, HTML 틀이 폰 몸체와 문구를 얹고 헤드리스 Chrome이 최종 PNG를 만듭니다. 한 언어당 폰 스크린샷 일곱 장과 그래픽 이미지 한 장입니다. 틀과 배치는 공통으로 쓰고, 원본 화면과 copy.json의 문구를 언어마다 바꿉니다.

처음에는 Codex에게 촬영부터 합성까지 맡겼습니다. 그러나 그 작업 환경에서는 Gradle의 잠금 파일 접근과 Chrome 실행이 막혀 이미지가 나오지 않았습니다. 그래서 «스크린샷 같은 건 너가 찍고 Codex 는 최종 이미지 뽑을 때만 시켜»라고 작업을 나눴습니다. Claude가 촬영과 최종 렌더링을 실행하고, Codex는 frame.html과 layout.json을 고친 뒤 결과 PNG를 보며 디자인을 다듬었습니다.

디자인은 다섯 판을 거쳤습니다. 세 번째 판을 중간에 멈춘 뒤 제가 레퍼런스 다섯 장을 보여 줬습니다. 로고샵, Soundok, 당근마켓, 현대카드 웨더, houseapp이었습니다. 네 번째 판부터 첫 장의 보라 배경, 기울인 폰이 둘째 장으로 이어지는 구도, 강조 단어 아래의 형광펜이 들어갔습니다. 마지막 판에서는 기울인 폰이 흐려지는 문제와 문구 주변의 간격을 고쳤습니다.

당시 만든 이미지는 한국어와 영어 열여섯 장이며, 영어는 초안이었습니다. 나머지 아홉 언어의 이미지 문구는 아직 비어 있었습니다.

28일에는 제가 «우리가 스토어 이미지 만든거 있잖아 다른 앱에도 똑같은 공식을 사용하자»고 했습니다. 엔진을 루트의 brand/store/로 옮기고, 제품에는 경로와 촬영법을 적은 product.json, 문구인 copy.json, 구도인 layout.json을 두었습니다. 같은 원본으로 APK 추출기의 열여섯 장을 다시 만들었더니 옮기기 전과 해시가 모두 같았습니다.

물타기 계산기, Pocket PDF, 환율 계산기도 같은 날 이 엔진으로 이미지를 만들었습니다. 기록 시점에는 앱별 작업 브랜치의 develop 머지가 남아 있었습니다. APK 추출기가 형제 앱의 부품을 받아 시작했다면, 이번에 만든 이미지 도구는 그 형제들이 함께 쓸 부품으로 남았습니다.

사람이 한 일

이틀 동안 제가 한 일은 결정을 말하는 것이었습니다. 전면 광고를 추출마다, 광고 제거 3,900원, 공유는 zip 하나로, 글자 배율은 1.0 고정, 레퍼런스 다섯 장. 그 말들은 커밋 메시지와 코드 주석에 «…» 안에 그대로 인용되어 있습니다.

AI가 첫 커밋 다음 날 밤까지 앱을 조립할 수 있었던 것은 앞선 세 앱이 부품을 검증된 꼴로 두고 있었고, 부품마다 출처와 이유가 파일 머리에 적혀 있었고, 어긋남을 사람이 아니라 스크립트와 출시 빌드와 다른 AI의 검수가 세고 있었기 때문입니다. 이 범주에서는 그 일을 AX 엔지니어링이라고 부르고, 앱마다 무엇을 물려받고 무엇을 새로 세웠는지 적어 가려고 합니다.