Offline WebView 자산 번들을 원자적으로 교체하기

Offline WebView 자산 번들을 원자적으로 교체하기

한눈에 보기

실행 중인 WebView 자산을 파일별로 덮어쓰면 앱 종료나 디스크 부족 순간에 여러 버전이 섞인다. 새 번들은 별도 staging 디렉터리에 완전히 받은 뒤 manifest, 파일 크기, 해시, 호환성을 검증한다. 이후 불변인 버전 디렉터리를 확정하고 작은 활성 포인터만 원자적으로 교체한다. 시작 시에는 포인터가 가리키는 번들을 다시 검증하고 실패하면 직전 정상 버전이나 앱 내장 번들로 복구한다.

웹 화면을 네이티브 앱 안의 WebView로 제공하면 웹 배포 속도와 앱 기능을 함께 활용할 수 있다. 그런데 완전한 오프라인 부팅까지 지원하려면 HTML, JavaScript, CSS, 이미지 같은 웹 자산을 앱 파일 시스템에 내려받아야 한다.

여기서 다운로드 자체보다 더 까다로운 문제는 언제 새 버전을 활성 상태로 볼 것인가다.

index.html을 먼저 바꾸고 main.js를 받는 도중 앱이 종료되면 HTML은 새 번들, JavaScript는 이전 번들이 된다. 새 JavaScript를 먼저 바꿔도 반대 문제가 생긴다. 파일 하나의 쓰기는 성공했지만 번들 전체는 어느 버전도 아니다.

이 글은 Offline WebView 프로젝트를 바탕으로, 여러 자산으로 이루어진 웹 번들을 하나의 배포 단위처럼 교체하는 방법을 정리한다. 코드는 실제 구현을 복사하지 않고 파일 저장소의 계약과 장애 경계를 보여 주도록 새로 구성했다.

목차

파일별 덮어쓰기가 위험한 이유

가장 단순한 업데이트는 활성 디렉터리의 파일을 순서대로 덮어쓰는 것이다.

// 중간 상태가 노출되는 나쁜 예시
for (const asset of manifest.assets) {
  const bytes = await download(asset.url);
  await fileStore.write(`active/${asset.path}`, bytes);
}

정상 네트워크에서는 잘 동작해 보인다. 하지만 아래 지점마다 앱이 종료될 수 있다.

index.html 교체 완료
main.js 다운로드 완료
main.js 쓰기 도중 종료  ← 여기
styles.css 미교체
logo.webp 미교체

다음 실행에서 index.html은 새 해시의 main.js를 기대하지만 실제 파일은 잘린 상태거나 이전 버전이다. 온라인 사이트라면 다시 요청할 수 있어도 오프라인에서는 복구 경로가 없다.

실패 시점 활성 디렉터리 상태 다음 부팅 결과
첫 파일 쓰기 전 이전 버전 정상
파일 일부 교체 후 구·신 버전 혼합 예측 불가능
마지막 파일 쓰기 후, 메타데이터 전 새 파일이지만 버전 불명 복구 판단 어려움
정리 중 참조 중 파일 삭제 가능 흰 화면 또는 로드 실패

이 문제는 각 파일의 쓰기를 원자적으로 만들어도 해결되지 않는다. 필요한 보장은 ‘파일 하나가 온전함’이 아니라 manifest에 속한 모든 파일이 한 버전으로 보임이기 때문이다.

교체 단위

WebView가 한 번에 소비하는 HTML·JavaScript·CSS·이미지 집합이 원자적 교체 단위다. 파일 단위 성공을 번들 단위 성공으로 착각하면 안 된다.

번들을 불변 버전으로 저장한다

활성 디렉터리를 계속 수정하지 않고 버전별 디렉터리를 만든다.

web-bundles/
├── versions/
│   ├── web-2026.08.1/
│   │   ├── manifest.json
│   │   ├── index.html
│   │   └── assets/...
│   └── web-2026.08.2/
│       ├── manifest.json
│       ├── index.html
│       └── assets/...
├── staging/
│   └── install-7f2a/
└── state/
    ├── active.json
    └── previous.json

