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 을 APP_VERSION 문자열 그대로(pre-release 면 -pre suffix) 사용한다. apps/native-app/app.config.ts:
// release.config.js 에서 import 한 APP_VERSION / IS_PRERELEASE
runtimeVersion: IS_PRERELEASE ? `${APP_VERSION}-pre` : APP_VERSION,왜 fingerprint 정책이 아니라 appVersion 정책인가
Expo 의 runtimeVersion 정책은 여러 가지(appVersion, nativeVersion, fingerprint, …)가 있는데 우리는 appVersion(사실상 고정 문자열)을 쓴다.
fingerprint정책의 함정: fingerprint 해시는 빌드 환경마다 달라진다(로컬 macOS vs Linux CI/EAS). 자체 호스팅 OTA 는 CI(Linux)에서 발행하는데, 사용자 손에 있는 빌드가 다른 환경에서 만들어졌으면runtimeVersion이 불일치해 OTA 가 아예 안 떨어진다. 이 문제를 실제로 겪었다.- appVersion(고정 문자열)의 장점: 환경과 무관하게 결정적이다. CI 발행이든 로컬 빌드든
runtimeVersion이 항상4.0.0(또는4.0.0-pre)으로 똑같아 매칭이 보장된다.
발행 스크립트도 fingerprint 해시를 직접 계산해 runtimeVersion 으로 쓰지 않고, CLI 로 app.config 정책을 그대로 resolve 한다(scripts/release-update.mjs 의 resolveRuntimeVersion → npx expo-updates runtimeversion:resolve). 정책을 나중에 바꿔도 발행 runtimeVersion 이 클라이언트와 항상 일치한다.
runtimeVersion은 네이티브 빌드에 박히는 값이다(iOSExpo.plist, Androidstrings.xml). 즉 OTA 로는 못 바꾼다. 새runtimeVersion을 쓰려면 새 스토어 빌드를 내야 한다. 그래서 fingerprint(네이티브)를 여기에 직접 물리면 안 되고, 대신 버전을 사람이 올린다.
runtimeVersion = 호환 매트릭스, fingerprint = 발행 가드
두 값의 역할을 헷갈리면 안 된다.
| 값 | 무엇 | 어디에 박히나 | 역할 |
|---|---|---|---|
runtimeVersion (APP_VERSION [+-pre]) | OTA 매칭 키 | 네이티브 빌드(불변) + release row | 같은 값끼리만 OTA 전달 |
fingerprint (@expo/fingerprint hash) | 네이티브 구성 스냅샷 | release metadata (DB) | “네이티브가 바뀌었나” 발행 시 감지 |
runtimeVersion 은 사람이 관리하는 굵은 호환 경계(스토어 빌드 단위)이고, 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 에 버전을 함께 올리면 안 걸린다(게이트는 깜빡 방지 안전망).
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)에서만, pending 1건당 1회.
⚠️ 현재는 정책 수신·분기 배관만 있다.
Immediate(오버레이 후reloadAsync)·Notify(재실행 안내) 의 실제 UX 동작은 TODO 로, 확정 전까지 실질적으로 전부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사용자에게 다음 콜드런치에 적용. - 네이티브를 바꿨다(플러그인·네이티브 의존성·
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 | 같은 버전 문자열 공존 | 스토어가 같은 version+build 재제출 허용 → 같은 runtime 에 native 다른 두 바이너리 공존 가능 | 실사용자는 스토어가 버전당 1빌드만 서빙해 안전. 잔여 = native 다른 같은-버전 빌드가 TestFlight/내부에 남고 그 뒤 production OTA. native 변경 시 버전 up 으로 해소 |
| 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 안 바뀜”이 증명된 게 아니다. #1·#2 가 가장 실질적이며 둘 다 코드가 아니라 버전 규율 + 릴리즈 절차로 닫힌다.
릴리즈 절차
핵심 규율: 네이티브 변경 = APP_VERSION 상향, 그리고 버전 상향은 스토어 출시 의무를 동반한다. 이 절차는 native-release 스킬로 자동 안내된다.
- 네이티브 변경 + 버전 상향을 develop 에 정상 머지(협업상 미루지 않음). 함께 올리면 dry-run 게이트에 안 걸린다. (깜빡해 게이트에 막히면 그때 버전 상향으로 해소)
- 그 (squash) 머지 커밋에서
fastlane deploy— 이 레포는 squash merge 라 반드시 머지 후 develop 커밋에서 빌드(브랜치/머지 전·main 빌드 아님). uncommitted 빌드 금지. - deploy 성공 후 그 커밋에
native-v<ver>태그 + push(실패 시 태그 없음). - develop→main·OTA 는 정상 흐름. 새 버전 production OTA 는 심사 중에도 자유 발행(구 트랙 무영향, 새 트랙엔 아직 스토어 유저 없음). 스토어 라이브 = non-event — 새 바이너리가 이미 서빙 중인 그 트랙에 합류.
왜 미루지/홀드하지 않나: 네이티브 변경이 mainline 에 들어온 이상 구버전 OTA 는 어차피 불가(그 JS 가 구 바이너리에서 크래시). 그러니 버전 상향을 미뤄도 라이브 유저에게 얻는 게 없다. 대신 버전을 정상적으로 올려 새 트랙으로 가고, 라이브 유저는 심사 중 동결(크래시 아님)됐다가 스토어 업데이트로 이행한다.
baseline 자동 정합: 버전을 develop 에 도입하는 그 커밋 = 스토어 빌드 커밋 = 첫 새-버전 OTA 커밋이라, 첫 OTA 의 native == 스토어 빌드가 자동 보장된다(별도 go-live 점검 불필요).
한 runtimeVersion = 네이티브 상태 하나: 같은 버전에 두 번째 네이티브 변경을 넣으면 production 발행 시 가드 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 는 그 태그에 워크플로가 없을 수 있어 브랜치 경유) - 재제출 버전 규율: 반려된 다음 버전을 네이티브 변경으로 고쳐 재제출하면 다음 patch 로(예: 4.0.1→4.0.2). 같은 runtimeVersion 에 두 네이티브 상태가 공존하면 그 트랙 OTA 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/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) |