[fix] solution/site: 옛 해시 자산을 30일 남긴다 — 배포와 전체 재굽기를 뗀다

writeSharedAssets 가 빌드마다 out/assets 를 통째로 지우고 다시 깔았다. HTML 은 자산 경로를
파일명 해시까지 박아 굽기 때문에, 렌더러를 배포하는 순간 아직 다시 굽지 않은 사이트는
전부 CSS·JS 404 였다. 그 구멍을 "기동 시 전체 재굽기"와 "배포하면 반드시 전체 재업로드"라는
규칙으로 막고 있었다 — 규칙으로 막는다는 건 구조가 못 막는다는 뜻이다.

진짜 위험은 방문자가 아니라 크롤러다. 구글은 HTML 을 가져간 뒤 렌더를 나중에 돌린다.
그 사이 자산이 사라지면 스타일도 스크립트도 없는 페이지를 렌더한 것으로 기록한다.
유예 창이 필요한 건 통념이고(Vercel 은 검색봇에 한해 스큐 보호 창을 60일로 늘린다),
우리 창은 0초였다. 한 벌이 400KB 안팎이라 한 달치를 남겨도 10MB 남짓이다.

- scripts/prerender.ts: assets/ 통째 삭제 제거. 권한 때문에 지웠던 것인데
  copyDirectoryFiles 가 파일마다 먼저 rmSync 하므로 그 문제는 그대로 해결된다
- ASSET_RETENTION_DAYS(30) · ASSET_MIN_BUILDS(2) — 기간이 지나도 직전 빌드는 남는다
- out/assets/.builds.json 대장 — mtime 으로 나이를 재지 않는다(복사·동기화가 시각을 갈아
  버리면 옛 파일이 영원히 젊어지거나 산 파일이 지워진다). 발행마다 이 함수가 도므로
  번들이 그대로면 줄을 늘리지 않고 맨 앞 줄의 시각만 갱신한다
- AGENTS.md 함정 항목 · docs/DEPLOY.md 2절 · docs/DEVLOG.md

남은 것: azure_static._upload_shared 가 매 발행마다 assets/ 전체를 올린다 — 보관 기간만큼
업로드량이 는다. Azure 는 지금 꺼져 있으므로 켤 때 기존 블롭 건너뛰기를 먼저 붙인다.

검증: 세 번 구워 확인 — 번들 해시가 바뀌어도 옛 파일 3개가 남고, 같은 번들로 다시 구우면
대장이 안 늘며(2줄 유지), 대장 마지막 줄을 60일 전으로 돌리자 그 빌드 파일 3개만 정리됐다.
tsc·eslint 통과, vitest 22 passed

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018xTWrJ6Mrr6HhEN6hZEER4
This commit is contained in:
Mina Choi 2026-09-07 11:27:52 +09:00
parent 09b0538c9c
commit 8f6ea16f65
4 changed files with 156 additions and 11 deletions

View File

@ -28,9 +28,12 @@
- **번들 파일명은 콘텐츠 해시다.** HTML 은 `/assets/index-DvNTmLhy.css` 를 **루트 절대경로**로
가리킨다. 경로는 프리렌더가 `dist/client/.vite/manifest.json` 에서 읽어 박는다
(`prerender.ts:160`). 렌더러 CSS 를 고치면 이름이 바뀐다.
- **로컬 `out/assets` 는 빌드마다 통째로 갈린다** (`prerender.ts:573` `rmSync`). 옛 해시 파일이
사라지므로 새 번들로 일부 사이트만 구우면 나머지는 CSS 가 404 다.
→ 프리렌더 기동 시 전체 재굽기가 이 구멍을 메운다.
- **옛 해시 자산은 30일 남는다** (`prerender.ts` `ASSET_RETENTION_DAYS`). 예전에는 빌드마다
`out/assets` 를 통째로 갈아서, 새 번들로 일부만 구우면 나머지 사이트가 CSS 404 였다.
지금은 남긴다 — 구글은 HTML 을 가져간 뒤 렌더를 **나중에** 돌리므로, 그 사이 자산이 사라지면
스타일 없는 페이지를 렌더한 것으로 기록된다. 보관 근거는 `out/assets/.builds.json` 대장이고
파일 mtime 이 아니다.
→ 재굽기는 여전히 필요하지만 **급하지 않다**. 디자인이 반영 안 될 뿐, 깨지지는 않는다.
- **★ 프론트(`solution/site`)를 배포하면 반드시 전체 재굽기 + 전체 재업로드.**
`azure_static.publish(slug)` 는 공용 자산 + `s/<slug>` 만 올린다 —
**렌더러를 고쳐도 다른 사이트에는 반영되지 않는다.**

