React Native OTA를 S3로 직접 운영하기 글 표지
React NativeCodePushOTAAWSS3Release Engineering

React Native OTA를 S3로 직접 운영하기

S3와 CI로 React Native OTA 배포 체계를 만들고 여러 앱에 적용한 경험과, 운영하면서 보완할 지점을 정리했습니다.

React Native 앱의 JavaScript 버그를 수정해도 스토어에서 새 바이너리를 배포하려면 심사를 거쳐야 합니다. 이 프로젝트에서는 JavaScript와 정적 에셋을 교체하는 OTA(Over-the-Air) 배포 체계를 S3와 CI로 만들었습니다.

기존 선택지에도 변화가 있었습니다. Microsoft는 Visual Studio App Center의 CodePush를 포함한 대부분의 기능을 2025년 3월 31일 종료했습니다. EAS Update는 대안 중 하나지만 유료 전용 서비스는 아닙니다. Free 플랜에도 월별 한도 안에서 업데이트가 포함되며, 유료 플랜은 더 많은 사용자와 대역폭·저장 공간, 초과 사용 같은 운영 선택지를 제공합니다.

이미 쓰고 있던 AWS와 CI를 활용하면서 배포 형식과 보관 기간, 장애 대응을 직접 통제하려고 S3를 택했습니다. 대신 업데이트 클라이언트와 운영까지 팀이 맡아야 했습니다.

실제로 구축한 범위는 번들 생성과 S3 게시, 앱 시작 시 버전 확인과 다운로드, 스플래시 구간 적용, 네이티브 번들 선택입니다. 이후에 다루는 서명과 무결성 검사, pending/confirmed 기반 자동 롤백은 운영 안정성을 높이기 위해 정리한 보강안입니다.

불길 속에서 괜찮다고 말하는 캐릭터
긴급 수정인데 스토어 심사를 기다릴 때의 기분

선택지를 좁힌 기준

  • 스토어 배포만 유지하면 구조는 가장 단순하지만 JavaScript 수정도 다음 바이너리 릴리스까지 기다려야 합니다.
  • EAS Update는 업데이트 프로토콜과 대시보드, 롤아웃 같은 운영 기능을 제공합니다. Free 플랜으로 시작할 수 있고 운영 규모에 따라 유료 플랜을 검토할 수 있습니다.
  • S3에 자체 호스팅하면 기존 인프라와 배포 권한 체계를 재사용할 수 있습니다. 반면 런타임 호환성, 서명, 롤백, 관측 도구를 직접 구현해야 합니다.

번들 주소를 버전별로 불변하게 유지하고 마지막에 작은 매니페스트만 교체하는 배포 모델이 현재 CI와 잘 맞았습니다. 앱이 적거나 OTA 운영 인력이 없다면 관리형 서비스의 비용이 더 낮을 수 있습니다.

CI에서 게시하고 앱 시작에 적용하기

번들 생성과 S3 게시를 CI에서 반복할 수 있게 했습니다. 번들 URL에는 배포 ID를 넣고, 마지막에 매니페스트를 바꾸는 구조입니다. 한번 게시한 번들은 같은 URL에서 덮어쓰지 않습니다.

CI에서 불변 번들을 올리고 매니페스트를 마지막에 게시한 뒤 앱이 받는 OTA 배포 흐름
번들은 불변 경로에 먼저 올리고, 매니페스트는 마지막에 바꿉니다.

이 프로젝트에서는 앱을 사용하는 중간에 화면을 다시 불러오지 않도록 스플래시 구간에서 업데이트를 확인하고 적용한 뒤 재시작했습니다. 실행할 때마다 다운로드까지 기다리면 첫 화면이 느려지는 대가가 있습니다.

번들 선택은 JavaScript가 실행되기 전에 일어납니다. 따라서 활성 번들 경로를 고르는 책임은 iOS와 Android의 부팅 코드에 있습니다. 네이티브 바이너리에 맞는 번들을 고르고, 사용 중인 앱이 갑자기 재시작하지 않도록 막는 쪽이 S3 게시보다 까다로웠습니다.

배포 단위를 나눈 기준

OTA 번들은 플랫폼만 같다고 실행할 수 있는 것이 아닙니다. 앱 바이너리에 포함된 네이티브 모듈과 설정이 번들이 기대하는 환경과 같아야 합니다. 이를 표현하려고 runtimeVersion을 배포의 1차 경계로 삼았습니다. 네이티브 의존성, React Native 버전, 네이티브 설정이 바뀌면 새 runtime과 새 스토어 빌드가 필요합니다.

