Skip to main content
네이티브
입문네이티브React Native 플랫폼 · 31

React Native 점진 배포: 내부 테스트에서 전체 사용자까지 어떻게 넓히는가

내부 테스트 통과를 전체 사용자 안전으로 확대하는 실패를 재현하고, 각 플랫폼에서 검증한 build의 실제 채택·기기 집단별 신호·관찰 시간을 근거로 스토어 배포를 확대·대기·중단하는 법을 익힌다.

마지막 검증 재검증 정책: 제품 버전 의존: 새 주요 버전마다 재검증
목차
표준·구현·측정·해석 표시는 무엇인가요?
  • 표준웹 표준이나 언어 명세가 정한 동작
  • 구현특정 기술이나 브라우저가 실제로 구현한 동작
  • 측정명시한 환경에서 직접 실행해 관찰한 결과
  • 해석앞선 근거에서 도출한 설계 판단
  • 미확인아직 공식 근거나 재현 결과를 확인하지 못한 내용

30초 요약

내부 테스트는 선택한 사람과 기기에서 build를 실행한 사전 증거다. 점진 배포는 각 플랫폼에서 검증한 동일 산출물을 작은 운영 대상부터 열고 실제 사용자 신호를 확인한 뒤 범위를 넓히는 노출 방식이다. 배포 단계 판정은 미리 정한 자료와 기준으로 확대(expand)·대기(wait)·중단(halt) 중 하나를 고르는 규칙이다.

제품 release ID + Android·Apple 산출물 ID 고정 → 내부·제한 테스트 → 작은 운영 노출
→ 실제 build 채택 확인 → 플랫폼·운영체제·기기·흐름별 신호 판정
→ 확대 / 자료 대기 / 추가 노출 중단

관찰 창은 한 배포 단계에서 자료를 모으기로 정한 시간 범위다. 집단(cohort)은 같은 플랫폼 산출물과 운영체제· 기기·기능 조건으로 묶어 비교할 대상이다. 제품 release ID는 양 플랫폼의 한 출시 의도를 묶고, Android와 Apple의 산출물 ID는 실제로 관찰할 AAB와 Archive build를 각각 가리킨다. 이 글의 반복 문장은 이것이다.

각 플랫폼에서 테스트한 동일 산출물을 작게 열고, 실제 채택과 집단별 신호가 기준을 통과할 때만 넓힌다.

이 글을 관통하는 상황: 내부 테스트가 통과해 곧바로 전체 공개한다

영수증 촬영 release receipt-418의 Android AAB와 Apple Archive build가 30편의 JavaScript·네이티브· E2E·실기기 테스트를 통과했다. Google Play internal test와 TestFlight 내부 그룹에서도 금액 인식과 저장 흐름이 성공했다. 팀은 각 플랫폼에서 검증한 산출물을 전체 사용자에게 공개하려 한다.

하지만 운영 사용자는 테스터보다 다양한 기기·운영체제·카메라 구현을 쓴다. 실제 작은 노출에서는 특정 Android 14 기기 집단의 camera callback이 늦게 도착해 스캔 실패와 crash가 함께 늘었다. 전체 평균만 보면 다른 큰 집단에 희석돼 안전해 보였다.

이 글의 질문은 하나다.

내부 테스트에서 전체 사용자까지 배포 범위를 왜, 어떤 증거로 단계적으로 넓혀야 하는가?

전체 평균만 보는 잘못된 배포 판정을 재현한다

30편에서 만든 React Native 0.86.2 TestLayerApp을 그대로 사용한다. 이 fixture는 스토어에 실제 build를 올리지 않고, 배포 판정 함수와 고정된 운영 자료만 Jest에서 실행한다. 앱 루트에 다음 두 파일을 만든다.

TestLayerApp/
├─ rollout-gate.ts
└─ __tests__/
   └─ rollout-gate.test.ts

rollout-gate.ts를 만든다. 아래의 0.01, 0.02, 500, 100, 24는 Google이나 Apple의 공통 권장값이 아니라 이 fixture가 정한 학습용 제품 정책이다.