versions/web-2026.08.1은 설치가 끝난 뒤 수정하지 않는다. 새 버전은 새 디렉터리에 준비하고, 활성 버전을 나타내는 작은 포인터만 바꾼다.

이 구조가 주는 장점은 크다.

버전 문자열만 신뢰하지 않고 번들 내용 해시를 함께 키로 사용할 수 있다.

version key = web-2026.08.2+sha256-8d39...

동일 버전 이름에 다른 콘텐츠가 배포되는 실수를 탐지할 수 있고, 이미 받은 동일 콘텐츠를 재사용하기도 쉽다.

manifest를 설치 계약으로 사용한다

압축 파일을 풀 수 있다고 실행 가능한 번들은 아니다. 어떤 파일이 필수이고 각 파일이 어떤 내용이어야 하는지 manifest가 선언해야 한다.

{
  "schemaVersion": 2,
  "bundleVersion": "web-2026.08.2",
  "entryHtml": "index.html",
  "nativeBridge": {
    "minimum": 3,
    "maximum": 5
  },
  "files": [
    {
      "path": "index.html",
      "size": 1842,
      "sha256": "example_hash_index"
    },
    {
      "path": "assets/main-a9c1.js",
      "size": 281430,
      "sha256": "example_hash_main"
    }
  ],
  "totalSize": 283272,
  "createdAt": "2026-08-18T02:00:00Z",
  "signature": "example_signature"
}

manifest 검증은 여러 층으로 나눈다.

  1. JSON 형식과 schema version을 확인한다.
  2. entryHtml이 파일 목록에 있는지 확인한다.
  3. 경로가 상대 경로이고 디렉터리 밖으로 나가지 않는지 확인한다.
  4. 중복 경로와 대소문자 충돌을 확인한다.
  5. 파일 개수·개별 크기·전체 크기가 정책 한도 안인지 확인한다.
  6. 모든 파일의 실제 바이트 수와 SHA-256을 확인한다.
  7. 네이티브 브리지 호환 범위를 확인한다.
  8. 신뢰된 키로 manifest 서명을 검증한다.

경로 검증을 빼면 악성 번들이 ../../state/active.json 같은 이름으로 활성 포인터를 덮을 수 있다.

import path from "node:path";

function normalizeAssetPath(input: string): string {
  if (input.includes("\0")) throw new Error("NUL_IN_PATH");

  const normalized = path.posix.normalize(input.replaceAll("\\", "/"));
  if (
    normalized.startsWith("/") ||
    normalized === ".." ||
    normalized.startsWith("../")
  ) {
    throw new Error("PATH_ESCAPES_BUNDLE");
  }
  return normalized;
}

플랫폼마다 대소문자 구분과 예약 이름이 다르다. Logo.pnglogo.png가 한 플랫폼에서는 별개이고 다른 플랫폼에서는 충돌할 수 있으므로 빌드 단계에서도 이식성을 검증한다.

다운로드와 활성화를 분리한다

설치 흐름을 download → verify → commit → activate 네 단계로 나누면 실패의 의미가 명확해진다.

flowchart LR
    A[manifest 조회] --> B[staging에 다운로드]
    B --> C[압축 해제와 경로 검증]
    C --> D[크기와 해시 검증]
    D --> E[호환성 및 서명 검증]
    E --> F[불변 version 디렉터리로 commit]
    F --> G[active pointer 교체]
    G --> H[WebView 다음 로드부터 사용]

    B -. 실패 .-> X[staging 폐기]
    C -. 실패 .-> X
    D -. 실패 .-> X
    E -. 실패 .-> X

각 단계의 불변식을 정의한다.

단계 외부에서 보이는 상태 실패 시 처리
download 현재 활성 버전만 보임 staging 삭제 또는 재개
verify 현재 활성 버전만 보임 새 데이터 격리
commit 새 버전은 설치됨, 아직 비활성 활성 버전에 영향 없음
activate 포인터가 이전 또는 새 버전 중 하나 시작 시 포인터 검증

이를 코드 계약으로 표현하면 다음과 같다.

