본문으로 건너뛰기

React Native Expo OTA Updates

직접 만든 Expo 업데이트 서버를 Codemagic Patch로 바꾸기

네이티브 프로젝트까지 커밋된 실제 Expo 앱을 expo-updates에서 Codemagic Patch로 옮겼습니다. 로컬 스택, 마이그레이션 과정, 제가 만들고 OTA로 고친 버그, 그리고 릴리스가 실제로 크래시했을 때 롤백이 어떻게 되는지까지.

밝혀둘 것. 이 마이그레이션과 글은 Codemagic의 의뢰로 비용을 받고 진행했습니다. Codemagic은 사실관계 확인을 위한 검토만 했을 뿐 어조에는 관여하지 않았고, 이 글의 어떤 부분도 그쪽 요청으로 바뀌지 않았습니다. 문제를 만나 보고한 곳은 그렇다고 적었고, 읽으실 시점에 고쳐져 있는 문제는 고쳐졌다고 적어두었습니다.

지난 2월에 Django와 Tigris로 Expo 업데이트 서버를 직접 만든 이야기를 썼습니다. 그 서버는 지금도 잘 돌아갑니다. 제가 운영하는 커뮤니티 앱 maru는 그 뒤로 모든 OTA 수정을 두 플랫폼 모두 이 서버를 통해 받았고, 비용은 월 1달러 정도였습니다.

동시에 그 모든 조각을 제가 떠안고 있다는 뜻이기도 합니다. Django 모델 두 개와 마이그레이션, 430줄짜리 viewset, 400줄짜리 관리 명령, 210줄짜리 퍼블리시 스크립트, 임포트 시크릿, 그리고 업데이트가 하나씩 깨질 때마다 배운 함정 목록까지요. 이번 작업을 계획하려고 앉았다가 알게 된 건데, 퍼블리시 스크립트는 처음 쓴 날부터 프로덕션 퍼블리시가 끝날 때마다 “Sent 0 push notifications"를 출력하고 있었습니다. 이 서버가 한 번도 돌려준 적 없는 필드를 읽고 있었던 거죠. 아무도 눈치채지 못했고, 저부터가 그랬습니다. 직접 만든 걸 운영한다는 게 대개 이렇게 흘러갑니다.

그래서 Codemagic에서 CodePush를 오픈소스로 다시 만든, 직접 호스팅할 수 있는 Patch를 써보지 않겠냐고 물었을 때, 조건 하나를 걸고 수락했습니다. 데모가 아니라 실제 앱으로 하겠다는 것. 실제 서명, 실제 릴리스 절차, 손으로 고친 부분이 들어 있는 커밋된 네이티브 프로젝트, 전부 다요. EAS Update에서 옮겨가는 사람 대부분은 새 템플릿이 아니라 그런 앱을 옮기니까요.

그 과정이 어땠는지, 거친 부분을 다듬지 않고 그대로 적었습니다.

Patch가 뭔가
#

Patch는 직접 운영하는 서버와 앱에 넣는 SDK로 이루어져 있습니다. 서버 쪽은 API, 백그라운드 워커, Postgres, S3 호환 오브젝트 스토리지, 웹 대시보드이고, Docker Compose 스택으로 제공됩니다. 클라이언트 쪽은 @codemagic/react-native-patch인데, iOS와 Android용 TurboModule에 Expo config plugin이 딸려 있고, 여기에 JavaScript를 번들링해서 퍼블리시하는 CLI cmpatch가 있습니다.

소스를 읽고 나니 설계 결정 두 가지가 눈에 들어왔습니다.

첫째, 앱은 업데이트가 있는지 API에 묻지 않습니다. 릴리스가 퍼블리시되면 워커가 오브젝트 스토리지에 평범한 JSON 매니페스트를 써두고, 앱은 그걸 가져오기만 합니다. 업데이트 확인은 스토리지나 CDN에 대한 정적 GET 두 번이고, API는 메트릭만 받습니다. 제 Django 엔드포인트는 앱이 실행될 때마다 요청 경로 위에 있었습니다. 이건 그럴 수가 없죠.

둘째, 모든 릴리스는 자기가 대상으로 하는 바이너리 버전을 정확히 명시하고, CLI는 네이티브 프로젝트의 핑거프린트를 계산해서 네이티브 코드가 일치하지 않는 바이너리에는 JavaScript 퍼블리시를 거부합니다. 제 서버에는 runtime_version 컬럼 하나와 많은 기대만 있었습니다.

먼저 노트북에서 돌려보기
#

문서는 로컬 평가용 스택부터 시작하라고 안내하는데, 시작점으로 맞습니다.

npm install -g @codemagic/patch-cli
cmpatch selfhost local-eval up

CLI 설치는 2초 만에 끝났습니다. 스택은 처음에 3분 14초가 걸렸는데 대부분 서버와 대시보드 이미지를 로컬에서 빌드하는 시간이었고, 비대화형 모드에서는 그동안 아무것도 출력하지 않아서 딱 멈춘 것처럼 보입니다. 그러고 나면 정상 컨테이너 네 개가 전부 localhost에 바인딩되어 나타납니다. 대시보드는 8080, API는 3000, Postgres, 그리고 MinIO는 9100. 제 스택이 이미 쓰고 있던 포트는 알아서 피해 갔습니다.

