안녕하세요, 로건입니다 👋
여러분, 솔직히 말하면 이번 도입 결정은 조금 부끄러운 사연으로 시작해요.
저희 레포에는 codepush.release.js라는 파일이 한참 동안 그냥 남아 있었어요. AppCenter 시절에 OTA를 굴리던 스크립트인데, Microsoft가 AppCenter를 retire한 뒤 — 그러니까 한 줄로 말하면 서비스가 종료되고 나서 — 저희는 한동안 그 빈 자리를 메우지 못한 채로 앱을 운영했습니다. 바쁘다는 이유가 가장 컸고, 솔직히는 "한 번 핫픽스 길어지는 정도는 견디면 되지" 라는 안일함도 있었어요.
이 시리즈는 그 공백기를 끝내고 EAS Update 위에 OTA를 다시 배선하기까지의 회고예요. 1편은 가장 앞쪽 이야기를 다룹니다.
- 우리는 왜 한참 동안 OTA 없이 살았는가
- 언제 그게 더는 견딜 수 없게 됐는가
- 후보를 펼쳐놓고 왜 EAS Update를 골랐는가
- 옛
codepush.release.js를 끝까지 지우지 않은 이유
CodePush 시절의 풍경 — 우리가 한 번 가졌던 그것
먼저 옛날에 우리가 가지고 있던 것을 한 장 그려볼게요. 그 시절의 OTA 배포는 이렇게 생겼었어요.
// codepush.release.js — 옛 시절의 풍경 (실제 코드의 요지만 재구성)
const deployment = await ask("Deploy to [ Staging / Production ]: ");
const description = await ask("Enter the update description: ");
spawn("appcenter", [
"codepush", "release-react",
"-a", `<APP_OWNER>/Bodoc4-${platform}`,
"-d", deployment,
"--mandatory",
"--description", description,
]);
// 끝나면 Slack webhook으로 결과 한 줄 던지기읽다 보면 단순합니다. 터미널에서 한 줄 띄우고, staging/production 둘 중 하나 고르고, 메시지 적고, AppCenter CLI가 알아서 묶어 올림. 그리고 끝나면 Slack에 한 줄. 이게 그 시절 OTA의 전부였어요.
좋았던 점도 분명히 있었습니다.
- 간단함. 채널이라는 개념도 둘뿐(Staging/Production)이었고, deployment 한 단어로 끝났어요.
--mandatory한 줄로 강제 업데이트. 사용자가 앱을 켰을 때 강제로 새 번들 받아가게 만드는 게, 인자 하나 추가하는 일이었습니다.- AppCenter 대시보드. rollout %, install %, 어느 빌드에서 어느 비율로 받았는지가 한 화면에 다 보였어요.
이 풍경이 AppCenter retire 이후로 조용히 사라졌습니다. 스크립트는 그대로 있는데, 그걸 받아주는 서비스가 사라진 거예요.
OTA 공백기 — 우리가 한참 동안 그것 없이 살았던 시간
여기가 1편의 덜 멋있지만 가장 정직한 구간이에요.
AppCenter가 retire된 뒤 우리는 곧바로 후속 OTA를 들이지 않았어요. 바빴고, 다른 우선순위가 컸고, 마켓 업데이트로도 어찌어찌 돌아가긴 했거든요. 그래서 한동안 OTA 없이 운영을 했습니다. 시간이 꽤 흘렀어요.
그 사이 우리가 받아낸 비용은 이런 모양이었습니다.
- 마켓 심사 사이클이 곧 핫픽스 사이클이 됨. 한 줄 짜리 버그 픽스도 iOS 심사 + Android 롤아웃을 기다려야 했어요. 빠르면 1–2일, 늦으면 1주일.
- 사용자 업데이트 지연이 그대로 누적. 마켓에 올라가도 사용자가 즉시 업데이트하는 것도 아니에요. 영향받는 사용자가 심사 시간 + 사용자 업데이트 시간만큼 그대로 깔려 있었습니다.
- 작은 회귀가 어쩔 수 없이 살아 있었음. "이 정도는 다음 정기 릴리스에 묶죠"가 늘었어요. 그 누적이 어느 순간 대수롭지 않게 보이지 않을 부피가 됐죠.
이 비용이 한 발 한 발 누적되면서, 어느 시점에 결심이 섰어요. "OTA를 다시 가져야 한다. 그것도 이번엔 우리가 운영을 책임지는 모양으로." 1편의 진짜 출발선은 이 결심입니다.
다시 들이기 — 후보를 펼쳐놓고
여기서 우리 환경에 대한 사실 하나를 짚고 가야 해요. 저희 앱은 Expo 위에 올라간 앱이 아니었습니다. bare React Native — 그러니까 Expo 런타임 없이 네이티브 프로젝트를 직접 들고 있는 형태예요. 이게 후보 평가의 출발점입니다.
| 후보 | 짧은 평 |
|---|---|
| EAS Update (Expo) | bare RN에서도 EAS Update만 따로 붙일 수 있음. Expo 런타임 도입 없이 OTA 기능만 분리해서 가져올 수 있다는 게 결정타 |
| 자체 OTA 서버 | "직접 만든다"는 매혹은 있지만, OTA는 망가지면 사용자가 앱을 못 켭니다. 자체 운영의 비용이 너무 큼 |
| AppFlow (Ionic) | 우리는 Ionic 아님. 후보에서 일찍 제외 |
| 그냥 마켓 업데이트만 계속 사용 | 지금까지 그래왔던 방식. 우리가 왜 결심했는지를 부정하는 선택지 |
| CodePush를 fork해서 자체 운영 | Microsoft가 왜 retire 했는지를 다시 떠안는 일 |
결론은 EAS Update였어요. 이유는 세 가지였습니다.
- bare RN에 OTA 기능만 분리해서 붙일 수 있음. Expo 전체로 마이그레이션할 여력은 없었어요. 프로젝트 구조를 통째로 바꾸지 않고도
expo-updates라이브러리 + EAS Update만 얹어서 OTA 채널을 가질 수 있다는 게 가장 컸어요. Expo가 자기네 생태계 안에 묶지 않고 모듈 단위로 제공한다는 점이 우리 같은 bare RN 팀에게는 안 보이게 큰 미덕입니다. runtimeVersion이라는 안전장치. CodePush 시절의 binary version mismatch는 매번 우리를 물었어요. EAS Update는runtimeVersion을 명시적 필드로 끌어올려서, 호환되지 않는 OTA는 아예 매칭이 안 되게 디자인되어 있어요.- CLI가 CI 친화적. eas-cli가 핵심 동작(
update,update:republish,update:list)을 다 갖고 있어서, 우리가 직접 만들어야 하는 건 그 위의 오케스트레이션뿐이었어요. 자체 서버를 만드는 비용에 비하면 한 자릿수 차이.
EAS Update를 처음 켰을 때 만난 세 가지 벽
좋은 결정이었지만, 켜자마자 만난 벽이 세 개 있었어요. CodePush 시절의 근육 기억이 EAS의 모델과 어긋나는 지점들이에요. 1편의 기술적 핵심이 이 부분입니다.
1. "deployment"가 "channel"이 되었고, 의미가 바뀌었다
CodePush는 deployment가 Staging, Production 두 개. EAS는 channel이 똑같이 두 개(staging/production)인데, 개념이 다릅니다.
EAS에서는 **번들이 실제로 저장되는 곳은 branch**고, channel은 그 branch를 가리키는 alias예요. CodePush 식 사고로 "staging이랑 production은 다른 번들이야"라고 가정하면, EAS의 동작이 이상하게 보입니다.
이 차이가 재배포(republish)라는 새 동작을 가능하게 만들어요 — staging에서 검증된 바로 그 번들을 production 채널에 그대로 묶을 수 있다는 뜻. 이게 3편의 주제예요.
2. runtimeVersion이라는 새 규칙
CodePush 시절 우리는 "이 OTA는 1.4.2 binary용"이라는 걸 암묵적으로 운영했어요. binary version과 OTA의 호환을 사람의 머리로 관리했죠.
EAS Update는 이걸 runtimeVersion이라는 필드로 명시적으로 못 박습니다. app.json에 적힌 값이 OTA의 매칭 키예요. 클라이언트의 runtimeVersion과 OTA의 runtimeVersion이 다르면 아예 다운로드도 시도하지 않습니다.
bare RN 프로젝트에 expo-updates만 따로 얹은 우리 입장에서, app.json은 EAS Update에게 필요한 최소한의 메타데이터만 적힌 얇은 파일이에요. Expo 전체 도입과는 결이 달라요.
// app.json — EAS Update에 필요한 최소 메타데이터만
{
"expo": {
"runtimeVersion": "4.9.6",
"updates": { "url": "https://u.expo.dev/<PROJECT_ID>" }
}
}이걸 처음 마주쳤을 때 "왜 이렇게 빡빡해?"라고 생각했어요. 그런데 운영을 시작하니까, 바로 그 빡빡함이 우리를 살린다는 게 보입니다. 옛날 binary에 새 OTA가 깔려서 앱이 죽는 일 — CodePush 시절의 단골 사고예요 — 그 사고가 EAS에서는 원리적으로 안 일어납니다.
3. groupId가 진실이 된다
CodePush의 release는 그냥 "버전 v17" 같은 식의 단조 증가 번호였어요. EAS의 update는 groupId라는 UUID로 식별돼요. 그리고 그 groupId 하나가 곧 그 시점의 정직한 번들입니다.
이게 "재배포(republish)"의 의미를 정의해요. staging의 groupId-A를 production 채널에 묶으면, production에는 groupId-A가 떠 있어요. 다시 빌드한 게 아니라, 그 식별자가 가리키는 바로 그 번들이 떠 있는 거예요.
CodePush로는 staging→production이 재패키징에 가까웠어요. 두 번 빌드, 두 번 압축, 두 번 업로드. EAS는 그게 같은 식별자라는 걸 사람의 약속이 아니라 시스템 보장으로 들고 있어요.
잃은 것 — 정직하게 말해두기
EAS로 옮기면서 잃은 것도 있어요. AI 글에서 자주 빠지는 부분이라 정직하게 적어둘게요.
- AppCenter 대시보드의 rollout %. EAS도 점진 배포는 가능하지만, 대시보드 UX는 AppCenter가 더 직관적이었어요. 지금은 우리가 직접 Remote Config로 비슷한 효과를 냅니다 (2편 주제).
--mandatory한 줄. EAS에는 강제 업데이트라는 1급 개념이 따로 없어요. 우리가 직접 구현했어요. 이게 시리즈 전체의 핵심 트릭(Stale/Silent/Soft/Force4모드)으로 발전합니다.- AppCenter CLI의 인터랙티브 UX. "Deploy to?"라고 묻고 답을 받는 그 흐름. EAS는 CI 친화적이라 인터랙티브 prompt가 적어요. 운영 자동화에는 좋지만, 1회성 수동 배포에는 살짝 허전합니다.
새로 얻은 것 — 1편이 끝나기 전에 한 줄로
대신 새로 얻은 게 셋이에요.
runtimeVersion이라는 안전 가드 — 사람이 머리로 관리하던 호환성이 시스템 보장으로.groupId로 표현되는 정직한 승격 — staging에서 검증된 바로 그 번들을 production으로.- CLI가 CI 친화적 — 우리의 GitLab Runner와 깔끔하게 결혼할 수 있어요. (4편 주제)
이 셋 위에 우리는 4가지 OTA 모드와 Remote Config 동기화와 manual GitLab 트리거를 얹었습니다. 시리즈가 그 이야기예요.
옛 스크립트를 지우지 않은 이유
마지막으로 약속한 이야기 — 왜 codepush.release.js를 지금도 안 지웠는가.
솔직히 말하면 처음에는 의도해서 남긴 게 아니에요. AppCenter가 retire된 직후 그냥 손대지 않고 있었던 거예요. 그런데 EAS Update 도입을 마치고 보니, 그 그냥 남아 있던 파일이 의외로 세 가지 역할을 하고 있더라고요.
- 공백기의 기록. 이 코드는 우리가 OTA를 한 번 가졌다가, 잃었다가, 다시 만들었다는 그 시간선을 보여주는 화석이에요. 새로 들어온 동료가 왜 우리가 이런 식으로 다시 만들었는지 따라가 볼 수 있는 길.
- 트레이드오프의 증거. 새 시스템이 더 좋다는 주장은 대조군이 있어야 의미가 있어요. 옛 코드가 같이 있으면, "이게 왜 더 나은지"를 코드 한 줄로 비교할 수 있습니다.
- 만에 하나의 옅은 보험.** EAS 인프라가 한 시간 죽었을 때 우리가 가진 백업 경로가 마켓 강제 업데이트뿐이라고 단언하기 어려웠어요. 다시 켤 가능성은 거의 0에 가깝지만, 0이 아닌 것만은 분명했어요.
세 번째 이유는 가장 옅은 이유예요. 그래도 남겨뒀습니다. 운영을 다시 시작하면서 알게 된 게 하나 있는데, 옅은 이유가 종종 결정의 마지막 한 표가 되더라고요.
이주가 끝났다는 건 옛것을 지웠다는 뜻이 아니에요. 한 번 잃었던 자리를 어떻게 다시 채웠는지 기록한 채로 새 운영을 시작하는 일이에요.
다음 편에서는, 새 시스템에 우리가 얹은 첫 번째 트릭 — OTA 메시지 한 줄로 클라이언트 동작을 가르는 4모드를 다뤄볼게요. "Stale", "Silent", "Soft", "Force" — 한 단어가 무엇을 결정하는가에 대한 이야기입니다.