이 fixture에서 session은 앱을 실제로 사용한 한 번의 실행 구간, scanner attempt는 영수증 스캔 한 번, scanner failure는 그 시도가 사용자 결과를 만들지 못한 사건이다. integrity failure는 스캔이 끝났지만 금액이 틀리게 저장된 무결성 실패로, 한 건만 확인돼도 중단한다. releaseId는 양 플랫폼의 한 출시 의도를 묶고, 각 cohort의 artifactId는 실제 Android 또는 Apple 산출물을 가리킨다. Android ID는 versionCode와 AAB SHA-256을 함께 쓴다. versionCode는 Android 내부 빌드 번호이고, SHA-256은 AAB 파일 내용이 같은지 비교하는 지문이다. Apple ID의 bundle ID는 앱을 구분하는 문자열, CFBundleShortVersionString은 사용자에게 보이는 앱 버전, CFBundleVersion은 그 버전의 구체적인 빌드 번호다. 정책은 이번 단계에서 반드시 볼 cohort 이름과 플랫폼도 고정한다.

const USE_COHORT_GATES = false
 
export type RolloutDecision = 'expand' | 'wait' | 'halt'
 
export type AndroidArtifactId =
  `android:versionCode=${number}:aabSha256=${string}`
export type AppleArtifactId =
  `ios:bundleId=${string}:CFBundleShortVersionString=${string}:CFBundleVersion=${string}`
 
export type PlatformArtifacts = {
  android: AndroidArtifactId
  ios: AppleArtifactId
}
 
type SharedCohortEvidence = {
  name: string
  sessions: number
  crashes: number
  scannerAttempts: number
  scannerFailures: number
  integrityFailures: number
}
 
export type CohortEvidence =
  | (SharedCohortEvidence & {
      platform: 'android'
      artifactId: AndroidArtifactId
    })
  | (SharedCohortEvidence & {
      platform: 'ios'
      artifactId: AppleArtifactId
    })
 
export type RolloutEvidence = {
  releaseId: string
  expectedArtifacts: PlatformArtifacts
  observationHours: number
  cohorts: CohortEvidence[]
}
 
const POLICY = {
  requiredCohorts: [
    { name: 'android-14-oem-a', platform: 'android' },
    { name: 'android-other', platform: 'android' },
    { name: 'ios-supported', platform: 'ios' },
  ] as const,
  minObservationHours: 24,
  minSessionsPerCohort: 500,
  minScannerAttemptsPerCohort: 100,
  maxCrashRate: 0.01,
  maxScannerFailureRate: 0.02,
}
 
function rate(failures: number, total: number): number {
  return total === 0 ? 0 : failures / total
}
 
export function decideRollout(
  evidence: RolloutEvidence,
): RolloutDecision {
  if (!USE_COHORT_GATES) {
    const totals = evidence.cohorts.reduce(
      (current, cohort) => ({
        sessions: current.sessions + cohort.sessions,
        crashes: current.crashes + cohort.crashes,
        scannerAttempts:
          current.scannerAttempts + cohort.scannerAttempts,
        scannerFailures:
          current.scannerFailures + cohort.scannerFailures,
      }),
      {
        sessions: 0,
        crashes: 0,
        scannerAttempts: 0,
        scannerFailures: 0,
      },
    )
 
    const aggregateFailed =
      rate(totals.crashes, totals.sessions) > POLICY.maxCrashRate ||
      rate(totals.scannerFailures, totals.scannerAttempts) >
        POLICY.maxScannerFailureRate
 
    return aggregateFailed ? 'halt' : 'expand'
  }
 
  const hasKnownHarm = evidence.cohorts.some((cohort) => {
    if (cohort.integrityFailures > 0) {
      return true
    }
 
    const crashFailed =
      cohort.sessions >= POLICY.minSessionsPerCohort &&
      rate(cohort.crashes, cohort.sessions) > POLICY.maxCrashRate
    const scannerFailed =
      cohort.scannerAttempts >= POLICY.minScannerAttemptsPerCohort &&
      rate(cohort.scannerFailures, cohort.scannerAttempts) >
        POLICY.maxScannerFailureRate
 
    return crashFailed || scannerFailed
  })
 
  if (hasKnownHarm) {
    return 'halt'
  }
 
  const allArtifactsMatch = evidence.cohorts.every(
    (cohort) =>
      cohort.artifactId === evidence.expectedArtifacts[cohort.platform],
  )
  const hasEveryRequiredCohort = POLICY.requiredCohorts.every(
    (required) =>
      evidence.cohorts.some(
        (cohort) =>
          cohort.name === required.name &&
          cohort.platform === required.platform,
      ),
  )
  const needsMoreEvidence =
    evidence.cohorts.length === 0 ||
    !allArtifactsMatch ||
    !hasEveryRequiredCohort ||
    evidence.observationHours < POLICY.minObservationHours ||
    evidence.cohorts.some(
      (cohort) =>
        cohort.sessions < POLICY.minSessionsPerCohort ||
        cohort.scannerAttempts < POLICY.minScannerAttemptsPerCohort,
    )
 
  return needsMoreEvidence ? 'wait' : 'expand'
}

