native 앱 빌드·배포 구성
apps/native-app 은 Expo CNG(Continuous Native Generation) 기반이라 ios/·android/ 네이티브 폴더를 커밋하지 않는다. 대신 네이티브 설정 전부를 app.config.ts + plugins/*.js(config plugins) 로 선언하고, prebuild 가 매번 그로부터 네이티브 프로젝트를 재생성한다.
이 문서는 그 배선을 정리한다: 환경(APP_ENV) 분기, config plugins, 릴리즈 제어판, fastlane lane, dev client vs release 빌드, CI 캐시.
- OTA(self-hosted Expo Updates) 상세: OTA
- Metro/의존성 shim: Metro & 의존성
- 전체 개요: native 앱
CNG — ios/android 는 산출물
CNG 모델에서 ios/·android/ 는 prebuild 의 산출물이며 gitignore 대상이다(소스 아님). 네이티브 쪽을 바꾸려면 폴더를 직접 수정하는 게 아니라:
- Expo 표준 설정이면
app.config.ts의 필드/plugin 옵션으로, - 표준 설정으로 안 되는 것(Podfile 텍스트, MainActivity.kt, build.gradle 등)은
plugins/*.jsconfig plugin 으로 주입한다.
// package.json scripts (발췌)
"prebuild:clean": "expo prebuild --clean", // ios/android 재생성
"prebuild:clean:android": "expo prebuild --clean --platform android"--clean 은 매번 ios/·android/ 를 비우고 다시 만든다. 그래서 build.gradle·Podfile·MainActivity 등에 대한 수정은 폴더에 남지 않고, config plugin 이 prebuild 시점마다 재적용되어야 유지된다. (예: withAndroidGradleMemory 는 gradle.properties 가 매번 재생성되므로 plugin 으로 주입한다.)
config plugin 은 대부분 앵커 정규식으로 생성된 네이티브 파일을 찾아 텍스트를 주입한다. Expo/RN 템플릿이 바뀌어 앵커를 못 찾으면 조용히 넘어가지 않고 throw 하도록 짜여 있다(
replaceOrThrow헬퍼,withAndroidBackToBackground등). “prebuild 가 실패한다”면 템플릿 변경으로 plugin 앵커가 깨졌을 가능성을 먼저 본다.
APP_ENV — 환경별 정체성 분기
APP_ENV(local | development | qa | production | canary)가 단일 진실 공급원이다. app.config.ts 가 이 값으로 앱 정체성을 분기한다. prebuild/빌드 실행 시 APP_ENV 환경변수로 주입한다(scripts 는 APP_ENV=... EXPO_PUBLIC_APP_ENV=... 을 함께 세팅).
APP_ENV: prebuild 시점에app.config.ts가 읽어 name/icon/bundleId 등 네이티브 정체성을 결정.EXPO_PUBLIC_APP_ENV: expo 가 JS 번들에 정적 인라인. release 빌드에서Constants.expoConfig가 null 이 되어도 런타임이 env 를 안전하게 결정하기 위한 fallback.
핵심 규칙은 production 만 base bundle id, 나머지는 모두 .dev suffix 다:
// app.config.ts
const BASE_BUNDLE_ID = 'co.howmuchhome.HowmuchhomeApp';
const bundleIdentifier = isProd ? BASE_BUNDLE_ID : `${BASE_BUNDLE_ID}.dev`;| 항목 | local | development | qa | production | canary |
|---|---|---|---|---|---|
| bundle id / package | ...HowmuchhomeApp.dev | .dev | .dev | ...HowmuchhomeApp | .dev |
| 앱 이름 | 얼마집 Dev | 얼마집 Dev | 얼마집 QA | 얼마집 | 얼마집 Canary |
| Firebase | dev | dev | dev | prod | dev |
| icon | dev | dev | dev | prod | dev |
| URL scheme(app) | howmuchhomeappdev | dev | dev | howmuchhomeapp | dev |
| Airbridge subdomain | howmuchhomedev | dev | dev | howmuchhome | dev |
| OTA updates | 비활성 | development 채널 | development 채널 | production 채널 | development 채널 |
| OTA manifest URL | (없음) | dev-front-api | dev-front-api | front-api | dev-front-api |
scheme 은 항상 ['howmuchhome', APP_URL_SCHEME] 두 개(expo-router 딥링크용 howmuchhome + airbridge/앱 custom scheme). iOS associatedDomains 와 Android intentFilters 는 Airbridge subdomain({subdomain}.abr.ge / .airbridge.io) 을 universal/app link 로 등록한다.
canary 의 특수성
canary 는 prod 서버 + develop OTA 조합이다. 정체성은 dev(.dev bundle id, dev firebase, 얼마집 Canary 이름)지만 콘텐츠는 development 채널 OTA 를 따라간다.
OTA 번들이 development 로 와도 JS 환경(EXPO_PUBLIC_APP_ENV/expoConfig.extra)이 덮여쓰이므로, 런타임 canary 식별은 JS env 가 아니라 네이티브 앱 이름(Application.applicationName === '얼마집 Canary', src/config/env.ts)으로 한다. 그래서 NAME_BY_ENV.canary('얼마집 Canary')는 env.ts 의 CANARY_APP_NAME 과 반드시 일치해야 한다. 자세한 내용은 OTA 문서를 본다.
config plugins
app.config.ts 의 plugins 배열에 배선된 로컬 config plugin(./plugins/with*) 목록. Expo 표준 plugin(expo-font, expo-camera, @react-native-firebase/*, @sentry/react-native/expo 등)은 제외한 자체 작성 plugin 만.
| plugin | 플랫폼 | 목적 |
|---|---|---|
withAndroidPictureInPicture | Android | MainActivity 에 supportsPictureInPicture="true" + PiP configChanges 추가(총회 IVS 영상 PiP). |
withAndroidBackToBackground | Android | 루트 뒤로가기를 moveTaskToBack(홈 버튼과 동일)으로 통일. 기본 finish() 로 인한 딥링크 재진입 흰 화면/크래시 방지. |
withAndroidKillOnTaskRemoved | Android | 최근앱 스와이프로 task 제거 시 프로세스 확정 종료(onTaskRemoved) → 재실행이 항상 cold start(홈). |
withExternalAppSchemes | 양쪽 | 외부 앱 스킴(kakaotalk/kakaoplus/supertoss/naversearchapp)을 Android <queries> + iOS LSApplicationQueriesSchemes 에 선등록(openURL/canOpenURL, 재빌드로 선반영). |
withModularHeaders | iOS | Podfile 에 use_modular_headers! 주입. static framework + @react-native-firebase Swift pod 조합의 module map 문제 해결. |
withChannelIoMavenRepo | Android | root build.gradle 에 maven.channel.io 저장소 주입(ChannelTalk SDK, io.channel/com.zoyi content filter). |
withChannelIoInit | Android | MainApplication.onCreate 에 ChannelIO.initialize(this) 주입(미초기화 시 NOT_INITIALIZED). |
withChannelIoInitIos | iOS | Swift AppDelegate didFinishLaunching 에 ChannelIO.initialize(application) 주입. |
withAirbridgeMavenRepo | Android | root build.gradle 에 Airbridge maven 저장소 주입(io.airbridge content filter). |
withAirbridgeAndroidInit | Android | MainApplication 에 initializeSDK, MainActivity 에 onNewIntent/onResume + trackDeeplink 주입(딥링크 트래킹). |
withAirbridgeIosInit | iOS | Swift AppDelegate 에 initializeSDK + open url/universal link trackDeeplink 주입. |
withAndroidReleaseSigning | Android | app build.gradle 에 signingConfigs.release 추가하고 buildTypes.release.signingConfig 를 release 로 연결(keystore.properties 또는 env 로 keystore 로드). |
withAndroidNetworkSecurityConfig | Android | cleartext 도메인 화이트리스트(network_security_config.xml) 작성. release=화이트리스트(localhost/naver/google/kakao postcode 등), debug=전체 허용(Metro dev 서버 연결용). |
withAndroidGradleMemory | Android | gradle.properties 의 org.gradle.jvmargs 를 키워 release lint(lintVitalAnalyzeRelease) OOM(Metaspace) 방지. |
withFcmDefaultNotificationChannel | Android | FCM 표시형 푸시 기본 채널을 normal(DEFAULT importance)로 지정(tools:replace 로 firebase 라이브러리 값 override). push.ts 의 DEFAULT_NOTIFICATION_CHANNEL_ID 와 일치. |
withNotifeeMavenRepo | Android | root build.gradle 에 notifee 로컬 AAR(@notifee/react-native/android/libs) maven 저장소 주입(node_modules 상향 탐색, hoist 여부 무관). |
plugins/replaceOrThrow.js와plugins/babel-plugin-rn-svg-icons.js는 config plugin 이 아니다(각각 앵커 치환 헬퍼, babel 플러그인).plugins/withAllowBackupReplace.js파일은 존재하지만 현재app.config.ts의plugins배열에 배선되어 있지 않다 — 배선된 것만 prebuild 에 반영된다.
푸시/딥링크(Airbridge·FCM·notifee) 런타임 동작은 푸시 알림 & 딥링크 라우팅 문서를 본다.
릴리즈 제어판 — release.config.js
버전·pre-release 등 매 릴리즈 사람이 직접 손대는 값만 release.config.js 에 모아둔다. app.config.ts 가 이 값을 import 한다.
// release.config.js
const APP_VERSION = '4.0.0'; // 마케팅 버전(iOS CFBundleShortVersionString / Android versionName)
const ANDROID_VERSION_CODE = 2100000031; // 매 스토어 빌드마다 반드시 증가
const IOS_BUILD_NUMBER = '1'; // iOS CFBundleVersion
const IS_PRERELEASE = true; // 스토어 미출시 여부
.js인 이유: expo config 로더는 진입 파일(app.config.ts)만 트랜스파일하고 상대 import 는 런타임require로 해석해 sibling.ts를 못 읽는다. 값은.js, 타입은release.config.d.ts에 둔다.
runtimeVersion 과 IS_PRERELEASE
runtimeVersion 은 fingerprint 가 아니라 APP_VERSION 문자열을 그대로 쓴다. fingerprint 는 빌드 환경(macOS 로컬 vs Linux CI)마다 해시가 달라져 self-hosted OTA(발행=CI) 와 빌드의 runtimeVersion 이 불일치 → OTA 미전달 문제가 있었기 때문이다. APP_VERSION 은 환경 무관 결정적 값이다.
IS_PRERELEASE 는 runtimeVersion 의 -pre suffix 하나로 두 가드를 동시에 제어한다:
runtimeVersion: IS_PRERELEASE ? `${APP_VERSION}-pre` : APP_VERSION,true→runtimeVersion=<ver>-pre→ OTA fingerprint 가드 우회(같은 pre 트랙 내 네이티브 자유 변경) + fastlanedeploy(스토어 제출) lane 이 이 빌드를 거부. pre 트랙은 정식 트랙과 격리돼 스토어 baseline 을 오염시키지 않는다.false→ 정식 트랙 → OTA fingerprint 가드 ON + 스토어 배포 허용.
스토어 실제 출시 시 반드시 false 로 바꾸고 재빌드한다(runtimeVersion 은 빌드에 박히는 값). APP_VERSION·OTA fingerprint 연계 상세는 OTA 를 본다.
fastlane lanes
fastlane/Fastfile. 호출: fastlane ios <lane> / fastlane android <lane>. 모든 lane 은 진입 시 자기 env 로 expo prebuild --clean 을 강제 재실행해(configure_app_env → expo_prebuild) 산출물이 빌드 env 와 항상 일치하게 한다. env 는 오직 APP_ENV(v1 의 .env.*/ENVFILE/productFlavors 패턴 미사용).
| lane | env | iOS 배포처 | Android 배포처 |
|---|---|---|---|
beta_development | development | Firebase App Distribution | Firebase App Distribution |
beta_qa | qa | Firebase App Distribution | Firebase App Distribution |
beta_canary | canary | Firebase App Distribution | Firebase App Distribution |
beta_production_release | production | Firebase App Distribution | Firebase App Distribution |
internal_test_qa | qa | TestFlight | Play internal 트랙 |
internal_test | production | TestFlight | Play internal 트랙 |
internal_app_sharing_qa | qa | — | Play Internal App Sharing |
internal_app_sharing | production | — | Play Internal App Sharing |
deploy | production | App Store 제출 | Play production(draft) |
Firebase App Distribution 은 ad-hoc(iOS)/APK(Android) 빌드를,
internal_test*는 App Store Connect/Play 업로드(iOS=IPA→TestFlight, Android=AAB→internal)를 만든다.internal_app_sharing*는 Android 전용(AAB → Play Internal App Sharing 링크).
deploy 는 pre-release 를 차단
iOS·Android deploy lane 은 assert_not_prerelease 를 먼저 호출한다. npx expo-updates runtimeversion:resolve 결과가 -pre 로 끝나면 raise 하여 스토어 제출을 막는다:
if rv.end_with?('-pre')
raise "pre-release 빌드(runtimeVersion=#{rv})는 스토어에 배포할 수 없습니다. ..."
endIS_PRERELEASE=true 인 상태로 스토어에 올리려 하면 여기서 걸린다 → release.config.js 에서 false 로 바꾼 뒤 재시도한다.
fastlane 헬퍼(요지)
configure_app_env(app_env):APP_ENV+EXPO_PUBLIC_APP_ENV를 ENV 에 직접 세팅(이후 spawn 되는 prebuild/gradle/sentry-cli 가 상속).expo_prebuild(platform):configure_app_env선행 후expo prebuild --clean --platform.- iOS signing: prebuild 산 pbxproj 은 automatic signing 기본값이라 archive 가 깨진다 →
match(adhoc/appstore) + manual signing xcargs 로 강제. - Android signing:
copy_android_release_signing이android-config/(보존) →android/(prebuild —clean 이 매번 비움) 로 keystore/properties 복사. - release 빌드는 Sentry source map 을 업로드하므로
require_sentry_auth_token이 lane 시작 시 토큰 부재를 빠르게 차단, iOS 는ios_upload_dsym_to_sentry로 dSYM 업로드.
dev client vs release 빌드
- dev client(
expo-dev-client): Metro 번들러에 붙는 개발 빌드.metro:local로 Metro 를 띄우고run:local:*로 붙거나,android:*/ios:*(prebuild + run) 로 빌드한다.EXPO_PUBLIC_STORYBOOK/EXPO_PUBLIC_E2E플래그로 on-device Storybook·E2E 진입도 여기서. - release 빌드:
android:production/ios:production은--variant release/--configuration Release로 빌드하며 keystore 복사·SENTRY_DISABLE_AUTO_UPLOAD=true등 로컬 release 검증용 세팅을 포함한다. 실제 배포용 서명/업로드는 fastlane lane 을 쓴다. - EAS:
build:*(eas build --profile ...) 로도 클라우드 빌드가 가능하다.
주요 로컬 실행 script(package.json):
"metro:local": "APP_ENV=local … expo start --dev-client",
"android:development":"… expo prebuild --clean --platform android && expo run:android",
"android:production": "… --variant release …(keystore 복사 포함)",
"ios:production": "… expo run:ios --configuration Release",
"ota:export": "expo export --platform ios --platform android --source-maps",
"ota:release": "node scripts/release-update.mjs"CI
두 워크플로 모두 .github/workflows/ 에 있고, turbo 의존성 그래프로 native-app 영향을 정밀 판정한다(packages/** glob 은 superset 오탐이 있어 turbo 로 좁힘).
native-app-export-check.yml: develop 로 향하는 PR 에서expo export(=ota:export) 로 RN JS 번들이 깨지지 않는지 검증(발행 없음). paths 필터를 두지 않고(required check 가 paths skip 되면 보호규칙이 영구 pending) 단일 job 안에서 turbo 로 미영향이면 install/export 를 건너뛰고 즉시 green.native-app-ota.yml: develop push → development 채널, main push → production 채널 OTA 발행(export → R2 업로드 → 등록). 상세는 OTA.
CI 캐시 함정 — 새 워크스페이스 패키지
두 워크플로의 node_modules 캐시 키는 yarn.lock 뿐 아니라 워크스페이스 package.json 들도 해시한다:
key: ${{ runner.os }}-node-modules-${{ hashFiles('yarn.lock', 'package.json', 'apps/*/package.json', 'packages/*/package.json') }}의존성 없는 새 워크스페이스 패키지를 추가하면 yarn.lock 이 안 바뀐다. yarn.lock 만 키로 쓰면 캐시 HIT → install 스킵 → 새 패키지 심링크가 없는 stale node_modules 로 빌드가 깨진다(Unable to resolve). 그래서 package.json 들을 키에 포함해 새 패키지 추가 시 캐시가 무효화되게 한다.
모노레포 배포 독립성
같은 브랜치(develop/main)에 native-app 과 web 앱(app·dashboard·api·short-link)이 함께 있지만, 배포는 앱별로 독립이다. native-app 의 버전 관리나 스토어 심사 대기가 다른 앱 배포를 막지 않는다.
- web 앱들은 각각 별도 Vercel 프로젝트로 배포된다(PR 체크의
Vercel – howmuchhome-web-app / -dashboard / -front-api / -short-link). develop/main 머지 시 영향받은 앱만 즉시 배포되며, native 스토어 심사 상태와 무관하다. - native 스토어 빌드(fastlane)·OTA(별도 워크플로)는 web 배포를 게이팅하지 않는다.
- CI 체크도 turbo 그래프로 조건부라, dashboard-only 변경이면 native export/E2E 잡이 스킵되고 그 역도 성립 → 서로의 체크가 머지를 막지 않는다.
즉 “브랜치 공유 ≠ 배포 결합”이다. 유일하게 신경 쓸 결합은 native 내부의 OTA 스트랜딩(심사 중 APP_VERSION 을 조기 상향하면 라이브 버전 OTA 가 끊김)이며, 이는 OTA 문서의 릴리즈 절차로 관리한다.