요약

  • 운전 중 듣는 방송 앱에서는 정책을 받지 못했다고 앱을 막으면 피해가 가장 크다. 그래서 확인하지 못하면 연다(fail-open).
  • 정책 조회 결과와 화면 상태를 나누고, 모든 실패를 이유가 붙은 결과로 바꾸고, 차단 결정은 순수 함수 하나에 둔다.
  • 2026년 5월 28일 iOS·Android에 정식 출시한 뒤, 6월에 이 게이트를 넣어 iOS 2.1.1·Android 4.1.1부터 적용했다. 실패에 이유를 붙이는 판단 구조는 공개용으로 다시 쓴 설계다.

상황

tbn교통방송 앱은 13개 지역 FM 라이브 방송을 듣고 교통 정보를 제보하는 React Native 앱이다. 2025년부터 앱을 다시 만들었고 2026년 5월 28일 iOS·Android에 정식 출시했다. 사용자는 주로 운전 중에 앱을 연다. 앱이 열리지 않으면 방송을 듣지 못하고, 운전 중에 원인을 찾을 수도 없다.

출시 뒤 강제 업데이트가 필요해졌다. 서버 API가 바뀌면 오래된 버전은 오류 없이 기능이 깨진다. 서버가 플랫폼별 최소 지원 버전을 알려 주고, 현재 버전이 낮으면 스토어로 안내한다. 실제 앱에서는 이 정책이 Firebase Remote Config 값으로 내려온다.

이 글의 코드는 같은 판단 구조를 공개용으로 다시 쓴 것이다. 제품 코드를 그대로 옮기지 않았다.

문제: Boolean 하나에 두 가지 뜻이 섞인다

처음 떠오르는 구현은 다음과 같다.

async function isUpdateRequired(): Promise<boolean> {
  try {
    const response = await fetchPolicy();
    return isOlder(getCurrentVersion(), response.minimumVersion);
  } catch {
    return true;
  }
}

true가 “정책이 이 버전을 막았다”와 “정책 서버에 연결하지 못했다”를 모두 뜻한다. 정책 서버가 느려지거나 응답 형식이 바뀌면 모든 사용자가 업데이트 화면에 갇힌다. 최신 버전으로 업데이트해도 같은 서버 장애를 만나므로 빠져나갈 길이 없다. 로그에서도 실제 강제 업데이트와 서버 장애가 같은 사건으로 남는다.

결정: 확인하지 못하면 연다 (fail-open)

선택지는 세 가지였다.

선택지 정책 서버 장애 때 판단
fail-closed: 확인 실패도 차단 모든 사용자가 앱을 못 연다 보안 정책이 반드시 지켜져야 할 때만 맞다. 방송 앱에서는 장애 범위가 너무 크다
마지막 정책 캐시로 판단 캐시가 있으면 판단을 이어 간다 캐시 수명, 기기 시간 오류, 서버에서 푼 차단이 캐시에 남는 문제를 함께 풀어야 한다. 1차 출시 범위를 넘는다
fail-open: 확인 실패는 진입 허용 앱은 열리고, 다음 실행에서 다시 확인한다 장애가 진입 장애로 번지지 않는다. 실패 이유를 기록해 장애를 따로 감지한다

fail-open을 골랐다. 차단은 유효한 정책을 받았고, 현재 버전이 최소 버전보다 낮다고 확인된 경우에만 일어난다. 네트워크·timeout·HTTP·형식 오류는 진입을 허용하되 실패 이유를 상태에 담는다.

checking스플래시 · timeoutopen · fail-open실패 이유 기록open · normal현재 ≥ 최소 버전blocked현재 < 최소 버전권한 요청→ 홈timeout · networkhttp · invalid-payload정책 확인정책 확인 + 버전 낮음스토어 안내에 머문다
유효한 정책이 차단을 확인한 경우에만 스토어 안내로 간다. 권한 요청은 업데이트 판정 뒤로 옮겼다.

구현 1: 조회 결과와 화면 상태를 나눈다

type Platform = 'ios' | 'android';

type VersionPolicy = {
  minimumVersion: Record<Platform, string>;
  storeURL: Record<Platform, string>;
};

type FailureReason =
  | 'timeout'
  | 'network'
  | 'http'
  | 'invalid-payload';

type PolicyResult =
  | { ok: true; policy: VersionPolicy }
  | { ok: false; reason: FailureReason };