type PreparedBundle = {
  installId: string;
  stagingDirectory: string;
  manifest: BundleManifest;
};

type CommittedBundle = {
  versionKey: string;
  directory: string;
  manifest: BundleManifest;
};

interface BundleInstaller {
  prepare(source: URL): Promise<PreparedBundle>;
  verify(bundle: PreparedBundle): Promise<void>;
  commit(bundle: PreparedBundle): Promise<CommittedBundle>;
  activate(bundle: CommittedBundle): Promise<void>;
}

타입만으로 런타임 안전성이 생기는 것은 아니지만, 검증되지 않은 staging 경로를 곧바로 활성화하는 호출을 줄일 수 있다.

같은 파일 시스템에서 rename한다

대부분의 파일 시스템에서 같은 파일 시스템 안의 rename은 이름이 이전 또는 새 상태 중 하나로 보이게 하는 원자적 연산으로 사용할 수 있다. 그러나 staging을 임시 시스템 디렉터리에 만들고 최종 앱 저장소로 옮기면 서로 다른 볼륨일 수 있다. 이 경우 rename이 실패하거나 copy+delete로 바뀌어 원자성을 잃는다.

앱 데이터 디렉터리/
├── staging/install-7f2a   ← 여기서 준비
└── versions/web-2026.08.2 ← 같은 파일 시스템 안에서 rename

예시 인터페이스는 원자적 rename을 명시적으로 요구한다.

interface AtomicFileStore {
  makeDirectory(path: string): Promise<void>;
  writeFile(path: string, bytes: Uint8Array): Promise<void>;
  renameWithinRoot(from: string, to: string): Promise<void>;
  syncFile(path: string): Promise<void>;
  syncDirectory(path: string): Promise<void>;
}

React Native나 Expo의 파일 API가 모든 플랫폼에서 디렉터리 fsync와 원자적 rename을 그대로 노출하지 않을 수 있다. 이때 ‘메서드 이름이 move이므로 원자적일 것’이라고 가정하지 않고 플랫폼 어댑터의 보장 범위를 문서화한다.

copy는 rename이 아니다

큰 파일을 목적지로 복사한 뒤 원본을 지우는 동작은 중간 상태가 보인다. API 구현이 내부에서 어떤 동작을 하는지 확인하지 못했다면 최종 디렉터리 자체를 활성 포인터로 삼지 말고, 완료 marker와 시작 시 검증을 추가한다.

활성 포인터를 원자적으로 바꾼다

설치가 끝난 버전 디렉터리는 불변이므로 실제 활성화는 다음 작은 파일의 교체다.

{
  "versionKey": "web-2026.08.2+sha256-8d39",
  "generation": 42,
  "activatedAt": "2026-08-18T03:15:00Z"
}

포인터 파일에 직접 쓰지 않고 같은 디렉터리에 임시 파일을 만든 뒤 rename한다.

async function replaceActivePointer(
  store: AtomicFileStore,
  stateDir: string,
  pointer: ActivePointer,
): Promise<void> {
  const temp = `${stateDir}/active.${pointer.generation}.tmp`;
  const active = `${stateDir}/active.json`;
  const bytes = new TextEncoder().encode(JSON.stringify(pointer));

  await store.writeFile(temp, bytes);
  await store.syncFile(temp);
  await store.renameWithinRoot(temp, active);
  await store.syncDirectory(stateDir);
}

WebView는 active.json을 매 요청마다 읽기보다 시작 시 한 번 해석해 절대 version 경로를 고정한다.

async function resolveWebViewSource(): Promise<{ uri: string }> {
  const active = await loadAndValidateActivePointer();
  const bundle = await verifyCommittedBundle(active.versionKey);
  return {
    uri: toFileUri(`${bundle.directory}/${bundle.manifest.entryHtml}`),
  };
}

업데이트 중이던 WebView는 이전 version 경로를 계속 읽고, 새 화면 또는 명시적 reload부터 새 버전을 사용한다. 실행 중 source를 즉시 바꾸면 화면 상태와 브리지 요청이 중간에 끊길 수 있으므로 활성화와 재로딩 정책도 분리한다.

