요약
- React Native 앱 바이너리 안에 박힌 JS 엔진(Hermes/JSC)이 JS 번들을 인터프리트하는 구조이므로, 네이티브는 그대로 두고 번들만 갈아끼울 수 있다.
- 클라이언트는
deploymentKey + appVersion + packageHash로 서버에 질의하고, 서버는 호환되는 가장 최신 패키지를 골라 다운로드 URL을 돌려준다. - 다운로드 크기는 publish 시점에 직전 N개 패키지와의 파일 단위 diff zip을 미리 만들어 두는 방식으로 줄인다.
본문
상위 원리는 인터프리트되는 코드는 데이터이므로 OTA로 교체 가능하다 참고. 이 노트는 CodePush가 그 원리를 어떻게 구체적으로 구현했는지를 다룬다.
핵심 식별자 4개
| 식별자 | 의미 |
|---|---|
deploymentKey |
배포 채널 식별. 한 앱이 Staging / Production을 다른 키로 운영. |
appVersion |
네이티브 앱 바이너리 버전 (semver). 이 버전과 호환되는 JS 번들만 받아야 안 깨짐. |
packageHash |
현재 클라이언트가 가진 JS 번들의 해시. 서버가 클라이언트 상태를 식별하는 핵심 키. |
label |
v1, v2 같은 사람이 읽는 릴리즈 라벨. |
appVersion이 필요한 이유: JS 번들은 네이티브 모듈의 API를 호출하므로, 네이티브 API가 바뀐 새 바이너리에서는 옛 JS 번들이 깨질 수 있음. 그래서 호환 범위를 semver로 명시.
Update Check 플로우
클라이언트가 앱 실행 시 서버에 deploymentKey + appVersion + packageHash + label로 질의. 서버 동작:
- 해당 deployment의 패키지 히스토리를 역순(최신부터) 으로 순회.
- 각 패키지의
appVersion이 클라이언트appVersion을 만족(semver satisfies)하는지 확인. - 만족하는 가장 최신 enabled 패키지를 후보로 선택.
- 후보의
packageHash가 클라이언트와 같으면 → 업데이트 없음 (isAvailable: false). - 다르면 → 그 패키지의
downloadURL,packageHash,isMandatory,packageSize를 반환.
핵심은 "해시로 비교 → URL만 돌려준다"는 단순성. 서버는 zip 트래픽을 직접 받지 않고 객체 스토리지의 URL로 위임한다.
Diff 패키지 (델타 업데이트)
전체 zip을 매번 받으면 데이터 낭비가 큼. CodePush는 publish 시점에 미리 diff를 만들어 저장:
- 새 패키지가 들어오면, 같은
appVersion을 가진 직전 N개(default 5) 패키지를 가져옴. - 각 패키지에 대해 파일 경로 → 해시 매니페스트(
hotcodepush.json)를 비교. - 변경/추가된 파일만 zip에 넣고, 삭제된 파일 목록은 매니페스트에 기재 → diff zip 생성.
- 패키지 메타에
diffPackageMap[oldPackageHash] = { url, size }형태로 저장.
Update Check 시 클라이언트의 packageHash에 해당하는 diff가 있으면 full zip 대신 diff zip URL을 돌려준다. 클라이언트는 받아서 기존 번들 위에 패치 적용.
중요한 제약: 같은 appVersion(semver match)에 한해서만 diff 생성. 다른 네이티브 버전 간 diff는 의미 없음 — 네이티브 인터페이스가 달라졌을 수 있으므로 full 번들을 받아야 함.
Rollout (점진 배포)
rollout: 0-100 퍼센트. 100 미만이면 일부 클라이언트에게만 업데이트를 노출:
hash(clientUniqueId + packageHash) % 100 < rollout
- 결정론적(deterministic) — 같은 클라이언트는 항상 같은 결과. 어떤 유저는 받고 어떤 유저는 안 받는 상태가 새 요청마다 흔들리지 않는다.
packageHash를 시드에 섞기 때문에 패키지가 바뀌면 rollout 그룹도 새로 결정 — 이전 패키지에서 빠진 유저가 다음 패키지에서도 빠질 확률은 무작위.clientUniqueId가 없으면 rollout 대상에서 제외.
Mandatory 누락 방지
클라이언트가 v3 → v7로 점프할 때, 중간 v5가 isMandatory: true였다면 결과적으로 v7도 mandatory로 강제. 보안 패치가 optional v6, v7에 묻혀 그냥 넘어가는 사고를 막는다.
코드상 구현: 히스토리 역순 순회 중 mandatory 패키지를 만나면 shouldMakeUpdateMandatory = true 플래그를 세우고 그대로 break.
Storage 구조
| 데이터 | 저장소 |
|---|---|
| 앱·배포·패키지 히스토리 메타데이터 | D1 (SQLite) |
| 패키지/Diff zip 파일 | R2 (S3 호환 오브젝트 스토리지) |
| 캐시 | D1 자체를 캐시 레이어로 활용 (Redis 대안) |
R2 URL을 그대로 클라이언트에 던지므로 Worker는 zip 트래픽을 받지 않는다. CDN edge에서 직접 다운로드.
Metrics와 롤백 판단
클라이언트가 다운로드/설치 성공/실패를 서버에 리포트. ACTIVE count는 deployment + label 별로 현재 그 버전을 쓰는 디바이스 수를 추적. 새 라벨의 ACTIVE가 늘면서 이전 라벨이 줄어드는지 확인 → 안 늘어나면 클라이언트가 적용 실패 중이라는 신호 → 롤백 트리거.
전체 흐름
개발자: JS 번들 빌드 → CLI로 publish
↓
서버: zip을 R2에 저장 + 직전 패키지들과 diff 생성 + DB 메타 기록
↓
클라이언트: 앱 실행 시 update check
↓
서버: appVersion 매칭 → 최신 enabled 패키지 선택 → diff 또는 full URL 반환
↓
클라이언트: zip 다운로드 → 다음 앱 재시작 시 적용
네이티브 코드는 이 전 과정에서 한 번도 안 바뀐다.
관련 노트
참고
- Microsoft CodePush 원본 서버: https://github.com/microsoft/code-push-server
- Cloudflare Workers 포트 (이 노트의 코드 참조 대상): https://github.com/ssut/code-push-cloudflare-workers
- 코드 위치:
apps/server/src/handlers/updateCheck.ts,apps/server/src/utils/package-differ.ts,apps/server/src/utils/rollout.ts