안녕하세요, 로건입니다 👋
여러분, 이 글은 EAS Update Playbook 시리즈와 나란히 있지만 들어가지 못한 한 토픽이에요. 시리즈 본편이 프로덕션 운영에 대한 글이었다면, 이 글은 그 옆에 있는 QA의 일상에 대한 글이거든요.
OTA를 운영하면서 가장 자주 받은 요청은 이거였어요.
"이 OTA가 staging에서 잘 도는지 검증하고 싶은데, 매번 staging 빌드를 다시 받아야 하나요?"
자연스러운 답은 "네, 그게 모델이에요". 한 빌드에 한 채널이 묶여 있으니까요. 그런데 우리는 그 묶임을 느슨하게 풀어두기로 했어요. staging 빌드 안에 작은 화면 한 칸을 만들어서, QA 브랜치에 올라간 어떤 OTA든 골라 받을 수 있도록.
이 글은 그 한 칸 — easPreviewUpdates.ts — 에 대한 이야기예요.
출발점 — 왜 한 빌드에 한 OTA가 묶이는가
expo-updates의 동작 모델은 단순해요. 빌드 시점에 app.json의 updates.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의 정직함을 더 보장합니다.