내구성과 원자성은 다르다

rename이 원자적이라는 말은 관찰자가 중간 이름을 보지 않는다는 뜻에 가깝다. 전원 손실 뒤 새 파일 내용과 디렉터리 메타데이터가 반드시 디스크에 남는다는 내구성까지 자동으로 보장하지는 않는다.

데스크톱 파일 시스템의 전형적인 순서는 다음과 같다.

1. staging 파일 쓰기
2. 각 파일 fsync
3. staging 디렉터리 fsync
4. 최종 version 디렉터리로 rename
5. versions 부모 디렉터리 fsync
6. active.tmp 쓰기와 fsync
7. active.json으로 rename
8. state 디렉터리 fsync

모바일 런타임에서는 이 제어를 모두 할 수 없을 수 있다. 그러면 시작 시 검증과 이전 포인터 보관이 더 중요해진다.

보장 질문
원자성 관찰자가 반쯤 쓴 포인터를 볼 수 있는가
내구성 완료 응답 뒤 전원이 꺼져도 새 포인터가 남는가
무결성 포인터가 가리키는 모든 파일이 기대한 바이트인가
복구 가능성 새 버전이 깨졌을 때 이전 버전을 찾을 수 있는가

‘rename을 썼다’는 한 문장만으로 네 가지 질문에 모두 답할 수는 없다.

앱 시작 시 복구 절차

앱 시작은 정상 경로만 읽는 코드가 아니라 이전 업데이트가 어느 지점에서 끊겼는지 수습하는 복구 루틴이어야 한다.

flowchart TD
    S[앱 시작] --> A{active pointer 읽기 가능?}
    A -->|예| B{대상 번들 검증 성공?}
    B -->|예| C[active 번들로 부팅]
    B -->|아니오| P{previous 번들 정상?}
    A -->|아니오| P
    P -->|예| R[previous를 active로 복구]
    P -->|아니오| E{앱 내장 번들 존재?}
    E -->|예| F[내장 번들로 부팅]
    E -->|아니오| X[복구 화면과 진단 정보]

검증 범위는 성능과 맞바꾼다. 매 부팅 때 수백 MB 전체 해시를 다시 계산하면 느리다. 다음 계층으로 나눌 수 있다.

완료 marker도 유용하다.

{
  "versionKey": "web-2026.08.2+sha256-8d39",
  "manifestHash": "example_manifest_hash",
  "verifiedAt": "2026-08-18T03:14:58Z"
}

marker는 모든 검증과 version commit이 끝난 뒤에만 쓴다. 시작 시 marker가 없는 version 디렉터리는 불완전한 설치로 간주한다.

staging 잔여물은 활성 후보가 아니다. 일정 시간이 지난 install 디렉터리만 청소하고, 현재 다운로드 작업의 lease가 있는 경로는 보존한다.

WebView가 사용 중인 버전을 지우지 않는다

활성 포인터를 새 버전으로 바꿨다고 이전 파일을 즉시 지우면 안 된다. 이미 열린 WebView가 이전 index.html을 로드한 뒤 지연 로딩 chunk를 요청할 수 있기 때문이다.

WebView A: versions/v41/index.html 실행 중
active pointer: v42로 변경
WebView A: versions/v41/assets/settings.chunk.js 요청
cleanup: v41 삭제  ← 런타임 오류

정리 정책은 최소한 다음 버전을 보호한다.

type BundleLease = {
  sessionId: string;
  versionKey: string;
  expiresAt: number;
};

interface BundleUsageRegistry {
  acquire(versionKey: string, sessionId: string): Promise<BundleLease>;
  renew(lease: BundleLease): Promise<BundleLease>;
  release(lease: BundleLease): Promise<void>;
  pinnedVersions(now: number): Promise<Set<string>>;
}

프로세스가 강제 종료되면 release가 실행되지 않으므로 lease에는 만료 시간이 필요하다. 다음 시작에서 살아 있는 세션이 없음을 확인한 뒤 만료된 pin을 정리한다.