서명과 자동 롤백을 더한다면

여기부터는 아직 구현하지 않은 보강안입니다. 네이티브 코드와 맞지 않는 번들을 내려보내면 앱이 시작하지 못할 수 있습니다. 배포 파일의 무결성과 출처를 검사하고, 새 번들로 부팅에 실패했을 때 돌아갈 경로도 필요합니다.

매니페스트 검증

매니페스트는 네트워크에서 받은 입력이므로 TypeScript 타입만 선언해서는 부족합니다. 아래 코드는 사내 경로와 인증 방식을 생략하고 검증 필드를 더한 개념 예시입니다.

import { z } from 'zod';
 
const updatePayloadSchema = z
  .object({
    id: z.string().min(1),
    platform: z.enum(['ios', 'android']),
    runtimeVersion: z.string().min(1),
    createdAt: z.string().datetime(),
    bundle: z
      .object({
        url: z.string().url(),
        size: z.number().int().positive(),
        sha256: z.string().regex(/^[a-f0-9]{64}$/)
      })
      .strict()
  })
  .strict();
 
const updateManifestSchema = z
  .object({
    payload: updatePayloadSchema,
    signature: z.string().min(1)
  })
  .strict();
 
type UpdateManifest = z.infer<typeof updateManifestSchema>;
 
async function fetchManifest(url: string): Promise<UpdateManifest> {
  const response = await fetch(url);
 
  if (!response.ok) {
    throw new Error(`Manifest request failed: ${response.status}`);
  }
 
  const raw: unknown = await response.json();
  return updateManifestSchema.parse(raw);
}

보강안에서는 signature를 제외한 payload의 정규화된 바이트를 대상으로 서명을 계산합니다. 클라이언트가 스키마와 서명을 확인한 뒤, 기존 호환성 검사와 마찬가지로 payload.platformpayload.runtimeVersion이 현재 바이너리와 모두 일치할 때만 후보를 받도록 설계했습니다. id 비교는 이미 설치한 배포인지 판단하는 용도일 뿐, 네이티브 호환성 검사를 대신하지 않습니다.

파일을 검증한 뒤 매니페스트 게시

S3는 한 객체 키의 갱신을 원자적으로 처리하고 강한 읽기 일관성을 제공합니다. 그러나 번들, 에셋, 매니페스트 여러 객체를 하나의 트랜잭션으로 묶어 주지는 않습니다. 매니페스트를 먼저 공개하면 클라이언트가 아직 업로드되지 않은 번들을 가리킬 수 있습니다.

기존 게시 순서에 크기·해시 검증과 서명 생성을 더하면 다음 흐름이 됩니다.

  1. 번들과 에셋을 배포 ID가 포함된 새 경로에 올립니다.
  2. 업로드된 객체의 크기와 해시를 다시 확인합니다.
  3. 매니페스트 서명을 만듭니다.
  4. 마지막 단계에서 manifest.json을 교체합니다.

아래 워크플로도 구조를 설명하려고 줄인 개념 예시입니다. 실제 빌드 명령, 권한 위임, 서명 키 접근 방식은 저장소 밖의 배포 환경에 맞춰야 합니다.

# 개념 예시: 번들 생성과 검증 단계는 프로젝트에 맞게 보강해야 한다.
- name: Upload immutable artifacts
  run: aws s3 sync ./dist/artifacts "s3://$BUCKET/updates/$PLATFORM/$RUNTIME/$DEPLOY_ID/"
 
- name: Verify uploaded artifacts
  run: ./scripts/verify-upload "$PLATFORM" "$RUNTIME" "$DEPLOY_ID"
 
- name: Publish manifest last
  run: aws s3 cp ./dist/manifest.json \
    "s3://$BUCKET/updates/$PLATFORM/$RUNTIME/manifest.json" \
    --cache-control "no-store"

버전별 번들에는 긴 캐시 수명을 적용하고, 최신 포인터인 매니페스트는 캐시하지 않거나 매우 짧게 캐시합니다. CloudFront를 앞에 둘 때도 이 원칙은 같습니다. AWS 역시 자주 바뀌는 파일에 버전이 붙은 이름을 사용하면 캐시 제어와 롤백이 쉬워진다고 안내합니다.

다운로드 검증과 적용 시점

보강안에서는 클라이언트가 파일을 임시 경로에 저장한 뒤 크기와 SHA-256을 확인합니다. 해시는 전송 중 손상은 찾지만 공격자가 파일과 해시를 함께 바꾸는 상황은 막지 못합니다. 그래서 매니페스트 서명 검증을 함께 넣는 구성을 제안합니다.