__tests__/rollout-gate.test.ts를 만든다.

아래 여덟 사례는 하나의 시간 순서가 아니라 판정 함수에 각각 넣는 독립된 고정 자료 묶음이다. receipt-418은 자료 부족·집단 회귀·무결성 실패·필수 집단 누락·산출물 혼합을 따로 재현한다. receipt-419receipt-418을 중단한 뒤 같은 테스트 계층을 다시 통과한 수정 release가 모든 조건을 충족한 별도 사례다.

import {
  decideRollout,
  type PlatformArtifacts,
  type RolloutDecision,
  type RolloutEvidence,
} from '../rollout-gate'
 
const ARTIFACTS_418: PlatformArtifacts = {
  android:
    'android:versionCode=418:aabSha256=aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa',
  ios:
    'ios:bundleId=com.byteloft.receipt:CFBundleShortVersionString=4.18.0:CFBundleVersion=418',
}
 
const ARTIFACTS_419: PlatformArtifacts = {
  android:
    'android:versionCode=419:aabSha256=bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb',
  ios:
    'ios:bundleId=com.byteloft.receipt:CFBundleShortVersionString=4.19.0:CFBundleVersion=419',
}
 
const CASES: Array<{
  name: string
  evidence: RolloutEvidence
  expected: RolloutDecision
}> = [
  {
    name: '작은 Android 집단에 큰 회귀가 있음',
    evidence: {
      releaseId: 'receipt-418',
      expectedArtifacts: ARTIFACTS_418,
      observationHours: 24,
      cohorts: [
        {
          name: 'android-14-oem-a',
          platform: 'android',
          artifactId: ARTIFACTS_418.android,
          sessions: 800,
          crashes: 24,
          scannerAttempts: 120,
          scannerFailures: 12,
          integrityFailures: 0,
        },
        {
          name: 'android-other',
          platform: 'android',
          artifactId: ARTIFACTS_418.android,
          sessions: 5200,
          crashes: 4,
          scannerAttempts: 800,
          scannerFailures: 2,
          integrityFailures: 0,
        },
        {
          name: 'ios-supported',
          platform: 'ios',
          artifactId: ARTIFACTS_418.ios,
          sessions: 3000,
          crashes: 2,
          scannerAttempts: 500,
          scannerFailures: 1,
          integrityFailures: 0,
        },
      ],
    },
    expected: 'halt',
  },
  {
    name: '자료와 관찰 시간이 부족함',
    evidence: {
      releaseId: 'receipt-418',
      expectedArtifacts: ARTIFACTS_418,
      observationHours: 6,
      cohorts: [
        {
          name: 'android-14-oem-a',
          platform: 'android',
          artifactId: ARTIFACTS_418.android,
          sessions: 50,
          crashes: 0,
          scannerAttempts: 20,
          scannerFailures: 0,
          integrityFailures: 0,
        },
        {
          name: 'android-other',
          platform: 'android',
          artifactId: ARTIFACTS_418.android,
          sessions: 50,
          crashes: 0,
          scannerAttempts: 20,
          scannerFailures: 0,
          integrityFailures: 0,
        },
        {
          name: 'ios-supported',
          platform: 'ios',
          artifactId: ARTIFACTS_418.ios,
          sessions: 50,
          crashes: 0,
          scannerAttempts: 20,
          scannerFailures: 0,
          integrityFailures: 0,
        },
      ],
    },
    expected: 'wait',
  },
  {
    name: '적은 표본에서 금액 무결성 실패가 확인됨',
    evidence: {
      releaseId: 'receipt-418',
      expectedArtifacts: ARTIFACTS_418,
      observationHours: 1,
      cohorts: [
        {
          name: 'android-14-oem-a',
          platform: 'android',
          artifactId: ARTIFACTS_418.android,
          sessions: 10,
          crashes: 0,
          scannerAttempts: 2,
          scannerFailures: 0,
          integrityFailures: 1,
        },
      ],
    },
    expected: 'halt',
  },
  {
    name: '집단 자료가 비어 있음',
    evidence: {
      releaseId: 'receipt-418',
      expectedArtifacts: ARTIFACTS_418,
      observationHours: 24,
      cohorts: [],
    },
    expected: 'wait',
  },
  {
    name: 'iOS 필수 집단이 누락됨',
    evidence: {
      releaseId: 'receipt-418',
      expectedArtifacts: ARTIFACTS_418,
      observationHours: 24,
      cohorts: [
        {
          name: 'android-14-oem-a',
          platform: 'android',
          artifactId: ARTIFACTS_418.android,
          sessions: 1000,
          crashes: 1,
          scannerAttempts: 200,
          scannerFailures: 1,
          integrityFailures: 0,
        },
        {
          name: 'android-other',
          platform: 'android',
          artifactId: ARTIFACTS_418.android,
          sessions: 1000,
          crashes: 1,
          scannerAttempts: 200,
          scannerFailures: 1,
          integrityFailures: 0,
        },
      ],
    },
    expected: 'wait',
  },
  {
    name: '필수 Android 기기 집단이 누락됨',
    evidence: {
      releaseId: 'receipt-418',
      expectedArtifacts: ARTIFACTS_418,
      observationHours: 24,
      cohorts: [
        {
          name: 'android-other',
          platform: 'android',
          artifactId: ARTIFACTS_418.android,
          sessions: 1000,
          crashes: 1,
          scannerAttempts: 200,
          scannerFailures: 1,
          integrityFailures: 0,
        },
        {
          name: 'ios-supported',
          platform: 'ios',
          artifactId: ARTIFACTS_418.ios,
          sessions: 1000,
          crashes: 1,
          scannerAttempts: 200,
          scannerFailures: 1,
          integrityFailures: 0,
        },
      ],
    },
    expected: 'wait',
  },
  {
    name: '다른 Android 산출물 자료가 섞임',
    evidence: {
      releaseId: 'receipt-418',
      expectedArtifacts: ARTIFACTS_418,
      observationHours: 24,
      cohorts: [
        {
          name: 'android-14-oem-a',
          platform: 'android',
          artifactId: ARTIFACTS_419.android,
          sessions: 1000,
          crashes: 1,
          scannerAttempts: 200,
          scannerFailures: 1,
          integrityFailures: 0,
        },
        {
          name: 'android-other',
          platform: 'android',
          artifactId: ARTIFACTS_418.android,
          sessions: 1000,
          crashes: 1,
          scannerAttempts: 200,
          scannerFailures: 1,
          integrityFailures: 0,
        },
        {
          name: 'ios-supported',
          platform: 'ios',
          artifactId: ARTIFACTS_418.ios,
          sessions: 1000,
          crashes: 1,
          scannerAttempts: 200,
          scannerFailures: 1,
          integrityFailures: 0,
        },
      ],
    },
    expected: 'wait',
  },
  {
    name: '모든 필수 집단이 같은 산출물의 자료와 기준을 충족함',
    evidence: {
      releaseId: 'receipt-419',
      expectedArtifacts: ARTIFACTS_419,
      observationHours: 24,
      cohorts: [
        {
          name: 'android-14-oem-a',
          platform: 'android',
          artifactId: ARTIFACTS_419.android,
          sessions: 1000,
          crashes: 1,
          scannerAttempts: 200,
          scannerFailures: 1,
          integrityFailures: 0,
        },
        {
          name: 'android-other',
          platform: 'android',
          artifactId: ARTIFACTS_419.android,
          sessions: 1000,
          crashes: 1,
          scannerAttempts: 200,
          scannerFailures: 1,
          integrityFailures: 0,
        },
        {
          name: 'ios-supported',
          platform: 'ios',
          artifactId: ARTIFACTS_419.ios,
          sessions: 1000,
          crashes: 1,
          scannerAttempts: 200,
          scannerFailures: 1,
          integrityFailures: 0,
        },
      ],
    },
    expected: 'expand',
  },
]
 
