EAS 배포 구조: Build·Submit·Update 는 왜 따로인가
앱 하나를 두 스토어에 올리면서 EAS 를 처음 제대로 썼습니다. 그 전까지는 "배포 명령을 돌리면 스토어에 올라간다" 정도로만 알고 있었는데, 실제로 해보니 그 한 문장 안에 서로 다른 세 가지 일이 섞여 있었습니다. 그걸 구분하고 나서야 어디서 막혔는지가 보였습니다.
「배포」라는 한 단어가 셋을 덮고 있었습니다
EAS 가 하는 일은 크게 셋입니다.
| 하는 일 | 결과물 | |
|---|---|---|
| Build | 소스에서 앱 바이너리를 만든다 | .aab / .ipa 파일 |
| Submit | 그 바이너리를 스토어 콘솔에 올린다 | 스토어에 등록된 빌드 |
| Update | 이미 깔린 앱의 자바스크립트만 갈아끼운다 | 사용자 기기에 즉시 반영 |
이게 왜 중요하냐면, 셋 다 성공해도 앱이 공개되지는 않기 때문입니다. Submit 이 끝나면 스토어 콘솔에 바이너리가 올라간 상태일 뿐이고, 심사 제출과 출시는 사람이 따로 눌러야 합니다. 저는 처음에 "빌드 성공"을 "출시됨"으로 읽고 있었습니다.
Update 는 나머지 둘과 성격이 아예 다릅니다. 네이티브 바이너리를 건드리지 않고 자바스크립트만 교체하는 것이라 스토어를 거치지 않습니다. 대신 네이티브가 바뀌는 변경(새 네이티브 모듈, 권한 추가, SDK 올리기)은 Update 로 못 보냅니다.
빌드는 내 컴퓨터에서 일어나지 않습니다
이게 가장 크게 착각했던 부분입니다.
EAS Build 는 제 맥에 있는 node_modules 를 쓰지 않습니다. 소스를 압축해서 올린 뒤, 클라우드 머신에서 처음부터 다시 설치하고 빌드합니다. 실제로 오늘 올라간 건 312KB 였습니다. 의존성도 네이티브 폴더도 없이 소스만 간 겁니다.
그래서 이런 것들이 따라옵니다.
- 로컬에서 잘 돌아도 소용없습니다. 클라우드에서
npm ci가 통과해야 합니다. - 락파일이 곧 계약서입니다. 로컬
node_modules가 어떻게 생겼든 서버는 락파일만 봅니다. .gitignore된 파일은 안 올라갑니다. 인증 키를 로컬 경로로 참조하면 서버에서 그 파일이 없습니다.
마지막 항목은 형제 앱에서 실제로 밟았던 지뢰였습니다. 제출용 인증 키를 설정 파일에 로컬 경로로 적어 두면 내 컴퓨터에서는 되고 CI 에서만 깨집니다.
13초 만에 죽은 빌드
첫 안드로이드 빌드가 13초 만에 실패했습니다. CLI 가 알려준 건 이게 전부였습니다.
Unknown error. See logs of the Install dependencies build phase for more information.13초라는 게 힌트였습니다. 의존성을 실제로 설치했다면 이렇게 빠를 수 없으니 시작하자마자 튕긴 겁니다.
문제는 진짜 메시지를 보는 방법이었습니다. 로그는 웹 대시보드에 있는데, CLI 로 꺼내려니 빌드 기록에 서명된 로그 파일 주소가 들어 있었습니다. 받아 보니 텍스트가 아니라 압축 덩어리였고, brotli 로 풀고 나서야 줄마다 JSON 인 로그가 나왔습니다. 거기 이렇게 적혀 있었습니다.
npm ci can only install packages when your package.json and
package-lock.json are in sync.
Invalid: lock file's @react-native/[email protected]
does not satisfy @react-native/[email protected]원인은 와일드카드 하나였습니다
락파일을 뒤져 보니 이랬습니다.
react-native-worklets 라는 패키지가 @react-native/metro-config 를 * 로 요구하고 있었습니다. 아무 버전이나 좋다는 뜻이라 최신인 0.86.2 가 끌려왔고, 거기 딸린 세 개도 함께 올라왔습니다. 그런데 정작 react-native 는 0.85.3 이라 자기와 같은 0.85.3 을 요구합니다. 둘이 안 맞으니 npm ci 가 설치를 거부한 겁니다.
고치는 건 간단했습니다. 그 계열 네 개를 react-native 와 같은 버전에 묶었습니다.
"overrides": {
"@react-native/metro-config": "0.85.3",
"@react-native/js-polyfills": "0.85.3",
"@react-native/metro-babel-transformer": "0.85.3",
"@react-native/babel-preset": "0.85.3"
}로컬에서는 통과했습니다
여기가 제일 배울 게 많았던 지점입니다.
원인을 찾고 나서, 같은 커밋을 새로 복제해 로컬에서 npm ci 를 돌려 봤습니다. 통과했습니다. 서버에서 죽은 그 명령이 제 맥에서는 멀쩡히 끝났습니다. npm 버전에 따라 이 정합성 검사를 다르게 하기 때문입니다. 제 쪽이 관대했고 서버 쪽이 엄격했습니다.
같은 함정에 두 번째로 빠졌습니다
의존성을 고치고 다시 돌렸더니 이번엔 안드로이드는 넘어가고 iOS 가 죽었습니다. 에러는 전혀 다른 얼굴이었습니다.
'worklets/Compat/StableApi.h' file not found
in target 'RNReanimated' from project 'Pods'헤더 파일을 못 찾는다는 건데, 제 맥에서 찾아보니 그 파일이 멀쩡히 있었습니다.
node_modules/react-native-worklets/Common/cpp/worklets/Compat/StableApi.h
ios/Pods/Headers/Public/RNWorklets/worklets/Compat/StableApi.h심지어 그 경로는 빌드 설정의 헤더 탐색 경로에 정확히 들어 있었습니다. 로컬에서는 같은 앱이 이미 성공적으로 빌드돼 폰에 깔려 있기까지 했고요.
서버 로그를 끝까지 읽고서야 차이가 보였습니다.
[Expo-precompiled] RNWorklets: downloaded remote release artifact
[Expo-precompiled] 📦 RNWorklets (0.8.3)EAS 는 일부 패키지를 소스에서 컴파일하지 않고 미리 컴파일된 바이너리로 내려받습니다. 빌드 시간을 줄이려는 장치입니다. 그런데 그렇게 받아온 패키지는 헤더가 공개 경로에 깔리지 않습니다. 바이너리만 쓰면 헤더가 필요 없으니까요.
문제는 그 패키지의 헤더를 다른 패키지가 참조하고 있었다는 것입니다. RNReanimated 는 소스에서 빌드되면서 RNWorklets 의 헤더를 #include 합니다. 한쪽은 바이너리, 한쪽은 소스인 조합에서 그 연결이 끊어진 겁니다.
끄는 방법은 코드를 읽어서 찾았습니다.
def enabled?
return false unless ENV['EXPO_USE_PRECOMPILED_MODULES'] == '1'정확히 '1' 일 때만 켜집니다. EAS 가 이 값을 빌드 환경에 미리 넣어 둡니다. 제 맥에는 그 변수가 없으니 전부 소스에서 컴파일됐고, 그래서 로컬만 성공했던 것입니다. 빌드 프로파일의 환경 변수에서 '0' 으로 덮어 해결했습니다.
두 실패의 공통점
원인은 완전히 달랐는데(락파일 정합성 / 미리컴파일된 바이너리), 증상은 같았습니다. 로컬에서는 되고 EAS 에서만 깨진다.
정리해 보니 이런 것들이었습니다.
- 로컬에는 있고 서버에는 없는 파일 (gitignore 된 것)
- 로컬에는 이미 설치돼 있어 다시 안 받는 것 (락파일이 어긋나도 기존
node_modules로 굴러감) - 도구 버전 차이 (npm 이 검사를 다르게 함)
- 서버에만 있는 환경 변수 (오늘 두 번째 실패)
넷 다 "내 컴퓨터 상태에 기대고 있었다"는 같은 말입니다. 그리고 그 기댐은 로컬에서 성공하는 한 절대 드러나지 않습니다.
자격증명은 EAS 서버가 들고 있습니다
빌드하려면 앱에 서명해야 하고, 서명하려면 키가 필요합니다. 그 키를 어디 두느냐가 계속 헷갈렸는데, 정리하면 EAS 가 대신 보관합니다.
안드로이드는 아무것도 안 했는데 로그에 이렇게 찍혔습니다.
✔ Using remote Android credentials (Expo server)
✔ Created keystore키스토어를 자기가 만들어서 자기 서버에 넣어 둔 겁니다. 이건 편한 만큼 무서운 구석도 있습니다. 안드로이드 키스토어는 한 번 정해지면 바꿀 수 없어서, 잃어버리면 같은 앱을 업데이트할 방법이 없습니다. EAS 가 갖고 있다는 건 곧 EAS 계정을 잃으면 앱도 잃는다는 뜻이라, 따로 내려받아 보관해 둘 필요가 있습니다.
iOS 는 자동으로 안 됩니다. 배포 인증서와 프로비저닝 프로파일을 만들려면 Apple 개발자 포털에 접근해야 하고, 그건 사람이 로그인해야 합니다. 비대화형으로 돌리면 여기서 멈춥니다.
Distribution Certificate is not validated for non-interactive builds.
Credentials are not set up. Run this command again in interactive mode.한 번 대화형으로 로그인해서 만들어 두면 그 뒤로는 저장돼서 자동으로 씁니다.
프로파일이 바꾸는 것
설정 파일에 빌드 프로파일이 셋 있는데, 이름만 다른 게 아니라 결과물이 달라집니다.
| 프로파일 | 배포 방식 | 안드로이드 결과물 | 쓰임 |
|---|---|---|---|
| development | 내부 | APK | 개발용, 리로드 가능 |
| preview | 내부 | APK | 설치 링크로 바로 깔아 보기 |
| production | 스토어 | AAB | 스토어 제출용 |
스토어에 올릴 거면 안드로이드는 AAB 여야 합니다. APK 로는 Play 콘솔이 안 받습니다. 그래서 "빌드해서 폰에 깔아 보기"와 "스토어에 낼 것 만들기"는 애초에 다른 명령입니다.
버전 번호는 서버가 셉니다
설정에 appVersionSource: remote 가 들어 있는데, 이게 무슨 뜻인지 몰랐다가 로그를 보고 알았습니다.
No remote versions are configured for this project,
versionCode will be initialized based on the value from the local project.
- Incrementing versionCode from 1 to 2.빌드할 때마다 올라가는 번호를 EAS 가 원격에서 관리합니다. 스토어는 같은 번호를 두 번 받지 않기 때문에 이 번호가 꼬이면 제출이 막히는데, 사람이 손으로 세는 대신 서버가 셉니다. 오늘 첫 빌드가 실패했는데도 번호는 2로 올라갔고, 다음 빌드가 3이 됐습니다. 실패한 빌드도 번호를 쓴 셈입니다.
오늘 밟은 순서
정리하면 이렇습니다.
- EAS 프로젝트 만들기 (프로젝트 ID 발급)
- 스토어 양쪽에 앱 레코드 만들기 (웹 콘솔에서만 가능)
- 제출용 키를 EAS 에 올리기 (로컬 경로로 두지 말 것)
build --profile production(안드로이드는 자동, iOS 는 Apple 로그인 한 번)submit으로 스토어 콘솔에 올리기- 스토어에서 메타데이터 채우고 심사 제출 (사람이)
4번까지가 자동화되는 구간이고, 2번과 6번은 사람이 웹에서 해야 합니다. 그 경계가 어디인지 몰라서 처음에 헤맸습니다.
제출은 빌드와 또 다른 관문이었습니다
빌드가 끝나 바이너리가 나왔으니 이제 올리기만 하면 되는 줄 알았습니다. 안드로이드는 실제로 그랬습니다. 앞서 서비스 계정 키를 EAS 에 올려 둔 덕에 명령 한 줄로 Play 콘솔 내부 테스트 트랙까지 들어갔습니다.
Google Service Account Key:
Key Source : EAS servers
✔ Submitted your app to Google Play Store!"Key Source: EAS servers" 라는 줄이 핵심입니다. 로컬 파일을 안 보고 서버에 올려 둔 키를 씁니다. 그래서 사람 손이 안 갔습니다.
iOS 는 그 준비가 안 돼 있어서 여기서 멈췄습니다.
App Store Connect API Keys cannot be set up in --non-interactive mode.환경 변수로 키 경로와 ID 를 넘겨 봤지만 같은 곳에서 막혔습니다. 키를 그때그때 넘기는 게 아니라, EAS 서버에 한 번 등록해 두는 구조이기 때문입니다. 등록 자체가 대화형입니다.
대화형으로 다시 돌리니 이렇게 물었습니다.
Select the App Store Connect Api Key to use for your project:
> [Choose an existing key]
[Add a new key]여기서 새로 만들기보다 기존 것을 고르는 편이 낫습니다. 오늘 저는 원인을 찾느라 Apple 쪽 API 키를 여러 개 만들었다 지웠다 했는데, 그러다 보니 어느 키가 어디에 쓰이는지 헷갈리기 시작했습니다. 키는 계정당 오십 개까지 만들 수 있어서 한도에 걸릴 일은 없지만, 늘어날수록 회전할 때 무엇을 바꿔야 하는지가 흐려집니다.
자격증명은 결국 같은 모양이었습니다
정리해 보니 세 종류의 키가 전부 같은 패턴이었습니다.
| 키 | 처음 등록 | 그 뒤 |
|---|---|---|
| 안드로이드 키스토어 | EAS 가 자동 생성 | 서버가 보관 |
| Play 서비스 계정 | 대화형으로 한 번 업로드 | 서버가 보관 |
| iOS 배포 인증서 | Apple 로그인 한 번 | 서버가 보관 |
| App Store Connect API 키 | 대화형으로 한 번 등록 | 서버가 보관 |
한 번은 사람이 붙어야 하고, 그 뒤로는 서버가 대신합니다. 이 구조를 몰랐을 때는 "왜 어떤 건 되고 어떤 건 안 되지"가 계속 헷갈렸는데, 알고 보니 되던 것들은 전에 누군가 이미 그 한 번을 거쳐 둔 것이었습니다.
설정 파일에 로컬 경로를 적어 두면 안 되는 이유도 여기서 나옵니다. 그건 "서버에 등록"이 아니라 "내 컴퓨터 파일 참조"라서, 빌드 서버에는 그 파일이 없습니다.
오늘 어디까지 갔나
양쪽 스토어에 다 올라갔습니다.
| 스토어 | 상태 |
|---|---|
| App Store Connect | 처리 완료, TestFlight 준비됨 |
| Google Play | 내부 테스트 트랙 등록 완료 |
빌드 시간은 iOS 가 약 5분이었습니다. 실패한 빌드까지 세면 오늘 네 번 돌렸고, 그중 둘이 죽었습니다. 죽은 둘은 코드 문제가 아니라 환경 문제였습니다.
iOS 는 업로드 직후 TestFlight 목록이 비어 있어서 잠깐 당황했는데, Apple 이 바이너리를 검사하는 동안은 안 보이는 게 정상이었습니다. 몇 분 뒤 상태가 유효로 바뀌면서 나타났습니다. "업로드 성공"과 "TestFlight 에 보임" 사이에도 한 단계가 더 있었던 셈입니다.
아직 끝이 아닙니다
빌드가 통과했다고 심사가 통과하는 건 아닙니다. 개인정보 처리 방침, 콘텐츠 등급, 스크린샷 같은 서류가 남아 있고 그건 기술 문제가 아닙니다.
그리고 자동화의 경계는 생각보다 일찍 그어져 있었습니다. 앱 레코드를 만드는 것도, 인증 키를 처음 등록하는 것도, 심사에 내는 것도 결국 사람이 웹이나 대화형 화면에서 눌러야 합니다. 기계가 하는 건 그 사이 구간, 바이너리를 만들어 콘솔까지 옮기는 일뿐입니다.
처음에 저는 "배포 명령 한 줄이면 스토어에 올라간다"고 생각했습니다. 실제로는 명령이 여럿이고, 그 사이사이에 사람이 들어가야 하는 자리가 정해져 있었습니다. 그 자리가 어디인지 아는 것이 오늘 배운 것의 절반쯤 됩니다.
함께 읽기
- 빌드 우회 셋 정리: 어디까지가 내가 고칠 수 있는 문제인가앱 하나를 두 스토어에 올리는 동안 빌드가 여러 번 깨졌고, 그때마다 우회를 하나씩 넣었습니다. 다 끝나고 저장소를 보니 임시방편이 셋 쌓여 있었습니다.
- Metro 포트 충돌로 배운 것: RN이 OTA 업데이트를 할 수 있는 이유새로 만든 앱을 아이폰에 설치했습니다. 빌드는 성공했고 설치도 됐는데, 앱을 열자 빨간 에러 화면이 떴습니다. 그런데 에러가 가리키는 파일이 이상했습니다.
- Expo 앱 스토어 배포 준비 체크리스트: 순서가 중요한 이유새 Expo 앱 하나를 두 스토어에 올릴 준비를 했습니다. 필요한 항목 자체는 검색하면 다 나옵니다. 그런데 정작 저를 막은 건 항목이 아니라 순서였습니다. 앞 단계가 끝나야 뒤 단계의 값이 생기는 구간이 몇 군데 있고, 그걸 모르고 덤비면 "왜 이 필드가 비어 있지"에서 멈춥니다.
- 여러 고객 앱을 운영할 때 계정과 서명 키를 분리하는 방법React Native와 Expo로 여러 고객 앱을 개발·배포한다면 계정, 비용, 서명 키, 스토어 권한을 어떻게 나눌지 초기에 정해야 합니다. 이 구조를 잘못 잡으면 나중에 소유권 이전, 업데이트, 보안 관리가 복잡해집니다.
- 모바일 앱 스토어 배포 자동화를 설계할 때 볼 것들모바일 앱 배포는 빌드 파일을 만드는 것에서 끝나지 않습니다. 인증서, 서명 키, 스토어 메타데이터, 심사 정보, 버전 관리, 제출 자동화까지 함께 설계해야 반복 가능한 배포가 됩니다.