검증을 마친 파일만 최종 경로로 원자적으로 이동하고 그다음에 후보 포인터를 기록합니다. 다운로드가 끊기거나 검증에 실패하면 임시 파일을 지우고 현재 번들을 그대로 실행합니다. 네트워크 오류 때문에 스플래시 화면이 끝없이 남지 않도록 확인 시간에도 상한을 둡니다.

스플래시 대기를 줄이려면 매니페스트 확인에 짧은 제한 시간을 두고, 오프라인이거나 제한 시간을 넘기면 현재 정상 번들로 시작하도록 보강할 수 있습니다. 새 번들을 백그라운드에서 받아 다음 실행에 적용하는 방식도 대안이지만 긴급 수정이 반영되는 시점은 늦어집니다.

부팅 실패를 복구하는 네이티브 로더

네이티브 로더는 검증된 로컬 번들을 우선하되 파일이 없거나 상태가 올바르지 않으면 앱에 포함된 번들로 돌아가도록 설계합니다.

구현 지점은 React Native 버전과 아키텍처에 따라 달라집니다. Legacy Architecture의 RCTBridge 콜백이나 재시작 명령에만 기대면 New Architecture에서 같은 방식으로 동작한다고 보장할 수 없습니다. New Architecture는 JavaScript와 네이티브 사이의 기존 브리지를 제거합니다. 해당 버전의 ReactNativeHost·factory/delegate 초기화 경로에서 시작 번들 URL을 선택하고 재시작 동작을 실제 빌드로 검증해야 합니다.

롤백까지 포함하려면 새 번들을 처음 실행할 때 pending으로 기록해야 합니다. JavaScript가 핵심 초기화를 마치면 네이티브에 성공을 알리고 confirmed로 바꿉니다. 확인 전에 비정상 종료가 반복되면 마지막으로 확인된 번들 또는 앱 내장 번들로 포인터를 되돌립니다. 원격 매니페스트를 이전 배포 ID로 되돌리는 서버 롤백만으로는 이미 새 번들을 받은 앱의 부팅 실패를 복구할 수 없습니다.

보강안을 적용할 때 확인할 항목

서명과 자동 롤백을 적용할 때 확인할 조건입니다. 자동 테스트와 실제 기기 점검으로 나눠 배포 전에 검증합니다.

  • 깨끗한 설치, 기존 업데이트가 있는 설치, 앱 내장 번들만 남은 상태
  • 오프라인, 타임아웃, 중간에 끊긴 다운로드, 저장 공간 부족
  • 잘못된 JSON, 알 수 없는 필드, runtime 불일치
  • 크기·해시 불일치와 유효하지 않은 서명
  • 매니페스트는 새 값인데 CDN에 이전 응답이 남아 있는 상황
  • 후보 번들 첫 실행 실패와 마지막 정상 번들 복구
  • iOS·Android의 현재 React Native 아키텍처별 시작과 재시작

OTA가 넘지 말아야 할 선

이 방식은 JavaScript와 정적 에셋을 교체할 뿐 네이티브 기능을 추가하지 못합니다. 새 권한, SDK, 네이티브 모듈, 앱의 주된 목적을 바꾸는 기능은 새 바이너리로 배포해야 합니다.

스토어 정책도 배포 설계의 일부입니다. Apple의 App Review Guideline 2.5.2는 앱 기능을 새로 도입하거나 바꾸는 코드를 내려받아 실행하는 행위를 제한합니다. Google Play는 Play 외부에서 dex·JAR·네이티브 코드 등을 받는 행위를 금지하며, 인터프리터에서 실행되는 JavaScript에는 예외를 두지만 그 코드도 다른 Play 정책을 위반해서는 안 된다고 밝힙니다. OTA를 쓴다는 사실 자체가 모든 업데이트를 허용한다는 뜻은 아닙니다. 실제 배포 전에는 앱 기능과 배포 범위를 기준으로 최신 정책을 다시 확인해야 합니다.

직접 운영하고 남은 것

CI 게시부터 스플래시 적용과 네이티브 번들 선택까지 하나의 배포 흐름으로 연결했습니다. 서명과 자동 롤백, 단계적 롤아웃은 앞으로 더 보완해야 할 부분입니다.

이 운영 도구까지 팀이 직접 유지하기 어렵다면 EAS Update 같은 관리형 서비스가 더 합리적일 수 있습니다. S3는 배포 파일을 담아 둘 뿐입니다. OTA의 안전성은 결국 앱과 CI에서 만들어야 합니다.

참고한 공식 문서