describe.each(CASES)('$name', ({ evidence, expected }) => {
  test(`배포 판단은 ${expected}`, () => {
    expect(decideRollout(evidence)).toBe(expected)
  })
})

실행 전에 예측한다.

  1. 특정 Android 집단의 crash 비율 3%, 스캔 실패 비율 10%는 전체 평균에서 보일까?
  2. 오류 0건이지만 6시간·집단별 50 session이면 wait가 될까?
  3. 적은 표본이라도 금액 무결성 실패 1건이면 halt가 될까?
  4. 집단 배열, iOS 자료 또는 필수 Android 기기 집단이 누락되면 wait가 될까?
  5. 다른 Android AAB의 자료가 섞이면 확대하지 않고 wait가 될까?
  6. 같은 플랫폼 산출물의 자료와 기준을 충족한 수정 release receipt-419expand가 될까?

앱 루트에서 이 파일만 실행한다.

npm test -- --runInBand __tests__/rollout-gate.test.ts

전체 평균만 보는 기준선에서는 일곱 사례가 실패하고 정상 receipt-419만 통과해야 한다.

Test Suites: 1 failed, 1 total
Tests:       7 failed, 1 passed, 8 total

이 결과는 Google Play나 App Store 배포를 실행한 증거가 아니다. 전체 평균과 오류 0건만으로 확대하는 판정 정책이 작은 집단의 회귀, 자료 부족과 절대 중단 조건을 구분하지 못한다는 가상 증거다.