이 모드에서는 로그인이 꺼져 있고, 페이지마다 외부에 노출하지 말라는 경고가 붙습니다.

로컬 평가용 로그인 페이지. 이메일이 미리 채워져 있고 인증이 비활성화됐다는 경고가 보인다
로컬 평가 모드에서는 GitHub 로그인 대신 미리 채워진 이메일이 나옵니다.

스택에는 Example Data라는 앱이 시드로 들어 있는데, 뭔가 퍼블리시하기 전에 한번 볼 만합니다. 대시보드가 무엇을 위한 것인지 보여주거든요. 다음 두 스크린샷의 숫자는 실제 사용자가 아니라 그 시드 데이터입니다.

시드된 Example Data 앱의 릴리스 이력. 10% 카나리 릴리스와 릴리스별 성공/실패 횟수가 보인다
시드 데모 데이터: 10% 카나리, 대상 버전, 릴리스별 성공/실패 횟수가 있는 릴리스 이력.
시드된 앱의 배포 메트릭: 버전 분포, 시간에 따른 적용률, 업데이트 결과
시드 데모 데이터: 기기들이 실제로 어떤 릴리스를 돌리고 있는지, 설치는 어떻게 됐는지.

비교하자면 제 대시보드는 Django admin의 업데이트 목록에 “is active” 체크박스가 달린 게 전부였습니다. 실제로 몇 대의 폰이 업데이트를 받았는지는 전혀 몰랐고요.

CLI 로그인은 PKCE와 localhost 콜백을 쓰는 제대로 된 브라우저 플로우이고, 승인 페이지는 어느 계정이 요청하는지와 코드가 1분쯤 뒤에 만료된다는 걸 알려줍니다.

CLI 로그인을 승인할지 묻는 대시보드
cmpatch login은 대시보드에 터미널을 승인해 달라고 요청합니다.

실제 앱에 연결하기
#

Patch는 플랫폼마다 앱 하나를 원하고, 각 앱에는 Staging과 Production 배포(deployment)가 미리 만들어져 있습니다. 저는 maru-ios는 대시보드에서, maru-androidcmpatch app create로 만들었습니다. 둘 다 써보고 싶어서요. 앱을 만들고 나서 뜨는 다이얼로그가 마이그레이션에는 이 제품에서 가장 유용한 화면입니다. 배포 키 두 개, SDK에 필요한 URL 두 개, 그리고 배포 키는 비밀이 아니라는 안내까지 한 화면에 있습니다.

앱 생성 완료 다이얼로그. Staging과 Production 배포 키, SDK URL이 보인다
SDK에 필요한 모든 것이 한 화면에.

사소한 것 둘. cmpatch init은 이미 존재하는 앱에 프로젝트를 연결하는 명령이라, 마이그레이션 가이드가 어떻게 읽히든 첫 번째 명령이 될 수 없습니다. 그리고 다이얼로그는 API URL을 http://localhost:8080으로 알려주는데 문서와 CLI는 3000이라고 합니다. 각 포트가 둘 다 서빙하기 때문에 둘 다 동작하지만, 고개를 갸웃하게 되죠.

그다음 손대지 않은 앱에 cmpatch doctor를 돌렸더니 3초 만에 진짜 문제를 하나 찾아냈습니다. iOS 바이너리 버전을 2.0.0이라고 보고한 겁니다. 앱은 2.5.1입니다.

CLI는 ios/ 아래의 모든 Info.plist를 찾고, 경로에 “test"가 들어간 게 없으면 알파벳순으로 첫 번째를 고릅니다. maru에는 WidgetKit 익스텐션이 있고, ios/ExpoWidgetsTarget/Info.plistios/maru/Info.plist보다 앞에 정렬됩니다. 익스텐션의 plist가 2.0.0에 머물러 있던 건 제 버그지만, 결과는 고약합니다. 자동 감지는 모든 iOS 릴리스를 아무 폰도 돌리지 않는 버전에 겨냥하고, 에러는 하나도 나지 않습니다. 업데이트가 그냥 영영 도착하지 않는 거죠. Tigris 글에서 경고했던 바로 그 실패입니다. Android는 versionName을 제대로 읽었습니다. 우회책은 --target-binary-version이나 --plist-file을 넘기는 것이고, 제 퍼블리시 스크립트는 이제 app.config.js와 두 네이티브 프로젝트가 일치하는지 확인한 뒤 버전을 명시적으로 넘깁니다. 나중에는 같은 버그 때문에 doctor 자체의 매니페스트 검사가 멀쩡히 동작하는 배포에 대해 실패하기도 했습니다. 2.0.0 버전의 매니페스트를 찾으러 갔으니까요. 낡은 plist를 고치고 나서는 doctor가 24개 검사를 전부 통과했습니다.

JavaScript 쪽
#

expo-updates를 제거하고 SDK를 추가하는 건 두 줄짜리 변경입니다. 앱이 업데이트로 하던 일을 대체하는 데는 더 고민이 필요했습니다.