type GateState =
  | { kind: 'checking' }
  | { kind: 'open'; mode: 'normal' }
  | {
      kind: 'open';
      mode: 'fail-open';
      reason: FailureReason | 'invalid-app-version';
    }
  | {
      kind: 'blocked';
      minimumVersion: string;
      storeURL: string;
    };

isLoading, hasError, isRequired를 따로 두면 “로딩 중이면서 차단” 같은 불가능한 조합이 생긴다. 하나의 합 타입으로 두면 화면이 받을 수 있는 상태가 4개로 고정된다.

구현 2: 모든 실패를 이유가 붙은 결과로 바꾼다

정책 조회는 예외를 밖으로 던지지 않는다. timeout, 연결 실패, HTTP 오류, 형식 오류를 각각의 이유로 바꿔 돌려준다. 응답 형식 검증(isVersionPolicy)은 부록에 있다.

async function loadPolicy(
  endpoint: string,
  timeoutMs: number,
): Promise<PolicyResult> {
  const controller = new AbortController();
  const timeout = setTimeout(() => controller.abort(), timeoutMs);

  try {
    const response = await fetch(endpoint, {
      method: 'GET',
      signal: controller.signal,
      headers: { Accept: 'application/json' },
    });

    if (!response.ok) {
      return { ok: false, reason: 'http' };
    }

    const payload: unknown = await response.json();
    if (!isVersionPolicy(payload)) {
      return { ok: false, reason: 'invalid-payload' };
    }

    return { ok: true, policy: payload };
  } catch (error) {
    if (error instanceof Error && error.name === 'AbortError') {
      return { ok: false, reason: 'timeout' };
    }
    if (error instanceof SyntaxError) {
      return { ok: false, reason: 'invalid-payload' };
    }
    return { ok: false, reason: 'network' };
  } finally {
    clearTimeout(timeout);
  }
}

timeout 상한이 없으면 스플래시가 checking에 머문다. 제한 시간이 지나면 요청을 취소하고 timeout으로 판정해 진입 여부를 바로 결정한다.

구현 3: 차단 결정은 순수 함수 하나에 둔다

function parseVersion(version: string): [number, number, number] | null {
  if (!versionPattern.test(version)) return null;

  const parts = version.split('.').map(Number);
  if (parts.some((part) => !Number.isSafeInteger(part))) return null;

  return [parts[0], parts[1], parts[2]];
}

function compareVersions(left: string, right: string): number | null {
  const a = parseVersion(left);
  const b = parseVersion(right);
  if (!a || !b) return null;

  for (let index = 0; index < a.length; index += 1) {
    if (a[index] !== b[index]) return a[index] - b[index];
  }
  return 0;
}

function decideGate(
  currentVersion: string,
  platform: Platform,
  result: PolicyResult,
): GateState {
  if (!result.ok) {
    return {
      kind: 'open',
      mode: 'fail-open',
      reason: result.reason,
    };
  }

  if (!parseVersion(currentVersion)) {
    return {
      kind: 'open',
      mode: 'fail-open',
      reason: 'invalid-app-version',
    };
  }

  const minimumVersion = result.policy.minimumVersion[platform];
  const comparison = compareVersions(currentVersion, minimumVersion);

  if (comparison === null) {
    return {
      kind: 'open',
      mode: 'fail-open',
      reason: 'invalid-payload',
    };
  }

  if (comparison >= 0) {
    return { kind: 'open', mode: 'normal' };
  }

  return {
    kind: 'blocked',
    minimumVersion,
    storeURL: result.policy.storeURL[platform],
  };
}

판정 함수를 네트워크와 React 생명주기에서 떼어 놓았다. 그래서 정책 전체를 입력 표 하나로 테스트할 수 있다. 비교할 수 없는 버전도 차단하지 않고 fail-open으로 보낸다. 앱 자신의 버전이 잘못되었으면 서버 응답 오류와 구분해 invalid-app-version으로 남긴다. 앱 자신의 버전 형식은 빌드 단계에서 검사해 잘못된 값이 배포되지 않게 한다.

구현 4: 시작 순서를 고정한다

시작 흐름은 스플래시 → 정책 조회(timeout 상한) → 판정 → 권한 요청 → 홈 순서로 고정했다. 차단될 버전에서 위치·알림 권한을 먼저 묻지 않으려고 권한 요청을 판정 뒤로 옮겼다.

function AppGate({ state }: { state: GateState }) {
  switch (state.kind) {
    case 'checking':
      return <LaunchScreen />;

    case 'blocked':
      return (
        <RequiredUpdateScreen
          minimumVersion={state.minimumVersion}
          onPressUpdate={() => openExternalURL(state.storeURL)}
        />
      );

    case 'open':
      return <AppNavigator />;
  }
}

