EAS 빌드 큐 40분, 로컬 5분: 로컬로 옮기기 전에 백업해야 할 것
앱 하나를 출시하려고 EAS에 안드로이드 production 빌드를 걸었습니다. 큐에서 40분을 기다렸습니다. 무료 티어라 빌드 머신이 빌 때까지 순서를 기다리는 구조입니다.
기다리는 동안 할 게 없어서 로그를 보다가, 같은 앱을 조금 전에 제 맥에서 빌드했던 게 떠올랐습니다.
로컬에서는 5분 28초였습니다
같은 앱의 스토어 스크린샷을 찍으려고 안드로이드 기기에 npx expo run:android --variant release를 돌렸었습니다. 콜드 빌드가 5분 28초, 생체 인증 문구를 고치고 다시 돌린 증분 빌드가 2분 34초였습니다.
EAS 쪽은 16시 07분에 생성된 빌드가 40분 가까이 IN_QUEUE였습니다. 큐를 빠져나온 뒤 실제 빌드가 돌았고, .aab가 나오기까지 전부 합쳐 약 50분이었습니다. 느린 건 빌드가 아니라 순서를 기다리는 시간입니다.
숫자를 나란히 놓고 보니 로컬이 압도적입니다. 그런데 클라우드 빌드를 쓰는 이유는 속도가 아니라 환경이었습니다. 제 맥의 SDK 버전, 캐시, 자바 버전에 영향받지 않는 깨끗한 빌드를 얻는 것, 그리고 GitHub에 푸시하면 CI가 알아서 도는 것입니다.
중간이 있습니다. eas build --local은 같은 eas.json 프로파일을 쓰고 keystore도 EAS에 저장된 걸 내려받아 씁니다. versionCode 원격 자동 증가도 그대로 동작합니다. 큐만 사라집니다.
솔직히 말하면 아직 --local로는 안 돌려봤습니다. 이번에 잰 5분 28초는 expo run:android였고, --local은 EAS와 같은 순서를 밟으니 조금 더 걸릴 겁니다. 다음 빌드에서 재볼 생각입니다.
옮기려다가 포맷을 떠올렸습니다
로컬 빌드를 상시로 쓰기로 하면 제 맥이 빌드 환경이 됩니다. 그 순간 걱정이 하나 생겼습니다. 맥을 포맷하면 어떻게 되나.
제 첫 생각은 이랬습니다. 로컬 빌드로 옮기면 서명 키를 직접 들고 있어야 하니, 그게 포맷할 때 가장 위험한 물건이겠구나. 안드로이드 업로드 keystore를 잃으면 Play 스토어에서 그 앱을 영원히 업데이트할 수 없습니다. 새 키로 서명한 앱은 같은 앱으로 인정되지 않습니다.
그래서 세어봤습니다. 그런데 순서가 틀렸습니다.
서명 키는 이미 제 맥에 없었습니다
keystore는 EAS가 빌드하면서 자동 생성했고, EAS 서버에 있습니다. iOS 배포 인증서와 프로비저닝 프로파일도, App Store Connect API 키도 마찬가지입니다. 제 맥을 밀어도 재로그인하면 그대로 있습니다.
정확히 말하면 이건 안심할 이유가 아니라 위치가 다를 뿐입니다. EAS 계정에 사고가 나면 똑같이 잃습니다. 그래서 keystore는 따로 받아두는 게 맞습니다.
APP_VARIANT=production npx eas-cli@latest credentials --platform android메뉴에서 Download existing keystore를 고르면 됩니다. 대화형이라 스크립트로는 못 돌리고, 받은 파일은 비밀번호 관리자에 넣어두면 됩니다.
여기까지가 제가 예상했던 답이었습니다. 그다음이 예상 밖이었습니다.
정작 위험했던 건 git 밖에 있던 프로젝트였습니다
앱 프로젝트 디렉터리 아래에 10개가 있습니다. 각각 git status와 미푸시 커밋 수를 찍어봤더니 9개는 깨끗했습니다. 미푸시 0건, 변경 0건.
10번째는 목록에 아예 나오지 않았습니다. .git 디렉터리가 없었기 때문입니다. 아직 출시하지 않은 앱입니다.
Expo 프로젝트 하나가 통째로, 어떤 원격에도 올라간 적 없이 제 디스크에만 있었습니다. .gitignore는 제대로 작성돼 있었습니다. git init만 한 적이 없었던 겁니다. 디렉터리는 7.2GB인데 그중 node_modules가 6.0GB, ios가 1.2GB입니다. 실제 소스는 57개 파일입니다.
포맷했으면 이것만 사라졌을 겁니다. 서명 키도 아니고 빌드 환경도 아니고, 그냥 프로젝트 하나가요.
바로 비공개 리포로 만들어 푸시했습니다.
리포를 만들자 CI가 처음 돌았고, 18초 만에 죽었습니다
.github/workflows/ci.yml은 처음부터 있었습니다. 리포가 없어서 한 번도 실행된 적이 없었을 뿐입니다. 푸시하자마자 처음 돌더니 npm ci에서 18초 만에 끝났습니다.
npm error Missing: @emnapi/core@2.0.0-alpha.4 from lock file제 맥에서는 npm install을 돌려도 락파일이 한 글자도 바뀌지 않습니다. 락파일이 이미 package.json의 범위를 만족하니 npm이 손댈 이유가 없습니다. 리눅스 러너에서만 깨집니다.
처음에는 CI의 Node 버전이 문제라고 봤습니다. ci.yml이 20.19.0을 고정하고 있었는데 package.json의 engines는 ^22.13.0 이상을 요구합니다. 다른 앱들은 22.13.0을 씁니다. 고쳐서 올렸더니 같은 자리에서 그대로 죽었습니다. 값이 어긋난 건 맞았지만 원인은 아니었습니다.
CI가 통과하는 다른 앱의 락파일과 나란히 놓고 나서야 보였습니다. 차이는 버전이 아니라 위치였습니다.
정상: node_modules/@emnapi/core = 1.10.0
문제: node_modules/@unrs/resolver-binding-wasm32-wasi/node_modules/@emnapi/core = 1.10.0같은 1.10.0인데 한쪽은 최상위에, 한쪽은 중첩돼 있습니다. @napi-rs/wasm-runtime 1.2.3이 @emnapi를 peer로 요구하는데, 중첩된 자리에 있으면 그 요구가 채워지지 않습니다. 리눅스의 npm은 트리를 다시 계산하면서 최상위에 @emnapi를 놓으려 하고, 락파일에 그 자리가 없으니 Missing으로 끊습니다.
여기서 두 번 헤맸습니다. npm update로 그 하위 트리만 다시 풀었더니 요구 버전만 2.0.0-alpha.4에서 1.11.3으로 바뀌고 실패는 그대로였습니다. 그전에는 락파일을 지우고 npm install --package-lock-only로 다시 만들어 봤는데, optional 의존성이 통째로 빠지면서 2259줄이 사라졌습니다. 그건 더 나쁩니다.
결국 node_modules와 락파일을 둘 다 지우고 npm install을 온전히 다시 돌렸습니다. 호이스팅 위치는 npm이 트리를 처음부터 풀 때만 다시 계산합니다. 그러고 나서 통과했습니다.
푸시하기 전에 빈 디렉터리에 package.json과 락파일만 복사해서 npm ci를 돌려봤습니다. 리눅스가 아니어도 락파일 일관성은 여기서 잡힙니다. CI에 세 번 밀어 넣고 세 번 기다리는 것보다 훨씬 빠릅니다.
이건 백업의 부수 효과였습니다. 프로젝트를 git에 올려두지 않았다는 건 소스를 잃을 위험만이 아니라, 그동안 아무도 이 프로젝트를 검사한 적이 없다는 뜻이기도 했습니다.
나머지로 나온 것들
같은 스캔에서 몇 가지가 더 나왔습니다.
git이 무시하지만 디스크에 있는 파일 중 자격 증명처럼 생긴 걸 이름만 훑었더니, 한 프로젝트의 .env 하나였습니다. 안에 든 키는 EXPO_TOKEN 하나입니다. Expo에서 재발급하면 되니 급하지 않습니다.
~/.ssh에는 개인키가 5개 있었습니다. GitHub 접근용은 다시 등록하면 그만이지만, 서버 접속용이 섞여 있으면 상대 서버의 authorized_keys까지 손봐야 합니다. 이건 복사해두는 게 낫습니다.
커밋 안 된 작업도 두 군데 있었습니다. 한쪽이 8개 파일, 다른 쪽이 1개 파일. 열어보니 각각 앱 이름 한글화와 Apple Team ID 추가였습니다. 몇 분이면 다시 할 수 있는 양이지만, 다시 해야 한다는 걸 기억해내는 게 더 어렵습니다.
결국 순서가 바뀌었습니다
로컬 빌드로 옮길지 결정하려고 시작했는데, 답은 "옮겨도 되고 아니어도 된다"였습니다. 빌드 환경은 어느 쪽이든 재구성 가능합니다. 노드 깔고 Xcode 깔고 npm install하면 됩니다.
진짜 위험은 재구성이 불가능한 것들에 있었고, 그건 빌드와 상관없는 자리에 있었습니다. git에 없는 소스와, 다시 발급할 수 없는 서명 키입니다.
그래서 지금은 이렇게 정리했습니다. 개발 중 반복은 로컬로, 릴리즈 태그를 찍을 때는 EAS로. 그리고 포맷 전에 확인할 건 세 줄입니다.
모든 리포에 미푸시 커밋이 0건인가. keystore를 내려받아 뒀는가. ~/.ssh를 옮겼는가.
아직 안 한 것
eas build --local은 아직 안 재봤습니다. 큐가 사라지는 건 확실한데 빌드 자체가 얼마나 걸리는지는 다음 빌드에서 확인하려고 합니다.
이번 EAS 빌드에서 .easignore 때문에 한 번 크게 헛돌았던 일이 있는데, 그건 별도로 쓰겠습니다. .easignore가 있으면 EAS가 .gitignore를 보지 않는다는 것, 그래서 프리빌드 산출물이 통째로 업로드돼 아카이브가 642MB가 되고 EAS가 프로젝트를 bare workflow로 오인한다는 이야기입니다.
함께 읽기
- Expo 앱 다국어: 사전의 키를 한국어 원문으로 둔 이유성경 지도 앱에 언어 다섯 개를 더했습니다. 영어, 일본어, 중국어 간체와 번체, 스페인어입니다. 화면이 하나뿐이고 서버도 없는 앱이라 붙이는 일 자체는 간단할 줄 알았는데, 번역문을 어디에 둘지에서 막혔습니다.
- 빌드 우회 셋 정리: 어디까지가 내가 고칠 수 있는 문제인가앱 하나를 두 스토어에 올리는 동안 빌드가 여러 번 깨졌고, 그때마다 우회를 하나씩 넣었습니다. 다 끝나고 저장소를 보니 임시방편이 셋 쌓여 있었습니다.
- EAS 배포 구조: Build·Submit·Update 는 왜 따로인가앱 하나를 두 스토어에 올리면서 EAS 를 처음 제대로 썼습니다. 그 전까지는 "배포 명령을 돌리면 스토어에 올라간다" 정도로만 알고 있었는데, 실제로 해보니 그 한 문장 안에 서로 다른 세 가지 일이 섞여 있었습니다. 그걸 구분하고 나서야 어디서 막혔는지가 보였습니다.
- Metro 포트 충돌로 배운 것: RN이 OTA 업데이트를 할 수 있는 이유새로 만든 앱을 아이폰에 설치했습니다. 빌드는 성공했고 설치도 됐는데, 앱을 열자 빨간 에러 화면이 떴습니다. 그런데 에러가 가리키는 파일이 이상했습니다.
- Expo 앱 스토어 배포 준비 체크리스트: 순서가 중요한 이유새 Expo 앱 하나를 두 스토어에 올릴 준비를 했습니다. 필요한 항목 자체는 검색하면 다 나옵니다. 그런데 정작 저를 막은 건 항목이 아니라 순서였습니다. 앞 단계가 끝나야 뒤 단계의 값이 생기는 구간이 몇 군데 있고, 그걸 모르고 덤비면 "왜 이 필드가 비어 있지"에서 멈춥니다.