간단한 앱이라면 복잡한 lease 대신 현재와 이전 두 버전을 항상 보존하고 앱 재시작 뒤에만 더 오래된 버전을 지우는 정책도 충분하다.

호환되지 않는 번들을 막는다

웹 번들이 새로운 브리지 메시지 camera.scan.v4를 보내는데 설치된 네이티브 앱이 v2까지만 지원하면 자산 자체는 온전해도 실행할 수 없다. 따라서 manifest에 네이티브 API 호환 범위를 둔다.

type Compatibility = {
  nativeBridgeMinimum: number;
  nativeBridgeMaximum: number;
  requiredCapabilities: string[];
};

function assertCompatible(
  bundle: Compatibility,
  runtime: { bridgeVersion: number; capabilities: Set<string> },
): void {
  if (
    runtime.bridgeVersion < bundle.nativeBridgeMinimum ||
    runtime.bridgeVersion > bundle.nativeBridgeMaximum
  ) {
    throw new Error("INCOMPATIBLE_BRIDGE_VERSION");
  }

  for (const capability of bundle.requiredCapabilities) {
    if (!runtime.capabilities.has(capability)) {
      throw new Error(`MISSING_CAPABILITY:${capability}`);
    }
  }
}

앱 버전 문자열만 비교하면 플랫폼별 기능 차이나 선택 기능을 표현하기 어렵다. 프로토콜 버전과 capability를 함께 쓰면 더 명확하다. 관련 설계는 WebView 브릿지를 버전 있는 프로토콜로 만들기와 이어진다.

롤백 호환성

새 웹 번들이 로컬 SQLite 스키마를 파괴적으로 바꾼 뒤 이전 번들로 돌아가면 이전 코드가 데이터를 읽지 못할 수 있다. 자산 롤백을 지원하려면 로컬 데이터 마이그레이션도 전후 호환되게 설계해야 한다.

무결성과 출처를 함께 검증한다

SHA-256 해시는 다운로드한 파일이 manifest와 같은지 확인한다. 그러나 공격자가 번들과 manifest를 함께 바꿀 수 있다면 해시만으로 신뢰할 수 없다. manifest 자체를 신뢰된 키로 서명해야 한다.

빌드 서버
  파일 해시 계산
  → manifest 생성
  → 배포 전용 개인 키로 manifest 서명

앱
  내장 공개 키로 signature 확인
  → manifest의 각 파일 해시 확인
  → 호환성 확인
  → 설치

서명 키 운영에서는 다음 경계를 둔다.

TLS는 전송 경로를 보호하지만 CDN 오배포나 저장소 변조 뒤 오프라인 재사용까지 설명하지 못한다. 서명은 번들의 출처와 내용을 검증한다.

다운그레이드가 위험한 서비스라면 앱에 마지막 허용 security generation을 저장하고 더 낮은 generation을 거절한다. 단, 잘못된 배포를 이전 정상 버전으로 되돌려야 하는 운영 요구와 충돌하므로 일반 release version과 security floor를 분리하는 편이 좋다.

디스크 부족과 정리 정책

다운로드를 시작하기 전에 manifest의 totalSize만큼 공간이 있다고 끝이 아니다. 압축 파일과 해제된 파일이 동시에 존재하고, 현재·이전 버전도 보존해야 한다.

필요 여유 공간
≈ 압축 다운로드 크기
+ 해제 후 번들 크기
+ 임시 파일 오버헤드
+ 안전 여유

정리 순서는 안전성이 낮은 데이터부터다.

  1. 오래된 실패 staging
  2. active·previous·pinned가 아닌 오래된 버전
  3. 다시 받을 수 있는 일반 응답 캐시
  4. 그래도 부족하면 업데이트 보류

현재 active를 지워 공간을 확보한 뒤 새 다운로드를 시도하는 것은 금지한다. 새 설치까지 실패하면 오프라인 부팅 수단이 모두 사라진다.