fail-open 상태는 오류 화면 없이 앱으로 들어간다. 이 글의 설계에서는 대신 실패 이유, 플랫폼, 앱 버전을 이벤트로 남긴다. 예제 코드는 이유를 상태에 담는 데까지만 보여 준다. 실제 제품 코드는 경고 로그만 남겼다. 토큰, 사용자 식별 정보, 내부 endpoint는 남기지 않는다.

결과

  • 2026년 5월 28일 iOS·Android 정식 출시. 출시 뒤 제보 전화번호를 잘못 연결하던 오류를 찾아 패치했다. 이 패치와 게이트는 iOS 2.1.1·Android 4.1.1에 함께 들어갔다.
  • 5월 28일 빌드에는 게이트가 없어 강제 업데이트를 걸 수 없다. 강제 업데이트로 옮길 수 있는 사용자는 게이트가 들어간 iOS 2.1.1·Android 4.1.1 이후 버전 사용자다.
  • 실제 정책은 Firebase Remote Config 값으로 내려왔다. 제품 코드는 조회에 실패하면 “차단 안 함”을 돌려주고 경고 로그만 남겼다. 실패 이유를 결과에 붙이는 구조는 이 글에서 다시 쓴 설계다.
  • 최신 버전 사용 비율은 원본 자료가 없어 쓰지 않는다.
  • 정책 조회 장애가 실제로 몇 번 있었는지는 측정하지 않았다. fail-open은 장애가 나도 진입 장애로 번지지 않게 하려는 설계다.

테스트

판정 함수는 상태 전이 표로 고정한다. 아래는 Node 내장 테스트 러너(node:test)로 CI에서 실행하는 실제 테스트다.

const policy: VersionPolicy = {
  minimumVersion: {
    ios: '2.4.0',
    android: '3.1.0',
  },
  storeURL: {
    ios: 'https://apps.apple.com/app/id1234567890',
    android: 'https://play.google.com/store/apps/details?id=com.example.app',
  },
};

describe('decideGate', () => {
  it('opens normally when the current version meets the policy', () => {
    assert.deepEqual(decideGate('2.4.0', 'ios', { ok: true, policy }), {
      kind: 'open',
      mode: 'normal',
    });
  });

  it('blocks only an older version with a confirmed policy', () => {
    assert.deepEqual(decideGate('2.3.9', 'ios', { ok: true, policy }), {
      kind: 'blocked',
      minimumVersion: '2.4.0',
      storeURL: 'https://apps.apple.com/app/id1234567890',
    });
  });

  for (const reason of ['timeout', 'network', 'http', 'invalid-payload'] as const) {
    it(`fails open on ${reason}`, () => {
      assert.deepEqual(decideGate('2.3.9', 'ios', { ok: false, reason }), {
        kind: 'open',
        mode: 'fail-open',
        reason,
      });
    });
  }

  it('fails open on an invalid app version', () => {
    assert.deepEqual(decideGate('2.4', 'ios', { ok: true, policy }), {
      kind: 'open',
      mode: 'fail-open',
      reason: 'invalid-app-version',
    });
  });
});

E2E에서는 실제 스토어를 열지 않고 외부 URL opener를 주입해 다음 경로를 고정한다.

  1. 정책 서버가 응답하지 않아도 제한 시간 뒤 홈으로 들어간다.
  2. 현재 버전이 최소 버전과 같으면 홈으로 들어간다.
  3. 현재 버전이 낮고 정책이 유효할 때만 업데이트 화면에 머문다.
  4. 스토어 열기에 실패해도 다시 시도할 수 있다.

의견: 언제 이 방법을 쓰지 말아야 하는가

  • 보안 패치를 반드시 강제해야 할 때. 예를 들어 알려진 취약점이 있는 버전이 결제나 본인인증을 처리한다면 fail-closed가 맞다. 이때는 차단 화면에 고객센터·재시도 경로를 함께 둔다.
  • 정책 서버가 앱 API와 같은 장애 범위에 있을 때. 정책 서버가 죽을 때 앱 API도 함께 죽는다면, 앱을 열어도 주요 기능을 쓸 수 없다. 그러면 fail-open의 이득도 작다. 이때는 정책을 CDN 같은 정적 경로로 옮겨, 정책 조회를 앱 API 장애와 떼어 놓는 편이 낫다.

