Skip to Content
Native AppOTA (self-hosted Expo Updates) & 버전 관리

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.tsgetLatestExpoUpdateReleasechannel/platform/runtimeVersion 3중 eq 조회).

이 프로젝트는 runtimeVersionproduction 은 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네이티브 빌드에 박히는 값(iOS Expo.plist, Android strings.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) 각각:

  1. runtimeVersion resolve (위 참조).
  2. fingerprint 계산(createFingerprintAsync, platform별 1회 캐싱).
  3. bundle(launch asset) + assets 를 hash 기반 key 로 R2 에 업로드. asset key 는 updates/<channel>/<runtimeVersion>/assets/<hash> 형태라 동일 asset 은 자동 dedup 된다(R2 PutObject 는 idempotent — head 조회 없이 그냥 PUT).
  4. release endpoint 에 platform별 POST.

release POST payload(apps/api/.../updates/release/route.tsReleasePayload)의 핵심 필드:

필드의미
channel'development' | 'production'
platform'ios' | 'android'
runtimeVersion매칭 키
launchAsset / assetsR2 URL + hash
expoConfigpublic Expo config (아래 참조)
updatePolicy적용 정책(1/2/3, 아래 참조)
fingerprint발행 가드 비교 기준
allowNativeChange⚠️ break-glass 우회 플래그
metadatacommit/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 응답의 manifest part 로 직렬화, part raw bytes 에 RSA-PKCS1v15-SHA256 서명(expo-signature 헤더). 클라이언트는 빌드에 박힌 certs/certificate.pem(app.config codeSigningCertificate)으로 검증한다.
  • release 없음 → 204 No Content(“update 없음”). 클라이언트 현재 update id 와 최신 id 가 같으면 → 204(변경 없음).

인증 없는 endpoint다 — 보안은 응답 서명 검증으로 한다.

extra.expoClient 가 반드시 필요한 이유 (anti-bricking)

매니페스트의 extra.expoClientpublic 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 suffixpre-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.jsonscripts
  • 제외(=바뀌어도 통과): 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(=-pre suffix)로 한다 — 그건 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 가 담당한다.

동작(발행 없음):

  1. turbo 그래프로 native-app 영향 판정 — 미영향이면 즉시 통과.
  2. 영향이 있으면 platform 별로 runtimeVersion resolve + @expo/fingerprint 계산(release-update.mjs 와 동일 방식이라 baseline 과 비교 가능).
  3. 서버의 production baseline fingerprint 조회: GET /api/updates/baseline-fingerprint?channel=production&runtimeVersion=&platform=.
  4. 같은 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.ymldevelop push 마다 돈다:

  1. turbo 로 native-app 영향 판정(미영향 스킵).
  2. ota:drift-check(production baseline 대비 fingerprint) → 드리프트 확정(exit 1) 이면 bump 필요.
  3. scripts/native-bump.mjs --apply --type buildcodebuildcode 만 bump(iOS build +1 / Android versionCode = 모든 태그·현재 최대 +1).
  4. 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 (누적 트랙)

  1. 첫 네이티브 변경이 develop 에 들어오는 순간 release.config.js 의 빌드번호(+필요시 APP_VERSION)를 한 번 올린다 → 새 트랙 개설 → 게이트 green.
  2. 이후 네이티브 변경들은 그 같은 pending 트랙에 계속 쌓인다(추가 bump 불필요). 그 트랙에 production baseline 이 생기기 전(=미출시)이라 게이트는 계속 green.
  3. 준비되면 그 누적 버전 하나로 빌드·제출·태그(native-release). 스토어 바이너리에 누적 네이티브 변경 전부 포함.
  4. 출시 후 다음 네이티브 변경은 새 사이클 → 다시 bump.
  • 장점: 네이티브 변경을 develop 에 미루지 않고 계속 통합(CI·충돌 관리 유리). native-release 스킬의 기존 권장(“네이티브 변경은 빌드번호 상향과 함께 develop 에”)과 일치.
  • ⚠️ 단점(트레이드오프): develop 을 다음 버전으로 올린 순간부터 develop→main OTA 는 그 미출시 트랙으로 발행된다 → 그 사이 live(구버전) 유저는 mainline OTA 를 못 받는다. 이 window 동안 구버전 JS 수정은 hotfix(release/<현재출시버전>)로 내린다. → bump 는 “곧 빌드 낼 때” 하고 빌드를 오래 끌지 말 것.

