Expo OTA 배포: Git 푸시와 프로덕션 릴리스를 분리한 이유
앱 아이콘을 바꾸고 커밋하려다가 아주 기본적인 질문 앞에서 멈췄습니다.
main에 푸시하면 이미 설치된 앱에도 영향이 갈까?
처음에는 아니라고 생각했습니다. Git 푸시는 소스 코드를 올리는 일이고, App Store 배포는 새 바이너리를 제출하는 별도 절차이기 때문입니다. 그런데 저장소의 GitHub Actions를 열어보니 이 프로젝트에서는 그 답이 틀렸습니다.
main에 app/**, lib/**, assets/**, app.json 같은 파일이 들어오면 EAS Update가 자동 실행되도록 되어 있었습니다. 스토어 배포와 Git 푸시는 별개였지만, Git 푸시와 OTA 배포는 한 동작으로 묶여 있었습니다.
커밋, 푸시, 배포는 서로 다른 동작입니다
먼저 네 동작을 구분했습니다.
| 동작 | 외부 영향 |
|---|---|
| 로컬 커밋 | 제 컴퓨터의 Git 기록만 바뀝니다 |
| 기능 브랜치 푸시 | 원격 저장소에 코드가 올라가지만 사용자 앱에는 전달되지 않습니다 |
main 푸시 |
당시 설정에서는 production OTA가 자동 발행됐습니다 |
| EAS production 빌드와 스토어 제출 | 앱 아이콘과 네이티브 설정을 포함한 새 앱 바이너리가 만들어집니다 |
문제는 세 번째 줄이었습니다. main을 배포 브랜치처럼 엄격하게 운영한다면 자동 OTA도 한 가지 선택입니다. 하지만 저는 소스 병합과 사용자 배포 사이에 확인 단계를 두고 싶었습니다. 아이콘처럼 OTA로 반영되지 않는 변경까지 production 업데이트를 만들어내는 것도 불필요했습니다.
아이콘은 OTA로 바뀌지 않습니다
EAS Update는 설치된 바이너리 위에서 자바스크립트와 호환되는 에셋을 바꾸는 기능입니다. 앱 아이콘, 네이티브 스플래시 설정, 권한, 네이티브 라이브러리는 바이너리에 들어갑니다. 이런 변경은 새 EAS Build가 필요합니다.
이번 변경은 다음 항목이었습니다.
- iOS Icon Composer의
.icon디렉터리 - Android adaptive icon의 전경, 배경, 모노크롬 이미지
expo-splash-screen설정에 쓰는 라이트·다크 이미지app.json의 iOS 아이콘과 Android 배경색
이 파일을 OTA로 올려도 기존 사용자의 홈 화면 아이콘은 바뀌지 않습니다. 새 바이너리를 App Store나 Play Store에 올리고, 사용자가 그 버전으로 업데이트해야 바뀝니다.
Expo는 빌드의 네이티브 계층과 OTA 계층이 호환되는지 runtimeVersion으로 구분합니다. 이 프로젝트는 appVersion 정책을 쓰고 있었고, 확인 당시 production 업데이트의 runtime은 1.0.0이었습니다. 네이티브가 달라지는 새 스토어 빌드를 만들 때 앱 버전도 올려야 하는 이유입니다.
자동 트리거를 수동 버튼으로 바꿨습니다
기존 워크플로에는 두 진입점이 있었습니다.
on:
push:
branches: [main]
paths:
- 'app/**'
- 'lib/**'
- 'assets/**'
- 'app.json'
workflow_dispatch:여기서 push를 제거하고 workflow_dispatch만 남겼습니다. 채널과 업데이트 설명도 실행할 때 입력하도록 바꿨습니다.
on:
workflow_dispatch:
inputs:
channel:
description: "업데이트 채널"
type: choice
options: [production, preview]
default: preview
message:
description: "업데이트 내용"
type: string
default: "manual OTA update"기본값을 production이 아니라 preview로 둔 것도 의도적이었습니다. 선택을 하나 놓쳤다는 이유로 실제 사용자에게 배포되는 것보다는, 내부 테스트 앱만 받는 채널에 올라가는 편이 낫습니다.
변경 순서도 나눴습니다. 먼저 OTA 워크플로만 수동으로 바꾸어 main에 병합했습니다. 그다음 아이콘 변경을 별도 PR로 병합했습니다. 두 번의 main 푸시에서 실행된 것은 타입 검사 CI뿐이었고, EAS Update 실행은 새로 생기지 않았습니다.
이제 main 병합은 소스 관리의 끝이고, OTA 발행은 사람이 별도로 시작하는 배포 작업이 됐습니다.
preview를 선택해도 테스트할 앱이 없었습니다
수동 워크플로를 만들고 나면 preview를 먼저 누르면 된다고 생각했습니다. EAS 채널을 실제로 조회해보니 production만 있었고 preview는 없었습니다.
이유는 단순했습니다. preview 프로필 설정은 있었지만, 그 프로필로 빌드해 설치한 앱이 아직 없었습니다. OTA는 업데이트를 받을 바이너리가 있어야 의미가 있습니다. preview 브랜치에 번들을 발행하는 것만으로 테스트 앱이 생기지는 않습니다.
preview-first 흐름을 사용하려면 순서가 하나 더 필요합니다.
preview채널을 가리키는 내부 배포 빌드를 만듭니다.- 그 빌드를 테스트 기기에 설치합니다.
- 같은 runtime의 OTA를
preview에 발행합니다. - 설치된 preview 앱에서 확인합니다.
- 확인한 커밋을
production에 발행합니다.
사용자는 production 채널을 가리키는 스토어 빌드만 받습니다. preview OTA가 production 앱으로 넘어가지는 않습니다. 반대로 preview 빌드가 없다면 preview OTA를 확인할 사람도 없습니다.
environment:만 적는다고 승인 단계가 생기지 않습니다
워크플로의 job에는 선택한 채널 이름을 GitHub Environment로 쓰도록 설정했습니다.
jobs:
update:
environment: ${{ inputs.channel }}처음에는 이 줄이 production 배포 전에 승인 화면까지 만들어줄 것처럼 생각했습니다. 저장소 설정을 API로 확인하니 Environment는 0개였습니다. 참조할 이름을 적는 것과 보호 규칙을 구성하는 것은 다른 일이었습니다.
GitHub에서 required reviewer나 대기 시간을 설정해야 그 environment를 사용하는 job이 실제로 멈춥니다. 보호 규칙이 없다면 Run workflow를 누르는 행위 자체가 마지막 확인입니다.
그래서 현재 안전장치는 두 단계입니다. 자동 push 트리거를 없앴고, 기본 채널을 preview로 바꿨습니다. 별도의 승인 단계까지 원한다면 저장소 설정에서 production Environment와 보호 규칙을 추가해야 합니다.
지금은 OTA를 발행하지 않았습니다
아이콘 변경을 main에 병합한 뒤에도 OTA 버튼은 누르지 않았습니다. 마지막 production OTA 이후 바뀐 것은 빌드 설정, 워크플로, 아이콘과 스플래시였습니다. 기존 사용자에게 전달할 새 자바스크립트나 지도 데이터가 없었습니다.
앞으로는 변경 내용을 보고 배포 경로를 먼저 고릅니다.
- 화면 로직, 문구, 정적 데이터처럼 현재 바이너리와 호환되는 변경은 수동 OTA 후보입니다.
- 앱 아이콘, 권한, config plugin, 네이티브 의존성 변경은 새 스토어 빌드 후보입니다.
- 네이티브가 달라졌다면 앱 버전과 runtime 호환성을 먼저 확인합니다.
이 구분을 워크플로에 반영하고 나니 Git 푸시를 저장 행위로 다시 쓸 수 있게 됐습니다. 사용자의 앱을 바꾸는 순간은 커밋 메시지가 아니라 배포 버튼 앞에서 결정합니다.
참고한 문서:
함께 읽기
- Metro 포트 충돌로 배운 것: RN이 OTA 업데이트를 할 수 있는 이유새로 만든 앱을 아이폰에 설치했습니다. 빌드는 성공했고 설치도 됐는데, 앱을 열자 빨간 에러 화면이 떴습니다. 그런데 에러가 가리키는 파일이 이상했습니다.
- React Native와 Expo에서 OTA 업데이트를 이해하는 방법OTA(Over-The-Air) 업데이트는 앱 스토어 심사를 거치지 않고 설치된 앱의 JavaScript 코드와 에셋을 갱신하는 방식입니다. React Native와 Expo 환경에서는 빠른 버그 수정과 작은 기능 개선에 매우 유용합니다.
- React Native Debug·Release 구성 차이: No script URL provided 가 뜨는 이유앱 두 개를 아이폰에 올려놓고 하나를 열었더니 빨간 화면에 이 문구가 떴습니다.
- Liquid Glass 아이콘 직접 만들기: .icon 파일 구조와 librsvg 함정앱 아이콘 하나 만드는 데 하루를 썼습니다. 열 번 넘게 갈아엎었고 빌드를 네 번 중간에 끊었습니다. 그 과정에서 알아낸 것 중에 검색해도 잘 안 나오는 게 두 개 있어서 적어둡니다.
- Expo 앱을 통째로 복사해 새 프로젝트 만들기: 그대로 가는 파일과 고쳐야 하는 파일성경 지도라는 앱을 만들면서 npx create-expo-app을 치지 않았습니다. 이미 두 스토어에 배포되고 있는 다른 앱의 폴더를 통째로 복사해서 시작했습니다.