View File

@ -66,13 +66,23 @@ site-out/
```
프론트 수정 → 새 번들(새 해시)
로컬 out/assets : 통째로 교체 (옛 해시 삭제) ← 재굽기 안 한 사이트는 CSS 404
Azure : 새 해시 추가, 옛 해시 유지 ← 안 깨지지만 옛 디자인 그대로 박제
로컬 out/assets : 새 해시 추가, 옛 해시 30일 보관 ← 안 깨진다. 옛 디자인으로 뜰 뿐
Azure : 새 해시 추가, 옛 해시 유지 ← 같다
```
프리렌더 컨테이너는 **기동할 때 payload 전체를 다시 굽는다.** 그래서 로컬 out/ 은 재시작만
하면 정합이 맞는다. 하지만 Azure 는 발행 잡이 도는 사이트 하나씩만 올린다 — 그 짝을 맞추는 게
`solution/backend/scripts/republish_all.py` 다.
**2026-09-07 이전에는 로컬 `out/assets` 를 통째로 갈았다.** 그래서 재굽기 전까지 나머지 사이트가
CSS 404 였다 — 하필 크롤러가 그 순간 렌더하면 스타일 없는 페이지를 본 것으로 기록된다.
지금은 `ASSET_RETENTION_DAYS`(30일) 동안 옛 해시를 남긴다. 보관 근거는 `out/assets/.builds.json`
대장이다(파일 mtime 이 아니다 — 복사·동기화가 시각을 갈아 버린다).
**그래서 재굽기는 여전히 필요하지만 급하지는 않다.** 안 하면 그 사이트만 옛 디자인으로 뜬다.
프리렌더 컨테이너는 **기동할 때 payload 전체를 다시 굽는다.** 하지만 Azure 는 발행 잡이 도는
사이트 하나씩만 올린다 — 그 짝을 맞추는 게 `solution/backend/scripts/republish_all.py` 다.
⚠️ 남은 것: `azure_static._upload_shared` 는 매 발행마다 `assets/` **전체**를 다시 올린다.
옛 해시를 남기기 시작했으므로 보관 기간만큼 업로드량이 는다. Azure 를 켤 때는 이미 있는
블롭(해시 파일이라 이름이 같으면 내용도 같다)을 건너뛰도록 먼저 고친다.
**규칙: `solution/site` 를 배포하면 반드시 전체 재굽기 + 전체 재업로드.**

View File