옵션 B — draft/브랜치로 모아 릴리즈 때 일괄 머지

  1. 네이티브 변경 PR 들을 draft(또는 미머지 브랜치)로 보류해 develop 에 넣지 않는다.
  2. 릴리즈를 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.jsIS_PRERELEASE 하나로 두 가드를 동시에 제어한다. -pre suffix 가 스위치 역할이다.

IS_PRERELEASEruntimeVersionOTA fingerprint 가드fastlane deploy(스토어 제출)
true<ver>-pre우회(같은 pre 트랙 내 네이티브 자유 변경)차단
false<ver>ON허용
  • OTA 쪽: 서버가 runtimeVersion.endsWith('-pre') 면 fingerprint 가드를 건너뛴다.
  • 스토어 쪽: fastlane/Fastfileassert_not_prerelease(platform)deploy lane 진입 시 runtimeVersion 을 resolve 해 -pre 로 끝나면 예외를 던져 스토어 제출을 거부한다. (ios deploy / android deploy lane 모두 호출)

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_POLICY env(1|2|3)로 실어 보낸다. 미지정이면 payload 에서 빠지고 서버/클라이언트가 Default 로 취급.
  • 서버는 manifestextra.updatePolicy 로 서빙(serializeManifestrelease.updatePolicy != null 일 때만).
  • 클라이언트 apps/native-app/src/features/ota/OtaUpdateGate.tsxUpdates.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 는 유저에게 배너로 넛지된다.

⚠️ 배너는 이 코드가 포함된 새 스토어 빌드부터 뜬다. 그 이전 구버전 바이너리는 Notify release 를 받아도 배너를 못 그려 현행 Default 처럼 동작(다음 콜드런치 자동 적용).

채널 & 환경 (APP_ENV)

APP_ENV 가 단일 진실 공급원이고, 거기서 채널(2종)·매니페스트 URL 이 파생된다(app.config.tsUPDATES_CHANNEL_BY_ENV / UPDATES_URL_BY_ENV). 채널 enum 은 'development' / 'production' 딱 둘이다(expoUpdateChannelEnum).

APP_ENV채널매니페스트 URL비고
local(development)UPDATES_ENABLED=false (Metro 직접)
developmentdevelopmentdev-front-apidevelop OTA
qadevelopmentdev-front-apidevelopment 채널 공유
productionproductionfront-apimain OTA
canarydevelopmentdev-front-apiprod 콘텐츠 + 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/qadevelopment 채널을 그대로 구독하므로, develop 에 발행된 OTA 를 함께 받는다. 서버·전용 채널 변경이 필요 없다.

CI 워크플로 (native-app-ota.yml)

