Logan.

2026-05-18

QA가 staging 빌드에서 OTA를 골라 받는 트릭 Updates URL을 런타임에 갈아 끼우기

QA가 검증하려면 보통 빌드 하나에 OTA 하나가 묶여 있어요. 그 묶임을 풀어, 한 staging 빌드 안에서 QA 브랜치의 어떤 OTA든 골라 받을 수 있게 만든 작은 화면 한 칸의 이야기.

by Logan·10분 읽기·#expo #eas-update #qa #react-native #trick

안녕하세요, 로건입니다 👋

여러분, 이 글은 EAS Update Playbook 시리즈와 나란히 있지만 들어가지 못한 한 토픽이에요. 시리즈 본편이 프로덕션 운영에 대한 글이었다면, 이 글은 그 옆에 있는 QA의 일상에 대한 글이거든요.

OTA를 운영하면서 가장 자주 받은 요청은 이거였어요.

"이 OTA가 staging에서 잘 도는지 검증하고 싶은데, 매번 staging 빌드를 다시 받아야 하나요?"

자연스러운 답은 "네, 그게 모델이에요". 한 빌드에 한 채널이 묶여 있으니까요. 그런데 우리는 그 묶임을 느슨하게 풀어두기로 했어요. staging 빌드 안에 작은 화면 한 칸을 만들어서, QA 브랜치에 올라간 어떤 OTA든 골라 받을 수 있도록.

이 글은 그 한 칸 — easPreviewUpdates.ts — 에 대한 이야기예요.

출발점 — 왜 한 빌드에 한 OTA가 묶이는가

expo-updates의 동작 모델은 단순해요. 빌드 시점에 app.jsonupdates.url을 통째로 굽고, 클라이언트는 그 URL에 대고 내 channel과 runtimeVersion에 맞는 update를 물어요. URL이 빌드에 박혀 있으니, 새 URL로 가려면 새 빌드예요.

이게 프로덕션에서는 좋은 모델이에요. 사용자가 임의의 OTA를 받으면 안 되니까. 그런데 QA에게는 너무 빡빡해요. QA가 한 OTA를 받기 위해 staging 빌드를 통째로 갈아끼우는 건 시간 낭비예요.

트릭의 정체 — URL을 런타임에 override 한다

expo-updates에는 별로 알려져 있지 않은 API가 하나 있어요.

import * as Updates from "expo-updates";
 
Updates.setUpdateURLAndRequestHeadersOverride({
  updateUrl: "<원래와는 다른 manifest URL>",
  requestHeaders: { /* 인증 등 */ },
});

빌드에 박힌 URL을 런타임에 덮어쓰는 API. 이 한 줄이 모든 트릭의 시작이에요. 이게 가능하면:

  • QA 브랜치의 특정 update groupId가 가리키는 manifest URL을 알고
  • 그 URL로 override한 뒤
  • Updates.fetchUpdateAsync()로 다운로드

라는 3단계로 임의의 OTA를 받을 수 있어요.

QA branch라는 별도 채널

먼저 정리할 것 — 우리는 production 채널을 QA가 임의로 만지게 두지 않아요. 이건 우리가 만든 별도의 QA branch에서만 동작합니다.

const getPreviewBranchName = (): string =>
  Config.EXPO_QA_UPDATES_BRANCH?.trim() ||
  (Updates.runtimeVersion ? `qa-${Updates.runtimeVersion}` : "qa");
  • EXPO_QA_UPDATES_BRANCH 환경 변수가 있으면 그걸 사용
  • 없으면 qa-<runtimeVersion> 형태 (예: qa-4.9.6)

이 branch는 production이나 staging과 완전히 분리돼 있어요. 일반 사용자에게는 원리적으로 도달하지 않는 채널이에요. QA만 보는 별도 보관함이라고 생각하면 됩니다.

QA가 가능한 update만 보기 — runtimeVersion 필터

QA가 모든 update를 다 봐서는 안 돼요. 내 staging 빌드와 호환되는 update만 받을 수 있어야 합니다. 1편에서 이야기한 runtimeVersion이 여기서도 가드 역할을 해요.

const platformUpdate = group.find(
  (update) =>
    update.platform.toLowerCase() === currentPlatform &&     // 현재 플랫폼
    update.runtimeVersion === Updates.runtimeVersion,        // 현재 런타임 버전
);
 
if (!platformUpdate?.manifestPermalink) return null;

이 두 조건을 통과한 update만 선택 가능한 후보로 화면에 보여요. 사용자(QA)가 호환되지 않는 OTA를 받아서 앱이 죽는 사고가 원리적으로 일어나지 않아요.

데이터 받아오기 — Expo GraphQL

EAS의 update list는 EAS GraphQL API로 받아와요.

const VIEW_UPDATE_GROUPS_ON_BRANCH_QUERY = `
  query ViewUpdateGroupsOnBranch(
    $appId: String!
    $branchName: String!
    $limit: Int!
    $offset: Int!
  ) {
    app {
      byId(appId: $appId) {
        updateBranchByName(name: $branchName) {
          updateGroups(limit: $limit, offset: $offset) {
            id
            group
            message
            createdAt
            runtimeVersion
            platform
            manifestPermalink
            branch { name }
          }
        }
      }
    }
  }
`;
 