maru는 실행 시점과 포그라운드로 돌아올 때마다 업데이트를 확인하는데, 아무리 잦아도 15분에 한 번입니다. 업데이트가 있으면 내려받고 재시작할지 물어봅니다. 거절하면 다음 콜드 스타트 때 적용되고요. 설정 화면에는 지금 어떤 번들이 돌고 있는지 보여주는 수동 확인도 있고, 에러 리포터는 모든 리포트에 출처 업데이트를 태그로 붙입니다.

SDK 소스에서 처음 알게 된 건, 임포트 시점에 TurboModuleRegistry.getEnforcing으로 네이티브 모듈을 찾고 모듈이 없으면 예외를 던진다는 점입니다. maru는 웹 빌드도 내놓고 있어서, 최상위 임포트는 웹 앱을 통째로 죽이게 됩니다. 그래서 전부 SDK를 지연 로드하는 작은 모듈 하나를 거치게 하고, 아무것도 반환하지 않는 .web.ts 짝을 두었습니다. 덕분에 SDK가 웹 번들에서 완전히 빠지기도 하고요.

// services/ota.ts
export function patchSdk(): PatchSdk | null {
  if (sdk !== undefined) return sdk;
  if (Platform.OS === "web") return (sdk = null);
  try {
    sdk = require("@codemagic/react-native-patch") as PatchSdk;
  } catch {
    sdk = null;
  }
  return sdk;
}

자동 확인 자체는 대부분 sync()입니다. 예외를 던지지 않고 상태 값으로 resolve되죠.

const status = await patch.sync({
  installMode: "ON_NEXT_RESTART",
  mandatoryInstallMode: "ON_NEXT_RESUME",
});
if (status === "update-installed") offerRestart();

필수(mandatory) 릴리스에 대한 SDK 기본값은 IMMEDIATE인데, 사용자가 어디에 있든 JavaScript를 그 자리에서 다시 로드합니다. 저는 앱이 다음에 백그라운드에서 돌아올 때 적용되도록 바꿨습니다. 쓰다 만 글이 날아가는 건 웬만한 수정이 주는 이득보다 더 나쁜 버그니까요. 그리고 InstallMode는 문서 예제가 암시하는 enum 객체가 아니라 문자열 유니온이라는 점도 적어둡니다.

네이티브 쪽, ios/와 android/를 커밋해뒀다면
#

마이그레이션 가이드는 config plugin을 추가한 뒤 npx expo prebuild --clean을 돌리라고 합니다. 별도 체크아웃에서 돌려봤더니 튜토리얼 이미지 여섯 장, Android 네트워크 보안 설정, iOS 프라이버시 매니페스트, Podfile.lock을 지우고, build.gradle의 Detox 수정과 Info.plist 수백 줄을 되돌려놨습니다. --clean 없이도 Expo는 두 디렉터리를 어차피 비웠고요.

Patch 잘못은 아닙니다. maru는 managed Expo 앱으로 시작해서 네이티브 커스터마이징이 늘어난 경우라 ios/android/가 커밋되어 있고, EAS는 그걸 그대로 빌드합니다. 하지만 이런 앱은 흔하고, 가이드는 경고해주지 않습니다.

플러그인이 실제로 바꾸는 건 손으로 해도 될 만큼 적었습니다. iOS에서는 AppDelegate.swift에 임포트 하나와 한 줄:

return CodemagicPatch.bundleURL() ?? Bundle.main.url(forResource: "main", withExtension: "jsbundle")

Android에서는 MainApplication.kt에 임포트 하나와 인자 하나(React Native 0.82 이상 형태이고, 그 전 버전은 대신 getJSBundleFile()을 오버라이드합니다):