트리거는 2단계로 좁힌다:

  1. push paths(싼 GitHub 1차 필터): apps/native-app/**, packages/**, yarn.lock, 이 워크플로. packages/** 는 native-app 이 의존하지 않는 패키지에도 매칭되는 오탐(superset)이 있다.
  2. changes job(turbo 의존성 그래프): native-app(또는 그 의존 패키지)이 실제 영향받았는지 정밀 판정. 미영향이면 ota job 을 스킵해 불필요한 export+R2 발행을 피한다. base SHA 부재(수동 실행·첫 push)·판정 실패면 보수적으로 native=true.

ota job: checkout → node_modules 캐시 복원 → Resolve channelyarn ota:export → (Sentry 소스맵 업로드, continue-on-error) → yarn ota:release.

함정: node_modules 캐시 key 에 yarn.lock 뿐 아니라 워크스페이스 package.json 들도 해시한다. dep 없는 새 워크스페이스 패키지 추가는 yarn.lock 을 안 바꿔서, yarn.lock 만 key 로 쓰면 캐시 HIT → install 스킵 → 새 패키지 심링크가 없는 stale node_modules 로 빌드가 깨진다(Unable to resolve).

DB 스키마 (expo_update_release)

한 release = (channel, runtimeVersion, platform) 조합에 대한 OTA 한 벌(packages/db/schema/expoUpdate.ts).

  • 조회 인덱스 expo_update_lookup_idx on (channel, runtimeVersion, platform, createdAt) — manifest endpoint 가 그 조합의 최신 createdAt row 1개를 가져온다.
  • 롤백은 별도 latest 컬럼이 아니라 같은 조합에 더 늦은 createdAt 로 새 row 를 insert 하는 방식이다(“가장 최근 = 활성”).
  • update_policyNULL OR BETWEEN 1 AND 3 check 제약.

운영 규칙 (요약)

이 한 줄이 전부다:

네이티브(fingerprint)가 바뀌면 버전을 올려 스토어 빌드를 낸다. JS-only 변경이면 그냥 OTA 로 나간다.

구체적으로:

  • JS/asset만 바꿨다 → develop/main 에 머지하면 CI 가 자동 OTA 발행. 같은 runtimeVersion 사용자에게 적용된다 — production 은 상단 “지금 업데이트” 배너(Notify)로 즉시 적용 유도, 안 누르면(또는 구버전 바이너리면) 다음 콜드런치에 자동 적용. development/qa/canary 는 무음(다음 콜드런치).
  • 네이티브를 바꿨다(플러그인·네이티브 의존성·app.config 네이티브 설정 등) → release.config.jsAPP_VERSION(+ ANDROID_VERSION_CODE, iOS IOS_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 제외):

#구멍왜 못 잡나완화
1baseline 이 스토어 바이너리와 미검증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
5baseline 체인 끊김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 스킬로 안내된다.

  1. 네이티브 변경 + 빌드번호 상향(필요시 마케팅 버전)을 develop 에 정상 머지(협업상 미루지 않음). 빌드번호가 오르면 새 nativeVersion 트랙이라 dry-run 게이트에 안 걸린다. (빌드번호 깜빡해 막히면 그때 상향으로 해소)
  2. 그 (squash) 머지 커밋에서 fastlane deploy — 이 레포는 squash merge 라 반드시 머지 후 develop 커밋에서 빌드(브랜치/머지 전·main 빌드 아님). uncommitted 빌드 금지.
  3. deploy 성공 후 그 커밋에 native-v<APP_VERSION>+<ANDROID_VERSION_CODE> 태그 + push(실패 시 태그 없음).
  4. 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.tsruntimeVersion 정책, updates 블록, 채널/URL by env
apps/native-app/release.config.js릴리즈 제어판(APP_VERSION/IS_PRERELEASE 등)
packages/native-app-shared/ota/index.tsUpdatePolicy 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.mjsexport→R2→release POST, fingerprint 계산
apps/native-app/scripts/ota-drift-check.mjsdry-run 드리프트 체크(발행 없이 baseline 비교)
apps/api/src/app/api/updates/manifest/route.ts매니페스트 endpoint
apps/api/src/app/api/updates/release/route.tsrelease 등록 + fingerprint 가드(production 한정)
apps/api/src/app/api/updates/baseline-fingerprint/route.tsbaseline fingerprint 조회(dry-run 게이트용)
apps/api/src/features/expoUpdates/manifest.tsprotocol v1 응답 빌더(서명·multipart)
packages/db/schema/expoUpdate.ts · services/expoUpdate.tsrelease 스키마·조회
apps/native-app/fastlane/Fastfileassert_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.ymldevelop→main dry-run 게이트(require-ota-green)
Last updated on