Expo 앱을 통째로 복사해 새 프로젝트 만들기: 그대로 가는 파일과 고쳐야 하는 파일
성경 지도라는 앱을 만들면서 npx create-expo-app을 치지 않았습니다. 이미 두 스토어에 배포되고 있는 다른 앱의 폴더를 통째로 복사해서 시작했습니다.
이유는 단순합니다. 새 프로젝트를 만들면 코드는 생기지만 배포되는 상태는 안 생깁니다. EAS 프로젝트 연결, 빌드 프로필, GitHub Actions 워크플로, 인증서 규약, .gitignore 관례. 이걸 처음부터 다시 맞추는 게 화면 하나 짜는 것보다 오래 걸린다는 걸 지난번에 겪었습니다.
결과적으로는 옳은 선택이었는데, 복사가 어디까지 통하고 어디서부터 안 통하는지가 명확히 갈렸습니다. 다 끝난 뒤에 두 프로젝트를 파일 단위로 비교해봤습니다.
그대로 9 / 수정 16 / 신규 9 / 원본에만 21원본은 추적 파일 46개, 새 앱은 34개입니다.
그대로 넘어간 9개
.claude/settings.json
.github/workflows/build-android.yml
.github/workflows/build-ios.yml
.github/workflows/ci.yml
.github/workflows/eas-build-reusable.yml
.gitignore
AGENTS.md
LICENSE
app.config.js빌드 워크플로 네 개가 한 글자도 안 고치고 넘어갔습니다. 이게 복사로 번 것의 대부분입니다.
왜 그대로 통했는지가 중요합니다. 앱의 정체성을 파일 안에 적어두지 않았기 때문입니다. app.config.js가 대표적입니다.
module.exports = ({ config }) => ({
...config,
name: IS_DEVELOPMENT ? `${config.name} Dev` : config.name,
ios: {
...config.ios,
bundleIdentifier: IS_DEVELOPMENT
? `${config.ios.bundleIdentifier}.dev`
: config.ios.bundleIdentifier,
},
...
});개발 빌드와 프로덕션 빌드의 번들 ID를 나누는 파일인데, 앱 이름도 번들 ID도 직접 쓰지 않고 config에서 받아서 가공만 합니다. 그래서 어느 앱에 갖다 놓아도 그대로 동작합니다. 원래 이렇게 짤 의도로 짠 건 아니었고, 결과적으로 그렇게 되어 있었습니다.
한 줄만 고친 워크플로
워크플로 다섯 개 중 넷은 그대로였고 하나만 고쳤습니다. 차이가 딱 이 한 줄입니다.
- - 'src/**'
+ - 'lib/**'OTA 업데이트를 자동으로 내보내는 워크플로인데, 어떤 경로가 바뀌었을 때 실행할지를 적어둔 부분입니다. 원본은 소스를 src/에 두고 새 앱은 lib/에 뒀으니 안 맞습니다.
다섯 개 중 유일하게 경로를 하드코딩한 파일이 유일하게 손이 간 파일이었습니다. 앞에서 말한 기준이 그대로 드러납니다.
고쳐야 했던 16개
| 파일 | 왜 |
|---|---|
app.json |
앱 이름, 번들 ID, 패키지명. 정체성 그 자체 |
eas.json |
제출 대상 ascAppId를 새 앱 것으로 |
package.json |
프로젝트 이름과 의존성 조정 |
package-lock.json |
위 변경의 결과 |
assets/*.png 7개 |
아이콘, 스플래시, 파비콘 |
app/_layout.tsx |
원본은 인증 상태를 감싸는 껍데기였음 |
tsconfig.json |
경로 별칭 |
CLAUDE.md, DEPLOY.md |
문서 |
.github/workflows/ota-update.yml |
위의 한 줄 |
정체성을 담은 파일과 눈에 보이는 자산, 이 둘입니다. 나머지 배관은 건드릴 게 없었습니다.
안 가져온 21개
원본은 프로젝트 관리 포털이라 로그인, 탭 구조, 프로젝트 상세, 문서, 환경변수, 스레드 화면이 있습니다. 새 앱은 지도 한 화면짜리라 전부 필요 없었습니다.
app/(tabs)/_layout.tsx
app/login.tsx
app/project/[id].tsx
...
src/core/api.ts
src/core/auth-context.tsx
src/core/auth.ts그러니까 실제 순서는 "복사한 다음 지우기"였습니다. 껍데기만 남기고 화면을 걷어낸 뒤 app/index.tsx 하나를 새로 썼습니다. 남길 것을 고르는 것보다 지울 것을 고르는 게 판단하기 쉬웠습니다. 지우다 보면 무엇이 배포에 필요한 배관이고 무엇이 그 앱의 기능인지 저절로 갈립니다.
복사로 번 것과 못 번 것
번 것은 파일만이 아닙니다. Apple 배포 인증서와 프로비저닝 프로파일은 팀 단위라 그대로 재사용됩니다. Android 업로드 키도 EAS Credentials에 이미 있었습니다. Expo 계정과 프로젝트 소유 조직도 같습니다.
못 번 것은 스토어에 앱을 등록하는 일 전부입니다. App Store Connect의 앱 생성, 번들 ID 등록, Play Console의 앱 생성, 스토어 메타데이터, 스크린샷, 개인정보처리방침 URL, 콘텐츠 등급 설문. 이건 새 앱이면 처음부터 다시 합니다. 심지어 App Store Connect는 API로 앱 생성 자체가 막혀 있어서 웹 콘솔에서 손으로 만들어야 했습니다.
복사가 줄여주는 건 레포 안쪽이고, 스토어 계정 쪽은 그대로 남습니다. 체감상 절반쯤 벌었습니다.
복사는 나쁜 것도 같이 옮깁니다
eas.json을 고치다가 하나 발견했습니다. 원본에는 이런 줄이 있었습니다.
"serviceAccountKeyPath": "./asc/illowa-f4d41-724b57c4631f.json"Play 제출에 쓰는 서비스 계정 키 경로인데, asc/는 .gitignore에 걸려 있습니다. 즉 이 파일은 제 노트북에만 있고 레포에는 없습니다. 키가 새지 않은 건 다행이지만, 다른 곳에서 클론하면 제출이 안 됩니다. 로컬 상태에 묶인 설정입니다.
새 앱에서는 이 줄을 지우고 키를 EAS Credentials에 올렸습니다. 서버가 들고 있으니 어디서 실행하든 됩니다.
복사를 안 했으면 이 문제를 몰랐을 겁니다. 남의 설정을 그대로 옮겨 붙이면서 한 줄씩 읽게 되니까 눈에 띄었습니다. 반대로 말하면, 읽지 않고 복사했으면 문제도 같이 복제됐을 겁니다.
다시 한다면
또 복사할 겁니다. 다만 순서를 바꾸겠습니다. 이번에는 복사하고 코드를 다 짠 다음에 eas.json과 app.json을 손봤는데, 그 사이에 잘못된 번들 ID로 빌드를 한 번 돌렸습니다.
복사 직후에 정체성 파일부터 정리하는 게 맞습니다. app.json의 이름과 번들 ID, eas.json의 제출 대상, package.json의 프로젝트 이름. 세 파일입니다. 이것만 먼저 바꿔두면 나머지는 원래 앱의 배관이 그대로 굴러갑니다.
함께 읽기
- EAS 배포 구조: Build·Submit·Update 는 왜 따로인가앱 하나를 두 스토어에 올리면서 EAS 를 처음 제대로 썼습니다. 그 전까지는 "배포 명령을 돌리면 스토어에 올라간다" 정도로만 알고 있었는데, 실제로 해보니 그 한 문장 안에 서로 다른 세 가지 일이 섞여 있었습니다. 그걸 구분하고 나서야 어디서 막혔는지가 보였습니다.
- 여러 고객 앱을 운영할 때 계정과 서명 키를 분리하는 방법React Native와 Expo로 여러 고객 앱을 개발·배포한다면 계정, 비용, 서명 키, 스토어 권한을 어떻게 나눌지 초기에 정해야 합니다. 이 구조를 잘못 잡으면 나중에 소유권 이전, 업데이트, 보안 관리가 복잡해집니다.
- 빌드 우회 셋 정리: 어디까지가 내가 고칠 수 있는 문제인가앱 하나를 두 스토어에 올리는 동안 빌드가 여러 번 깨졌고, 그때마다 우회를 하나씩 넣었습니다. 다 끝나고 저장소를 보니 임시방편이 셋 쌓여 있었습니다.
- Metro 포트 충돌로 배운 것: RN이 OTA 업데이트를 할 수 있는 이유새로 만든 앱을 아이폰에 설치했습니다. 빌드는 성공했고 설치도 됐는데, 앱을 열자 빨간 에러 화면이 떴습니다. 그런데 에러가 가리키는 파일이 이상했습니다.
- Expo 앱 스토어 배포 준비 체크리스트: 순서가 중요한 이유새 Expo 앱 하나를 두 스토어에 올릴 준비를 했습니다. 필요한 항목 자체는 검색하면 다 나옵니다. 그런데 정작 저를 막은 건 항목이 아니라 순서였습니다. 앞 단계가 끝나야 뒤 단계의 값이 생기는 구간이 몇 군데 있고, 그걸 모르고 덤비면 "왜 이 필드가 비어 있지"에서 멈춥니다.