내부 테스트는 운영 노출의 사전 증거다

beta build는 전체 공개 전에 tester에게 배포하는 시험용 build다.

이 숫자는 2026-08-09에 확인한 현재 제품 한계이며 테스트 품질 점수나 반드시 채워야 할 일반 목표가 아니다. 테스터 수보다 “어떤 위험을 어느 기기와 흐름에서 실제로 실행했는가?”가 먼저다.

30편의 테스트 계층은 다음 실패를 배포 전에 빠르게 막는다.

JavaScript test       금액 계산과 상태 전이
native integration    권한 callback과 플랫폼 module 연결
E2E                   설치 앱의 촬영→금액 검토 흐름
physical device       실제 렌즈 초점과 제조사 camera 구현

그러나 내부 tester는 실제 사용자 분포 전체가 아니다. 여러 실제·가상 기기를 원격으로 자동 실행하는 시험 환경인 기기 farm, 장시간 테스트와 부하 테스트에서도 이런 위험을 미리 찾을 수 있지만, 내부 표본에서 충분히 드러나지 않았던 기기 조합·사용 시간·서버 부하·실제 update 채택의 회귀가 작은 운영 노출에서 새로 보일 수 있다. 점진 배포는 테스트를 건너뛰는 대신이 아니라, 각 플랫폼에서 검증한 동일 산출물을 실제 운영 분포에서 제한적으로 관찰하는 다음 증거다.

전체 평균은 작은 기기 집단의 회귀를 숨긴다

fixture의 첫 사례를 전체로 합치면 다음처럼 보인다.

전체 session        9,000
전체 crash             30  → 0.33%
전체 scanner attempt 1,420
전체 scanner failure    15  → 1.06%

학습용 전체 기준 1%·2% 아래라 expand로 보인다. 그러나 집단을 나누면 결과가 달라진다.

android-14-oem-a  crash 24 / 800 = 3%
                  scanner failure 12 / 120 = 10%

23편의 변경 조합 분리와 28편의 오류 분류를 배포 자료에 이어 붙인다. 최소한 다음 축을 제한된 이름으로 기록한다.

release ID + platform artifact ID + 배포 단계 + OS version + device group + 핵심 flow

사용자 ID처럼 값이 끝없이 늘어나는 항목을 지표 이름에 넣지 않는다. 제한된 집단은 지표에서 비교하고, 개별 오류 재현은 28편의 incident와 25~27편 stack 산출물로 연결한다.

자료가 부족하면 성공이 아니라 대기다

오류가 0건이라는 문장은 분모와 관찰 시간을 함께 적어야 한다.

0 crash / 50 session / 6시간
0 crash / 50,000 session / 24시간

두 결과의 증거 강도는 같지 않다. 사용자가 아직 update를 설치하지 않았거나 특정 흐름을 실행하지 않았고, 스토어 자료가 privacy threshold(아주 적은 수량을 개인과 연결하지 않도록 결과에서 숨기는 표시 기준) 아래라 보이지 않을 수도 있다. 그래서 배포 판정에는 expandhalt 사이에 wait를 둔다.