async function selectEvictionCandidates(
  installed: InstalledBundle[],
  protectedVersions: Set<string>,
): Promise<InstalledBundle[]> {
  return installed
    .filter((bundle) => !protectedVersions.has(bundle.versionKey))
    .sort((a, b) => a.lastUsedAt - b.lastUsedAt);
}

삭제 도중 앱이 종료돼도 활성 포인터가 삭제 대상 버전을 가리키지 않도록 먼저 보호 집합을 스냅샷으로 만들고, 각 삭제 전 다시 확인한다. 업데이트와 cleanup이 동시에 실행되지 않도록 프로세스 내부 mutex나 파일 lease도 필요하다.

장애 지점별 테스트

원자적 업데이트는 정상 설치 테스트보다 각 쓰기 뒤에 앱이 죽는 테스트가 더 중요하다.

가상 파일 저장소에 실패 지점을 주입할 수 있다.

class FaultInjectingStore implements AtomicFileStore {
  private operation = 0;

  constructor(
    private readonly delegate: AtomicFileStore,
    private readonly failAt: number,
  ) {}

  private checkpoint(): void {
    this.operation += 1;
    if (this.operation === this.failAt) {
      throw new Error("SIMULATED_PROCESS_CRASH");
    }
  }

  async writeFile(path: string, bytes: Uint8Array) {
    this.checkpoint();
    return this.delegate.writeFile(path, bytes);
  }

  async renameWithinRoot(from: string, to: string) {
    this.checkpoint();
    return this.delegate.renameWithinRoot(from, to);
  }

  // 나머지 메서드도 같은 방식으로 감싼다.
}

테스트는 가능한 모든 checkpoint에서 중단한 뒤 저장소를 새 인스턴스로 다시 열고 다음 불변식을 확인한다.

항상 다음 중 하나여야 한다.

1. 이전 known-good 번들로 부팅된다.
2. 새 번들이 완전히 검증된 상태로 부팅된다.
3. 둘 다 불가능하면 내장 번들로 부팅된다.

절대로 구·신 파일이 섞인 경로를 active로 선택하지 않는다.

필수 시나리오는 다음과 같다.

시나리오 확인할 결과
manifest 다운로드 중 종료 기존 active 유지
자산 일부 다운로드 뒤 종료 staging만 남고 부팅 영향 없음
해시 불일치 새 버전 격리, active 유지
version rename 직후 종료 설치됐지만 비활성 또는 안전하게 복구
active pointer 임시 파일 쓰기 중 종료 이전 또는 새 포인터만 선택
활성화 직후 전원 종료 시작 검증 후 새 버전 또는 previous
cleanup과 WebView chunk 로드 경쟁 사용 중 버전 보존
디스크 부족 current/previous를 지우지 않고 업데이트 보류
호환되지 않는 bridge version 다운로드했더라도 활성화 금지

실제 기기에서는 플랫폼별 파일 API와 OS 종료 동작을 확인한다. 단위 테스트가 가상 저장소 계약을 검증하더라도 어댑터가 move를 copy로 구현하면 전제가 달라질 수 있다.

관측 가능성과 롤백

업데이트 문제는 사용자가 ‘흰 화면’으로만 보고할 가능성이 높다. 설치의 각 단계를 구조화된 이벤트로 남긴다.

{
  "event": "web_bundle_activation_failed",
  "installId": "install-demo-7f2a",
  "targetVersion": "web-2026.08.2",
  "currentVersion": "web-2026.08.1",
  "stage": "verify_hash",
  "reason": "ASSET_HASH_MISMATCH",
  "assetPathHash": "example:12ab...",
  "availableBytes": 482344960,
  "platform": "ios"
}

원본 URL에 토큰이 있거나 파일 경로에 사용자 정보가 있다면 로그에서 제거한다.

운영 지표는 다음을 본다.

활성화 직후 WebView health check가 실패하면 previous로 되돌릴 수 있다.

async function activateWithHealthCheck(bundle: CommittedBundle) {
  const previous = await readActivePointer();
  await replaceActivePointer(store, stateDir, pointerFor(bundle));

  const healthy = await bootHiddenWebViewAndProbe(bundle, 5_000);
  if (!healthy) {
    await replaceActivePointer(store, stateDir, previous);
    await markBundleQuarantined(bundle.versionKey);
    throw new Error("BUNDLE_HEALTH_CHECK_FAILED");
  }
}

