/** * payload 감시 → 자동 프리렌더. * * ★ 왜 필요한가 * 발행 잡은 payload JSON 까지만 만든다(backend/services/site_payload). 그 뒤 HTML 로 굽는 * 단계가 수동이라, 사장님이 [발행]을 눌러도 사이트가 없었다 — [사이트 열기] 가 404 였다. * 검색 → 크롤링 → 발행 → **사이트 이동** 이 끊기는 유일한 자리가 여기다. * * ★ 왜 백엔드 워커가 직접 안 굽나 * 굽는 데 Node 와 이 프로젝트의 의존성이 필요하다. 파이썬 컨테이너에 Node 를 넣으면 * 백엔드 이미지가 프론트 빌드 도구를 떠안는다. 대신 payload 디렉토리를 사이에 두고 * 따로 도는 프로세스가 읽는다 — 백엔드는 파일만 쓰고, 여기는 파일만 본다. * * ★ 바뀐 사이트만 굽는다 * 예전에는 payload 디렉토리를 통째로 넘겨서, 한 명이 발행하면 발행된 사이트 전부를 * 다시 구웠다(게다가 매번 vite 클라이언트 번들까지 새로 만들었다). 사이트가 늘면 * 그대로 못 쓴다. 지금은 mtime 이 바뀐 payload 만 골라 넘기고, 번들은 기동 때 한 번만 만든다. * * ★ 실패를 삼키지 않는다 * 프리렌더가 깨지면 DB 에는 "발행됨"인데 페이지는 없는 상태가 된다. 실패한 payload 는 * 백오프를 두고 다시 시도하고, 소진되면 경고로 남긴다. 결과 보고서는 프리렌더가 * payloads/.status/.json 에 쓴다(백엔드가 그걸 읽는다). * * 실행: npm run watch (개발) * node scripts/watch-payloads.mjs --once (한 번만) */ import {spawn} from 'node:child_process'; import {existsSync, mkdirSync, readFileSync, readdirSync, renameSync, statSync, writeFileSync} from 'node:fs'; import {dirname, join, resolve} from 'node:path'; import {fileURLToPath} from 'node:url'; const HERE = dirname(fileURLToPath(import.meta.url)); const ROOT = resolve(HERE, '..'); const PAYLOAD_DIR = join(ROOT, 'payloads'); const PRERENDER_JS = join(ROOT, 'dist', 'prerender', 'prerender.js'); const SITES_DIR = join(ROOT, 'out', 's'); const ONCE = process.argv.includes('--once'); /** 폴링 간격. 발행은 분 단위 작업이라 2초 지연은 문제가 되지 않는다. */ const POLL_MS = 2000; /** 실패한 payload 재시도 횟수. 소진되면 경고만 남기고 다음 변경을 기다린다. */ const MAX_ATTEMPTS = 3; /** 재시도 백오프(회차별 ms). 디스크 순단·부분 기록 같은 일시 실패를 흡수한다. */ const BACKOFF_MS = [5000, 20000]; function log(msg) { console.log(`[watch ${new Date().toTimeString().slice(0, 8)}] ${msg}`); } function warn(msg) { console.error(`[watch ${new Date().toTimeString().slice(0, 8)}] ${msg}`); } /** * 자식 프로세스 하나를 돌리고 `{code, stderr}` 를 돌려준다. * * ★ stderr 를 흘려보내면서 동시에 모은다. 화면에는 지금까지처럼 그대로 나가야 하고 * (개발자가 보는 것), 실패 보고서에는 사유가 실려야 한다(백엔드가 읽는 것). */ function run(command, args, label) { return new Promise((done) => { log(`${label} 시작`); const child = spawn(command, args, {cwd: ROOT, stdio: ['inherit', 'inherit', 'pipe'], shell: false}); let stderr = ''; child.stderr.on('data', (chunk) => { process.stderr.write(chunk); // 사유는 앞부분에 나온다. 스택 전체를 들고 있을 이유가 없다. if (stderr.length < 4000) stderr += chunk.toString(); }); child.on('close', (code) => { if (code === 0) log(`${label} 완료`); else warn(`${label} 실패(exit ${code})`); done({code: code ?? 1, stderr}); }); child.on('error', (ex) => { warn(`${label} 실행 불가: ${ex.message}`); done({code: 1, stderr: ex.message}); }); }); } /** * 프리렌더가 **자기 보고서를 쓰지도 못하고 죽었을 때** 대신 실패를 남긴다. * * ★ 왜 필요한가 * 프리렌더는 사이트별 실패를 스스로 `.status/.json` 에 적는다. 그런데 프로세스가 * 사이트를 하나도 돌기 전에 죽으면(의존성 누락·번들 오류) 보고서가 아예 안 생긴다. * 그러면 백엔드 BUILD 잡은 180초를 꼬박 기다린 뒤 "결과를 못 받았다"로만 실패한다 — * 진짜 사유(예: Cannot find package 'embla-carousel-react')는 컨테이너 로그에만 남고 * 사장님 화면에도, 발행 기록에도 안 나타난다. 실제로 그 상태로 47분간 모든 사이트의 * 재발행이 조용히 죽어 있었다. * * ★ 재시도가 소진된 뒤에만 쓴다. 첫 실패에 바로 쓰면 백엔드가 그 보고서를 집어가서 * 재시도가 성공해도 이미 늦는다 — 백오프(5s+20s)는 180초 안에 끝나므로 여유가 있다. * * ★ siteVersion 을 payload 에서 읽어 싣는다. 백엔드는 **버전이 맞는 보고서만** 받으므로 * (backend/services/render_report.wait_for) 버전이 없으면 이 보고서는 무시된다. */ function writeFailureReport(file, error) { let payload; try { payload = JSON.parse(readFileSync(file, 'utf-8')); } catch (ex) { warn(`실패 보고서를 쓰지 못했다(payload 를 읽을 수 없다) ${basenameOf(file)}: ${ex.message}`); return; } const site = payload?.site ?? {}; const slug = site.slug || basenameOf(file).replace(/\.json$/, ''); const report = { schemaVersion: 1, slug, siteId: site.siteId ?? '', placeId: site.placeId ?? '', siteVersion: site.version ?? 0, ok: false, renderedAt: new Date().toISOString(), error: (error || '프리렌더가 보고서를 남기지 못하고 종료했다').slice(0, 1000), }; try { const dir = join(PAYLOAD_DIR, '.status'); mkdirSync(dir, {recursive: true}); const tmp = join(dir, `.${slug}.json.tmp`); writeFileSync(tmp, JSON.stringify(report, null, 2), 'utf-8'); renameSync(tmp, join(dir, `${slug}.json`)); warn(`실패 보고서 기록 — .status/${slug}.json (백엔드가 이 사유로 발행을 중단한다)`); } catch (ex) { warn(`실패 보고서를 쓰지 못했다 ${slug}: ${ex.message}`); } } /** * 클라이언트 번들 + 프리렌더 번들을 만든다. **기동 때 한 번만** 부른다. * * ★ payload 가 바뀌었다고 번들을 다시 만들 이유가 없다. 데이터만 바뀌었고 코드는 그대로다. * 코드가 바뀌면 컨테이너가 다시 뜨고, 그때 여기를 지난다. */ /** * 클라이언트 번들 → 프리렌더 번들. 앞이 실패하면 뒤는 돌리지 않는다. * * ★ run() 은 `{code, stderr}` 를 돌려준다. 예전에는 이 값을 숫자로 알고 `code === 0` 으로 * 비교했는데, 객체는 0 과 절대 같지 않아서 **프리렌더 번들이 한 번도 실행되지 않았다.** * 기동 때마다 "번들 빌드 실패" 로 끝났고, 그래서 DEPLOY.md 가 약속한 '기동 시 전체 * 재굽기'가 실제로는 일어나지 않았다. */ async function buildBundles() { const client = await run('npm', ['run', 'build:client'], '클라이언트 번들'); if (client.code !== 0) return client; return run('npm', ['run', 'build:prerender'], '프리렌더 번들'); } /** payload 파일들을 굽는다. 빈 배열이면 아무것도 하지 않는다. * ★ 호출부가 `{code, stderr}` 를 구조분해하므로 빈 경우에도 같은 모양을 돌려준다 — * 숫자 0 을 돌려주면 code 가 undefined 가 되어 성공이 실패로 읽힌다. */ function prerender(files, reason) { if (files.length === 0) return Promise.resolve({code: 0, stderr: ''}); const label = `프리렌더 ${files.length}개 — ${reason}`; return run('node', [PRERENDER_JS, ...files.map((file) => `--payload=${file}`)], label); } /** * 기동 때 공용 자산(out/assets · out/fonts · public/)만 채워 둔다. **굽지 않는다.** * * ★ 발행 버전 시스템(2026-09-15)으로 바뀌면서 "이미 구워진 HTML 의 자산 주소만 갈아 끼우는" * 예전 방식(refreshBakedAssets)은 없어졌다 — 버전마다 자기 디렉토리에 굽고 공개 심볼릭 * 링크(`out/s/`)는 발행할 때만 돈다(prerender.ts publishVersion). 그래서 기동 시 * 할 일은 아직 하나도 안 구워진 payload 를 굽는 것과, 그 전에 공용 자산을 한 번 깔아 * 두는 것뿐이다 — HTML 은 하나도 건드리지 않는다. */ function seedAssets() { return run('node', [PRERENDER_JS, '--seed-assets'], '공용 자산 시딩 — 기동'); } // ── 큐 ──────────────────────────────────────────────────────────────────── /** 굽기를 기다리는 payload 경로. 굽는 동안 들어온 변경은 여기에 쌓였다가 이어서 돈다. */ const pending = new Set(); /** payload 경로 → 지금까지 실패한 횟수. 성공하면 지운다. */ const attempts = new Map(); let running = false; function enqueue(file) { pending.add(file); void drain(); } async function drain() { if (running || pending.size === 0) return; running = true; try { while (pending.size > 0) { const batch = [...pending]; pending.clear(); const {code, stderr} = await prerender(batch, batch.map((f) => basenameOf(f)).join(', ')); if (code === 0) { batch.forEach((file) => attempts.delete(file)); continue; } // ★ 배치가 실패하면 어느 사이트가 깨졌는지는 보고서(.status/.json)에 남는다. // 여기서는 배치 전체를 재시도한다 — 프리렌더는 사이트별로 실패를 격리하므로 // 이미 성공한 사이트를 다시 구워도 결과는 같다(멱등). for (const file of batch) { const tried = (attempts.get(file) ?? 0) + 1; attempts.set(file, tried); if (tried >= MAX_ATTEMPTS) { warn(`${basenameOf(file)} — ${tried}회 실패, 재시도를 멈춥니다.`); // 프리렌더가 자기 보고서를 못 남기고 죽었을 수 있다 — 그러면 백엔드가 180초를 // 헛기다린 뒤 사유 없이 실패한다. 여기서 사유를 실어 남긴다(writeFailureReport 주석 참조). writeFailureReport(file, stderr.trim()); continue; } const delay = BACKOFF_MS[Math.min(tried - 1, BACKOFF_MS.length - 1)]; warn(`${basenameOf(file)} — 실패(${tried}/${MAX_ATTEMPTS}), ${delay / 1000}초 후 재시도`); setTimeout(() => enqueue(file), delay); } } } finally { running = false; } } function basenameOf(file) { return file.slice(file.lastIndexOf('/') + 1); } /** 그 payload 가 이미 구워져 있나. 파일명이 슬러그다(백엔드가 `.json` 으로 쓴다). */ function bakedIndexOf(file) { return join(SITES_DIR, basenameOf(file).replace(/\.json$/, ''), 'index.html'); } /** payload 디렉토리의 *.json 목록. 백엔드가 rename 전에 쓰는 임시파일(.tmp)은 건너뛴다. */ function listPayloads() { if (!existsSync(PAYLOAD_DIR)) return []; return readdirSync(PAYLOAD_DIR) .filter((name) => name.endsWith('.json') && !name.startsWith('.')) .map((name) => join(PAYLOAD_DIR, name)); } // ── 기동 ────────────────────────────────────────────────────────────────── async function main() { mkdirSync(PAYLOAD_DIR, {recursive: true}); const {code} = await buildBundles(); if (code !== 0) { warn('번들 빌드 실패 — 프리렌더를 할 수 없습니다. 컨테이너를 다시 띄우세요.'); process.exitCode = 1; return; } // ★ 기동 시 전부 굽지 않는다(seedAssets 주석). 공용 자산만 깔아 두고, 내용은 // 사장님이 다시 발행할 때(또는 아래 "아직 안 구워진 것") 새 렌더러로 구워진다. const all = listPayloads(); const seen = new Map(all.map((file) => [file, statSync(file).mtimeMs])); await seedAssets(); // 아직 한 번도 안 구워진 payload 는 굽는다 — 감시가 꺼져 있는 동안 발행됐거나 볼륨이 // 비어 있던 경우다. 사이트가 아예 없는 것과 "옛 내용으로 서 있는 것" 은 다른 문제다. const unbuilt = all.filter((file) => !existsSync(bakedIndexOf(file))); await prerender(unbuilt, '아직 안 구워진 것'); if (ONCE) return; log(`감시 중: ${PAYLOAD_DIR} (바뀐 payload 만 굽습니다)`); /** * ★ fs.watch 를 쓰지 않는다. Docker 볼륨(bind mount)을 통해 들어온 변경은 macOS 에서 * inotify/FSEvents 이벤트가 오지 않는 경우가 있다 — 발행해도 아무 일이 안 일어난다. * 폴링은 느리지만 확실하다. */ setInterval(() => { for (const file of listPayloads()) { let mtime; try { mtime = statSync(file).mtimeMs; } catch { continue; // 폴링과 rename 이 겹친 순간. 다음 틱에 다시 본다. } if (seen.get(file) === mtime) continue; seen.set(file, mtime); log(`${basenameOf(file)} 변경 감지`); enqueue(file); } }, POLL_MS); } void main();