halt    알려진 중대한 피해나 충분한 자료에서 기준 초과
wait    관찰 시간·실제 채택·집단별 사건 수가 부족하거나 자료가 모호함
expand  필요한 자료가 있고 모든 사전 기준을 통과

최소 session, 핵심 흐름 사건 수와 시간은 앱의 사용 빈도·피해 크기·시간대에 따라 제품이 정한다. fixture의 숫자를 다른 앱의 공식 안전 기준처럼 복사하지 않는다. 자료 손상·결제 오류·보안 노출처럼 한 건도 큰 피해는 비율을 기다리지 않고 즉시 중단하는 별도 절대 기준으로 둔다.

스토어마다 퍼센트가 제어하는 대상이 다르다

eligible user 또는 eligible device는 국가·스토어 계정·지원 운영체제 같은 조건상 해당 update를 받을 수 있는 사용자 또는 기기다. 퍼센트는 이 조건을 만족한 전체 대상 안에서 적용된다.

구분Google Play staged rolloutApp Store phased release
대상app updateapp version update
비율 진행운영자가 직접 증가자동 update 대상이 7일 일정으로 증가
대상 선택새 release마다 무작위 eligible user자동 update가 켜진 eligible device를 사용하는 사용자의 무작위 표본
중단추가 대상 전달을 halt일정 진행을 pause
중요한 경계이미 받은 사용자는 version에 남음수동 download는 언제든 가능

따라서 스토어 화면의 5%는 “현재 session의 정확히 5%가 이 build를 실행한다”는 뜻이 아니다. 실제 앱 시작과 핵심 흐름에서 플랫폼 산출물 ID별 채택·session·성공·오류를 따로 확인해야 한다.

중단은 추가 노출을 막지만 이미 설치한 build를 복구하지 않는다

문제를 발견했을 때 행동을 다음처럼 분리한다.

1. 추가 노출 중단
2. 영향 build·집단·핵심 흐름 확인
3. 서버 차단·기능 비활성화처럼 가능한 즉시 완화
4. 안전한 수정 build를 같은 테스트 계층으로 검증
5. 새 store update를 다시 작은 범위부터 배포
6. 이미 영향받은 사용자의 실제 회복 확인

사용자 자료 형식이 바뀌었다면 10편의 migration 호환성도 다시 본다. 단순히 과거 소스를 build하거나 중단 버튼을 눌렀다는 사실만으로 설치 앱·로컬 자료·서버 상태가 돌아오지 않는다.

플랫폼별 산출물과 판정 계약을 보존한다

배포 기록은 퍼센트 변경만 남기지 않는다.

identity        제품 release ID / Android versionCode·AAB SHA-256 / Apple bundle ID·app version·build number
stage           internal / closed·external / production percentage·day
window          시작·종료 시간과 시간대
adoption        실제 설치·사용한 기기·session과 핵심 흐름 실행 수
cohorts         플랫폼·운영체제·기기 집단·지역·기능 흐름
signals         crash·ANR·앱 시작·스캔 완료·금액 무결성 실패
decision        expand / wait / halt와 근거
owner           다음 확인 시점, 중단·완화·수정 build 담당자

Android App Bundle(AAB, Google Play에 올리는 Android 앱 코드·자원 묶음)과 Apple Archive에서 검증한 build를 각각 식별하고, 두 값을 제품 release ID로 묶는다. 같은 source commit을 다시 build한 파일은 서명·도구· 의존성이 달라질 수 있으므로 같은 산출물이라고 가정하지 않는다. 11편의 호환성 matrix와 23편의 조합 결과도 해당 플랫폼 산출물 ID에 연결한다.

집단별 판정으로 fixture를 교정한다

rollout-gate.ts의 상수를 바꾼다.

const USE_COHORT_GATES = true

같은 test를 다시 실행한다.

npm test -- --runInBand __tests__/rollout-gate.test.ts
PASS __tests__/rollout-gate.test.ts
Test Suites: 1 passed, 1 total
Tests:       8 passed, 8 total

교정 뒤 세 상태가 모두 관찰된다.

receipt-418 / android-14-oem-a 기준 초과 → halt
receipt-418 / 6시간·집단별 50 session     → wait
receipt-418 / 금액 무결성 실패 1건         → halt
receipt-418 / 빈 자료·필수 집단 누락         → wait
receipt-418 / 다른 Android 산출물 혼합       → wait
receipt-419 / 동일 산출물·필수 집단 정상     → expand