const response = await fetch("https://api.expo.dev/graphql", {
  method: "POST",
  headers: {
    "Content-Type": "application/json",
    Authorization: `Bearer ${expoToken}`,
  },
  body: JSON.stringify({
    query: VIEW_UPDATE_GROUPS_ON_BRANCH_QUERY,
    variables: { appId: projectId, branchName, limit, offset: 0 },
  }),
});

여기 토큰(EXPO_QA_UPDATES_TOKEN)은 staging 빌드에만 박힌 별도 토큰이에요. production 빌드에는 애초에 들어가지 않습니다. QA 권한이 production 자산으로 새지 않게 하는 디자인.

선택 → override → 다운로드

QA가 한 update를 고르면 흐름은 이래요.

export const selectPreviewCandidateAsync = async (
  selection: EasPreviewCandidate,
): Promise<ApplyPreviewResult> => {
  // ① URL을 그 update의 manifest로 override
  Updates.setUpdateURLAndRequestHeadersOverride({
    updateUrl: selection.manifestPermalink,
    requestHeaders: {},
  });
 
  // ② 선택을 디스크에 저장 (앱 재시작 후에도 유지)
  await savePreviewSelectionAsync(selection);
 
  // ③ 새 URL에서 update 받아오기
  return fetchLatestForCurrentTargetAsync();
};

3단계에서 호출되는 함수는 평범한 expo-updates 흐름이에요.

const fetchLatestForCurrentTargetAsync = async () => {
  const updateResult = await Updates.checkForUpdateAsync();
  if (!updateResult.isAvailable) return { status: "no-update" };
 
  await Updates.fetchUpdateAsync();
  return { status: "downloaded" };
};

다운로드까지 끝나면 QA가 Reload 버튼을 한 번 누릅니다.

export const reloadCurrentUpdateAsync = async () =>
  Updates.reloadAsync();

앱이 재시작되면서 새 번들이 적용돼요. QA는 빌드를 다시 받지 않고 임의의 QA-branch update를 눈으로 검증할 수 있어요.

안전 가드 — production 빌드에서는 원리적으로 안 보임

기능을 켜는 단 한 군데의 게이트는 이거예요.

const isPreviewFeatureEnabled = (): boolean =>
  toolbox.isStaging && Updates.isEnabled;
  • toolbox.isStaging: 우리 앱이 staging 빌드임을 자기 인식
  • Updates.isEnabled: expo-updates 자체가 켜진 빌드

이 둘 중 하나라도 false면, GraphQL 호출 함수가 시작 직전에 막아요.

const ensurePreviewSupport = (): string | null => {
  if (!toolbox.isStaging) return "OTA preview is only enabled in the staging app.";
  if (!Updates.isEnabled)  return "expo-updates is not enabled in this build.";
  return null;
};

다시 말하면: production 빌드에는 토큰도 안 들어가 있고, 그 함수가 시작도 못 합니다. 두 겹의 가드가 한 방향으로 박혀 있어요.

reset — 원래 URL로 돌아오기

QA가 검증을 마치면 원래 staging 채널로 돌아와야 해요. 그래서 reset 함수도 같이 둡니다.

export const resetPreviewSelectionAsync = async () => {
  Updates.setUpdateURLAndRequestHeadersOverride(null);   // override 해제
  await clearPreviewSelectionAsync();                    // 디스크 선택 삭제
  return fetchLatestForCurrentTargetAsync();             // 원래 채널에서 다시 fetch
};

이 함수가 있다는 게 디자인적으로 중요해요. override는 끄는 방법까지 같이 만들어야 안전하거든요. 켜진 채로 잊혀진 트릭은 시간이 지나면 왜 이러지? 사고로 돌아옵니다.

닫는 말

easPreviewUpdates.ts는 200줄 정도예요. 시리즈 본편의 4모드·republish·CI 파이프라인에 비하면 훨씬 가벼운 코드지만, 운영상의 마찰을 한 단계 낮추는 효과가 있어요.

핵심 트릭은 한 줄이에요. Updates.setUpdateURLAndRequestHeadersOverride로 manifest URL을 런타임에 갈아 끼운다. 이 한 줄이 가능하다는 걸 알면, 그 위에 QA에게만 보이는 작은 화면 한 칸을 얹는 게 자연스러운 다음 단계예요.

프로덕션을 정직하게 운영하기 위해선 빡빡한 모델이 필요하지만, QA의 일상은 그 모델에서 한 칸만 양보해주면 훨씬 가벼워진다.

OTA를 운영하는 분들께 추천드리는 한 줄이에요. staging 빌드에 작은 한 칸을 두세요. QA의 마찰이 줄어드는 만큼 검증의 빈도가 늘어요. 그게 결국 production의 정직함을 더 보장합니다.

related