ExpoReactHostFactory.getDefaultReactHost(
  jsBundleFilePath = CodemagicPatch.getJSBundleFile(applicationContext),
  // ...

여기에 플랫폼별 설정값 세 개와 기존 expo-updates 설정 제거까지. 두 프로젝트 합쳐 13줄 추가, 20줄 삭제였습니다.

플러그인은 그 설정값을 prebuild 시점에 리터럴로 써넣는데, 네이티브 프로젝트를 커밋해둔 상태에서는 모든 빌드에 배포 하나가 하드코딩된다는 뜻입니다. 저는 같은 프로젝트에서 프리뷰 빌드는 Staging으로, 스토어 빌드는 Production으로 가길 원했기 때문에, 값을 빌드 환경에서 가져오게 했습니다. Info.plist에서는 Xcode가 빌드 설정을 확장해줍니다.

<key>CodemagicPatchDeploymentKey</key>
<string>$(PATCH_IOS_DEPLOYMENT_KEY)</string>

그리고 android/app/build.gradle에서는:

def patchEnv = { name -> System.getenv(name) ?: (findProperty(name) ?: "") }
resValue "string", "CodemagicPatchDeploymentKey", patchEnv("PATCH_ANDROID_DEPLOYMENT_KEY")

키는 각 eas.json 빌드 프로파일에 EXPO_PUBLIC_ 값들 옆에 둡니다. 값이 비어 있으면 SDK는 조용히 아무것도 하지 않는데, 로컬 빌드라면 괜찮지만 스토어 빌드라면 재앙입니다. 그래서 app.config.js는 이제 preview나 production EAS 빌드에 키가 빠져 있거나 여전히 localhost를 가리키면 예외를 던집니다.

첫 업데이트
#

첫 릴리스로는 로그인 없이도 볼 수 있는 온보딩 헤드라인을 바꿔서 퍼블리시했습니다.

./scripts/publish-patch-update.sh ios --local --notes "Onboarding headline over the air"

30초 걸렸고 대부분 Metro와 Hermes 시간이었습니다. 서버 워커는 업로드가 끝나고 0.6초 뒤에 매니페스트를 퍼블리시했습니다. 업데이트는 압축된 7.3 MB tarball이었는데, 예전 export가 만들던 11 MB Hermes 번들과 에셋에 비하면 작습니다.

여기서 제대로 해야 할 게 하나 있습니다. JavaScript 번들은 빌드될 때 EXPO_PUBLIC_ 값을 인라인하기 때문에, 업데이트는 그것이 올라갈 바이너리와 같은 값으로 빌드해야 합니다. 안 그러면 엉뚱한 백엔드에 말을 거는 JavaScript를 내보내게 되죠. 제 스크립트는 바이너리를 빌드한 것과 같은 eas.json 프로파일에서 값을 불러옵니다.

그런 다음 앱을 다시 실행했습니다. 기기 로그에는 업데이트 확인 전체가 오브젝트 스토리지로 가는 요청 두 개, meta.json2.5.1/manifest.json으로 찍혔습니다. SDK는 실행 3초 뒤에 Downloaded 이벤트를 기록하고 업데이트를 스테이징했고, 앱은 재시작을 제안했습니다.

재실행, sync()가 update-installed를 반환해 뜬 앱 자체 프롬프트, 그리고 OTA로 전달된 릴리스 v1.

버그는 제 것이었다
#

그리고 대시보드를 봤습니다. 시뮬레이터 한 대가 릴리스 v1을 설치했는데, 성공 설치 2건에 활성 사용자 2명으로 표시됐습니다.

Staging 배포에 기기 한 대가 활성 사용자 2명, 성공 2건으로 표시된 화면
시뮬레이터 한 대가 두 번 집계됐습니다.

SDK는 상태와 아직 보내지 않은 메트릭 이벤트를 앱 컨테이너 안에 파일로 보관하기 때문에 추적이 쉬웠습니다. 타임스탬프는 같고 id는 다른 Success 이벤트가 두 개 있었습니다. 제 훅은 마운트 시 notifyAppReady()로 실행 중인 번들을 확인한 뒤 곧바로 sync()를 호출했는데, sync()는 내부에서 notifyAppReady()를 또 호출합니다. 두 호출이 겹치면서 각각 릴리스를 대기 중으로 보고, 각각 이벤트를 기록한 겁니다. 서버는 이벤트 id로 중복을 제거하지만 id가 달랐으니 둘 다 집계됐고, 그 중복은 영구적으로 남습니다.

제 쪽 수정은 실행마다 한 번의 공유 확인을 두고 나머지가 전부 그걸 await하게 하는 것이었습니다. SDK 쪽에서도 겹치는 호출을 막을 수 있을 텐데, 그렇게 제안해뒀습니다.

기분 좋았던 건 수정을 배포하는 과정이었습니다. JavaScript만 바뀐 변경이라 릴리스 v2로 내보냈고, v1을 돌리는 기기에는 바이너리 diff를 보낼 수 있어서 v2는 7.3 MB 번들 대신 543 KB 패치로 내려갔습니다. 크기의 약 7%죠. 재시작 후: 다운로드 1, 설치 1, 성공 1, 활성 기기 1.

v1은 부풀려진 숫자, v2는 정확한 숫자를 보여주는 릴리스 이력
v1의 중복은 남고, v2는 제대로 집계됩니다.

일부러 망가뜨리기
#

롤백은 제가 가장 신경 쓴 기능입니다. 예전 구성에는 기기 쪽 롤백이 전혀 없었거든요. 나쁜 업데이트가 나가면 제가 알아채고 체크박스를 해제할 때까지 실행할 때마다 크래시가 났을 겁니다.

그래서 첫 렌더 전에 예외를 던지는 v3를 릴리스했습니다. 즉 notifyAppReady()를 절대 호출할 수 없는 번들이죠. 앱은 내려받고 재시작을 제안한 뒤 홈 화면으로 죽었습니다.

v3가 실행 직후 크래시.

다음 실행에서도 v3가 다시 부팅되고 다시 크래시했는데, 이건 의외였습니다. 문서에는 notifyAppReady() 전에 크래시하는 번들은 롤백된다고 되어 있으니까요. 소스를 보니 설명이 됩니다. SDK는 크래시로 판정하기 전에 확인되지 않은 실행을 세 번까지 허용합니다. 확인되지 않은 실행 한 번은 JavaScript가 돌기도 전에 iOS가 멀쩡한 프로세스를 죽인 것일 수도 있으니까요. 네 번째 실행에서 v2로 돌아왔습니다.

네 번째 실행: 아무도 서버를 건드리지 않았는데 v2로 롤백.

그다음 기기가 스스로 실패를 crash_rollback으로 보고했고, 대시보드는 그걸 v3에 표시했습니다. 합리적인 설계지만 무슨 뜻인지는 분명히 해둘 필요가 있습니다. 나쁜 릴리스는 사용자마다 크래시 세 번을 겪은 뒤에야 회복된다는 것, 그리고 문서에는 그 숫자가 없다는 것. Staging 먼저, 그다음 Production 소규모 롤아웃을 해야 하는 이유입니다.

다른 모든 사용자에게 나쁜 릴리스를 멈추는 건 명령 하나이고, 1초도 안 걸렸습니다.

cmpatch release rollback --app maru-ios --deployment Staging

이력을 고쳐 쓰지는 않습니다. 이전 릴리스를 새 릴리스(v4, v2의 롤백으로 표시)로 다시 퍼블리시하기 때문에, 크래시한 릴리스는 실패 횟수와 함께 목록에 남습니다. 정직한 기록이죠. 대시보드에서도 다이얼로그로 같은 일을 할 수 있습니다.

대시보드에서의 롤백: v2가 v4로 돌아오고, v3는 목록에 남습니다.
롤백 후 릴리스 이력. v4는 롤백으로 표시되고 v3에는 실패 1건이 남아 있다
v4가 롤백이고, v3는 실패 기록을 유지합니다.

Android
#

Android에서는 두 가지가 놀라웠는데, 둘 다 Patch 탓은 아닙니다. SDK용으로 추가한 R8 keep 규칙이 app.config.js에 있었는데, 이건 prebuild를 통해서만 네이티브 프로젝트에 반영되기 때문에, 첫 릴리스 빌드는 본의 아니게 SDK가 keep 규칙 없이 R8을 견디는지 테스트한 셈이 됐습니다. 견딥니다. R8이 클래스 이름을 전부 바꿨는데도 업데이트 다운로드, 설치, 롤백이 문제없이 됐습니다. 그리고 막 콜드 부팅한 에뮬레이터는 Gradle이 아직 메모리를 쥐고 있어서 너무 느렸고, 첫 테스트는 SDK가 이미 시작한 다운로드를 끝내기 45초 전에 포기해버렸습니다.

릴리스 v1은 성공 1, 활성 기기 1로 적용됐고, 크래시하는 v2는 같은 세 번의 실행 예산을 쓴 뒤 롤백됐습니다.

OTA로 전달된 헤드라인을 보여주는 Android 앱
Android: 자체 롤백 후 다시 v1으로.

해피 패스 너머
#

업데이트가 도착하고 롤백되는 게 핵심이지만, 팀은 나머지 기능 속에서 살아가니 하나씩 짚어봤습니다.

단계적 롤아웃은 결정론적인데, 이 점이 마음에 들었습니다. md5(deviceId + "-" + releaseLabel)의 앞 여덟 자리 16진수를 100으로 나눈 나머지가 롤아웃 퍼센트보다 작으면 포함되므로, 같은 폰은 한 릴리스에 대해 계속 포함되거나 계속 제외됩니다. 제 시뮬레이터가 다음 릴리스에서 91번 버킷에 있다는 걸 계산해두고 25%로 퍼블리시했더니, 시뮬레이터는 정확히 아무것도 받지 않았습니다. cmpatch release patch로 95%까지 올리는 데 1초도 안 걸렸고, 다음 실행에서 업데이트됐습니다.

부분 롤아웃을 다시 퍼블리시하지 않고 25%에서 95%로 올리기.

그다음 릴리스는 409로 실패했습니다. “deployment has an active rollout below 100 percent”. 배포당 부분 롤아웃은 하나만 허용되니, 다른 걸 내보내기 전에 카나리를 끝내거나 비활성화하거나 롤백해야 합니다. 합리적이고 문서화도 되어 있지만, 머지마다 퍼블리시하는 파이프라인은 카나리 도중에 이 벽에 부딪힐 겁니다.

필수 릴리스는 SDK의 mandatoryInstallMode를 씁니다. 제 설정은 다음 resume 때 적용이라, 앱은 업데이트를 내려받고 프롬프트를 띄웠고, 프롬프트를 무시한 채 앱을 백그라운드로 보냈다가 다시 가져오니 새 버전이 그냥 거기 있었습니다.

릴리스 비활성화는 단순히 제공을 멈추는 것 이상입니다. 최신 릴리스를 비활성화한 순간 매니페스트는 아직 활성 상태인 가장 새로운 릴리스를 가리켰고, 비활성화된 릴리스를 돌리던 기기에는 그쪽으로 돌아가는 패치가 제공됐습니다. 한 바이너리 버전의 릴리스를 전부 비활성화하면 매니페스트의 target이 null이 되고, SDK는 앱을 바이너리에 실려 나간 번들로 되돌립니다. 제 업데이트 훅은 처음엔 이 경우에 프롬프트를 띄우지 않았는데, 이제는 띄웁니다.

Staging에서 Production으로 승격(promote)하는 데는 1초도 안 걸렸고, 다시 빌드하는 대신 테스트한 패키지를 해시와 노트까지 그대로 재사용했습니다. 롤아웃 퍼센트도 받기 때문에 “Production의 10%로 승격"이 명령 하나입니다.

빌드와 퍼블리시를 별도 단계로 나눌 수도 있습니다. cmpatch bundle.cmpatch 아티팩트를 만들고, 대시보드의 “Bundle upload” 옵션은 업로드 전에 그걸 읽어서 플랫폼, 대상 버전, 핑거프린트, 서명 상태, 크기, 번들러를 보여줍니다. CI가 아티팩트를 빌드하고 퍼블리시 시점은 다른 사람이 정하는 구성에 맞습니다.

번들 업로드: 대시보드가 아티팩트를 읽고, 이어서 핑거프린트 가드가 개입합니다.

핑거프린트 가드를 제대로 맞닥뜨린 것도 여기서였습니다. 낡은 위젯 plist를 고친 뒤, 서버는 다음 릴리스를 409로 거부했습니다. 네이티브 프로젝트의 핑거프린트가 2.5.1에 대해 기록해둔 것과 더 이상 일치하지 않았기 때문입니다. Android에서는 app.config.js의 주석 하나를 고친 것만으로 충분했습니다. Expo가 앱 설정을 핑거프린트에 포함시키니까요. 대시보드는 같은 검사를 두 핑거프린트와 “Upload anyway” 버튼이 달린 경고로 보여줍니다. 두 경우 모두 설치된 바이너리의 네이티브 쪽은 아무것도 바뀌지 않았으니 무시하고 올리는 게 맞았습니다. 하지만 실제 운영에서 정직한 답은 거의 언제나 버전을 올리고 스토어 빌드를 하는 것이고, 제 퍼블리시 스크립트는 이제 이 상황이 생기면 정확히 그렇게 말해줍니다.

코드 서명은 RS256입니다. CLI가 패키지 해시를 RSA 개인 키로 서명하고, SDK는 바이너리에 컴파일된 공개 키로 검증합니다. 키는 base64 한 줄로 들어가서 제 빌드 설정 방식과 잘 맞았고, 두 플랫폼에서 모두 테스트했습니다. 앱에 키가 들어 있으면 서명되지 않은 릴리스는 다운로드 전에 거부됐습니다. 앱에 “require code signing"을 켜두면 서버는 서명되지 않은 퍼블리시를 거부하면서 해결 방법을 적고 가이드를 링크한 에러를 돌려줬습니다. 서명된 릴리스는 설치됐고, SDK는 서명이 검증됐다고 기록했습니다. 서명된 릴리스를 비활성화했을 때 서버가 되돌아간 이전의 서명되지 않은 릴리스도 거부했고요. 걸리는 점은, 업데이트로는 공개 키를 추가할 수 없기 때문에 처음 내보내는 바이너리에 공개 키가 들어 있어야 한다는 것입니다.

서명이 Signed, sha256으로 표시된 릴리스 상세
서명된 릴리스.

팀과 CI도 갖춰져 있습니다. 멤버는 팀 전체에 적용되는 네 역할(owner, admin, developer, viewer) 중 하나를 받고, 초대는 그 사람이 처음 로그인할 때까지 대기합니다. 개인 액세스 토큰은 홈 디렉터리가 비어 있는 상태에서도 --token이나 환경 변수로 잘 동작했고, 토큰 목록에는 각 토큰이 마지막으로 쓰인 시점이 나옵니다. 같은 퍼블리시 스크립트를 Linux 러너에서 시크릿에 든 서명 키로 돌리는 수동 GitHub Actions 워크플로우도 써뒀는데, 호스팅된 서버가 필요해서 아직 돌려보지는 않았습니다.

당분간 건너뛸 도구가 하나 있다면 cmpatch debug ios입니다. 시스템 로그를 “OTA"로 대소문자 구분 없이 필터링해서 스트리밍하는데, 이러면 “Rotation"에도 매칭돼서 20초짜리 앱 실행에 SpringBoard 메시지가 화면 한가득 나오고 앱에서 나온 건 하나도 없었습니다.

그래서 얼마나 어려운가
#

Codemagic이 제 의견을 물었으니, 최대한 담백하게 적겠습니다.

써보는 건 쉽습니다. 명령 두 개, 몇 분이면 실제 서버와 데모 데이터가 든 대시보드, 그리고 자기 앱에 연결할 수 있는 CLI가 생깁니다. 첫 한 시간 중 가장 좋은 부분이었습니다.

SDK 통합은 중간 정도의 작업이고, 어려움의 대부분은 Patch가 아니라 자기 프로젝트 안에 있습니다. 네이티브 폴더가 생성된 새 Expo 앱이라면 정말로 플러그인 하나에 expo-updates 호출을 바꾸는 몇 줄이 전부입니다. maru처럼 커밋된 네이티브 프로젝트, 위젯 익스텐션, 웹 빌드, R8, 빌드 프로파일이 있는 앱에서는 작업이 가장자리에 있었습니다. prebuild를 피하려고 네이티브 변경을 손으로 적용해야 했고, Staging과 Production 빌드가 다를 수 있도록 설정을 빌드 시점으로 옮겨야 했고, SDK를 웹에서 떼어놔야 했습니다. 업데이트의 JavaScript가 바이너리와 같은 환경으로 빌드되도록 하는 것도요. 이해하고 나면 어려운 건 없었지만, 대부분은 아직 문서에 없습니다.

일상적인 사용은 좋습니다. 퍼블리시는 명령 하나에 30초 남짓. 롤아웃, 승격, 비활성화, 롤백은 CLI에서든 대시보드에서든 전부 1초 이내이고, 에러는 뭐가 잘못됐고 뭘 해야 하는지 말해줍니다. 서버의 가드(핑거프린트 검사, 한 번에 카나리 하나, 서명)는 엄격한데, 그 덕분에 제가 잘못된 일을 하는 걸 여러 번 막아줬습니다.

프로덕션에서 운영하는 건 더 큰 결심이 필요합니다. Postgres와 오브젝트 스토리지가 딸린 Docker Compose 스택을 직접 호스팅하고 백업하고 업그레이드해야 하고, 도메인 두 개와 OAuth 앱이 필요합니다. 이미 서버를 운영하는 팀에게는 조용한 오후 한나절이고요. 서버를 원치 않아서 EAS를 쓰는 팀에게는 이게 전환의 진짜 비용이고, SDK 작업보다 큽니다.

실제 시간으로 따지면 12:29에 CLI를 설치했고 13:34에 시뮬레이터에서 첫 업데이트가 적용됐는데, 그중 30분은 Patch와 무관한 이유로 멈춰 있던 Xcode 빌드였습니다. 이 글에 있는 모든 것은 두 플랫폼 모두 오후 중반까지 끝났습니다. 호스팅 서버는 빼고요.

시간을 가장 많이 잡아먹은 것들을 순서대로 꼽으면 이렇습니다. 커밋된 네이티브 프로젝트를 날려버린 expo prebuild --clean과 그 뒤 모든 걸 손으로 연결하는 방법을 알아내는 일. 아무도 돌리지 않는 버전으로 업데이트를 보낼 뻔한 iOS 버전 감지 버그. 두 번 집계된 설치. 이건 제 잘못이지만 sync()가 스스로 notifyAppReady()를 호출하는 이상 누구나 되풀이하기 쉬운 실수입니다. 그리고 테스트 도중에 저를 놀라게 한, 문서에 없는 크래시 3회 예산. 그다음은 맞긴 하지만 계획이 필요한 것들입니다. 무해한 네이티브 인접 편집에도 발동하는 핑거프린트 가드, 배포당 카나리 하나, 그리고 겹칠 수 없는 라이프사이클 작업(스크립트가 여러 일을 연달아 하는 순간 문제가 됩니다). 나머지는 사소했습니다. 아무 말 없는 첫 실행 설정, 시끄러운 debug 명령, 코드와 맞지 않는 문서 예제 몇 개.

이 모든 걸 상세 내용과 함께 Codemagic에 정리해서 보냈습니다.

프로덕션으로 가기
#

위의 모든 것은 평가용 스택에서 돌렸습니다. 프로덕션은 같은 앱과 같은 스크립트를 실제 서버에 연결하는 것이고, 이게 maru의 계획입니다.

서버를 운영하는 공식 지원 방식은 Docker Compose 설치입니다. 80, 443 포트가 열린 Linux 머신, 도메인 두 개(API와 대시보드용 하나, 다운로드용 하나), 로그인용 GitHub OAuth 앱(이게 없으면 서버가 시작하지 않습니다)이 필요합니다. 나머지는 TLS까지 포함해 설치 스크립트가 처리합니다.

scripts/selfhost/install.sh \
  --api-domain updates.example.com \
  --storage-domain storage.updates.example.com \
  --email [email protected] \
  --github-oauth-client-id <id> --github-oauth-client-secret <secret>

데이터베이스와 스토리지 시크릿이 담긴 .env.selfhost를 써주는데, 이건 비밀번호 관리자에 넣어둬야 합니다.

다만 번들된 데이터베이스와 스토리지를 꼭 써야 하는 건 아니고, 그래서 제 스택 같은 구성에도 맞습니다. maru의 API는 이미 Fly.io에서 돌고 파일은 Tigris에 있는데, Patch는 둘 다에서 동작합니다. Compose 파일 아래를 보면 서버는 MODE=all(API와 릴리스 워커 함께)로 도는 컨테이너 하나이고, Postgres 데이터베이스, S3 호환 스토리지, 파일용 공개 HTTPS URL이 필요합니다. 즉 저장소의 Dockerfile로 빌드한 Fly 앱(아직 공개된 이미지가 없어서 fly deploy가 빌드합니다), Fly Postgres 데이터베이스, Tigris 버킷이면 됩니다.

MODE=all
DATABASE_URL=postgresql://…            # Fly Postgres
STORAGE_ADAPTER=s3
S3_ENDPOINT=https://fly.storage.tigris.dev
S3_REGION=auto
S3_BUCKET=maru-patch
S3_FORCE_PATH_STYLE=true
PUBLIC_BASE_URL=https://maru-patch.fly.storage.tigris.dev/codemagic-patch

주의할 점 둘. Compose 밖에서 운영하는 건 저장소에 참고 자료로 문서화되어 있을 뿐 이번 첫 오픈소스 릴리스에서 지원되는 경로는 아니라서, Fly에서는 업그레이드와 백업을 알아서 챙겨야 합니다. 저도 아직 그렇게 배포해보지는 않았습니다. 이건 보고가 아니라 계획입니다. 그리고 Fly의 0으로 스케일 다운 글을 읽으셨다면, 머신은 최소 한 대는 켜두세요. 매니페스트가 스토리지에서 바로 오기 때문에 폰이 업데이트 확인에서 API를 기다리는 일은 없지만, 그렇지 않으면 모든 메트릭 업로드와 모든 릴리스 작업이 콜드 부팅으로 시작하게 됩니다.

Tigris가 제가 가장 기대하는 부분입니다. maru의 파일이 이미 거기 있고, 앱이 실행될 때마다 때리는 URL 앞에 CDN을 세워주니까요. 이렇게 할 거라면 매니페스트 캐싱에 주의하세요. 기본 base-URL 전달 어댑터에서 Patch는 매니페스트를 no-cache로 서빙하는데, CDN을 퍼지할 수 없기 때문입니다. 매니페스트가 자체 TTL로 캐시되면 방금 롤백한 릴리스를 계속 내보내게 됩니다.

호스팅된 서버에서는 앱을 다시 만들고, 새 키를 eas.jsonpreviewproduction 프로파일에 넣고(Staging 키와 Production 키), 퍼블리시용 토큰을 만듭니다.

cmpatch token create --name ci

코드 서명을 쓸 거라면 지금 정하세요. 프로덕션 키 쌍을 생성하고, 개인 키는 CI 시크릿으로 보관하고, 공개 키는 빌드 프로파일에 넣고, 두 앱 모두 “require code signing"을 켭니다. 첫 Patch 바이너리에는 새 버전 번호도 필요합니다. 스토어에 이미 올라간 바이너리는 expo-updates를 돌리고 있으니까요. 먼저 preview 프로파일로 TestFlight과 Play 내부 테스트용으로 빌드하고, 누군가에게 건네기 전에 키가 실제로 빌드된 Info.plist와 Android 리소스에 들어갔는지 확인하세요. 제 로컬 테스트가 검증하지 못한 유일한 고리가 이 부분입니다. 환경을 손으로 export했으니까요. 그다음 Staging에 퍼블리시하고, 테스터마다 다운로드 1, 성공 1을 확인하고, 그제야 스토어용으로 빌드합니다.

프로덕션 릴리스는 작게 시작해서 키워갑니다.

npm run publish-update:prod -- --rollout 10
cmpatch release patch --app maru-ios --deployment Production --label v1 --rollout-percentage 100

그동안 기존 Django 서버는 계속 켜둡니다. 사람들이 아직 업데이트하지 않은 2.5.1 사본은 전부 여전히 그 서버를 확인하고, 그들을 위한 핫픽스도 여전히 예전 스크립트로 나갑니다. 로그에서 그 요청이 멈춘 게 보이면 모델, viewset, 명령, 스크립트, 시크릿을 전부 지울 수 있습니다.

Expo 청구서를 받고 있는 분에게
#

서버를 만드는 게 너무 큰 일처럼 보여서 EAS Update에 돈을 내고 있다면, Patch는 실질적인 답입니다. 서버를 운영하긴 하지만, 그건 Compose 스택과 설치 스크립트이지 여러분이 유지보수할 코드가 아닙니다. 제가 만든 것과 비교하면 단계적 롤아웃, 릴리스별 설치·실패 숫자, 바이너리 패치, 핑거프린트 가드, 코드 서명, 기기에서의 크래시 롤백, 롤백 버튼이 따라옵니다. 저는 그중 하나도 만들지 않았습니다.

비용은 이렇습니다. SDK가 앱에 들어 있어야 하니 누구든 Patch 업데이트를 받기 전에 새 바이너리가 필요하고, 계속 업그레이드하고 백업해야 할 서버가 생기고, 릴리스가 충분히 나쁘면 사용자마다 크래시 세 번을 겪고, 네이티브 프로젝트가 커밋되어 있다면 손이 좀 갑니다. 아직 어리기도 합니다. 이번은 0.3.0 버전이었고, 버전 감지 버그, 시끄러운 debug 명령, 문서의 빈틈 몇 군데를 만났고, SDK가 막아줄 수 있었을 버그를 제가 직접 하나 만들었습니다. 그중 어떤 것도 마이그레이션을 멈추지는 못했고, 전부 CLI에서, 대시보드에서, 혹은 기기 안 SDK 파일에서 눈에 보였습니다. 제 예전 구성에 대해서는 할 수 없는 말이죠.