fixture가 증명한 것은 고정 입력에서 판정 함수가 세 상태를 구분한다는 사실뿐이다. 실제 store build 업로드, tester 설치, staged/phased delivery, 지표 수집 지연, 중단과 사용자 회복은 제품 통합에서 별도로 검증한다.

pull request와 release 기록에서 달라져야 할 행동

출시 관련 pull request에는 “테스트 통과” 다음 단계가 보여야 한다.

build identity     실제 올릴 Android·Apple 산출물의 불변 ID
test evidence      30편의 JavaScript / native / E2E / physical device 결과
first exposure     플랫폼별 첫 운영 대상과 최대 영향 범위
gate policy        최소 시간·사건 수·집단·절대 중단 기준
observability      28편 신호와 build·platform·OS·device·flow 연결
store action       Play increase·halt / App Store day·pause
recovery           이미 설치한 사용자 완화와 수정 build 계획
decision log       시간·자료 범위·결정·담당자

검토자는 다음 질문을 실제 증거로 닫는다.

  • 내부 tester와 운영 사용자의 기기·운영체제·핵심 흐름 차이는 무엇인가?
  • 설정 비율이 아니라 실제 build 채택과 session을 확인했는가?
  • 전체 평균뿐 아니라 작은 기기·운영체제 집단의 회귀를 봤는가?
  • 자료 없음과 오류 0건을 구분하고 wait 상태를 두었는가?
  • 확대 전에 관찰 시간·사건 수·절대 중단 조건을 썼는가?
  • halt·pause를 이미 설치한 사용자의 복구 완료로 확대하지 않았는가?
  • Android와 Apple의 서로 다른 퍼센트·중단 계약을 같은 것으로 합치지 않았는가?

한 장으로 다시 보기

문제       내부 test 통과와 건강한 전체 평균만 보고 전체 공개
숨은 실패  특정 Android 14 기기 집단의 crash·scanner 실패가 평균에 희석
사전 증거  JavaScript → native integration → E2E → 필요한 physical device
운영 증거  제품 release ID에 묶인 플랫폼별 산출물의 실제 채택·관찰 시간·집단별 신호
판정       피해 확인=halt / 자료 부족=wait / 모든 기준 통과=expand
Play       update 비율을 운영자가 증가, halt 뒤 기존 수신자는 version 유지
Apple      자동 update를 7일 단계 확대, pause 가능, 수동 download는 계속 가능
복구       중단 ≠ 설치 build 제거 ≠ 사용자 회복 완료

점진 배포는 테스트가 놓친 문제를 운에 맡기는 과정이 아니다. 각 플랫폼에서 검증한 동일 산출물의 영향 범위를 제한하고, 실제 사용자 분포에서 얻은 자료를 다음 확대 결정에 연결하는 과정이다. 마지막 단계까지 expand만 있는 계획은 판정이 아니다. waithalt, 이미 노출된 사용자의 회복 행동까지 있어야 내부 테스트의 확신을 전체 사용자에게 안전하게 넓힐 수 있다.

스스로 확인할 질문

  1. 내부 테스트 통과가 전체 사용자 환경을 증명하지 못하는 이유는 무엇인가?
  2. 설정한 store rollout 비율과 실제 실행 중인 build 비율이 다른 이유는 무엇인가?
  3. 전체 평균이 특정 기기 집단의 큰 회귀를 숨기는 과정을 fixture 숫자로 설명할 수 있는가?
  4. 오류 0건인데도 wait해야 하는 조건은 무엇인가?
  5. Google Play staged rollout과 App Store phased release의 비율·중단 계약은 어떻게 다른가?
  6. halt·pause가 이미 설치한 사용자의 복구 완료가 아닌 이유는 무엇인가?
  7. release 기록에 플랫폼 산출물 ID·집단·관찰 창·판정 근거를 함께 남겨야 하는 이유는 무엇인가?

Active recall

기억에서 꺼내 보기

