RSS

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 에서만 깨진다.

정리해 보니 이런 것들이었습니다.

  1. 로컬에는 있고 서버에는 없는 파일 (gitignore 된 것)
  2. 로컬에는 이미 설치돼 있어 다시 안 받는 것 (락파일이 어긋나도 기존 node_modules 로 굴러감)
  3. 도구 버전 차이 (npm 이 검사를 다르게 함)
  4. 서버에만 있는 환경 변수 (오늘 두 번째 실패)

넷 다 "내 컴퓨터 상태에 기대고 있었다"는 같은 말입니다. 그리고 그 기댐은 로컬에서 성공하는 한 절대 드러나지 않습니다.

자격증명은 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이 됐습니다. 실패한 빌드도 번호를 쓴 셈입니다.

오늘 밟은 순서

정리하면 이렇습니다.

  1. EAS 프로젝트 만들기 (프로젝트 ID 발급)
  2. 스토어 양쪽에 앱 레코드 만들기 (웹 콘솔에서만 가능)
  3. 제출용 키를 EAS 에 올리기 (로컬 경로로 두지 말 것)
  4. build --profile production (안드로이드는 자동, iOS 는 Apple 로그인 한 번)
  5. submit 으로 스토어 콘솔에 올리기
  6. 스토어에서 메타데이터 채우고 심사 제출 (사람이)

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 에 보임" 사이에도 한 단계가 더 있었던 셈입니다.

아직 끝이 아닙니다

빌드가 통과했다고 심사가 통과하는 건 아닙니다. 개인정보 처리 방침, 콘텐츠 등급, 스크린샷 같은 서류가 남아 있고 그건 기술 문제가 아닙니다.

그리고 자동화의 경계는 생각보다 일찍 그어져 있었습니다. 앱 레코드를 만드는 것도, 인증 키를 처음 등록하는 것도, 심사에 내는 것도 결국 사람이 웹이나 대화형 화면에서 눌러야 합니다. 기계가 하는 건 그 사이 구간, 바이너리를 만들어 콘솔까지 옮기는 일뿐입니다.

처음에 저는 "배포 명령 한 줄이면 스토어에 올라간다"고 생각했습니다. 실제로는 명령이 여럿이고, 그 사이사이에 사람이 들어가야 하는 자리가 정해져 있었습니다. 그 자리가 어디인지 아는 것이 오늘 배운 것의 절반쯤 됩니다.