다시 한다면 마지막 성공 정책을 짧은 수명으로 캐시해, “정책 확인 실패 + 캐시상 차단 대상”을 별도 상태로 다루겠다. 그리고 fail-open 비율을 대시보드에 올려 정책 서버 장애를 사용자 신고 전에 알 수 있게 하겠다.

부록: 원격 응답과 스토어 URL 검증

TypeScript 타입은 네트워크 응답을 검증하지 않는다. 스토어 URL은 외부 앱 열기로 이어지므로 플랫폼별 공식 host와 path 형식만 허용한다. https:// 접두사 검사만으로는 파싱 실패 문자열, 계정 정보가 들어간 URL, 공식 스토어처럼 보이는 다른 host를 막지 못한다.

검증에는 URL 대신 정규식을 쓴다. 이 앱이 쓰는 React Native 0.79의 내장 URL은 protocol, hostname 같은 getter를 구현하지 않아 읽으면 예외를 던진다. 그 예외는 loadPolicy에서 network로 분류되어 게이트가 차단하지 못한다. Node의 URL은 getter를 모두 구현하므로 Node 테스트로는 이 문제를 잡지 못한다.

const versionPattern = /^\d+\.\d+\.\d+$/;

const allowedStoreHosts: Record<Platform, ReadonlySet<string>> = {
  ios: new Set(['apps.apple.com']),
  android: new Set(['play.google.com']),
};

const storeURLPattern =
  /^https:\/\/([A-Za-z0-9-]+(?:\.[A-Za-z0-9-]+)*)(\/[^?#]*)?(?:\?([^#]*))?(?:#.*)?$/;

function isAllowedStoreURL(
  value: unknown,
  platform: Platform,
): value is string {
  if (typeof value !== 'string') return false;

  const match = storeURLPattern.exec(value);
  if (!match) return false;

  const host = match[1].toLowerCase();
  const path = match[2] ?? '';
  const query = match[3] ?? '';
  const isAllowedHost = Array.from(allowedStoreHosts[platform]).some(
    (allowed) => host === allowed || host.endsWith(`.${allowed}`),
  );
  if (!isAllowedHost) return false;

  switch (platform) {
    case 'ios':
      return /\/id\d+\/?$/.test(path);
    case 'android':
      return (
        path === '/store/apps/details' && /(?:^|&)id=[^&]+/.test(query)
      );
  }
}

function isVersionPolicy(value: unknown): value is VersionPolicy {
  if (typeof value !== 'object' || value === null) return false;

  const candidate = value as Record<string, unknown>;
  const minimum = candidate.minimumVersion as
    | Record<string, unknown>
    | undefined;
  const store = candidate.storeURL as Record<string, unknown> | undefined;

  return Boolean(
    minimum &&
      store &&
      typeof minimum.ios === 'string' &&
      typeof minimum.android === 'string' &&
      versionPattern.test(minimum.ios) &&
      versionPattern.test(minimum.android) &&
      isAllowedStoreURL(store.ios, 'ios') &&
      isAllowedStoreURL(store.android, 'android'),
  );
}

검증 테스트는 정상 URL과 함께 깨진 URL, 계정 정보가 든 URL, 비슷한 host를 넣는다.

describe('isAllowedStoreURL', () => {
  it('accepts only the platform store destination shape', () => {
    assert.equal(
      isAllowedStoreURL('https://apps.apple.com/kr/app/example/id1234567890', 'ios'),
      true,
    );
    assert.equal(
      isAllowedStoreURL(
        'https://play.google.com/store/apps/details?id=com.example.app',
        'android',
      ),
      true,
    );
  });

  for (const value of [
    'https://',
    'https://[bad',
    'https://user:secret@apps.apple.com/app/id1234567890',
    'https://apps.apple.com.evil.example/app/id1234567890',
    'https://apps.apple.com:8443/app/id1234567890',
  ]) {
    it(`rejects an unsafe iOS store URL: ${value}`, () => {
      assert.equal(isAllowedStoreURL(value, 'ios'), false);
    });
  }
});

버전은 major.minor.patch 세 숫자라고 가정한다. prerelease까지 다뤄야 한다면 semver 비교기가 필요하다. 네 자리 버전은 semver 형식이 아니므로 별도 비교 규칙을 정해야 한다.

이 글의 코드와 테스트는 examples/react-native-force-update-fail-open에 있고, CI가 실행한다. 같은 판단을 Swift로 옮긴 VersionGate도 같은 폴더에 있다.