perf(frontend): 배포 버전을 ETag로 재검증한다

This commit is contained in:
2026-08-22 06:04:01 +00:00
parent 3b2b98dc88
commit 8d8ddc5b83
6 changed files with 68 additions and 20 deletions
+3 -2
View File
@@ -85,8 +85,9 @@ Node.js는 `.nvmrc`에서 24.x로 고정합니다. 의존성 설치와 검증에
`package.json``pnpm-lock.yaml`을 함께 사용해 주세요. 모든 workspace package는
내부 전용(`private`)이므로 manifest의 `0.0.0`은 배포 버전이 아닙니다. 배포 source는
full Git commit으로 고정하고, 실험 릴리스 같은 milestone은 annotated Git tag로
식별합니다. Profile frontend는 같은 commit의 정적 버전 문서를 주기적으로 확인하고,
현재 열린 bundle과 달라졌을 때만 강제 reload 없이 공용 toast로 한 번 안내합니다.
식별합니다. Profile frontend는 같은 commit의 정적 버전 문서를 고정 URL과 ETag로
주기적으로 재검증하고, 현재 열린 bundle과 달라졌을 때만 강제 reload 없이 공용
toast로 한 번 안내합니다.
## 개발 환경
@@ -8,8 +8,10 @@ const noticeMessage = '새 버전이 준비되었습니다. 새로고침하면
const installVersionFixture = async (page: Page) => {
let availableCommitSha = currentCommitSha;
let requests = 0;
await page.route('**/deployment-version.json*', async (route) => {
const requestUrls: string[] = [];
await page.route('**/deployment-version.json', async (route) => {
requests += 1;
requestUrls.push(route.request().url());
await route.fulfill({
status: 200,
contentType: 'application/json',
@@ -21,9 +23,46 @@ const installVersionFixture = async (page: Page) => {
availableCommitSha = nextCommitSha;
},
requestCount: () => requests,
requestUrls: () => requestUrls,
};
};
test('revalidates the stable production version URL with the browser-managed ETag', async ({ page }) => {
test.skip(process.env.PLAYWRIGHT_FRONTEND_MODE !== 'production', 'Vite production ETag is required.');
const requests: Array<Promise<{ url: string; ifNoneMatch: string | null }>> = [];
const responseStatuses: number[] = [];
page.on('request', (request) => {
if (!new URL(request.url()).pathname.endsWith('/deployment-version.json')) return;
requests.push(
request.headerValue('if-none-match').then((ifNoneMatch) => ({
url: request.url(),
ifNoneMatch,
}))
);
});
page.on('response', (response) => {
if (!new URL(response.url()).pathname.endsWith('/deployment-version.json')) return;
responseStatuses.push(response.status());
});
await page.goto(gamePath('/version-notice-etag-fixture'));
await expect.poll(() => requests.length).toBeGreaterThan(0);
await expect.poll(() => responseStatuses.length).toBeGreaterThan(0);
const completedResponses = responseStatuses.length;
await page.evaluate(() => window.dispatchEvent(new Event('online')));
await expect.poll(() => requests.length).toBeGreaterThan(1);
await expect.poll(() => responseStatuses.length).toBeGreaterThan(completedResponses);
const requestDetails = await Promise.all(requests);
expect(new Set(requestDetails.map(({ url }) => url))).toEqual(
new Set([new URL('deployment-version.json', page.url()).toString()])
);
expect(requestDetails[0]?.ifNoneMatch).toBeNull();
expect(requestDetails.slice(1).some(({ ifNoneMatch }) => Boolean(ifNoneMatch))).toBe(true);
// Chromium exposes a successfully revalidated cached response to Fetch as the usable 200 response.
expect(responseStatuses.every((status) => status === 200)).toBe(true);
});
for (const viewport of [
{ name: 'desktop', width: 1280, height: 800 },
{ name: 'mobile', width: 390, height: 844 },
@@ -34,6 +73,7 @@ for (const viewport of [
await page.goto(gamePath('/version-notice-fixture'));
await expect(page.getByRole('heading', { name: 'Not Found' })).toBeVisible();
await expect.poll(fixture.requestCount).toBeGreaterThan(0);
expect(fixture.requestUrls().every((url) => new URL(url).search === '')).toBe(true);
await page.evaluate(() => {
Object.assign(window, { __versionNoticePageMarker: 'kept' });
});
@@ -7,7 +7,6 @@ export type DeploymentVersionCheckerOptions = {
versionUrl: string;
fetchVersion?: typeof fetch;
storage?: VersionStorage;
now?: () => number;
onVersionChanged: (availableCommitSha: string) => void;
};
@@ -26,7 +25,6 @@ const notificationStorageKey = (versionUrl: string, availableCommitSha: string):
export const createDeploymentVersionChecker = (options: DeploymentVersionCheckerOptions) => {
const currentCommitSha = options.currentCommitSha.trim().toLowerCase();
const fetchVersion = options.fetchVersion ?? fetch;
const now = options.now ?? Date.now;
let inFlight: Promise<void> | null = null;
let lastNotifiedCommitSha: string | null = null;
@@ -48,10 +46,8 @@ export const createDeploymentVersionChecker = (options: DeploymentVersionChecker
const run = async (): Promise<void> => {
if (!fullCommitShaPattern.test(currentCommitSha)) return;
const separator = options.versionUrl.includes('?') ? '&' : '?';
const response = await fetchVersion(`${options.versionUrl}${separator}t=${now()}`, {
cache: 'no-store',
headers: { 'Cache-Control': 'no-cache' },
const response = await fetchVersion(options.versionUrl, {
cache: 'no-cache',
});
if (!response.ok) return;
const availableCommitSha = parseDeploymentCommitSha(await response.json());
@@ -28,7 +28,7 @@ void test('exposes only the read-only build commit in the deployment version ass
assert.deepEqual(JSON.parse(deploymentVersionAssetSource(currentCommitSha)), { commitSha: currentCommitSha });
});
void test('notifies once per available version and bypasses browser caches', async () => {
void test('notifies once per available version while revalidating one stable URL', async () => {
const requests: Array<{ url: string; init?: RequestInit }> = [];
const stored = new Map<string, string>();
const notifications: string[] = [];
@@ -36,7 +36,6 @@ void test('notifies once per available version and bypasses browser caches', asy
const checker = createDeploymentVersionChecker({
currentCommitSha,
versionUrl: '/che/deployment-version.json',
now: () => 1234,
storage: {
getItem: (key) => stored.get(key) ?? null,
setItem: (key, value) => void stored.set(key, value),
@@ -56,9 +55,18 @@ void test('notifies once per available version and bypasses browser caches', asy
await checker.check();
assert.deepEqual(notifications, [nextCommitSha, laterCommitSha]);
assert.equal(requests.every(({ url }) => url === '/che/deployment-version.json?t=1234'), true);
assert.equal(requests.every(({ init }) => init?.cache === 'no-store'), true);
assert.equal(requests.every(({ init }) => new Headers(init?.headers).get('Cache-Control') === 'no-cache'), true);
assert.equal(
requests.every(({ url }) => url === '/che/deployment-version.json'),
true
);
assert.equal(
requests.every(({ init }) => init?.cache === 'no-cache'),
true
);
assert.equal(
requests.every(({ init }) => init?.headers === undefined),
true
);
});
void test('shares the once-only notice within the current tab session', async () => {
+5 -3
View File
@@ -140,9 +140,11 @@ commit의 cached artifact를 현재 버전으로 오인하지 않습니다. Orch
game frontend는 bundle에 고정된 commit과 이 정적 문서의 commit을 최초 mount,
60초 주기, tab visibility 복귀와 network online 복귀 때 비교합니다. 다른 full SHA를
처음 관찰하면 공용 info toast로 새로고침 안내만 하고 reload를 강제하지 않습니다.
조회에는 cache-busting query와 `cache: no-store`를 사용하며, 실패·잘못된 문서는
현재 화면을 방해하지 않고 무시합니다. 알림 여부는 tab session storage에만 남으므로
Gateway/game DB, Redis, 인증, 턴 처리에는 mutation이 없습니다. Profile `DEPLOY`
조회 URL은 고정하고 fetch `cache: no-cache`로 browser HTTP cache의 ETag를 매번
재검증합니다. 같은 artifact면 본문 없는 `304`, 새 artifact면 최신 JSON `200`
사용하며, 실패·잘못된 문서는 현재 화면을 방해하지 않고 무시합니다. 알림 여부는 tab
session storage에만 남으므로 Gateway/game DB, Redis, 인증, 턴 처리에는 mutation이
없습니다. Profile `DEPLOY`
API·engine·frontend를 같은 commit worktree에서 함께 전환하므로 이 frontend 문서는
해당 profile의 새 backend/frontend release가 readiness를 통과해 실제 서빙되기 시작한
뒤에만 달라집니다.
+4 -3
View File
@@ -141,9 +141,10 @@ path별 Turbo cache에 저장됩니다. Orchestrator는 cache 복원 후 이를
따라서 같은 commit·같은 공개 prefix의 재배포는 `vue-tsc`와 Vite를 다시 실행하지
않고, 여러 instance가 같은 prefix를 쓰더라도 각 runtime target은 따로 materialize됩니다.
각 artifact의 `deployment-version.json`은 bundle과 같은 full commit SHA만 담습니다.
열린 profile 탭은 이 파일을 cache 우회로 확인해 bundle SHA와 달라졌을 때 한 번만
`새 버전이 준비되었습니다. 새로고침하면 변경사항이 반영됩니다.` toast를 표시합니다.
서버는 reload를 강제하지 않으며 이 확인은 정적 GET과 tab session storage만 사용해
열린 profile 탭은 이 파일의 고정 URL을 ETag로 조건부 재검증해 bundle SHA와 달라졌을
때 한 번만 `새 버전이 준비되었습니다. 새로고침하면 변경사항이 반영됩니다.` toast를
표시합니다. 같은 artifact의 응답은 `304`라 본문을 다시 전송하지 않습니다. 서버는
reload를 강제하지 않으며 이 확인은 정적 GET과 tab session storage만 사용해
game/Gateway DB와 Redis에 영향을 주지 않습니다.
`RESET`은 선택 worktree의 Gateway profile-seed CLI도 실행하므로 기존처럼
`gateway-api`까지 server build에 포함하고, seed를 호출하지 않는 `DEPLOY`만 명시적