health check가 성공했다고 모든 사용자 경로가 안전한 것은 아니다. 새 버전의 크래시율이 높아질 때 원격 kill switch로 특정 번들의 추가 활성화를 중단하는 장치도 필요하다.

구현하면서 놓치기 쉬운 경계

zip 파일 하나라고 자동으로 원자적인 것은 아니다

압축 파일 다운로드가 완전해도 해제 중 실패할 수 있다. WebView가 zip을 직접 실행하지 않는다면 실제 실행 디렉터리의 완전성을 따로 보장해야 한다.

ETag를 콘텐츠 검증으로 오해하지 않는다

ETag의 생성 방식은 서버마다 다르고 암호학적 해시가 아닐 수 있다. 캐시 재검증에는 유용하지만 manifest의 파일 해시를 대신하지 않는다.

포인터를 환경 설정 여러 키에 나눠 쓰지 않는다

activeVersion, entryPath, manifestHash를 별도 key-value 쓰기로 갱신하면 서로 다른 세대가 섞일 수 있다. 하나의 작은 레코드로 직렬화하고 세대 번호를 넣는다.

설치 성공과 실행 성공을 구분한다

해시가 모두 맞아도 JavaScript 런타임 오류, 브리지 불일치, CSP 문제로 부팅에 실패할 수 있다. 설치 검증 뒤 제한된 실행 health check와 운영 실패율을 본다.

background update와 화면 reload를 분리한다

새 번들 활성화가 곧 현재 WebView 강제 reload를 뜻하지 않게 한다. 사용자가 작성 중인 폼이나 오프라인 작업을 잃을 수 있다. 다음 앱 시작, 안전한 화면 전환, 사용자 승인 등 제품 정책을 둔다.

이전 버전도 무조건 정상이라고 가정하지 않는다

previous 포인터가 오래됐거나 파일이 OS 정리에 의해 사라질 수 있다. 롤백 전에도 manifest와 entry를 검증하고, 최종적으로 앱 패키지에 포함된 최소 번들을 준비한다.

로컬 URL의 보안 경계를 유지한다

file://로 실행하는 WebView는 외부 파일 접근, universal access, 임의 navigation 권한을 최소화해야 한다. 서명된 번들이라고 브리지 메시지를 무조건 신뢰하지 않고 WebView와 Native의 책임 경계에 맞춰 검증한다.

정리

Offline WebView 자산 업데이트의 핵심은 다운로드 기술이 아니라 부분 상태를 절대로 활성 상태로 해석하지 않는 것이다.

설계를 요약하면 다음과 같다.

  1. 활성 파일을 덮어쓰지 않고 버전별 불변 디렉터리를 사용한다.
  2. 새 번들은 active와 같은 파일 시스템의 staging에 완전히 준비한다.
  3. manifest 스키마, 경로, 크기, 해시, 서명, 브리지 호환성을 검증한다.
  4. 검증된 staging만 version 디렉터리로 commit한다.
  5. 작은 active pointer를 임시 파일 쓰기와 rename으로 교체한다.
  6. 원자성과 내구성을 구분하고 가능한 범위에서 파일과 부모 디렉터리를 동기화한다.
  7. 앱 시작 때 active, previous, 내장 번들 순으로 검증하며 복구한다.
  8. 실행 중 WebView가 pin한 버전은 cleanup에서 제외한다.
  9. 디스크가 부족해도 현재 정상 버전을 먼저 지우지 않는다.
  10. 모든 쓰기 지점에서 강제 종료를 주입해 ‘이전 또는 새 정상 버전만 부팅’이라는 불변식을 검사한다.

원자적 업데이트란 모든 파일을 한순간에 쓰는 기술이 아니다. 완성되지 않은 파일 집합은 끝까지 보이지 않게 하고, 마지막에 아주 작은 참조만 바꾸는 설계다.

관련 노트