Expo SDK 56 앱을 App Store와 Google Play에 배포하기
Expo 앱을 배포한다고 하면 보통 eas build 명령 하나를 떠올립니다. 실제 운영 배포는 빌드, 앱 서명, 스토어 업로드, 베타 테스트, 심사, 공개 출시, OTA 업데이트가 서로 다른 단계입니다. 이 구분을 놓치면 CI가 성공했는데 스토어에는 앱이 없거나, JavaScript 수정이라고 생각해 OTA를 발행했는데 설치된 앱과 호환되지 않는 문제가 생깁니다.
이 글은 실제로 App Store와 Google Play에 공개된 Duolabs 고객 포털 앱의 설정과 배포 이력을 바탕으로 Expo 앱의 전체 배포 과정을 정리합니다. 특정 계정의 프로젝트 ID, 팀 ID, API 키와 서명 파일은 제외하고 재사용 가능한 구조만 설명합니다.
기준일은 2026년 7월 22일입니다. Duolabs 앱은 Expo SDK 56, React Native 0.85, React 19.2.3, Expo Router와 CNG 구조를 사용합니다. Android 내부 배포 APK와 iOS·Android production 빌드를 EAS에서 완료했고, 버전 1.0을 두 스토어에 공개한 뒤 1.0.1 릴리스 사이클을 준비하는 상태를 기준으로 했습니다.
먼저 배포 전체 지도를 이해한다
소스 코드
↓ CI: 타입·의존성·설정 검사
EAS Build
├─ development: 개발 도구가 포함된 개발 빌드
├─ preview: 팀과 고객이 설치하는 내부 테스트 빌드
└─ production: 스토어 제출용 IPA·AAB
↓
EAS Submit
├─ iOS: App Store Connect 업로드 → TestFlight
└─ Android: Google Play 테스트 트랙 업로드
↓
스토어 작업
├─ 설명·스크린샷·개인정보·심사 정보
├─ 내부·비공개 테스트
└─ 심사 제출 → 단계적 또는 전체 공개
별도 경로
소스 코드 → EAS Update → 같은 채널·runtimeVersion의 설치 앱EAS Build가 성공했다는 것은 설치 가능한 바이너리가 만들어졌다는 뜻입니다. EAS Submit 성공은 그 바이너리가 스토어 시스템에 업로드됐다는 뜻입니다. App Store나 Google Play에 공개됐다는 뜻은 아닙니다.
iOS는 업로드 후 TestFlight 처리와 App Review가 남습니다. Android는 Play Console의 앱 설정, 스토어 등록정보, 앱 콘텐츠와 테스트 트랙을 거쳐 production으로 승격해야 합니다.
Duolabs 앱의 실제 배포 구조
| 항목 | Duolabs 구성 | 역할 |
|---|---|---|
| 앱 구조 | Expo SDK 56 + Expo Router + CNG | ios·android 폴더를 생성물로 취급 |
| 앱 설정 | app.json | 이름, 버전, 식별자, 권한, 플러그인, OTA 설정 |
| 배포 설정 | eas.json | development·preview·production 빌드와 submit 프로필 |
| iOS 식별자 | 고정 bundle identifier | App Store 앱과 서명 연결 |
| Android 식별자 | 고정 package name | Google Play 앱과 서명 연결 |
| 버전 관리 | remote source + production autoIncrement | buildNumber·versionCode 자동 증가 |
| OTA 호환성 | runtimeVersion = appVersion | 같은 앱 버전끼리만 업데이트 수신 |
| 업데이트 채널 | development·preview·production | 환경별 업데이트 분리 |
| CI | GitHub Actions | 타입 검사, EAS 빌드 트리거, OTA 발행 |
| 앱 서명 | EAS managed credentials 중심 | Android keystore, iOS 인증서·프로파일 관리 |
Duolabs 앱은 ios와 android 디렉터리를 Git에 넣지 않습니다. app.json과 config plugin을 기준으로 EAS 빌드 때 네이티브 프로젝트를 생성하는 CNG 방식입니다.
이 구조에서는 Xcode나 Android Studio에서 생성된 파일을 직접 수정하는 것이 장기적인 해결책이 아닙니다. 앱 권한, URL scheme, splash, 아이콘과 네이티브 모듈 설정은 app config와 plugin에 선언해야 다음 prebuild에서도 유지됩니다.
배포 전에 준비할 계정과 고정값
Expo 계정과 EAS 프로젝트
EAS Build, Submit, Update를 사용하려면 Expo 계정과 EAS 프로젝트가 필요합니다.
npx eas-cli login
npx eas-cli init
npx eas-cli build:configure
npx eas-cli update:configure프로젝트를 연결하면 app config에 owner, EAS projectId와 updates.url이 추가됩니다. projectId는 앱과 EAS의 빌드·업데이트 이력을 연결하는 식별자입니다. API 비밀키는 아니지만 다른 프로젝트 값을 복사하면 배포 기록과 OTA가 잘못 연결될 수 있습니다.
Apple Developer와 App Store Connect
iOS 스토어 제출에는 유료 Apple Developer 계정이 필요합니다. App Store Connect에서 앱을 만들고 앱의 bundle identifier와 정확히 연결해야 합니다.
조직 계정을 사용한다면 법인 정보와 D-U-N-S 확인에 시간이 걸릴 수 있으므로 출시 직전에 시작하면 일정이 밀릴 수 있습니다. 인증서보다 계정 등록과 계약, 세금·은행 정보, 앱 생성 권한을 먼저 확인하는 편이 좋습니다.
Google Play Console
Android 출시에는 Google Play 개발자 계정과 Play Console 앱이 필요합니다. 앱을 생성할 때 사용하는 package name은 production 바이너리와 일치해야 합니다.
새 개인 개발자 계정에는 비공개 테스트 인원과 기간 요건이 적용될 수 있습니다. 2023년 11월 13일 이후 생성된 개인 계정은 공식 안내 기준 최소 12명의 테스터가 14일 연속 참여한 비공개 테스트 후 production 접근을 신청해야 합니다. 조직 계정과 기존 계정은 적용 조건이 다르므로 자신의 Play Console 대시보드를 기준으로 확인해야 합니다.
bundle identifier와 package name
두 값은 앱의 주소와 같습니다. 스토어에 앱을 등록한 뒤에는 같은 앱의 식별자를 바꿀 수 없습니다. 바꾸면 업데이트가 아니라 별개의 새 앱이 됩니다.
브랜드명이나 화면 제목보다 먼저 소유 가능한 도메인 역순 구조와 조직 계정을 확정해야 합니다. 개발·preview 앱을 production과 같은 기기에 함께 설치해야 한다면 app.config.ts에서 환경별 식별자를 분리하는 방식도 고려할 수 있습니다.
app.json에서 결정되는 것
다음은 Duolabs 구조를 일반화한 예시입니다.
{
"expo": {
"name": "회사 앱",
"slug": "company-app",
"scheme": "company",
"version": "1.0.1",
"runtimeVersion": {
"policy": "appVersion"
},
"ios": {
"bundleIdentifier": "com.company.portal",
"infoPlist": {
"ITSAppUsesNonExemptEncryption": false
}
},
"android": {
"package": "com.company.portal"
},
"plugins": [
"expo-router",
"expo-secure-store",
"expo-font",
"expo-splash-screen"
],
"extra": {
"eas": {
"projectId": "EAS_PROJECT_ID"
}
},
"updates": {
"url": "https://u.expo.dev/EAS_PROJECT_ID"
}
}
}name과 slug
name은 홈 화면과 스토어에서 보이는 앱 이름의 기초가 되고 slug는 Expo 프로젝트 좌표에 사용됩니다. 스토어 표시 이름은 App Store Connect와 Play Console에서 별도로 현지화할 수 있습니다.
scheme
딥링크에서 앱을 여는 주소에 사용됩니다. scheme을 추가하거나 변경하면 네이티브 설정이 바뀌므로 새 바이너리 빌드가 필요합니다.
version
사용자가 스토어에서 보는 버전입니다. Duolabs는 1.0 공개 후 소스의 version을 1.0.1로 올려 다음 릴리스 사이클을 분리했습니다.
plugin과 권한
Duolabs 앱은 SecureStore, Router, Font, Splash Screen과 사진 첨부용 Image Picker 설정을 app config에서 관리합니다. 플러그인을 추가하거나 네이티브 권한 문구를 바꾸면 새 빌드를 만들어야 합니다.
사진 권한 설명은 사용자가 기능의 이유를 이해할 수 있게 구체적으로 작성해야 합니다. 사용하지 않는 카메라, 위치, 연락처 권한을 편의상 추가하면 심사와 개인정보 고지 범위만 늘어납니다.
암호화 신고
ITSAppUsesNonExemptEncryption 값을 무조건 복사하면 안 됩니다. 앱이 표준 HTTPS와 운영체제 암호화 기능만 사용하는지, 별도의 암호화 알고리즘이나 VPN·보안 기능을 포함하는지 확인한 뒤 실제 기능에 맞게 응답해야 합니다.
환경 변수와 비밀키를 분리한다
Duolabs 앱은 API 주소를 EXPO_PUBLIC_API_URL로 읽습니다. 이 접두사가 붙은 값은 JavaScript 번들 안에 인라인되므로 설치 파일을 분석하는 사용자가 읽을 수 있습니다.
EXPO_PUBLIC_API_URL=https://api.example.com 가능
EXPO_PUBLIC_SENTRY_DSN=... 공개 가능한 DSN인지 검토 후 가능
EXPO_PUBLIC_DATABASE_PASSWORD=... 금지
EXPO_PUBLIC_ADMIN_TOKEN=... 금지모바일 앱은 사용자 기기에 배포되는 클라이언트입니다. 이름에 secret을 붙이거나 EAS Secret으로 저장해도 최종 앱 번들에 들어간 값은 비밀이 아닙니다. 데이터베이스 비밀번호, 관리자 토큰과 결제 비밀키는 백엔드에만 둬야 합니다.
EAS 환경은 development, preview, production으로 나누고 build와 update가 같은 환경 값을 사용하도록 맞추는 편이 안전합니다.
eas env:create --name EXPO_PUBLIC_API_URL --value https://api.example.com --environment production --visibility plaintextSDK 55 이상에서는 EAS Update에도 환경을 명시해 build와 같은 값을 사용하도록 하는 것이 중요합니다.
eas update --channel production --environment production --message "로그인 오류 수정"GitHub Actions에서 EAS를 호출하는 EXPO_TOKEN, App Store Connect API 키와 Google 서비스 계정 JSON은 앱 번들에 들어가는 값이 아닙니다. GitHub Secret, EAS Credentials 또는 안전한 파일 시크릿으로 관리하고 저장소에는 커밋하지 않습니다.
eas.json의 세 가지 빌드 프로필
Duolabs의 핵심 구조를 민감한 제출 정보 없이 줄이면 다음과 같습니다.
{
"cli": {
"appVersionSource": "remote"
},
"build": {
"development": {
"developmentClient": true,
"distribution": "internal",
"channel": "development"
},
"preview": {
"distribution": "internal",
"channel": "preview",
"android": {
"buildType": "apk"
}
},
"production": {
"autoIncrement": true,
"channel": "production",
"android": {
"buildType": "app-bundle"
}
}
},
"submit": {
"production": {
"ios": {
"ascAppId": "APP_STORE_CONNECT_APP_ID"
},
"android": {
"track": "internal"
}
}
}
}development
개발 메뉴와 Metro 연결 기능이 포함된 개발 빌드입니다. Expo Go에서 지원하지 않는 네이티브 모듈을 개발할 때 사용합니다.
developmentClient를 true로 설정했다면 expo-dev-client 패키지도 설치돼 있는지 확인해야 합니다.
npx expo install expo-dev-client
eas build --platform ios --profile developmentDuolabs는 현재 Expo Go와 로컬 run 명령을 함께 사용하고 있으며, development 프로필을 본격적으로 배포할 때는 expo-dev-client 포함 여부를 먼저 확인하는 구조입니다.
preview
스토어에 공개하지 않고 팀과 고객에게 실제 앱처럼 보여 주는 빌드입니다. Android는 buildType을 apk로 지정해 설치 링크나 QR로 바로 설치할 수 있습니다.
Duolabs는 GitHub Actions에서 Android preview 빌드를 트리거해 EAS 클라우드에서 설치 가능한 APK가 만들어지는 전체 경로를 검증했습니다.
iOS의 internal distribution은 등록된 기기를 대상으로 하는 ad hoc 서명 제약이 있습니다. 테스트 인원이 늘어나거나 실제 스토어 환경을 확인하려면 TestFlight가 더 편리할 수 있습니다.
production
iOS는 App Store 제출용 IPA, Android는 Google Play 제출용 AAB를 만듭니다. production 프로필은 스토어에 올릴 바이너리이므로 디버그 전용 코드, 개발 서버 주소와 데모 비밀번호가 포함되지 않았는지 확인해야 합니다.
빌드 전에 실행할 검사
EAS 빌드를 시작하기 전에 로컬과 CI에서 빠르게 실패할 문제를 먼저 잡습니다.
npm ci
npx tsc --noEmit
npx expo-doctor
npx expo install --check
npx expo config --type public- npm ci는 lock 파일과 동일한 의존성을 설치하는지 확인합니다.
- TypeScript 검사는 명백한 타입 오류를 막습니다.
- Expo Doctor는 SDK와 라이브러리 호환성, app config와 프로젝트 상태를 검사합니다.
- expo install 검사는 Expo SDK가 기대하는 패키지 버전을 확인합니다.
- public config 출력에서는 최종 앱 이름, 버전, 식별자, 플러그인과 권한이 의도대로 해석되는지 봅니다.
Duolabs 초기 셋업에서는 React와 react-dom의 패치 버전이 달라 expo-updates 설치 과정에서 ERESOLVE가 발생했습니다. Expo SDK 56은 React 19.2.3을 기준으로 하므로 두 패키지를 같은 버전으로 명시하고 npx expo install을 통해 정렬해 해결했습니다.
로컬 Expo Go에서 화면이 열린다는 사실만으로 production 빌드가 안전하다고 판단하면 안 됩니다. Expo Go에는 미리 포함된 네이티브 모듈과 개발 도구가 있고 production은 환경 변수, 권한, 난독화와 번들 방식이 다릅니다.
Android preview APK 만들기
내부 테스트용 Android 빌드는 다음처럼 시작합니다.
eas build --platform android --profile preview첫 빌드에서 keystore가 없다면 EAS managed credentials를 선택해 생성하고 보관할 수 있습니다. 빌드가 끝나면 EAS의 설치 URL이나 QR을 테스트 기기에 전달합니다.
Preview APK에서는 다음을 확인합니다.
- production API가 아니라 의도한 preview API를 호출하는가
- 새로 설치한 상태에서 로그인할 수 있는가
- 토큰이 SecureStore에 저장되고 만료 때 갱신되는가
- 사진 권한을 허용·거부했을 때 모두 정상 동작하는가
- Android 뒤로가기와 키보드가 화면을 가리지 않는가
- 네트워크 끊김과 서버 오류가 빈 화면이 아니라 안내로 표시되는가
APK는 직접 설치할 수 있지만 새 Google Play 앱의 정식 제출 파일은 AAB여야 합니다. Preview APK가 정상이라는 이유로 같은 파일을 Play Console에 올릴 수는 없습니다.
iOS production과 TestFlight 배포
1. iOS 서명 정보를 준비한다
iOS production 빌드에는 배포 인증서와 App Store provisioning profile이 필요합니다. EAS managed credentials를 사용하면 eas build 또는 eas credentials 과정에서 생성하고 Expo 계정 권한에 따라 팀과 공유할 수 있습니다.
eas credentials --platform ios인증서, profile과 App Store Connect API 키는 역할이 다릅니다. 인증서와 profile은 앱 바이너리를 서명하고, App Store Connect API 키는 자동 업로드를 인증합니다.
2. production IPA를 만든다
eas build --platform ios --profile productionEAS는 CNG 설정으로 iOS 프로젝트를 생성하고 인증서를 적용해 IPA를 만듭니다. Duolabs는 production iOS 빌드를 여러 차례 완료했고 buildNumber가 원격에서 증가하는 것을 확인했습니다.
3. App Store Connect에 제출한다
eas submit --platform ios --profile production --latest또는 빌드와 제출을 연결할 수 있습니다.
eas build --platform ios --profile production --auto-submitEAS Submit은 IPA를 App Store Connect에 올립니다. 처리된 빌드는 TestFlight에 나타나지만 이것만으로 App Store에 공개되지는 않습니다.
4. TestFlight에서 검증한다
내부 테스터에게 먼저 배포하고 다음 항목을 확인합니다.
- 실제 production API와 로그인
- iPhone 작은 화면과 큰 화면
- iPad 지원을 선언했다면 iPad 레이아웃
- 라이트·다크 테마와 글자 크기
- 딥링크와 외부 링크
- 파일과 이미지 첨부
- 앱을 종료하고 다시 열었을 때 세션 복구
- 버전과 buildNumber 표시
외부 테스터 그룹은 별도의 TestFlight Beta App Review가 필요할 수 있습니다.
5. App Review에 제출한다
App Store Connect에서 다음 정보를 준비합니다.
- 앱 이름, 부제, 설명, 키워드, 카테고리
- 지원 URL과 개인정보처리방침 URL
- 요구 규격에 맞는 실제 앱 스크린샷
- App Privacy 데이터 수집·사용 답변
- 암호화 수출 규정 응답
- 연령 등급
- 심사 메모와 연락처
- 로그인 기능을 검토할 활성 데모 계정
Duolabs 앱처럼 고객 계정으로 로그인해야 기능이 보이는 앱은 심사 계정이 특히 중요합니다. Apple은 계정 기반 기능이 있으면 활성 데모 계정이나 완전한 데모 모드를 제공하고, 검토 중 백엔드가 실제로 접근 가능해야 한다고 안내합니다.
데모 계정에는 프로젝트, 문서, 진행 정보와 메시지처럼 핵심 화면을 확인할 수 있는 샘플 데이터를 넣되 실제 고객 정보와 비밀키는 절대 사용하지 않습니다. 2단계 인증이나 사내 IP 제한이 있다면 심사자가 막히지 않도록 심사 메모에 절차를 적습니다.
Android production과 Google Play 배포
1. Android keystore를 확정한다
Android 업데이트는 같은 앱 서명 계보를 유지해야 합니다. EAS managed keystore를 사용하더라도 계정 소유권, 접근 권한과 복구 방법을 기록해야 합니다.
eas credentials --platform androidGoogle Play App Signing을 사용하면 Play 배포 서명과 업로드 키가 분리될 수 있습니다. 업로드 키와 EAS가 가진 keystore의 관계를 모른 채 새 키를 생성하면 업데이트 업로드가 막힐 수 있습니다.
2. production AAB를 만든다
eas build --platform android --profile productionDuolabs production 프로필은 app-bundle을 사용합니다. EAS의 remote app version source와 autoIncrement가 versionCode를 올리므로 Play Console에서 이미 사용한 번호 때문에 거절되는 실수를 줄일 수 있습니다.
3. Play Console에 제출한다
eas submit --platform android --profile production --latestDuolabs submit 프로필은 먼저 internal 트랙으로 보냅니다. 바로 production으로 배포하지 않기 때문에 Play를 통한 설치와 업데이트를 내부 테스터에게 검증한 뒤 트랙을 승격할 수 있습니다.
Google 서비스 계정 JSON을 사용한다면 Play Console 권한과 EAS 프로젝트 연결을 최소 권한으로 구성합니다. 파일을 Git에 넣지 말고 EAS Credentials에 업로드하거나 CI에서 안전한 파일 시크릿으로 복원합니다.
4. Play Console 등록정보와 앱 콘텐츠를 채운다
- 앱 이름, 간단한 설명, 자세한 설명
- 아이콘, feature graphic과 휴대전화·태블릿 스크린샷
- 개인정보처리방침 URL
- Data safety 답변
- 콘텐츠 등급과 타깃 연령
- 광고 포함 여부
- 데이터 삭제와 계정 관련 정책
- 앱 접근 권한과 활성 심사 계정
Google Play도 로그인 뒤에 기능이 있는 앱은 활성 데모 계정, 로그인 정보와 검토에 필요한 자원을 제공하도록 요구합니다. Data safety에는 앱 코드뿐 아니라 분석, 오류 수집과 기타 서드파티 SDK가 다루는 데이터까지 반영해야 합니다.
5. 테스트 트랙을 거쳐 production으로 승격한다
Internal testing은 최대 100명의 빠른 QA에 적합합니다. 그다음 closed testing에서 실제 설치·업데이트와 정책 준비를 확인하고, production 접근 요건을 충족한 뒤 점진적으로 공개합니다.
새 버전은 한 번에 100% 배포하기보다 가능한 경우 소수 비율로 시작해 crash, ANR, 로그인 실패와 API 오류를 확인한 뒤 확대합니다.
GitHub Actions로 빌드와 제출을 자동화한다
Duolabs 앱은 플랫폼별 수동 워크플로와 재사용 가능한 공용 빌드 워크플로를 사용합니다.
Build Android / Build iOS 수동 실행
↓ platform, profile, auto_submit 전달
재사용 EAS Build 워크플로
↓ checkout
↓ Node·EAS CLI 설정
↓ npm ci
↓ eas build --non-interactive --no-wait
EAS 클라우드 빌드 큐
└─ auto_submit이면 빌드 성공 후 스토어 제출 연결CI에는 Expo 계정 인증용 EXPO_TOKEN을 GitHub Secret으로 둡니다. GitHub Environment는 preview와 production을 분리해 승인 게이트와 환경별 시크릿 범위를 설정할 수 있습니다.
--no-wait의 의미
Duolabs 워크플로는 GitHub runner 시간을 줄이기 위해 --no-wait를 사용합니다. 이때 GitHub Actions가 성공했다는 것은 EAS가 빌드 요청을 접수했다는 의미일 수 있습니다. 실제 EAS 빌드, 서명, App Store Connect 또는 Play 제출 성공은 EAS 대시보드와 스토어에서 별도로 확인해야 합니다.
운영 자동화에서는 EAS webhook, 후속 상태 확인 작업이나 릴리스 담당자의 체크리스트를 추가해야 '초록색 CI = 공개 완료'라는 오해를 막을 수 있습니다.
Git에 없는 키 파일은 CI에도 없다
로컬 eas.json에 ascApiKeyPath나 serviceAccountKeyPath를 적고 해당 디렉터리를 gitignore하면 로컬 수동 제출은 가능하지만 GitHub runner에는 파일이 존재하지 않습니다.
CI auto-submit을 사용하려면 다음 중 하나가 필요합니다.
- App Store Connect API 키와 Google 서비스 계정 키를 EAS Credentials에 업로드한다.
- GitHub 또는 EAS의 파일 시크릿으로 작업 중에만 안전하게 복원한다.
- 제출은 승인된 로컬 환경에서 수동으로 수행한다.
파일 경로가 설정돼 있다는 사실과 CI에서 키를 읽을 수 있다는 사실은 다릅니다.
EAS Update로 스토어 심사 없이 수정하기
EAS Update는 설치된 바이너리의 JavaScript 번들과 에셋을 교체합니다. 모든 변경을 배포할 수 있는 우회 수단은 아닙니다.
| 변경 | OTA 가능 여부 | 이유 |
|---|---|---|
| 문구, 색상, 레이아웃 | 대체로 가능 | JavaScript·에셋 변경 |
| API 요청과 상태 처리 버그 | 대체로 가능 | 기존 네이티브 기능 범위 |
| 번들에 포함된 이미지 교체 | 대체로 가능 | 에셋 업데이트 |
| Expo 플러그인 추가·변경 | 새 빌드 필요 | 네이티브 프로젝트 변경 |
| 네이티브 모듈 설치·업그레이드 | 새 빌드 필요 | 설치 앱에 모듈이 없음 |
| 권한·Info.plist·AndroidManifest 변경 | 새 빌드 필요 | 네이티브 설정 변경 |
| 앱 아이콘과 splash 네이티브 설정 | 새 빌드 필요 | 바이너리에 포함 |
| bundle identifier·package name 변경 | 기존 앱 업데이트 불가 | 별도 앱 식별자 |
channel과 runtimeVersion
Duolabs는 development, preview, production 채널을 분리하고 runtimeVersion에 appVersion 정책을 사용합니다.
production 앱 1.0 → runtimeVersion 1.0 → production 채널의 1.0 업데이트만 수신
production 앱 1.0.1 → runtimeVersion 1.0.1 → production 채널의 1.0.1 업데이트만 수신
preview 앱 1.0.1 → runtimeVersion 1.0.1 → preview 채널의 1.0.1 업데이트만 수신runtimeVersion은 업데이트의 JavaScript가 설치 앱의 네이티브 코드와 호환되는지 나누는 경계입니다. 네이티브 코드가 바뀌었는데 runtimeVersion이 같으면 새 모듈을 호출하는 JavaScript가 이전 앱에 전달될 수 있습니다.
appVersion 정책에서 가장 중요한 규칙
Duolabs의 production autoIncrement는 iOS buildNumber와 Android versionCode를 자동으로 올립니다. 사용자에게 보이는 expo.version은 자동으로 올리지 않습니다.
runtimeVersion이 appVersion 정책이면 네이티브 변경이 포함된 새 production 릴리스에서 expo.version도 직접 올려야 합니다.
잘못된 예
1.0.1 build 4: 이전 네이티브 코드
1.0.1 build 5: 새 네이티브 모듈 추가
둘 다 runtimeVersion 1.0.1 → 호환되지 않는 OTA가 섞일 수 있음
권장 예
1.0.1 build 4: 이전 네이티브 코드, runtime 1.0.1
1.0.2 build 5: 새 네이티브 코드, runtime 1.0.2Expo 공식 문서도 appVersion runtime 정책을 사용할 때 production 릴리스마다 사용자 버전을 명시적으로 갱신하는 흐름을 권장합니다.
preview에서 확인하고 production으로 보낸다
OTA도 바로 production에 발행하지 않고 preview 또는 staging 빌드에서 확인하는 편이 안전합니다.
eas update --channel preview --environment preview --message "파일 첨부 오류 수정"
eas update --channel production --environment production --message "파일 첨부 오류 수정"가능하면 같은 커밋에서 검증한 번들을 production으로 승격합니다. Expo는 일부 사용자에게만 업데이트를 보내는 비율 rollout과 잘못된 업데이트를 이전 상태로 되돌리는 rollback도 제공합니다.
main push 자동 OTA의 주의점
Duolabs는 app, src, assets, package.json, app.json 변경이 main에 push되면 OTA를 발행하는 자동화를 사용합니다. 편리하지만 package.json과 app.json에는 네이티브 변경도 포함될 수 있습니다.
새 네이티브 패키지, config plugin, 권한, scheme이나 splash 설정이 바뀐 커밋은 자동 OTA만으로 끝내면 안 됩니다. 변경을 JS-only와 native-required로 분류하는 승인 단계, native 변경 감지 또는 production OTA 수동 승격을 두는 것이 더 안전합니다.
Apple 정책상 OTA를 사용해 심사를 우회하며 앱의 핵심 목적을 바꾸는 방식도 피해야 합니다. EAS Update는 기존 앱 범위 안의 버그 수정과 콘텐츠 개선을 빠르게 전달하는 수단으로 운영합니다.
버전 번호를 세 종류로 나눠서 관리한다
| 구분 | iOS | Android | Duolabs 관리 방식 |
|---|---|---|---|
| 사용자 버전 | CFBundleShortVersionString | versionName | app.json의 version을 사람이 변경 |
| 빌드 버전 | CFBundleVersion | versionCode | EAS remote + autoIncrement |
| OTA 호환 버전 | runtimeVersion | runtimeVersion | appVersion 정책으로 사용자 버전에서 파생 |
사용자 버전은 1.0.1처럼 릴리스 의미를 표현합니다. 빌드 버전은 같은 사용자 버전으로 TestFlight나 Play 테스트에 새 바이너리를 올릴 때마다 증가해야 합니다. runtimeVersion은 네이티브 호환성을 나눕니다.
빌드 버전을 자동화했다고 사용자 버전과 runtimeVersion까지 자동으로 안전해지는 것은 아닙니다. production 릴리스 시작 시 다음을 한 묶음으로 확인합니다.
- 네이티브 변경 여부를 판단한다.
- 필요하면 expo.version을 올린다.
- preview와 production의 환경 값을 확인한다.
- EAS가 원격 buildNumber·versionCode를 증가시킨다.
- 생성된 바이너리의 버전을 TestFlight와 Play Console에서 확인한다.
로그인형 고객 포털의 심사 체크리스트
Duolabs 앱은 로그인 후 고객별 프로젝트와 문서, 진행 상황, 메시지를 보여 줍니다. 공개 콘텐츠 앱보다 심사자가 기능을 확인하기 어려우므로 다음 준비가 중요합니다.
심사 계정
- 심사 기간 내내 만료되지 않는 계정
- 실제 고객과 완전히 분리된 샘플 데이터
- 로그인 직후 빈 화면이 아닌 대표 프로젝트
- 문서, 진행률, 메시지와 첨부 기능 확인 가능
- 2단계 인증, QR, 조직 승인 등이 있다면 정확한 절차 제공
동작 중인 운영 백엔드
- 심사 지역에서 HTTPS로 접근 가능
- 점검 시간과 방화벽 제한 확인
- API v1의 구버전 호환성 유지
- 서버 오류 때 앱이 강제 종료되지 않음
- 데모 계정을 지우는 정리 작업과 심사 기간이 겹치지 않음
개인정보 고지
- App Store App Privacy와 Google Play Data safety가 실제 동작과 일치
- 이메일, 고객 프로젝트, 첨부 이미지와 메시지 처리 목적 명시
- 분석·오류 수집 SDK의 데이터도 포함
- 개인정보처리방침 URL이 로그인 없이 열림
- 계정 생성 기능이 있다면 스토어별 삭제 요구사항 확인
최소 기능과 설명
웹사이트를 그대로 감싼 앱처럼 보이지 않도록 모바일 앱의 실제 가치를 심사 메모에 적습니다. Duolabs는 고객별 인증, 프로젝트 진행 확인, 문서 열람, 환경·링크 관리와 문의 스레드라는 계정 기반 기능을 제공합니다.
출시 직전 실전 체크리스트
코드와 설정
- main에 출시 대상 커밋이 모두 반영됐다.
- npm ci, TypeScript, Expo Doctor와 의존성 검사가 통과했다.
- production API URL과 앱 환경이 맞다.
- version, bundle identifier, package name이 맞다.
- 네이티브 변경이면 version과 runtimeVersion 경계를 새로 만들었다.
- 사용하지 않는 권한과 개발용 로그를 제거했다.
바이너리
- Android preview APK를 실기기에서 확인했다.
- iOS production IPA와 Android production AAB가 성공했다.
- buildNumber와 versionCode가 이전 업로드보다 크다.
- 아이콘, splash, 앱 이름과 라이트·다크 화면이 정상이다.
스토어
- 개인정보처리방침 URL이 공개되어 있다.
- App Privacy와 Data safety 답변이 최신이다.
- 설명, 스크린샷, 카테고리와 연령 등급을 확인했다.
- 심사 데모 계정과 리뷰 메모를 확인했다.
- TestFlight와 Play internal에서 production API를 검증했다.
출시와 관찰
- EAS Submit과 스토어 처리 상태를 직접 확인했다.
- 가능한 경우 단계적으로 공개한다.
- crash, ANR, 로그인 실패, API 오류와 문의를 관찰한다.
- OTA와 새 바이너리 중 어떤 방식으로 수정할지 미리 분류한다.
- rollback과 긴급 새 빌드 담당 절차가 있다.
자주 막히는 문제
Expo Go에서는 되는데 production에서 실패한다
환경 변수, production API, 권한, SecureStore 데이터, 네이티브 모듈과 minify 차이를 확인합니다. 실제 production 프로필과 같은 preview 빌드가 있어야 재현이 쉬워집니다.
EAS Update 뒤 앱이 시작 화면에서 멈춘다
새 JavaScript가 설치 앱에 없는 네이티브 모듈이나 설정을 기대하는지 확인합니다. runtimeVersion을 잘못 공유했을 수 있습니다. 즉시 rollback한 뒤 새 앱 버전과 바이너리를 만듭니다.
스토어가 중복 buildNumber·versionCode를 거절한다
eas.json의 appVersionSource를 remote로 두고 production autoIncrement를 사용합니다. 기존 스토어 버전과 EAS 원격 값이 어긋났다면 eas build:version:set으로 마지막 값을 동기화합니다.
Preview APK는 설치되는데 Play에 올릴 수 없다
Google Play의 새 앱 제출은 AAB를 사용합니다. production 프로필의 app-bundle 결과물을 선택해야 합니다.
auto-submit이 CI에서 키 파일을 찾지 못한다
gitignore된 로컬 경로가 GitHub runner에는 없습니다. 키를 EAS Credentials에 올리거나 파일 시크릿으로 작업 중에 복원합니다. 로그에 키 내용이나 파일을 출력하지 않습니다.
CNG 빌드에서 Xcode 수정이 사라진다
생성된 ios·android 파일이 아니라 app config, config plugin 또는 Expo 모듈 설정에 변경을 옮깁니다. 필요하면 npx expo prebuild --clean으로 재생성해 같은 결과가 나오는지 확인합니다.
긴급 장애 때의 복구 방법
JavaScript·에셋 OTA 문제라면 EAS Update rollback이나 검증된 이전 업데이트 재발행을 먼저 검토합니다. runtimeVersion이 같은 설치 앱에 빠르게 적용할 수 있습니다.
네이티브 바이너리 문제라면 OTA로 해결할 수 없는 경우가 있습니다. 버전을 올린 새 production 빌드를 만들고 TestFlight·Play 테스트 후 긴급 심사를 요청하거나 스토어 rollout을 조정해야 합니다.
서버 문제라면 앱 재배포보다 API 롤백과 하위호환 복구가 더 빠를 수 있습니다. 스토어 사용자는 항상 최신 앱을 설치하지 않으므로 API v1에서 기존 필드를 갑자기 삭제하거나 의미를 바꾸지 않는 것이 중요합니다.
Duolabs에서 정착한 배포 원칙
Duolabs 앱 사례에서 가장 중요한 것은 명령 자체보다 경계를 명확히 한 것입니다.
- development는 개발 도구와 빠른 반복을 위한 빌드다.
- preview는 설치·권한·production 유사 환경을 확인하는 내부 배포다.
- production은 서명된 스토어 바이너리다.
- Submit은 업로드이고 공개 출시는 스토어의 별도 단계다.
- EAS Update는 같은 native runtime 안의 JavaScript·에셋 수정만 담당한다.
- 사용자 버전, 빌드 번호와 runtimeVersion을 서로 다른 값으로 관리한다.
- 심사 계정, 개인정보 고지와 동작 중인 백엔드도 배포 산출물에 포함한다.
Expo와 EAS는 Xcode와 Android SDK를 모든 개발자 컴퓨터에 완벽히 맞추지 않아도 양쪽 스토어 바이너리를 만들 수 있게 해줍니다. 대신 앱 식별자, 서명 자산, 환경 변수, 스토어 정책과 OTA 호환성까지 자동으로 판단해 주는 것은 아닙니다.
빌드가 성공했는지가 아니라 고객이 안전하게 설치하고 로그인하며, 문제가 생겼을 때 어떤 경로로 복구할 수 있는지까지 확인해야 배포가 끝난 것입니다.
Expo와 React Native의 선택 기준이 궁금하다면 Flutter와 React Native, 장단점보다 먼저 봐야 할 기술 선택 기준도 함께 볼 수 있습니다.
참고 자료
함께 읽기
- 같은 Expo 앱인데 배포 방식은 달랐습니다: iOS와 Android 심사 제출 비교기앞선 글에서는 이미 TestFlight에 올라간 iOS 빌드를 AI가 App Store 심사까지 제출한 과정을 다뤘습니다.
- Expo로 개발할 때 자주 쓰는 실행 명령어 모음Expo 앱을 개발하다 보면 명령어보다 “지금 다시 빌드해야 하나?”가 더 헷갈립니다. 화면 코드만 바꿨는데 Gradle 빌드를 다시 돌리기도 하고, 반대로 스플래시 이미지를 바꾼 뒤 Metro만 재시작해서 왜 그대로인지 한참 보기도 합니다.
- Expo에서 app.json 수정이 OTA 업데이트에 반영되지 않는 이유Expo 앱의 app.json을 고친 뒤 저장소에 푸시했습니다. production 채널의 OTA 작업도 평소처럼 실행됐습니다. 배포 작업이 성공하면 변경 사항이 앱에 들어간 것처럼 느껴집니다. 이번에는 그렇지 않았습니다.
- React Native와 Expo에서 OTA 업데이트를 이해하는 방법OTA(Over-The-Air) 업데이트는 앱 스토어 심사를 거치지 않고 설치된 앱의 JavaScript 코드와 에셋을 갱신하는 방식입니다. React Native와 Expo 환경에서는 빠른 버그 수정과 작은 기능 개선에 매우 유용합니다.
- 코드는 그대로인데 앱 심사는 제출됐습니다: AI에게 Expo 배포를 맡겨본 기록코드를 한 줄도 수정하지 않았는데 TestFlight에 올라가 있던 앱이 App Review 제출 상태로 바뀌었습니다. 얼핏 보면 AI가 앱을 새로 빌드해서 배포한 것처럼 보이지만, 실제로는 이미 준비된 빌드와 스토어 정보를 확인한 뒤 App Store Connect의 심사 절차를 진행한 것입니다.