@ -5,6 +5,41 @@
---
## 2026-09-07 — 옛 해시 자산을 30일 남긴다 — 배포와 재굽기를 뗀다
**왜**
`writeSharedAssets` 가 빌드마다 `out/assets` 를 통째로 지우고 다시 깔았다. HTML 은 자산 경로를
파일명 해시까지 박아 굽기 때문에, 렌더러를 배포하는 순간 **아직 다시 굽지 않은 사이트는 전부
CSS·JS 404** 였다. 구멍을 "기동 시 전체 재굽기" 와 "배포하면 반드시 전체 재업로드" 라는 **규칙**
으로 막고 있었다 — 규칙으로 막는다는 건 구조가 못 막는다는 뜻이다.
진짜 위험은 방문자가 아니라 크롤러다. 구글은 HTML 을 가져간 뒤 렌더를 **나중에** 돌린다.
그 사이에 자산이 사라지면 스타일도 스크립트도 없는 페이지를 렌더한 것으로 기록한다 —
하필 지금이 신규 도메인이 평가받는 시기다. 유예 창이 필요하다는 건 업계 통념이고
(Vercel 은 검색봇에 한해 스큐 보호 창을 60일로 늘린다), 우리 창은 0초였다.
**바꾼 것** (`scripts/prerender.ts`)
- `assets/` 를 통째로 지우지 않는다. 권한 때문에 지웠던 것인데 `copyDirectoryFiles` 가
**파일마다** 먼저 `rmSync` 하므로 그 문제는 그대로 해결된다
- `ASSET_RETENTION_DAYS`(30일) · `ASSET_MIN_BUILDS`(2) — 기간이 지나도 직전 빌드는 남는다
- `out/assets/.builds.json` 대장: 어떤 빌드가 어떤 파일을 깔았는지. **mtime 으로 나이를 재지
않는다** — 복사·동기화가 시각을 갈아 버리면 옛 파일이 영원히 젊어지거나 산 파일이 지워진다.
발행마다 이 함수가 도므로, 번들이 그대로면 줄을 늘리지 않고 맨 앞 줄의 시각만 갱신한다
- 점(.)으로 시작해 `azure_static` 의 dotfile 필터에 걸러진다 — 대장은 업로드되지 않는다
**얻은 것** — 프론트 배포와 전체 재굽기가 **분리된다.** 재굽기를 안 하면 그 사이트만 옛
디자인으로 뜬다(예전엔 깨졌다). AGENTS.md 의 ★규칙은 남지만 이유가 "안 하면 죽는다" 에서
"안 하면 반영이 안 된다" 로 내려온다.
**남은 것** — `azure_static._upload_shared` 가 매 발행마다 `assets/` 전체를 올린다. 보관 기간만큼
업로드량이 는다. Azure 는 지금 꺼져 있으므로(DEPLOY.md) 켤 때 이미 있는 블롭을 건너뛰도록 고친다.
**검증** — 실제로 세 번 구워 확인: 번들 해시가 바뀌어도 옛 파일 3개가 그대로 남고, 같은 번들로
다시 구우면 대장이 늘지 않으며(2줄 유지), 대장의 마지막 줄을 60일 전으로 돌리자 그 빌드의
파일 3개만 정리됐다. `tsc·eslint` 통과, `vitest` 22 passed.
---
## 2026-09-07 — 사이트맵 lastmod 를 파일 mtime 에서 뗐다
**왜**

View File

