Skip to Content
Native Appnative 앱 빌드·배포 구성

native 앱 빌드·배포 구성

apps/native-appExpo 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 캐시.

CNG — ios/android 는 산출물

CNG 모델에서 ios/·android/prebuild 의 산출물이며 gitignore 대상이다(소스 아님). 네이티브 쪽을 바꾸려면 폴더를 직접 수정하는 게 아니라:

  1. Expo 표준 설정이면 app.config.ts 의 필드/plugin 옵션으로,
  2. 표준 설정으로 안 되는 것(Podfile 텍스트, MainActivity.kt, build.gradle 등)은 plugins/*.js config 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 시점마다 재적용되어야 유지된다. (예: withAndroidGradleMemorygradle.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`;
항목localdevelopmentqaproductioncanary
bundle id / package...HowmuchhomeApp.dev.dev.dev...HowmuchhomeApp.dev
앱 이름얼마집 Dev얼마집 Dev얼마집 QA얼마집얼마집 Canary
Firebasedevdevdevproddev
icondevdevdevproddev
URL scheme(app)howmuchhomeappdevdevdevhowmuchhomeappdev
Airbridge subdomainhowmuchhomedevdevdevhowmuchhomedev
OTA updates비활성development 채널development 채널production 채널development 채널
OTA manifest URL(없음)dev-front-apidev-front-apifront-apidev-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.tsCANARY_APP_NAME반드시 일치해야 한다. 자세한 내용은 OTA 문서를 본다.

config plugins

app.config.tsplugins 배열에 배선된 로컬 config plugin(./plugins/with*) 목록. Expo 표준 plugin(expo-font, expo-camera, @react-native-firebase/*, @sentry/react-native/expo 등)은 제외한 자체 작성 plugin 만.

plugin플랫폼목적
withAndroidPictureInPictureAndroidMainActivity 에 supportsPictureInPicture="true" + PiP configChanges 추가(총회 IVS 영상 PiP).
withAndroidBackToBackgroundAndroid루트 뒤로가기를 moveTaskToBack(홈 버튼과 동일)으로 통일. 기본 finish() 로 인한 딥링크 재진입 흰 화면/크래시 방지.
withAndroidKillOnTaskRemovedAndroid최근앱 스와이프로 task 제거 시 프로세스 확정 종료(onTaskRemoved) → 재실행이 항상 cold start(홈).
withExternalAppSchemes양쪽외부 앱 스킴(kakaotalk/kakaoplus/supertoss/naversearchapp)을 Android <queries> + iOS LSApplicationQueriesSchemes 에 선등록(openURL/canOpenURL, 재빌드로 선반영).
withModularHeadersiOSPodfile 에 use_modular_headers! 주입. static framework + @react-native-firebase Swift pod 조합의 module map 문제 해결.
withChannelIoMavenRepoAndroidroot build.gradle 에 maven.channel.io 저장소 주입(ChannelTalk SDK, io.channel/com.zoyi content filter).
withChannelIoInitAndroidMainApplication.onCreateChannelIO.initialize(this) 주입(미초기화 시 NOT_INITIALIZED).
withChannelIoInitIosiOSSwift AppDelegate didFinishLaunchingChannelIO.initialize(application) 주입.
withAirbridgeMavenRepoAndroidroot build.gradle 에 Airbridge maven 저장소 주입(io.airbridge content filter).
withAirbridgeAndroidInitAndroidMainApplicationinitializeSDK, MainActivityonNewIntent/onResume + trackDeeplink 주입(딥링크 트래킹).
withAirbridgeIosInitiOSSwift AppDelegate 에 initializeSDK + open url/universal link trackDeeplink 주입.
withAndroidReleaseSigningAndroidapp build.gradle 에 signingConfigs.release 추가하고 buildTypes.release.signingConfig 를 release 로 연결(keystore.properties 또는 env 로 keystore 로드).
withAndroidNetworkSecurityConfigAndroidcleartext 도메인 화이트리스트(network_security_config.xml) 작성. release=화이트리스트(localhost/naver/google/kakao postcode 등), debug=전체 허용(Metro dev 서버 연결용).
withAndroidGradleMemoryAndroidgradle.propertiesorg.gradle.jvmargs 를 키워 release lint(lintVitalAnalyzeRelease) OOM(Metaspace) 방지.
withFcmDefaultNotificationChannelAndroidFCM 표시형 푸시 기본 채널을 normal(DEFAULT importance)로 지정(tools:replace 로 firebase 라이브러리 값 override). push.tsDEFAULT_NOTIFICATION_CHANNEL_ID 와 일치.
withNotifeeMavenRepoAndroidroot build.gradle 에 notifee 로컬 AAR(@notifee/react-native/android/libs) maven 저장소 주입(node_modules 상향 탐색, hoist 여부 무관).

plugins/replaceOrThrow.jsplugins/babel-plugin-rn-svg-icons.js 는 config plugin 이 아니다(각각 앵커 치환 헬퍼, babel 플러그인). plugins/withAllowBackupReplace.js 파일은 존재하지만 현재 app.config.tsplugins 배열에 배선되어 있지 않다 — 배선된 것만 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_PRERELEASEruntimeVersion-pre suffix 하나로 두 가드를 동시에 제어한다:

runtimeVersion: IS_PRERELEASE ? `${APP_VERSION}-pre` : APP_VERSION,
  • trueruntimeVersion=<ver>-pre → OTA fingerprint 가드 우회(같은 pre 트랙 내 네이티브 자유 변경) + fastlane deploy(스토어 제출) 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_envexpo_prebuild) 산출물이 빌드 env 와 항상 일치하게 한다. env 는 오직 APP_ENV(v1 의 .env.*/ENVFILE/productFlavors 패턴 미사용).

laneenviOS 배포처Android 배포처
beta_developmentdevelopmentFirebase App DistributionFirebase App Distribution
beta_qaqaFirebase App DistributionFirebase App Distribution
beta_canarycanaryFirebase App DistributionFirebase App Distribution
beta_production_releaseproductionFirebase App DistributionFirebase App Distribution
internal_test_qaqaTestFlightPlay internal 트랙
internal_testproductionTestFlightPlay internal 트랙
internal_app_sharing_qaqaPlay Internal App Sharing
internal_app_sharingproductionPlay Internal App Sharing
deployproductionApp 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})는 스토어에 배포할 수 없습니다. ..." end

IS_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_signingandroid-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 문서의 릴리즈 절차로 관리한다.

Last updated on