답을 쓰지 않아도 됩니다. 먼저 머릿속으로 답한 뒤 펼쳐서 정답·이유·흔한 오해를 비교하세요.

  1. 01Google Play internal test와 TestFlight 내부 테스트가 모두 통과하면 각 플랫폼의 검증 build를 곧바로 전체 사용자에게 열어도 될까?

    정답

    그 결과는 선택한 테스터·기기·흐름의 증거다. 실제 운영 분포와 드문 기기 조합까지 대표했다고 확인하지 않았다면 작은 운영 노출에서 신호를 다시 봐야 한다.

    왜 그런가

    사전 테스트와 운영 점진 배포는 서로 대체하는 단계가 아니라 다른 대상을 관찰하는 증거다.

    관련 설명 다시 읽기
    지금 어느 정도 기억났나요?

    선택한 상태와 다음 복습일은 이 브라우저에만 저장됩니다.

  2. 02Google Play rollout을 5%로 설정하거나 App Store phased release 1일 차가 되면 현재 앱 세션도 정확히 그 비율만큼 새 build를 실행할까?

    정답

    아니다. 스토어 설정은 새 update를 받을 대상을 제어하지만 설치 시점과 자동 update 여부가 다르고, Apple에서는 누구나 수동으로 update를 받을 수 있다.

    왜 그런가

    설정한 배포 비율과 실제 실행 중인 build 비율은 별도 상태다.

    관련 설명 다시 읽기
    지금 어느 정도 기억났나요?

    선택한 상태와 다음 복습일은 이 브라우저에만 저장됩니다.

  3. 03새 build의 전체 crash 비율이 기준 안이면 특정 Android 버전·기기 집단의 큰 회귀도 없다고 볼 수 있을까?

    정답

    아니다. 작은 집단의 큰 실패가 전체 평균에서 희석될 수 있으므로 build와 플랫폼·운영체제·기기·핵심 흐름으로 나눈 자료를 함께 봐야 한다.

    왜 그런가

    전체 값은 영향 규모를 보여 주지만 어느 사용자 조건에 집중된 피해인지 숨길 수 있다.

    관련 설명 다시 읽기
    지금 어느 정도 기억났나요?

    선택한 상태와 다음 복습일은 이 브라우저에만 저장됩니다.

  4. 04작은 단계에서 crash와 ANR이 0건인데 설치·세션 자료도 거의 없다면 확대해도 될까?

    정답

    아니다. 실제 채택과 관찰 사건이 부족할 수 있으므로 자료 없음은 성공이 아니라 대기 상태로 둔다.

    왜 그런가

    스토어 품질 자료에는 opt-in과 낮은 수량의 표시 제한이 있어 0과 미수집을 구분해야 한다.

    관련 설명 다시 읽기
    지금 어느 정도 기억났나요?

    선택한 상태와 다음 복습일은 이 브라우저에만 저장됩니다.

  5. 05staged rollout이나 phased release를 멈추면 이미 새 build를 설치한 사용자도 이전 build로 돌아갈까?

    정답

    아니다. 중단은 추가 자동 전달을 제한하지만 이미 설치한 build를 자동 제거하지 않는다. 영향을 받은 사용자의 복구 경로는 별도로 마련하고 확인해야 한다.

    왜 그런가

    신규 노출 제어와 설치된 클라이언트 복구는 서로 다른 상태 전이다.

    관련 설명 다시 읽기
    지금 어느 정도 기억났나요?

    선택한 상태와 다음 복습일은 이 브라우저에만 저장됩니다.

출처와 검증 범위

아래 날짜는 링크를 마지막으로 열어 본 날입니다. 문서 상단의 검증일은 글의 설명과 적용 범위를 다시 확인한 날입니다.

  1. Set up an open, closed, or internal testGoogle Play Console Help · 공식 문서 · 확인 2026-08-09
  2. Release app updates with staged rolloutsGoogle Play Console Help · 공식 문서 · 확인 2026-08-09
  3. Review your app's data per releaseGoogle Play Console Help · 공식 문서 · 확인 2026-08-09
  4. Monitor your app's technical quality with Android vitalsGoogle Play Console Help · 공식 문서 · 확인 2026-08-09
  5. TestFlight overviewApple Developer · 공식 문서 · 확인 2026-08-09
  6. Release a version update in phasesApple Developer · 공식 문서 · 확인 2026-08-09
  7. App usageApple Developer · 공식 문서 · 확인 2026-08-09
  8. Download Analytics ReportsApple Developer · 공식 문서 · 확인 2026-08-09
이 문서의 마지막까지 읽었습니다.