@ -479,18 +479,115 @@ function writeReport(payloadFile: string, report: RenderReport) {
renameSync(tmp, target);
}
/**
* 옛 자산 보관 기간.
*
* ★ 왜 지우지 않고 남기나 — HTML 은 자산 경로를 **파일명 해시까지 박아** 굽는다
* (`/assets/index-DvNTmLhy.css`). 렌더러를 배포하면 이름이 바뀌는데, 그 순간 옛 파일을
* 지우면 아직 다시 굽지 않은 사이트는 CSS·JS 가 **404** 다. 예전 구현이 그랬다.
*
* ★ 더 나쁜 건 크롤러다. 구글은 HTML 을 가져간 뒤 렌더를 **나중에** 돌린다. 그 사이에
* 자산이 사라지면 스타일도 스크립트도 없는 페이지를 렌더한 것으로 기록한다 — 하필
* 신규 도메인이 평가받는 시기에 그렇게 된다. 유예 창이 필요하다는 게 업계 통념이고
* (Vercel 은 검색봇에 한해 스큐 보호 창을 60일로 늘린다), 우리 창은 0초였다.
*
* 한 벌이 400KB 안팎이라 한 달치를 남겨도 10MB 남짓이다 — 싸게 사는 안전이다.
*/
const ASSET_RETENTION_DAYS = 30;
/** 하루에 여러 번 배포해도 직전 빌드는 반드시 남는다(보관 기간과 무관). */
const ASSET_MIN_BUILDS = 2;
/**
* 어떤 빌드가 어떤 파일을 깔았는지. **보관 기간의 근거는 이 파일이다.**
*
* ★ 파일 mtime 으로 나이를 재지 않는다 — 복사·동기화가 시각을 갈아 버리면 옛 파일이
* 영원히 젊어지거나 산 파일이 지워진다. 점(.)으로 시작해 업로드에서 빠진다
* (azure_static 이 dotfile 을 거른다).
*/
const ASSET_LEDGER = '.builds.json';
/** 자산 대장 한 줄 — 한 번의 번들 빌드가 깐 파일 목록. */
interface AssetBuild {
/** 이 번들을 마지막으로 깐 시각(ISO). 같은 번들로 다시 구우면 갱신된다. */
at: string;
/** assets/ 기준 상대 경로. */
files: string[];
}
/** 디렉토리 안의 파일을 상대 경로로 편다(하위 디렉토리 포함). */
function listRelativeFiles(dir: string, prefix = ''): string[] {
const found: string[] = [];
for (const entry of readdirSync(dir, {withFileTypes: true})) {
const rel = prefix ? `${prefix}/${entry.name}` : entry.name;
if (entry.isDirectory()) found.push(...listRelativeFiles(join(dir, entry.name), rel));
else if (entry.isFile()) found.push(rel);
}
return found;
}
function readAssetLedger(assetsDir: string): AssetBuild[] {
try {
const parsed = JSON.parse(readFileSync(join(assetsDir, ASSET_LEDGER), 'utf-8')) as {
builds?: AssetBuild[];
};
return Array.isArray(parsed.builds) ? parsed.builds : [];
} catch {
// 없거나 깨졌으면 이번 빌드부터 다시 센다. 옛 파일은 그대로 남는다 —
// 대장을 못 읽었다고 지우는 쪽으로 실패하면 사이트가 깨진다.
return [];
}
}
/**
* 보관 기간이 지난 옛 해시 파일만 지운다.
*
* ★ 발행할 때마다 이 함수가 돈다(사이트 하나만 구울 때도). 번들이 그대로면 대장에 줄이
* 늘지 않고 맨 앞 줄의 시각만 갱신된다 — 안 그러면 발행 횟수만큼 대장이 자란다.
*/
function pruneAssets(assetsDir: string, current: string[]) {
const signature = (files: string[]) => [...files].sort().join('\n');
const previous = readAssetLedger(assetsDir);
const head: AssetBuild = {at: new Date().toISOString(), files: current};
const builds =
previous[0] && signature(previous[0].files) === signature(current)
? [head, ...previous.slice(1)]
: [head, ...previous];
const cutoff = Date.now() - ASSET_RETENTION_DAYS * 24 * 60 * 60 * 1000;
const kept = builds.filter(
(build, index) => index < ASSET_MIN_BUILDS || Date.parse(build.at) >= cutoff,
);
const alive = new Set(kept.flatMap((build) => build.files));
let removed = 0;
for (const file of listRelativeFiles(assetsDir)) {
if (file === ASSET_LEDGER || alive.has(file)) continue;
rmSync(join(assetsDir, file), {force: true});
removed += 1;
}
writeFileSync(
join(assetsDir, ASSET_LEDGER),
JSON.stringify({schemaVersion: 1, builds: kept}, null, 2),
'utf-8',
);
if (removed > 0) {
console.log(` ✓ 보관 기간(${ASSET_RETENTION_DAYS}일)이 지난 자산 ${removed}개 정리`);
}
}
/**
* 클라이언트 번들과 public/ 을 대상 디렉토리에 깐다.
*
* Docker bind mount 에서 이전 빌드가 남긴 파일 권한이 호스트와 어긋날 수 있어
* assets/ 는 통째로 교체한다(낡은 해시 파일과 권한을 함께 제거).
* ★ assets/ 를 통째로 지우고 다시 깔지 않는다(예전 구현). 옛 해시 파일은 보관 기간까지
* 남겨야 한다 — 근거는 ASSET_RETENTION_DAYS 주석. 권한 때문에 통째로 지웠던 것인데,
* copyDirectoryFiles 가 **파일마다** 먼저 rmSync 하므로 그 문제는 그대로 해결된다.
*/
function writeSharedAssets(destRoot: string) {
const assetsSrc = join(CLIENT_DIR, 'assets');
if (existsSync(assetsSrc)) {
const assetsDest = join(destRoot, 'assets');
rmSync(assetsDest, {recursive: true, force: true});
copyDirectoryFiles(assetsSrc, assetsDest);
pruneAssets(assetsDest, listRelativeFiles(assetsSrc));
}
const publicDir = join(SITE_ROOT, 'public');
if (existsSync(publicDir)) {