feat: 런타임 메뉴 응답을 한 시간 캐시

공개 navigation REST 응답에 content ETag와 1시간 fresh cache를 적용한다. 만료 뒤 조건부 요청은 내용이 같으면 304를 반환하고 운영 JSON이 바뀌면 새 본문과 ETag를 제공한다.
This commit is contained in:
2026-08-19 12:08:01 +00:00
parent a7c3da2fef
commit 17a3072ba7
5 changed files with 161 additions and 12 deletions
@@ -1,9 +1,14 @@
import fs from 'node:fs/promises'; import fs from 'node:fs/promises';
import { createHash } from 'node:crypto';
import type { RuntimeNavigationConfig } from '@sammo-ts/common/navigation/menuConfig'; import type { RuntimeNavigationConfig } from '@sammo-ts/common/navigation/menuConfig';
import { z } from 'zod'; import { z } from 'zod';
const zId = z.string().min(1).max(80).regex(/^[a-z0-9][a-z0-9-]*$/u); const zId = z
.string()
.min(1)
.max(80)
.regex(/^[a-z0-9][a-z0-9-]*$/u);
const zLabel = z.string().min(1).max(80); const zLabel = z.string().min(1).max(80);
const zInternalPath = z const zInternalPath = z
.string() .string()
@@ -98,6 +103,10 @@ export class RuntimeNavigationConfigStore {
) {} ) {}
async get(): Promise<RuntimeNavigationConfig> { async get(): Promise<RuntimeNavigationConfig> {
return (await this.getWithEtag()).config;
}
async getWithEtag(): Promise<{ config: RuntimeNavigationConfig; etag: string }> {
const configPath = await this.resolveConfigPath(); const configPath = await this.resolveConfigPath();
let raw: unknown; let raw: unknown;
try { try {
@@ -109,7 +118,10 @@ export class RuntimeNavigationConfigStore {
if (!parsed.success) { if (!parsed.success) {
throw new Error(`메뉴 설정 파일이 올바르지 않습니다: ${configPath}: ${parsed.error.message}`); throw new Error(`메뉴 설정 파일이 올바르지 않습니다: ${configPath}: ${parsed.error.message}`);
} }
return parsed.data; return {
config: parsed.data,
etag: `"${createHash('sha256').update(JSON.stringify(parsed.data)).digest('hex')}"`,
};
} }
private async resolveConfigPath(): Promise<string> { private async resolveConfigPath(): Promise<string> {
@@ -0,0 +1,28 @@
import type { FastifyInstance } from 'fastify';
import type { RuntimeNavigationConfigStore } from './runtimeNavigationConfig.js';
export const runtimeNavigationCacheControl = 'public, max-age=3600, must-revalidate';
export const matchesIfNoneMatch = (header: string | undefined, etag: string): boolean => {
if (!header) return false;
return header.split(',').some((candidate) => {
const value = candidate.trim();
return value === '*' || value.replace(/^W\//u, '') === etag;
});
};
export const registerRuntimeNavigationRoute = (
app: FastifyInstance,
navigationConfig: RuntimeNavigationConfigStore
): void => {
app.get('/navigation', async (request, reply) => {
const current = await navigationConfig.getWithEtag();
void reply.header('Cache-Control', runtimeNavigationCacheControl);
void reply.header('ETag', current.etag);
if (matchesIfNoneMatch(request.headers['if-none-match'], current.etag)) {
return reply.code(304).send();
}
return current.config;
});
};
+2 -4
View File
@@ -32,6 +32,7 @@ import { installGatewayShutdownController } from './lifecycle/shutdownController
import { RemoteUserIconStore } from './account/remoteUserIconStore.js'; import { RemoteUserIconStore } from './account/remoteUserIconStore.js';
import { gatewayFastifyRouterOptions } from './fastifyOptions.js'; import { gatewayFastifyRouterOptions } from './fastifyOptions.js';
import { RuntimeNavigationConfigStore } from './navigation/runtimeNavigationConfig.js'; import { RuntimeNavigationConfigStore } from './navigation/runtimeNavigationConfig.js';
import { registerRuntimeNavigationRoute } from './navigation/runtimeNavigationRoute.js';
export const createGatewayApiServer = async () => { export const createGatewayApiServer = async () => {
const config = resolveGatewayApiConfigFromEnv(); const config = resolveGatewayApiConfigFromEnv();
@@ -109,10 +110,7 @@ export const createGatewayApiServer = async () => {
profiles, profiles,
secret: config.gameTokenSecret, secret: config.gameTokenSecret,
}); });
app.get('/navigation', async (_request, reply) => { registerRuntimeNavigationRoute(app, navigationConfig);
void reply.header('Cache-Control', 'no-store');
return navigationConfig.get();
});
await app.register(fastifyTRPCPlugin, { await app.register(fastifyTRPCPlugin, {
prefix: config.trpcPath, prefix: config.trpcPath,
@@ -0,0 +1,102 @@
import fs from 'node:fs/promises';
import os from 'node:os';
import path from 'node:path';
import fastify from 'fastify';
import { afterEach, describe, expect, it } from 'vitest';
import { RuntimeNavigationConfigStore } from '../src/navigation/runtimeNavigationConfig.js';
import {
matchesIfNoneMatch,
registerRuntimeNavigationRoute,
runtimeNavigationCacheControl,
} from '../src/navigation/runtimeNavigationRoute.js';
const temporaryDirectories: string[] = [];
afterEach(async () => {
await Promise.all(temporaryDirectories.splice(0).map((directory) => fs.rm(directory, { recursive: true })));
});
const createStore = async (): Promise<{
store: RuntimeNavigationConfigStore;
overridePath: string;
raw: { gateway: { items: Array<{ label: string }> } };
}> => {
const directory = await fs.mkdtemp(path.join(os.tmpdir(), 'sammo-navigation-route-'));
temporaryDirectories.push(directory);
const overridePath = path.join(directory, 'navigation.json');
const defaultPath = path.resolve(import.meta.dirname, '../../../resources/navigation.json');
const raw = JSON.parse(await fs.readFile(defaultPath, 'utf8')) as {
gateway: { items: Array<{ label: string }> };
};
await fs.writeFile(overridePath, JSON.stringify(raw));
return { store: new RuntimeNavigationConfigStore(overridePath, defaultPath), overridePath, raw };
};
describe('runtime navigation HTTP cache', () => {
it('한 시간 fresh cache와 content ETag를 제공한다', async () => {
const { store } = await createStore();
const app = fastify();
registerRuntimeNavigationRoute(app, store);
const response = await app.inject({ method: 'GET', url: '/navigation' });
expect(response.statusCode).toBe(200);
expect(response.headers['cache-control']).toBe(runtimeNavigationCacheControl);
expect(response.headers.etag).toMatch(/^"[a-f0-9]{64}"$/u);
expect(response.json<{ gateway: { items: Array<{ label: string }> } }>().gateway.items[0]?.label).toBe(
'공지사항'
);
await app.close();
});
it('freshness 만료 후 같은 ETag이면 본문 없이 304를 반환한다', async () => {
const { store } = await createStore();
const app = fastify();
registerRuntimeNavigationRoute(app, store);
const first = await app.inject({ method: 'GET', url: '/navigation' });
const response = await app.inject({
method: 'GET',
url: '/navigation',
headers: { 'if-none-match': `"other", W/${first.headers.etag}` },
});
expect(response.statusCode).toBe(304);
expect(response.body).toBe('');
expect(response.headers['cache-control']).toBe(runtimeNavigationCacheControl);
expect(response.headers.etag).toBe(first.headers.etag);
await app.close();
});
it('운영 JSON 내용이 바뀌면 새 ETag와 본문을 반환한다', async () => {
const { store, overridePath, raw } = await createStore();
const app = fastify();
registerRuntimeNavigationRoute(app, store);
const first = await app.inject({ method: 'GET', url: '/navigation' });
raw.gateway.items[0]!.label = '운영 공지';
await fs.writeFile(overridePath, JSON.stringify(raw));
const response = await app.inject({
method: 'GET',
url: '/navigation',
headers: { 'if-none-match': first.headers.etag },
});
expect(response.statusCode).toBe(200);
expect(response.headers.etag).not.toBe(first.headers.etag);
expect(response.json<{ gateway: { items: Array<{ label: string }> } }>().gateway.items[0]?.label).toBe(
'운영 공지'
);
await app.close();
});
});
describe('matchesIfNoneMatch', () => {
it('wildcard와 weak validator를 GET 비교에서 허용한다', () => {
expect(matchesIfNoneMatch('*', '"etag"')).toBe(true);
expect(matchesIfNoneMatch('W/"etag"', '"etag"')).toBe(true);
expect(matchesIfNoneMatch('"other"', '"etag"')).toBe(false);
});
});
+15 -6
View File
@@ -8,10 +8,18 @@ Gateway 상단 메뉴와 profile 게임 화면의 공통 메뉴는 하나의 JSO
## 반영 경계 ## 반영 경계
`GET /gateway/api/navigation``navigation.get`은 인증 없이 현재 파일을 요청마다 `GET /gateway/api/navigation``navigation.get`은 인증 없이 현재 파일을 요청마다
읽고 schema를 검증합니다. 읽고 schema를 검증합니다. REST 응답은 `Cache-Control: public, max-age=3600,
Gateway와 게임 frontend는 화면을 처음 열 때 이를 조회하므로 JSON을 저장한 뒤 must-revalidate`와 JSON 내용 기반 `ETag`를 제공합니다. 브라우저와 공유 캐시는 한
브라우저를 새로고침하면 frontend 재빌드나 profile DB 초기화 없이 반영됩니다. 시간 동안 저장된 응답을 재사용하고, 만료 뒤에는 조건부 요청으로 변경 여부를
이미 열린 화면을 서버가 강제로 바꾸지는 않습니다. 확인하여 같으면 `304 Not Modified`를 받습니다. `immutable`이나
`stale-while-revalidate`는 사용하지 않아 stale 응답의 허용 범위를 한 시간보다
늘리지 않습니다. tRPC `navigation.get`에는 이 HTTP cache 계약을 적용하지 않습니다.
Gateway와 게임 frontend는 화면을 처음 열 때 REST API를 조회합니다. 운영 JSON을
저장한 뒤 일반 새로고침에서 보이는 메뉴는 캐시 때문에 최대 한 시간 이전 값일 수
있으며, 캐시를 우회하는 강력 새로고침은 즉시 재검증할 수 있습니다. frontend
재빌드나 profile DB 초기화는 필요하지 않고, 이미 열린 화면을 서버가 강제로
바꾸지는 않습니다.
운영 파일이 아직 없으면 저장소 기본값을 사용합니다. Docker entrypoint는 최초 운영 파일이 아직 없으면 저장소 기본값을 사용합니다. Docker entrypoint는 최초
기동 때만 저장소 기본값을 영속 경로로 복사하고, 이미 존재하는 운영 파일은 배포나 기동 때만 저장소 기본값을 영속 경로로 복사하고, 이미 존재하는 운영 파일은 배포나
@@ -42,8 +50,9 @@ API는 오류를 반환하고 frontend는 빌드에 포함된 안전한 기본
1. `/srv/data/navigation.json`을 별도 위치에 복사해 되돌릴 파일을 확보합니다. 1. `/srv/data/navigation.json`을 별도 위치에 복사해 되돌릴 파일을 확보합니다.
2. 임시 파일에서 편집하고 `jq empty`로 JSON 문법을 확인합니다. 2. 임시 파일에서 편집하고 `jq empty`로 JSON 문법을 확인합니다.
3. 임시 파일을 운영 경로로 같은 filesystem 안에서 교체합니다. 3. 임시 파일을 운영 경로로 같은 filesystem 안에서 교체합니다.
4. `GET /gateway/api/navigation`이 성공하는지 확인합니다. 4. `GET /gateway/api/navigation`이 성공하`Cache-Control`, `ETag`가 있는지
5. Gateway desktop/mobile과 실제 profile 화면을 새로고침해 순서, 링크, 확인합니다. 같은 `ETag``If-None-Match`로 보내 `304`도 확인합니다.
5. Gateway desktop/mobile과 실제 profile 화면을 강력 새로고침해 순서, 링크,
dropdown과 hover/focus를 확인합니다. dropdown과 hover/focus를 확인합니다.
API 검증이 실패하면 직전 복사본을 원래 경로로 되돌립니다. 저장소 기본값으로 API 검증이 실패하면 직전 복사본을 원래 경로로 되돌립니다. 저장소 기본값으로