From 8f6ea16f6595107636a5c4366e569cbcee9ce227 Mon Sep 17 00:00:00 2001 From: Mina Choi Date: Mon, 7 Sep 2026 11:27:52 +0900 Subject: [PATCH] =?UTF-8?q?[fix]=20solution/site:=20=EC=98=9B=20=ED=95=B4?= =?UTF-8?q?=EC=8B=9C=20=EC=9E=90=EC=82=B0=EC=9D=84=2030=EC=9D=BC=20?= =?UTF-8?q?=EB=82=A8=EA=B8=B4=EB=8B=A4=20=E2=80=94=20=EB=B0=B0=ED=8F=AC?= =?UTF-8?q?=EC=99=80=20=EC=A0=84=EC=B2=B4=20=EC=9E=AC=EA=B5=BD=EA=B8=B0?= =?UTF-8?q?=EB=A5=BC=20=EB=97=80=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) Claude-Session: https://claude.ai/code/session_018xTWrJ6Mrr6HhEN6hZEER4 --- AGENTS.md | 9 ++- docs/DEPLOY.md | 20 ++++-- docs/DEVLOG.md | 35 ++++++++++ solution/site/scripts/prerender.ts | 103 ++++++++++++++++++++++++++++- 4 files changed, 156 insertions(+), 11 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 5652113..784c678 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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/` 만 올린다 — **렌더러를 고쳐도 다른 사이트에는 반영되지 않는다.** diff --git a/docs/DEPLOY.md b/docs/DEPLOY.md index 2490f3b..9a27c1c 100644 --- a/docs/DEPLOY.md +++ b/docs/DEPLOY.md @@ -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` 를 배포하면 반드시 전체 재굽기 + 전체 재업로드.** diff --git a/docs/DEVLOG.md b/docs/DEVLOG.md index f56c53f..0d33e6c 100644 --- a/docs/DEVLOG.md +++ b/docs/DEVLOG.md @@ -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 에서 뗐다 **왜** diff --git a/solution/site/scripts/prerender.ts b/solution/site/scripts/prerender.ts index b6395e9..22941c6 100644 --- a/solution/site/scripts/prerender.ts +++ b/solution/site/scripts/prerender.ts @@ -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)) {