OTA (self-hosted Expo Updates) & 버전 관리
native 앱의 OTA(Over-The-Air) 업데이트를 자체 호스팅한 Expo Updates(protocol v1)로 운영합니다. EAS Update 를 쓰지 않고, 매니페스트 서버(apps/api) + 번들 스토리지(Cloudflare R2) + 발행 스크립트/워크플로를 직접 구현했습니다. 이 문서는 그 설계·계약·운영 규칙과 그렇게 만든 이유를 정리합니다.
핵심: OTA 는 JS 번들(+정적 asset)만 교체한다. 네이티브 바이너리(플러그인·의존성·
app.config네이티브 설정)는 OTA 로 못 바꾼다. 그래서 “어떤 변경이 네이티브인가”를 판정하는 게 OTA 안전성의 전부다. 이 판정을runtimeVersion(매칭)과fingerprint가드(발행 차단)로 이중으로 건다.빌드·배포(fastlane·스토어 제출) 자체는 빌드 & 릴리즈를, OTA 정책 enum 은
@howmuchhome-web/native-app-shared를 참조.
큰 그림
develop/main push
│
▼
.github/workflows/native-app-ota.yml
│ (turbo 그래프로 native 영향 판정 → 미영향이면 스킵)
▼
expo export --platform ios --platform android (yarn ota:export)
│
▼
scripts/release-update.mjs (yarn ota:release)
├─ runtimeVersion resolve (platform별)
├─ fingerprint 계산 (@expo/fingerprint)
├─ bundle + assets → R2 업로드 (hash 기반 key)
└─ POST apps/api /api/updates/release ← fingerprint 가드
│
▼ (DB: expo_update_release)
앱 시작 시 GET /api/updates/manifest ────────────┘
├─ (channel, runtimeVersion, platform) 최신 release 조회
├─ code-signing 서명된 multipart manifest 응답
└─ extra.expoClient / extra.updatePolicy 포함runtimeVersion 정책
OTA 는 runtimeVersion 단위로 매칭된다. 앱은 자기 빌드에 박힌 runtimeVersion 을 매니페스트 요청 헤더(expo-runtime-version)로 보내고, 서버는 정확히 같은 runtimeVersion 의 release 만 돌려준다(packages/db/services/expoUpdate.ts 의 getLatestExpoUpdateRelease — channel/platform/runtimeVersion 3중 eq 조회).
이 프로젝트는 runtimeVersion 을 production 은 nativeVersion 정책(= 버전(빌드번호), 플랫폼별), pre-release 는 버전-pre 로 쓴다. apps/native-app/app.config.ts:
// release.config.js 에서 import 한 APP_VERSION / IS_PRERELEASE / (플랫폼 빌드번호)
runtimeVersion: IS_PRERELEASE ? `${APP_VERSION}-pre` : {policy: 'nativeVersion'},
// resolve 결과: production → ios `4.0.0(1)` / android `4.0.0(2100000032)` (플랫폼 빌드번호), pre → `4.0.0-pre`왜 nativeVersion 정책인가 (appVersion 아님, fingerprint 아님)
Expo 정책은 appVersion(마케팅 버전) / nativeVersion(버전+빌드번호) / fingerprint(해시) 등이 있다.
fingerprint정책의 함정: 해시가 빌드 환경마다 달라진다(로컬 macOS vs Linux CI). 자체 호스팅 OTA 는 CI 에서 발행하는데 사용자 빌드가 다른 환경산이면runtimeVersion불일치로 OTA 가 안 떨어진다. → 부적합.appVersion의 함정(과거에 겪음): 빌드번호를 무시하고 마케팅 버전만 본다. 같은 마케팅 버전을 재빌드(예: 심사 반려 후 네이티브 고쳐 versionCode 만 올려 재제출)하면runtimeVersion이 그대로4.0.0→ 한 runtimeVersion 에 네이티브 상태 2개 → 발행 fingerprint 가드가 정상 재빌드를 드리프트로 오탐한다.nativeVersion의 장점:버전(빌드번호)라 스토어 빌드 1개 = 고유 runtimeVersion = 고유 OTA 트랙. 재빌드(빌드번호 상향)면 자동으로 새 트랙 → 위 오탐이 원천 제거. 빌드번호는 커밋된 값이라 fingerprint 와 달리 결정적(환경 무관). JS-only 변경은 빌드번호를 안 올리니 같은 트랙 유지 → OTA 정상 도달.
발행/게이트 스크립트는 fingerprint 를 runtimeVersion 으로 쓰지 않고 CLI 로 app.config 정책을 그대로 resolve 한다(npx expo-updates runtimeversion:resolve). 정책이 바뀌어도 발행·게이트·빌드의 runtimeVersion 이 항상 일치한다.
runtimeVersion은 네이티브 빌드에 박히는 값(iOSExpo.plist, Androidstrings.xml) → OTA 로 못 바꾼다. 새 트랙은 새 스토어 빌드(빌드번호↑)로만 생긴다.전환 주의: 기존 live 4.0.0 빌드는 구 정책(
runtimeVersion=4.0.0)으로 박혀 있어 그 트랙 OTA 를 계속 받는다. nativeVersion 은 다음 빌드부터 적용(4.0.0(빌드번호)트랙). 두 트랙은 공존하며, 사용자는 스토어 업데이트로 새 트랙으로 이행한다.
runtimeVersion = 호환 매트릭스, fingerprint = 발행 가드
두 값의 역할을 헷갈리면 안 된다.
| 값 | 무엇 | 어디에 박히나 | 역할 |
|---|---|---|---|
runtimeVersion (production 버전(빌드번호) / pre 버전-pre) | OTA 매칭 키 | 네이티브 빌드(불변) + release row | 같은 값끼리만 OTA 전달 |
fingerprint (@expo/fingerprint hash) | 네이티브 구성 스냅샷 | release metadata (DB) | “네이티브가 바뀌었나” 발행 시 감지 |
runtimeVersion 은 스토어 빌드 단위의 굵은 호환 경계(빌드번호로 자동 구분)이고, fingerprint 는 그 경계 안에서(같은 빌드번호인데) 네이티브가 몰래 바뀌었는지 감지하는 안전망이다 — 즉 “네이티브 바꾸고 빌드번호(재빌드) 안 올린 채 OTA 발행”이라는 실수를 잡는 게 fingerprint 가드의 목적이다.
self-hosted 파이프라인
1. export
yarn ota:export = expo export --platform ios --platform android --source-maps. apps/native-app/dist/ 에 platform별 JS bundle(Hermes bytecode)·정적 asset·metadata.json 을 생성한다.
2. R2 업로드 + release 등록
yarn ota:release = node scripts/release-update.mjs. platform(ios/android) 각각:
runtimeVersionresolve (위 참조).fingerprint계산(createFingerprintAsync, platform별 1회 캐싱).- bundle(launch asset) + assets 를 hash 기반 key 로 R2 에 업로드. asset key 는
updates/<channel>/<runtimeVersion>/assets/<hash>형태라 동일 asset 은 자동 dedup 된다(R2PutObject는 idempotent — head 조회 없이 그냥 PUT). - release endpoint 에 platform별 POST.
release POST payload(apps/api/.../updates/release/route.ts 의 ReleasePayload)의 핵심 필드:
| 필드 | 의미 |
|---|---|
channel | 'development' | 'production' |
platform | 'ios' | 'android' |
runtimeVersion | 매칭 키 |
launchAsset / assets | R2 URL + hash |
expoConfig | public Expo config (아래 참조) |
updatePolicy | 적용 정책(1/2/3, 아래 참조) |
fingerprint | 발행 가드 비교 기준 |
allowNativeChange | ⚠️ break-glass 우회 플래그 |
metadata | commit/branch/actor/runId |
인증은 Authorization: Bearer <EXPO_UPDATES_RELEASE_TOKEN> (timingSafeEqual 상수시간 비교). 성공하면 expo_update_release 에 새 row 를 넣고 201.
3. manifest 서빙
앱은 시작 시(checkAutomatically: 'ON_LOAD') GET /api/updates/manifest 를 호출한다. 서버(route.ts + apps/api/src/features/expoUpdates/manifest.ts):
- 요청 헤더
expo-platform/expo-runtime-version/expo-channel-name/expo-current-update-id로 조회. (channel, runtimeVersion, platform)최신 release 를 찾아multipart/mixed응답의manifestpart 로 직렬화, part raw bytes 에 RSA-PKCS1v15-SHA256 서명(expo-signature헤더). 클라이언트는 빌드에 박힌certs/certificate.pem(app.configcodeSigningCertificate)으로 검증한다.- release 없음 →
204 No Content(“update 없음”). 클라이언트 현재 update id 와 최신 id 가 같으면 →204(변경 없음).
인증 없는 endpoint다 — 보안은 응답 서명 검증으로 한다.
extra.expoClient 가 반드시 필요한 이유 (anti-bricking)
매니페스트의 extra.expoClient 에 public Expo config(expo config --type public)를 실어야 한다. 이 값이 OTA 로 실행되는 번들의 Constants.expoConfig 를 채운다(expo-constants 는 update manifest 의 extra.expoClient 를 읽음).
이게 없으면 OTA 번들에서
Constants.expoConfig === null→ iOS 에서expo-router/expo-linking이 scheme 을 못 찾아 시작 시 크래시 → expo-updates anti-bricking 이 롤백한다. 결과적으로 “iOS 만 OTA 가 영영 안 붙는” 증상이 된다(embedded 실행은 native app.config 에서 읽으므로 무관 → Android 는 통과). 과거 self-hosted 구현이extra: {}였던 게 원인이었다.
serializeManifest(manifest.ts)는 release.expoConfig 가 있을 때만 extra.expoClient 를 채우고, expoUpdate.ts 스키마의 expoConfig 컬럼은 이 컬럼 도입 이전 release 와의 하위호환을 위해 nullable 이다.
fingerprint 가드 (핵심 안전장치)
release POST 는 production 채널 발행 전에 네이티브가 바뀐 OTA 를 차단한다(route.ts). 로직:
if (channel === 'production' && payload.fingerprint && !allowNativeChange && !isPrereleaseRuntime) {
const prior = getLatestExpoUpdateRelease(channel, platform, runtimeVersion)
if (prior && prior.metadata.fingerprint && prior.fingerprint !== payload.fingerprint)
→ 409 // "native fingerprint changed for this runtimeVersion"
}즉 같은 (production, runtimeVersion, platform)의 직전 release fingerprint 와 다르면 409로 막는다. 다르다는 건 “이번 OTA 에 네이티브 변경이 섞였는데 runtimeVersion(스토어 빌드 경계)은 그대로”라는 뜻이고, 그러면 그 변경이 없는 구 스토어 바이너리에 번들이 떨어져 크래시(브릭) 날 수 있다.
왜 production 채널에만 (develop 은 dry-run 게이트가)
가드를 development 채널에도 걸면, develop 에 네이티브 변경이 쌓일 때마다 develop OTA 가 409(red)로 나서 릴리즈 트레인(develop→main)이 통째로 막힌다(gate-main-on-ota 가 develop OTA green 을 요구했었기 때문). development 채널은 dev/QA/canary/internal 이 필요 시 재빌드하므로, 가드는 production 실발행 시점의 최후 방어선으로만 두고, develop→main 의 pre-merge 방어는 dry-run 게이트(아래 별도 섹션)가 담당한다.
통과(가드 우회)하는 경우:
| 조건 | 이유 |
|---|---|
channel !== 'production' | development 채널은 미적용(위 참조) |
| 직전 release 없음 | baseline — 비교 대상이 없음 |
| 직전 release 에 fingerprint 미기록 | 하위호환(구 release) |
runtimeVersion 이 -pre suffix | pre-release 트랙 — 스토어 미출시라 보호할 사용자가 없음 |
allowNativeChange: true | ⚠️ break-glass 수동 우회 (아래) |
fingerprint 는 통과한 release 의 metadata.fingerprint 로 보관되어 다음 발행의 비교 기준이 된다. fingerprint 절대값 자체가 클라이언트 빌드와 일치할 필요는 없다(비교는 CI 발행끼리라, 발행 환경 내에서 결정적이기만 하면 됨).
fingerprint 가 해시하는 것 (포함/제외)
@expo/fingerprint 는 **번들 JS 가 아니라 “네이티브를 결정하는 입력”**을 해시한다. 이 프로젝트 기준 실측(createFingerprintAsync(root, {platforms:[p]})):
- 포함(=바뀌면 native 변경으로 판정):
- 네이티브 모듈(의존성) 디렉토리 —
@react-native-firebase/*,@notifee/*,@datadog/mobile-*,expo-camera/location/...등 (dep 추가·삭제·버전·내부파일 변경) - config plugin 파일들 + 로컬
plugins/*.js+release.config.js expoConfig(app.config 평가 결과), autolinking 설정,react-native버전,package.json의scripts
- 네이티브 모듈(의존성) 디렉토리 —
- 제외(=바뀌어도 통과):
src/의 JS/TS 앱 소스(번들로 가는 코드) → JS 만 바꾸면 해시 동일 → 가드 통과(정상 OTA)
계산 시점 & 상태 의존성
- 발행 시점(
release-update.mjs)에 계산한다.ota:export(=expo export)는 prebuild 를 하지 않고, OTA 워크플로에도 prebuild 스텝이 없다 → CI 엔ios/·android/산출물이 없다. 즉 네이티브 코드가 생성되기 “전”, 그 입력(레시피)을 해시한다(플러그인으로 native 가 바뀔 것을 생성 전에 감지). - ⚠️ 상태 의존적:
ios/·android/디렉토리가 존재하면(예: 로컬에서 prebuild 후) 그 산출물도 함께 해시되어 해시가 달라진다. 가드 비교가 의미 있으려면 baseline 과 이후 발행이 같은 환경에서 계산돼야 하므로, OTA 발행은 항상 CI(네이티브 디렉토리 없음) 에서 한다. 로컬 수동 발행은 금지.
allowNativeChange — break-glass (위험)
OTA_ALLOW_NATIVE_CHANGE=1(release-update.mjs) → payload allowNativeChange: true → 서버가 가드를 건너뛰고 감사 로그(console.warn)를 남긴다.
⚠️ 정식(스토어 배포된) 트랙에서 켜면 실제 사용자 기기 크래시(브릭) 위험. 일반적인 pre-release 우회는 이 플래그가 아니라
IS_PRERELEASE(=-presuffix)로 한다 — 그건 pre 트랙을 정식 트랙과 격리해 사용자 영향이 없는 안전한 경로다.allowNativeChange는 그 어느 것도 안 맞는 예외(네이티브 변경이 JS 계약상 확실히 호환됨을 검증했거나 장애 복구)에서 사람이 로컬에서 수동으로만 켜는 최후 수단이다. CI(native-app-ota.yml)는 이 값을 주입하지 않는다.
develop→main dry-run 게이트
가드는 production 실발행 시점의 방어선이라, 이미 develop→main 이 된 뒤에야 작동한다. develop→main 을 머지 전에 막으려면 발행 없이 미리 판정해야 하는데, 이를 gate-main-on-ota.yml(required check require-ota-green) + scripts/ota-drift-check.mjs 가 담당한다.
동작(발행 없음):
- turbo 그래프로 native-app 영향 판정 — 미영향이면 즉시 통과.
- 영향이 있으면 platform 별로 runtimeVersion resolve +
@expo/fingerprint계산(release-update.mjs 와 동일 방식이라 baseline 과 비교 가능). - 서버의 production baseline fingerprint 조회:
GET /api/updates/baseline-fingerprint?channel=production&runtimeVersion=&platform=. - 같은 runtimeVersion 인데 fingerprint 가 다르면 차단(= 네이티브 바뀌었는데 버전 안 올림 → production 으로 가려 함). 버전 다름(새 트랙)·baseline 없음 → 통과.
- 발행/대기 없이 그 자리에서 계산·비교한다(옛 방식인 “develop OTA run 이 green 인지 대기”를 대체 — develop 은 이제 가드 프리라 항상 green).
- ⚠️ fail-open: 엔드포인트 미배포(404)·네트워크·계산 오류 등 판정 불가는 경고 후 통과시킨다(인프라 문제로 릴리즈 트레인 전체를 막지 않기 위함). 확정 드리프트일 때만 차단. production 실발행 서버 가드가 최후 방어선.
- 계산 일관성: dry-run·baseline 둘 다 CI 에서 계산되므로 결정적으로 비교된다(로컬 발행 금지).
개발자 경험: develop→main 이 이 게이트에 막히면 = “네이티브 바꿨는데 버전 안 올림”.
native-release(APP_VERSION 상향)로 해소하면 새 트랙이 되어 통과한다. 애초에 네이티브 변경 PR 에 버전을 함께 올리면 안 걸린다(게이트는 깜빡 방지 안전망).
여러 네이티브 변경이 쌓일 때 (자동 bump)
결정: CI 자동 bump(아래 “자동화”). 팀원은 버전/스토어를 신경 쓰지 않고 feature PR 만 올린다. 아래 A/B 는 배경이며, 채택안은 A(조기 bump)를 CI 로 자동화한 것이다.
개발 중 네이티브 변경(deps·config plugin·app.config 네이티브 부분·autolinking 대상 등 OTA 로 못 내리는 것)이 여러 개 동시에 진행/누적될 때, develop→main 트레인(다른 앱과 공유)을 green 으로 유지하면서 결국 스토어로 내보내는 방법.
자동화: native-auto-bump (CI) — 채택안
.github/workflows/native-auto-bump.yml 가 develop push 마다 돈다:
- turbo 로 native-app 영향 판정(미영향 스킵).
ota:drift-check(production baseline 대비 fingerprint) → 드리프트 확정(exit 1) 이면 bump 필요.scripts/native-bump.mjs --apply --type buildcode로 buildcode 만 bump(iOS build +1 / Android versionCode = 모든 태그·현재 최대 +1).- develop 은 PR 필수 브랜치라 직접 push 못 함 → auto-merge PR(
[native-bump])로 넣는다(required 승인 0 + Vercel 필수체크는 native-only 에도 success → 통과). 마커로 자기 커밋 재트리거 방지,concurrency로 직렬화.
정책·근거:
- 왜 CI 인가: develop→main(릴리즈)은 여러 앱이 공유하고 누구나 돌린다. 사람이 릴리즈 전에 수동 bump 하는 방식은, 웹 담당자가 릴리즈할 때 쌓인 네이티브 드리프트에 막히는 걸 못 막는다. CI 가 develop 을 항상 게이트-green 으로 유지해야 누가 릴리즈하든 안전하다.
- 왜 buildcode 만: 마케팅
APP_VERSION은 릴리즈 담당자가 스토어 릴리즈(native-release) 때 수동으로 올린다(App Store 도 새 출시엔 새 version 강제). CI 가 사용자 대면 버전 숫자를 정하지 않는다. Android versionCode 는 어느 경우든 전역 단조 증가(리셋 X). - 트레이드오프: bump 시점부터 mainline OTA 가 미출시 트랙으로 이동 → 라이브 구버전 유저는 그 사이 OTA 동결. 급하면 hotfix로 그 트랙에 직접 발행. (공유 트레인 + 자유 머지의 본질적 비용)
- 최종 안전망: 그래도 develop→main 의 fingerprint 게이트 + production 발행 서버 가드가 최후 방어선으로 남는다.
scripts/native-bump.mjs 는 릴리즈 담당자가 로컬에서도 쓴다(package.json 스크립트로 넣으면 그 자체가 fingerprint 드리프트라 직접 호출): node scripts/native-bump.mjs(plan), ... --apply --type version --bump patch|minor|major(스토어 릴리즈용 마케팅 버전 bump).
(배경) 수동 운영 시 A/B 옵션
왜 문제인가 (메커니즘)
드리프트 게이트는 develop 의 fingerprint 를 현재 resolve 되는 runtimeVersion 의 production baseline 과 비교한다.
- 네이티브 바꿨는데 빌드번호 그대로 → 같은 트랙 baseline 과 달라짐 → 게이트 red → 트레인 전체(다른 앱 포함) 막힘.
- 빌드번호를 올리면 →
버전(새빌드번호)= 새 트랙 → 그 트랙엔 아직 production release 가 없음(baseline 없음) → 게이트 green.
핵심: “새 트랙엔 비교 대상이 없어 통과”한다. 그래서 처리 전략은 “언제 트랙(빌드번호)을 새로 여느냐”의 문제로 귀결된다.
옵션 A — develop 조기 bump (누적 트랙)
- 첫 네이티브 변경이 develop 에 들어오는 순간
release.config.js의 빌드번호(+필요시APP_VERSION)를 한 번 올린다 → 새 트랙 개설 → 게이트 green. - 이후 네이티브 변경들은 그 같은 pending 트랙에 계속 쌓인다(추가 bump 불필요). 그 트랙에 production baseline 이 생기기 전(=미출시)이라 게이트는 계속 green.
- 준비되면 그 누적 버전 하나로 빌드·제출·태그(
native-release). 스토어 바이너리에 누적 네이티브 변경 전부 포함. - 출시 후 다음 네이티브 변경은 새 사이클 → 다시 bump.
- 장점: 네이티브 변경을 develop 에 미루지 않고 계속 통합(CI·충돌 관리 유리).
native-release스킬의 기존 권장(“네이티브 변경은 빌드번호 상향과 함께 develop 에”)과 일치. - ⚠️ 단점(트레이드오프): develop 을 다음 버전으로 올린 순간부터 develop→main OTA 는 그 미출시 트랙으로 발행된다 → 그 사이 live(구버전) 유저는 mainline OTA 를 못 받는다. 이 window 동안 구버전 JS 수정은 hotfix(
release/<현재출시버전>)로 내린다. → bump 는 “곧 빌드 낼 때” 하고 빌드를 오래 끌지 말 것.
옵션 B — draft/브랜치로 모아 릴리즈 때 일괄 머지
- 네이티브 변경 PR 들을 draft(또는 미머지 브랜치)로 보류해 develop 에 넣지 않는다.
- 릴리즈를 cut 할 때 빌드번호 bump 와 함께 일괄 머지 → 그 커밋에서 빌드·제출·태그.
- 장점: develop 이 항상 현재 출시 버전 트랙에 머물러 mainline OTA 가 계속 live 유저에게 도달(미출시 트랙 window 없음). 게이트도 애초에 안 건드림.
- ⚠️ 단점: 네이티브 변경의 develop 통합이 지연(머지 충돌·CI 늦음). 변경이 잦거나 크면 부담.
A/B 성격 (배경 — 현재 기본값 아님)
아래는 수동 운영 시 두 방식의 성격 설명이다. 현재 채택·기본값은 위 “자동화: native-auto-bump(CI)”(=A 를 자동화)이며, 아래 표의 선택은 더 이상 기본값이 아니다.
| 상황 | 성격 |
|---|---|
| 네이티브 변경이 드물고 작음 | B (draft 로 모아 다음 빌드에 일괄) |
| 네이티브 변경이 잦거나 큼 | A (조기 bump + 누적, live 유저는 hotfix 병행) — CI 가 이걸 자동화 |
판정 도구: 변경이 “네이티브”인지 애매하면
yarn --cwd apps/native-app ota:drift-check로 fingerprint 변동을 확인한다(단, 로컬 해시는 CI 와 절대값이 다르니 상대 비교용). 게이트가 red 면 그 변경은 네이티브였다는 신호.
IS_PRERELEASE 스위치
apps/native-app/release.config.js 의 IS_PRERELEASE 하나로 두 가드를 동시에 제어한다. -pre suffix 가 스위치 역할이다.
IS_PRERELEASE | runtimeVersion | OTA fingerprint 가드 | fastlane deploy(스토어 제출) |
|---|---|---|---|
true | <ver>-pre | 우회(같은 pre 트랙 내 네이티브 자유 변경) | 차단 |
false | <ver> | ON | 허용 |
- OTA 쪽: 서버가
runtimeVersion.endsWith('-pre')면 fingerprint 가드를 건너뛴다. - 스토어 쪽:
fastlane/Fastfile의assert_not_prerelease(platform)가deploylane 진입 시runtimeVersion을 resolve 해-pre로 끝나면 예외를 던져 스토어 제출을 거부한다. (iosdeploy/ androiddeploylane 모두 호출)
pre 트랙은 정식 트랙과 격리돼 스토어 baseline fingerprint 를 오염시키지 않는다. 그래서 pre 중엔 네이티브를 자유롭게 바꿔가며 OTA 로 빠르게 반복할 수 있다.
⚠️ 정식 출시 시 반드시
IS_PRERELEASE = false로 바꾸고 재빌드한다.runtimeVersion은 빌드에 박히는 값이라 OTA 로-pre를 뗄 수 없다.
release.config.js 는 “릴리즈 제어판”이다 — 매 릴리즈 사람이 손대는 값(APP_VERSION, ANDROID_VERSION_CODE, IOS_BUILD_NUMBER, IS_PRERELEASE)만 모여 있고 app.config.ts 가 import 한다.
함정: expo config 로더는 진입 파일(
app.config.ts)만 트랜스파일하고 상대 import 는 런타임require로 해석하므로 sibling.ts를 못 읽는다. 그래서 값은release.config.js(JS)에 두고 타입만release.config.d.ts로 둔다.
updatePolicy — 적용 정책
release 마다 “이 업데이트를 클라이언트가 어떻게 적용할지”를 실어 보낼 수 있다. enum 은 서버·클라이언트 공유(packages/native-app-shared/ota/index.ts):
export enum UpdatePolicy {
Immediate = 1, // 즉시 강제 적용(다운로드되면 오버레이 후 reload)
Notify = 2, // 준비됨 안내 → 사용자가 재실행하면 적용(reload)
Default = 3, // 현행(다음 콜드런치에 자동 적용). null(미지정)도 Default 취급.
}- wire/DB 는 정수다. 숫자 값을 바꾸면 안 된다. DB(
expo_update_release.update_policy)에는1~3범위check제약이 걸려 있다(API 밖 경로도 방어). - 발행 시
OTA_UPDATE_POLICYenv(1|2|3)로 실어 보낸다. 미지정이면 payload 에서 빠지고 서버/클라이언트가Default로 취급. - 서버는
manifest의extra.updatePolicy로 서빙(serializeManifest—release.updatePolicy != null일 때만). - 클라이언트
apps/native-app/src/features/ota/OtaUpdateGate.tsx가Updates.useUpdates()의 pending update 매니페스트에서extra.updatePolicy를 읽어 분기한다(release 빌드Updates.isEnabled에서만). 노출 판정은 순수 함수resolveOtaBannerVisibility.ts, 배너 UI 는OtaUpdateBanner.tsx(상단 풀폭 슬림 바)로 분리돼 있다.Notify(구현됨): 다운로드 완료(isUpdatePending) 시 상단에 “새 버전이 준비됐어요 + [지금 업데이트]” 배너 노출 → 탭하면Updates.reloadAsync()로 즉시 적용.[×]로 닫으면 그updateId는 세션 내 재노출 안 함(더 새 업데이트가 오면 다시 노출). 이미 다운로드된 상태라 적용에 추가 대기 없음.Immediate(미구현): 아직 TODO — 현행Default와 동일(무음, 다음 콜드런치 자동 적용).Default: 무음, 다음 콜드런치 자동 적용.
production 채널 OTA 기본값 =
Notify. CI(native-app-ota.yml)가 production 발행 시OTA_UPDATE_POLICY=2를 주입한다(development/qa/canary 는 미주입 →Default무음). 그래서 main 머지 OTA 는 유저에게 배너로 넛지된다.⚠️ 배너는 이 코드가 포함된 새 스토어 빌드부터 뜬다. 그 이전 구버전 바이너리는
Notifyrelease 를 받아도 배너를 못 그려 현행Default처럼 동작(다음 콜드런치 자동 적용).
채널 & 환경 (APP_ENV)
APP_ENV 가 단일 진실 공급원이고, 거기서 채널(2종)·매니페스트 URL 이 파생된다(app.config.ts 의 UPDATES_CHANNEL_BY_ENV / UPDATES_URL_BY_ENV). 채널 enum 은 'development' / 'production' 딱 둘이다(expoUpdateChannelEnum).
APP_ENV | 채널 | 매니페스트 URL | 비고 |
|---|---|---|---|
local | (development) | — | UPDATES_ENABLED=false (Metro 직접) |
development | development | dev-front-api | develop OTA |
qa | development | dev-front-api | development 채널 공유 |
production | production | front-api | main OTA |
canary | development | dev-front-api | prod 콘텐츠 + develop OTA 구독(canary) |
CI(native-app-ota.yml)의 채널 매핑: develop push → development 채널(dev-front-api), main push → production 채널(front-api). Resolve channel step 이 github.ref_name 으로 OTA_CHANNEL·APP_ENV·release API URL(dev/prod secret)을 정한다.
canary/qa 가 development 채널을 그대로 구독하므로, develop 에 발행된 OTA 를 함께 받는다. 서버·전용 채널 변경이 필요 없다.
CI 워크플로 (native-app-ota.yml)
트리거는 2단계로 좁힌다:
- push
paths(싼 GitHub 1차 필터):apps/native-app/**,packages/**,yarn.lock, 이 워크플로.packages/**는 native-app 이 의존하지 않는 패키지에도 매칭되는 오탐(superset)이 있다. changesjob(turbo 의존성 그래프): native-app(또는 그 의존 패키지)이 실제 영향받았는지 정밀 판정. 미영향이면otajob 을 스킵해 불필요한 export+R2 발행을 피한다. base SHA 부재(수동 실행·첫 push)·판정 실패면 보수적으로native=true.
ota job: checkout → node_modules 캐시 복원 → Resolve channel → yarn ota:export → (Sentry 소스맵 업로드, continue-on-error) → yarn ota:release.
함정: node_modules 캐시 key 에
yarn.lock뿐 아니라 워크스페이스package.json들도 해시한다. dep 없는 새 워크스페이스 패키지 추가는yarn.lock을 안 바꿔서,yarn.lock만 key 로 쓰면 캐시 HIT → install 스킵 → 새 패키지 심링크가 없는 stalenode_modules로 빌드가 깨진다(Unable to resolve).
DB 스키마 (expo_update_release)
한 release = (channel, runtimeVersion, platform) 조합에 대한 OTA 한 벌(packages/db/schema/expoUpdate.ts).
- 조회 인덱스
expo_update_lookup_idxon(channel, runtimeVersion, platform, createdAt)— manifest endpoint 가 그 조합의 최신createdAtrow 1개를 가져온다. - 롤백은 별도 latest 컬럼이 아니라 같은 조합에 더 늦은
createdAt로 새 row 를 insert 하는 방식이다(“가장 최근 = 활성”). update_policy는NULL OR BETWEEN 1 AND 3check 제약.
운영 규칙 (요약)
이 한 줄이 전부다:
네이티브(fingerprint)가 바뀌면 버전을 올려 스토어 빌드를 낸다. JS-only 변경이면 그냥 OTA 로 나간다.
구체적으로:
- JS/asset만 바꿨다 → develop/main 에 머지하면 CI 가 자동 OTA 발행. 같은
runtimeVersion사용자에게 적용된다 — production 은 상단 “지금 업데이트” 배너(Notify)로 즉시 적용 유도, 안 누르면(또는 구버전 바이너리면) 다음 콜드런치에 자동 적용. development/qa/canary 는 무음(다음 콜드런치). - 네이티브를 바꿨다(플러그인·네이티브 의존성·
app.config네이티브 설정 등) →release.config.js의APP_VERSION(+ANDROID_VERSION_CODE, iOSIOS_BUILD_NUMBER)을 올리고 새 스토어 빌드를 낸다.runtimeVersion이 바뀌므로 구 빌드엔 이 OTA 가 안 떨어진다(의도된 격리). 실수로 안 올렸다면 fingerprint 가드가 발행을409로 막아 알려준다. - pre-release 중(
IS_PRERELEASE = true) → 네이티브 자유 변경 + OTA 자유 발행, 스토어 제출은 차단. 정식 출시 직전IS_PRERELEASE = false+ 재빌드. - 깨진 OTA 를 되돌리기 → 같은 조합에 이전 정상 번들을 새 release 로 다시 발행(더 늦은 시간)한다.
가드의 한계 (과신 금지)
fingerprint 가드는 1차 안전장치가 아니라 backstop이다. 진짜 계약은 위 운영 규칙의 **“네이티브가 바뀌면 버전 문자열을 올린다(→ runtimeVersion 분리 → 구 빌드와 격리)“**이고, 애초에 OTA 는 네이티브를 못 실으므로 위험은 “새 JS 가 (그 바이너리에 없는) 네이티브를 가정”할 때뿐이다. 가드는 그 흔한 실수를 잡아줄 뿐, 다음 구멍은 못 막는다(의도적 우회인 -pre·allowNativeChange 제외):
| # | 구멍 | 왜 못 잡나 | 완화 |
|---|---|---|---|
| 1 | baseline 이 스토어 바이너리와 미검증 | fastlane 빌드가 fingerprint 를 기록/비교하지 않음. 비교 기준은 “그 runtime 의 첫 CI OTA”일 뿐 실제 출시 바이너리와 대조된 적 없음. 첫 OTA 는 무조건 통과 | 첫 OTA 를 스토어 빌드와 **같은 커밋(native)**에서 발행 (아래 릴리즈 절차) |
| 2 | (구 appVersion 정책에서) 같은 마케팅 버전 재빌드가 같은 runtime 에 native 2개 상태를 만들어 오탐 유발했음 | nativeVersion 정책 채택으로 제거 — runtimeVersion 에 빌드번호가 들어가 재빌드=새 트랙. 빌드번호만 안 올리고 native 바꾸면 여전히 #가드가 잡음(그게 목적) | |
| 3 | 빌드시점 해석 native 버전 드리프트 | 입력 해시라, Gradle/CocoaPods 부동 버전이 빌드 때 다르게 잡혀도 감지 못 함 | Expo SDK 핀 + yarn.lock 고정으로 확률 낮음 |
| 4 | 해시 환경 의존 | ios/android 유무·툴 버전 등에 따라 해시가 달라짐 | 발행은 항상 동일 CI |
| 5 | baseline 체인 끊김 | fingerprint 없이 저장된 release(수동 insert·롤백 재발행)가 최신이면 다음 발행이 무조건 통과 | 발행은 release-update.mjs 단일 경로로만 |
| 6 | @expo/fingerprint 커버리지 밖 | autolinking 밖 커스텀 native 설정, 툴체인/서명 등은 미해시(단 이건 새 스토어 빌드를 수반해 OTA 위험은 낮음) | — |
결론: 가드가 통과했다고 “native 안 바뀜”이 증명된 게 아니다. #2 는 nativeVersion 정책으로 구조적으로 닫혔고, #1 은 “첫 OTA 를 스토어 빌드와 같은 커밋에서 발행”으로 관리한다. 가드는 여전히 “native 바꾸고 빌드번호(재빌드) 안 올린 채 발행” 실수를 잡는 backstop 이다.
릴리즈 절차
핵심 규율: 네이티브 변경 = 새 스토어 빌드. nativeVersion 정책이라 빌드번호(ANDROID_VERSION_CODE / IOS_BUILD_NUMBER)만 올려도 새 runtimeVersion 트랙이 생겨 구 빌드와 격리된다(마케팅 APP_VERSION 상향은 제품 판단). 어느 쪽이든 스토어 출시를 동반한다. 이 절차는 native-release 스킬로 안내된다.
- 네이티브 변경 + 빌드번호 상향(필요시 마케팅 버전)을 develop 에 정상 머지(협업상 미루지 않음). 빌드번호가 오르면 새 nativeVersion 트랙이라 dry-run 게이트에 안 걸린다. (빌드번호 깜빡해 막히면 그때 상향으로 해소)
- 그 (squash) 머지 커밋에서
fastlane deploy— 이 레포는 squash merge 라 반드시 머지 후 develop 커밋에서 빌드(브랜치/머지 전·main 빌드 아님). uncommitted 빌드 금지. - deploy 성공 후 그 커밋에
native-v<APP_VERSION>+<ANDROID_VERSION_CODE>태그 + push(실패 시 태그 없음). - develop→main·OTA 는 정상 흐름. 새 버전 production OTA 는 심사 중에도 자유 발행(구 트랙 무영향, 새 트랙엔 아직 스토어 유저 없음). 스토어 라이브 = non-event — 새 바이너리가 이미 서빙 중인 그 트랙에 합류.
왜 미루지/홀드하지 않나: 네이티브 변경이 mainline 에 들어온 이상 구버전(구 빌드) OTA 는 어차피 불가(그 JS 가 구 바이너리에서 크래시). 그러니 빌드/버전 상향을 미뤄도 라이브 유저에게 얻는 게 없다. 대신 빌드번호를 올려 새 트랙으로 가고, 라이브 유저는 심사 중 동결(크래시 아님)됐다가 스토어 업데이트로 이행한다.
baseline 자동 정합: 빌드번호를 올리는 그 커밋 = 스토어 빌드 커밋 = 첫 새-트랙 OTA 커밋이라, 첫 OTA 의 native == 스토어 빌드가 자동 보장된다(별도 go-live 점검 불필요).
한 runtimeVersion = 네이티브 상태 하나: 같은 마케팅 버전이라도 네이티브를 또 바꾸면 빌드번호를 올려 새 빌드를 내면 된다(새 트랙, 문제 없음). 빌드번호를 안 올린 채 네이티브만 바뀐 상태로 발행하면 그때 가드 409(develop→main dry-run 게이트가 pre-merge 로 감지) — “재빌드(빌드번호↑)하라”는 신호다. (JS-only 변경은 같은 트랙에 얼마든지 OTA 가능)
임베드 번들은 “빌드 시점 스냅샷”이라 출시 직후 잠깐 HEAD 보다 뒤처지는 건 정상이며, 흐르고 있는 OTA 로 곧 캐치업된다.
hotfix / 유지보수 OTA (구버전에 계속 발행)
다음 버전이 심사에 묶여 있거나(반려·지연) live 버전에만 긴급/추가 JS 업데이트가 필요할 때, live 버전 트랙에 OTA 를 계속 내리는 경로다. native-hotfix 스킬로 안내된다.
- 유지보수 브랜치:
release/<ver>←native-v<ver>태그(그 스토어 바이너리를 만든 커밋). 그 버전의 정확한 네이티브 상태라 runtimeVersion 이 그 버전으로 resolve 되고 fingerprint 가 그 버전 baseline 과 일치 → 가드 우회 없이 통과. - JS/assets 만: 네이티브 인풋을 건드리면 live 바이너리와 호환 안 되고 가드가 (정당하게) 막는다. 네이티브가 필요하면 hotfix 가 아니라 새 스토어 버전(native-release).
- 변경 출처 = develop 먼저 → cherry-pick: 수정을 develop(최신 버전)에 먼저 머지하고
release/<ver>로 cherry-pick(다음 버전 승인 시 회귀 방지). - 발행:
gh workflow run native-app-ota.yml --ref release/<ver> -f channel=production. ref 의 코드가 그 버전 runtimeVersion 으로 resolve → production 의 그 트랙에만 서빙(다른 트랙 무영향). develop→main 을 거치지 않아 다른 앱 릴리즈 트레인/드리프트 게이트에 영향 없다. - 롤백:
release/<ver>에서 hotfix 를revert(또는reset --hard native-v<ver>) 후 재-dispatch → 최신 createdAt 번들이 곧 롤백. (태그 직접 dispatch 는 그 태그에 워크플로가 없을 수 있어 브랜치 경유) - 재제출 버전 규율: 반려된 다음 버전을 메타/정책이든 네이티브 변경이든 같은 마케팅 버전 그대로, 빌드번호만 올려 재제출하면 된다. runtimeVersion 이 nativeVersion 정책(
버전(빌드번호))이라 빌드번호가 오르면 새 트랙으로 자동 격리돼 fingerprint 충돌이 없다. (마케팅 버전 상향은 필요 시 제품 판단) - EOL: 다음 버전이 승인·확산되면
release/<ver>OTA 를 중단(아카이브) → “최신만 OTA” 기존 정책 복귀.
EAS 와의 관계 & 장기 방향
이 셋업은 사실상 EAS Update 를 self-host 로 재구현한 것이다(같은 protocol v1, channel, runtimeVersion 매칭, R2=CDN). 임베드 드리프트·스토어 심사·모노레포 앱 독립성은 EAS 든 우리든 동일하고, 유일한 실질 차이는 runtimeVersion 정책이다.
- EAS 권장:
runtimeVersion.policy: 'fingerprint'→ fingerprint 가 곧 runtimeVersion → native 변경 = 자동으로 새 runtime → 비호환 업데이트가 애초에 매칭 안 됨. 위 한계 #1·#2·#4가 구조적으로 사라진다. - 우리는
appVersion정책 + 별도 가드: self-host 라 빌드 환경이 섞여(로컬 fastlane 빌드 + CI 발행) fingerprint 해시가 어긋나 fingerprint 정책이 불안정하기 때문. 그래서 환경 무관 결정적인appVersion을 쓰고 가드로 보완한다(대가 = 위 한계들). - 장기 방향: 빌드를 **로컬 → 단일 외부 환경(CI 러너 또는 EAS Build)**으로 통일하면 “빌드=발행 같은 커밋/환경”이 보장돼
fingerprint정책 전환이 가능해지고 #1·#2·#4 와 릴리즈 타이밍 게임이 크게 줄어든다. (지금은 혼합 환경이라appVersion이 옳은 선택)
관련 파일
| 파일 | 역할 |
|---|---|
apps/native-app/app.config.ts | runtimeVersion 정책, updates 블록, 채널/URL by env |
apps/native-app/release.config.js | 릴리즈 제어판(APP_VERSION/IS_PRERELEASE 등) |
packages/native-app-shared/ota/index.ts | UpdatePolicy enum (서버·클라 공유) |
apps/native-app/src/features/ota/OtaUpdateGate.tsx | 정책 소비/분기(배관) |
apps/native-app/src/features/ota/OtaUpdateBanner.tsx | 상단 슬림 “지금 업데이트” 배너 UI |
apps/native-app/src/features/ota/resolveOtaBannerVisibility.ts | 배너 노출 판정 + readUpdatePolicy(순수) |
apps/native-app/scripts/release-update.mjs | export→R2→release POST, fingerprint 계산 |
apps/native-app/scripts/ota-drift-check.mjs | dry-run 드리프트 체크(발행 없이 baseline 비교) |
apps/api/src/app/api/updates/manifest/route.ts | 매니페스트 endpoint |
apps/api/src/app/api/updates/release/route.ts | release 등록 + fingerprint 가드(production 한정) |
apps/api/src/app/api/updates/baseline-fingerprint/route.ts | baseline fingerprint 조회(dry-run 게이트용) |
apps/api/src/features/expoUpdates/manifest.ts | protocol v1 응답 빌더(서명·multipart) |
packages/db/schema/expoUpdate.ts · services/expoUpdate.ts | release 스키마·조회 |
apps/native-app/fastlane/Fastfile | assert_not_prerelease / deploy lane |
.github/workflows/native-app-ota.yml | 발행 워크플로. push: develop→dev·main→prod. workflow_dispatch(channel): hotfix/유지보수 시 임의 ref(release/<ver>)에서 채널 지정 발행 |
.github/workflows/gate-main-on-ota.yml | develop→main dry-run 게이트(require-ota-green) |