Logan.

2026-05-18

OTA 공백기를 끝낸 이주 CodePush 흔적 위에 다시 그린 EAS Update

한번 가졌다가 잃고, 한참을 OTA 없이 살았던 시간. 다시 OTA가 필요해진 계기와, 옛 CodePush 스크립트를 옆에 두고 EAS Update 위에 새로 배선한 도입기.

by Logan·17분 읽기·part 1 of EAS Update Playbook·#expo #eas-update #codepush #ota #migration

안녕하세요, 로건입니다 👋

여러분, 솔직히 말하면 이번 도입 결정은 조금 부끄러운 사연으로 시작해요.

저희 레포에는 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였어요. 이유는 세 가지였습니다.

  1. bare RN에 OTA 기능만 분리해서 붙일 수 있음. Expo 전체로 마이그레이션할 여력은 없었어요. 프로젝트 구조를 통째로 바꾸지 않고도 expo-updates 라이브러리 + EAS Update만 얹어서 OTA 채널을 가질 수 있다는 게 가장 컸어요. Expo가 자기네 생태계 안에 묶지 않고 모듈 단위로 제공한다는 점이 우리 같은 bare RN 팀에게는 안 보이게 큰 미덕입니다.
  2. runtimeVersion이라는 안전장치. CodePush 시절의 binary version mismatch는 매번 우리를 물었어요. EAS Update는 runtimeVersion을 명시적 필드로 끌어올려서, 호환되지 않는 OTA는 아예 매칭이 안 되게 디자인되어 있어요.
  3. 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/Force 4모드)으로 발전합니다.
  • AppCenter CLI의 인터랙티브 UX. "Deploy to?"라고 묻고 답을 받는 그 흐름. EAS는 CI 친화적이라 인터랙티브 prompt가 적어요. 운영 자동화에는 좋지만, 1회성 수동 배포에는 살짝 허전합니다.

새로 얻은 것 — 1편이 끝나기 전에 한 줄로

대신 새로 얻은 게 셋이에요.

  1. runtimeVersion이라는 안전 가드 — 사람이 머리로 관리하던 호환성이 시스템 보장으로.
  2. groupId로 표현되는 정직한 승격 — staging에서 검증된 바로 그 번들을 production으로.
  3. 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" — 한 단어가 무엇을 결정하는가에 대한 이야기입니다.

related — in series · EAS Update Playbook