junsobi

Menu

Close

EAS 없이 S3로 React Native CodePush 자체 구축하기

React Native 앱 OTA 배포 인프라를 유료 도구(EAS) 없이 AWS S3로 직접 구축하고, 여러 앱에 이식해 배포 표준을 만든 경험.

List

EAS 없이 S3로 React Native CodePush 자체 구축하기

앱스토어 심사 기다리는 심정 (This is Fine)

React Native 앱을 운영하면 반드시 마주치는 문제가 있습니다. 앱스토어 심사에 걸리는 시간입니다. 간단한 버그 픽스 하나 올리려고 1~3일씩 심사를 기다리는 건 프로덕션 서비스에서 치명적입니다.

Microsoft App Center CodePush는 이 문제를 풀었지만 2025년 3월 서비스 종료를 선언했고, 대안으로 Expo의 EAS Update가 남았습니다. 하지만 EAS는 유료 구독이고, 앱 개수가 늘어날수록 비용도 선형으로 증가합니다. 우리 팀은 RN 앱이 늘어날 예정이었기 때문에 유료 구독에 묶이는 구조가 부담스러웠습니다.

그래서 결정했습니다. AWS S3 하나로 CodePush를 직접 구축해보자.

유료 구독 대신 직접 만드는 확장된 사고 (Galaxy Brain)


왜 S3인가

CodePush의 본질은 단순합니다.

  1. 번들(JS 코드)을 원격 저장소에 올린다
  2. 앱이 시작할 때 원격의 최신 번들 버전을 확인한다
  3. 새 버전이 있으면 다운로드해서 다음 실행 때 적용한다

이걸 가능하게 해주는 서비스가 EAS Update 같은 유료 도구지만, 본질적으로는 "정적 파일 저장소 + 버전 매니페스트"만 있으면 됩니다. S3는 정확히 그 역할을 해주고, 월 비용도 무시할 수준입니다.

결국 파일 저장소 + 매니페스트라는 걸 깨달았을 때 (Confused Math Lady)


설계: 3개의 레이어

1. 번들 업로드 파이프라인 (CI)

GitHub Actions에서 빌드 → 번들 생성 → S3 업로드 → 매니페스트 업데이트까지 자동화했습니다.

# .github/workflows/codepush.yml (요약)
- name: Bundle JS
  run: npx react-native bundle \
    --platform ios \
    --entry-file index.js \
    --bundle-output ./build/main.jsbundle \
    --assets-dest ./build
 
- name: Upload to S3
  run: aws s3 sync ./build s3://$BUCKET/codepush/ios/$VERSION/
 
- name: Update manifest
  run: |
    echo "{\"version\": \"$VERSION\", \"bundleUrl\": \"$URL\"}" > manifest.json
    aws s3 cp manifest.json s3://$BUCKET/codepush/ios/manifest.json

2. 앱 내 업데이트 로직 (RN)

앱이 부팅되면 S3의 매니페스트를 읽고, 로컬 버전과 비교해서 업데이트가 필요한지 판단합니다.

// src/codepush/check.ts
async function checkForUpdate(): Promise<UpdateInfo | null> {
  const manifest = await fetch(`${CDN_BASE}/codepush/${platform}/manifest.json`)
    .then(r => r.json());
 
  const localVersion = await AsyncStorage.getItem('codepush:version');
  if (manifest.version !== localVersion) {
    return { url: manifest.bundleUrl, version: manifest.version };
  }
  return null;
}

다운로드 후 번들 파일을 앱의 Documents 디렉토리에 저장하고, React Native 브릿지로 새 번들 경로를 지정합니다. 네이티브 쪽은 AppRegistry.registerComponent가 호출될 때 해당 번들을 로드하도록 설정했습니다.

3. Splash Gate (UX 안전장치)

OTA 번들이 앱 실행 중에 교체되면 사용자가 앱을 쓰다가 갑자기 화면이 리로드되는 경험을 할 수 있습니다. 이를 막기 위해 스플래시 구간에서만 업데이트 체크·적용을 집중시켰습니다.

// 앱 부팅 시퀀스
async function bootstrap() {
  const update = await checkForUpdate();
  if (update) {
    await downloadBundle(update.url);
    await restart(); // 새 번들로 재시작
  }
  hideSplashScreen(); // 업데이트가 없거나 적용 완료 후에만 스플래시 해제
}

이 "Splash Gate" 패턴이 가장 중요한 UX 보장 장치였습니다.

앱 쓰는 중에 갑자기 리로드되면 유저 심정 (First World Problems)


프로덕션에서 배운 것 — 크래시 방어

getJSBundleFile null safety

iOS 네이티브 쪽에서 CodePush가 적용된 번들 경로를 읽는 getJSBundleFile 메서드가 특정 엣지 케이스(최초 설치 직후 매니페스트 생성 전 등)에서 null을 반환할 수 있습니다. 이걸 방어하지 않으면 앱이 크래시합니다.

// AppDelegate.mm
- (NSURL *)sourceURLForBridge:(RCTBridge *)bridge {
  NSString *path = [CodePush getJSBundleFile];
  if (path == nil) {
    return [[NSBundle mainBundle] URLForResource:@"main" withExtension:@"jsbundle"];
  }
  return [NSURL fileURLWithPath:path];
}

이 한 줄 덕분에 신규 설치 유저가 첫 실행 때 crash 나는 일을 막았습니다.

null 하나가 앱 전체를 태우는 광경 (Disaster Girl)

Bridgeless 모드 호환

React Native 0.74부터 도입된 New Architecture의 Bridgeless 모드에서는 기존 CodePush reload 로직이 동작하지 않는 이슈가 있었습니다. 별도 분기를 두어 Bridgeless 모드에서는 RCTReloadCommand 대신 새로운 이벤트 시스템을 사용하도록 처리했습니다.


이식 가능한 인프라로

셀러박스 앱에서 이 구조를 먼저 안정화한 뒤, 같은 패턴을 매머드 커피·더리터 점주앱에 그대로 이식했습니다. 앱별로 다른 건 S3 버킷 경로와 환경변수 정도였고, 핵심 업데이트 로직과 Splash Gate 패턴은 공통 모듈로 뺐습니다.

결과적으로:

  • EAS 유료 구독료 없음
  • 앱스토어 심사 대기 없이 당일 핫픽스 배포
  • 여러 앱에 동일 패턴 이식 → 조직 내 RN 앱 배포 표준

언제 이 방식이 맞고, 언제 아닌가

맞는 경우

  • 사내에 RN 앱이 2개 이상이고 앞으로 늘어날 예정
  • AWS 인프라를 이미 쓰고 있음
  • CI/CD 직접 구축할 수 있는 엔지니어가 있음

안 맞는 경우

  • RN 앱 하나만 쓰고 빠르게 배포만 하고 싶음 → EAS Update가 편함
  • 네이티브 브릿지 수정이나 번들 로딩 로직을 직접 다루기 부담스러움
  • 앱 수가 적어서 구독료가 자체 구축 인건비보다 낮음

마치며

유료 도구가 문제 해결을 쉽게 만들어주긴 하지만, "왜 유료인지" 를 이해하면 직접 만들 수 있습니다. CodePush처럼 본질이 단순한 인프라일수록 그렇습니다.

앞으로 EAS Update 종속 없이 우리 팀만의 배포 파이프라인을 유지할 수 있게 됐다는 점이 가장 값진 결과입니다.

직접 만든 뿌듯함 (I Made This)